From 74dd34fe99bbe60d22b124346fcfc7198f27d2c6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:38:43 -0300 Subject: [PATCH] fix(cli): stop double-prefixing combo model ids in opencode plugin static catalog (#7976) (#8047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildStaticProviderEntry() keyed static-catalog combo entries with opts.providerId (the OC-gate-prefixed id, e.g. "opencode-omniroute") instead of opts.omnirouteProviderId (the bare server-facing id, "omniroute") that the dynamic provider.models() hook already uses per #6859. OC dispatches the static models-map key verbatim as the `model` field of the outbound request, so a bare-slug combo key doubled up to "opencode-omniroute/opencode-omniroute/" and OmniRoute's parseModel() resolved credentials for the nonexistent provider "opencode-omniroute" instead of "omniroute". Regular models were unaffected because their raw ids already contain a slash, skipping the prefixing branch entirely. Swap the buildComboKey() call to use opts.omnirouteProviderId, mirroring the dynamic hook. Adds a permanent regression test to provider-id-routing.test.ts and aligns the pre-existing hardcoded "opencode-omniroute/" assertions in config-shim.test.ts that had codified the buggy prefix. Co-authored-by: Fábio Silva <13762289+fabioluissilva@users.noreply.github.com> --- @omniroute/opencode-plugin/src/index.ts | 11 ++++- .../opencode-plugin/tests/config-shim.test.ts | 14 +++---- .../tests/provider-id-routing.test.ts | 42 +++++++++++++++++++ .../7976-opencode-combo-double-prefix.md | 1 + 4 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 changelog.d/fixes/7976-opencode-combo-double-prefix.md diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 526fa99799..e96180aa49 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -3939,7 +3939,16 @@ export function buildStaticProviderEntry( // `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, opts.providerId)] = entry; + // #6859: server-facing key — NOT the OC-gate-prefixed `opts.providerId`. + // OC dispatches the static-catalog `models` map key VERBATIM as the + // `model` field of the outbound `@ai-sdk/openai-compatible` request + // (only the top-level `provider[""]` segment is stripped for + // routing) — so a bare-slug combo key prefixed with the OC-gated + // `opts.providerId` reaches OmniRoute's server doubled + // (`opencode-omniroute/opencode-omniroute/`), and `parseModel()` + // resolves credentials for the nonexistent provider `opencode-omniroute` + // instead of `omniroute`. See #7976. + models[buildComboKey(combo, usedComboKeys, opts.omnirouteProviderId)] = 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/config-shim.test.ts b/@omniroute/opencode-plugin/tests/config-shim.test.ts index 8bfd5fe91b..04ec61f1b7 100644 --- a/@omniroute/opencode-plugin/tests/config-shim.test.ts +++ b/@omniroute/opencode-plugin/tests/config-shim.test.ts @@ -248,7 +248,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider // Combo surfaces under bare key + LCD'd // (gemini's reasoning=false → combo reasoning=false). - const combo = entry.models["opencode-omniroute/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"); @@ -474,7 +474,7 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m "opencode-omniroute/claude-sonnet-4-6", "opencode-omniroute/gemini-3-flash", ]); - assert.equal(entry.models["opencode-omniroute/claude-tier"], undefined, "no combo entry"); + 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" @@ -748,7 +748,7 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => { "https://or.example/v1", "sk-test" ); - assert.equal(block.models["opencode-omniroute/claude-tier"], undefined); + assert.equal(block.models["omniroute/claude-tier"], undefined); assert.ok(block.models["opencode-omniroute/claude-sonnet-4-6"]); }); @@ -858,7 +858,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD) "https://or.example/v1", "sk-test" ); - const combo = block.models["opencode-omniroute/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"]); @@ -970,7 +970,7 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async () assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); assert.equal(entry.models["opencode-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["opencode-omniroute/claude-tier"].name, "Claude Tier"); + assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier"); assert.equal(enrichmentFetcher.callCount(), 1); }); @@ -1337,7 +1337,7 @@ test("config: providerTag (default-on) prepends ' - ' to enriched raw- ); assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini - Gemini 3 Flash"); // Combos stay untouched — `Combo: ` prefix already conveys multi-upstream. - assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier"); + assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier"); }); test("config: providerTag=false suppresses the suffix", async () => { @@ -1516,7 +1516,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["opencode-omniroute/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/provider-id-routing.test.ts b/@omniroute/opencode-plugin/tests/provider-id-routing.test.ts index eb01aac703..a55e935475 100644 --- a/@omniroute/opencode-plugin/tests/provider-id-routing.test.ts +++ b/@omniroute/opencode-plugin/tests/provider-id-routing.test.ts @@ -22,9 +22,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + buildStaticProviderEntry, createOmniRouteProviderHook, mapRawModelToModelV2, resolveOmniRoutePluginOptions, + type OmniRouteRawCombo, } from "../src/index.js"; /** @@ -97,3 +99,43 @@ test("#6859: createOmniRouteProviderHook end-to-end — catalog keys/providerID "the OC-gate prefix must never leak into ModelV2.providerID" ); }); + +// #7976: buildStaticProviderEntry (the STATIC provider() config-hook path, +// exercised when the plugin writes `opencode.json` up front rather than +// registering the dynamic `provider.models()` hook) never received the +// #6859 fix. OC dispatches a static-catalog `models` map key verbatim as +// the `model` field of the outbound request — only the top-level +// `provider[""]` segment is stripped for routing — so a bare-slug combo +// key built with the OC-gated `providerId` reaches OmniRoute's server +// doubled and fails credential lookup for the nonexistent provider +// `opencode-omniroute`. Confirmed against the issue's own curl repro +// (`model: "opencode-omniroute/hermes-smart-stack"` → "No active +// credentials for provider: opencode-omniroute"). +test("#7976: buildStaticProviderEntry keys bare-slug combo ids with the unprefixed omnirouteProviderId (no double OC-gate prefix)", () => { + const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" }); + assert.equal(resolved.providerId, "opencode-omniroute"); + assert.equal(resolved.omnirouteProviderId, "omniroute"); + + const combo = { + id: "combo-abc123", + name: "Hermes Smart Stack", + isHidden: false, + models: [], + } as unknown as OmniRouteRawCombo; + + const block = buildStaticProviderEntry( + [], + [combo], + resolved, + "https://or.example/v1", + "sk-test" + ); + + assert.deepEqual(Object.keys(block.models), ["omniroute/hermes-smart-stack"]); + assert.equal( + block.models["opencode-omniroute/hermes-smart-stack"], + undefined, + "combo key must not carry the OC-gate-prefixed providerId — it doubles up once " + + "OC dispatches it verbatim as the `model` field" + ); +}); diff --git a/changelog.d/fixes/7976-opencode-combo-double-prefix.md b/changelog.d/fixes/7976-opencode-combo-double-prefix.md new file mode 100644 index 0000000000..36570e28a5 --- /dev/null +++ b/changelog.d/fixes/7976-opencode-combo-double-prefix.md @@ -0,0 +1 @@ +- fix(cli): stop double-prefixing combo model ids in the opencode plugin's static provider catalog (#7976)