From 5f7f74dc6ade2d17e0bf52a50f7c1cf68cf478f5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:56:16 -0300 Subject: [PATCH] fix(dashboard): qualify vendor-namespaced Playground models with provider prefix (#3050) (#3102) The provider Playground (LlmChatCard) only added the providerId/ prefix to models without a slash, so vendor-namespaced ids (moonshotai/kimi-k2.6, nvidia/zyphra/...) were sent bare and rejected with 'Ambiguous model' when the same id exists under multiple providers. Extracted qualifyPlaygroundModel() which always prefixes with the provider unless already qualified. Bug 1 ('unhashable type: dict') is an upstream NVIDIA NIM server error, not OmniRoute. Tests: 4 cases for the qualifier. --- CHANGELOG.md | 1 + .../components/LlmChatCard.tsx | 31 ++++++++++++++----- tests/unit/playground-model-qualify.test.ts | 31 +++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 tests/unit/playground-model-qualify.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 72fc125322..9a28591224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ _Development cycle in progress — entries are added as work merges into `releas ### 🔧 Bug Fixes +- **dashboard:** fix "Ambiguous model" error in the provider Playground for vendor-namespaced models — the Playground only prefixed models without a `/`, so ids like `moonshotai/kimi-k2.6` or `nvidia/zyphra/zamba2-7b-instruct` (NVIDIA NIM) were sent bare and rejected when the same id exists under multiple providers. The Playground now always qualifies the selected model with its `providerId/` prefix (without double-prefixing). ([#3050](https://github.com/diegosouzapw/OmniRoute/issues/3050)) - **db:** stop accepting duplicate API keys for the same provider — `createProviderConnection` now dedups by the decrypted key value (not just by name), so re-adding the same key under a different/blank name updates the existing connection instead of inserting a second row. Whitespace-only differences also dedup. ([#3023](https://github.com/diegosouzapw/OmniRoute/issues/3023)) - **dashboard:** "Import from /models" now works for no-auth providers (e.g. OpenCode Free) — the button used to silently no-op because no-auth providers have no connection row, so `handleImportModels` returned early and the models route 404'd. The route now serves the provider's model catalog when called with a no-auth provider id, and the dashboard falls back to the provider id when there is no connection. ([#3047](https://github.com/diegosouzapw/OmniRoute/issues/3047)) - **providers:** forward Grok's paired `sso-rw` cookie for grok-web — both the executor and the connection validator now send `sso=…; sso-rw=…` (via the new `buildGrokCookieHeader` helper) when the pasted blob carries `sso-rw`, fixing the `403` _"Request rejected by anti-bot rules"_ that Grok returns for `sso` alone. The add-account hint now asks for the full cookie line. ([#3063](https://github.com/diegosouzapw/OmniRoute/issues/3063)) diff --git a/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx index 12c9b304e1..12bdd585c2 100644 --- a/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx +++ b/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx @@ -15,6 +15,25 @@ import { useProviderModels } from "../../providers/hooks/useProviderModels"; const ENDPOINT = "/api/v1/chat/completions"; +/** + * Qualify a provider-scoped playground model with its `providerId/` prefix so + * OmniRoute can resolve it unambiguously. The previous heuristic only prefixed + * models without a `/`, which skipped vendor-namespaced ids like + * `moonshotai/kimi-k2.6` or `nvidia/zyphra/zamba2-7b-instruct` — those already + * contain a slash, so they were sent bare and rejected with + * "Ambiguous model ... Use provider/model prefix" when the same id exists under + * several providers (#3050). Always prefix unless the id is already qualified + * with this provider. + */ +export function qualifyPlaygroundModel( + model: string | null | undefined, + providerId: string | null | undefined +): string { + const m = (model ?? "").trim(); + if (!m || !providerId) return m; + return m === providerId || m.startsWith(`${providerId}/`) ? m : `${providerId}/${m}`; +} + interface Message { role: "user" | "assistant"; content: string; @@ -125,13 +144,11 @@ export function LlmChatCard({ const firstModel = models[0]?.id ?? ""; const effectiveModel = model || firstModel || initialModel || ""; - // Auto-prefix model with providerId when no provider/model prefix present, to avoid - // OmniRoute "Ambiguous model" rejection when same alias is registered under multiple providers. - const qualifiedModel = effectiveModel.includes("/") - ? effectiveModel - : providerId - ? `${providerId}/${effectiveModel}` - : effectiveModel; + // Auto-prefix model with providerId to avoid OmniRoute "Ambiguous model" + // rejection when the same id is registered under multiple providers. This + // also covers vendor-namespaced ids (e.g. `moonshotai/kimi-k2.6`) that already + // contain a slash but still need the provider prefix (#3050). + const qualifiedModel = qualifyPlaygroundModel(effectiveModel, providerId); // Autofocus textarea in embedded mode useEffect(() => { diff --git a/tests/unit/playground-model-qualify.test.ts b/tests/unit/playground-model-qualify.test.ts new file mode 100644 index 0000000000..4d85e1022d --- /dev/null +++ b/tests/unit/playground-model-qualify.test.ts @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { qualifyPlaygroundModel } = await import( + "../../src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx" +); + +// #3050 — vendor-namespaced model ids already contain a "/", so the old +// `.includes("/")` heuristic skipped the provider prefix and the request was +// rejected with "Ambiguous model 'moonshotai/kimi-k2.6'". +test("qualifyPlaygroundModel prefixes a vendor-namespaced model with providerId (#3050)", () => { + assert.equal(qualifyPlaygroundModel("moonshotai/kimi-k2.6", "nim"), "nim/moonshotai/kimi-k2.6"); + assert.equal( + qualifyPlaygroundModel("nvidia/zyphra/zamba2-7b-instruct", "nim"), + "nim/nvidia/zyphra/zamba2-7b-instruct" + ); +}); + +test("qualifyPlaygroundModel prefixes a bare model", () => { + assert.equal(qualifyPlaygroundModel("gpt-4o", "openai"), "openai/gpt-4o"); +}); + +test("qualifyPlaygroundModel does not double-prefix an already-qualified model", () => { + assert.equal(qualifyPlaygroundModel("nim/moonshotai/kimi-k2.6", "nim"), "nim/moonshotai/kimi-k2.6"); + assert.equal(qualifyPlaygroundModel("nim", "nim"), "nim"); +}); + +test("qualifyPlaygroundModel returns the model unchanged without a providerId", () => { + assert.equal(qualifyPlaygroundModel("moonshotai/kimi-k2.6", ""), "moonshotai/kimi-k2.6"); + assert.equal(qualifyPlaygroundModel("", "nim"), ""); +});