From 2a0d20945de9bf75d9b48ecb5d38142ee8a0b3d3 Mon Sep 17 00:00:00 2001 From: Automation Date: Thu, 21 May 2026 14:37:26 +0200 Subject: [PATCH] fix(@omniroute/opencode-provider): include limit.context in model entries for OpenCode context window detection OpenCode determines model context windows by reading limit.context from opencode.json model entries. The provider was not emitting this field, so all OmniRoute models appeared with an unknown (0) context window in OpenCode, preventing proper compaction and overflow detection. - Add limit.context to OpenCodeModelEntry interface - Add OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS map (200K Claude / 1M Gemini) - Include limit.context when generating model entries - Extend fetchLiveModels to capture context_length from /v1/models - 5 new tests covering context length coverage, JSON serialisation, unknown model fallback, and live model fetch Closes #2481 --- @omniroute/opencode-provider/src/index.ts | 50 ++++++++++++- .../opencode-provider/tests/index.test.ts | 72 +++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/@omniroute/opencode-provider/src/index.ts b/@omniroute/opencode-provider/src/index.ts index 4e2f070a19..454f45012c 100644 --- a/@omniroute/opencode-provider/src/index.ts +++ b/@omniroute/opencode-provider/src/index.ts @@ -78,6 +78,20 @@ export interface ModelCapabilities { tool_call?: boolean; } +/** + * Default per-model context window sizes (tokens) for the curated default catalog. + * Matches the context lengths used by OmniRoute's provider registry. + */ +export const OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS: Record = { + "cc/claude-opus-4-7": 200_000, + "cc/claude-sonnet-4-6": 200_000, + "cc/claude-haiku-4-5-20251001": 200_000, + "claude-opus-4-5-thinking": 200_000, + "claude-sonnet-4-5-thinking": 200_000, + "gemini-3.1-pro-high": 1_000_000, + "gemini-3-flash": 1_000_000, +}; + /** * Default per-model capability hints for the curated default catalog. * @@ -141,6 +155,19 @@ export interface OpenCodeModelEntry { reasoning?: boolean; temperature?: boolean; tool_call?: boolean; + /** + * Context window limit. OpenCode reads this to determine usable context + * length for compaction, overflow detection, and router decisions. + * Maps to `limit.context` in OpenCode's provider config schema. + */ + limit?: { + /** Maximum context length in tokens (e.g. 200000 for Claude, 1000000 for Gemini). */ + context: number; + /** Optional per-request max input tokens. */ + input?: number; + /** Optional max output tokens. */ + output?: number; + }; } export interface OpenCodeProviderEntry { @@ -235,6 +262,14 @@ export function createOmniRouteProvider(options: OmniRouteProviderOptions): Open if (typeof merged.reasoning === "boolean") entry.reasoning = merged.reasoning; if (typeof merged.temperature === "boolean") entry.temperature = merged.temperature; if (typeof merged.tool_call === "boolean") entry.tool_call = merged.tool_call; + + // Include context window limit when known — OpenCode reads this to + // determine usable context length for compaction & overflow detection. + const contextLength = OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS[id]; + if (typeof contextLength === "number" && contextLength > 0) { + entry.limit = { context: contextLength }; + } + models[id] = entry; } @@ -445,6 +480,8 @@ async function fetchJSON(url: string, apiKey: string, timeoutMs: number): Pro export interface OmniRouteLiveModel { id: string; name: string; + /** Context window length in tokens (e.g. 200000 for Claude, 1000000 for Gemini). */ + contextLength?: number; } /** @@ -514,7 +551,18 @@ export async function fetchLiveModels( ? r.display_name.trim() : id; - models.push({ id, name: name || id }); + // Extract context_length from OmniRoute's /v1/models response. + // OmniRoute returns context_length in snake_case for both synced + // models (with inputTokenLimit) and custom models; the catalog's + // getDefaultContextFallback also injects it from registry defaults. + const contextLength = + typeof r.context_length === "number" && r.context_length > 0 + ? r.context_length + : typeof r.max_context_window_tokens === "number" && r.max_context_window_tokens > 0 + ? r.max_context_window_tokens + : undefined; + + models.push({ id, name: name || id, ...(contextLength ? { contextLength } : {}) }); } return models; diff --git a/@omniroute/opencode-provider/tests/index.test.ts b/@omniroute/opencode-provider/tests/index.test.ts index 324ae68bfa..9e14ba3bff 100644 --- a/@omniroute/opencode-provider/tests/index.test.ts +++ b/@omniroute/opencode-provider/tests/index.test.ts @@ -15,6 +15,7 @@ import { mergeIntoExistingConfig, normalizeBaseURL, OMNIROUTE_DEFAULT_MODEL_CAPABILITIES, + OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS, OMNIROUTE_DEFAULT_OPENCODE_MODELS, OMNIROUTE_MCP_DEFAULT_SCOPES, OMNIROUTE_PROVIDER_NPM, @@ -394,6 +395,77 @@ test("OMNIROUTE_DEFAULT_OPENCODE_MODELS includes cc/ prefixed models", () => { assert.ok(defaults.length >= 7, "should have at least 7 models"); }); +test("OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS covers every default model id", () => { + for (const id of OMNIROUTE_DEFAULT_OPENCODE_MODELS) { + const ctx = OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS[id]; + assert.ok( + typeof ctx === "number" && ctx > 0, + `default context_length for ${id} missing — should be a positive number` + ); + // Sanity: context should be at least 8K, at most 2M tokens + assert.ok(ctx >= 8_000, `${id} context_length ${ctx} seems too low`); + assert.ok(ctx <= 2_000_000, `${id} context_length ${ctx} seems too high`); + } +}); + +test("createOmniRouteProvider emits limit.context on default model entries", () => { + const provider = createOmniRouteProvider({ + baseURL: "http://localhost:20128", + apiKey: "sk_omniroute", + }); + const entry = provider.models["cc/claude-opus-4-7"]; + assert.ok(entry.limit, "model entry should have a limit field"); + assert.equal(entry.limit!.context, 200_000); +}); + +test("createOmniRouteProvider omits limit.context for unknown model ids", () => { + const provider = createOmniRouteProvider({ + baseURL: "http://localhost:20128", + apiKey: "sk_omniroute", + models: ["completely-unknown-model"], + }); + const entry = provider.models["completely-unknown-model"]; + assert.equal(entry.limit, undefined); +}); + +test("createOmniRouteProvider serialises limit.context to JSON", () => { + const provider = createOmniRouteProvider({ + baseURL: "http://localhost:20128", + apiKey: "sk_omniroute", + }); + const round = JSON.parse(JSON.stringify(provider)); + for (const id of OMNIROUTE_DEFAULT_OPENCODE_MODELS) { + const expectedContext = OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS[id]; + assert.equal( + round.models[id].limit?.context, + expectedContext, + `${id} should serialise limit.context=${expectedContext}` + ); + } +}); + +test("fetchLiveModels extracts context_length from snake_case field", async () => { + const { url, close } = await startMockServer(() => ({ + data: [ + { id: "cc/claude-opus-4-7", name: "Claude Opus 4.7", context_length: 200_000 }, + { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro", context_length: 1_000_000 }, + { id: "no-context", name: "No Context" }, + ], + })); + try { + const models = await fetchLiveModels(url, "sk_test"); + const claude = models.find((m) => m.id === "cc/claude-opus-4-7"); + assert.ok(claude, "claude model should be present"); + assert.equal(claude!.contextLength, 200_000); + const gemini = models.find((m) => m.id === "gemini-3.1-pro-high"); + assert.equal(gemini!.contextLength, 1_000_000); + const noCtx = models.find((m) => m.id === "no-context"); + assert.equal(noCtx!.contextLength, undefined); + } finally { + close(); + } +}); + test("OMNIROUTE_DEFAULT_MODEL_CAPABILITIES covers every default model id", () => { for (const id of OMNIROUTE_DEFAULT_OPENCODE_MODELS) { const caps = OMNIROUTE_DEFAULT_MODEL_CAPABILITIES[id];