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
This commit is contained in:
Automation
2026-05-21 14:37:26 +02:00
parent 1ea91b6c71
commit 2a0d20945d
2 changed files with 121 additions and 1 deletions

View File

@@ -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<string, number> = {
"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<T>(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;

View File

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