diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 7783bcfc6f..12fe5b302c 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -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( { diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index f2f0887eee..94dc5d9610 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -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, diff --git a/src/lib/modelsDevSync.ts b/src/lib/modelsDevSync.ts index 263e0918e1..14d8f41da3 100644 --- a/src/lib/modelsDevSync.ts +++ b/src/lib/modelsDevSync.ts @@ -154,12 +154,16 @@ const MODELS_DEV_PROVIDER_MAP: Record = { 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 = { 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 = { scaleway: ["scw"], ollama: ["ollamacloud", "ollama-cloud"], blackbox: ["bb", "blackbox"], - kilocode: ["kc", "kilocode"], cline: ["cl", "cline"], cursor: ["cu", "cursor"], github: ["gh", "github"], diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index b6d00dab6f..96d3f6c87c 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -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), diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index d1dd29d2af..1ca0db09c8 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -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" }); diff --git a/tests/unit/modelsDevSync.test.ts b/tests/unit/modelsDevSync.test.ts index c6257c4f48..fac19978c1 100644 --- a/tests/unit/modelsDevSync.test.ts +++ b/tests/unit/modelsDevSync.test.ts @@ -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)", () => {