From 82a86f58da8c4dc1a3e1bc2a89c285c4645d9170 Mon Sep 17 00:00:00 2001 From: lrli Date: Sat, 18 Jul 2026 10:02:23 +0800 Subject: [PATCH] fix(models): map legacy provider api_type values to pi's canonical api ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting "Anthropic" as a model provider has never worked: the option stored api_type="anthropic", which flowed verbatim into every model config's `api` field — but pi-ai's API-provider registry only knows "anthropic-messages" ("anthropic" is a provider slug there, not an api), so every turn failed with "No API provider registered for api: anthropic". The bare-API default "openai" had the same latent problem. Fix on both sides: - normalizeProviderApi() in core/model-compat maps the legacy values (anthropic → anthropic-messages, openai → openai-completions) and passes everything else through. Applied at every boundary that builds a pi model config from model_providers.api_type: the four adapter handlers (agent settings + model-binding, HTTP and WS mirrors), chat-gateway, cli-snapshot-api, and model-routing-config. Existing DB rows keep working with no migration. - The TUI Anthropic preset and the provider-create default now emit canonical ids. - Models page: select options store canonical ids; the edit form maps a legacy stored value so it displays (and re-saves) correctly. Deliberately untouched: the default-provider endpoints that return raw rows to sicore, and ai-security-reviewer (raw chat/completions fetch, not pi) — normalizing those could change consumer-side comparisons. Co-Authored-By: Claude Fable 5 --- portal-web/src/pages/Models.test.ts | 14 +++++++++++++ portal-web/src/pages/Models.tsx | 22 +++++++++++++++++--- src/core/model-compat.test.ts | 31 ++++++++++++++++++++++++++++- src/core/model-compat.ts | 20 +++++++++++++++++++ src/core/provider-presets.ts | 8 +++++--- src/portal/adapter.ts | 22 +++++++++++--------- src/portal/chat-gateway.ts | 7 ++++--- src/portal/cli-snapshot-api.ts | 7 ++++--- src/portal/model-routing-config.ts | 7 ++++--- src/portal/siclaw-api.ts | 2 +- 10 files changed, 114 insertions(+), 26 deletions(-) create mode 100644 portal-web/src/pages/Models.test.ts diff --git a/portal-web/src/pages/Models.test.ts b/portal-web/src/pages/Models.test.ts new file mode 100644 index 000000000..099f91dff --- /dev/null +++ b/portal-web/src/pages/Models.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest" +import { normalizeApiType } from "./Models" + +describe("normalizeApiType", () => { + it("maps legacy stored api_type values to pi's canonical api ids", () => { + expect(normalizeApiType("anthropic")).toBe("anthropic-messages") + expect(normalizeApiType("openai")).toBe("openai-completions") + }) + + it("passes canonical values through unchanged", () => { + expect(normalizeApiType("anthropic-messages")).toBe("anthropic-messages") + expect(normalizeApiType("openai-completions")).toBe("openai-completions") + }) +}) diff --git a/portal-web/src/pages/Models.tsx b/portal-web/src/pages/Models.tsx index 002994e0f..7f7a1d750 100644 --- a/portal-web/src/pages/Models.tsx +++ b/portal-web/src/pages/Models.tsx @@ -25,6 +25,19 @@ export interface Provider { models?: ModelEntry[] } +// Legacy api_type values stored before the options became pi's canonical api +// ids ("anthropic" is a pi provider slug, not an api). Mirrors the server-side +// normalizeProviderApi (src/core/model-compat.ts), which keeps old rows working +// at read time; this map only affects what the edit form shows and re-saves. +const LEGACY_API_TYPES: Record = { + anthropic: "anthropic-messages", + openai: "openai-completions", +} + +export function normalizeApiType(apiType: string): string { + return LEGACY_API_TYPES[apiType] ?? apiType +} + export function Models() { const [providers, setProviders] = useState([]) const [loading, setLoading] = useState(true) @@ -82,7 +95,10 @@ export function Models() { name: provider.name, base_url: provider.base_url, api_key: provider.api_key || "", - api_type: provider.api_type, + // Rows created before the api_type options became pi's canonical api ids + // carry legacy values; map them so the select shows (and re-saves) the + // canonical id instead of silently snapping to the first option. + api_type: normalizeApiType(provider.api_type), }) setExpandedId(provider.id) } @@ -188,7 +204,7 @@ export function Models() { @@ -247,7 +263,7 @@ export function Models() { setEditForm({ ...editForm, name: e.target.value })} className="h-7 px-2 text-xs rounded-md border border-border bg-background" /> setEditForm({ ...editForm, base_url: e.target.value })} className="w-full h-7 px-2 text-xs rounded-md border border-border bg-background font-mono" /> diff --git a/src/core/model-compat.test.ts b/src/core/model-compat.test.ts index 1c97491d7..0836f4547 100644 --- a/src/core/model-compat.test.ts +++ b/src/core/model-compat.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildProviderModelDescriptor, defaultProviderModelCompat } from "./model-compat.js"; +import { buildProviderModelDescriptor, defaultProviderModelCompat, normalizeProviderApi } from "./model-compat.js"; describe("defaultProviderModelCompat", () => { it("keeps developer-role messages for the official OpenAI API", () => { @@ -59,3 +59,32 @@ describe("buildProviderModelDescriptor", () => { expect(d.input).toEqual(["text"]); }); }); + +describe("normalizeProviderApi", () => { + it("maps the legacy 'anthropic' api_type to pi's registered api id", () => { + // "anthropic" is a pi provider slug, not an api — feeding it to a model + // config fails the turn with "No API provider registered for api: anthropic". + expect(normalizeProviderApi("anthropic")).toBe("anthropic-messages"); + }); + + it("maps the legacy 'openai' api_type to openai-completions", () => { + expect(normalizeProviderApi("openai")).toBe("openai-completions"); + }); + + it("passes canonical and unknown api ids through unchanged", () => { + expect(normalizeProviderApi("anthropic-messages")).toBe("anthropic-messages"); + expect(normalizeProviderApi("openai-completions")).toBe("openai-completions"); + expect(normalizeProviderApi("openai-responses")).toBe("openai-responses"); + expect(normalizeProviderApi("some-custom-api")).toBe("some-custom-api"); + }); + + it("is case/whitespace tolerant on legacy values", () => { + expect(normalizeProviderApi(" Anthropic ")).toBe("anthropic-messages"); + }); + + it("falls back to openai-completions for empty/null", () => { + expect(normalizeProviderApi("")).toBe("openai-completions"); + expect(normalizeProviderApi(null)).toBe("openai-completions"); + expect(normalizeProviderApi(undefined)).toBe("openai-completions"); + }); +}); diff --git a/src/core/model-compat.ts b/src/core/model-compat.ts index 217b9fc92..66b4e93aa 100644 --- a/src/core/model-compat.ts +++ b/src/core/model-compat.ts @@ -15,6 +15,26 @@ export interface ProviderModelRow { max_tokens: number; } +/** + * Map legacy provider api names to the pi-ai API-provider registry's canonical + * ids. pi looks a model's `api` up in its registry verbatim ("anthropic" is a + * provider slug there, not an api — only "anthropic-messages" is registered), + * so any legacy value reaching a model config fails the whole turn with + * "No API provider registered for api: …". Portal DB rows and settings.json + * written before this mapping existed carry the legacy names; normalizing at + * read time keeps them working without a data migration. + */ +const LEGACY_API_ALIASES: Record = { + anthropic: "anthropic-messages", + openai: "openai-completions", +}; + +export function normalizeProviderApi(api: string | null | undefined): string { + const raw = (api ?? "").trim(); + if (!raw) return "openai-completions"; + return LEGACY_API_ALIASES[raw.toLowerCase()] ?? raw; +} + function isOfficialOpenAIBaseUrl(baseUrl?: string | null): boolean { if (!baseUrl) return false; try { diff --git a/src/core/provider-presets.ts b/src/core/provider-presets.ts index 1f822cd6b..005f58a48 100644 --- a/src/core/provider-presets.ts +++ b/src/core/provider-presets.ts @@ -48,7 +48,9 @@ export const PRESETS: ProviderPreset[] = [ label: "Anthropic (Claude Sonnet 4, Claude Opus 4)", name: "anthropic", baseUrl: "https://api.anthropic.com/v1", - api: "anthropic", + // pi-ai API-provider registry id — "anthropic" alone is a provider slug + // there, not an api, and fails model resolution (see normalizeProviderApi). + api: "anthropic-messages", models: [ { id: "claude-sonnet-4-20250514", @@ -58,7 +60,7 @@ export const PRESETS: ProviderPreset[] = [ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 16000, - compat: defaultProviderModelCompat({ api: "anthropic", baseUrl: "https://api.anthropic.com/v1" }), + compat: defaultProviderModelCompat({ api: "anthropic-messages", baseUrl: "https://api.anthropic.com/v1" }), }, { id: "claude-opus-4-20250514", @@ -68,7 +70,7 @@ export const PRESETS: ProviderPreset[] = [ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 16000, - compat: defaultProviderModelCompat({ api: "anthropic", baseUrl: "https://api.anthropic.com/v1" }), + compat: defaultProviderModelCompat({ api: "anthropic-messages", baseUrl: "https://api.anthropic.com/v1" }), }, ], }, diff --git a/src/portal/adapter.ts b/src/portal/adapter.ts index c92e398bd..27057e532 100644 --- a/src/portal/adapter.ts +++ b/src/portal/adapter.ts @@ -15,7 +15,7 @@ import { parseBody, type RestRouter, } from "../gateway/rest-router.js"; -import { buildProviderModelDescriptor } from "../core/model-compat.js"; +import { buildProviderModelDescriptor, normalizeProviderApi } from "../core/model-compat.js"; import type { TracingConfig } from "../core/config.js"; import { assembleExporterHeaders, type ExporterAuth } from "./tracing-exporters.js"; import { normalizeChatSessionPreview, normalizeChatSessionTitle } from "./chat-session-fields.js"; @@ -1091,14 +1091,15 @@ export function registerAdapterRoutes(router: RestRouter, internalSecret: string provider: agent.model_provider, modelId: agent.model_id, }); + const settingsApi = normalizeProviderApi(p.api_type); sendJson(res, 200, { providers: { [p.name]: { baseUrl: p.base_url, apiKey: p.api_key || "", - api: p.api_type, + api: settingsApi, models: (modelRows as any[]).map((m: any) => - buildProviderModelDescriptor(m, { api: p.api_type, baseUrl: p.base_url }), + buildProviderModelDescriptor(m, { api: settingsApi, baseUrl: p.base_url }), ), }, }, @@ -1690,12 +1691,13 @@ export function registerAdapterRoutes(router: RestRouter, internalSecret: string return; } const p = providerRows[0]; + const bindingApi = normalizeProviderApi(p.api_type); const [entryRows] = await db.query( "SELECT model_id, name, reasoning, vision, context_window, max_tokens FROM model_entries WHERE provider_id = ?", [p.id], ) as any; const models = (entryRows as any[]).map((m: any) => - buildProviderModelDescriptor(m, { api: p.api_type, baseUrl: p.base_url }), + buildProviderModelDescriptor(m, { api: bindingApi, baseUrl: p.base_url }), ); const modelRouting = await resolveAgentModelRouting(agent.model_routing, { provider: agent.model_provider, @@ -1709,7 +1711,7 @@ export function registerAdapterRoutes(router: RestRouter, internalSecret: string name: p.name, baseUrl: p.base_url, apiKey: p.api_key ?? "", - api: p.api_type, + api: bindingApi, authHeader: true, models, }, @@ -2206,14 +2208,15 @@ export function buildAdapterRpcHandlers(): Map - buildProviderModelDescriptor(m, { api: p.api_type, baseUrl: p.base_url }), + buildProviderModelDescriptor(m, { api: settingsApi, baseUrl: p.base_url }), ), }, }, @@ -2251,12 +2254,13 @@ export function buildAdapterRpcHandlers(): Map - buildProviderModelDescriptor(m, { api: p.api_type, baseUrl: p.base_url }), + buildProviderModelDescriptor(m, { api: bindingApi, baseUrl: p.base_url }), ); const modelRouting = await resolveAgentModelRouting(agent.model_routing, { provider: agent.model_provider, @@ -2270,7 +2274,7 @@ export function buildAdapterRpcHandlers(): Map - buildProviderModelDescriptor(m, { api: provider.api_type, baseUrl: provider.base_url }), + buildProviderModelDescriptor(m, { api: bindingApi, baseUrl: provider.base_url }), ); const modelRouting = await resolveAgentModelRouting(agent.model_routing, { @@ -317,7 +318,7 @@ export async function resolveAgentModelBinding(agentId: string): Promise - buildProviderModelDescriptor(m, { api: p.api_type, baseUrl: p.base_url }), + buildProviderModelDescriptor(m, { api: providerApi, baseUrl: p.base_url }), ), }; // First model flagged is_default wins. If none, first provider's first model is a fallback. diff --git a/src/portal/model-routing-config.ts b/src/portal/model-routing-config.ts index bbd2f3a60..173f2863d 100644 --- a/src/portal/model-routing-config.ts +++ b/src/portal/model-routing-config.ts @@ -1,6 +1,6 @@ import { getDb } from "../gateway/db.js"; import { safeParseJson } from "../gateway/dialect-helpers.js"; -import { buildProviderModelDescriptor } from "../core/model-compat.js"; +import { buildProviderModelDescriptor, normalizeProviderApi } from "../core/model-compat.js"; import { normalizeCandidates, normalizeModelRoutePolicy, @@ -100,14 +100,15 @@ async function loadProviderConfigs(providerNames: string[]): Promise - buildProviderModelDescriptor(model, { api: provider.api_type, baseUrl: provider.base_url }), + buildProviderModelDescriptor(model, { api: providerApi, baseUrl: provider.base_url }), ), }); } diff --git a/src/portal/siclaw-api.ts b/src/portal/siclaw-api.ts index 087495e69..5c6ea21f3 100644 --- a/src/portal/siclaw-api.ts +++ b/src/portal/siclaw-api.ts @@ -2983,7 +2983,7 @@ export function registerSiclawRoutes(router: RestRouter, config: SiclawConfig, c VALUES (?, ?, ?, ?, ?, ?, ?)`, [ id, auth.orgId, trim(body.name), trim(body.base_url), - trim(body.api_key) || null, trim(body.api_type) || "openai", + trim(body.api_key) || null, trim(body.api_type) || "openai-completions", body.sort_order ?? 0, ], );