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];