From fb0d722219adca1c1de2ea0c0fc035009d93ddae Mon Sep 17 00:00:00 2001 From: smakosh Date: Mon, 3 Aug 2026 20:59:57 +0200 Subject: [PATCH 01/12] feat: split LLM Gateway into two provider catalogs Renames the existing llmgateway provider to "DevPass (LLM Gateway)" (id and models unchanged: the aggregated, auto-routed root-model catalog) and adds llmgateway-providers ("LLM Gateway"): one entry per upstream provider mapping, addressed as provider/model-id, synced from /v1/models?mapped=true. The catalog starts empty and is populated by the scheduled sync automation; the sync refuses to run against a deployment without the mapped view so it fails loudly instead of syncing wrong ids. Claude-Session: https://claude.ai/code/session_017pReWhniXJcDL9aiQHqoFQ --- package.json | 1 + packages/core/src/sync/index.ts | 5 +- .../core/src/sync/providers/llmgateway.ts | 233 ++++++++++++++++-- providers/llmgateway-providers/logo.svg | 6 + .../llmgateway-providers/models/.gitkeep | 0 providers/llmgateway-providers/provider.toml | 19 ++ providers/llmgateway/provider.toml | 2 +- 7 files changed, 244 insertions(+), 22 deletions(-) create mode 100644 providers/llmgateway-providers/logo.svg create mode 100644 providers/llmgateway-providers/models/.gitkeep create mode 100644 providers/llmgateway-providers/provider.toml diff --git a/package.json b/package.json index cea23def31..e31bc42523 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "huggingface:sync": "bun ./packages/core/script/sync-models.ts huggingface", "kilo:sync": "bun ./packages/core/script/sync-models.ts kilo", "llmgateway:sync": "bun ./packages/core/script/sync-models.ts llmgateway", + "llmgateway-providers:sync": "bun ./packages/core/script/sync-models.ts llmgateway-providers", "merge-gateway:sync": "bun ./packages/core/script/sync-models.ts merge-gateway", "nano-gpt:sync": "bun ./packages/core/script/sync-models.ts nano-gpt", "venice:sync": "bun ./packages/core/script/sync-models.ts venice", diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 6334268d87..18b340905e 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -18,7 +18,7 @@ import { google } from "./providers/google.js"; import { hyper } from "./providers/hyper.js"; import { huggingface } from "./providers/huggingface.js"; import { kilo } from "./providers/kilo.js"; -import { llmgateway } from "./providers/llmgateway.js"; +import { llmgateway, llmgatewayProviders } from "./providers/llmgateway.js"; import { mergeGateway } from "./providers/merge-gateway.js"; import { nanoGpt } from "./providers/nano-gpt.js"; import { openai } from "./providers/openai.js"; @@ -124,6 +124,7 @@ export const providers: { huggingface: SyncProvider; kilo: SyncProvider; llmgateway: SyncProvider; + "llmgateway-providers": SyncProvider; "merge-gateway": SyncProvider; "nano-gpt": SyncProvider; ofox: SyncProvider; @@ -151,6 +152,7 @@ export const providers: { huggingface, kilo, llmgateway, + "llmgateway-providers": llmgatewayProviders, "merge-gateway": mergeGateway, "nano-gpt": nanoGpt, ofox, @@ -172,6 +174,7 @@ export const groups = { "huggingface", "kilo", "llmgateway", + "llmgateway-providers", "merge-gateway", "nano-gpt", "ofox", diff --git a/packages/core/src/sync/providers/llmgateway.ts b/packages/core/src/sync/providers/llmgateway.ts index 3e6a9123a1..0c8272181e 100644 --- a/packages/core/src/sync/providers/llmgateway.ts +++ b/packages/core/src/sync/providers/llmgateway.ts @@ -36,8 +36,19 @@ export const LLMGatewayModel = z.object({ input_modalities: z.array(z.string()), output_modalities: z.array(z.string()), }), + providers: z.array( + z.object({ + providerId: z.string(), + vision: z.boolean().optional(), + tools: z.boolean().optional(), + reasoning: z.boolean().optional(), + }).passthrough(), + ).optional(), pricing: Pricing, - context_length: z.number(), + // Absent for pseudo-models (custom/auto) and some non-text mappings; text + // models always report it. + context_length: z.number().optional(), + max_output: z.number().optional(), supported_parameters: z.array(z.string()), structured_outputs: z.boolean().optional(), }).passthrough(); @@ -48,25 +59,33 @@ export const LLMGatewayResponse = z.object({ export type LLMGatewayModel = z.infer; +async function fetchLLMGatewayModels(url: string) { + const headers = process.env.LLMGATEWAY_API_KEY + ? { Authorization: `Bearer ${process.env.LLMGATEWAY_API_KEY}` } + : undefined; + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error(`LLM Gateway request failed: ${response.status} ${response.statusText}`); + } + return response.json(); +} + +function textOnly(model: LLMGatewayModel) { + const output = model.architecture.output_modalities; + return output.length === 1 && output[0] === "text"; +} + +// The DevPass (LLM Gateway) provider: the gateway's aggregated catalog of root +// model IDs, auto-routed across upstream providers. export const llmgateway = { id: "llmgateway", - name: "LLM Gateway", + name: "DevPass (LLM Gateway)", modelsDir: "providers/llmgateway/models", async fetchModels() { - const headers = process.env.LLMGATEWAY_API_KEY - ? { Authorization: `Bearer ${process.env.LLMGATEWAY_API_KEY}` } - : undefined; - const response = await fetch(API_ENDPOINT, { headers }); - if (!response.ok) { - throw new Error(`LLM Gateway request failed: ${response.status} ${response.statusText}`); - } - return response.json(); + return fetchLLMGatewayModels(API_ENDPOINT); }, parseModels(raw) { - return LLMGatewayResponse.parse(raw).data.filter((model) => { - const output = model.architecture.output_modalities; - return output.length === 1 && output[0] === "text"; - }); + return LLMGatewayResponse.parse(raw).data.filter(textOnly); }, translateModel(model, context) { return { @@ -76,6 +95,36 @@ export const llmgateway = { }, } satisfies SyncProvider; +// The LLM Gateway provider: one entry per upstream provider mapping, addressed +// the way the gateway accepts provider-pinned requests (`provider/model-id`). +export const llmgatewayProviders = { + id: "llmgateway-providers", + name: "LLM Gateway", + modelsDir: "providers/llmgateway-providers/models", + async fetchModels() { + return fetchLLMGatewayModels(`${API_ENDPOINT}?mapped=true`); + }, + parseModels(raw) { + const data = LLMGatewayResponse.parse(raw).data; + // A deployment without the mapped view ignores the query param and returns + // aggregated root IDs (no provider prefix); syncing those here would wipe + // the provider-pinned catalog, so refuse to proceed. + if (!data.every((model) => model.id.includes("/"))) { + throw new Error("LLM Gateway mapped view unavailable: response contains unprefixed model ids"); + } + // llmgateway/custom is the BYO-model placeholder and llmgateway/auto the + // auto-router; pinning either to a provider is meaningless in this catalog + // (the aggregated llmgateway provider carries `auto`). + return data.filter((model) => !model.id.startsWith("llmgateway/") && textOnly(model)); + }, + translateModel(model, context) { + return { + id: model.id, + model: buildLLMGatewayMappedModel(model, context.existing(model.id)), + }; + }, +} satisfies SyncProvider; + function dateFromTimestamp(timestamp: number) { return new Date(timestamp * 1000).toISOString().slice(0, 10); } @@ -106,12 +155,12 @@ function modalities(values: string[], fallback: Modality[]): Modality[] { return [...new Set(result.length > 0 ? result : fallback)]; } -function resolveLLMGatewayBaseModel(model: LLMGatewayModel) { - const alias = BASE_MODEL_ALIASES[model.id]; +function resolveLLMGatewayBaseModel(model: LLMGatewayModel, modelID = model.id) { + const alias = BASE_MODEL_ALIASES[modelID]; if (alias !== undefined) return alias; if (model.family === undefined) return undefined; const prefix = CANONICAL_FAMILY_ALIASES[model.family] ?? model.family; - return resolveCanonicalBaseModel(`${prefix}/${model.id}`); + return resolveCanonicalBaseModel(`${prefix}/${modelID}`); } function inferFamily(model: LLMGatewayModel, name: string) { @@ -138,9 +187,8 @@ export function buildLLMGatewayModel( const completion = price(model.pricing.completion); const reasoning = model.supported_parameters.includes("reasoning") || model.supported_parameters.includes("include_reasoning"); - const context = model.context_length > 0 - ? model.context_length - : existing?.limit?.context ?? model.context_length; + const reported = model.context_length ?? 0; + const context = reported > 0 ? reported : existing?.limit?.context ?? reported; // The gateway is authoritative for the volatile, gateway-specific data — cost // and served limits. Its supported_parameters / modalities are too noisy to @@ -276,6 +324,151 @@ export function buildLLMGatewayModel( } satisfies SyncedFullModel; } +export function buildLLMGatewayMappedModel( + model: LLMGatewayModel, + existing: ExistingModel | undefined, +): SyncedModel { + // Mapped entries carry exactly one provider mapping; its capability flags + // describe that specific deployment, unlike the aggregated view where + // supported_parameters are too noisy to trust. + const mapping = model.providers?.[0]; + const prompt = price(model.pricing.prompt); + const completion = price(model.pricing.completion); + const reasoning = mapping?.reasoning + ?? (model.supported_parameters.includes("reasoning") + || model.supported_parameters.includes("include_reasoning")); + const reported = model.context_length ?? 0; + const context = reported > 0 ? reported : existing?.limit?.context ?? reported; + + const cost = prompt !== undefined && completion !== undefined + ? { + input: prompt, + output: completion, + reasoning: reasoning ? nonZeroPrice(model.pricing.internal_reasoning) ?? existing?.cost?.reasoning : existing?.cost?.reasoning, + cache_read: nonZeroPrice(model.pricing.input_cache_read) ?? existing?.cost?.cache_read, + cache_write: nonZeroPrice(model.pricing.input_cache_write) ?? existing?.cost?.cache_write, + tiers: existing?.cost?.tiers, + } + : existing?.cost; + const limit = { + context, + input: existing?.limit?.input, + output: existing?.limit?.output ?? model.max_output ?? context, + }; + + // Existing factored model: refresh cost + limit, keep every authored override + // as-is. Unlike the aggregated provider, the name override must be carried + // forward: mapped names disambiguate deployments of the same model (e.g. + // "GPT-5.5 (Azure)" vs "GPT-5.5 (OpenAI)") and must not collapse back to the + // base metadata name. + if (existing?.base_model !== undefined) { + return factorBaseModel( + existing.base_model, + { + name: existing.name ?? model.name, + attachment: existing.attachment, + description: existing.description ?? describeModel({ + id: model.id, + name: existing.name ?? model.name, + family: existing.family, + reasoning: existing.reasoning, + tool_call: existing.tool_call, + structured_output: existing.structured_output, + open_weights: existing.open_weights, + limit, + modalities: existing.modalities, + }), + reasoning: existing.reasoning, + temperature: existing.temperature, + tool_call: existing.tool_call, + structured_output: existing.structured_output, + status: existing.status, + interleaved: existing.interleaved, + knowledge: existing.knowledge, + modalities: existing.modalities, + limit, + cost, + }, + limit, + existing.base_model_omit, + ); + } + + // Existing full model: refresh cost + limit, preserve curated metadata. + if (existing !== undefined) { + return { + name: existing.name ?? model.name, + description: existing.description ?? describeModel({ + id: model.id, + name: existing.name ?? model.name, + family: existing.family, + reasoning: existing.reasoning, + tool_call: existing.tool_call, + structured_output: existing.structured_output, + open_weights: existing.open_weights, + limit, + modalities: existing.modalities ?? defaultModalities(model), + }), + family: existing.family, + release_date: existing.release_date ?? dateFromTimestamp(model.created), + last_updated: existing.last_updated ?? dateFromTimestamp(model.created), + attachment: existing.attachment ?? mapping?.vision ?? false, + reasoning: existing.reasoning ?? reasoning, + temperature: existing.temperature ?? false, + tool_call: existing.tool_call ?? mapping?.tools ?? false, + structured_output: existing.structured_output ?? model.structured_outputs, + knowledge: existing.knowledge, + open_weights: existing.open_weights ?? false, + status: existing.status, + interleaved: existing.interleaved, + cost, + limit, + modalities: existing.modalities ?? defaultModalities(model), + } satisfies SyncedFullModel; + } + + // Brand-new model with a reviewed metadata entry: factor against the + // canonical base. The mapped ID is `serving-provider/model-id` and the + // serving provider is unrelated to the originating lab, so resolve the base + // from the root model ID + family, and keep the disambiguating name. + const rootID = model.id.split("/").slice(1).join("/"); + const canonical = resolveLLMGatewayBaseModel(model, rootID); + if (canonical !== undefined) { + const factoredLimit = { context, input: undefined, output: model.max_output ?? context }; + return factorBaseModel(canonical, { name: model.name, limit: factoredLimit, cost }, factoredLimit); + } + + // Brand-new model without metadata: best-effort translation. The mapping's + // own capability flags are reliable here; modalities mirror the mapping too. + const { input, output } = defaultModalities(model); + return { + name: model.name, + description: describeModel({ + id: model.id, + name: model.name, + family: inferFamily(model, model.name), + reasoning, + tool_call: mapping?.tools ?? false, + structured_output: model.structured_outputs ?? false, + open_weights: false, + limit, + modalities: { input, output }, + }), + family: inferFamily(model, model.name), + release_date: dateFromTimestamp(model.created), + last_updated: dateFromTimestamp(model.created), + attachment: mapping?.vision ?? input.some((value) => value !== "text"), + reasoning, + temperature: model.supported_parameters.includes("temperature"), + tool_call: mapping?.tools ?? false, + structured_output: model.structured_outputs ?? false, + open_weights: false, + cost, + limit, + modalities: { input, output }, + } satisfies SyncedFullModel; +} + function defaultModalities(model: LLMGatewayModel) { return { input: modalities(model.architecture.input_modalities, ["text"]), diff --git a/providers/llmgateway-providers/logo.svg b/providers/llmgateway-providers/logo.svg new file mode 100644 index 0000000000..4bda1089f2 --- /dev/null +++ b/providers/llmgateway-providers/logo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/providers/llmgateway-providers/models/.gitkeep b/providers/llmgateway-providers/models/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/providers/llmgateway-providers/provider.toml b/providers/llmgateway-providers/provider.toml new file mode 100644 index 0000000000..3fc54bcd87 --- /dev/null +++ b/providers/llmgateway-providers/provider.toml @@ -0,0 +1,19 @@ +# The provider-pinned LLM Gateway catalog: one entry per upstream provider +# mapping, addressed as `provider/model-id` — the format the gateway accepts to +# pin a request to a specific upstream provider. The aggregated, auto-routed +# catalog of root model IDs lives in the `llmgateway` provider +# ("DevPass (LLM Gateway)"). Both are served by the same endpoint and API key. +# +# POST /v1/chat/completions accepts $.reasoning_effort = none|minimal|low| +# medium|high|xhigh|max. Its raw schema lists $.reasoning.effort = low|medium| +# high; the two effort paths are mutually exclusive. $.reasoning.max_tokens +# overrides either effort path. Anthropic budgets are clamped to 1024..128000. +# POST /v1/messages translates $.thinking to unified reasoning controls; its +# $.output_config.effort controls adaptive depth on Opus 4.7+. +# https://docs.llmgateway.io/features/reasoning (accessed 2026-06-25) +# https://docs.llmgateway.io/v1_messages (accessed 2026-06-25) +name = "LLM Gateway" +env = ["LLMGATEWAY_API_KEY"] +npm = "@ai-sdk/openai-compatible" +doc = "https://llmgateway.io/docs" +api = "https://api.llmgateway.io/v1" diff --git a/providers/llmgateway/provider.toml b/providers/llmgateway/provider.toml index 5381d21a87..a454e9829e 100644 --- a/providers/llmgateway/provider.toml +++ b/providers/llmgateway/provider.toml @@ -6,7 +6,7 @@ # $.output_config.effort controls adaptive depth on Opus 4.7+. # https://docs.llmgateway.io/features/reasoning (accessed 2026-06-25) # https://docs.llmgateway.io/v1_messages (accessed 2026-06-25) -name = "LLM Gateway" +name = "DevPass (LLM Gateway)" env = ["LLMGATEWAY_API_KEY"] npm = "@ai-sdk/openai-compatible" doc = "https://llmgateway.io/docs" From f5678d9ddaa49b43a404ee0e97012b98eb1348ab Mon Sep 17 00:00:00 2001 From: smakosh Date: Mon, 3 Aug 2026 22:45:29 +0200 Subject: [PATCH 02/12] fix: apply deployment data on mapped factored entries Addresses the PR review: brand-new factored mapped entries now carry the mapping's own capability flags (attachment/tool_call/reasoning and structured_output) as overrides, translate the deployment's declared reasoning_efforts into reasoning_options instead of stamping [], prefer the gateway's served max_output over inherited/authored output limits, and only fall back to context when the base metadata declares no output. Adds unit tests for mapped factoring, capability overrides, max_output preference, and the unprefixed-id refusal guard. Claude-Session: https://claude.ai/code/session_017pReWhniXJcDL9aiQHqoFQ --- .../core/src/sync/providers/llmgateway.ts | 53 ++++++++- packages/core/test/sync.test.ts | 110 +++++++++++++++++- 2 files changed, 158 insertions(+), 5 deletions(-) diff --git a/packages/core/src/sync/providers/llmgateway.ts b/packages/core/src/sync/providers/llmgateway.ts index 0c8272181e..ca22acba61 100644 --- a/packages/core/src/sync/providers/llmgateway.ts +++ b/packages/core/src/sync/providers/llmgateway.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; import { describeModel } from "../../describe.js"; import { inferKimiFamily, ModelFamilyValues } from "../../family.js"; @@ -42,6 +44,9 @@ export const LLMGatewayModel = z.object({ vision: z.boolean().optional(), tools: z.boolean().optional(), reasoning: z.boolean().optional(), + reasoning_efforts: z.array( + z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]), + ).optional(), }).passthrough(), ).optional(), pricing: Pricing, @@ -155,6 +160,22 @@ function modalities(values: string[], fallback: Modality[]): Modality[] { return [...new Set(result.length > 0 ? result : fallback)]; } +const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models"); +const canonicalOutputLimitByID = new Map(); + +// Whether the canonical metadata declares limit.output; factored entries can +// only omit their own output override when the base has one to inherit. +function canonicalOutputLimit(modelID: string) { + if (!canonicalOutputLimitByID.has(modelID)) { + const filePath = path.join(MODELS_DIR, `${modelID}.toml`); + const metadata = existsSync(filePath) + ? Bun.TOML.parse(readFileSync(filePath, "utf8")) as { limit?: { output?: number } } + : undefined; + canonicalOutputLimitByID.set(modelID, metadata?.limit?.output); + } + return canonicalOutputLimitByID.get(modelID); +} + function resolveLLMGatewayBaseModel(model: LLMGatewayModel, modelID = model.id) { const alias = BASE_MODEL_ALIASES[modelID]; if (alias !== undefined) return alias; @@ -337,6 +358,10 @@ export function buildLLMGatewayMappedModel( const reasoning = mapping?.reasoning ?? (model.supported_parameters.includes("reasoning") || model.supported_parameters.includes("include_reasoning")); + // The exact reasoning_effort values this deployment accepts. + const reasoningOptions = mapping?.reasoning_efforts?.length + ? [{ type: "effort" as const, values: mapping.reasoning_efforts }] + : undefined; const reported = model.context_length ?? 0; const context = reported > 0 ? reported : existing?.limit?.context ?? reported; @@ -350,10 +375,12 @@ export function buildLLMGatewayMappedModel( tiers: existing?.cost?.tiers, } : existing?.cost; + // The gateway's max_output is the deployment's real served limit, so it wins + // over inherited/authored values, unlike the aggregated view. const limit = { context, input: existing?.limit?.input, - output: existing?.limit?.output ?? model.max_output ?? context, + output: model.max_output ?? existing?.limit?.output ?? context, }; // Existing factored model: refresh cost + limit, keep every authored override @@ -430,12 +457,29 @@ export function buildLLMGatewayMappedModel( // Brand-new model with a reviewed metadata entry: factor against the // canonical base. The mapped ID is `serving-provider/model-id` and the // serving provider is unrelated to the originating lab, so resolve the base - // from the root model ID + family, and keep the disambiguating name. + // from the root model ID + family, and keep the disambiguating name. The + // mapping's own capability flags describe this specific deployment, so they + // go in as overrides (factorBaseModel drops the ones equal to the base). const rootID = model.id.split("/").slice(1).join("/"); const canonical = resolveLLMGatewayBaseModel(model, rootID); if (canonical !== undefined) { - const factoredLimit = { context, input: undefined, output: model.max_output ?? context }; - return factorBaseModel(canonical, { name: model.name, limit: factoredLimit, cost }, factoredLimit); + const factoredLimit = { + context, + input: undefined, + // Without a served limit, inherit the base's output; only fall back to + // context when the base declares none (output is required downstream). + output: model.max_output ?? (canonicalOutputLimit(canonical) !== undefined ? undefined : context), + }; + return factorBaseModel(canonical, { + name: model.name, + attachment: mapping?.vision, + reasoning: mapping?.reasoning, + reasoning_options: reasoningOptions, + tool_call: mapping?.tools, + structured_output: model.structured_outputs, + limit: factoredLimit, + cost, + }, factoredLimit); } // Brand-new model without metadata: best-effort translation. The mapping's @@ -459,6 +503,7 @@ export function buildLLMGatewayMappedModel( last_updated: dateFromTimestamp(model.created), attachment: mapping?.vision ?? input.some((value) => value !== "text"), reasoning, + reasoning_options: reasoningOptions, temperature: model.supported_parameters.includes("temperature"), tool_call: mapping?.tools ?? false, structured_output: model.structured_outputs ?? false, diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 8578ff239c..ea2d6549bd 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -32,7 +32,12 @@ import { resolveCanonicalBaseModel, type OpenRouterModel, } from "../src/sync/providers/openrouter.js"; -import { buildLLMGatewayModel, type LLMGatewayModel } from "../src/sync/providers/llmgateway.js"; +import { + buildLLMGatewayMappedModel, + buildLLMGatewayModel, + llmgatewayProviders, + type LLMGatewayModel, +} from "../src/sync/providers/llmgateway.js"; import { buildMergeGatewayModel, fetchMergeGatewayModels, @@ -2298,6 +2303,93 @@ test("factors aliased LLM Gateway routes against canonical metadata", () => { }); }); +test("factors mapped LLM Gateway entries against the root model metadata", () => { + const model = buildLLMGatewayMappedModel(llmGatewayMappedModel(), undefined); + + expect(model).toEqual({ + base_model: "anthropic/claude-fable-5", + name: "Claude Fable 5 (Anthropic)", + reasoning_options: [{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }], + structured_output: true, + cost: { + input: 10, + output: 50, + cache_read: 1, + cache_write: 12.5, + }, + }); +}); + +test("applies deployment capability flags on mapped factored entries", () => { + const model = buildLLMGatewayMappedModel(llmGatewayMappedModel({ + providers: [{ providerId: "anthropic", vision: false, tools: false, reasoning: false }], + max_output: 64_000, + }), undefined); + + expect(model).toEqual({ + base_model: "anthropic/claude-fable-5", + name: "Claude Fable 5 (Anthropic)", + attachment: false, + reasoning: false, + tool_call: false, + structured_output: true, + limit: { + output: 64_000, + }, + cost: { + input: 10, + output: 50, + cache_read: 1, + cache_write: 12.5, + }, + }); +}); + +test("prefers the gateway max_output over authored output on mapped resyncs", () => { + const model = buildLLMGatewayMappedModel(llmGatewayMappedModel({ max_output: 32_000 }), { + base_model: "anthropic/claude-fable-5", + name: "Claude Fable 5 (Anthropic)", + description: "Claude Fable 5 served by Anthropic", + limit: { output: 64_000 }, + }); + + expect(model).toEqual({ + base_model: "anthropic/claude-fable-5", + name: "Claude Fable 5 (Anthropic)", + description: "Claude Fable 5 served by Anthropic", + limit: { + output: 32_000, + }, + cost: { + input: 10, + output: 50, + cache_read: 1, + cache_write: 12.5, + }, + }); +}); + +test("refuses aggregated responses in the mapped LLM Gateway sync", () => { + expect(() => llmgatewayProviders.parseModels({ data: [llmGatewayModel()] })) + .toThrow("mapped view unavailable"); +}); + +test("filters pseudo and non-text entries from the mapped LLM Gateway sync", () => { + const parsed = llmgatewayProviders.parseModels({ + data: [ + llmGatewayMappedModel(), + llmGatewayMappedModel({ id: "llmgateway/auto", name: "Auto Route (LLM Gateway)" }), + llmGatewayMappedModel({ + id: "openai/sora-2", + name: "Sora 2 (OpenAI)", + architecture: { input_modalities: ["text"], output_modalities: ["video"] }, + }), + ], + }); + + expect(parsed.map((model) => model.id)).toEqual(["anthropic/claude-fable-5"]); +}); + // Ensures catalog pagination preserves authentication and returns every page. test("fetches every page of the Merge Gateway catalog", async () => { const requests: string[] = []; @@ -3032,6 +3124,22 @@ function llmGatewayModel(overrides: Partial = {}): LLMGatewayMo }; } +function llmGatewayMappedModel(overrides: Partial = {}): LLMGatewayModel { + return llmGatewayModel({ + id: "anthropic/claude-fable-5", + name: "Claude Fable 5 (Anthropic)", + providers: [{ + providerId: "anthropic", + vision: true, + tools: true, + reasoning: true, + reasoning_efforts: ["low", "medium", "high", "xhigh", "max"], + }], + max_output: 128_000, + ...overrides, + }); +} + function mergeGatewayVendor( overrides: Partial = {}, ): MergeGatewayModel["vendors"][string] { From 7f307b192562ebea66ae7494ad1920ece0900259 Mon Sep 17 00:00:00 2001 From: smakosh Date: Tue, 4 Aug 2026 18:55:51 +0200 Subject: [PATCH 03/12] chore: seed the llmgateway-providers catalog The dev branch now rejects providers with zero models, so the empty .gitkeep-anchored catalog no longer validates. Seed it with a small representative set generated by the sync (factored, full, duplicate deployments of one model, capability deltas); the scheduled sync fills in the rest once the gateway's mapped view is live. Claude-Session: https://claude.ai/code/session_017pReWhniXJcDL9aiQHqoFQ --- .../llmgateway-providers/models/.gitkeep | 0 .../models/anthropic/claude-opus-4-8.toml | 13 +++++++++++ .../models/anthropic/claude-sonnet-5.toml | 13 +++++++++++ .../models/azure/gpt-5.5.toml | 11 +++++++++ .../models/embercloud/glm-5.1.toml | 13 +++++++++++ .../models/openai/gpt-5.5.toml | 11 +++++++++ .../models/perplexity/sonar-pro.toml | 23 +++++++++++++++++++ .../models/together-ai/kimi-k2.6.toml | 16 +++++++++++++ 8 files changed, 100 insertions(+) delete mode 100644 providers/llmgateway-providers/models/.gitkeep create mode 100644 providers/llmgateway-providers/models/anthropic/claude-opus-4-8.toml create mode 100644 providers/llmgateway-providers/models/anthropic/claude-sonnet-5.toml create mode 100644 providers/llmgateway-providers/models/azure/gpt-5.5.toml create mode 100644 providers/llmgateway-providers/models/embercloud/glm-5.1.toml create mode 100644 providers/llmgateway-providers/models/openai/gpt-5.5.toml create mode 100644 providers/llmgateway-providers/models/perplexity/sonar-pro.toml create mode 100644 providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml diff --git a/providers/llmgateway-providers/models/.gitkeep b/providers/llmgateway-providers/models/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/providers/llmgateway-providers/models/anthropic/claude-opus-4-8.toml b/providers/llmgateway-providers/models/anthropic/claude-opus-4-8.toml new file mode 100644 index 0000000000..1a38a73940 --- /dev/null +++ b/providers/llmgateway-providers/models/anthropic/claude-opus-4-8.toml @@ -0,0 +1,13 @@ +base_model = "anthropic/claude-opus-4-8" +name = "Claude Opus 4.8 (Anthropic)" +structured_output = true + +[[reasoning_options]] +type = "effort" +values = ["low", "medium", "high", "xhigh", "max"] + +[cost] +input = 5 +output = 25 +cache_read = 0.5 +cache_write = 6.25 diff --git a/providers/llmgateway-providers/models/anthropic/claude-sonnet-5.toml b/providers/llmgateway-providers/models/anthropic/claude-sonnet-5.toml new file mode 100644 index 0000000000..c7b9069c47 --- /dev/null +++ b/providers/llmgateway-providers/models/anthropic/claude-sonnet-5.toml @@ -0,0 +1,13 @@ +base_model = "anthropic/claude-sonnet-5" +name = "Claude Sonnet 5 (Anthropic)" +structured_output = true + +[[reasoning_options]] +type = "effort" +values = ["low", "medium", "high", "xhigh", "max"] + +[cost] +input = 2 +output = 10 +cache_read = 0.2 +cache_write = 2.5 diff --git a/providers/llmgateway-providers/models/azure/gpt-5.5.toml b/providers/llmgateway-providers/models/azure/gpt-5.5.toml new file mode 100644 index 0000000000..642821a814 --- /dev/null +++ b/providers/llmgateway-providers/models/azure/gpt-5.5.toml @@ -0,0 +1,11 @@ +base_model = "openai/gpt-5.5" +name = "GPT-5.5 (Azure)" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "medium", "high", "xhigh"] + +[cost] +input = 5 +output = 30 +cache_read = 0.5 diff --git a/providers/llmgateway-providers/models/embercloud/glm-5.1.toml b/providers/llmgateway-providers/models/embercloud/glm-5.1.toml new file mode 100644 index 0000000000..4784881888 --- /dev/null +++ b/providers/llmgateway-providers/models/embercloud/glm-5.1.toml @@ -0,0 +1,13 @@ +base_model = "zhipuai/glm-5.1" +name = "GLM-5.1 (EmberCloud)" +structured_output = false +reasoning_options = [] + +[cost] +input = 0.931 +output = 2.93 +cache_read = 0.173 + +[limit] +context = 203_000 +output = 131_000 diff --git a/providers/llmgateway-providers/models/openai/gpt-5.5.toml b/providers/llmgateway-providers/models/openai/gpt-5.5.toml new file mode 100644 index 0000000000..2dbf74cae2 --- /dev/null +++ b/providers/llmgateway-providers/models/openai/gpt-5.5.toml @@ -0,0 +1,11 @@ +base_model = "openai/gpt-5.5" +name = "GPT-5.5 (OpenAI)" + +[[reasoning_options]] +type = "effort" +values = ["none", "low", "medium", "high", "xhigh"] + +[cost] +input = 5 +output = 30 +cache_read = 0.5 diff --git a/providers/llmgateway-providers/models/perplexity/sonar-pro.toml b/providers/llmgateway-providers/models/perplexity/sonar-pro.toml new file mode 100644 index 0000000000..6e531c1a3f --- /dev/null +++ b/providers/llmgateway-providers/models/perplexity/sonar-pro.toml @@ -0,0 +1,23 @@ +name = "Sonar Pro (Perplexity)" +description = "Advanced Sonar search model for deeper research and cited synthesis" +family = "sonar-pro" +release_date = "2025-03-07" +last_updated = "2025-03-07" +attachment = false +reasoning = false +temperature = true +tool_call = false +structured_output = true +open_weights = false + +[cost] +input = 3 +output = 15 + +[limit] +context = 200_000 +output = 200_000 + +[modalities] +input = ["text"] +output = ["text"] diff --git a/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml b/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml new file mode 100644 index 0000000000..16bb7549b0 --- /dev/null +++ b/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml @@ -0,0 +1,16 @@ +base_model = "moonshotai/kimi-k2.6" +name = "Kimi K2.6 (Together AI)" +tool_call = false +structured_output = false + +[[reasoning_options]] +type = "effort" +values = ["none"] + +[cost] +input = 1.2 +output = 4.5 +cache_read = 0.2 + +[limit] +output = 32_768 From 48bf376685465ed0fd35a83319e005270f309f12 Mon Sep 17 00:00:00 2001 From: smakosh Date: Tue, 4 Aug 2026 19:27:43 +0200 Subject: [PATCH 04/12] fix: honor base and sibling reasoning data on mapped sync Round 2 of review feedback: - Factored resyncs no longer stamp context as limit.output when the gateway omits max_output and the base declares an output to inherit; the served max_output still wins whenever reported (creates and resyncs), and reasoning_options now refresh from deployment efforts. - A deployment whose only accepted effort is "none" is a plain on/off switch, so it translates to a toggle (matches the lab's control). - When a deployment declares no efforts, mapped entries reuse the aggregated llmgateway catalog's curated reasoning_options for the same root model instead of ending up with []; a curated [] counts as unknown so a bad first stamp is not sticky. The runner also stops stamping [] onto factored reasoners whose base metadata already declares reasoning_options (it would shadow the base's controls). - perplexity added to the canonical prefixes so Sonar models factor against their lab metadata; the sonar-pro seed is now override-only. Claude-Session: https://claude.ai/code/session_017pReWhniXJcDL9aiQHqoFQ --- packages/core/src/sync/index.ts | 10 +++- .../core/src/sync/providers/llmgateway.ts | 58 ++++++++++++++++--- .../core/src/sync/providers/openrouter.ts | 1 + packages/core/test/sync.test.ts | 38 ++++++++++++ .../models/embercloud/glm-5.1.toml | 4 +- .../models/perplexity/sonar-pro.toml | 17 +----- .../models/together-ai/kimi-k2.6.toml | 3 +- 7 files changed, 103 insertions(+), 28 deletions(-) diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 5f9b6bb978..a5c3a2229f 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -262,13 +262,16 @@ export async function syncProvider( : preserveBaseModel(translated.model, existing.get(relativePath)?.authored); const translatedBase = "base_model" in translatedModel ? translatedModel.base_model : undefined; let resolvedReasoning: boolean | undefined; + let baseReasoningOptions: unknown; if (translatedBase !== undefined) { if (translated.metadata?.id === translatedBase) { resolvedReasoning = translated.metadata.model.reasoning; + baseReasoningOptions = translated.metadata.model.reasoning_options; } else { modelMetadata ??= await readModelMetadata(provider.modelsDir); const canonicalReasoning = modelMetadata[translatedBase]?.reasoning; resolvedReasoning = typeof canonicalReasoning === "boolean" ? canonicalReasoning : undefined; + baseReasoningOptions = modelMetadata[translatedBase]?.reasoning_options; } } else { resolvedReasoning = existing.get(relativePath)?.toml.reasoning; @@ -277,6 +280,7 @@ export async function syncProvider( translatedModel, existing.get(relativePath)?.authored, resolvedReasoning, + baseReasoningOptions, ); const withDescription = provider.preserveDescriptions === false ? withReasoningOptions @@ -468,6 +472,7 @@ export function preserveReasoningOptions( model: SyncedModel, existing: ExistingModel | undefined, resolvedReasoning: boolean | undefined = existing?.reasoning, + baseReasoningOptions: unknown = undefined, ): SyncedModel { if ((model.reasoning ?? resolvedReasoning) === false) { const { reasoning_options: _reasoningOptions, ...withoutReasoningOptions } = model; @@ -475,7 +480,10 @@ export function preserveReasoningOptions( } if (model.reasoning_options !== undefined) return model; if (existing?.reasoning_options === undefined) { - return (model.reasoning ?? resolvedReasoning) === true + // When the base model already declares reasoning_options, leave the field + // unset so the factored file inherits them — stamping [] here would + // shadow the base's real controls with "no controls". + return (model.reasoning ?? resolvedReasoning) === true && baseReasoningOptions === undefined ? { ...model, reasoning_options: [] } : model; } diff --git a/packages/core/src/sync/providers/llmgateway.ts b/packages/core/src/sync/providers/llmgateway.ts index ca22acba61..c796ac3175 100644 --- a/packages/core/src/sync/providers/llmgateway.ts +++ b/packages/core/src/sync/providers/llmgateway.ts @@ -161,7 +161,26 @@ function modalities(values: string[], fallback: Modality[]): Modality[] { } const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models"); +const AGGREGATED_MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "providers", "llmgateway", "models"); const canonicalOutputLimitByID = new Map(); +const siblingReasoningOptionsByID = new Map(); + +// The aggregated llmgateway catalog curates reasoning controls for the same +// gateway surface; mapped deployments of the same root model reuse them when +// they do not declare their own effort values. +function siblingReasoningOptions(rootID: string) { + if (!siblingReasoningOptionsByID.has(rootID)) { + const filePath = path.join(AGGREGATED_MODELS_DIR, `${rootID}.toml`); + const authored = existsSync(filePath) + ? Bun.TOML.parse(readFileSync(filePath, "utf8")) as { reasoning_options?: SyncedFullModel["reasoning_options"] } + : undefined; + siblingReasoningOptionsByID.set( + rootID, + authored?.reasoning_options?.length ? authored.reasoning_options : undefined, + ); + } + return siblingReasoningOptionsByID.get(rootID); +} // Whether the canonical metadata declares limit.output; factored entries can // only omit their own output override when the base has one to inherit. @@ -353,14 +372,28 @@ export function buildLLMGatewayMappedModel( // describe that specific deployment, unlike the aggregated view where // supported_parameters are too noisy to trust. const mapping = model.providers?.[0]; + const rootID = model.id.split("/").slice(1).join("/"); const prompt = price(model.pricing.prompt); const completion = price(model.pricing.completion); const reasoning = mapping?.reasoning ?? (model.supported_parameters.includes("reasoning") || model.supported_parameters.includes("include_reasoning")); - // The exact reasoning_effort values this deployment accepts. - const reasoningOptions = mapping?.reasoning_efforts?.length - ? [{ type: "effort" as const, values: mapping.reasoning_efforts }] + // The exact reasoning_effort values this deployment accepts. A deployment + // whose only accepted effort is "none" exposes a plain on/off switch (the + // gateway honours it through the thinking toggle), not effort tiers. + const deploymentOptions = mapping?.reasoning_efforts?.length + ? mapping.reasoning_efforts.length === 1 && mapping.reasoning_efforts[0] === "none" + ? [{ type: "toggle" as const }] + : [{ type: "effort" as const, values: mapping.reasoning_efforts }] + : undefined; + // Deployment-declared efforts win; then non-empty curation on this file; + // then the aggregated llmgateway catalog's curated controls for the same + // root model on the same gateway surface. A curated [] counts as unknown so + // a bad first stamp is not sticky. Non-reasoning deployments carry none. + const reasoningOptions = reasoning + ? deploymentOptions + ?? (existing?.reasoning_options?.length ? existing.reasoning_options : undefined) + ?? siblingReasoningOptions(rootID) : undefined; const reported = model.context_length ?? 0; const context = reported > 0 ? reported : existing?.limit?.context ?? reported; @@ -377,10 +410,11 @@ export function buildLLMGatewayMappedModel( : existing?.cost; // The gateway's max_output is the deployment's real served limit, so it wins // over inherited/authored values, unlike the aggregated view. + const servedOutput = model.max_output ?? existing?.limit?.output; const limit = { context, input: existing?.limit?.input, - output: model.max_output ?? existing?.limit?.output ?? context, + output: servedOutput ?? context, }; // Existing factored model: refresh cost + limit, keep every authored override @@ -389,6 +423,13 @@ export function buildLLMGatewayMappedModel( // "GPT-5.5 (Azure)" vs "GPT-5.5 (OpenAI)") and must not collapse back to the // base metadata name. if (existing?.base_model !== undefined) { + // Mirror the brand-new factored path: without a served or authored output, + // keep inheriting the base's output rather than stamping context over it. + const factoredLimit = { + context, + input: existing.limit?.input, + output: servedOutput ?? (canonicalOutputLimit(existing.base_model) !== undefined ? undefined : context), + }; return factorBaseModel( existing.base_model, { @@ -402,10 +443,11 @@ export function buildLLMGatewayMappedModel( tool_call: existing.tool_call, structured_output: existing.structured_output, open_weights: existing.open_weights, - limit, + limit: factoredLimit, modalities: existing.modalities, }), reasoning: existing.reasoning, + reasoning_options: reasoningOptions, temperature: existing.temperature, tool_call: existing.tool_call, structured_output: existing.structured_output, @@ -413,10 +455,10 @@ export function buildLLMGatewayMappedModel( interleaved: existing.interleaved, knowledge: existing.knowledge, modalities: existing.modalities, - limit, + limit: factoredLimit, cost, }, - limit, + factoredLimit, existing.base_model_omit, ); } @@ -441,6 +483,7 @@ export function buildLLMGatewayMappedModel( last_updated: existing.last_updated ?? dateFromTimestamp(model.created), attachment: existing.attachment ?? mapping?.vision ?? false, reasoning: existing.reasoning ?? reasoning, + reasoning_options: reasoningOptions, temperature: existing.temperature ?? false, tool_call: existing.tool_call ?? mapping?.tools ?? false, structured_output: existing.structured_output ?? model.structured_outputs, @@ -460,7 +503,6 @@ export function buildLLMGatewayMappedModel( // from the root model ID + family, and keep the disambiguating name. The // mapping's own capability flags describe this specific deployment, so they // go in as overrides (factorBaseModel drops the ones equal to the base). - const rootID = model.id.split("/").slice(1).join("/"); const canonical = resolveLLMGatewayBaseModel(model, rootID); if (canonical !== undefined) { const factoredLimit = { diff --git a/packages/core/src/sync/providers/openrouter.ts b/packages/core/src/sync/providers/openrouter.ts index 855789cf2e..f94966c27b 100644 --- a/packages/core/src/sync/providers/openrouter.ts +++ b/packages/core/src/sync/providers/openrouter.ts @@ -34,6 +34,7 @@ const CANONICAL_PROVIDER_PREFIXES = { moonshotai: { provider: "moonshotai", metadata: "moonshotai" }, openai: { provider: "openai", metadata: "openai" }, nvidia: { provider: "nvidia", metadata: "nvidia" }, + perplexity: { provider: "perplexity", metadata: "perplexity" }, qwen: { provider: "alibaba", metadata: "alibaba" }, sakana: { provider: "sakana", metadata: "sakana" }, stepfun: { provider: "stepfun", metadata: "stepfun" }, diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 9b198d80fa..9b15a688de 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -2143,6 +2143,11 @@ test("defaults new reasoning models to empty reasoning options", () => { }); }); +test("inherits base reasoning options instead of stamping empty ones", () => { + expect(preserveReasoningOptions({ reasoning: true }, undefined, undefined, [{ type: "toggle" }])) + .toEqual({ reasoning: true }); +}); + test("syncs OpenRouter reasoning efforts from model metadata", () => { const model = buildOpenRouterModel(openRouterModel({ reasoning: { @@ -2418,6 +2423,7 @@ test("prefers the gateway max_output over authored output on mapped resyncs", () base_model: "anthropic/claude-fable-5", name: "Claude Fable 5 (Anthropic)", description: "Claude Fable 5 served by Anthropic", + reasoning_options: [{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }], limit: { output: 32_000, }, @@ -2430,6 +2436,38 @@ test("prefers the gateway max_output over authored output on mapped resyncs", () }); }); +test("translates a none-only effort list into a reasoning toggle", () => { + const model = buildLLMGatewayMappedModel(llmGatewayMappedModel({ + providers: [{ providerId: "anthropic", vision: true, tools: true, reasoning: true, reasoning_efforts: ["none"] }], + }), undefined); + + expect(model).toMatchObject({ + base_model: "anthropic/claude-fable-5", + reasoning_options: [{ type: "toggle" }], + }); +}); + +test("keeps inheriting base output on factored resyncs without max_output", () => { + const model = buildLLMGatewayMappedModel(llmGatewayMappedModel({ max_output: undefined }), { + base_model: "anthropic/claude-fable-5", + name: "Claude Fable 5 (Anthropic)", + description: "Claude Fable 5 served by Anthropic", + }); + + expect(model).toEqual({ + base_model: "anthropic/claude-fable-5", + name: "Claude Fable 5 (Anthropic)", + description: "Claude Fable 5 served by Anthropic", + reasoning_options: [{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }], + cost: { + input: 10, + output: 50, + cache_read: 1, + cache_write: 12.5, + }, + }); +}); + test("refuses aggregated responses in the mapped LLM Gateway sync", () => { expect(() => llmgatewayProviders.parseModels({ data: [llmGatewayModel()] })) .toThrow("mapped view unavailable"); diff --git a/providers/llmgateway-providers/models/embercloud/glm-5.1.toml b/providers/llmgateway-providers/models/embercloud/glm-5.1.toml index 4784881888..e4dafcd48a 100644 --- a/providers/llmgateway-providers/models/embercloud/glm-5.1.toml +++ b/providers/llmgateway-providers/models/embercloud/glm-5.1.toml @@ -1,7 +1,9 @@ base_model = "zhipuai/glm-5.1" name = "GLM-5.1 (EmberCloud)" structured_output = false -reasoning_options = [] + +[[reasoning_options]] +type = "toggle" [cost] input = 0.931 diff --git a/providers/llmgateway-providers/models/perplexity/sonar-pro.toml b/providers/llmgateway-providers/models/perplexity/sonar-pro.toml index 6e531c1a3f..1b1f445819 100644 --- a/providers/llmgateway-providers/models/perplexity/sonar-pro.toml +++ b/providers/llmgateway-providers/models/perplexity/sonar-pro.toml @@ -1,23 +1,8 @@ +base_model = "perplexity/sonar-pro" name = "Sonar Pro (Perplexity)" -description = "Advanced Sonar search model for deeper research and cited synthesis" -family = "sonar-pro" -release_date = "2025-03-07" -last_updated = "2025-03-07" attachment = false -reasoning = false -temperature = true -tool_call = false structured_output = true -open_weights = false [cost] input = 3 output = 15 - -[limit] -context = 200_000 -output = 200_000 - -[modalities] -input = ["text"] -output = ["text"] diff --git a/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml b/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml index 16bb7549b0..13260fbd00 100644 --- a/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml +++ b/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml @@ -4,8 +4,7 @@ tool_call = false structured_output = false [[reasoning_options]] -type = "effort" -values = ["none"] +type = "toggle" [cost] input = 1.2 From 545d19abeac290e1c3f9d16a8e1b291f22812f2c Mon Sep 17 00:00:00 2001 From: smakosh Date: Tue, 4 Aug 2026 19:50:13 +0200 Subject: [PATCH 05/12] fix: harden mapped sync guards and seed curation Round 3 of review feedback: - Both LLM Gateway syncs now reject an empty (or fully filtered) response instead of authoritatively deleting the catalog through the delete-missing pass; the every() prefix guard alone passed on []. - A vision-less deployment also overrides modalities on factored creates, so attachment=false can no longer coexist with inherited image input (sonar-pro seed regenerated accordingly). - Mapped entries copy the interleaved reasoning side-channel from the aggregated llmgateway catalog when the deployment reasons (same wire surface); glm-5.1 and kimi-k2.6 seeds now carry it. - Toggle seeds carry the required leading wire-path comment. - gpt-5.5 seeds author the 272k context pricing tier so resync preserves it, matching the first-party and aggregated entries. Claude-Session: https://claude.ai/code/session_017pReWhniXJcDL9aiQHqoFQ --- .../core/src/sync/providers/llmgateway.ts | 72 +++++++++++++------ packages/core/test/sync.test.ts | 10 +++ .../models/azure/gpt-5.5.toml | 6 ++ .../models/embercloud/glm-5.1.toml | 7 ++ .../models/openai/gpt-5.5.toml | 6 ++ .../models/perplexity/sonar-pro.toml | 3 + .../models/together-ai/kimi-k2.6.toml | 8 +++ 7 files changed, 91 insertions(+), 21 deletions(-) diff --git a/packages/core/src/sync/providers/llmgateway.ts b/packages/core/src/sync/providers/llmgateway.ts index c796ac3175..8a148fb069 100644 --- a/packages/core/src/sync/providers/llmgateway.ts +++ b/packages/core/src/sync/providers/llmgateway.ts @@ -90,7 +90,13 @@ export const llmgateway = { return fetchLLMGatewayModels(API_ENDPOINT); }, parseModels(raw) { - return LLMGatewayResponse.parse(raw).data.filter(textOnly); + const data = LLMGatewayResponse.parse(raw).data.filter(textOnly); + // An empty catalog is an upstream fault; syncing it would delete every + // model file, so fail loudly instead. + if (data.length === 0) { + throw new Error("LLM Gateway returned no text models"); + } + return data; }, translateModel(model, context) { return { @@ -113,14 +119,20 @@ export const llmgatewayProviders = { const data = LLMGatewayResponse.parse(raw).data; // A deployment without the mapped view ignores the query param and returns // aggregated root IDs (no provider prefix); syncing those here would wipe - // the provider-pinned catalog, so refuse to proceed. - if (!data.every((model) => model.id.includes("/"))) { - throw new Error("LLM Gateway mapped view unavailable: response contains unprefixed model ids"); + // the provider-pinned catalog, so refuse to proceed. An empty response (or + // one left empty after filtering) would silently do the same via the + // delete-missing pass, so it is equally fatal. + if (data.length === 0 || !data.every((model) => model.id.includes("/"))) { + throw new Error("LLM Gateway mapped view unavailable: response is empty or contains unprefixed model ids"); } // llmgateway/custom is the BYO-model placeholder and llmgateway/auto the // auto-router; pinning either to a provider is meaningless in this catalog // (the aggregated llmgateway provider carries `auto`). - return data.filter((model) => !model.id.startsWith("llmgateway/") && textOnly(model)); + const mapped = data.filter((model) => !model.id.startsWith("llmgateway/") && textOnly(model)); + if (mapped.length === 0) { + throw new Error("LLM Gateway mapped view returned no text models"); + } + return mapped; }, translateModel(model, context) { return { @@ -163,23 +175,31 @@ function modalities(values: string[], fallback: Modality[]): Modality[] { const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models"); const AGGREGATED_MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "providers", "llmgateway", "models"); const canonicalOutputLimitByID = new Map(); -const siblingReasoningOptionsByID = new Map(); -// The aggregated llmgateway catalog curates reasoning controls for the same -// gateway surface; mapped deployments of the same root model reuse them when -// they do not declare their own effort values. -function siblingReasoningOptions(rootID: string) { - if (!siblingReasoningOptionsByID.has(rootID)) { +interface SiblingCuration { + reasoning_options?: SyncedFullModel["reasoning_options"]; + interleaved?: SyncedFullModel["interleaved"]; +} + +const siblingCurationByID = new Map(); + +// The aggregated llmgateway catalog curates reasoning controls and the +// reasoning side-channel for the same gateway surface; mapped deployments of +// the same root model reuse them when the deployment does not declare its own. +function siblingCuration(rootID: string): SiblingCuration { + let curation = siblingCurationByID.get(rootID); + if (curation === undefined) { const filePath = path.join(AGGREGATED_MODELS_DIR, `${rootID}.toml`); const authored = existsSync(filePath) - ? Bun.TOML.parse(readFileSync(filePath, "utf8")) as { reasoning_options?: SyncedFullModel["reasoning_options"] } + ? Bun.TOML.parse(readFileSync(filePath, "utf8")) as SiblingCuration : undefined; - siblingReasoningOptionsByID.set( - rootID, - authored?.reasoning_options?.length ? authored.reasoning_options : undefined, - ); + curation = { + reasoning_options: authored?.reasoning_options?.length ? authored.reasoning_options : undefined, + interleaved: authored?.interleaved, + }; + siblingCurationByID.set(rootID, curation); } - return siblingReasoningOptionsByID.get(rootID); + return curation; } // Whether the canonical metadata declares limit.output; factored entries can @@ -389,11 +409,16 @@ export function buildLLMGatewayMappedModel( // Deployment-declared efforts win; then non-empty curation on this file; // then the aggregated llmgateway catalog's curated controls for the same // root model on the same gateway surface. A curated [] counts as unknown so - // a bad first stamp is not sticky. Non-reasoning deployments carry none. + // a bad first stamp is not sticky. Non-reasoning deployments carry none; + // the same applies to the interleaved reasoning side-channel. + const sibling = siblingCuration(rootID); const reasoningOptions = reasoning ? deploymentOptions ?? (existing?.reasoning_options?.length ? existing.reasoning_options : undefined) - ?? siblingReasoningOptions(rootID) + ?? sibling.reasoning_options + : undefined; + const interleaved = reasoning + ? existing?.interleaved ?? sibling.interleaved : undefined; const reported = model.context_length ?? 0; const context = reported > 0 ? reported : existing?.limit?.context ?? reported; @@ -452,7 +477,7 @@ export function buildLLMGatewayMappedModel( tool_call: existing.tool_call, structured_output: existing.structured_output, status: existing.status, - interleaved: existing.interleaved, + interleaved, knowledge: existing.knowledge, modalities: existing.modalities, limit: factoredLimit, @@ -490,7 +515,7 @@ export function buildLLMGatewayMappedModel( knowledge: existing.knowledge, open_weights: existing.open_weights ?? false, status: existing.status, - interleaved: existing.interleaved, + interleaved, cost, limit, modalities: existing.modalities ?? defaultModalities(model), @@ -517,8 +542,12 @@ export function buildLLMGatewayMappedModel( attachment: mapping?.vision, reasoning: mapping?.reasoning, reasoning_options: reasoningOptions, + interleaved, tool_call: mapping?.tools, structured_output: model.structured_outputs, + // A deployment without vision must not inherit image/pdf inputs from + // the base — attachment=false with image input is contradictory. + modalities: mapping?.vision === false ? defaultModalities(model) : undefined, limit: factoredLimit, cost, }, factoredLimit); @@ -546,6 +575,7 @@ export function buildLLMGatewayMappedModel( attachment: mapping?.vision ?? input.some((value) => value !== "text"), reasoning, reasoning_options: reasoningOptions, + interleaved, temperature: model.supported_parameters.includes("temperature"), tool_call: mapping?.tools ?? false, structured_output: model.structured_outputs ?? false, diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 9b15a688de..cb3639e725 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -39,6 +39,7 @@ import { import { buildLLMGatewayMappedModel, buildLLMGatewayModel, + llmgateway, llmgatewayProviders, type LLMGatewayModel, } from "../src/sync/providers/llmgateway.js"; @@ -2389,6 +2390,7 @@ test("factors mapped LLM Gateway entries against the root model metadata", () => test("applies deployment capability flags on mapped factored entries", () => { const model = buildLLMGatewayMappedModel(llmGatewayMappedModel({ providers: [{ providerId: "anthropic", vision: false, tools: false, reasoning: false }], + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, max_output: 64_000, }), undefined); @@ -2399,6 +2401,9 @@ test("applies deployment capability flags on mapped factored entries", () => { reasoning: false, tool_call: false, structured_output: true, + modalities: { + input: ["text"], + }, limit: { output: 64_000, }, @@ -2468,6 +2473,11 @@ test("keeps inheriting base output on factored resyncs without max_output", () = }); }); +test("refuses empty responses in both LLM Gateway syncs", () => { + expect(() => llmgateway.parseModels({ data: [] })).toThrow("no text models"); + expect(() => llmgatewayProviders.parseModels({ data: [] })).toThrow("mapped view unavailable"); +}); + test("refuses aggregated responses in the mapped LLM Gateway sync", () => { expect(() => llmgatewayProviders.parseModels({ data: [llmGatewayModel()] })) .toThrow("mapped view unavailable"); diff --git a/providers/llmgateway-providers/models/azure/gpt-5.5.toml b/providers/llmgateway-providers/models/azure/gpt-5.5.toml index 642821a814..956bc57141 100644 --- a/providers/llmgateway-providers/models/azure/gpt-5.5.toml +++ b/providers/llmgateway-providers/models/azure/gpt-5.5.toml @@ -9,3 +9,9 @@ values = ["none", "low", "medium", "high", "xhigh"] input = 5 output = 30 cache_read = 0.5 + +[[cost.tiers]] +tier = { type = "context", size = 272_000 } +input = 10 +output = 45 +cache_read = 1 diff --git a/providers/llmgateway-providers/models/embercloud/glm-5.1.toml b/providers/llmgateway-providers/models/embercloud/glm-5.1.toml index e4dafcd48a..ab1c4379ba 100644 --- a/providers/llmgateway-providers/models/embercloud/glm-5.1.toml +++ b/providers/llmgateway-providers/models/embercloud/glm-5.1.toml @@ -1,7 +1,14 @@ +# Toggle: $.reasoning_effort = "none" disables thinking; any other accepted +# value (or omitting the field) leaves it on. The gateway maps it to the +# deployment's thinking switch; thinking streams back in reasoning_content. +# https://docs.llmgateway.io/features/reasoning (accessed 2026-08-04) base_model = "zhipuai/glm-5.1" name = "GLM-5.1 (EmberCloud)" structured_output = false +[interleaved] +field = "reasoning_content" + [[reasoning_options]] type = "toggle" diff --git a/providers/llmgateway-providers/models/openai/gpt-5.5.toml b/providers/llmgateway-providers/models/openai/gpt-5.5.toml index 2dbf74cae2..3914d89fc5 100644 --- a/providers/llmgateway-providers/models/openai/gpt-5.5.toml +++ b/providers/llmgateway-providers/models/openai/gpt-5.5.toml @@ -9,3 +9,9 @@ values = ["none", "low", "medium", "high", "xhigh"] input = 5 output = 30 cache_read = 0.5 + +[[cost.tiers]] +tier = { type = "context", size = 272_000 } +input = 10 +output = 45 +cache_read = 1 diff --git a/providers/llmgateway-providers/models/perplexity/sonar-pro.toml b/providers/llmgateway-providers/models/perplexity/sonar-pro.toml index 1b1f445819..cd0900675c 100644 --- a/providers/llmgateway-providers/models/perplexity/sonar-pro.toml +++ b/providers/llmgateway-providers/models/perplexity/sonar-pro.toml @@ -6,3 +6,6 @@ structured_output = true [cost] input = 3 output = 15 + +[modalities] +input = ["text"] diff --git a/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml b/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml index 13260fbd00..728a5fe692 100644 --- a/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml +++ b/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml @@ -1,8 +1,16 @@ +# Toggle: $.reasoning_effort = "none" disables thinking via Together's +# thinking switch. Together accepts any other effort string without validating +# it and no tier changes reasoning length, so on/off is the only real control; +# thinking streams back in reasoning_content. +# https://api.llmgateway.io/v1/models?mapped=true providers[].reasoning_efforts (accessed 2026-08-04) base_model = "moonshotai/kimi-k2.6" name = "Kimi K2.6 (Together AI)" tool_call = false structured_output = false +[interleaved] +field = "reasoning_content" + [[reasoning_options]] type = "toggle" From 7ddf9e90917356a0bd527d0537bb6c6d5e0709b4 Mon Sep 17 00:00:00 2001 From: smakosh Date: Tue, 4 Aug 2026 20:26:59 +0200 Subject: [PATCH 06/12] fix: never author zero limits, enforce vision on modalities Round 4 of review feedback: - A missing/zero context_length is no longer written as limit.context=0: factored entries leave context unset and inherit the base, and unfactored creates without a positive served context are skipped (reported via sourceID) instead of publishing unusable limits. Applies to both the aggregated and mapped builders. - vision=false now forces non-image input modalities from the mapping itself instead of trusting the model-level architecture, on both the factored and unfactored create paths (and the existing-full fallback). Claude-Session: https://claude.ai/code/session_017pReWhniXJcDL9aiQHqoFQ --- .../core/src/sync/providers/llmgateway.ts | 91 ++++++++++++++----- packages/core/test/sync.test.ts | 54 +++++++++++ 2 files changed, 121 insertions(+), 24 deletions(-) diff --git a/packages/core/src/sync/providers/llmgateway.ts b/packages/core/src/sync/providers/llmgateway.ts index 8a148fb069..8b5b0615e9 100644 --- a/packages/core/src/sync/providers/llmgateway.ts +++ b/packages/core/src/sync/providers/llmgateway.ts @@ -99,10 +99,14 @@ export const llmgateway = { return data; }, translateModel(model, context) { - return { - id: model.id, - model: buildLLMGatewayModel(model, context.existing(model.id)), - }; + const translated = buildLLMGatewayModel(model, context.existing(model.id)); + if (translated === undefined) { + return undefined; + } + return { id: model.id, model: translated }; + }, + sourceID(model) { + return model.id; }, } satisfies SyncProvider; @@ -135,10 +139,14 @@ export const llmgatewayProviders = { return mapped; }, translateModel(model, context) { - return { - id: model.id, - model: buildLLMGatewayMappedModel(model, context.existing(model.id)), - }; + const translated = buildLLMGatewayMappedModel(model, context.existing(model.id)); + if (translated === undefined) { + return undefined; + } + return { id: model.id, model: translated }; + }, + sourceID(model) { + return model.id; }, } satisfies SyncProvider; @@ -172,6 +180,21 @@ function modalities(values: string[], fallback: Modality[]): Modality[] { return [...new Set(result.length > 0 ? result : fallback)]; } +// Modalities as served by a specific deployment: a mapping without vision must +// not carry image/pdf input, regardless of what the model-level architecture +// claims — attachment=false with image input is contradictory. +function deploymentModalities(model: LLMGatewayModel, vision: boolean | undefined) { + const base = defaultModalities(model); + if (vision !== false) { + return base; + } + const input = base.input.filter((value) => value !== "image" && value !== "pdf"); + return { + input: input.length > 0 ? input : (["text"] satisfies Modality[]), + output: base.output, + }; +} + const MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "models"); const AGGREGATED_MODELS_DIR = path.join(import.meta.dirname, "..", "..", "..", "..", "..", "providers", "llmgateway", "models"); const canonicalOutputLimitByID = new Map(); @@ -242,13 +265,17 @@ function inferFamily(model: LLMGatewayModel, name: string) { export function buildLLMGatewayModel( model: LLMGatewayModel, existing: ExistingModel | undefined, -): SyncedModel { +): SyncedModel | undefined { const prompt = price(model.pricing.prompt); const completion = price(model.pricing.completion); const reasoning = model.supported_parameters.includes("reasoning") || model.supported_parameters.includes("include_reasoning"); const reported = model.context_length ?? 0; - const context = reported > 0 ? reported : existing?.limit?.context ?? reported; + // A missing/zero context must never be authored as limit.context = 0: + // factored entries leave it unset and inherit the base, and unfactored + // creates are skipped entirely. + const servedContext = reported > 0 ? reported : undefined; + const context = servedContext ?? existing?.limit?.context; // The gateway is authoritative for the volatile, gateway-specific data — cost // and served limits. Its supported_parameters / modalities are too noisy to @@ -267,14 +294,19 @@ export function buildLLMGatewayModel( } : existing?.cost; const limit = { - context, + context: context ?? reported, input: existing?.limit?.input, - output: existing?.limit?.output ?? context, + output: existing?.limit?.output ?? context ?? reported, }; // Existing factored model: refresh cost + limit, keep every authored override // as-is (undefined fields keep inheriting the base model). if (existing?.base_model !== undefined) { + const factoredLimit = { + context, + input: existing.limit?.input, + output: existing.limit?.output ?? context, + }; return factorBaseModel( existing.base_model, { @@ -287,7 +319,7 @@ export function buildLLMGatewayModel( tool_call: existing.tool_call, structured_output: existing.structured_output, open_weights: existing.open_weights, - limit, + limit: factoredLimit, modalities: existing.modalities, }), reasoning: existing.reasoning, @@ -298,10 +330,10 @@ export function buildLLMGatewayModel( interleaved: existing.interleaved, knowledge: existing.knowledge, modalities: existing.modalities, - limit, + limit: factoredLimit, cost, }, - limit, + factoredLimit, existing.base_model_omit, ); } @@ -352,7 +384,11 @@ export function buildLLMGatewayModel( } // Brand-new model: best-effort translation from the gateway. Capability and - // modality data are unreliable here and should be hand-reviewed. + // modality data are unreliable here and should be hand-reviewed. Without a + // positive served context there is nothing usable to author, so skip. + if (servedContext === undefined) { + return undefined; + } const { input, output } = defaultModalities(model); return { name: model.name, @@ -387,7 +423,7 @@ export function buildLLMGatewayModel( export function buildLLMGatewayMappedModel( model: LLMGatewayModel, existing: ExistingModel | undefined, -): SyncedModel { +): SyncedModel | undefined { // Mapped entries carry exactly one provider mapping; its capability flags // describe that specific deployment, unlike the aggregated view where // supported_parameters are too noisy to trust. @@ -421,7 +457,10 @@ export function buildLLMGatewayMappedModel( ? existing?.interleaved ?? sibling.interleaved : undefined; const reported = model.context_length ?? 0; - const context = reported > 0 ? reported : existing?.limit?.context ?? reported; + // Same zero-context rule as the aggregated builder: never author 0, inherit + // on factored entries, skip unfactored creates. + const servedContext = reported > 0 ? reported : undefined; + const context = servedContext ?? existing?.limit?.context; const cost = prompt !== undefined && completion !== undefined ? { @@ -437,9 +476,9 @@ export function buildLLMGatewayMappedModel( // over inherited/authored values, unlike the aggregated view. const servedOutput = model.max_output ?? existing?.limit?.output; const limit = { - context, + context: context ?? reported, input: existing?.limit?.input, - output: servedOutput ?? context, + output: servedOutput ?? context ?? reported, }; // Existing factored model: refresh cost + limit, keep every authored override @@ -501,7 +540,7 @@ export function buildLLMGatewayMappedModel( structured_output: existing.structured_output, open_weights: existing.open_weights, limit, - modalities: existing.modalities ?? defaultModalities(model), + modalities: existing.modalities ?? deploymentModalities(model, mapping?.vision), }), family: existing.family, release_date: existing.release_date ?? dateFromTimestamp(model.created), @@ -518,7 +557,7 @@ export function buildLLMGatewayMappedModel( interleaved, cost, limit, - modalities: existing.modalities ?? defaultModalities(model), + modalities: existing.modalities ?? deploymentModalities(model, mapping?.vision), } satisfies SyncedFullModel; } @@ -547,7 +586,7 @@ export function buildLLMGatewayMappedModel( structured_output: model.structured_outputs, // A deployment without vision must not inherit image/pdf inputs from // the base — attachment=false with image input is contradictory. - modalities: mapping?.vision === false ? defaultModalities(model) : undefined, + modalities: mapping?.vision === false ? deploymentModalities(model, false) : undefined, limit: factoredLimit, cost, }, factoredLimit); @@ -555,7 +594,11 @@ export function buildLLMGatewayMappedModel( // Brand-new model without metadata: best-effort translation. The mapping's // own capability flags are reliable here; modalities mirror the mapping too. - const { input, output } = defaultModalities(model); + // Without a positive served context there is nothing usable to author. + if (servedContext === undefined) { + return undefined; + } + const { input, output } = deploymentModalities(model, mapping?.vision); return { name: model.name, description: describeModel({ diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index cb3639e725..aafaaf0112 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -2473,6 +2473,60 @@ test("keeps inheriting base output on factored resyncs without max_output", () = }); }); +test("skips unfactorable LLM Gateway creates without a served context", () => { + // Unknown family, so no canonical base to inherit a context from. + const mapped = buildLLMGatewayMappedModel(llmGatewayMappedModel({ + id: "acme/mystery-model", + name: "Mystery Model (Acme)", + family: undefined, + context_length: undefined, + }), undefined); + expect(mapped).toBeUndefined(); + + const aggregated = buildLLMGatewayModel(llmGatewayModel({ + id: "mystery-model", + name: "Mystery Model", + family: undefined, + context_length: undefined, + }), undefined); + expect(aggregated).toBeUndefined(); +}); + +test("leaves context unset on mapped factored creates without a served context", () => { + const model = buildLLMGatewayMappedModel(llmGatewayMappedModel({ + context_length: undefined, + max_output: undefined, + }), undefined); + + // Everything limit-related inherits from the base; no zero is authored. + expect(model).toBeDefined(); + expect("limit" in model!).toBe(false); +}); + +test("strips image input when the deployment has no vision", () => { + // The model-level architecture still claims image input; the deployment + // flag must win on both the factored and the unfactored path. + const factored = buildLLMGatewayMappedModel(llmGatewayMappedModel({ + providers: [{ providerId: "anthropic", vision: false, tools: true, reasoning: false }], + }), undefined); + expect(factored).toMatchObject({ + base_model: "anthropic/claude-fable-5", + attachment: false, + modalities: { input: ["text"] }, + }); + + const full = buildLLMGatewayMappedModel(llmGatewayMappedModel({ + id: "acme/mystery-model", + name: "Mystery Model (Acme)", + family: undefined, + providers: [{ providerId: "acme", vision: false, tools: true, reasoning: false }], + }), undefined); + expect(full).toMatchObject({ + attachment: false, + modalities: { input: ["text"], output: ["text"] }, + }); +}); + test("refuses empty responses in both LLM Gateway syncs", () => { expect(() => llmgateway.parseModels({ data: [] })).toThrow("no text models"); expect(() => llmgatewayProviders.parseModels({ data: [] })).toThrow("mapped view unavailable"); From 48ec46576c693eb872dd03c78eb94be318528de6 Mon Sep 17 00:00:00 2001 From: smakosh Date: Wed, 5 Aug 2026 18:16:50 +0200 Subject: [PATCH 07/12] fix: scalable logo, require one mapping per entry Review round 5: drop the fixed width/height from the new provider logo (AGENTS.md blocker), and fail the mapped sync loudly when a kept model does not carry exactly one providers[] mapping instead of letting the builder silently fall back to noisy supported_parameters defaults. Claude-Session: https://claude.ai/code/session_0131ZfUnTfJrCzygw3wE4bNF --- .../core/src/sync/providers/llmgateway.ts | 12 +++++++ packages/core/test/sync.test.ts | 35 +++++++++++++++++++ providers/llmgateway-providers/logo.svg | 2 +- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/core/src/sync/providers/llmgateway.ts b/packages/core/src/sync/providers/llmgateway.ts index 8b5b0615e9..065ad6aa08 100644 --- a/packages/core/src/sync/providers/llmgateway.ts +++ b/packages/core/src/sync/providers/llmgateway.ts @@ -136,6 +136,18 @@ export const llmgatewayProviders = { if (mapped.length === 0) { throw new Error("LLM Gateway mapped view returned no text models"); } + // Every mapped entry is one specific provider deployment whose single + // providers[] mapping drives capabilities and reasoning controls. A kept + // entry with zero or several mappings would make the builder silently fall + // back to noisy supported_parameters / sibling defaults, so fail loudly. + const malformed = mapped.filter((model) => model.providers?.length !== 1); + if (malformed.length > 0) { + throw new Error( + `LLM Gateway mapped view returned entries without exactly one provider mapping: ${ + malformed.map((model) => model.id).join(", ") + }`, + ); + } return mapped; }, translateModel(model, context) { diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index cab3cd1541..d7587dc33d 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -2575,6 +2575,41 @@ test("filters pseudo and non-text entries from the mapped LLM Gateway sync", () expect(parsed.map((model) => model.id)).toEqual(["anthropic/claude-fable-5"]); }); +test("refuses mapped LLM Gateway entries without exactly one provider mapping", () => { + expect(() => llmgatewayProviders.parseModels({ + data: [llmGatewayMappedModel({ providers: undefined })], + })).toThrow("without exactly one provider mapping"); + + expect(() => llmgatewayProviders.parseModels({ + data: [llmGatewayMappedModel({ providers: [] })], + })).toThrow("without exactly one provider mapping"); + + expect(() => llmgatewayProviders.parseModels({ + data: [ + llmGatewayMappedModel(), + llmGatewayMappedModel({ + id: "azure/gpt-5.5", + name: "GPT-5.5 (Azure)", + providers: [{ providerId: "azure" }, { providerId: "openai" }], + }), + ], + })).toThrow("azure/gpt-5.5"); + + // Entries the sync drops anyway (pseudo-models, non-text) may lack a + // mapping without tripping the guard. + const parsed = llmgatewayProviders.parseModels({ + data: [ + llmGatewayMappedModel(), + llmGatewayMappedModel({ + id: "llmgateway/auto", + name: "Auto Route (LLM Gateway)", + providers: undefined, + }), + ], + }); + expect(parsed.map((model) => model.id)).toEqual(["anthropic/claude-fable-5"]); +}); + // Ensures catalog pagination preserves authentication and returns every page. test("fetches every page of the Merge Gateway catalog", async () => { const requests: string[] = []; diff --git a/providers/llmgateway-providers/logo.svg b/providers/llmgateway-providers/logo.svg index 4bda1089f2..bfa0a34470 100644 --- a/providers/llmgateway-providers/logo.svg +++ b/providers/llmgateway-providers/logo.svg @@ -1,4 +1,4 @@ - + From aab927636a065eabd2dc9a0cb358bbfc8fff0966 Mon Sep 17 00:00:00 2001 From: smakosh Date: Wed, 5 Aug 2026 18:32:41 +0200 Subject: [PATCH 08/12] fix: inherit lab descriptions, author toggle headers Review round 6: mapped factored resyncs no longer stamp a synthesized describeModel blurb as a sticky description override (unset keeps inheriting the lab text, matching merge-gateway/cortecs), and mapped sync writes now author the required leading wire-path comment on files that carry a toggle reasoning control via a new optional header on the translateModel result (an existing on-disk header always wins). Claude-Session: https://claude.ai/code/session_0131ZfUnTfJrCzygw3wE4bNF --- packages/core/src/sync/index.ts | 14 ++++++-- .../core/src/sync/providers/llmgateway.ts | 33 ++++++++++++------- packages/core/test/sync.test.ts | 27 +++++++++++++++ 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index e460806d86..4bf933c20d 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -96,7 +96,17 @@ export interface SyncProvider { existing(id: string): ExistingModel | undefined; authored(id: string): ExistingModel | undefined; }, - ): { id: string; model: SyncedModel; metadata?: { id: string; model: SyncedMetadata } } | undefined; + ): { + id: string; + model: SyncedModel; + metadata?: { id: string; model: SyncedMetadata }; + /** + * Leading comment block for the written file when it has none of its own + * (e.g. the wire-path header every toggle reasoning control requires). A + * header already present on the existing file always wins. + */ + header?: string; + } | undefined; } export interface SyncResult { @@ -299,7 +309,7 @@ export async function syncProvider( desired.set(relativePath, { model: parsed.data, - content: (existing.get(relativePath)?.header ?? "") + formatToml(parsed.data), + content: ((existing.get(relativePath)?.header || translated.header) ?? "") + formatToml(parsed.data), }); } diff --git a/packages/core/src/sync/providers/llmgateway.ts b/packages/core/src/sync/providers/llmgateway.ts index 065ad6aa08..4a53cfd86c 100644 --- a/packages/core/src/sync/providers/llmgateway.ts +++ b/packages/core/src/sync/providers/llmgateway.ts @@ -110,6 +110,22 @@ export const llmgateway = { }, } satisfies SyncProvider; +// Every toggle reasoning control requires a leading wire-path comment, and the +// sync runner only carries over headers that already exist on disk. Files this +// sync writes with a toggle get the gateway-wide default; a hand-written +// header on the existing file always wins. +const TOGGLE_HEADER = `# Toggle: $.reasoning_effort = "none" disables thinking; any other accepted +# value (or omitting the field) leaves it on. The gateway maps it to the +# deployment's thinking switch. +# https://docs.llmgateway.io/features/reasoning +`; + +function toggleHeader(model: SyncedModel) { + return model.reasoning_options?.some((option) => option.type === "toggle") + ? TOGGLE_HEADER + : undefined; +} + // The LLM Gateway provider: one entry per upstream provider mapping, addressed // the way the gateway accepts provider-pinned requests (`provider/model-id`). export const llmgatewayProviders = { @@ -155,7 +171,7 @@ export const llmgatewayProviders = { if (translated === undefined) { return undefined; } - return { id: model.id, model: translated }; + return { id: model.id, model: translated, header: toggleHeader(translated) }; }, sourceID(model) { return model.id; @@ -511,17 +527,10 @@ export function buildLLMGatewayMappedModel( { name: existing.name ?? model.name, attachment: existing.attachment, - description: existing.description ?? describeModel({ - id: model.id, - name: existing.name ?? model.name, - family: existing.family, - reasoning: existing.reasoning, - tool_call: existing.tool_call, - structured_output: existing.structured_output, - open_weights: existing.open_weights, - limit: factoredLimit, - modalities: existing.modalities, - }), + // No describeModel fallback: synthesizing a description here would + // stamp a sticky generic override on every name-pinned factored entry; + // leaving it unset keeps inheriting the lab text from the base. + description: existing.description, reasoning: existing.reasoning, reasoning_options: reasoningOptions, temperature: existing.temperature, diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index d7587dc33d..1f5b73b7a5 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -2474,6 +2474,33 @@ test("translates a none-only effort list into a reasoning toggle", () => { }); }); +test("never synthesizes a description on mapped factored resyncs", () => { + const model = buildLLMGatewayMappedModel(llmGatewayMappedModel(), { + base_model: "anthropic/claude-fable-5", + name: "Claude Fable 5 (Anthropic)", + }); + + // An unset description must keep inheriting the base's lab text instead of + // being stamped with a sticky synthesized override on the first resync. + expect(model).toBeDefined(); + expect(model!.description).toBeUndefined(); +}); + +test("authors the toggle wire-path header on mapped sync creates", () => { + const context = { existing: () => undefined, authored: () => undefined }; + + const toggle = llmgatewayProviders.translateModel(llmGatewayMappedModel({ + providers: [{ providerId: "anthropic", vision: true, tools: true, reasoning: true, reasoning_efforts: ["none"] }], + }), context); + expect(toggle?.header).toStartWith("# Toggle: $.reasoning_effort"); + + const effort = llmgatewayProviders.translateModel(llmGatewayMappedModel(), context); + expect(effort?.model).toMatchObject({ + reasoning_options: [{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }], + }); + expect(effort?.header).toBeUndefined(); +}); + test("keeps inheriting base output on factored resyncs without max_output", () => { const model = buildLLMGatewayMappedModel(llmGatewayMappedModel({ max_output: undefined }), { base_model: "anthropic/claude-fable-5", From ba74b69b15962d6d1f445b85ab78ca9aae1631aa Mon Sep 17 00:00:00 2001 From: smakosh Date: Wed, 5 Aug 2026 18:48:10 +0200 Subject: [PATCH 09/12] fix: keep mapping flags authoritative on resyncs Review round 7: mapped existing-entry resyncs (factored and full) now apply the deployment mapping's reasoning/vision/tools/structured-output flags with the same authority as creates, so the written booleans and the reasoning_options derived from them always move together and drift self-heals hourly; prior curation only fills in where the mapping is silent. Also documents in the together-ai/kimi-k2.6 seed header why that pin is intentionally weaker than Together's first-party row (the gateway serves it with tools/JSON off and a 32k output cap per its own e2e'd catalog mapping). Claude-Session: https://claude.ai/code/session_0131ZfUnTfJrCzygw3wE4bNF --- .../core/src/sync/providers/llmgateway.ts | 47 +++++++++++++------ packages/core/test/sync.test.ts | 23 +++++++++ .../models/together-ai/kimi-k2.6.toml | 5 ++ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/packages/core/src/sync/providers/llmgateway.ts b/packages/core/src/sync/providers/llmgateway.ts index 4a53cfd86c..c48bc0af97 100644 --- a/packages/core/src/sync/providers/llmgateway.ts +++ b/packages/core/src/sync/providers/llmgateway.ts @@ -459,7 +459,12 @@ export function buildLLMGatewayMappedModel( const rootID = model.id.split("/").slice(1).join("/"); const prompt = price(model.pricing.prompt); const completion = price(model.pricing.completion); + // The mapping's flag stays authoritative on resyncs too, so the written + // reasoning boolean and the reasoning_options derived from it always move + // together; prior curation only fills in when the mapping is silent, then + // the noisy supported_parameters signal as a last resort. const reasoning = mapping?.reasoning + ?? existing?.reasoning ?? (model.supported_parameters.includes("reasoning") || model.supported_parameters.includes("include_reasoning")); // The exact reasoning_effort values this deployment accepts. A deployment @@ -522,24 +527,28 @@ export function buildLLMGatewayMappedModel( input: existing.limit?.input, output: servedOutput ?? (canonicalOutputLimit(existing.base_model) !== undefined ? undefined : context), }; + // Deployment capability flags keep their create-path authority on + // resyncs: a mapping that gains or loses reasoning/vision/tools/structured + // outputs realigns the written flags together with the reasoning_options + // computed from them, instead of freezing stale curation forever. return factorBaseModel( existing.base_model, { name: existing.name ?? model.name, - attachment: existing.attachment, + attachment: mapping?.vision ?? existing.attachment, // No describeModel fallback: synthesizing a description here would // stamp a sticky generic override on every name-pinned factored entry; // leaving it unset keeps inheriting the lab text from the base. description: existing.description, - reasoning: existing.reasoning, + reasoning: mapping?.reasoning ?? existing.reasoning, reasoning_options: reasoningOptions, temperature: existing.temperature, - tool_call: existing.tool_call, - structured_output: existing.structured_output, + tool_call: mapping?.tools ?? existing.tool_call, + structured_output: model.structured_outputs ?? existing.structured_output, status: existing.status, interleaved, knowledge: existing.knowledge, - modalities: existing.modalities, + modalities: mapping?.vision === false ? deploymentModalities(model, false) : existing.modalities, limit: factoredLimit, cost, }, @@ -549,36 +558,46 @@ export function buildLLMGatewayMappedModel( } // Existing full model: refresh cost + limit, preserve curated metadata. + // Capability flags follow the same rule as the factored path above: the + // deployment mapping wins, curation fills the gaps. if (existing !== undefined) { + const resolved = { + attachment: mapping?.vision ?? existing.attachment ?? false, + tool_call: mapping?.tools ?? existing.tool_call ?? false, + structured_output: model.structured_outputs ?? existing.structured_output, + modalities: mapping?.vision === false + ? deploymentModalities(model, false) + : existing.modalities ?? deploymentModalities(model, mapping?.vision), + }; return { name: existing.name ?? model.name, description: existing.description ?? describeModel({ id: model.id, name: existing.name ?? model.name, family: existing.family, - reasoning: existing.reasoning, - tool_call: existing.tool_call, - structured_output: existing.structured_output, + reasoning, + tool_call: resolved.tool_call, + structured_output: resolved.structured_output, open_weights: existing.open_weights, limit, - modalities: existing.modalities ?? deploymentModalities(model, mapping?.vision), + modalities: resolved.modalities, }), family: existing.family, release_date: existing.release_date ?? dateFromTimestamp(model.created), last_updated: existing.last_updated ?? dateFromTimestamp(model.created), - attachment: existing.attachment ?? mapping?.vision ?? false, - reasoning: existing.reasoning ?? reasoning, + attachment: resolved.attachment, + reasoning, reasoning_options: reasoningOptions, temperature: existing.temperature ?? false, - tool_call: existing.tool_call ?? mapping?.tools ?? false, - structured_output: existing.structured_output ?? model.structured_outputs, + tool_call: resolved.tool_call, + structured_output: resolved.structured_output, knowledge: existing.knowledge, open_weights: existing.open_weights ?? false, status: existing.status, interleaved, cost, limit, - modalities: existing.modalities ?? deploymentModalities(model, mapping?.vision), + modalities: resolved.modalities, } satisfies SyncedFullModel; } diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 1f5b73b7a5..ac3d328c69 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -2451,6 +2451,7 @@ test("prefers the gateway max_output over authored output on mapped resyncs", () name: "Claude Fable 5 (Anthropic)", description: "Claude Fable 5 served by Anthropic", reasoning_options: [{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }], + structured_output: true, limit: { output: 32_000, }, @@ -2474,6 +2475,27 @@ test("translates a none-only effort list into a reasoning toggle", () => { }); }); +test("realigns capability flags from the mapping on mapped factored resyncs", () => { + // The deployment dropped reasoning and gained tools since the file was + // written: the resync must move the booleans and the reasoning controls + // together instead of clearing options under a frozen reasoning = true. + const model = buildLLMGatewayMappedModel(llmGatewayMappedModel({ + providers: [{ providerId: "anthropic", vision: true, tools: true, reasoning: false }], + }), { + base_model: "anthropic/claude-fable-5", + name: "Claude Fable 5 (Anthropic)", + reasoning: true, + reasoning_options: [{ type: "toggle" }], + tool_call: false, + }); + + expect(model).toMatchObject({ reasoning: false }); + expect(model!.reasoning_options).toBeUndefined(); + // Realigned to the mapping and now equal to the base, the stale + // tool_call = false override is dropped and inherits the base again. + expect(model!.tool_call).toBeUndefined(); +}); + test("never synthesizes a description on mapped factored resyncs", () => { const model = buildLLMGatewayMappedModel(llmGatewayMappedModel(), { base_model: "anthropic/claude-fable-5", @@ -2513,6 +2535,7 @@ test("keeps inheriting base output on factored resyncs without max_output", () = name: "Claude Fable 5 (Anthropic)", description: "Claude Fable 5 served by Anthropic", reasoning_options: [{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }], + structured_output: true, cost: { input: 10, output: 50, diff --git a/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml b/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml index 728a5fe692..dafa20cee2 100644 --- a/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml +++ b/providers/llmgateway-providers/models/together-ai/kimi-k2.6.toml @@ -2,6 +2,11 @@ # thinking switch. Together accepts any other effort string without validating # it and no tier changes reasoning length, so on/off is the only real control; # thinking streams back in reasoning_content. +# Intentionally weaker than Together's first-party row: the gateway's own +# together-ai mapping serves this pin with tool calls and JSON mode off and a +# 32k output cap, from gateway e2e against the deployment (no streaming +# content with tools; theopenco/llmgateway#2094) — this catalog mirrors what +# the gateway actually accepts and bills, and resyncs pick up any later fix. # https://api.llmgateway.io/v1/models?mapped=true providers[].reasoning_efforts (accessed 2026-08-04) base_model = "moonshotai/kimi-k2.6" name = "Kimi K2.6 (Together AI)" From 714a1c812295647053a271336a06369bc1c0fb22 Mon Sep 17 00:00:00 2001 From: smakosh Date: Wed, 5 Aug 2026 18:56:56 +0200 Subject: [PATCH 10/12] fix: realign vision modalities in both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 8: mapped resyncs no longer keep a stale text-only modalities override once the deployment's vision returns — a declared vision=true clears the override on factored entries (base image/pdf inputs inherit again) and recomputes from the served architecture on full entries, mirroring how vision=false already strips them; only a silent mapping leaves curated modalities untouched. Claude-Session: https://claude.ai/code/session_0131ZfUnTfJrCzygw3wE4bNF --- .../core/src/sync/providers/llmgateway.ts | 18 +++++++++--- packages/core/test/sync.test.ts | 28 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/core/src/sync/providers/llmgateway.ts b/packages/core/src/sync/providers/llmgateway.ts index c48bc0af97..f84fecc93e 100644 --- a/packages/core/src/sync/providers/llmgateway.ts +++ b/packages/core/src/sync/providers/llmgateway.ts @@ -548,7 +548,14 @@ export function buildLLMGatewayMappedModel( status: existing.status, interleaved, knowledge: existing.knowledge, - modalities: mapping?.vision === false ? deploymentModalities(model, false) : existing.modalities, + // Vision realigns modalities in both directions: false strips + // image/pdf, true clears any stale stripped override so the base's + // richer inputs inherit again; only a silent mapping keeps curation. + modalities: mapping?.vision === undefined + ? existing.modalities + : mapping.vision + ? undefined + : deploymentModalities(model, false), limit: factoredLimit, cost, }, @@ -565,9 +572,12 @@ export function buildLLMGatewayMappedModel( attachment: mapping?.vision ?? existing.attachment ?? false, tool_call: mapping?.tools ?? existing.tool_call ?? false, structured_output: model.structured_outputs ?? existing.structured_output, - modalities: mapping?.vision === false - ? deploymentModalities(model, false) - : existing.modalities ?? deploymentModalities(model, mapping?.vision), + // Same bidirectional vision rule as the factored path; with no base to + // inherit from, a declared vision recomputes from the served + // architecture instead of clearing. + modalities: mapping?.vision === undefined + ? existing.modalities ?? deploymentModalities(model, undefined) + : deploymentModalities(model, mapping.vision), }; return { name: existing.name ?? model.name, diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index ac3d328c69..37b3f0a862 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -2496,6 +2496,34 @@ test("realigns capability flags from the mapping on mapped factored resyncs", () expect(model!.tool_call).toBeUndefined(); }); +test("restores image input when vision returns on mapped resyncs", () => { + // The file was written while the deployment had no vision (text-only + // stripped modalities); vision is back, so the stale override must clear. + const factored = buildLLMGatewayMappedModel(llmGatewayMappedModel(), { + base_model: "anthropic/claude-fable-5", + name: "Claude Fable 5 (Anthropic)", + attachment: false, + modalities: { input: ["text"] }, + }); + expect(factored!.modalities).toBeUndefined(); + expect(factored!.attachment).toBeUndefined(); + + const full = buildLLMGatewayMappedModel(llmGatewayMappedModel({ + id: "acme/mystery-model", + name: "Mystery Model (Acme)", + family: undefined, + providers: [{ providerId: "acme", vision: true, tools: true, reasoning: false }], + }), { + name: "Mystery Model (Acme)", + attachment: false, + modalities: { input: ["text"], output: ["text"] }, + }); + expect(full).toMatchObject({ + attachment: true, + modalities: { input: ["text", "image"], output: ["text"] }, + }); +}); + test("never synthesizes a description on mapped factored resyncs", () => { const model = buildLLMGatewayMappedModel(llmGatewayMappedModel(), { base_model: "anthropic/claude-fable-5", From 5328716e80cc4917e814bca0a35cdb137b010ee2 Mon Sep 17 00:00:00 2001 From: smakosh Date: Wed, 5 Aug 2026 19:12:21 +0200 Subject: [PATCH 11/12] fix: local perplexity resolution, no zero limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 9: drop the perplexity entry from the shared CANONICAL_PROVIDER_PREFIXES (it would silently start factoring other hosts' standalone perplexity files) — the llmgateway sync now resolves lab IDs through resolveModelMetadataBaseModel, whose exact models/ path match covers perplexity without touching other providers. Full-row resyncs in both builders no longer fall back to the zero/absent reported context: authored limits only ever carry known-positive values, an authored 0 on disk counts as unusable, and a full row with no usable context anywhere fails loudly (skipping would hand the file to the delete-missing pass). Claude-Session: https://claude.ai/code/session_0131ZfUnTfJrCzygw3wE4bNF --- .../core/src/sync/providers/llmgateway.ts | 73 +++++++++++++------ .../core/src/sync/providers/openrouter.ts | 1 - packages/core/test/sync.test.ts | 40 ++++++++++ 3 files changed, 90 insertions(+), 24 deletions(-) diff --git a/packages/core/src/sync/providers/llmgateway.ts b/packages/core/src/sync/providers/llmgateway.ts index f84fecc93e..ad73781f23 100644 --- a/packages/core/src/sync/providers/llmgateway.ts +++ b/packages/core/src/sync/providers/llmgateway.ts @@ -5,13 +5,16 @@ import path from "node:path"; import { describeModel } from "../../describe.js"; import { inferKimiFamily, ModelFamilyValues } from "../../family.js"; import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js"; -import { factorBaseModel, resolveCanonicalBaseModel } from "./openrouter.js"; +import { factorBaseModel, resolveModelMetadataBaseModel } from "./openrouter.js"; const API_ENDPOINT = "https://api.llmgateway.io/v1/models"; // LLM Gateway names the originating lab in `family`; most already match the -// canonical prefixes understood by resolveCanonicalBaseModel. Alias the few that -// spell the lab differently. (Mirrors huggingface's CANONICAL_ORG_PREFIXES.) +// canonical prefixes understood by resolveModelMetadataBaseModel, and labs +// outside that shared table (e.g. perplexity) resolve through its exact +// `models/` path match without widening the OpenRouter prefix map for every +// other provider. Alias the few that spell the lab differently. (Mirrors +// huggingface's CANONICAL_ORG_PREFIXES.) const CANONICAL_FAMILY_ALIASES: Record = { mistral: "mistralai", moonshot: "moonshotai", @@ -271,7 +274,7 @@ function resolveLLMGatewayBaseModel(model: LLMGatewayModel, modelID = model.id) if (alias !== undefined) return alias; if (model.family === undefined) return undefined; const prefix = CANONICAL_FAMILY_ALIASES[model.family] ?? model.family; - return resolveCanonicalBaseModel(`${prefix}/${modelID}`); + return resolveModelMetadataBaseModel(`${prefix}/${modelID}`); } function inferFamily(model: LLMGatewayModel, name: string) { @@ -301,9 +304,10 @@ export function buildLLMGatewayModel( const reported = model.context_length ?? 0; // A missing/zero context must never be authored as limit.context = 0: // factored entries leave it unset and inherit the base, and unfactored - // creates are skipped entirely. + // creates are skipped entirely. An authored 0 on the existing file is + // equally unusable and must not be re-stamped. const servedContext = reported > 0 ? reported : undefined; - const context = servedContext ?? existing?.limit?.context; + const context = servedContext ?? (existing?.limit?.context || undefined); // The gateway is authoritative for the volatile, gateway-specific data — cost // and served limits. Its supported_parameters / modalities are too noisy to @@ -321,11 +325,15 @@ export function buildLLMGatewayModel( tiers: existing?.cost?.tiers, } : existing?.cost; - const limit = { - context: context ?? reported, - input: existing?.limit?.input, - output: existing?.limit?.output ?? context ?? reported, - }; + // Authored limits carry only known-positive values — never the zero/absent + // `reported` fallback. + const limit = context !== undefined + ? { + context, + input: existing?.limit?.input, + output: (existing?.limit?.output || undefined) ?? context, + } + : undefined; // Existing factored model: refresh cost + limit, keep every authored override // as-is (undefined fields keep inheriting the base model). @@ -368,6 +376,12 @@ export function buildLLMGatewayModel( // Existing full model: refresh cost + limit, preserve curated metadata. if (existing !== undefined) { + // With no usable context from the API or the file there is nothing valid + // to author, and skipping would hand the file to the delete-missing pass — + // fail loudly rather than write limit.context = 0. + if (limit === undefined) { + throw new Error(`LLM Gateway entry ${model.id} has no usable context to author`); + } return { name: existing.name ?? model.name, description: existing.description ?? describeModel({ @@ -417,6 +431,7 @@ export function buildLLMGatewayModel( if (servedContext === undefined) { return undefined; } + const createdLimit = limit ?? { context: servedContext, input: undefined, output: servedContext }; const { input, output } = defaultModalities(model); return { name: model.name, @@ -429,7 +444,7 @@ export function buildLLMGatewayModel( || model.supported_parameters.includes("tool_choice"), structured_output: model.structured_outputs ?? false, open_weights: false, - limit, + limit: createdLimit, modalities: { input, output }, }), family: inferFamily(model, model.name), @@ -443,7 +458,7 @@ export function buildLLMGatewayModel( structured_output: model.structured_outputs ?? false, open_weights: false, cost, - limit, + limit: createdLimit, modalities: { input, output }, } satisfies SyncedFullModel; } @@ -491,9 +506,10 @@ export function buildLLMGatewayMappedModel( : undefined; const reported = model.context_length ?? 0; // Same zero-context rule as the aggregated builder: never author 0, inherit - // on factored entries, skip unfactored creates. + // on factored entries, skip unfactored creates. An authored 0 on the + // existing file is equally unusable. const servedContext = reported > 0 ? reported : undefined; - const context = servedContext ?? existing?.limit?.context; + const context = servedContext ?? (existing?.limit?.context || undefined); const cost = prompt !== undefined && completion !== undefined ? { @@ -507,12 +523,16 @@ export function buildLLMGatewayMappedModel( : existing?.cost; // The gateway's max_output is the deployment's real served limit, so it wins // over inherited/authored values, unlike the aggregated view. - const servedOutput = model.max_output ?? existing?.limit?.output; - const limit = { - context: context ?? reported, - input: existing?.limit?.input, - output: servedOutput ?? context ?? reported, - }; + const servedOutput = (model.max_output || undefined) ?? (existing?.limit?.output || undefined); + // Authored limits carry only known-positive values — never the zero/absent + // `reported` fallback. + const limit = context !== undefined + ? { + context, + input: existing?.limit?.input, + output: servedOutput ?? context, + } + : undefined; // Existing factored model: refresh cost + limit, keep every authored override // as-is. Unlike the aggregated provider, the name override must be carried @@ -568,6 +588,12 @@ export function buildLLMGatewayMappedModel( // Capability flags follow the same rule as the factored path above: the // deployment mapping wins, curation fills the gaps. if (existing !== undefined) { + // With no usable context from the API or the file there is nothing valid + // to author, and skipping would hand the file to the delete-missing pass — + // fail loudly rather than write limit.context = 0. + if (limit === undefined) { + throw new Error(`LLM Gateway mapped entry ${model.id} has no usable context to author`); + } const resolved = { attachment: mapping?.vision ?? existing.attachment ?? false, tool_call: mapping?.tools ?? existing.tool_call ?? false, @@ -648,6 +674,7 @@ export function buildLLMGatewayMappedModel( if (servedContext === undefined) { return undefined; } + const createdLimit = limit ?? { context: servedContext, input: undefined, output: servedOutput ?? servedContext }; const { input, output } = deploymentModalities(model, mapping?.vision); return { name: model.name, @@ -659,7 +686,7 @@ export function buildLLMGatewayMappedModel( tool_call: mapping?.tools ?? false, structured_output: model.structured_outputs ?? false, open_weights: false, - limit, + limit: createdLimit, modalities: { input, output }, }), family: inferFamily(model, model.name), @@ -674,7 +701,7 @@ export function buildLLMGatewayMappedModel( structured_output: model.structured_outputs ?? false, open_weights: false, cost, - limit, + limit: createdLimit, modalities: { input, output }, } satisfies SyncedFullModel; } diff --git a/packages/core/src/sync/providers/openrouter.ts b/packages/core/src/sync/providers/openrouter.ts index f94966c27b..855789cf2e 100644 --- a/packages/core/src/sync/providers/openrouter.ts +++ b/packages/core/src/sync/providers/openrouter.ts @@ -34,7 +34,6 @@ const CANONICAL_PROVIDER_PREFIXES = { moonshotai: { provider: "moonshotai", metadata: "moonshotai" }, openai: { provider: "openai", metadata: "openai" }, nvidia: { provider: "nvidia", metadata: "nvidia" }, - perplexity: { provider: "perplexity", metadata: "perplexity" }, qwen: { provider: "alibaba", metadata: "alibaba" }, sakana: { provider: "sakana", metadata: "sakana" }, stepfun: { provider: "stepfun", metadata: "stepfun" }, diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index 37b3f0a862..b0e027acfb 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -2592,6 +2592,46 @@ test("skips unfactorable LLM Gateway creates without a served context", () => { expect(aggregated).toBeUndefined(); }); +test("factors perplexity entries without widening the shared prefix map", () => { + // The perplexity family resolves through resolveModelMetadataBaseModel's + // exact models/ path match; CANONICAL_PROVIDER_PREFIXES stays untouched so + // other hosts' standalone perplexity files keep their current behavior. + const model = buildLLMGatewayMappedModel(llmGatewayMappedModel({ + id: "perplexity/sonar-pro", + name: "Sonar Pro (Perplexity)", + family: "perplexity", + }), undefined); + + expect(model).toMatchObject({ base_model: "perplexity/sonar-pro" }); +}); + +test("refuses to author a zero context on full LLM Gateway resyncs", () => { + // Existing full rows (no base to inherit from) with nothing usable from the + // API or the file must fail loudly instead of being rewritten with + // limit.context = 0. + expect(() => buildLLMGatewayMappedModel(llmGatewayMappedModel({ + context_length: undefined, + max_output: undefined, + }), { + name: "Claude Fable 5 (Anthropic)", + })).toThrow("no usable context"); + + // An authored 0 on disk is as unusable as an absent context. + expect(() => buildLLMGatewayMappedModel(llmGatewayMappedModel({ + context_length: 0, + max_output: undefined, + }), { + name: "Claude Fable 5 (Anthropic)", + limit: { context: 0 }, + })).toThrow("no usable context"); + + expect(() => buildLLMGatewayModel(llmGatewayModel({ + context_length: undefined, + }), { + name: "Claude Fable 5", + })).toThrow("no usable context"); +}); + test("leaves context unset on mapped factored creates without a served context", () => { const model = buildLLMGatewayMappedModel(llmGatewayMappedModel({ context_length: undefined, From a6f9598b99085d6e177c7f9b890bf387d13e8945 Mon Sep 17 00:00:00 2001 From: smakosh Date: Wed, 5 Aug 2026 19:22:13 +0200 Subject: [PATCH 12/12] fix: merge deployment efforts with curated controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 10: deployment reasoning_efforts now own only the effort/toggle surface — curated non-effort controls such as budget_tokens (the same host's $.reasoning.max_tokens path, mirroring DigitalOcean's sync) survive from the existing file or the aggregated sibling instead of being wiped on every resync. Mapped creates also seed cost.tiers from the aggregated sibling's curated tiers, since the gateway API exposes none and the bulk sync would otherwise author tiered models at flat long-context rates; authored tiers still win on resync. Claude-Session: https://claude.ai/code/session_0131ZfUnTfJrCzygw3wE4bNF --- .../core/src/sync/providers/llmgateway.ts | 42 ++++++++++++------ packages/core/test/sync.test.ts | 43 +++++++++++++++++++ 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/packages/core/src/sync/providers/llmgateway.ts b/packages/core/src/sync/providers/llmgateway.ts index ad73781f23..2bccc5b0cc 100644 --- a/packages/core/src/sync/providers/llmgateway.ts +++ b/packages/core/src/sync/providers/llmgateway.ts @@ -233,23 +233,28 @@ const canonicalOutputLimitByID = new Map(); interface SiblingCuration { reasoning_options?: SyncedFullModel["reasoning_options"]; interleaved?: SyncedFullModel["interleaved"]; + cost_tiers?: NonNullable["tiers"]; } const siblingCurationByID = new Map(); -// The aggregated llmgateway catalog curates reasoning controls and the -// reasoning side-channel for the same gateway surface; mapped deployments of -// the same root model reuse them when the deployment does not declare its own. +// The aggregated llmgateway catalog curates reasoning controls, the reasoning +// side-channel, and context pricing tiers for the same gateway surface; mapped +// deployments of the same root model reuse them when the deployment does not +// declare its own. function siblingCuration(rootID: string): SiblingCuration { let curation = siblingCurationByID.get(rootID); if (curation === undefined) { const filePath = path.join(AGGREGATED_MODELS_DIR, `${rootID}.toml`); const authored = existsSync(filePath) - ? Bun.TOML.parse(readFileSync(filePath, "utf8")) as SiblingCuration + ? Bun.TOML.parse(readFileSync(filePath, "utf8")) as SiblingCuration & { + cost?: { tiers?: NonNullable["tiers"] }; + } : undefined; curation = { reasoning_options: authored?.reasoning_options?.length ? authored.reasoning_options : undefined, interleaved: authored?.interleaved, + cost_tiers: authored?.cost?.tiers, }; siblingCurationByID.set(rootID, curation); } @@ -490,16 +495,24 @@ export function buildLLMGatewayMappedModel( ? [{ type: "toggle" as const }] : [{ type: "effort" as const, values: mapping.reasoning_efforts }] : undefined; - // Deployment-declared efforts win; then non-empty curation on this file; - // then the aggregated llmgateway catalog's curated controls for the same - // root model on the same gateway surface. A curated [] counts as unknown so - // a bad first stamp is not sticky. Non-reasoning deployments carry none; - // the same applies to the interleaved reasoning side-channel. + // Deployment-declared efforts own the effort/toggle surface; curation falls + // back from non-empty options on this file to the aggregated llmgateway + // catalog's controls for the same root model on the same gateway surface. + // Curated non-effort controls (e.g. budget_tokens for $.reasoning.max_tokens, + // which this host serves regardless of the effort list) survive alongside + // deployment efforts instead of being wiped by them. A curated [] counts as + // unknown so a bad first stamp is not sticky. Non-reasoning deployments + // carry none; the same applies to the interleaved reasoning side-channel. const sibling = siblingCuration(rootID); + const curatedOptions = (existing?.reasoning_options?.length ? existing.reasoning_options : undefined) + ?? sibling.reasoning_options; const reasoningOptions = reasoning - ? deploymentOptions - ?? (existing?.reasoning_options?.length ? existing.reasoning_options : undefined) - ?? sibling.reasoning_options + ? deploymentOptions !== undefined + ? [ + ...(curatedOptions ?? []).filter((option) => option.type !== "effort" && option.type !== "toggle"), + ...deploymentOptions, + ] + : curatedOptions : undefined; const interleaved = reasoning ? existing?.interleaved ?? sibling.interleaved @@ -518,7 +531,10 @@ export function buildLLMGatewayMappedModel( reasoning: reasoning ? nonZeroPrice(model.pricing.internal_reasoning) ?? existing?.cost?.reasoning : existing?.cost?.reasoning, cache_read: nonZeroPrice(model.pricing.input_cache_read) ?? existing?.cost?.cache_read, cache_write: nonZeroPrice(model.pricing.input_cache_write) ?? existing?.cost?.cache_write, - tiers: existing?.cost?.tiers, + // The gateway API does not expose context pricing tiers, so authored + // tiers stick and new files seed from the aggregated sibling's curated + // tiers rather than silently under-stating long-context pricing. + tiers: existing?.cost?.tiers ?? sibling.cost_tiers, } : existing?.cost; // The gateway's max_output is the deployment's real served limit, so it wins diff --git a/packages/core/test/sync.test.ts b/packages/core/test/sync.test.ts index b0e027acfb..b558e741ea 100644 --- a/packages/core/test/sync.test.ts +++ b/packages/core/test/sync.test.ts @@ -2592,6 +2592,49 @@ test("skips unfactorable LLM Gateway creates without a served context", () => { expect(aggregated).toBeUndefined(); }); +test("keeps curated budget controls under deployment efforts", () => { + // Deployment efforts own only the effort/toggle surface: the hand-authored + // budget_tokens control (this host's $.reasoning.max_tokens path) survives + // the resync, while the stale effort list is replaced. + const model = buildLLMGatewayMappedModel(llmGatewayMappedModel(), { + base_model: "anthropic/claude-fable-5", + name: "Claude Fable 5 (Anthropic)", + reasoning_options: [ + { type: "effort", values: ["low", "high"] }, + { type: "budget_tokens", min: 1_024, max: 63_999 }, + ], + }); + expect(model!.reasoning_options).toEqual([ + { type: "budget_tokens", min: 1_024, max: 63_999 }, + { type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }, + ]); + + // Same merge on creates, with the budget coming from the aggregated + // sibling's curation for the same root model. + const seeded = buildLLMGatewayMappedModel(llmGatewayMappedModel({ + id: "anthropic/claude-sonnet-4-6", + name: "Claude Sonnet 4.6 (Anthropic)", + }), undefined); + expect(seeded!.reasoning_options).toEqual([ + { type: "budget_tokens", min: 1_024, max: 63_999 }, + { type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }, + ]); +}); + +test("seeds context pricing tiers from the aggregated sibling on creates", () => { + // The gateway API carries no tier pricing; without the sibling's curated + // tiers the bulk sync would author tiered models at flat long-context rates. + const model = buildLLMGatewayMappedModel(llmGatewayMappedModel({ + id: "openai/gpt-5.5", + name: "GPT-5.5 (OpenAI)", + family: "openai", + }), undefined); + + expect(model!.cost?.tiers).toEqual([ + { tier: { type: "context", size: 272_000 }, input: 10, output: 45, cache_read: 1 }, + ]); +}); + test("factors perplexity entries without widening the shared prefix map", () => { // The perplexity family resolves through resolveModelMetadataBaseModel's // exact models/ path match; CANONICAL_PROVIDER_PREFIXES stays untouched so