mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
89a76d8c1c
commit
dbd70ddd1f
13
.env.example
13
.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=
|
||||
|
||||
|
||||
108
docs/fix-opencode-context.md
Normal file
108
docs/fix-opencode-context.md
Normal 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
|
||||
```
|
||||
@@ -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. |
|
||||
|
||||
23
scripts/ad-hoc/regen-opencode-config.ts
Normal file
23
scripts/ad-hoc/regen-opencode-config.ts
Normal 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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
208
tests/unit/opencode-zen-alias-combo-e2e.test.ts
Normal file
208
tests/unit/opencode-zen-alias-combo-e2e.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user