fix(api): expose models.dev context windows in /v1/models (#1972)

Integrated into release/v3.7.9
This commit is contained in:
payne
2026-05-05 15:08:18 +03:00
committed by GitHub
parent 3d769e6a73
commit 64b1a20010
6 changed files with 174 additions and 36 deletions

View File

@@ -338,10 +338,6 @@ export async function getUnifiedModelsResponse(
continue;
}
// Get default context length from registry (provider-level default)
const registryEntry = REGISTRY[alias] || REGISTRY[canonicalProviderId];
const defaultContextLength = registryEntry?.defaultContextLength;
for (const model of providerModels) {
if (!providerSupportsModel(canonicalProviderId, model.id)) continue;
const aliasId = `${alias}/${model.id}`;
@@ -349,8 +345,6 @@ export async function getUnifiedModelsResponse(
const visionFields =
getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(model.id);
// Model-level context length overrides provider default
const contextLength = model.contextLength || defaultContextLength;
models.push({
id: aliasId,
@@ -360,7 +354,6 @@ export async function getUnifiedModelsResponse(
permission: [],
root: model.id,
parent: null,
...(contextLength ? { context_length: contextLength } : {}),
...(visionFields || {}),
});
@@ -378,7 +371,6 @@ export async function getUnifiedModelsResponse(
permission: [],
root: model.id,
parent: aliasId,
...(contextLength ? { context_length: contextLength } : {}),
...(providerVisionFields || {}),
});
}
@@ -813,7 +805,24 @@ export async function getUnifiedModelsResponse(
finalModels = filtered;
}
const enrichedModels = finalModels.map((model) => enrichCatalogModelEntry(model));
const getDefaultContextFallback = (model: any): number | undefined => {
if (typeof model.context_length === "number") return undefined;
if (model.owned_by === "combo") return undefined;
if (model.type && model.type !== "chat") return undefined;
const provider = typeof model.owned_by === "string" ? model.owned_by : null;
if (!provider) return undefined;
const canonicalId = aliasToProviderId[provider] || provider;
return REGISTRY[canonicalId]?.defaultContextLength;
};
const enrichedModels = finalModels.map((model) => {
const enriched = enrichCatalogModelEntry(model);
const fallbackContextLength = getDefaultContextFallback(enriched);
return fallbackContextLength
? { ...enriched, context_length: fallbackContextLength }
: enriched;
});
return Response.json(
{

View File

@@ -183,16 +183,21 @@ function resolveVisionCapability(
modalitiesInput: string[],
modalitiesOutput: string[]
): boolean | null {
if (typeof spec?.supportsVision === "boolean") return spec.supportsVision;
if (typeof registryModel?.supportsVision === "boolean") return registryModel.supportsVision;
const allModalities = [...modalitiesInput, ...modalitiesOutput].map((entry) =>
String(entry).toLowerCase()
);
if (typeof synced?.attachment === "boolean") {
return synced.attachment;
}
if (allModalities.some((entry) => entry.includes("image"))) {
return true;
}
if (typeof registryModel?.supportsVision === "boolean") return registryModel.supportsVision;
if (typeof spec?.supportsVision === "boolean") return spec.supportsVision;
return null;
}
@@ -215,18 +220,22 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo
) || "";
const supportsTools =
typeof spec?.supportsTools === "boolean"
? spec.supportsTools
: typeof registryModel?.toolCalling === "boolean"
? registryModel.toolCalling
: (synced?.tool_call ?? null);
synced?.tool_call ??
(typeof registryModel?.toolCalling === "boolean" ? registryModel.toolCalling : null) ??
(typeof spec?.supportsTools === "boolean" ? spec.supportsTools : null);
const supportsThinking =
typeof spec?.supportsThinking === "boolean"
? spec.supportsThinking
: typeof registryModel?.supportsReasoning === "boolean"
? registryModel.supportsReasoning
: (synced?.reasoning ?? null);
synced?.reasoning ??
(typeof registryModel?.supportsReasoning === "boolean"
? registryModel.supportsReasoning
: null) ??
(typeof spec?.supportsThinking === "boolean" ? spec.supportsThinking : null);
const contextWindow =
synced?.limit_context ??
(typeof registryModel?.contextLength === "number" ? registryModel.contextLength : null) ??
spec?.contextWindow ??
null;
return {
provider: resolved.provider,
@@ -247,14 +256,10 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo
attachment: synced?.attachment ?? null,
structuredOutput: synced?.structured_output ?? null,
temperature: synced?.temperature ?? null,
contextWindow:
spec?.contextWindow ??
(typeof registryModel?.contextLength === "number" ? registryModel.contextLength : null) ??
synced?.limit_context ??
null,
maxInputTokens: synced?.limit_input ?? spec?.contextWindow ?? null,
contextWindow,
maxInputTokens: synced?.limit_input ?? contextWindow,
maxOutputTokens:
spec?.maxOutputTokens ?? synced?.limit_output ?? MODEL_SPECS.__default__.maxOutputTokens,
synced?.limit_output ?? spec?.maxOutputTokens ?? MODEL_SPECS.__default__.maxOutputTokens,
defaultThinkingBudget: spec?.defaultThinkingBudget ?? 0,
thinkingBudgetCap: spec?.thinkingBudgetCap ?? null,
thinkingOverhead: spec?.thinkingOverhead ?? null,

View File

@@ -154,12 +154,16 @@ const MODELS_DEV_PROVIDER_MAP: Record<string, string[]> = {
openai: ["openai", "cx"], // cx = Codex (uses OpenAI models)
anthropic: ["anthropic", "cc"], // cc = Claude Code
google: ["gemini", "gemini-cli"],
"google-vertex": ["gemini", "vertex"],
"google-vertex-anthropic": ["anthropic", "cc", "vertex"],
vertex_ai: ["gemini", "vertex"],
deepseek: ["deepseek", "if"], // if = Qoder (routes through DeepSeek)
groq: ["groq"],
xai: ["xai"],
mistral: ["mistral"],
togetherai: ["together", "openrouter"],
together_ai: ["together", "openrouter"],
"fireworks-ai": ["fireworks"],
fireworks: ["fireworks"],
cerebras: ["cerebras"],
cohere: ["cohere"],
@@ -172,11 +176,25 @@ const MODELS_DEV_PROVIDER_MAP: Record<string, string[]> = {
perplexity: ["pplx", "perplexity"],
// OAuth / special providers
bedrock: ["kiro", "kr"], // kr = Kiro (AWS Bedrock)
"github-copilot": ["github", "gh"],
"github-models": ["github", "gh"],
kilo: ["kilocode", "kc", "kilo-gateway"],
kilocode: ["kilocode", "kc", "kilo-gateway"],
"kimi-for-coding": ["kimi-coding", "kmc", "kimi-coding-apikey", "kmca"],
opencode: ["opencode-zen"],
"opencode-go": ["opencode-go"],
// Additional providers that may overlap with OmniRoute
alibaba: ["ali", "alibaba", "bcp", "alicode", "alicode-intl"],
"alibaba-cn": ["ali", "alibaba", "bcp"],
"alibaba-coding-plan": ["alicode", "alicode-intl"],
"alibaba-coding-plan-cn": ["alicode"],
zai: ["zai", "glm"], // GLM models via Z.AI
"zai-coding-plan": ["zai", "glm"],
moonshotai: ["moonshot", "kimi"],
"moonshotai-cn": ["moonshot", "kimi"],
moonshot: ["moonshot", "kimi", "kimi-coding", "kmc", "kmca"],
minimax: ["minimax", "minimax-cn"],
"minimax-cn": ["minimax-cn"],
longcat: ["lc", "longcat"],
pollinations: ["pol", "pollinations"],
puter: ["pu", "puter"],
@@ -184,7 +202,6 @@ const MODELS_DEV_PROVIDER_MAP: Record<string, string[]> = {
scaleway: ["scw"],
ollama: ["ollamacloud", "ollama-cloud"],
blackbox: ["bb", "blackbox"],
kilocode: ["kc", "kilocode"],
cline: ["cl", "cline"],
cursor: ["cu", "cursor"],
github: ["gh", "github"],

View File

@@ -49,7 +49,7 @@ test.after(() => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("canonical model capability resolver merges models.dev data and keeps static overrides authoritative", () => {
test("canonical model capability resolver lets exact synced metadata override global specs", () => {
modelsDevSync.saveModelsDevCapabilities({
openai: {
"gpt-4o": buildCapability({
@@ -92,11 +92,11 @@ test("canonical model capability resolver merges models.dev data and keeps stati
const geminiHigh = modelCapabilities.getResolvedModelCapabilities(
"antigravity/gemini-3.1-pro-high"
);
assert.equal(geminiHigh.toolCalling, true);
assert.equal(geminiHigh.reasoning, true);
assert.equal(geminiHigh.supportsThinking, true);
assert.equal(geminiHigh.contextWindow, 1048576);
assert.equal(geminiHigh.maxOutputTokens, 65535);
assert.equal(geminiHigh.toolCalling, false);
assert.equal(geminiHigh.reasoning, false);
assert.equal(geminiHigh.supportsThinking, false);
assert.equal(geminiHigh.contextWindow, 1024);
assert.equal(geminiHigh.maxOutputTokens, 9999);
assert.equal(geminiHigh.defaultThinkingBudget, 24576);
assert.equal(
modelCapabilities.capThinkingBudget("antigravity/gemini-3.1-pro-high", 40000),

View File

@@ -14,6 +14,7 @@ const modelsDb = await import("../../src/lib/db/models.ts");
const combosDb = await import("../../src/lib/db/combos.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const modelsDevSync = await import("../../src/lib/modelsDevSync.ts");
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
async function resetStorage() {
@@ -689,6 +690,99 @@ test("v1 models catalog exposes provider-prefixed custom models, filters by raw
assert.equal(providerAlias.parent, "cl/demo-custom");
});
test("v1 models catalog uses synced models.dev limits instead of provider defaults", async () => {
await seedConnection("openai", { name: "openai-models-dev" });
try {
modelsDevSync.saveModelsDevCapabilities({
openai: {
"gpt-5.5": {
tool_call: true,
reasoning: true,
attachment: true,
structured_output: true,
temperature: true,
modalities_input: JSON.stringify(["text", "image"]),
modalities_output: JSON.stringify(["text"]),
knowledge_cutoff: null,
release_date: null,
last_updated: null,
status: null,
family: "gpt-5",
open_weights: false,
limit_context: 1050000,
limit_input: 1050000,
limit_output: 128000,
interleaved_field: null,
},
},
});
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
new Request("http://localhost/api/v1/models")
);
const body = (await response.json()) as any;
const model = body.data.find((item) => item.id === "openai/gpt-5.5");
assert.equal(response.status, 200);
assert.ok(model);
assert.equal(model.context_length, 1050000);
assert.equal(model.max_input_tokens, 1050000);
assert.equal(model.max_output_tokens, 128000);
} finally {
modelsDevSync.saveModelsDevCapabilities({});
}
});
test("v1 models catalog lets provider-specific synced limits beat global static specs", async () => {
await seedConnection("github", {
authType: "oauth",
name: "github-copilot-models-dev",
apiKey: null,
accessToken: "github-access",
});
try {
modelsDevSync.saveModelsDevCapabilities({
github: {
"gpt-5.5": {
tool_call: true,
reasoning: true,
attachment: true,
structured_output: true,
temperature: true,
modalities_input: JSON.stringify(["text", "image"]),
modalities_output: JSON.stringify(["text"]),
knowledge_cutoff: null,
release_date: null,
last_updated: null,
status: null,
family: "gpt-5",
open_weights: false,
limit_context: 400000,
limit_input: 272000,
limit_output: 128000,
interleaved_field: null,
},
},
});
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
new Request("http://localhost/api/v1/models")
);
const body = (await response.json()) as any;
const model = body.data.find((item) => item.id === "gh/gpt-5.5");
assert.equal(response.status, 200);
assert.ok(model);
assert.equal(model.context_length, 400000);
assert.equal(model.max_input_tokens, 272000);
assert.equal(model.max_output_tokens, 128000);
} finally {
modelsDevSync.saveModelsDevCapabilities({});
}
});
test("v1 models catalog returns 500 when model compatibility lookup crashes", async () => {
await seedConnection("openai", { name: "openai-compat-crash" });

View File

@@ -412,6 +412,19 @@ describe("modelsDevSync — mapProviderId", () => {
it("maps moonshot to the canonical provider plus Kimi aliases", () => {
assert.deepEqual(mapProviderId("moonshot"), ["moonshot", "kimi", "kimi-coding", "kmc", "kmca"]);
});
it("maps current models.dev provider IDs used by OmniRoute-compatible providers", () => {
assert.deepEqual(mapProviderId("github-copilot"), ["github", "gh"]);
assert.deepEqual(mapProviderId("kilo"), ["kilocode", "kc", "kilo-gateway"]);
assert.deepEqual(mapProviderId("kimi-for-coding"), [
"kimi-coding",
"kmc",
"kimi-coding-apikey",
"kmca",
]);
assert.deepEqual(mapProviderId("fireworks-ai"), ["fireworks"]);
assert.deepEqual(mapProviderId("togetherai"), ["together", "openrouter"]);
});
});
describe("modelsDevSync — fetchModelsDev (live API)", () => {