mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
committed by
GitHub
parent
2ecaae7c40
commit
b67f2c58da
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
48
tests/unit/passthrough-alias-1850.test.ts
Normal file
48
tests/unit/passthrough-alias-1850.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user