Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions portal-web/src/pages/Models.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
22 changes: 19 additions & 3 deletions portal-web/src/pages/Models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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<Provider[]>([])
const [loading, setLoading] = useState(true)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -188,7 +204,7 @@ export function Models() {
<label className="block text-sm font-medium mb-1">API Type</label>
<select value={providerForm.api_type} onChange={(e) => setProviderForm({ ...providerForm, api_type: e.target.value })} className="w-full h-8 px-3 text-sm rounded-md border border-border bg-background">
<option value="openai-completions">OpenAI Compatible</option>
<option value="anthropic">Anthropic</option>
<option value="anthropic-messages">Anthropic</option>
</select>
</div>
</div>
Expand Down Expand Up @@ -247,7 +263,7 @@ export function Models() {
<input placeholder="Name" value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} className="h-7 px-2 text-xs rounded-md border border-border bg-background" />
<select value={editForm.api_type} onChange={(e) => setEditForm({ ...editForm, api_type: e.target.value })} className="h-7 px-2 text-xs rounded-md border border-border bg-background">
<option value="openai-completions">OpenAI Compatible</option>
<option value="anthropic">Anthropic</option>
<option value="anthropic-messages">Anthropic</option>
</select>
</div>
<input placeholder="Base URL" value={editForm.base_url} onChange={(e) => 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" />
Expand Down
31 changes: 30 additions & 1 deletion src/core/model-compat.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
20 changes: 20 additions & 0 deletions src/core/model-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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 {
Expand Down
8 changes: 5 additions & 3 deletions src/core/provider-presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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" }),
},
],
},
Expand Down
22 changes: 13 additions & 9 deletions src/portal/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }),
),
},
},
Expand Down Expand Up @@ -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,
Expand All @@ -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,
},
Expand Down Expand Up @@ -2206,14 +2208,15 @@ export function buildAdapterRpcHandlers(): Map<string, (params: any, agentId: st
// buildTracingConfig(). Only the provider-bound path carries it: an agent
// with no provider never runs, so it needs no trace.
const tracing = await buildTracingConfig();
const settingsApi = normalizeProviderApi(p.api_type);
return {
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 }),
),
},
},
Expand Down Expand Up @@ -2251,12 +2254,13 @@ export function buildAdapterRpcHandlers(): Map<string, (params: any, agentId: st
return { binding: null };
}
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,
Expand All @@ -2270,7 +2274,7 @@ export function buildAdapterRpcHandlers(): Map<string, (params: any, agentId: st
name: p.name,
baseUrl: p.base_url,
apiKey: p.api_key ?? "",
api: p.api_type,
api: bindingApi,
authHeader: true,
models,
},
Expand Down
7 changes: 4 additions & 3 deletions src/portal/chat-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
isErrorDetail,
type ErrorDetail,
} from "../lib/error-envelope.js";
import { buildProviderModelDescriptor } from "../core/model-compat.js";
import { buildProviderModelDescriptor, normalizeProviderApi } from "../core/model-compat.js";
import { resolveAgentModelRouting } from "./model-routing-config.js";
import { authenticateApiKey } from "./api-key-auth.js";

Expand Down Expand Up @@ -297,12 +297,13 @@ export async function resolveAgentModelBinding(agentId: string): Promise<Resolve
| undefined;
if (!provider) return null;

const bindingApi = normalizeProviderApi(provider.api_type);
const [entryRows] = await db.query(
"SELECT model_id, name, reasoning, vision, context_window, max_tokens FROM model_entries WHERE provider_id = ?",
[provider.id],
) as any;
const models = (entryRows as any[]).map((m: any) =>
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, {
Expand All @@ -317,7 +318,7 @@ export async function resolveAgentModelBinding(agentId: string): Promise<Resolve
name: provider.name,
baseUrl: provider.base_url,
apiKey: provider.api_key ?? "",
api: provider.api_type,
api: bindingApi,
authHeader: true,
models,
},
Expand Down
7 changes: 4 additions & 3 deletions src/portal/cli-snapshot-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import type { RestRouter } from "../gateway/rest-router.js";
import { sendJson } from "../gateway/rest-router.js";
import { getDb, type Db } 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 { resolveCapabilities } from "../core/tool-capabilities.js";
import type {
CliSnapshotKnowledgeRepo,
Expand Down Expand Up @@ -404,13 +404,14 @@ export function registerCliSnapshotRoute(router: RestRouter, cliSnapshotSecret:

for (const p of providers) {
const entries = modelsByProviderId.get(p.id) ?? [];
const providerApi = normalizeProviderApi(p.api_type);
providersOut[p.name] = {
baseUrl: p.base_url,
apiKey: p.api_key ?? "",
api: p.api_type,
api: providerApi,
authHeader: true,
models: entries.map((m) =>
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.
Expand Down
7 changes: 4 additions & 3 deletions src/portal/model-routing-config.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -100,14 +100,15 @@ async function loadProviderConfigs(providerNames: string[]): Promise<Map<string,
"SELECT model_id, name, reasoning, vision, context_window, max_tokens FROM model_entries WHERE provider_id = ?",
[provider.id],
);
const providerApi = normalizeProviderApi(provider.api_type);
out.set(provider.name, {
name: provider.name,
baseUrl: provider.base_url,
apiKey: provider.api_key ?? "",
api: provider.api_type,
api: providerApi,
authHeader: true,
models: modelRows.map((model) =>
buildProviderModelDescriptor(model, { api: provider.api_type, baseUrl: provider.base_url }),
buildProviderModelDescriptor(model, { api: providerApi, baseUrl: provider.base_url }),
),
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/portal/siclaw-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
);
Expand Down
Loading