Files
hermes-webui/tests/test_issue3429_uri_scheme_model_label.py
T
747b5a6913 Batch release exp-v0.52.236 — dotted model labels (#6628) + MCP SDK pin (#6616) (#7103)
* 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>
2026-08-17 06:37:52 +00:00

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"