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
2 changes: 1 addition & 1 deletion plugins/web-ui/src/core-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ export interface RuntimeConfig {
scopeId: string;
approvedHarnesses: string[];
modelsByHarness: Record<string, string[]>;
modelCatalog: Record<string, { name: string; provider: string }>;
modelCatalog: Record<string, { name: string; provider: string; api?: "openai-completions" | "anthropic-messages"; baseUrl?: string }>;
orgDefault: { harnessId: string; modelId: string; effortLevel?: string; fastMode?: boolean; revision: number };
scopeOverride: {
harnessId: string;
Expand Down
10 changes: 5 additions & 5 deletions plugins/web-ui/src/model-options.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Api, Model } from "@earendil-works/pi-ai";
import { getBaseModel } from "./pi-models.ts";
import { getBaseModel, type CatalogEntry } from "./pi-models.ts";

export type ModelOptionValue = string;
export interface ModelOption {
Expand Down Expand Up @@ -78,7 +78,7 @@ function buildOption(
id: string,
harnessId = "pi",
qualified = false,
catalog: Readonly<Record<string, { name: string; provider: string }>> = {},
catalog: Readonly<Record<string, CatalogEntry>> = {},
): ModelOption | null {
try {
const dynamic = catalog[id];
Expand All @@ -101,7 +101,7 @@ function buildOptions(
ids: readonly string[],
harnessId = "pi",
qualified = false,
catalog: Readonly<Record<string, { name: string; provider: string }>> = {},
catalog: Readonly<Record<string, CatalogEntry>> = {},
): ModelOption[] {
const seen = new Set<string>();
const out: ModelOption[] = [];
Expand Down Expand Up @@ -154,7 +154,7 @@ export function applyPickerModelIds(ids: readonly string[] | null | undefined, b
export function runtimeModelOptions(
approvedHarnesses: readonly string[],
modelsByHarness: Readonly<Record<string, readonly string[]>>,
catalog: Readonly<Record<string, { name: string; provider: string }>> = {},
catalog: Readonly<Record<string, CatalogEntry>> = {},
): ModelOption[] {
const options = approvedHarnesses.flatMap((harnessId) => {
const configured = buildOptions(modelsByHarness[harnessId] ?? [], harnessId, true, catalog);
Expand All @@ -170,7 +170,7 @@ export function applyRuntimeOptions(
approvedHarnesses: readonly string[],
modelsByHarness: Readonly<Record<string, readonly string[]>>,
effective: { harnessId: string; modelId: string },
catalog: Readonly<Record<string, { name: string; provider: string }>> = {},
catalog: Readonly<Record<string, CatalogEntry>> = {},
): void {
const options = runtimeModelOptions(approvedHarnesses, modelsByHarness, catalog);
const applied = { options, defaultValue: `${effective.harnessId}:${effective.modelId}` };
Expand Down
35 changes: 29 additions & 6 deletions plugins/web-ui/src/pi-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,46 @@ function builtinModel(id: string): PiModel | undefined {
return undefined;
}

export function getBaseModel(id: string, fallback?: { name: string; provider: string }): PiModel {
export interface CatalogEntry {
name: string;
provider: string;
api?: "openai-completions" | "anthropic-messages";
baseUrl?: string;
}

const PROTOCOL_TEMPLATES: Readonly<Record<NonNullable<CatalogEntry["api"]>, { provider: string; id: string }>> = {
"openai-completions": { provider: "openrouter", id: "openrouter/auto" },
"anthropic-messages": { provider: "anthropic", id: "claude-opus-4-8" },
};

export function getBaseModel(id: string, fallback?: CatalogEntry): PiModel {
const builtin = builtinModel(id);
if (builtin) return builtin;
const clone = CLONE_TEMPLATES[id];
if (clone) {
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<typeof getModel>[1]) as PiModel | undefined;
if (template) return cloneModel(template, id, fallback.name);
if (fallback) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes the model selectable, but it also clones all of openrouter/auto’s transport identity. I reproduced the full runtime-options → transcript path with an Anthropic-compatible custom model: the selected model and reconstructed assistant message had provider: "openrouter", api: "openai-completions", and the OpenRouter base URL. /api/turn sends only model.id, so core routing remains correct, but the client transcript metadata and provider-derived defaults are wrong. Please carry the custom provider/protocol metadata into the client model (and cover both custom protocols in the web tests) rather than retaining the OpenRouter template identity.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and the diagnosis was exactly right — thanks for reproducing the whole path rather than just the symptom.

Fixed in 87757b5. The protocol and endpoint already existed server-side on CustomRuntimeModel; they were simply dropped at the catalogue boundary, which is why the client had nothing better to go on than the template. customModelCatalog() now carries api and baseUrl, ModelCatalogEntry and the runtime-config payload pass them through, and getBaseModel picks the template matching the declared protocol and then overrides provider, api and baseUrl with the model's own.

So an Anthropic-protocol custom model now clones an Anthropic template and reports its own provider slug and endpoint, rather than presenting as OpenRouter over openai-completions.

An entry with no declared protocol keeps the previous openai-completions assumption, so built-in dynamic OpenRouter models are unchanged.

Tests: both custom protocols plus the unspecified-protocol default in plugins/web-ui/test/pi-models.test.ts, including an explicit assertion that an anthropic-messages model is never attributed to an openai-completions transport. The catalogue round-trip test in test/custom-providers.test.ts now asserts the protocol fields it carries.

const protocol = PROTOCOL_TEMPLATES[fallback.api ?? "openai-completions"];
const template = getModel(
protocol.provider as Parameters<typeof getModel>[0],
protocol.id as Parameters<typeof getModel>[1],
) as PiModel | undefined;
if (template) return cloneModel(template, id, fallback.name, fallback);
}
throw new Error(`Unsupported model: ${id}`);
}

function cloneModel(model: PiModel, id: string, name: string): PiModel {
return { ...structuredClone(model), id, name };
function cloneModel(model: PiModel, id: string, name: string, identity?: CatalogEntry): PiModel {
const clone = { ...structuredClone(model), id, name };
if (!identity) return clone;
return {
...clone,
provider: identity.provider,
...(identity.api ? { api: identity.api } : {}),
...(identity.baseUrl ? { baseUrl: identity.baseUrl } : {}),
} as PiModel;
}

const fastModeByScope = new Map<string, Set<string>>();
Expand Down
35 changes: 35 additions & 0 deletions plugins/web-ui/test/pi-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,38 @@ test("fast-mode support is fed from core's runtime config, not a hardcoded clien
assert.equal(modelSupportsFastMode(null, "claude-haiku-4-5"), false);
assert.equal(modelSupportsFastMode(null, undefined), false);
});

test("a custom-provider model keeps its own provider, protocol and endpoint", () => {
const openaiStyle = getBaseModel("vendor/some-model", {
name: "Some Model",
provider: "my-gateway",
api: "openai-completions",
baseUrl: "https://gateway.example/v1",
});
assert.equal(openaiStyle.id, "vendor/some-model");
assert.equal(openaiStyle.name, "Some Model");
assert.equal(openaiStyle.provider, "my-gateway", "the custom provider slug survives, not the template's");
assert.equal(openaiStyle.api, "openai-completions");
assert.equal(openaiStyle.baseUrl, "https://gateway.example/v1");

const anthropicStyle = getBaseModel("vendor/claude-ish", {
name: "Claude Ish",
provider: "my-anthropic-gateway",
api: "anthropic-messages",
baseUrl: "https://anthropic.example",
});
assert.equal(anthropicStyle.provider, "my-anthropic-gateway");
assert.equal(
anthropicStyle.api,
"anthropic-messages",
"an Anthropic-protocol custom model must not be attributed to an OpenAI-completions transport",
);
assert.equal(anthropicStyle.baseUrl, "https://anthropic.example");
assert.notEqual(anthropicStyle.provider, "openrouter");
});

test("a catalogued model with no declared protocol defaults to OpenAI-completions", () => {
const model = getBaseModel("vendor/unspecified", { name: "Unspecified", provider: "my-gateway" });
assert.equal(model.provider, "my-gateway");
assert.equal(model.api, "openai-completions");
});
13 changes: 12 additions & 1 deletion src/api/routes/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1165,7 +1165,18 @@ async function runtimeConfigBody(ctx: ApiCtx, scope: ScopeId): Promise<Record<st
const modelCatalog = Object.fromEntries(
[...advertisedModelIds].flatMap((id) => {
const model = catalog.find((candidate) => candidate.id === id);
if (model) return [[id, { name: model.name, provider: model.provider }]];
if (model)
return [
[
id,
{
name: model.name,
provider: model.provider,
...(model.api ? { api: model.api } : {}),
...(model.baseUrl ? { baseUrl: model.baseUrl } : {}),
},
],
];
const resolved = resolveModel(id);
return resolved ? [[id, { name: resolved.name, provider: resolved.provider }]] : [];
}),
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const server = createServer(built.app, {
modelProviders: modelProviderAvailabilityFor(config.harness, providerKeysPresent(config)),
providerKeys: providerKeysPresent(config),
modelCredentials: built.modelCredentials,
customProviders: built.customProviders,
refreshCustomProviders: built.refreshCustomProviders,
...(config.brandingDefault ? { brandingDefault: config.brandingDefault } : {}),
harnessId: config.harness,
connectorTokens: built.connectorTokens,
Expand Down
16 changes: 14 additions & 2 deletions src/model/custom-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,20 @@ export function isCustomModelId(id: string): boolean {
return registry.has(id);
}

export function customModelCatalog(): Array<{ id: string; name: string; provider: string }> {
return [...registry.values()].map((m) => ({ id: m.id, name: m.name, provider: m.provider }));
export function customModelCatalog(): Array<{
id: string;
name: string;
provider: string;
api: CustomRuntimeModel["api"];
baseUrl: string;
}> {
return [...registry.values()].map((m) => ({
id: m.id,
name: m.name,
provider: m.provider,
api: m.api,
baseUrl: m.baseUrl,
}));
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/model/model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export interface ModelCatalogEntry {
name: string;
/** A built-in provider or the slug of an admin-registered custom provider. */
provider: string;
/** Set for custom-provider models: the wire protocol and endpoint the client must attribute the model to. */
api?: "openai-completions" | "anthropic-messages";
baseUrl?: string;
}

const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models?supported_parameters=tools&sort=most-popular";
Expand Down
10 changes: 9 additions & 1 deletion test/custom-providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,15 @@ test("a registered custom model is serviceable regardless of built-in key availa

test("catalog lists custom models; clearing the registry removes them", () => {
setCustomProviders([GATEWAY]);
assert.deepEqual(customModelCatalog(), [{ id: "acme-large", name: "Acme Large", provider: "acme-gateway" }]);
assert.deepEqual(customModelCatalog(), [
{
id: "acme-large",
name: "Acme Large",
provider: "acme-gateway",
api: "openai-completions",
baseUrl: "https://llm.acme.internal/v1",
},
]);
setCustomProviders([]);
assert.equal(isCustomModelId("acme-large"), false);
assert.equal(resolveModel("acme-large"), undefined);
Expand Down