diff --git a/static/ui.js b/static/ui.js index 0fc55263..6f752f0b 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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); diff --git a/tests/test_issue3429_uri_scheme_model_label.py b/tests/test_issue3429_uri_scheme_model_label.py new file mode 100644 index 00000000..fcf93e9c --- /dev/null +++ b/tests/test_issue3429_uri_scheme_model_label.py @@ -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"