From 185d4c49592a77ac424a11057a828f29486c08be Mon Sep 17 00:00:00 2001 From: herjarsa Date: Thu, 18 Jun 2026 22:47:55 +0200 Subject: [PATCH 1/2] fix(plugin): drop 'combo/' prefix from combo model keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode parses model IDs on '/' to extract a provider prefix, and a key like 'combo/MASTER' was being treated as provider=combo, model=MASTER. That fails credential resolution because no 'combo' provider is registered in auth.json — only 'omniroute' is. After this fix, combos surface under their bare slug (e.g. 'MASTER', 'MASTER-LIGHT', 'claude-tier') in both the dynamic provider hook and the static config hook, while the raw model dedup and the disambiguator suffix logic still prevent collisions. Closes: PR #4184 (rebased on top of the theoldllm context fix) Test plan: 259 plugin tests pass (config-shim, combos, features, schema) --- @omniroute/opencode-plugin/src/index.ts | 48 ++++++++----------- .../opencode-plugin/tests/combos.test.ts | 37 +++++++------- .../opencode-plugin/tests/config-shim.test.ts | 16 +++---- .../opencode-plugin/tests/features.test.ts | 2 +- 4 files changed, 46 insertions(+), 57 deletions(-) diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index f70807434c5..035132ef8ff 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -2248,26 +2248,32 @@ 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 (bare slug like `MASTER`, `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 + * bare key. * - * Falls back to `combo/` when the friendly name slugifies to the empty + * NOTE: the key MUST NOT carry a `combo/` namespace prefix — OpenCode + * parses model IDs on `/` to extract a provider prefix, and `combo/MASTER` + * would be treated as provider=`combo`, model=`MASTER`, causing a + * credentials-not-found error. See PR #4184. + * + * Falls back to bare `` when the friendly name slugifies to the empty * string (e.g. a combo named just punctuation). */ export function buildComboKey(combo: OmniRouteRawCombo, used: Set): 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 = slug; if (used.has(key)) { const tail = combo.id.split("-")[0] ?? combo.id; - key = `combo/${slug}-${tail}`; + key = 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 = slug + "-" + combo.id; } used.add(key); return key; @@ -2801,18 +2807,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); @@ -3717,12 +3711,8 @@ 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; const entry: OmniRouteStaticModelEntry = { name: displayName }; if (hasMembers) { @@ -3790,9 +3780,9 @@ 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; diff --git a/@omniroute/opencode-plugin/tests/combos.test.ts b/@omniroute/opencode-plugin/tests/combos.test.ts index 1b843cf1518..2e3c8eded86 100644 --- a/@omniroute/opencode-plugin/tests/combos.test.ts +++ b/@omniroute/opencode-plugin/tests/combos.test.ts @@ -450,10 +450,10 @@ test("models() returns combo entries merged into the map", async () => { 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["claude-tier"]); - const combo = out["combo/claude-tier"]; - assert.equal(combo.name, "Combo: Claude Tier"); + const combo = out["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["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["phantom-combo"].capabilities.toolcall, false); + assert.equal(out["phantom-combo"].capabilities.reasoning, false); + assert.equal(out["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["visible"]); + assert.ok(!out["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["claude-primary"], "combo surfaces under bare-slug key"); + assert.equal(out["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["claude"], "first combo at bare slug"); + assert.ok(out["claude-uuid"], "second combo disambiguated by id prefix"); }); test("models(): combos fetch fails → falls back to models-only, warn emitted, no throw", async () => { @@ -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["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["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 e95e108beca..35c147ae4ae 100644 --- a/@omniroute/opencode-plugin/tests/config-shim.test.ts +++ b/@omniroute/opencode-plugin/tests/config-shim.test.ts @@ -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["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)"); @@ -788,7 +788,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["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"]); @@ -899,7 +899,7 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async () assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); assert.equal(entry.models["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["claude-tier"].name, "Claude Tier"); assert.equal(enrichmentFetcher.callCount(), 1); }); @@ -1244,7 +1244,7 @@ test("config: providerTag (default-on) prepends ' - ' to enriched raw- 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"); // Combos stay untouched — `Combo: ` prefix already conveys multi-upstream. - assert.equal(entry.models["combo/claude-tier"].name, "Combo: Claude Tier"); + assert.equal(entry.models["claude-tier"].name, "Claude Tier"); }); test("config: providerTag=false suppresses the suffix", async () => { @@ -1412,7 +1412,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["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 d245ee9acaf..7b4a68e70d8 100644 --- a/@omniroute/opencode-plugin/tests/features.test.ts +++ b/@omniroute/opencode-plugin/tests/features.test.ts @@ -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["claude-primary"]; assert.ok(combo, "combo entry present"); assert.match( combo.name, From e983499d55a83a42cad3297994d7eb2da4cf1eed Mon Sep 17 00:00:00 2001 From: herjarsa Date: Fri, 19 Jun 2026 12:12:03 +0200 Subject: [PATCH 2/2] fix(plugin): emit providerID on static-catalog entries After the combo/ prefix removal, bare-slug combo keys like 'MASTER' are misread by OC's static-catalog reader: the model key has no '/' so the entire string becomes the providerID, and the lookup fails with 'Unable to determine provider for model master'. Match the OLD opencode-omniroute-auth@1.2.2 behavior (which already worked) and stamp providerID on every static entry so OC can resolve the credentials from the explicit field instead of parsing the key. Closes: PR #4184 follow-up Test plan: 259 plugin tests pass --- @omniroute/opencode-plugin/src/index.ts | 19 ++++++++++++++----- .../opencode-plugin/tests/config-shim.test.ts | 1 + 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 035132ef8ff..860fef7305e 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -1154,7 +1154,8 @@ const AUTO_COMBO_FALLBACK_OUTPUT = 8_192; * applies when the server omits them. Never 0. */ export function mapAutoComboToStaticEntry( - autoCombo: OmniRouteRawAutoCombo + autoCombo: OmniRouteRawAutoCombo, + providerID: string ): OmniRouteStaticModelEntry { const variant = autoCombo.variant; const name = formatAutoComboName(variant, autoCombo.candidateCount); @@ -1168,6 +1169,7 @@ export function mapAutoComboToStaticEntry( : AUTO_COMBO_FALLBACK_OUTPUT; return { name, + providerID, attachment: false, reasoning: true, temperature: true, @@ -2856,7 +2858,7 @@ export function createOmniRouteProviderHook( for (const autoCombo of rawAutoCombos) { if (!autoCombo || !autoCombo.id) continue; if (autoCombo.isHidden === true) continue; - const entry = mapAutoComboToStaticEntry(autoCombo); + const entry = mapAutoComboToStaticEntry(autoCombo, resolved.providerId); const key = autoComboModelId(autoCombo.variant); const mapped: ModelV2 = { id: key, @@ -3340,8 +3342,15 @@ function normaliseModalities(raw: unknown): OmniRouteModalityKind[] { } export interface OmniRouteStaticModelEntry { + /** Owning provider id. MUST match the parent `provider.` key so OC's + * static-catalog reader resolves credentials via `providerID` instead of + * parsing the model key on `/`. Without this, a bare-slug combo key like + * `MASTER` is misread as `providerID=MASTER, modelID=""` and the request fails + * with "Unable to determine provider". 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. */ @@ -3539,7 +3548,7 @@ export function buildStaticProviderEntry( if (!displayName.startsWith(prefix)) displayName = `${prefix}${displayName}`; } } - const entry: OmniRouteStaticModelEntry = { name: displayName }; + const entry: OmniRouteStaticModelEntry = { name: displayName, providerID: opts.providerId }; const attachment = caps.attachment ?? caps.vision; if (typeof attachment === "boolean") entry.attachment = attachment; @@ -3713,7 +3722,7 @@ export function buildStaticProviderEntry( combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id; const displayName = hasMembers && compressionSuffix ? `${friendlyName} ${compressionSuffix}` : friendlyName; - const entry: OmniRouteStaticModelEntry = { name: displayName }; + const entry: OmniRouteStaticModelEntry = { name: displayName, providerID: opts.providerId }; if (hasMembers) { // LCD across capabilities — every member must support for the combo @@ -3816,7 +3825,7 @@ export function buildStaticProviderEntry( for (const autoCombo of rawAutoCombos) { if (!autoCombo || !autoCombo.id) continue; if (autoCombo.isHidden === true) continue; - const entry = mapAutoComboToStaticEntry(autoCombo); + const entry = mapAutoComboToStaticEntry(autoCombo, opts.providerId); // Use the variant as the key: "auto", "auto/coding", etc. const key = autoComboModelId(autoCombo.variant); if (models[key]) { diff --git a/@omniroute/opencode-plugin/tests/config-shim.test.ts b/@omniroute/opencode-plugin/tests/config-shim.test.ts index 35c147ae4ae..06468380dd9 100644 --- a/@omniroute/opencode-plugin/tests/config-shim.test.ts +++ b/@omniroute/opencode-plugin/tests/config-shim.test.ts @@ -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)) {