Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 34 additions & 2 deletions src/app/api/v1/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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 } : {}),
});
}

Expand Down Expand Up @@ -813,7 +839,13 @@ export async function getUnifiedModelsResponse(
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 registryFallback = REGISTRY[canonicalId]?.defaultContextLength;
if (registryFallback) return registryFallback;

const modelId =
model.root || (typeof model.id === "string" ? model.id.split("/").pop() : undefined);
return modelId ? getTokenLimit(canonicalId, modelId) : getTokenLimit(canonicalId);
};

const enrichedModels = finalModels.map((model) => {
Expand Down
111 changes: 111 additions & 0 deletions tests/unit/models-catalog-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -892,3 +892,114 @@ 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 includes context_length for individual chat models", async () => {
await seedConnection("openai", { name: "openai-context" });
await seedConnection("claude", {
authType: "oauth",
name: "claude-context",
apiKey: null,
accessToken: "claude-access",
});

const response = await v1ModelsCatalog.getUnifiedModelsResponse(
new Request("http://localhost/api/v1/models")
);
const body = (await response.json()) as any;
const chatModels = body.data.filter((item) => !item.type || item.type === "chat");

assert.equal(response.status, 200);
assert.ok(chatModels.length > 0, "should have at least one chat model");

for (const model of chatModels) {
assert.ok(
typeof model.context_length === "number" && model.context_length > 0,
`chat model ${model.id} should have a positive context_length, got ${model.context_length}`
);
}
});

test("v1 models catalog falls back to getTokenLimit for models without registry defaultContextLength", async () => {
// opencode-go has defaultContextLength in REGISTRY, but we test the fallback
// path by verifying models from the synced path still get context_length
const connection = await seedConnection("opencode-go", {
name: "opencode-go-context-fallback",
apiKey: "go-key",
});

await modelsDb.replaceSyncedAvailableModelsForConnection("opencode-go", (connection as any).id, [
{
id: "test-model-no-context",
name: "Test Model No Context",
source: "imported",
supportedEndpoints: ["chat"],
},
]);

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 === "opencode-go/test-model-no-context");

assert.equal(response.status, 200);
assert.ok(model, "synced model should appear");
assert.ok(
typeof model.context_length === "number" && model.context_length > 0,
`synced model without inputTokenLimit should get context_length via getTokenLimit fallback, got ${model.context_length}`
);
});

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