diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json index 491de78c16..717c101001 100644 --- a/@omniroute/opencode-plugin/package.json +++ b/@omniroute/opencode-plugin/package.json @@ -23,7 +23,7 @@ "scripts": { "build": "tsup", "clean": "rm -rf dist", - "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts", + "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts", "prepublishOnly": "npm run clean && npm run build && npm test" }, "keywords": [ diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index d69383b087..cb154fb513 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -238,9 +238,26 @@ function trimLeadingDashes(value: string): string { */ export function resolveOmniRoutePluginOptions( opts?: OmniRoutePluginOptions -): Required> & - Pick { +): Required< + Pick +> & { + /** + * #6859: the UNPREFIXED provider id ("omniroute", "omniroute-preprod", …). + * `providerId` above is auto-prefixed with "opencode-" ONLY to satisfy OC + * 1.17.8+'s native-adapter gate ({openai, anthropic, opencode*}) — that + * prefixed value is OC-internal and must be used ONLY for AuthHook.provider + * and provider-registration keys (the OC config-hook top-level + * `provider.` block). `omnirouteProviderId` MUST be used everywhere an + * identifier reaches or represents something OmniRoute's own server parses + * (model `id` prefix, `ModelV2.providerID`, combo catalog keys in the + * dynamic provider hook) — OmniRoute's `parseModel()` has no alias for + * "opencode-", so a prefixed id there is unrecoverable and credential + * lookup fails with "No credentials for opencode-". + */ + omnirouteProviderId: string; +} & Pick { const rawProviderId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY; + const omnirouteProviderId = trimLeadingOpencodePrefix(rawProviderId); // OC 1.17.8+ native-adapter gate rejects providerID not in // {openai, anthropic, opencode*}. Silently prefix so existing // configs (providerId: "omniroute") keep working. @@ -258,6 +275,7 @@ export function resolveOmniRoutePluginOptions( : DEFAULT_MODEL_CACHE_TTL_MS; return { providerId, + omnirouteProviderId, displayName, modelCacheTtl, baseURL: opts?.baseURL, @@ -265,6 +283,18 @@ export function resolveOmniRoutePluginOptions( }; } +/** + * Strip a leading "opencode-" prefix (added only for the OC native-adapter + * gate — see `resolveOmniRoutePluginOptions`) so the returned id is safe to + * embed in anything OmniRoute's own server parses. A user-supplied + * `providerId: "opencode-omniroute"` (already prefixed) resolves to the same + * unprefixed "omniroute" as the default, matching `providerId`'s own + * idempotent-prefix handling above. + */ +function trimLeadingOpencodePrefix(rawProviderId: string): string { + return rawProviderId.startsWith("opencode-") ? rawProviderId.slice("opencode-".length) : rawProviderId; +} + /** * Strict parse of raw plugin options (as received from opencode.json or a * direct factory call) into the validated `OmniRoutePluginOptions` shape. @@ -2661,7 +2691,8 @@ export function createOmniRouteProviderHook( if (canonicalDedup.has(entry.id)) continue; if (usable && !isUsableRawModelId(entry.id, usable, rawEnrichment)) continue; const model = mapRawModelToModelV2(entry, { - providerId: resolved.providerId, + // #6859: server-facing id — NOT the OC-gate-prefixed `resolved.providerId`. + providerId: resolved.omnirouteProviderId, baseURL, apiFormat: resolved.features?.apiFormat, }); @@ -2826,7 +2857,8 @@ export function createOmniRouteProviderHook( const mapped = mapComboToModelV2( combo, memberEntries, - resolved.providerId, + // #6859: server-facing id — NOT the OC-gate-prefixed `resolved.providerId`. + resolved.omnirouteProviderId, baseURL, features.apiFormat ); @@ -2845,7 +2877,8 @@ export function createOmniRouteProviderHook( } } - const comboKey = buildComboKey(combo, usedComboKeys, resolved.providerId); + // #6859: server-facing key — NOT the OC-gate-prefixed `resolved.providerId`. + const comboKey = buildComboKey(combo, usedComboKeys, resolved.omnirouteProviderId); // Collision policy: combos win. Warn ONCE per (cacheKey, comboKey) // when overwriting a same-key raw model so the operator can spot @@ -2947,7 +2980,8 @@ export function createOmniRouteProviderHook( }, status: "active", release_date: "", - providerID: resolved.providerId, + // #6859: server-facing id — NOT the OC-gate-prefixed `resolved.providerId`. + providerID: resolved.omnirouteProviderId, options: {}, headers: {}, }; diff --git a/@omniroute/opencode-plugin/tests/combos.test.ts b/@omniroute/opencode-plugin/tests/combos.test.ts index 04102c0055..ff209a9a67 100644 --- a/@omniroute/opencode-plugin/tests/combos.test.ts +++ b/@omniroute/opencode-plugin/tests/combos.test.ts @@ -447,14 +447,14 @@ 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["opencode-omniroute/claude-primary"]); - assert.ok(out["opencode-omniroute/claude-secondary"]); - assert.ok(out["opencode-omniroute/gemini-3-flash"]); - assert.ok(out["opencode-omniroute/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["opencode-omniroute/claude-tier"]; + const combo = out["omniroute/claude-tier"]; assert.equal(combo.name, "Claude Tier"); - assert.equal(combo.providerID, "opencode-omniroute"); + assert.equal(combo.providerID, "omniroute"); // LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning) assert.equal(combo.limit.context, 100_000); assert.equal(combo.capabilities.reasoning, false); @@ -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["opencode-omniroute/phantom-combo"]); + assert.ok(out["omniroute/phantom-combo"]); // With zero resolvable members, LCD = all-false (defensive posture). - assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.toolcall, false); - assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.reasoning, false); - assert.equal(out["opencode-omniroute/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,8 +505,8 @@ 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["opencode-omniroute/visible"]); - assert.ok(!out["opencode-omniroute/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, raw deleted, no warn", async () => { @@ -530,8 +530,8 @@ test("models(): combo name exactly matches raw model id → raw deleted, raw del }); // Raw model replaced by combo of the same key; combo now lives at the bare slug. - assert.ok(out["opencode-omniroute/claude-primary"], "combo surfaces under prefixed key"); - assert.equal(out["opencode-omniroute/claude-primary"].name, "claude-primary"); + 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) => { @@ -565,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["opencode-omniroute/claude"], "first combo at prefixed slug"); - assert.ok(out["opencode-omniroute/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 () => { @@ -583,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["opencode-omniroute/claude-primary"]); - assert.ok(out["opencode-omniroute/claude-secondary"]); + assert.ok(out["omniroute/claude-primary"]); + assert.ok(out["omniroute/claude-secondary"]); // Soft-fail warning surfaced. const softFail = warnings.find((w) => { @@ -609,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["opencode-omniroute/claude-tier"]); + assert.ok(second["omniroute/claude-tier"]); }); test("models(): combos refetched after TTL expiry (same key as models)", async () => { @@ -701,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["opencode-omniroute/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/features.test.ts b/@omniroute/opencode-plugin/tests/features.test.ts index 73ee37a72b..929a097390 100644 --- a/@omniroute/opencode-plugin/tests/features.test.ts +++ b/@omniroute/opencode-plugin/tests/features.test.ts @@ -376,7 +376,8 @@ 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["opencode-omniroute/claude-sonnet-4-6"]; + // #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId. + 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); @@ -402,7 +403,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["opencode-omniroute/claude-sonnet-4-6"].name, + out["omniroute/claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id preserved" ); @@ -463,7 +464,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["opencode-omniroute/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-id-routing.test.ts b/@omniroute/opencode-plugin/tests/provider-id-routing.test.ts new file mode 100644 index 0000000000..eb01aac703 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/provider-id-routing.test.ts @@ -0,0 +1,99 @@ +/** + * Regression test for #6859. + * + * `resolveOmniRoutePluginOptions()` auto-prefixes `providerId` with + * `"opencode-"` (commit 75b52e286) so OpenCode 1.17.8+'s native-adapter gate + * accepts it as an OC-registered provider id. That prefixed value must stay + * OC-internal (AuthHook.provider / provider registration keys) — it must + * NEVER leak into the identifiers OmniRoute's own server parses to resolve + * credentials (`mapRawModelToModelV2`'s `id`/`providerID`, + * `mapComboToModelV2`'s `providerID`, and the dynamic-hook catalog keys). + * + * OmniRoute's server-side `parseModel()` (open-sse/services/model.ts) splits + * a dispatched model string on `/` to recover the provider name and look up + * credentials. If the plugin embeds the OC-gate-prefixed id in that string, + * the server looks up credentials for a provider named "opencode-omniroute" + * (which never exists in `src/shared/constants/providers.ts`) instead of + * "omniroute" — producing the exact "No credentials for opencode-omniroute" / + * "No active credentials for provider: opencode-omniroute" errors reported + * in #6859. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + createOmniRouteProviderHook, + mapRawModelToModelV2, + resolveOmniRoutePluginOptions, +} from "../src/index.js"; + +/** + * Minimal stand-in for OmniRoute's own `parseModel()` (open-sse/services/ + * model.ts), which splits a dispatched `/` string on the + * FIRST "/" to recover the provider name used for credential lookup. Kept + * local (rather than cross-importing the real module) so this package's + * self-contained test suite (`cd @omniroute/opencode-plugin && npm test`) + * doesn't depend on the root repo's `@/*` path-alias resolution. + */ +function splitProviderFromDispatchedModel(modelStr: string): string { + const idx = modelStr.indexOf("/"); + return idx === -1 ? modelStr : modelStr.slice(0, idx); +} + +const apiAuth = (key: string) => ({ type: "api" as const, key }); + +test("#6859: server-facing model id/providerID must resolve to the unprefixed provider name", () => { + const resolved = resolveOmniRoutePluginOptions(); + + // The OC-gate-compatible id stays prefixed — it is legitimate for + // AuthHook.provider / provider registration. + assert.equal(resolved.providerId, "opencode-omniroute"); + + // A second, unprefixed id must be exposed for anything that reaches + // OmniRoute's own server (model id prefix, ModelV2.providerID, combo keys). + assert.equal( + resolved.omnirouteProviderId, + "omniroute", + "resolveOmniRoutePluginOptions() must expose an unprefixed omnirouteProviderId" + ); + + // A bare raw /v1/models entry (no existing "/" in its id — the common + // case for OmniRoute's catalog) mapped with the server-facing id. + const model = mapRawModelToModelV2( + { id: "claude-opus-4-7" }, + { providerId: resolved.omnirouteProviderId, baseURL: "http://localhost:20128" } + ); + + assert.equal(model.providerID, "omniroute"); + assert.equal(model.id, "omniroute/claude-opus-4-7"); + + // OpenCode dispatches back to OmniRoute using `providerID/modelKey` + // (matches the issue's own repro: `-m opencode-omniroute/oc/big-pickle`). + const dispatchedModelString = `${model.providerID}/claude-opus-4-7`; + const parsedProvider = splitProviderFromDispatchedModel(dispatchedModelString); + + assert.equal( + parsedProvider, + "omniroute", + `server-side provider split resolved '${parsedProvider}', expected 'omniroute' — ` + + `credentials lookup would fail for an OC-gate-prefixed provider id` + ); +}); + +test("#6859: createOmniRouteProviderHook end-to-end — catalog keys/providerID never carry the OC-gate prefix", async () => { + const hook = createOmniRouteProviderHook( + { baseURL: "https://or.example.com/v1" }, + { + fetcher: async () => [{ id: "claude-opus-4-7" }], + combosFetcher: async () => [], + } + ); + const out = await hook.models!({} as never, { auth: apiAuth("sk-test") as never }); + const model = out["omniroute/claude-opus-4-7"]; + assert.ok(model, "catalog keyed under the unprefixed provider name"); + assert.equal(model.providerID, "omniroute"); + assert.ok( + !model.providerID.startsWith("opencode-"), + "the OC-gate prefix must never leak into ModelV2.providerID" + ); +}); diff --git a/@omniroute/opencode-plugin/tests/provider.test.ts b/@omniroute/opencode-plugin/tests/provider.test.ts index e4849205ac..20012ddb12 100644 --- a/@omniroute/opencode-plugin/tests/provider.test.ts +++ b/@omniroute/opencode-plugin/tests/provider.test.ts @@ -101,7 +101,10 @@ 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["opencode-omniroute/claude-primary"]); + // #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId + // ("omniroute"), not the OC-gate-prefixed hook.id ("opencode-omniroute") — + // that prefix must never leak into anything OmniRoute's server parses. + assert.ok(out["omniroute/claude-primary"]); }); test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => { @@ -152,13 +155,17 @@ 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["opencode-omniroute/claude-primary"]; + // #6859: dynamic-hook catalog keys/ids/providerID use the unprefixed + // omnirouteProviderId ("omniroute") — the OC-gate prefix ("opencode-") + // must stay OC-internal (hook.id / AuthHook.provider) and never leak into + // anything OmniRoute's own server parses for credential lookup. + const claude = out["omniroute/claude-primary"]; assert.ok(claude, "claude-primary present"); // `mapRawModelToModelV2` stamps the provider prefix on the id so OC's // static-catalog reader resolves `(providerID, modelID)` from the key. - assert.equal(claude.id, "opencode-omniroute/claude-primary"); + assert.equal(claude.id, "omniroute/claude-primary"); assert.equal(claude.name, "claude-primary"); - assert.equal(claude.providerID, "opencode-omniroute"); + assert.equal(claude.providerID, "omniroute"); assert.equal(claude.api.id, "openai-compatible"); assert.equal(claude.api.url, "https://or.example.com/v1"); assert.equal(claude.api.npm, "@ai-sdk/openai-compatible"); diff --git a/changelog.d/fixes/6859-plugin-provider-id.md b/changelog.d/fixes/6859-plugin-provider-id.md new file mode 100644 index 0000000000..9343798009 --- /dev/null +++ b/changelog.d/fixes/6859-plugin-provider-id.md @@ -0,0 +1 @@ +- **fix(plugin):** the `@omniroute/opencode-plugin` dynamic provider hook stopped embedding its OC-1.17.8+-gate-compatible `opencode-`-prefixed provider id into model routing fields (`ModelV2.id`/`providerID`, combo catalog keys) — OmniRoute's server has no `opencode-` provider alias, so every dispatched model failed credential lookup with "No credentials for opencode-omniroute" (#6859).