* fix(models): normalize dotted Bedrock/Vertex model IDs in labels
Split out of #6607 at reviewer request — it was unrelated to that PR's MEDIA
path handling and needed its own tests.
`us.anthropic.claude-opus-5` carries a cross-region routing prefix plus a vendor
namespace, and `mistral.mistral-large-2407-v1:0` adds a provisioned-revision
suffix. None of it belongs in a human label, so the turn footer rendered
"Us.anthropic.claude Opus 5".
Strip only the two shapes these hosts actually publish, against a CLOSED
provider allow-list:
<region>.<vendor>.<model> us.anthropic.claude-opus-5
<vendor>.<model> mistral.mistral-large-2407-v1:0
A generic "drop leading letters-only dot segments" loop was rejected because it
rewrites arbitrary uncatalogued IDs: `deepseek.v3` rendered as "V3" (vendor name
silently deleted) and `foo.bar.baz` as "BAZ". Dropping a vendor is additionally
gated on the remainder still naming the model, so `deepseek.v3` — where the
vendor IS the name — is left byte-intact.
Backend and frontend are paired: tests/test_dotted_model_label.py drives one
table through both `_get_label_for_model()` and `_stripDottedModelPrefix()` and
fails on divergence, including version dots (`gpt-4.1`, `qwen3.6-35b`),
URI-scheme IDs, and unknown vendors.
The Python half is inlined inside `_get_label_for_model` on purpose: the #3429
harnesses extract that function's source and eval it in isolation, so a
module-level helper NameErrors there. The #3429 JS driver is updated to pull in
the new helper for the same reason.
* fix(models): recognize `global` as a Bedrock routing prefix
Re-review catch, and a real gap: the catalog ships six
`global.anthropic.claude-*` IDs (api/config.py:1901-1909) and the first-party
routing notes use `global.anthropic.claude-…` as the canonical Bedrock shape,
but `global` was missing from the region allow-list. All six therefore kept the
noise this change exists to remove:
global.anthropic.claude-opus-4-7 -> "Global.anthropic.claude Opus 4 7"
Added to the region set in both implementations, which now read identically.
The root cause is two lists that must agree — the region allow-list and the
shipped catalog — so the new guard is catalog-driven rather than another
hardcoded region list: it scrapes every dotted `<head>.<vendor>.<model>` ID out
of api/config.py and asserts none of them keeps a routing/vendor namespace in
its label. A future routing prefix added to the catalog without updating the
region set fails there, instead of shipping mislabeled.
Also pins all six `global.*` IDs plus `us-gov` in the shared strip table (no
suite covered `us-gov` before either), and verified by mutation: dropping
`global` from the Python set fails 3 tests, dropping it from only the JS set
fails 2 — so backend/frontend parity drift is caught, not just a total absence.
Note: the review cited tests/test_provider_prefix_label_normalization.py and
tests/test_ui_model_label_parity.py as the suites to extend; neither exists in
this tree (nothing under tests/ referenced `us-gov` at all), so the cases live
in tests/test_dotted_model_label.py alongside the existing paired coverage.
* fix(models): add missing Bedrock vendors; make the catalog guard actually broad
Two self-review findings (hostile critic pass). Both are the same class as the
`global` gap the reviewer already caught, which means the guard I added for that
gap was too weak to prevent a recurrence.
1. Missing vendors. `luma`, `twelvelabs` and `ibm` are real Bedrock
foundation-model vendors and were absent from the allow-list, so genuine IDs
shipped with the namespace intact:
luma.ray-2 -> "Luma.ray 2"
us.twelvelabs.marengo-embed-2-7 -> "Us.twelvelabs.marengo Embed 2 7"
us.ibm.granite-3-8b -> "Us.ibm.granite 3 8B"
Added those plus `nvidia` and `snowflake` to both implementations.
2. The catalog guard inspected 6 of 75 dotted IDs. Its scrape regex only matched
three-segment `"id": "<region>.<vendor>.<model>"` literals with double quotes,
so the entire two-segment `<vendor>.<model>` shape -- the OTHER documented
shape this PR handles -- was invisible to it. A test that reads 8% of the
corpus while claiming to cover the catalog is worse than no test, because it
reads as proof.
Rewritten to scrape any quoted `id` value regardless of quote style or segment
count (75 dotted IDs now inspected), and to DERIVE the namespace heads from the
production `_regions`/`_vendors` literals instead of retyping them, so a set
that grows without a test update is still covered. Version dots (`qwen3.6-plus`,
`gpt-5.4`) are correctly skipped as non-namespaces.
Also added an explicit per-vendor round-trip test. It asserts no dotted NAMESPACE
survives rather than that the vendor word is absent, because a vendor legitimately
reappears inside some model names (`mistral.mistral-large-2407` -> "Mistral Large
2407") -- my first version of that assertion was wrong for exactly that reason.
Verified by mutation: removing the new vendors fails the round-trip test; deleting
the strip entirely fails 6 tests.
* test(models): drive real getModelLabel() in the parity oracle
The paired test compared _get_label_for_model() against itself, so JS
getModelLabel() never ran and nothing asserted the two sides agreed. It
stayed green when the JS dotted strip was reverted to a no-op AND when the
whole post-strip retry chain was deleted -- blind to both.
Replaced with two tests driven through the real getModelLabel() under Node,
reusing the driver already proven in test_issue3429_uri_scheme_model_label.
Only sinks are stubbed (_dynamicModelLabels empty as it is pre-fetch,
_fmtOllamaLabel identity); every decision function is the shipped source.
The oracle asserts the actual contract -- no routing/vendor namespace leaks
into either label -- rather than string equality of the two labels. Label
divergence is pre-existing and by design: at base dd7f6ac3, claude-opus-5
(no dot, untouched here) already labels 'claude-opus-5' in JS vs
'Claude Opus 5' in Python, and openai/gpt-4o 'GPT-4o' vs 'GPT 4O'.
getModelLabel() checks _dynamicModelLabels first (ui.js:6991), populated
from the server label (ui.js:3483,3494,3606), so the backend wins once a
catalog loads; the JS formatter is the pre-fetch fallback.
Dropping the JS post-normalization instead would regress the picker to raw
ids before catalog load (us.anthropic.claude-sonnet-4-5 -> 'claude-sonnet-4-5'
instead of 'Sonnet 4.5').
Mutation-checked: JS strip no-op fails 2, retry chain deleted fails 1,
'global' dropped from backend _regions fails 1. Region set derived from
api/config.py source rather than retyped.
No production code changed.
* fix(test): pin mcp SDK to compatible 1.x range with bootstrap guard (#6602)
Change to in requirements-dev.txt and the
mcp_server.py docstring, and add a bootstrap guard in mcp_server.py
that checks Server.list_tools existence at module load time.
The guard fails fast with a clear error message if an incompatible
mcp SDK (2.x) is installed, instead of producing dozens of
secondary errors at test collection/setup. This completes the
mitigation started in PR #6564 by adding a minimum version floor
and an import-time compatibility check.
* docs(changelog): note #6628 dotted model labels, #6616 mcp SDK pin
---------
Co-authored-by: Sam Painter <samfp@amazon.com>
Co-authored-by: webtecnica <webtecnica@gmail.com>
Co-authored-by: n <a@n>
121 lines
5.5 KiB
Python
121 lines
5.5 KiB
Python
"""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; }
|
|
// getModelLabel() calls the dotted Bedrock/Vertex prefix normalizer, which lives
|
|
// just above it with two Sets it closes over. Pull all three in, or the eval'd
|
|
// function ReferenceErrors on the first dotted id.
|
|
const _stripStart = ui.indexOf('const _BEDROCK_REGION_PREFIXES');
|
|
const _stripEnd = ui.indexOf('function getModelLabel(');
|
|
if (_stripStart < 0 || _stripStart > _stripEnd) throw new Error('_stripDottedModelPrefix block not found');
|
|
eval(ui.slice(_stripStart, _stripEnd));
|
|
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_label_edge_cases_never_return_authority_or_drop_digit_models():
|
|
"""#3429 follow-up (Codex gate): a single path segment is kept even when it
|
|
looks version-like, a digit-leading model name (2026-model) is NOT mistaken
|
|
for a version tail, and the authority/folder is never returned as the label."""
|
|
out = _labels([
|
|
"gpt://folder123/v4", # single path seg — keep it, don't fall back to authority
|
|
"gpt://folder123/latest", # single path seg — keep it
|
|
"gpt://folder123/2026-model/latest", # digit-leading real model name
|
|
])
|
|
assert out["gpt://folder123/v4"] == "v4"
|
|
assert out["gpt://folder123/latest"] == "latest"
|
|
assert out["gpt://folder123/2026-model/latest"] == "2026-model"
|
|
# The authority/folder must never be promoted to the label.
|
|
for label in out.values():
|
|
assert label != "folder123", "authority/folder leaked into label"
|
|
|
|
|
|
def test_uri_degenerate_ids_fall_back_to_raw_not_authority_or_placeholder():
|
|
"""#3429 follow-up 2 (Codex gate): a URI with no usable model segment must
|
|
fall back to the raw id — never the authority/folder, never a ${...} env-var."""
|
|
out = _labels([
|
|
"gpt://folder123", # authority only, no path
|
|
"gpt://folder123/${MODEL}", # path is only an env-var placeholder
|
|
])
|
|
assert out["gpt://folder123"] == "gpt://folder123"
|
|
assert out["gpt://folder123/${MODEL}"] == "gpt://folder123/${MODEL}"
|
|
for label in out.values():
|
|
assert label != "folder123", "authority leaked into label"
|
|
assert label not in ("${MODEL}",), "env-var placeholder leaked into label"
|
|
|
|
|
|
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"
|