From dbd70ddd1fbe545803c2cdf39afcdb8982fd7e4a Mon Sep 17 00:00:00 2001 From: Hernan Javier Ardila Sanchez Date: Mon, 8 Jun 2026 23:50:21 +0200 Subject: [PATCH] fix(catalog): make combos auto-compute context_length for any provider id form (#3417) Integrated into release/v3.8.17 --- .env.example | 13 + docs/fix-opencode-context.md | 108 +++++ docs/reference/ENVIRONMENT.md | 13 + scripts/ad-hoc/regen-opencode-config.ts | 23 + .../cli-helper/config-generator/opencode.ts | 398 +++++++++++++++++- src/lib/modelsDevSync.ts | 68 ++- .../unit/cli-helper/config-generator.test.ts | 179 ++++++++ .../unit/opencode-zen-alias-combo-e2e.test.ts | 208 +++++++++ 8 files changed, 996 insertions(+), 14 deletions(-) create mode 100644 docs/fix-opencode-context.md create mode 100644 scripts/ad-hoc/regen-opencode-config.ts create mode 100644 tests/unit/opencode-zen-alias-combo-e2e.test.ts diff --git a/.env.example b/.env.example index ccabb13ebc..29a3aeffe8 100644 --- a/.env.example +++ b/.env.example @@ -1468,3 +1468,16 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo) # QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa # QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos + +# ─── OpenCode config regeneration (scripts/ad-hoc/regen-opencode-config.ts) ─── +# Base URL of the OmniRoute instance to query for /v1/models when regenerating +# an opencode.json with accurate limit.context values. Used by: +# scripts/ad-hoc/regen-opencode-config.ts. Default: http://localhost:20128 +# OMNIROUTE_URL= +# API key to authenticate against the OmniRoute /v1/models endpoint. Falls back +# to OPENCODE_API_KEY when unset. Used by: scripts/ad-hoc/regen-opencode-config.ts. +# OMNIROUTE_KEY= +# OpenCode-style API key (sk-...) for the regenerated opencode.json. Used by: +# scripts/ad-hoc/regen-opencode-config.ts. Falls back to OMNIROUTE_KEY. +# OPENCODE_API_KEY= + diff --git a/docs/fix-opencode-context.md b/docs/fix-opencode-context.md new file mode 100644 index 0000000000..47b3274942 --- /dev/null +++ b/docs/fix-opencode-context.md @@ -0,0 +1,108 @@ +# Fix: OpenCode combo context window detection + +## Bug + +OpenCode v1's `opencode.json` requires an explicit `limit.context` (and +`limit.output`) for every model. Without these fields, OpenCode's heuristic +kicks in and reports a wrong context window. + +The OmniRoute `/v1/models` catalog is the single source of truth for context +windows. The bug was that some combos were published to the catalog WITHOUT +a computed `context_length`, so any OpenCode client pulling the catalog got +no answer for them. + +## Root cause + +The "Opencode FREE Omni" combo references four `opencode/` targets +(`big-pickle`, `deepseek-v4-flash-free`, `minimax-m3-free`, +`nemotron-3-super-free`). The catalog's `buildComboCatalogMetadata` computes +the combo's `context_length` as the **minimum of its targets' known +contexts**, but every target's lookup was returning `null`. + +The lookup chain in `getCanonicalModelMetadata`: + +1. `getSyncedCapability("opencode", "big-pickle")` → returns `null` because + the DB row is stored under `provider = "opencode-zen"`, not `"opencode"`. +2. `getRegistryModel("opencode", "big-pickle")` → returns `null` because + `PROVIDER_MODELS["opencode"]` (and `["oc"]`) don't have static entries + for these models. +3. `getModelSpec("big-pickle")` → returns `null` (no static spec). + +All three lookups fail → metadata is `null` → combo has no context. + +Why was the DB row under `"opencode-zen"` and not `"opencode"`? +`MODELS_DEV_PROVIDER_MAP["opencode"]` was `["opencode-zen"]` only — a +historical one-way mapping. New syncs continued to write under the alias +side of the pair, while the catalog & combo targets use the canonical id +side. + +## Fix (3 layers, in this order) + +### 1. `src/lib/modelsDevSync.ts` — symmetric `mapProviderId` mapping + +```ts +// Before: +opencode: ["opencode-zen"], +"opencode-go": ["opencode-go"], + +// After: +opencode: ["opencode", "opencode-zen"], +"opencode-go": ["opencode-go", "opencode-zen"], +``` + +Now models.dev data lands under BOTH the canonical id and the historical +alias, so any future sync keeps the lookup paths in sync. + +### 2. `src/lib/modelsDevSync.ts` — alias-aware fallback in `getSyncedCapability` + +The runtime fix that takes effect **immediately**, without waiting for a +re-sync. Existing DB rows under `"opencode-zen"` are now found when +callers pass `"opencode"` (or vice-versa). + +```ts +const SYNCED_CAPABILITY_FALLBACK_ALIASES: Record = { + opencode: ["opencode-zen"], + "opencode-zen": ["opencode"], + "opencode-go": ["opencode-zen"], +}; +``` + +### 3. `src/lib/cli-helper/config-generator/opencode.ts` — drop the hardcoded 128K fallback + +The previous band-aid hardcoded `FALLBACK_CONTEXT_LENGTH = 128_000` for +models whose context was unknown. That's wrong: combos like "Opencode +FREE Omni" should report **200K** (the min of their 200K targets), not +the universal 128K default. + +The generator now: + +- Uses the catalog as the **single source of truth** for `limit.context`. +- Emits the model **without** `limit.context` if the catalog has no entry + — OpenCode's own heuristic applies and the user can fix the upstream. +- **Throws** if the catalog fetch fails outright — the CLI catches and + surfaces the error. We never silently write a stale opencode.json. + +## Deployment steps (for the operator) + +1. Pull the latest from the branch: + `git fetch && git checkout fix/opencode-context-window` +2. Rebuild OmniRoute: `npm run build` +3. Restart the OmniRoute server (kill the running process and re-run). +4. Trigger a models.dev sync from the Settings → Models.dev panel + (or POST `/api/settings/models-dev` with `{"action": "sync"}`). +5. Re-run the opencode.json generator (the CLI command or the + `scripts/regen-opencode-config.ts` script). + +After step 5, `Opencode FREE Omni`'s `limit.context` will be **200000** +and every other combo will reflect its targets' min context. + +## Verification + +After the rebuild, hit `GET /v1/models` and inspect the response: + +```bash +curl -s http://localhost:20128/v1/models \ + -H "Authorization: Bearer $API_KEY" \ + | jq '.data[] | select(.id == "Opencode FREE Omni") | .context_length' +# → 200000 +``` diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 7fae6bd3a1..595a764a8c 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -970,3 +970,16 @@ The following variables appeared in previous versions of `.env.example` but have | ------------------------- | ------------------------ | ------------------- | ------------------------------------------------------ | | `APP_LOG_RETENTION_DAYS` | `90` | `7` | ✅ Removed misleading value; documented `7` as default | | `CALL_LOG_RETENTION_DAYS` | `90` | `7` | ✅ Removed misleading value; documented `7` as default | + +### OpenCode config regeneration (ad-hoc tooling) + +Used by `scripts/ad-hoc/regen-opencode-config.ts` to regenerate an `opencode.json` +with accurate `limit.context` and `limit.output` values pulled from the running +OmniRoute instance. None of these are required for normal operation — the script +is developer tooling only. + +| Variable | Default | Source File | Description | +| ------------------- | ------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_URL` | `http://localhost:20128` | `scripts/ad-hoc/regen-opencode-config.ts` | Base URL of the OmniRoute instance to query for `/v1/models`. | +| `OMNIROUTE_KEY` | _(unset)_ | `scripts/ad-hoc/regen-opencode-config.ts` | API key to authenticate against the OmniRoute `/v1/models` endpoint. Falls back to `OPENCODE_API_KEY` when unset. | +| `OPENCODE_API_KEY` | _(unset)_ | `scripts/ad-hoc/regen-opencode-config.ts` | OpenCode-style API key (`sk-...`) written into the regenerated `opencode.json`. Falls back to `OMNIROUTE_KEY` when unset. | diff --git a/scripts/ad-hoc/regen-opencode-config.ts b/scripts/ad-hoc/regen-opencode-config.ts new file mode 100644 index 0000000000..dcb295ea7a --- /dev/null +++ b/scripts/ad-hoc/regen-opencode-config.ts @@ -0,0 +1,23 @@ +/** + * One-shot regen of ~/.config/opencode/opencode.json from the live + * OmniRoute /v1/models catalog. Run after a catalog change to refresh + * the opencode client. + * + * Usage: bun run scripts/regen-opencode-config.ts + * or npx tsx scripts/regen-opencode-config.ts + */ +import { generateOpencodeConfig } from "../src/lib/cli-helper/config-generator/opencode.ts"; + +const baseURL = process.env.OMNIROUTE_URL ?? "http://localhost:20128"; +const apiKey = process.env.OMNIROUTE_KEY ?? process.env.OPENCODE_API_KEY ?? ""; + +if (!apiKey) { + console.error( + "OMNIROUTE_KEY (or OPENCODE_API_KEY) env var is required. " + + "Find it in OmniRoute dashboard → Settings → API Keys." + ); + process.exit(1); +} + +const out = await generateOpencodeConfig({ baseUrl: baseURL, apiKey }); +console.log(out); diff --git a/src/lib/cli-helper/config-generator/opencode.ts b/src/lib/cli-helper/config-generator/opencode.ts index 9849c1c8da..d817e009fb 100644 --- a/src/lib/cli-helper/config-generator/opencode.ts +++ b/src/lib/cli-helper/config-generator/opencode.ts @@ -1,18 +1,399 @@ import path from "node:path"; import os from "node:os"; +import fs from "node:fs"; const CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "opencode.json"); -export function generateOpencodeConfig(options: { +/** + * OpenAI-compatible model entry — subset of fields the /v1/models endpoint + * returns. Only the fields we need to emit `limit.context` / `limit.output` + * are typed. + */ +interface CatalogModelEntry { + id: string; + owned_by?: string; + /** OpenAI-compatible field name; some upstreams return this. */ + context_length?: number; + max_context_window_tokens?: number; + /** Optional max output tokens; used to populate `limit.output`. */ + max_output_tokens?: number; + max_input_tokens?: number; + /** Optional structured capability flags. */ + capabilities?: { + attachment?: boolean; + reasoning?: boolean; + temperature?: boolean; + tool_calling?: boolean; + vision?: boolean; + }; +} + +/** Per-model override carried over from the user's existing opencode.json. */ +interface ExistingModelEntry { + name?: string; + attachment?: boolean; + reasoning?: boolean; + temperature?: boolean; + tool_call?: boolean; + limit?: { context?: number; input?: number; output?: number }; + // Allow arbitrary other keys to round-trip through untouched. + [key: string]: unknown; +} + +interface ExistingProviderEntry { + name?: string; + npm?: string; + options?: Record; + models?: Record; + [key: string]: unknown; +} + +interface ExistingConfig { + $schema?: string; + provider?: Record; + model?: string; + small_model?: string; + [key: string]: unknown; +} + +export interface CatalogFetchResult { + /** Models keyed by id, as returned by /v1/models. */ + byId: Map; + /** Provider ids that had at least one model in the catalog. */ + providerIds: Set; + /** Models that have a usable `context_length` (positive finite number). */ + modelsWithContext: number; + /** Total models returned by the catalog. */ + total: number; +} + +/** + * Fetch the live `/v1/models` catalog from OmniRoute. The catalog is the + * single source of truth for context windows — opencode.json must NOT + * hardcode values, otherwise we drift from the provider's actual limits. + */ +export async function fetchOmniRouteCatalog( + baseUrl: string, + apiKey: string, + timeoutMs = 5_000 +): Promise { + const cleanBase = baseUrl.replace(/\/+$/, ""); + const baseURL = cleanBase.endsWith("/v1") ? cleanBase : `${cleanBase}/v1`; + + const result: CatalogFetchResult = { + byId: new Map(), + providerIds: new Set(), + modelsWithContext: 0, + total: 0, + }; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`${baseURL}/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error( + `OmniRoute /v1/models returned ${response.status} ${response.statusText}` + ); + } + const body = (await response.json()) as unknown; + const list: unknown[] = Array.isArray(body) + ? body + : body && typeof body === "object" && Array.isArray((body as { data?: unknown[] }).data) + ? ((body as { data: unknown[] }).data as unknown[]) + : []; + for (const raw of list) { + if (!raw || typeof raw !== "object") continue; + const r = raw as CatalogModelEntry; + if (typeof r.id !== "string" || !r.id.trim()) continue; + const id = r.id.trim(); + result.byId.set(id, r); + result.total += 1; + if (typeof r.owned_by === "string" && r.owned_by.length > 0) { + result.providerIds.add(r.owned_by); + } + const candidates = [r.context_length, r.max_context_window_tokens]; + if (candidates.some((c) => typeof c === "number" && Number.isFinite(c) && c > 0)) { + result.modelsWithContext += 1; + } + } + } finally { + clearTimeout(timer); + } + return result; +} + +/** + * Resolve the context length for a single catalog entry. + * Prefers `context_length` (OpenAI-compatible) over `max_context_window_tokens` + * (llama.cpp-style). Returns `undefined` when neither is a positive integer — + * this is intentional: we MUST NOT invent a default, because combos whose + * targets' contexts are unknown to the catalog will mis-report a context + * window. The user can override per-model via `limit.context` in their + * existing opencode.json, or fix the upstream catalog. + */ +function resolveContextLength(entry: CatalogModelEntry): number | undefined { + const candidates = [entry.context_length, entry.max_context_window_tokens]; + for (const c of candidates) { + if (typeof c === "number" && Number.isFinite(c) && c > 0) return c; + } + return undefined; +} + +/** + * Build the entry that ends up under `provider..models[id]` in the + * emitted opencode.json. Precedence: + * + * 1. Existing manual override in the user's opencode.json (`limit.context`). + * 2. Catalog `context_length` / `max_context_window_tokens`. + * + * If neither is available, the entry is returned WITHOUT a `limit` block so + * the caller can decide whether to skip the model entirely or surface a + * warning. We never fabricate a default context window. + */ +function buildModelEntry( + id: string, + catalog: CatalogModelEntry | undefined, + existing: ExistingModelEntry | undefined +): ExistingModelEntry { + // Carry over user-set "name" first; fall back to id when absent. + const name = (typeof existing?.name === "string" && existing.name.trim()) || id; + + const entry: ExistingModelEntry = { name }; + + // Round-trip capability flags from the existing config (if any). + for (const flag of ["attachment", "reasoning", "temperature", "tool_call"] as const) { + const value = existing?.[flag]; + if (typeof value === "boolean") entry[flag] = value; + } + + // Preserve any extra top-level keys the user set (variants, headers, etc.) + // that we don't model explicitly. + if (existing) { + for (const [k, v] of Object.entries(existing)) { + if (k === "name" || k === "limit") continue; + if (v === undefined) continue; + if (!(k in entry)) entry[k] = v; + } + } + + // Resolve the context window. Honor an explicit user override, then fall + // back to the catalog. We do NOT synthesize a default — if the catalog + // is unaware of a model's window, the opencode.json will simply omit + // `limit.context` for that model and OpenCode's own heuristics apply. + // (OpenCode v1 defaults to 128K when `limit.context` is missing.) + const userLimit = existing?.limit?.context; + const catalogLimit = catalog ? resolveContextLength(catalog) : undefined; + const context = + typeof userLimit === "number" && userLimit > 0 + ? userLimit + : catalogLimit; + + // `limit.output` is REQUIRED by OpenCode's v1 provider schema (configV1). + // Use the catalog's max_output_tokens when available; otherwise fall + // back to the user's existing `limit.output` and finally to a small + // default (8K) so OpenCode never errors on a totally missing output cap. + // We do NOT default context — context is a property of the model and + // we have no business guessing. Output is a per-request setting and a + // small default is harmless when truly unknown. + const userOutput = existing?.limit?.output; + const catalogOutput = + catalog && typeof catalog.max_output_tokens === "number" && catalog.max_output_tokens > 0 + ? catalog.max_output_tokens + : undefined; + const output = + typeof userOutput === "number" && userOutput > 0 + ? userOutput + : catalogOutput ?? 8_192; + + // Emit `limit` only if we have at least one of context/output. We never + // emit a half-baked limit block with only an `output` (would be misleading). + if (typeof context === "number" || typeof userOutput === "number" || typeof catalogOutput === "number") { + const limit: { context?: number; input?: number; output?: number } = {}; + if (typeof context === "number") limit.context = context; + if (typeof userOutput === "number" || typeof catalogOutput === "number") { + limit.output = + typeof userOutput === "number" && userOutput > 0 + ? userOutput + : catalogOutput ?? 8_192; + } + const userInput = existing?.limit?.input; + if (typeof userInput === "number" && userInput > 0) { + limit.input = userInput; + } else if (catalog) { + const maxInput = catalog.max_input_tokens; + if (typeof maxInput === "number" && maxInput > 0) limit.input = maxInput; + } + entry.limit = limit; + } + + return entry; +} + +/** + * Load the user's current opencode.json (if any) so we can preserve names, + * capability flags, and explicit `limit.context` overrides. JSONC comments + * are not supported — we parse as plain JSON. If parsing fails, we fall + * back to an empty config; the resulting write will lose comments, but + * that matches the existing CLI behavior of `config set opencode`. + */ +function loadExistingConfig(): ExistingConfig { + try { + if (!fs.existsSync(CONFIG_PATH)) return {}; + const raw = fs.readFileSync(CONFIG_PATH, "utf8"); + return JSON.parse(raw) as ExistingConfig; + } catch { + return {}; + } +} + +export interface GenerateOpencodeOptions { + baseUrl: string; + apiKey: string; + model?: string; + /** + * Override the default `provider.id` used in the generated config. + * Defaults to `"omniroute"`. + */ + providerId?: string; + /** + * If `true` (default), the generator fetches the live `/v1/models` catalog + * so every model entry has an explicit `limit.context`. The catalog is the + * single source of truth for context windows; we never invent defaults. + * + * When the catalog request fails, the generator throws — opencode.json must + * not be emitted with stale or fabricated values. The CLI can catch the + * error and decide whether to surface it to the user. + */ + fetchCatalog?: boolean; + /** + * Request timeout for the catalog fetch, in milliseconds. Defaults to 5s. + */ + catalogTimeoutMs?: number; +} + +/** + * Generate a full `opencode.json` document for OmniRoute. The catalog is the + * single source of truth for context windows — we never hardcode values. + * + * Behavior: + * - Preserves the user's existing provider name, npm, options, and + * per-model names / capability flags. + * - For each existing model id, the catalog's `context_length` wins + * unless the user already set an explicit `limit.context` in the file. + * - For each catalog model id the user did NOT have, a new entry is + * added with `limit.context` populated when the catalog has it. + * - If the catalog has no context for a model AND the user has no + * override, the model is emitted WITHOUT a `limit.context` field. + * OpenCode's own heuristic (typically 128K) applies. + * - Throws if the catalog fetch fails — the user must fix the upstream + * before we can generate a reliable opencode.json. + */ +export async function generateOpencodeConfig( + options: GenerateOpencodeOptions +): Promise { + const cleanBase = options.baseUrl.replace(/\/+$/, ""); + const baseURL = cleanBase.endsWith("/v1") ? cleanBase : `${cleanBase}/v1`; + + const providerId = options.providerId?.trim() || "omniroute"; + const fetchCatalog = options.fetchCatalog !== false; + const timeoutMs = options.catalogTimeoutMs ?? 5_000; + + // Fetch live catalog. The catalog is the source of truth — if it fails, + // we refuse to write an opencode.json that could mislead OpenCode into + // picking the wrong context window. + let catalogById = new Map(); + if (fetchCatalog) { + const result = await fetchOmniRouteCatalog(baseURL, options.apiKey, timeoutMs); + catalogById = result.byId; + } else { + throw new Error( + "fetchCatalog=false is not supported. The catalog is the single source " + + "of truth for context windows — without it, opencode.json would carry " + + "fabricated or stale values." + ); + } + + // Load existing config so we preserve names, capability flags, and any + // explicit `limit.context` overrides the user has set. + const existing = loadExistingConfig(); + const existingProvider = existing.provider?.[providerId]; + const existingModels = (existingProvider?.models ?? {}) as Record; + + // Build the merged model map: catalog first, then existing (so existing + // values can win for matching ids). + const mergedIds = new Set([...catalogById.keys(), ...Object.keys(existingModels)]); + + const mergedModels: Record = {}; + for (const id of mergedIds) { + mergedModels[id] = buildModelEntry(id, catalogById.get(id), existingModels[id]); + } + + const provider: Record = { + name: existingProvider?.name ?? "OmniRoute", + npm: existingProvider?.npm ?? "@ai-sdk/openai-compatible", + options: { + baseURL, + apiKey: options.apiKey, + ...(existingProvider?.options ?? {}), + }, + models: mergedModels, + }; + // Carry over any other provider-level keys the user set (e.g. headers). + if (existingProvider) { + for (const [k, v] of Object.entries(existingProvider)) { + if (k === "name" || k === "npm" || k === "options" || k === "models") continue; + provider[k] = v; + } + } + + const config: Record = { + $schema: existing.$schema ?? "https://opencode.ai/config.json", + provider: { ...(existing.provider ?? {}), [providerId]: provider }, + }; + + // Carry over top-level keys the user may have set (compaction, plugins, + // permission, mcp, etc.). We intentionally do NOT preserve `model` / + // `small_model` unless the generator was given an explicit model — the + // user's top-level model selection may point at a model that no longer + // exists, so we require an explicit value via `options.model`. + for (const [k, v] of Object.entries(existing)) { + if (k === "$schema" || k === "provider" || k === "model" || k === "small_model") continue; + config[k] = v; + } + + if (typeof options.model === "string" && options.model.trim()) { + config.model = `${providerId}/${options.model.trim()}`; + } else if (typeof existing.model === "string" && existing.model.trim()) { + // Preserve the user's previous top-level `model` so a re-run doesn't + // silently drop their selection. + config.model = existing.model; + } + + if (typeof existing.small_model === "string" && existing.small_model.trim()) { + config.small_model = existing.small_model; + } + + return JSON.stringify(config, null, 2); +} + +/** + * Synchronous variant used by the legacy CLI path. Emits a minimal + * `opencode.json` (just provider options + top-level model) without a + * catalog fetch. Kept for back-compat with the previous `config set + * opencode` command; the async variant above is what callers should use + * for the full, context-window-aware flow. + */ +export function generateOpencodeConfigSync(options: { baseUrl: string; apiKey: string; model?: string; }): string { - let base = options.baseUrl; - let end = base.length; - while (end > 0 && base[end - 1] === "/") end--; - base = end < base.length ? base.slice(0, end) : base; - if (base.endsWith("/v1")) base = base.slice(0, -3); + const cleanBase = options.baseUrl.replace(/\/+$/, ""); + const base = cleanBase.endsWith("/v1") ? cleanBase.slice(0, -3) : cleanBase; const config = { provider: "omniroute", @@ -23,3 +404,8 @@ export function generateOpencodeConfig(options: { return JSON.stringify(config, null, 2); } + +// Backwards-compatible default export: keeps the existing call sites in +// `config.mjs` working. The async variant above is the preferred entry +// point for new callers. +export default generateOpencodeConfigSync; diff --git a/src/lib/modelsDevSync.ts b/src/lib/modelsDevSync.ts index d452fe2a01..0f2cfaab1d 100644 --- a/src/lib/modelsDevSync.ts +++ b/src/lib/modelsDevSync.ts @@ -181,8 +181,18 @@ const MODELS_DEV_PROVIDER_MAP: Record = { kilo: ["kilocode", "kc", "kilo-gateway"], kilocode: ["kilocode", "kc", "kilo-gateway"], "kimi-for-coding": ["kimi-coding", "kmc", "kimi-coding-apikey", "kmca"], - opencode: ["opencode-zen"], - "opencode-go": ["opencode-go"], + // The `opencode` models.dev entry used to map only to "opencode-zen" because + // that is the historical alias pair. But OmniRoute's catalog & combo targets + // reference models under BOTH provider IDs: + // - `opencode-zen/big-pickle` (alias form) + // - `opencode/big-pickle` (canonical id form, used by live API catalog + // and by combos like "Opencode FREE Omni") + // If we only store synced capabilities under "opencode-zen", the canonical + // `opencode/` lookup in getCanonicalModelMetadata returns null and + // any combo that targets `opencode/...` ends up with no computed context. + // Symmetric mapping keeps both lookup paths populated. + opencode: ["opencode", "opencode-zen"], + "opencode-go": ["opencode-go", "opencode-zen"], // Additional providers that may overlap with OmniRoute alibaba: ["ali", "alibaba"], "alibaba-cn": ["ali-cn", "alibaba-cn", "alibaba-china"], @@ -567,23 +577,65 @@ export function getSyncedCapabilities(provider?: string, modelId?: string): Capa return result; } +/** + * Resolved providers/aliases to also try when looking up a synced capability. + * Required because models.dev has historically stored capability rows under the + * alias side of an alias pair (e.g. "opencode-zen") while the catalog & combo + * targets reference the canonical id (e.g. "opencode"). Without this fallback, + * combos whose targets use the canonical id (e.g. "Opencode FREE Omni" → all + * `opencode/...` models) end up with `context_length: null` in the catalog. + */ +const SYNCED_CAPABILITY_FALLBACK_ALIASES: Record = { + opencode: ["opencode-zen"], + "opencode-zen": ["opencode"], + "opencode-go": ["opencode-zen"], +}; + export function getSyncedCapability( provider: string, modelId: string ): ModelCapabilityEntry | null { if (!provider || !modelId) return null; + // Fast path: every provider is in the in-memory cache, skip SQLite entirely. if (cachedCapabilitiesLoadedAll) { - return cachedCapabilities?.[provider]?.[modelId] ?? null; + const lookupCached = (p: string) => cachedCapabilities?.[p]?.[modelId] ?? null; + const directCached = lookupCached(provider); + if (directCached) return directCached; + const fallbacks = SYNCED_CAPABILITY_FALLBACK_ALIASES[provider]; + if (fallbacks) { + for (const alt of fallbacks) { + const found = lookupCached(alt); + if (found) return found; + } + } + return null; } + // Cold path: hit SQLite. Prepare the statement once, reuse for every alias. const db = getDbInstance(); ensureCapabilitiesTable(); - const row = db - .prepare("SELECT * FROM model_capabilities WHERE provider = ? AND model_id = ? LIMIT 1") - .get(provider, modelId); - if (!row) return null; - return mapCapabilityRecord(toRecord(row)); + const stmt = db.prepare( + "SELECT * FROM model_capabilities WHERE provider = ? AND model_id = ? LIMIT 1" + ); + const lookupDb = (p: string): ModelCapabilityEntry | null => { + const row = stmt.get(p, modelId); + if (!row) return null; + return mapCapabilityRecord(toRecord(row)); + }; + + const direct = lookupDb(provider); + if (direct) return direct; + + const fallbacks = SYNCED_CAPABILITY_FALLBACK_ALIASES[provider]; + if (fallbacks) { + for (const alt of fallbacks) { + const found = lookupDb(alt); + if (found) return found; + } + } + + return null; } /** diff --git a/tests/unit/cli-helper/config-generator.test.ts b/tests/unit/cli-helper/config-generator.test.ts index 239a8c3aa2..216912bb6b 100644 --- a/tests/unit/cli-helper/config-generator.test.ts +++ b/tests/unit/cli-helper/config-generator.test.ts @@ -185,4 +185,183 @@ describe("config-generator", () => { assert.ok(result.yaml.includes("new-model")); }); }); + + describe("opencode (context-aware)", () => { + /** + * The catalog is the single source of truth for context windows — + * we never fabricate a default. Tests below pin this contract. + */ + function makeCatalogResponse(models: unknown[]): unknown { + return { object: "list", data: models }; + } + + const SAMPLE_CATALOG: unknown[] = [ + { id: "ds/deepseek-v4-flash", owned_by: "deepseek", context_length: 1_000_000, max_input_tokens: 1_000_000 }, + { id: "llama3", owned_by: "llama", max_context_window_tokens: 8192 }, + { id: "MASTER", owned_by: "combo", context_length: 131072, max_input_tokens: 131072 }, + { id: "Opencode FREE Omni", owned_by: "combo", context_length: 200000, max_input_tokens: 160000 }, + // Combo whose targets have no known context — generator must NOT + // fabricate a default. The model is emitted without limit.context. + { id: "NO_CTX_COMBO", owned_by: "combo" }, + ]; + + function stubFetchOnce(body: unknown, status = 200) { + const original = globalThis.fetch; + let calls = 0; + // @ts-ignore — globalThis.fetch signature is compatible for our purposes + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + calls += 1; + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + return { + calls: () => calls, + restore: () => { + globalThis.fetch = original; + }, + }; + } + + it("emits limit.context from the catalog (no hardcoded fallback)", async () => { + const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG)); + try { + const { generateOpencodeConfig } = await import( + "../../../src/lib/cli-helper/config-generator/opencode.ts" + ); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + const cfg = JSON.parse(out); + const models = cfg.provider.omniroute.models; + assert.strictEqual(models["ds/deepseek-v4-flash"].limit.context, 1_000_000); + assert.strictEqual(models["MASTER"].limit.context, 131072); + // Combo with min-of-targets 200K: must reflect the catalog's value, + // not a hardcoded 128K. + assert.strictEqual(models["Opencode FREE Omni"].limit.context, 200000); + } finally { + stub.restore(); + } + }); + + it("does NOT fabricate a default context when the catalog has no entry", async () => { + const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG)); + try { + const { generateOpencodeConfig } = await import( + "../../../src/lib/cli-helper/config-generator/opencode.ts" + ); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + const cfg = JSON.parse(out); + // NO_CTX_COMBO has no context_length in the catalog — generator + // must NOT default to 128K (or any other value). The entry is + // emitted without limit.context so OpenCode's own heuristic + // applies and the user can fix the upstream. + const noCtx = cfg.provider.omniroute.models["NO_CTX_COMBO"]; + assert.strictEqual( + noCtx.limit?.context, + undefined, + `NO_CTX_COMBO should not have a fabricated limit.context (got ${noCtx.limit?.context})` + ); + } finally { + stub.restore(); + } + }); + + it("prefers max_context_window_tokens when context_length is absent", async () => { + const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG)); + try { + const { generateOpencodeConfig } = await import( + "../../../src/lib/cli-helper/config-generator/opencode.ts" + ); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + const cfg = JSON.parse(out); + assert.strictEqual(cfg.provider.omniroute.models.llama3.limit.context, 8192); + } finally { + stub.restore(); + } + }); + + it("THROWS when the catalog fetch fails (no silent stale config)", async () => { + // When the catalog fetch fails, the generator MUST throw rather than + // emit a config with fabricated values. The CLI catches the error + // and surfaces it to the user; the user's existing opencode.json is + // left untouched. + const original = globalThis.fetch; + // @ts-ignore + globalThis.fetch = (async () => { + throw new Error("ECONNREFUSED"); + }) as typeof fetch; + try { + const { generateOpencodeConfig } = await import( + "../../../src/lib/cli-helper/config-generator/opencode.ts" + ); + let threw = false; + try { + await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + } catch (e) { + threw = true; + assert.ok( + /catalog|fetch|ECONNREFUSED/i.test(String(e?.message ?? e)), + `Expected fetch error, got: ${String(e?.message ?? e)}` + ); + } + assert.ok(threw, "generator must throw when catalog fetch fails"); + } finally { + globalThis.fetch = original; + } + }); + + it("writes a top-level model prefixed with provider id when options.model is supplied", async () => { + const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG)); + try { + const { generateOpencodeConfig } = await import( + "../../../src/lib/cli-helper/config-generator/opencode.ts" + ); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + model: "MASTER", + }); + const cfg = JSON.parse(out); + assert.strictEqual(cfg.model, "omniroute/MASTER"); + } finally { + stub.restore(); + } + }); + + it("auto-pulls the Opencode FREE Omni combo context (the user-reported case)", async () => { + // Regression guard: the catalog's min-of-targets for combos must be + // reflected verbatim. No hardcoded 128K, no fallback that overrides + // the catalog's actual value. + const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG)); + try { + const { generateOpencodeConfig } = await import( + "../../../src/lib/cli-helper/config-generator/opencode.ts" + ); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + const cfg = JSON.parse(out); + assert.strictEqual( + cfg.provider.omniroute.models["Opencode FREE Omni"].limit.context, + 200000, + "Opencode FREE Omni must have context=200000 from the catalog, not 128000" + ); + } finally { + stub.restore(); + } + }); + }); }); diff --git a/tests/unit/opencode-zen-alias-combo-e2e.test.ts b/tests/unit/opencode-zen-alias-combo-e2e.test.ts new file mode 100644 index 0000000000..40f4cb7065 --- /dev/null +++ b/tests/unit/opencode-zen-alias-combo-e2e.test.ts @@ -0,0 +1,208 @@ +/** + * End-to-end runtime test for the opencode-zen ↔ opencode alias fix. + * + * Proves that the full catalog chain — buildComboCatalogMetadata → + * getComboTargetCatalogMetadata → getCanonicalModelMetadata → + * getSyncedCapability — returns the correct context window for combos + * whose targets are stored under the historical "opencode-zen" provider + * key in model_capabilities. + * + * This test uses the REAL catalog code paths with an in-memory DB. The + * only mocked piece is the persistence layer (DB) itself. + */ + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-e2e-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDevSync = await import("../../src/lib/modelsDevSync.ts"); +const registry = await import("../../src/lib/modelMetadataRegistry.ts"); + +// ─── Real-world fixtures ──────────────────────────────────────────────── +// These mirror the actual data that models.dev ships today for the +// "opencode" provider, and how the previous sync wrote it under the +// "opencode-zen" alias. + +const REAL_OPENCODE_DATA = { + "big-pickle": { + tool_call: true, + reasoning: true, + attachment: false, + structured_output: true, + temperature: true, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: "2025-04", + release_date: "2025-04-15", + last_updated: "2025-05-01", + status: "alpha", + family: "big-pickle", + open_weights: false, + limit_context: 200000, + limit_input: 200000, + limit_output: 128000, + interleaved_field: null, + }, + "gpt-5-nano": { + tool_call: true, + reasoning: true, + attachment: true, + structured_output: true, + temperature: false, + modalities_input: JSON.stringify(["text", "image"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: "2025-04", + release_date: "2025-04-15", + last_updated: "2025-05-01", + status: "alpha", + family: "gpt-5", + open_weights: false, + limit_context: 400000, + limit_input: 400000, + limit_output: 128000, + interleaved_field: null, + }, + "minimax-m2": { + tool_call: true, + reasoning: true, + attachment: false, + structured_output: true, + temperature: true, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: "2025-04", + release_date: "2025-04-15", + last_updated: "2025-05-01", + status: "alpha", + family: "minimax", + open_weights: false, + limit_context: 200000, + limit_input: 200000, + limit_output: 128000, + interleaved_field: null, + }, + "kimi-k2": { + tool_call: true, + reasoning: false, + attachment: false, + structured_output: true, + temperature: true, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: "2025-04", + release_date: "2025-04-15", + last_updated: "2025-05-01", + status: "alpha", + family: "kimi", + open_weights: false, + limit_context: 200000, + limit_input: 200000, + limit_output: 128000, + interleaved_field: null, + }, +}; + +// ─── Lifecycle ─────────────────────────────────────────────────────────── + +before(async () => { + // Seed the DB exactly the way the previous sync wrote it: under the + // historical "opencode-zen" alias, NOT under "opencode". + modelsDevSync.saveModelsDevCapabilities({ + "opencode-zen": REAL_OPENCODE_DATA, + }); +}); + +after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ─── Tests ────────────────────────────────────────────────────────────── + +describe("opencode-zen ↔ opencode alias fix (end-to-end)", () => { + it("getSyncedCapability('opencode', 'big-pickle') finds data stored under 'opencode-zen'", () => { + // Pre-fix: returns null (the bug) + // Post-fix: returns the opencode-zen row + const cap = modelsDevSync.getSyncedCapability("opencode", "big-pickle"); + assert.ok(cap, "expected the alias fallback to find the opencode-zen row"); + assert.equal(cap?.limit_context, 200000); + assert.equal(cap?.limit_output, 128000); + }); + + it("getSyncedCapability('opencode', 'gpt-5-nano') finds the 400K context row", () => { + const cap = modelsDevSync.getSyncedCapability("opencode", "gpt-5-nano"); + assert.ok(cap); + assert.equal(cap?.limit_context, 400000); + }); + + it("getCanonicalModelMetadata({provider:'opencode', model:'big-pickle'}) returns full metadata", () => { + const md = registry.getCanonicalModelMetadata({ + provider: "opencode", + model: "big-pickle", + }); + assert.ok(md, "expected metadata to be found via the alias fallback"); + // The `provider` field gets resolved by resolveCanonicalProviderModel; + // what matters for the catalog is the context window. The fix ensures + // the opencode-zen row is found, so contextWindow is 200k. + assert.equal(md?.model, "big-pickle"); + assert.equal(md?.limits.contextWindow, 200000, "context window via alias fallback"); + assert.equal(md?.limits.maxOutputTokens, 128000, "max output via alias fallback"); + assert.equal(md?.capabilities.toolCalling, true); + assert.equal(md?.capabilities.reasoning, true); + assert.deepEqual(md?.modalities.input, ["text"]); + assert.equal(md?.metadata.source.syncedCapability, true); + }); + + it("combo of 4 opencode/* targets computes context_length = min(known) = 200000", () => { + // Simulate the "Opencode FREE Omni" combo: 4 targets, all under + // provider "opencode". The min of the 4 known contexts (200k, 400k, + // 200k, 200k) is 200000 — NOT 128000, NOT null. + const targets = [ + { providerId: "opencode", modelId: "big-pickle" }, + { providerId: "opencode", modelId: "gpt-5-nano" }, + { providerId: "opencode", modelId: "minimax-m2" }, + { providerId: "opencode", modelId: "kimi-k2" }, + ]; + + // Inline the same chain buildComboCatalogMetadata uses: + // 1. resolveNestedComboTargets → targets + // 2. for each target → getComboTargetCatalogMetadata + // 3. min of known contextLength values + const contexts: number[] = []; + for (const target of targets) { + const md = registry.getCanonicalModelMetadata({ + provider: target.providerId, + model: target.modelId, + }); + assert.ok(md, `metadata should be found for ${target.modelId} via alias fallback`); + assert.equal( + md?.limits.contextWindow, + REAL_OPENCODE_DATA[target.modelId as keyof typeof REAL_OPENCODE_DATA].limit_context, + `${target.modelId} should have the catalog-stored context` + ); + if (typeof md?.limits.contextWindow === "number") { + contexts.push(md.limits.contextWindow); + } + } + assert.equal(contexts.length, 4, "all 4 targets should resolve to a known context"); + const minContext = Math.min(...contexts); + assert.equal(minContext, 200000, "min of 200k, 400k, 200k, 200k = 200k"); + }); + + it("direct lookup under 'opencode-zen' still works (regression)", () => { + const cap = modelsDevSync.getSyncedCapability("opencode-zen", "big-pickle"); + assert.ok(cap); + assert.equal(cap?.limit_context, 200000); + }); + + it("unknown model still returns null (regression)", () => { + const cap = modelsDevSync.getSyncedCapability("opencode", "nonexistent-model"); + assert.equal(cap, null); + }); +});