diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index f70807434c..19dca23be2 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -708,7 +708,14 @@ export function mapRawModelToModelV2( const outMods = new Set(raw.output_modalities ?? ["text"]); return { - id: raw.id, + // OC's static-catalog reader parses the key on `/` to recover + // `(providerID, modelID)`. If the raw id is already provider-prefixed + // (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or + // `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave + // it as-is — double-prefixing breaks OC's lookup. Otherwise prefix with + // the resolved `providerId` so a bare key like `claude-opus-4` parses as + // `(omniroute, claude-opus-4)` and the credentials resolve correctly. + id: raw.id.includes("/") ? raw.id : `${ctx.providerId}/${raw.id}`, /** * Display name. Falls back to raw.id when no enrichment is available; * the caller (`createOmniRouteProviderHook`) overlays @@ -1166,6 +1173,10 @@ export function mapAutoComboToStaticEntry( typeof autoCombo.max_output_tokens === "number" && autoCombo.max_output_tokens > 0 ? autoCombo.max_output_tokens : AUTO_COMBO_FALLBACK_OUTPUT; + // No `providerID` field on static-catalog entries — OC ignores it on the static + // path, and stamping it on auto-combos but not on raw/combo entries was an + // internal inconsistency. The dynamic-hook path builds its ModelV2 from the + // individual fields below and never read this field either. return { name, attachment: false, @@ -2248,26 +2259,38 @@ export function slugifyComboName(name: string): string { } /** - * Build a combo's static-block key (`combo/`), guaranteeing uniqueness - * across an entire static catalog. If `` is already present in `used`, - * suffixes a short UUID-prefix disambiguator from `combo.id` so the second - * combo doesn't silently overwrite the first. Mutates `used` in place by - * recording the chosen key. Returns the final `combo/<...>` key. + * Build a combo's static-block key, provider-prefixed as `/` + * (e.g. `omniroute/MASTER`, `omniroute/MASTER-LIGHT`), guaranteeing uniqueness + * across an entire static catalog. If `/` is already present in + * `used`, suffixes a short UUID-prefix disambiguator from `combo.id` so the second + * combo doesn't silently overwrite the first. Mutates `used` in place by recording + * the chosen key. Returns the final `/` key. * - * Falls back to `combo/` when the friendly name slugifies to the empty + * NOTE: the key MUST carry the OWNING provider prefix (`omniroute/…`), never a + * `combo/` namespace — OpenCode parses model IDs on `/` to extract the provider, + * so `combo/MASTER` would resolve provider=`combo` (no credentials) and fail with + * "Unable to determine provider", whereas `omniroute/MASTER` resolves provider= + * `omniroute` and the openai-compatible adapter strips the prefix and sends the + * bare slug upstream, which the server resolves via getComboByName. See PR #4184. + * + * Falls back to `/` when the friendly name slugifies to the empty * string (e.g. a combo named just punctuation). */ -export function buildComboKey(combo: OmniRouteRawCombo, used: Set): string { +export function buildComboKey( + combo: OmniRouteRawCombo, + used: Set, + providerId: string +): string { const friendlyName = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id; let slug = slugifyComboName(friendlyName); if (slug.length === 0) slug = combo.id; - let key = `combo/${slug}`; + let key = `${providerId}/${slug}`; if (used.has(key)) { const tail = combo.id.split("-")[0] ?? combo.id; - key = `combo/${slug}-${tail}`; + key = `${providerId}/${slug}-${tail}`; // Defensive: in the (impossible) event the disambiguated key also // collides, append the full id. - if (used.has(key)) key = `combo/${slug}-${combo.id}`; + if (used.has(key)) key = `${providerId}/${slug}-${combo.id}`; } used.add(key); return key; @@ -2651,7 +2674,12 @@ export function createOmniRouteProviderHook( ); applyProviderTag(model, tagEntry); } - models[entry.id] = model; + // OC's static-catalog reader parses the key on `/` to recover + // (providerID, modelID). `mapRawModelToModelV2` already stamps the + // prefixed id on `model.id` (e.g. `omniroute/claude-primary`), so we + // must key by `model.id` — not by the raw `entry.id` which would be + // a bare slug and parse as `providerID=slug, modelID=""`. + models[model.id] = model; } // Default compression combo (used to decorate ALL combo names when @@ -2801,18 +2829,6 @@ export function createOmniRouteProviderHook( // models with curated names). applyEnrichment(mapped, rawEnrichment.get(combo.id)); - // `Combo: ` prefix surfaces the combo nature in OC's model picker. - // Idempotent guard covers the case where enrichment overwrote - // mapped.name with an already-prefixed string. Mirrors the - // static-hook Combo:-prefix decoration. - if (!mapped.name.startsWith("Combo: ")) { - mapped.name = `Combo: ${mapped.name}`; - } - - // Optionally decorate combo name with its compression pipeline. - // Only fires when features.compressionMetadata: true, OmniRoute - // returned at least one default compression combo, AND the - // combo has resolvable members — claiming compression on an // unroutable combo would mislead the picker. if (hasMembers && defaultCompression && defaultCompression.pipeline.length > 0) { const tag = formatCompressionPipeline(defaultCompression.pipeline); @@ -2821,18 +2837,37 @@ export function createOmniRouteProviderHook( } } - const comboKey = buildComboKey(combo, usedComboKeys); + const comboKey = buildComboKey(combo, usedComboKeys, resolved.providerId); // Collision policy: combos win. Warn ONCE per (cacheKey, comboKey) // when overwriting a same-key raw model so the operator can spot - // the unusual naming choice without log spam. + // the unusual naming choice without log spam. Suppress the warning + // when the collision is the intentional dedup pattern (combo.name + // exactly matches an existing raw model's id) — /v1/models + // pre-mirrors combos as raw entries and the operator's intent is + // always "combo wins" in that case. if (Object.prototype.hasOwnProperty.call(models, comboKey)) { - const dedupeKey = `${cacheKey}::${comboKey}`; - if (!collisionWarned.has(dedupeKey)) { - collisionWarned.add(dedupeKey); - console.warn( - `[omniroute-plugin] combo key "${comboKey}" collides with a model id; combo wins.` - ); + const existing = models[comboKey]; + // Intentional dedup: `/v1/models` pre-mirrors combos as raw + // entries, so the bare combo name appears as the model id in + // `rawModels`. After our prefixing the existing entry's id is + // `${providerId}/${raw.id}` — the combo name is a substring of + // that prefixed id (or, for already-prefixed raw models, the + // exact id). Use `endsWith` to avoid matching substrings of + // unrelated prefixed ids. + const isIntentionalDedup = + existing && + combo.name && + combo.name.trim().length > 0 && + (existing.id === combo.name.trim() || existing.id.endsWith(`/${combo.name.trim()}`)); + if (!isIntentionalDedup) { + const dedupeKey = `${cacheKey}::${comboKey}`; + if (!collisionWarned.has(dedupeKey)) { + collisionWarned.add(dedupeKey); + console.warn( + `[omniroute-plugin] combo key "${comboKey}" collides with a model id; combo wins.` + ); + } } } models[comboKey] = mapped; @@ -3346,8 +3381,18 @@ function normaliseModalities(raw: unknown): OmniRouteModalityKind[] { } export interface OmniRouteStaticModelEntry { + /** Owning provider id. SHOULD match the parent `provider.` key so OC's + * static-catalog reader resolves credentials via `providerID` instead of + * parsing the model key on `/`. Optional: OC's schema validator may + * reject the entire provider block when this field is present but the + * model KEY already carries the provider prefix (e.g. `omniroute/MASTER`), + * since the prefix makes the field redundant and the field is not part of + * OC's expected schema. We omit it from entries and rely on the prefix + * on the KEY alone. See PR #4184. */ + providerID?: string; /** Display label rendered in OC's model picker. Defaults to the model id. */ name: string; + /** ISO date the model was released. Surfaces in OC's model card when present. */ release_date?: string; /** Model accepts image / file attachments. */ @@ -3545,6 +3590,12 @@ export function buildStaticProviderEntry( if (!displayName.startsWith(prefix)) displayName = `${prefix}${displayName}`; } } + // OC's static-catalog schema doesn't expect a `providerID` field on + // individual entries — the parent block ID is the provider. Adding + // unknown fields here can cause OC's schema validator to reject the + // entire provider block, hiding ALL models. The provider prefix on the + // model KEY (e.g. `omniroute/claude-opus-4`) is what OC uses to recover + // (providerID, modelID) when the user selects a model. const entry: OmniRouteStaticModelEntry = { name: displayName }; const attachment = caps.attachment ?? caps.vision; @@ -3608,7 +3659,12 @@ export function buildStaticProviderEntry( entry.release_date = raw.release_date; } - models[raw.id] = entry; + // OC's static-catalog reader parses each key on `/` and rejects the + // entire provider block if ANY key resolves to a parsed providerID that + // has no corresponding provider block. So bare keys (no `/`) MUST be + // prefixed with the resolved providerId. Already-prefixed keys + // (e.g. `cc/claude-opus-4-7`) are left as-is to avoid double-prefixing. + models[raw.id.includes("/") ? raw.id : `${opts.providerId}/${raw.id}`] = entry; } // Combo entries → stripped LCD shape. Each combo is keyed as @@ -3717,12 +3773,11 @@ export function buildStaticProviderEntry( const hasMembers = memberEntries.length > 0; const friendlyName = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id; - // `Combo: ` prefix surfaces the combo nature in OC's model picker — the - // catalog key (`combo/`) is already namespaced, but the picker - // shows `name`, so prefix the display string too. - const prefixedName = `Combo: ${friendlyName}`; const displayName = - hasMembers && compressionSuffix ? `${prefixedName}${compressionSuffix}` : prefixedName; + hasMembers && compressionSuffix ? `${friendlyName} ${compressionSuffix}` : friendlyName; + // See the raw-model entry comment above — `providerID` on entries is + // not part of OC's static-catalog schema; the parent block ID is the + // provider and the KEY prefix (`omniroute/`) is what OC parses. const entry: OmniRouteStaticModelEntry = { name: displayName }; if (hasMembers) { @@ -3790,12 +3845,12 @@ export function buildStaticProviderEntry( entry.tool_call = false; } - // Key under `combo/` (e.g. `combo/claude-primary`) so the - // namespace cleanly separates combos from raw provider/model pairs - // and so the key is copy/paste-friendly. Slug collisions across + // Key under bare slug (e.g. `claude-primary`) — no `combo/` prefix + // because OpenCode parses model IDs on `/` and would treat + // `combo/MASTER` as provider=`combo`. Slug collisions across // combos are disambiguated with a short UUID-prefix suffix; see // `buildComboKey` for the policy. - models[buildComboKey(combo, usedComboKeys)] = entry; + models[buildComboKey(combo, usedComboKeys, opts.providerId)] = entry; // Make this combo's resolved entry available to parent combos // that reference it via combo-ref. Use the friendly name since diff --git a/@omniroute/opencode-plugin/tests/combos.test.ts b/@omniroute/opencode-plugin/tests/combos.test.ts index 1b843cf151..ff209a9a67 100644 --- a/@omniroute/opencode-plugin/tests/combos.test.ts +++ b/@omniroute/opencode-plugin/tests/combos.test.ts @@ -447,13 +447,13 @@ test("models() returns combo entries merged into the map", async () => { // 3 raw models + 1 combo = 4 entries assert.equal(Object.keys(out).length, 4); - assert.ok(out["claude-primary"]); - assert.ok(out["claude-secondary"]); - assert.ok(out["gemini-3-flash"]); - assert.ok(out["combo/claude-tier"]); + assert.ok(out["omniroute/claude-primary"]); + assert.ok(out["omniroute/claude-secondary"]); + assert.ok(out["omniroute/gemini-3-flash"]); + assert.ok(out["omniroute/claude-tier"]); - const combo = out["combo/claude-tier"]; - assert.equal(combo.name, "Combo: Claude Tier"); + const combo = out["omniroute/claude-tier"]; + assert.equal(combo.name, "Claude Tier"); assert.equal(combo.providerID, "omniroute"); // LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning) assert.equal(combo.limit.context, 100_000); @@ -478,11 +478,11 @@ test("models(): combo with unknown member ids degrades to all-false LCD posture" { fetcher: modelsFetcher, combosFetcher } ); const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); - assert.ok(out["combo/phantom-combo"]); + assert.ok(out["omniroute/phantom-combo"]); // With zero resolvable members, LCD = all-false (defensive posture). - assert.equal(out["combo/phantom-combo"].capabilities.toolcall, false); - assert.equal(out["combo/phantom-combo"].capabilities.reasoning, false); - assert.equal(out["combo/phantom-combo"].limit.context, 0); + assert.equal(out["omniroute/phantom-combo"].capabilities.toolcall, false); + assert.equal(out["omniroute/phantom-combo"].capabilities.reasoning, false); + assert.equal(out["omniroute/phantom-combo"].limit.context, 0); }); test("models(): hidden combos are excluded from the map", async () => { @@ -505,11 +505,11 @@ test("models(): hidden combos are excluded from the map", async () => { { fetcher: modelsFetcher, combosFetcher } ); const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); - assert.ok(out["combo/visible"]); - assert.ok(!out["combo/hidden"], "hidden combo must be omitted"); + assert.ok(out["omniroute/visible"]); + assert.ok(!out["omniroute/hidden"], "hidden combo must be omitted"); }); -test("models(): combo name exactly matches raw model id → raw deleted, combo lives at combo/ key, no warn", async () => { +test("models(): combo name exactly matches raw model id → raw deleted, raw deleted, no warn", async () => { // Combo.name === raw model id triggers the dedup deletion. This mirrors // the real OmniRoute payload where /v1/models pre-mirrors combos as // no-slash raw entries whose ids match /api/combos friendly names. @@ -529,10 +529,9 @@ test("models(): combo name exactly matches raw model id → raw deleted, combo l return hook.models!({} as never, { auth: apiAuth("sk-z") as never }); }); - // Raw model deleted by combo-name dedup; combo surfaces under combo/. - assert.equal(out["claude-primary"], undefined, "raw deleted by combo-name dedup"); - assert.ok(out["combo/claude-primary"], "combo surfaces under combo/ namespace"); - assert.equal(out["combo/claude-primary"].name, "Combo: claude-primary"); + // Raw model replaced by combo of the same key; combo now lives at the bare slug. + assert.ok(out["omniroute/claude-primary"], "combo surfaces under prefixed key"); + assert.equal(out["omniroute/claude-primary"].name, "claude-primary"); // No collision warning fires — dedup makes keys disjoint. const collisionWarns = warnings.filter((w) => { @@ -543,7 +542,7 @@ test("models(): combo name exactly matches raw model id → raw deleted, combo l }); test("models(): two combos with same slug → second gets disambiguator suffix", async () => { - // Both combos slug to `claude` — second must get `combo/claude-`. + // Both combos slug to `claude` — second must get `claude-`. const combos: OmniRouteRawCombo[] = [ { id: "uuid-a", @@ -566,8 +565,8 @@ test("models(): two combos with same slug → second gets disambiguator suffix", const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); // First combo gets the bare slug; second gets disambiguated. - assert.ok(out["combo/claude"], "first combo at bare slug"); - assert.ok(out["combo/claude-uuid"], "second combo disambiguated by id prefix"); + assert.ok(out["omniroute/claude"], "first combo at prefixed slug"); + assert.ok(out["omniroute/claude-uuid"], "second combo disambiguated by id prefix"); }); test("models(): combos fetch fails → falls back to models-only, warn emitted, no throw", async () => { @@ -584,8 +583,8 @@ test("models(): combos fetch fails → falls back to models-only, warn emitted, // Catalog includes the models but NOT any combo entries. assert.equal(Object.keys(out).length, 2); - assert.ok(out["claude-primary"]); - assert.ok(out["claude-secondary"]); + assert.ok(out["omniroute/claude-primary"]); + assert.ok(out["omniroute/claude-secondary"]); // Soft-fail warning surfaced. const softFail = warnings.find((w) => { @@ -610,7 +609,7 @@ test("models(): combos cached + reused within TTL (one combo fetch per TTL windo const second = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); assert.equal(combosFetcher.callCount(), 1, "combos fetched only once within TTL"); assert.equal(modelsFetcher.callCount(), 1, "models fetched only once within TTL"); - assert.ok(second["combo/claude-tier"]); + assert.ok(second["omniroute/claude-tier"]); }); test("models(): combos refetched after TTL expiry (same key as models)", async () => { @@ -702,7 +701,7 @@ test("models(): nested combo-ref context is the min of nested + raw members", as { fetcher: modelsFetcher, combosFetcher } ); const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); - const masterLight = out["combo/master-light"]; + const masterLight = out["omniroute/master-light"]; assert.ok(masterLight, "MASTER-LIGHT entry must exist"); assert.equal( masterLight.limit.context, diff --git a/@omniroute/opencode-plugin/tests/config-shim.test.ts b/@omniroute/opencode-plugin/tests/config-shim.test.ts index e95e108bec..11dc76c032 100644 --- a/@omniroute/opencode-plugin/tests/config-shim.test.ts +++ b/@omniroute/opencode-plugin/tests/config-shim.test.ts @@ -227,7 +227,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider // Stripped per-model shape: name + cap flags + modalities + (optional) // cost. OC's SDK static schema accepts only `limit.{context,output}` — // `limit.input` is NOT in the SDK shape and gets dropped silently. - const claude = entry.models["claude-sonnet-4-6"]; + const claude = entry.models["omniroute/claude-sonnet-4-6"]; assert.ok(claude, "claude model surfaced"); assert.equal(claude.name, "claude-sonnet-4-6"); assert.equal(claude.attachment, true); @@ -246,11 +246,11 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider assert.deepEqual(claude.modalities?.input, ["text", "image"]); assert.deepEqual(claude.modalities?.output, ["text"]); - // Combo surfaces under `combo/` namespace + LCD'd + // Combo surfaces under bare key + LCD'd // (gemini's reasoning=false → combo reasoning=false). - const combo = entry.models["combo/claude-tier"]; - assert.ok(combo, "combo surfaced under combo/ namespace"); - assert.equal(combo.name, "Combo: Claude Tier"); + const combo = entry.models["omniroute/claude-tier"]; + assert.ok(combo, "combo surfaced under bare key"); + assert.equal(combo.name, "Claude Tier"); assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false"); assert.equal(combo.tool_call, true); assert.equal(combo.limit?.context, 200_000, "LCD: min(200_000, 1_000_000)"); @@ -409,8 +409,8 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m .omniroute; assert.ok(entry); const ids = Object.keys(entry.models).sort(); - assert.deepEqual(ids, ["claude-sonnet-4-6", "gemini-3-flash"]); - assert.equal(entry.models["combo-claude-tier"], undefined, "no combo entry"); + assert.deepEqual(ids, ["omniroute/claude-sonnet-4-6", "omniroute/gemini-3-flash"]); + assert.equal(entry.models["omniroute/claude-tier"], undefined, "no combo entry"); assert.ok( logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")), "combos-fetch breadcrumb emitted" @@ -638,6 +638,7 @@ test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniro "cost", "limit", "modalities", + "providerID", ]); for (const [id, entry] of Object.entries(block.models)) { for (const key of Object.keys(entry)) { @@ -653,7 +654,7 @@ test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniro } // Sanity: claude entry has all expected stripped fields. - const claude = block.models["claude-sonnet-4-6"]; + const claude = block.models["omniroute/claude-sonnet-4-6"]; assert.equal(typeof claude.name, "string"); assert.equal(typeof claude.attachment, "boolean"); assert.equal(typeof claude.reasoning, "boolean"); @@ -678,8 +679,8 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => { "https://or.example/v1", "sk-test" ); - assert.equal(block.models["combo-claude-tier"], undefined); - assert.ok(block.models["claude-sonnet-4-6"]); + assert.equal(block.models["omniroute/claude-tier"], undefined); + assert.ok(block.models["omniroute/claude-sonnet-4-6"]); }); // ──────────────────────────────────────────────────────────────────────────── @@ -695,7 +696,7 @@ test("buildStaticProviderEntry: emits modalities.input from raw.input_modalities "https://or.example/v1", "sk-test" ); - const claude = block.models["claude-sonnet-4-6"]; + const claude = block.models["omniroute/claude-sonnet-4-6"]; assert.deepEqual(claude.modalities?.input, ["text", "image"]); assert.deepEqual(claude.modalities?.output, ["text"]); }); @@ -709,7 +710,7 @@ test("buildStaticProviderEntry: never emits limit.input (OC SDK rejects it)", () "https://or.example/v1", "sk-test" ); - const claude = block.models["claude-sonnet-4-6"]; + const claude = block.models["omniroute/claude-sonnet-4-6"]; assert.equal((claude.limit as Record).input, undefined); assert.equal(typeof claude.limit?.context, "number"); assert.equal(typeof claude.limit?.output, "number"); @@ -737,7 +738,7 @@ test("buildStaticProviderEntry: emits cost when enrichment carries pricing", () "sk-test", enrichment ); - const claude = block.models["claude-sonnet-4-6"]; + const claude = block.models["omniroute/claude-sonnet-4-6"]; assert.equal(claude.cost?.input, 3); assert.equal(claude.cost?.output, 15); assert.equal(claude.cost?.cache_read, 0.3); @@ -758,8 +759,8 @@ test("buildStaticProviderEntry: emits release_date when raw carries it; omits wh "https://or.example/v1", "sk-test" ); - assert.equal(block.models["claude-with-date"].release_date, "2026-02-19"); - assert.equal(block.models["gemini-3-flash"].release_date, undefined); + assert.equal(block.models["omniroute/claude-with-date"].release_date, "2026-02-19"); + assert.equal(block.models["omniroute/gemini-3-flash"].release_date, undefined); }); test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)", () => { @@ -788,7 +789,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD) "https://or.example/v1", "sk-test" ); - const combo = block.models["combo/mixed-tier"]; + const combo = block.models["omniroute/mixed-tier"]; assert.ok(combo, "combo emitted under slug key"); // claude has text+image, text-only has text → intersection drops image. assert.deepEqual(combo.modalities?.input, ["text"]); @@ -896,10 +897,10 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async () const entry = (input as { provider: Record }).provider .omniroute; assert.ok(entry); - assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); - assert.equal(entry.models["gemini-3-flash"].name, "Gemini 3 Flash"); + assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); + assert.equal(entry.models["omniroute/gemini-3-flash"].name, "Gemini 3 Flash"); // Combo names still come from /api/combos — enrichment overlay does NOT touch combos. - assert.equal(entry.models["combo/claude-tier"].name, "Combo: Claude Tier"); + assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier"); assert.equal(enrichmentFetcher.callCount(), 1); }); @@ -927,7 +928,11 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na .omniroute; assert.ok(entry); assert.equal(enrichmentFetcher.callCount(), 0, "enrichment fetch suppressed by feature flag"); - assert.equal(entry.models["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained"); + assert.equal( + entry.models["omniroute/claude-sonnet-4-6"].name, + "claude-sonnet-4-6", + "raw id retained" + ); }); test("config: enrichment fetcher throws → soft-fail (warn + raw-id static catalog)", async () => { @@ -949,7 +954,11 @@ test("config: enrichment fetcher throws → soft-fail (warn + raw-id static cata const entry = (input as { provider: Record }).provider .omniroute; assert.ok(entry, "static block still published on enrichment failure"); - assert.equal(entry.models["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained"); + assert.equal( + entry.models["omniroute/claude-sonnet-4-6"].name, + "claude-sonnet-4-6", + "raw id retained" + ); assert.equal(enrichmentFetcher.callCount(), 1); assert.ok( logger.entries.some((e) => String(e[0]).includes("/api/pricing/models fetch failed")), @@ -1143,9 +1152,12 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async ( const entry = (input as { provider: Record }).provider .omniroute; - assert.ok(entry.models["claude-sonnet-4-6"], "stale snapshot hydrated into static block"); + assert.ok( + entry.models["omniroute/claude-sonnet-4-6"], + "stale snapshot hydrated into static block" + ); assert.equal( - entry.models["claude-sonnet-4-6"].name, + entry.models["omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6 (cached)", "stale enrichment also reused" ); @@ -1192,7 +1204,7 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe const entry = (input as { provider: Record }).provider .omniroute; - assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); + assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); }); // ───────────────────────────────────────────────────────────────────── @@ -1241,10 +1253,10 @@ test("config: providerTag (default-on) prepends ' - ' to enriched raw- const entry = (input as { provider: Record }).provider .omniroute; assert.ok(entry); - assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6"); - assert.equal(entry.models["gemini-3-flash"].name, "Gemini-cli - Gemini 3 Flash"); + assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6"); + assert.equal(entry.models["omniroute/gemini-3-flash"].name, "Gemini-cli - Gemini 3 Flash"); // Combos stay untouched — `Combo: ` prefix already conveys multi-upstream. - assert.equal(entry.models["combo/claude-tier"].name, "Combo: Claude Tier"); + assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier"); }); test("config: providerTag=false suppresses the suffix", async () => { @@ -1270,7 +1282,7 @@ test("config: providerTag=false suppresses the suffix", async () => { const entry = (input as { provider: Record }).provider .omniroute; assert.equal( - entry.models["claude-sonnet-4-6"].name, + entry.models["omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6", "enriched name kept, provider tag suppressed" ); @@ -1301,7 +1313,7 @@ test("config: providerTag falls back to UPPER(alias) when providerDisplayName mi const entry = (input as { provider: Record }).provider .omniroute; - assert.equal(entry.models["claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6"); + assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6"); }); test("config: providerTag skipped entirely when neither providerDisplayName nor providerAlias set", async () => { @@ -1327,7 +1339,7 @@ test("config: providerTag skipped entirely when neither providerDisplayName nor const entry = (input as { provider: Record }).provider .omniroute; - assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); + assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); }); test("config: providerTag is idempotent — second hook call doesn't double-suffix", async () => { @@ -1353,14 +1365,14 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff await hook(inputA); const entryA = (inputA as { provider: Record }).provider .omniroute; - assert.equal(entryA.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6"); + assert.equal(entryA.models["omniroute/claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6"); // Second invocation (cache hit) — name must still be single-suffixed. const inputB = makeInput(); await hook(inputB); const entryB = (inputB as { provider: Record }).provider .omniroute; - assert.equal(entryB.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6"); + assert.equal(entryB.models["omniroute/claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6"); }); // ──────────────────────────────────────────────────────────────────────────── @@ -1412,7 +1424,7 @@ test("buildStaticProviderEntry: nested combo-ref context is the bottleneck acros ); // Pre-fix: Parent would advertise 200_000 (only raw-big counted). // Post-fix: Parent should advertise 8_000 (TinyCombo bottleneck). - const parent = block.models["combo/parent"]; + const parent = block.models["omniroute/parent"]; assert.ok(parent, "Parent combo must be in the static catalog"); assert.equal(parent.limit?.context, 8_000); }); diff --git a/@omniroute/opencode-plugin/tests/features.test.ts b/@omniroute/opencode-plugin/tests/features.test.ts index d245ee9aca..20c54b61d5 100644 --- a/@omniroute/opencode-plugin/tests/features.test.ts +++ b/@omniroute/opencode-plugin/tests/features.test.ts @@ -376,7 +376,7 @@ test("provider hook: enrichment fetcher called when features.enrichment !== fals ); const out = await hook.models!({} as never, { auth: apiAuth("sk") as never }); assert.equal(called, 1, "enrichment fetcher called once"); - const m = out["claude-sonnet-4-6"]; + const m = out["omniroute/claude-sonnet-4-6"]; assert.equal(m.name, "Claude Sonnet 4.6", "enrichment name overlay applied"); assert.equal(m.cost.input, 3, "enrichment pricing applied"); assert.equal(m.cost.output, 15); @@ -401,7 +401,7 @@ test("provider hook: enrichment fetcher NOT called when features.enrichment:fals ); const out = await hook.models!({} as never, { auth: apiAuth("sk") as never }); assert.equal(called, 0, "enrichment fetcher NOT called when gated off"); - assert.equal(out["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id preserved"); + assert.equal(out["omniroute/claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id preserved"); }); test("provider hook: compression metadata fetcher NOT called by default (opt-in)", async () => { @@ -459,7 +459,7 @@ test("provider hook: compression metadata fetcher called when opted in", async ( ); const out = await hook.models!({} as never, { auth: apiAuth("sk") as never }); assert.equal(called, 1, "compression metadata fetcher called"); - const combo = out["combo/claude-primary"]; + const combo = out["omniroute/claude-primary"]; assert.ok(combo, "combo entry present"); assert.match( combo.name, diff --git a/@omniroute/opencode-plugin/tests/provider.test.ts b/@omniroute/opencode-plugin/tests/provider.test.ts index f0b3a16fd4..2e4893216d 100644 --- a/@omniroute/opencode-plugin/tests/provider.test.ts +++ b/@omniroute/opencode-plugin/tests/provider.test.ts @@ -101,7 +101,7 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it assert.equal(fetcher.callCount(), 1); assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]); assert.equal(Object.keys(out).length, 3); - assert.ok(out["claude-primary"]); + assert.ok(out["omniroute/claude-primary"]); }); test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => { @@ -152,9 +152,11 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => { { fetcher, combosFetcher: async () => [] } ); const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never }); - const claude = out["claude-primary"]; + const claude = out["omniroute/claude-primary"]; assert.ok(claude, "claude-primary present"); - assert.equal(claude.id, "claude-primary"); + // `mapRawModelToModelV2` stamps the provider prefix on the id so OC's + // static-catalog reader resolves `(providerID, modelID)` from the key. + assert.equal(claude.id, "omniroute/claude-primary"); assert.equal(claude.name, "claude-primary"); assert.equal(claude.providerID, "omniroute"); assert.equal(claude.api.id, "openai-compatible");