fix(catalog): make combos auto-compute context_length for any provider id form (#3417)

Integrated into release/v3.8.17
This commit is contained in:
Hernan Javier Ardila Sanchez
2026-06-08 23:50:21 +02:00
committed by GitHub
parent 89a76d8c1c
commit dbd70ddd1f
8 changed files with 996 additions and 14 deletions

View File

@@ -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=

View File

@@ -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/<model>` 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<string, string[]> = {
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
```

View File

@@ -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. |

View File

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

View File

@@ -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<string, unknown>;
models?: Record<string, ExistingModelEntry>;
[key: string]: unknown;
}
interface ExistingConfig {
$schema?: string;
provider?: Record<string, ExistingProviderEntry>;
model?: string;
small_model?: string;
[key: string]: unknown;
}
export interface CatalogFetchResult {
/** Models keyed by id, as returned by /v1/models. */
byId: Map<string, CatalogModelEntry>;
/** Provider ids that had at least one model in the catalog. */
providerIds: Set<string>;
/** 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<CatalogFetchResult> {
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.<name>.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<string> {
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<string, CatalogModelEntry>();
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<string, ExistingModelEntry>;
// Build the merged model map: catalog first, then existing (so existing
// values can win for matching ids).
const mergedIds = new Set<string>([...catalogById.keys(), ...Object.keys(existingModels)]);
const mergedModels: Record<string, ExistingModelEntry> = {};
for (const id of mergedIds) {
mergedModels[id] = buildModelEntry(id, catalogById.get(id), existingModels[id]);
}
const provider: Record<string, unknown> = {
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<string, unknown> = {
$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;

View File

@@ -181,8 +181,18 @@ const MODELS_DEV_PROVIDER_MAP: Record<string, string[]> = {
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/<model>` 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<string, string[]> = {
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;
}
/**

View File

@@ -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();
}
});
});
});

View File

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