diff --git a/src/lib/providerModels/modelDiscovery.ts b/src/lib/providerModels/modelDiscovery.ts index 5ab8a9fb9f..ad83c61cf6 100644 --- a/src/lib/providerModels/modelDiscovery.ts +++ b/src/lib/providerModels/modelDiscovery.ts @@ -14,6 +14,21 @@ function toNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +/** + * Resolve a positive integer token limit from a list of candidate values. + * Used to fall back across the differently-named context/output fields that + * upstream catalogs expose (e.g. OpenRouter uses `context_length` / + * `top_provider.context_length` instead of `inputTokenLimit`). See #3202. + */ +function firstPositiveNumber(...candidates: unknown[]): number | undefined { + for (const candidate of candidates) { + if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) { + return candidate; + } + } + return undefined; +} + export function isAutoFetchModelsEnabled(providerSpecificData: unknown): boolean { return asRecord(providerSpecificData).autoFetchModels !== false; } @@ -45,6 +60,23 @@ export function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel ).sort() : undefined; + const topProvider = asRecord(record.top_provider); + + // OpenRouter (and similar passthrough catalogs) report the context window as + // `context_length` / `top_provider.context_length`, not `inputTokenLimit`. + // Fall back across those names so synced models carry a real window instead + // of the provider default (128K). Explicit `inputTokenLimit` still wins. #3202 + const inputTokenLimit = firstPositiveNumber( + record.inputTokenLimit, + record.context_length, + record.contextLength, + topProvider.context_length + ); + const outputTokenLimit = firstPositiveNumber( + record.outputTokenLimit, + topProvider.max_completion_tokens + ); + deduped.set(id, { id, name, @@ -53,12 +85,8 @@ export function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel ? { apiFormat: toNonEmptyString(record.apiFormat)! } : {}), ...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}), - ...(typeof record.inputTokenLimit === "number" - ? { inputTokenLimit: record.inputTokenLimit } - : {}), - ...(typeof record.outputTokenLimit === "number" - ? { outputTokenLimit: record.outputTokenLimit } - : {}), + ...(typeof inputTokenLimit === "number" ? { inputTokenLimit } : {}), + ...(typeof outputTokenLimit === "number" ? { outputTokenLimit } : {}), ...(typeof record.description === "string" ? { description: record.description } : {}), ...(record.supportsThinking === true ? { supportsThinking: true } : {}), }); diff --git a/tests/unit/openrouter-context-length-3202.test.ts b/tests/unit/openrouter-context-length-3202.test.ts new file mode 100644 index 0000000000..ad3ec04895 --- /dev/null +++ b/tests/unit/openrouter-context-length-3202.test.ts @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery"; + +// Regression guard for #3202. +// +// OpenRouter's /api/v1/models returns the context window as `context_length` +// (and `top_provider.context_length`), NOT `inputTokenLimit`. The provider +// discovery path (`parseResponse: (data) => data.data || []`) passes these raw +// records straight into `normalizeDiscoveredModels`, so before the fix synced +// OpenRouter models never carried `inputTokenLimit` and `/v1/models` fell back +// to the 128K provider default for every model. + +test("#3202 maps OpenRouter context_length into inputTokenLimit", () => { + const [model] = normalizeDiscoveredModels([ + { id: "deepseek/deepseek-v4", context_length: 1048576 }, + ]); + + assert.equal(model.id, "deepseek/deepseek-v4"); + assert.equal(model.inputTokenLimit, 1048576); +}); + +test("#3202 preserves an explicit inputTokenLimit when already present", () => { + const [model] = normalizeDiscoveredModels([ + { id: "vendor/with-explicit", inputTokenLimit: 200000, context_length: 999999 }, + ]); + + // Explicit inputTokenLimit wins over the context_length fallback. + assert.equal(model.inputTokenLimit, 200000); +}); + +test("#3202 falls back to top_provider.context_length", () => { + const [model] = normalizeDiscoveredModels([ + { id: "vendor/top-provider-window", top_provider: { context_length: 262144 } }, + ]); + + assert.equal(model.inputTokenLimit, 262144); +}); + +test("#3202 maps OpenRouter output cap (top_provider.max_completion_tokens)", () => { + const [model] = normalizeDiscoveredModels([ + { + id: "vendor/with-output-cap", + context_length: 131072, + top_provider: { max_completion_tokens: 32768 }, + }, + ]); + + assert.equal(model.inputTokenLimit, 131072); + assert.equal(model.outputTokenLimit, 32768); +}); + +test("#3202 leaves inputTokenLimit unset when no window field is present", () => { + const [model] = normalizeDiscoveredModels([{ id: "vendor/no-window" }]); + + assert.equal(model.inputTokenLimit, undefined); +});