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