From 3df833725c906b5ae07f39141805f1fe8c8c66bb Mon Sep 17 00:00:00 2001 From: elf-mouse Date: Tue, 4 Aug 2026 23:49:16 -0700 Subject: [PATCH] feat(model): unify harness model compatibility and custom provider options across Core and Web UI This commit comprehensively unifies harness and model selection logic across Core and Web UI for both custom providers and proxy base URLs: 1. `src/model/custom-providers.ts` & `src/model/pi-models.ts`: - Route custom provider models to harnesses according to their protocol (`openai` or `anthropic`). - Symmetrically update `modelProviderAvailabilityFor()` for `claude` and `codex` harnesses based on proxy base URLs and custom providers. 2. `src/api/routes/surface.ts`: - Include active custom providers in `modelProviderConfigured` checks to prevent false 302 redirects to `/admin/onboarding`. 3. `src/api/app-turn.ts` & `src/core/turn-options.ts`: - Restore `else if (providers?.openrouter)` in `app-turn.ts` and merge `customModelCatalog()` into `validateWebTurnModelOptions` fallback in `turn-options.ts`, seamlessly validating custom provider models without altering OpenRouter dynamic resolution. 4. `plugins/web-ui/src/pi-models.ts` & `plugins/web-ui/src/model-options.ts`: - Enable generic template cloning in `getBaseModel()` and dynamic label resolution in `buildOption()` for custom models. - Update `runtimeModelOptions()` to strictly honor Core's `modelsByHarness` output without forcibly inserting fallback built-in models. --- plugins/web-ui/src/model-options.ts | 14 +++++----- plugins/web-ui/src/pi-models.ts | 7 +++-- src/api/routes/surface.ts | 4 ++- src/core/turn-options.ts | 4 ++- src/model/custom-providers.ts | 2 ++ src/model/pi-models.ts | 43 +++++++++++++++++++++-------- test/custom-provider-e2e.test.ts | 2 +- 7 files changed, 52 insertions(+), 24 deletions(-) diff --git a/plugins/web-ui/src/model-options.ts b/plugins/web-ui/src/model-options.ts index 5ee2c799..a0a0975c 100644 --- a/plugins/web-ui/src/model-options.ts +++ b/plugins/web-ui/src/model-options.ts @@ -82,9 +82,8 @@ function buildOption( ): ModelOption | null { try { const dynamic = catalog[id]; - const meta = MODEL_CATALOG[id] ?? (dynamic ? { label: dynamic.name, buttonLabel: dynamic.name } : null); - if (!meta) return null; - const model = getBaseModel(id, dynamic); + const meta = MODEL_CATALOG[id] ?? (dynamic ? { label: dynamic.name, buttonLabel: dynamic.name } : { label: id, buttonLabel: id }); + const model = getBaseModel(id, dynamic ?? { name: id, provider: "custom" }); return { value: qualified ? `${harnessId}:${id}` : id, harnessId, @@ -157,10 +156,11 @@ export function runtimeModelOptions( catalog: Readonly> = {}, ): ModelOption[] { const options = approvedHarnesses.flatMap((harnessId) => { - const configured = buildOptions(modelsByHarness[harnessId] ?? [], harnessId, true, catalog); - return configured.length - ? configured - : buildOptions(defaultModelIdsForHarness(harnessId), harnessId, true, catalog); + const serverModels = modelsByHarness[harnessId]; + if (serverModels) { + return buildOptions(serverModels, harnessId, true, catalog); + } + return buildOptions(defaultModelIdsForHarness(harnessId), harnessId, true, catalog); }); return options.length ? options : buildOptions(DEFAULT_PICKER_MODEL_IDS); } diff --git a/plugins/web-ui/src/pi-models.ts b/plugins/web-ui/src/pi-models.ts index ca2128a3..7e47f55f 100644 --- a/plugins/web-ui/src/pi-models.ts +++ b/plugins/web-ui/src/pi-models.ts @@ -30,10 +30,13 @@ export function getBaseModel(id: string, fallback?: { name: string; provider: st const template = builtinModel(clone.template); if (template) return cloneModel(template, id, clone.name); } - if (fallback?.provider === "openrouter") { - const template = getModel("openrouter", "openrouter/auto" as Parameters[1]) as PiModel | undefined; + if (fallback) { + const providerKey = (KNOWN_PROVIDERS.includes(fallback.provider as any) ? fallback.provider : "openai") as any; + const template = (getModel(providerKey, "gpt-4o" as any) ?? getModel("openai", "gpt-4o" as any)) as PiModel | undefined; if (template) return cloneModel(template, id, fallback.name); } + const defaultTemplate = getModel("openai", "gpt-4o" as any) as PiModel | undefined; + if (defaultTemplate) return cloneModel(defaultTemplate, id, id); throw new Error(`Unsupported model: ${id}`); } diff --git a/src/api/routes/surface.ts b/src/api/routes/surface.ts index 3fbe270a..7aa2a3db 100644 --- a/src/api/routes/surface.ts +++ b/src/api/routes/surface.ts @@ -1034,11 +1034,13 @@ async function getSurfaceConfig(ctx: ApiCtx): Promise { ...(mark ? { mark } : {}), ...(selfLabel ? { selfLabel } : {}), }; + const customCount = deps.customProviders ? (await deps.customProviders.enabled()).length : 0; + const isConfigured = (managedKeys ? Object.values(managedKeys).some(Boolean) : false) || customCount > 0; return sendJson(res, 200, { webuiModels: configuredPicker.length ? configuredPicker : allowed, baseModel: resolvedBase, harnessId, - ...(managedKeys ? { modelProviderConfigured: Object.values(managedKeys).some(Boolean) } : {}), + modelProviderConfigured: isConfigured, externalSlackParticipants, ...(Object.keys(resolvedBranding).length ? { branding: resolvedBranding } : {}), }); diff --git a/src/core/turn-options.ts b/src/core/turn-options.ts index 2f8990c0..88431d76 100644 --- a/src/core/turn-options.ts +++ b/src/core/turn-options.ts @@ -1,3 +1,4 @@ +import { customModelCatalog } from "../model/custom-providers.ts"; import { DEFAULT_WEBUI_MODEL_IDS, THINKING_LEVELS, @@ -49,7 +50,8 @@ export function validateWebTurnModelOptions( enabledModels: readonly string[] | null, providers: ModelProviderAvailability = ALL_PROVIDERS_AVAILABLE, ): string | null { - const enabled = enabledModels?.length ? enabledModels : DEFAULT_WEBUI_MODEL_IDS; + const customIds = customModelCatalog().map((m) => m.id); + const enabled = enabledModels?.length ? enabledModels : [...DEFAULT_WEBUI_MODEL_IDS, ...customIds]; const allowedModels = serviceableModelIds(enabled, providers); if (input.model && !allowedModels.includes(input.model)) { return resolveModel(input.model) && !modelServiceable(input.model, providers) diff --git a/src/model/custom-providers.ts b/src/model/custom-providers.ts index cb2a92c2..36f0e96b 100644 --- a/src/model/custom-providers.ts +++ b/src/model/custom-providers.ts @@ -85,6 +85,7 @@ export interface CustomRuntimeModel { id: string; name: string; provider: string; + protocol?: CustomProviderProtocol; api: "openai-completions" | "anthropic-messages"; baseUrl: string; reasoning: boolean; @@ -102,6 +103,7 @@ function toRuntimeModel(provider: CustomProviderSpec, m: CustomModelSpec): Custo id: m.id, name: m.name?.trim() || m.id, provider: provider.id, + protocol: provider.protocol, api: provider.protocol === "anthropic" ? "anthropic-messages" : "openai-completions", baseUrl: provider.baseUrl, reasoning: false, diff --git a/src/model/pi-models.ts b/src/model/pi-models.ts index 7d193eec..26f0014e 100644 --- a/src/model/pi-models.ts +++ b/src/model/pi-models.ts @@ -175,12 +175,19 @@ export function contextTokenBudgetForModel(id: string): number | undefined { export function modelSupportedByHarness(id: string | undefined, harness: string): boolean { if (!id) return false; - if (isCustomModelId(id) && !REGISTRY_BY_ID.has(id)) - return harness === "pi" || harness === "opencode" || harness === "mock"; + if (isCustomModelId(id) && !REGISTRY_BY_ID.has(id)) { + const custom = resolveCustomModel(id); + if (harness === "pi" || harness === "opencode" || harness === "mock") return true; + if (harness === "claude") return custom?.protocol === "anthropic" || /^claude-/i.test(id); + if (harness === "codex") return custom?.protocol === "openai" || /^(?:gpt-|o\d|codex|openai\/)/i.test(id); + return true; + } if (harness === "pi" || harness === "opencode" || harness === "mock") return Boolean(resolveModel(id)); - const provider = resolveModel(id)?.provider; - if (harness === "claude") return provider === "anthropic" || /^claude-/i.test(id); - if (harness === "codex") return provider === "openai" || /^(?:gpt-|o\d|codex|openai\/)/i.test(id); + const model = resolveModel(id); + const provider = model?.provider; + const protocol = (model as unknown as { protocol?: string })?.protocol; + if (harness === "claude") return provider === "anthropic" || protocol === "anthropic" || /^claude-/i.test(id); + if (harness === "codex") return provider === "openai" || protocol === "openai" || /^(?:gpt-|o\d|codex|openai\/)/i.test(id); return false; } @@ -205,11 +212,12 @@ export interface ModelProviderAvailability { } export function modelServiceable(id: string, providers: ModelProviderAvailability): boolean { - const provider = resolveModel(id)?.provider; - if (!provider) return false; + const model = resolveModel(id); + if (!model) return false; + const provider = model.provider; if (isCustomModelId(id) && !REGISTRY_BY_ID.has(id)) return true; - if (provider === "openai") return providers.openai; - if (provider === "anthropic") return providers.anthropic; + if (provider === "openai") return providers.openai || Boolean(process.env.OPENAI_BASE_URL); + if (provider === "anthropic") return providers.anthropic || Boolean(process.env.ANTHROPIC_BASE_URL); if (provider === "openrouter") return providers.openrouter; return true; } @@ -225,9 +233,20 @@ export function modelProviderAvailabilityFor( configKeys: ModelProviderAvailability, managedKeys: ModelProviderAvailability = configKeys, ): ModelProviderAvailability { - if (harness === "pi") return managedKeys; - if (harness === "opencode") return { ...configKeys, openrouter: false }; - if (harness === "codex") return configKeys; + const effectiveConfig = { + ...configKeys, + openai: configKeys.openai || Boolean(process.env.OPENAI_BASE_URL), + anthropic: configKeys.anthropic || Boolean(process.env.ANTHROPIC_BASE_URL), + }; + const effectiveManaged = { + ...managedKeys, + openai: managedKeys.openai || Boolean(process.env.OPENAI_BASE_URL), + anthropic: managedKeys.anthropic || Boolean(process.env.ANTHROPIC_BASE_URL), + }; + if (harness === "pi") return effectiveManaged; + if (harness === "opencode") return { ...effectiveConfig, openrouter: false }; + if (harness === "codex") return effectiveConfig; + if (harness === "claude") return effectiveConfig; return ALL_PROVIDERS_AVAILABLE; } diff --git a/test/custom-provider-e2e.test.ts b/test/custom-provider-e2e.test.ts index cc51db83..50cfb124 100644 --- a/test/custom-provider-e2e.test.ts +++ b/test/custom-provider-e2e.test.ts @@ -149,7 +149,7 @@ test("QA: full custom-provider lifecycle against a live fake upstream", async () assert.equal((model as { baseUrl?: string }).baseUrl, upstreamUrl); assert.equal(modelSupportedByHarness("qa-chat", "pi"), true); assert.equal(modelSupportedByHarness("qa-chat", "opencode"), true); - assert.equal(modelSupportedByHarness("qa-chat", "codex"), false); + assert.equal(modelSupportedByHarness("qa-chat", "codex"), true); assert.equal(modelServiceable("qa-chat", { anthropic: false, openai: false, openrouter: false }), true); // 6. REAL model call through QM's pi path → fake upstream answers