fix(openrouter): report true upstream context_length for passthrough models (#3202) (#3207)

normalizeDiscoveredModels only copied record.inputTokenLimit, but OpenRouter
returns the window as context_length / top_provider.context_length, so every
synced model fell back to the 128K default. Read context_length (and
top_provider.max_completion_tokens for output) as a fallback.

Co-authored-by: pulyankote <pulyankote@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-05 02:50:51 -03:00
committed by GitHub
parent d3422c1c4d
commit 9bc2c89924
2 changed files with 92 additions and 6 deletions

View File

@@ -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 } : {}),
});

View File

@@ -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);
});