diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b7814aea4..86f027303f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### 🐛 Bug Fixes +- **fix(dashboard):** passthrough model aliases no longer collide when two namespaced model ids share a last segment. `enx/gpt-5.5` and `enx/codebuddy/gpt-5.5` both auto-generated the alias `gpt-5.5`, so the second model could never be added (the UI just alerted "alias already exists"). Aliases are now disambiguated deterministically — bare last segment when free, then parent-qualified (`codebuddy-gpt-5.5`), then a numeric suffix — while re-adding the exact same model id is still blocked. Regression guard: `tests/unit/passthrough-alias-1850.test.ts`. (thanks @arpicato) - **fix(translator):** preserve a Gemini `functionResponse` co-located with other parts (another `functionCall`, or trailing `text`) in the same content when translating **Gemini → OpenAI**. `convertGeminiContent()` early-returned the tool message on the first `functionResponse` part, dropping any co-located parts; such contents are now pre-split (one tool message per `functionResponse`, emitted first, plus one message for the remaining parts). Regression guard: `tests/unit/gemini-to-openai-function-response.test.ts`. (thanks @warelik) - **fix(headroom):** detect a python interpreter managed by **mise / pyenv / asdf / conda**. Headroom's python probe (`src/lib/headroom/detect.ts`) searched a hardcoded `PATH`, but version managers expose their interpreters via shim dirs that only join `PATH` through interactive-shell activation — which the non-interactive server never runs, so a managed python (≥3.10) was invisible and Headroom reported it missing. The search path now prepends the well-known shim/bin dirs (`~/.local/share/mise/shims`, `~/.pyenv/shims`, `~/.asdf/shims`, `$CONDA_PREFIX/bin`, `~/.local/bin`, respecting `MISE_DATA_DIR`/`PYENV_ROOT`/`ASDF_DATA_DIR` when set), and a new `HEADROOM_PYTHON` env override lets operators point straight at their interpreter (mirroring `HEADROOM_URL`). Still shell-free (`execFileSync`). Regression guard: `tests/unit/headroom-detect.test.ts` (5). (thanks @loopyd) - **fix(executors):** strip the OpenAI-Codex/Claude-CLI `client_metadata` passthrough field for **NVIDIA** requests. NVIDIA's OpenAI-compatible wrapper rejects it with `400 Unsupported parameter`, the same class already handled for `cerebras`/`mistral`; `nvidia` (executor `default`) was missing from the strip allowlist so Codex/Claude-Code passthrough requests 400'd. Regression guard: `tests/unit/executor-default-strip-client-metadata.test.ts` (+nvidia case). (thanks @phidinhmanh) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx index fb0d2b6e62..857281e3bc 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx @@ -14,6 +14,7 @@ */ import React, { useState, useMemo } from "react"; import { Button } from "@/shared/components"; +import { generateUniqueModelAlias } from "./passthroughAlias.ts"; import { matchesModelCatalogQuery, normalizeModelCatalogSource, @@ -323,22 +324,18 @@ export default function PassthroughModelsSection({ : filteredModels; const activeCount = allModels.filter((model) => !model.isHidden).length; - // Generate default alias from modelId (last part after /) - const generateDefaultAlias = (modelId: string) => { - const parts = modelId.split("/"); - return parts[parts.length - 1]; - }; - const handleAdd = async () => { if (!newModel.trim() || adding) return; const modelId = newModel.trim(); - const defaultAlias = generateDefaultAlias(modelId); - // Check if alias already exists - if (modelAliases[defaultAlias]) { - alert(t("aliasExistsAlert", { alias: defaultAlias })); + // #1850: block re-adding the SAME model, but disambiguate DISTINCT models + // that would otherwise collapse to the same last-segment alias (e.g. + // enx/gpt-5.5 vs enx/codebuddy/gpt-5.5 → both "gpt-5.5"). + if (Object.values(modelAliases).includes(modelId)) { + alert(t("aliasExistsAlert", { alias: modelId })); return; } + const defaultAlias = generateUniqueModelAlias(modelId, modelAliases); setAdding(true); try { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/passthroughAlias.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/passthroughAlias.ts new file mode 100644 index 0000000000..472d9d7907 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/passthroughAlias.ts @@ -0,0 +1,47 @@ +/** + * Generate a unique default alias for a passthrough model id (9router#1850). + * + * The naive "last path segment" alias collapses distinct namespaced ids to the + * same alias — e.g. `enx/codebuddy/gpt-5.5` and `enx/gpt-5.5` both become + * `gpt-5.5` — so the second model could never be added (the UI only alerted + * "alias already exists"). This disambiguates deterministically: + * 1. the bare last segment, if free; + * 2. progressively more-qualified names joined with "-" (parent segments + * prepended), if the shorter form is taken; + * 3. a numeric suffix on the last segment as a final fallback. + * + * Pure — no React/DOM deps — so it is unit-testable. + */ +export function generateUniqueModelAlias( + modelId: string, + existingAliases: Record = {} +): string { + const parts = String(modelId ?? "") + .split("/") + .filter(Boolean); + + if (parts.length === 0) { + // No usable segments (e.g. "" or "///") — fall back to the raw id + numeric. + const base = String(modelId ?? "").trim() || "model"; + return isTaken(base, existingAliases) ? nextNumeric(base, existingAliases) : base; + } + + // 1 + 2: try last segment, then last-2 joined, … up to the full path. + for (let take = 1; take <= parts.length; take++) { + const candidate = parts.slice(parts.length - take).join("-"); + if (!isTaken(candidate, existingAliases)) return candidate; + } + + // 3: every qualified form is taken → numeric suffix on the last segment. + return nextNumeric(parts[parts.length - 1], existingAliases); +} + +function isTaken(alias: string, existingAliases: Record): boolean { + return Object.prototype.hasOwnProperty.call(existingAliases, alias); +} + +function nextNumeric(base: string, existingAliases: Record): string { + let i = 2; + while (isTaken(`${base}-${i}`, existingAliases)) i++; + return `${base}-${i}`; +} diff --git a/tests/unit/passthrough-alias-1850.test.ts b/tests/unit/passthrough-alias-1850.test.ts new file mode 100644 index 0000000000..cc22d84b7d --- /dev/null +++ b/tests/unit/passthrough-alias-1850.test.ts @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { generateUniqueModelAlias } from "../../src/app/(dashboard)/dashboard/providers/[id]/components/passthroughAlias.ts"; + +/** + * Regression guard for upstream 9router#1850. + * + * The naive last-segment alias collapsed distinct namespaced model ids to the + * same alias, blocking the second model from ever being added. + */ + +test("bare last segment when free (preserves the common case)", () => { + assert.equal(generateUniqueModelAlias("enx/gpt-5.5", {}), "gpt-5.5"); + assert.equal(generateUniqueModelAlias("gpt-5.5", {}), "gpt-5.5"); +}); + +test("#1850: namespaced ids that share a last segment get distinct aliases", () => { + const aliases: Record = {}; + const a = generateUniqueModelAlias("enx/gpt-5.5", aliases); + aliases[a] = { modelId: "enx/gpt-5.5" }; + const b = generateUniqueModelAlias("enx/codebuddy/gpt-5.5", aliases); + + assert.equal(a, "gpt-5.5"); + assert.equal(b, "codebuddy-gpt-5.5", "second model must not collide with the first"); + assert.notEqual(a, b); +}); + +test("falls back to a numeric suffix when every qualified form is taken", () => { + const aliases: Record = { + "gpt-5.5": {}, + "codebuddy-gpt-5.5": {}, + "enx-codebuddy-gpt-5.5": {}, + }; + const alias = generateUniqueModelAlias("enx/codebuddy/gpt-5.5", aliases); + assert.equal(alias, "gpt-5.5-2"); +}); + +test("numeric suffix increments past existing numbered aliases", () => { + const aliases: Record = { foo: {}, "foo-2": {}, "foo-3": {} }; + // single-segment id whose only candidate is taken → numeric fallback skips to -4 + assert.equal(generateUniqueModelAlias("foo", aliases), "foo-4"); +}); + +test("degenerate ids do not throw", () => { + assert.equal(typeof generateUniqueModelAlias("", {}), "string"); + assert.equal(typeof generateUniqueModelAlias("///", {}), "string"); +});