fix(ui): getModelLabel must not mangle URI-scheme model IDs (#3429 regression from #3366)

#3366 changed getModelLabel() to strip only the first /-segment (fixing #3360
multi-slash proxy IDs). That regressed URI-scheme IDs like Yandex
gpt://${FOLDER}/deepseek-v4-flash/latest — indexOf('/') lands inside the ://
and leaves /${FOLDER}/... path junk in the composer model chip. Detect a
scheme:// id, drop scheme+authority, and take the last meaningful path segment
(skipping ${...} env-var placeholders and bare version tails like latest).
Non-URI multi-slash IDs keep the #3360 first-segment-strip behavior unchanged.
Node-driven regression test covers the URI case + the #3360 non-regression.
This commit is contained in:
nesquena-hermes committed 2026-06-02 20:36:59 +00:00
1 parent 6703b39a2d
commit c81c9649dd
2 files changed
+105 -1

No files matched your search

+23 -1
View File
@@ -2900,7 +2900,29 @@ function getModelLabel(modelId){
if(STATIC_LABELS[modelId]) return STATIC_LABELS[modelId];
// Safe Ollama-tag fallback: strip only the first slash-segment (provider
// prefix) so multi-slash IDs preserve their vendor hierarchy (#3360).
let _last = modelId.includes('/') ? (modelId.slice(modelId.indexOf('/')+1) || modelId) : modelId;
// URI-scheme ids (e.g. `gpt://${FOLDER}/deepseek-v4-flash/latest`, provider
// `yandex:gpt`) must NOT be first-segment-stripped — `indexOf('/')` would
// land inside the `://` and leave `/${FOLDER}/...` path junk (#3429). For a
// `scheme://` id, drop the scheme + authority and take the last meaningful
// path segment (ignoring a bare version tail like `latest`/`stable`).
let _last;
const _uriMatch = /^[a-z][a-z0-9+.-]*:\/\/(.+)$/i.exec(modelId);
if (_uriMatch) {
const _segs = _uriMatch[1].split('/').filter(Boolean);
const _isVersionTail = (s) => /^(latest|stable|current|default|v?\d[\w.-]*)$/i.test(s);
let _pick = '';
for (let _i = _segs.length - 1; _i >= 0; _i--) {
// Skip env-var placeholders (${...}) and bare version tails; prefer the
// last segment that actually looks like a model name.
if (/\$\{[^}]*\}/.test(_segs[_i])) continue;
if (_isVersionTail(_segs[_i]) && _pick) continue;
if (!_pick) _pick = _segs[_i];
if (!_isVersionTail(_segs[_i])) { _pick = _segs[_i]; break; }
}
_last = _pick || _segs[_segs.length - 1] || modelId;
} else {
_last = modelId.includes('/') ? (modelId.slice(modelId.indexOf('/')+1) || modelId) : modelId;
}
// Strip @provider: prefix if present (e.g. @ollama-cloud:kimi-k2.6)
if (_last.startsWith('@') && _last.includes(':')) _last = _last.split(':').slice(1).join(':');
const looksLikeOllamaTag = /^[a-z0-9][\w.-]*:[\w.-]+$/i.test(_last);
@@ -0,0 +1,82 @@
"""Regression test for #3429 — getModelLabel() mangles URI-scheme model IDs.
PR #3366 (fix for #3360) changed getModelLabel() to strip only the first
``/``-segment instead of ``split('/').pop()``. That fixed multi-slash proxy IDs
but regressed URI-scheme IDs (e.g. Yandex ``gpt://${FOLDER}/deepseek-v4-flash/latest``)
because ``indexOf('/')`` lands inside the ``://`` and leaves ``/${FOLDER}/...``
path junk in the composer model chip.
The fix detects a ``scheme://`` id and takes the last meaningful path segment
(skipping ``${...}`` env-var placeholders and bare version tails like ``latest``),
while NOT touching the #3360 multi-slash behavior for non-URI ids.
Runs the live getModelLabel() via Node so drift between the test and the real
code is caught immediately.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent.resolve()
UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
_DRIVER = r"""
const fs = require('fs');
const ui = fs.readFileSync(process.argv[1], 'utf8');
// Slice getModelLabel() by function boundaries (regex literals inside it defeat
// a naive brace counter, so bound it by the next top-level function instead).
const start = ui.indexOf('function getModelLabel(');
if (start < 0) throw new Error('getModelLabel not found');
const after = ui.indexOf('\nfunction _gatewayProviderName(', start);
if (after < 0) throw new Error('getModelLabel end boundary not found');
const fnSrc = ui.slice(start, after);
const _dynamicModelLabels = {};
function _fmtOllamaLabel(s){ return s; }
eval(fnSrc);
const out = {};
for (const m of JSON.parse(process.argv[2])) out[m] = getModelLabel(m);
process.stdout.write(JSON.stringify(out));
"""
def _labels(model_ids):
import json
proc = subprocess.run(
[NODE, "-e", _DRIVER, str(UI_JS_PATH), json.dumps(model_ids)],
capture_output=True, text=True, timeout=30,
)
assert proc.returncode == 0, f"node driver failed: {proc.stderr}"
return json.loads(proc.stdout)
def test_uri_scheme_model_id_label_is_model_name_not_path_junk():
"""#3429: a gpt://.../model/latest id must label as the model name."""
out = _labels([
"gpt://${YANDEX_FOLDER_ID}/deepseek-v4-flash/latest",
"gpt://folder123/deepseek-v4-flash/latest",
"openrouter://acct/qwen-3-coder",
"gpt://${F}/qwen-3-coder",
])
assert out["gpt://${YANDEX_FOLDER_ID}/deepseek-v4-flash/latest"] == "deepseek-v4-flash"
assert out["gpt://folder123/deepseek-v4-flash/latest"] == "deepseek-v4-flash"
assert out["openrouter://acct/qwen-3-coder"] == "qwen-3-coder"
assert out["gpt://${F}/qwen-3-coder"] == "qwen-3-coder"
# The env-var placeholder must never leak into the label.
for label in out.values():
assert "${" not in label, f"env-var placeholder leaked into label: {label!r}"
def test_uri_fix_does_not_regress_multi_slash_or_bare_ids():
"""#3360 multi-slash hierarchy + single-slash/bare ids stay correct."""
out = _labels([
"vendor_a/deepseek/deepseek-v4-pro", # #3360: keep vendor hierarchy
"claude-sonnet-4-6", # bare id unchanged
])
# Non-URI multi-slash id keeps everything after the first segment (#3360).
assert out["vendor_a/deepseek/deepseek-v4-pro"] == "deepseek/deepseek-v4-pro"
assert out["claude-sonnet-4-6"] == "claude-sonnet-4-6"