fix(dashboard): disambiguate colliding passthrough model aliases (port from 9router#1850) (#6431)

disambiguate colliding passthrough model aliases (port #1850) (net +1/-0, test OK). Integrated into release/v3.8.46.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-06 19:24:59 -03:00
committed by GitHub
parent 2ecaae7c40
commit b67f2c58da
4 changed files with 103 additions and 10 deletions

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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, unknown> = {}
): 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<string, unknown>): boolean {
return Object.prototype.hasOwnProperty.call(existingAliases, alias);
}
function nextNumeric(base: string, existingAliases: Record<string, unknown>): string {
let i = 2;
while (isTaken(`${base}-${i}`, existingAliases)) i++;
return `${base}-${i}`;
}

View File

@@ -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<string, unknown> = {};
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<string, unknown> = {
"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<string, unknown> = { 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");
});