diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 12fe5b302c..747253635f 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -25,6 +25,8 @@ import { getCatalogDiagnosticsHeaders, } from "@/lib/modelMetadataRegistry"; import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; +import { parseModel } from "@omniroute/open-sse/services/model.ts"; +import { getTokenLimit } from "@omniroute/open-sse/services/contextManager.ts"; const FALLBACK_ALIAS_TO_PROVIDER = { ag: "antigravity", @@ -313,6 +315,30 @@ export async function getUnifiedModelsResponse( // Add combos first (they appear at the top) — only active ones for (const combo of combos) { if (combo.isActive === false || combo.isHidden === true) continue; + + // Calculate combo context length from its model targets. + // OpenCode and other clients read context_length from the catalog; without it + // they fall back to a conservative ~4000 token limit, causing truncation. + const comboContextLength = Array.isArray(combo.models) + ? combo.models + .filter((step) => step && step.kind === "model" && step.model) + .map((step) => { + const parsed = parseModel(step.model); + const provider = parsed.provider || (step as any).providerId || "unknown"; + const model = parsed.model || step.model; + return getTokenLimit(provider, model); + }) + .filter((limit): limit is number => typeof limit === "number" && limit > 0) + .reduce((min, limit) => Math.min(min, limit), Infinity) + : undefined; + + const effectiveContextLength = + typeof combo.context_length === "number" && combo.context_length > 0 + ? combo.context_length + : comboContextLength !== undefined && comboContextLength !== Infinity + ? comboContextLength + : undefined; + models.push({ id: combo.name, object: "model", @@ -321,7 +347,7 @@ export async function getUnifiedModelsResponse( permission: [], root: combo.name, parent: null, - ...(combo.context_length ? { context_length: combo.context_length } : {}), + ...(effectiveContextLength !== undefined ? { context_length: effectiveContextLength } : {}), }); } diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 1ca0db09c8..d0036c91b9 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -892,3 +892,57 @@ test("v1 models catalog adds managed fallback models for Claude-compatible provi assert.ok(ids.has("ccdemo/claude-opus-4-6")); assert.equal(ids.has("ccdemo/claude-sonnet-4-6"), false); }); + +test("v1 models catalog auto-calculates combo context_length from targets when not set manually", async () => { + await seedConnection("openai", { name: "openai-auto-context" }); + await seedConnection("claude", { + authType: "oauth", + name: "claude-auto-context", + apiKey: null, + accessToken: "claude-access", + }); + + // Create a combo with targets having different context limits. + // openai/gpt-4o context = 128000, claude/claude-sonnet-4-6 = 200000. + // The combo should expose context_length = min = 128000. + const combo = await combosDb.createCombo({ + name: "auto-context-combo", + strategy: "priority", + models: ["openai/gpt-4o", "claude/claude-sonnet-4-6"], + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const comboModel = body.data.find((item) => item.id === "auto-context-combo"); + + assert.equal(response.status, 200); + assert.ok(comboModel); + assert.equal( + comboModel.context_length, + 128000, + "combo context_length should be the MIN of all target model limits" + ); +}); + +test("v1 models catalog prefers manual combo context_length over auto-calculated", async () => { + await seedConnection("openai", { name: "openai-manual-context" }); + + const combo = await combosDb.createCombo({ + name: "manual-context-combo", + strategy: "priority", + models: ["openai/gpt-4o"], + }); + await combosDb.updateCombo((combo as any).id, { context_length: 64000 }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const comboModel = body.data.find((item) => item.id === "manual-context-combo"); + + assert.equal(response.status, 200); + assert.ok(comboModel); + assert.equal(comboModel.context_length, 64000, "manual context_length should override auto-calc"); +});