Files
hermes-webui/tests/test_model_picker_badges.py
happy5318 5518e4ceca fix(config): drop synthetic `{provider}/{model}` alias from configured-model badges (#7290)
## Thinking Path

When the active provider is a built-in plugin provider (e.g. ``commandcode``)
and the configured default model id contains a slash (e.g.
``deepseek/deepseek-v4-flash``), ``_build_configured_model_badges`` and
``_configured_model_badges_from_static_catalog`` used to publish three
raw_candidates for the same configured identity:

  - the bare native id (``deepseek/deepseek-v4-flash``)
  - a slash-prefixed alias (``commandcode/deepseek/deepseek-v4-flash``)
  - the ``@provider:model``-prefixed form (``@commandcode:deepseek/deepseek-v4-flash``)

The slash-prefixed alias was synthetic — the catalog never actually
exposed that key. The WebUI dropdown rendered it as a real option,
selecting it sent ``model=commandcode/deepseek/deepseek-v4-flash`` to
the agent, and the provider rejected it with ``HTTP 400: Model ...
is not supported on this endpoint``. The session also persisted the
wrong id, so reload replayed the failure.

Verified by reading the live source at master: the catalog exposes
only the bare native id for built-in plugin providers; the
slash-prefixed alias was a three-line entry in the ``raw_candidates``
tuple. Maintainer's 9/25 pickup contract on the issue explicitly
prohibits positional slash-splitting for ownership inference: slashes
are valid in model ids (multi-slash, URI-scheme, host:port). The fix
drops only the synthetic prefix form; the bare native id (the actual
catalog form) and the documented ``@provider:model`` prefix stay.

## What Changed

1. ``api/config.py:8617`` (``_build_configured_model_badges``) — drop
   the ``f"{provider}/{model}"`` entry from the ``raw_candidates``
   tuple. The tuple now contains only ``model`` and
   ``f"@{provider}:{model}"``. Also type-annotate the local list.
2. ``api/config.py:6946`` (``_configured_model_badges_from_static_catalog``)
   — same one-line change for the static-catalog fallback path so the
   two builders stay in sync.

The match_id / norm_lookup / exact_match logic downstream is unchanged
— those already prefer the matching provider, and with the synthetic
alias gone the bare native id is the only remaining self-collision
candidate (which the existing ``test_duplicate_slash_id_primary_badge_sticks_to_matching_provider_only``
already pins).

## Why It Matters

A user picking the configured default model in the dropdown sees two
visually identical entries for the same model id, picks the wrong
"prefixed" one (the one matching the dropdown's order), the agent
returns 400, and the session state is corrupted with a non-functional
model id until the user manually clears it. The dropdown's own
deduplication already handles `@provider:model` correctly via the
documented prefix form; only the synthesised ``{provider}/{model}``
form was wrong.

## Verification

- ``tests/test_issue7290_no_slash_prefixed_alias.py`` (new, 10 tests):
  - ``test_commandcode_deepseek_does_not_emit_slash_prefixed_alias`` —
    the literal repro from the issue: ``commandcode/deepseek/deepseek-v4-flash``
    MUST NOT appear in the badge map; the bare id ``deepseek/deepseek-v4-flash``
    is the primary badge.
  - ``test_commandcode_deepseek_at_prefixed_alias_still_present`` — the
    ``@provider:model`` form is preserved.
  - ``test_duplicate_slash_id_does_not_pollute_other_provider`` — per
    contract 4: dedupe only when provider + full native model match.
  - ``test_non_slash_models_unchanged`` (parametrized × 4) — the bare,
    @-prefixed, URI-scheme, and URL model id forms all keep working.
  - ``test_synthetic_slash_prefix_alias_is_not_in_source`` (parametrized × 2)
    — the synthesised ``f"{provider}/{model}"`` literal MUST NOT appear
    in either builder; a silent reversion to the bug fails the suite.
  - ``test_raw_candidates_only_holds_bare_and_at_prefix`` — pins the
    shape of the ``for candidate in (...)`` tuple so a future
    regression that adds any *other* synthetic prefix form
    (``f"{provider}::{model}"``, etc.) also fails.
- ``tests/test_model_picker_badges.py::test_available_models_exposes_primary_and_fallback_badges``
  updated to assert the bare-id form (``claude-haiku-4.5``) and the
  ``@provider:model`` form (``@anthropic:claude-haiku-4.5``) carry the
  fallback badge; the deleted slash-prefixed form is explicitly
  asserted absent. All other test_model_picker_badges cases unchanged.
- The 4 sibling tests
  (``test_issue3360_multi_slash_model_collision``,
  ``test_issue3429_uri_scheme_model_ids``,
  ``test_issue3959_model_suffix_dedup``,
  ``test_norm_model_id_trailing_empty_guard``) still pass — they pin
  ``_norm_model_id`` behaviour which is unchanged by this PR.
- ``pytest tests/ -k 'model or picker or badge'``: 1507 passed,
  1 skipped, 2 failed. The 2 failures (test_issue1426_free_tier,
  test_issue2245_mixed_case) are the documented upstream shard-race
  flake — both pass when run individually (verified). Pre-existing
  per issue #7656's notes.
- ``ruff check api/config.py tests/test_issue7290_no_slash_prefixed_alias.py``:
  clean on the new test file. (The 4 pre-existing F811/import-order
  findings in api/config.py are on untouched lines.)

## Contract Change

No API contract change. The ``/api/models`` payload's
``configured_model_badges`` map drops one synthetic key per configured
entry (the slash-prefixed alias). Real consumers that look up a badge
by exact native id or by ``@provider:model`` are unaffected. The
dropdown's existing dedupe is now correct without a manual workaround.

## Risks

- A consumer that depended on the slash-prefixed alias as a stable
  lookup key would need to switch to the bare native id. No consumer
  in this repo matches that pattern — ``test_duplicate_slash_id_*
  _primary_badge_sticks_to_matching_provider_only`` was already
  asserting that the bare id is the canonical lookup.
- The dropdown's option value (the string sent as ``model=`` to the
  agent) is the bare native id; the test runner, the session
  restore path, and the request payload all use the bare id
  consistently per maintainer contract 3.

## Model Used

Hermes Agent (step-5-preview) with pytest, ruff. Tests execute the
live ``_build_configured_model_badges`` from the production source
(``exec`` the function body with inline ``_norm_model_id``), so a
revert to the original ``raw_candidates`` tuple fails the suite
immediately on the source-shape test.

## Related

- #7290 — original issue (Porter-Key, 2026-08-25) with the full repro
  and the maintainer's 9/25 pickup contract (6 items).
- #3478 — named custom-provider ``@custom:name:model`` prefix.
- #7241, #6884 — adjacent ``@provider:model`` prefix lanes.
- #3360 — multi-slash model id collision (the ``_norm_model_id``
  fix predates this PR and is preserved).
- #3429 — URI-scheme model id preservation (also preserved).
- #3959 — colon-suffix model id preservation (also preserved).
2026-09-22 10:10:10 +00:00

201 lines
9.0 KiB
Python

from pathlib import Path
from api import config
def _models_with_cfg(model_cfg=None, fallback_providers=None, custom_providers=None, active_provider=None):
old_cfg = config.cfg
old_mtime = config._cfg_mtime
old_cache = config._available_models_cache
old_cache_ts = config._available_models_cache_ts
try:
config._available_models_cache = None
config._available_models_cache_ts = 0.0
config._cfg_mtime = 0.0
config.cfg = {
"model": model_cfg or {"provider": "openai-codex", "default": "gpt-5.4"},
"fallback_providers": fallback_providers or [],
"providers": {},
}
if custom_providers is not None:
config.cfg["custom_providers"] = custom_providers
if active_provider:
config.cfg["model"]["provider"] = active_provider
return config.get_available_models()
finally:
config.cfg = old_cfg
config._cfg_mtime = old_mtime
config._available_models_cache = old_cache
config._available_models_cache_ts = old_cache_ts
def test_available_models_exposes_primary_and_fallback_badges():
result = _models_with_cfg(
model_cfg={"provider": "openai-codex", "default": "gpt-5.4"},
fallback_providers=[
{"provider": "copilot", "model": "gpt-4.1"},
{"provider": "anthropic", "model": "claude-haiku-4.5"},
],
)
badges = result.get("configured_model_badges")
assert isinstance(badges, dict), (
"get_available_models() deve expor configured_model_badges para o frontend "
"marcar visualmente o dropdown com a cadeia primária + fallback configurada."
)
assert badges.get("@openai-codex:gpt-5.4", {}).get("role") == "primary"
assert badges.get("@openai-codex:gpt-5.4", {}).get("label") == "Primary"
assert badges.get("@copilot:gpt-4.1", {}).get("role") == "fallback"
assert badges.get("@copilot:gpt-4.1", {}).get("label") == "Fallback 1"
# #7290: the synthesised ``{provider}/{model}`` alias is gone — the
# native bare id and the ``@provider:model`` prefix are the only
# keys a configured entry can publish.
assert badges.get("claude-haiku-4.5", {}).get("role") == "fallback"
assert badges.get("@anthropic:claude-haiku-4.5", {}).get("role") == "fallback"
assert "anthropic/claude-haiku-4.5" not in badges
assert badges.get("@anthropic:claude-haiku-4.5", {}).get("label") == "Fallback 2"
def test_duplicate_slash_id_primary_badge_sticks_to_matching_provider_only():
import textwrap
root = Path(__file__).resolve().parent.parent
src = (root / "api" / "config.py").read_text(encoding="utf-8")
start = src.index("def _build_configured_model_badges() -> dict[str, dict[str, str]]:")
end = src.index(" return badges", start) + len(" return badges")
fn_src = textwrap.dedent(src[start:end])
scope = {
"active_provider": "custom:beta",
"default_model": "google/gemma-4-27b",
"cfg": {"fallback_providers": []},
"groups": [
{"provider": "Alpha", "provider_id": "custom:alpha", "models": [{"id": "google/gemma-4-27b"}]},
{"provider": "Beta", "provider_id": "custom:beta", "models": [{"id": "@custom:beta:google/gemma-4-27b"}]},
],
"_resolve_provider_alias": lambda provider: provider,
}
exec(
"def _norm_model_id(model_id):\n"
" s=str(model_id or '').strip().lower()\n"
" if s.startswith('@') and ':' in s: s=s.split(':',1)[1]\n"
" if '/' in s: s=s.split('/',1)[1]\n"
" return s.replace('-', '.')\n",
scope,
)
exec(fn_src, scope)
badges = scope["_build_configured_model_badges"]()
assert badges.get("@custom:beta:google/gemma-4-27b", {}).get("role") == "primary"
assert "google/gemma-4-27b" not in badges, (
"When duplicate slash-qualified IDs are deduplicated across providers, "
"the shared raw ID must not keep the PRIMARY badge for the wrong provider."
)
def test_ui_badge_lookup_prefers_row_provider_for_duplicate_model_ids():
root = Path(__file__).resolve().parent.parent
js = (root / "static" / "ui.js").read_text(encoding="utf-8")
assert "function _getConfiguredModelBadge(modelId,badgeMap,providerId){" in js
assert "child.dataset&&child.dataset.provider?child.dataset.provider:''" in js
assert "const providerMatch=matches.find(badge=>String(badge&&badge.provider||'').toLowerCase()===provider);" in js
def test_configured_model_group_label_has_i18n_key():
"""The Configured model group must not render the raw i18n key."""
root = Path(__file__).resolve().parent.parent
i18n = (root / "static" / "i18n.js").read_text(encoding="utf-8")
locale_count = i18n.count("_lang:")
key_count = i18n.count("model_group_configured:")
assert key_count == locale_count, (
"model_group_configured must be present in every locale block so "
"t('model_group_configured') never falls back to the raw key."
)
def test_get_available_models_cache_preserves_configured_model_badges(tmp_path, monkeypatch):
cache_path = tmp_path / "models_cache.json"
old_cfg = config.cfg
old_mtime = config._cfg_mtime
old_cache = config._available_models_cache
old_cache_ts = config._available_models_cache_ts
old_cache_path = config._models_cache_path
try:
monkeypatch.setattr(config, "_models_cache_path", cache_path)
config._available_models_cache = None
config._available_models_cache_ts = 0.0
config._cfg_mtime = 0.0
config.cfg = {
"model": {"provider": "openai-codex", "default": "gpt-5.4"},
"fallback_providers": [{"provider": "copilot", "model": "gpt-4.1"}],
"providers": {},
}
cold = config.get_available_models()
assert cold.get("configured_model_badges", {}).get("@copilot:gpt-4.1", {}).get("label") == "Fallback 1"
config._available_models_cache = None
config._available_models_cache_ts = 0.0
warm = config.get_available_models()
assert "configured_model_badges" in warm, (
"O cache persistido de /api/models não pode descartar configured_model_badges, "
"senão o deploy/servidor reiniciado perde as TAGS do dropdown mesmo com o código novo."
)
assert warm["configured_model_badges"].get("@copilot:gpt-4.1", {}).get("label") == "Fallback 1"
finally:
config.cfg = old_cfg
config._cfg_mtime = old_mtime
config._available_models_cache = old_cache
config._available_models_cache_ts = old_cache_ts
monkeypatch.setattr(config, "_models_cache_path", old_cache_path)
def test_ui_renders_model_badges_from_api_payload():
root = Path(__file__).resolve().parent.parent
js = (root / "static" / "ui.js").read_text(encoding="utf-8")
html = (root / "static" / "index.html").read_text(encoding="utf-8")
css = (root / "static" / "style.css").read_text(encoding="utf-8")
assert "window._configuredModelBadges=data.configured_model_badges||{};" in js, (
"populateModelDropdown() deve guardar configured_model_badges do /api/models "
"para que o dropdown reflita a cadeia configurada atual."
)
assert "model-opt-badge" in js, (
"renderModelDropdown() deve renderizar um badge visual por modelo quando houver "
"metadata de primário/fallback no payload."
)
assert "_getConfiguredModelBadge" in js, (
"A UI precisa de um helper de matching resiliente para religar badges mesmo quando "
"o update do catálogo mudar prefixos/formas do model ID."
)
assert "model_group_configured" in js, (
"renderModelDropdown() deve expor uma seção Configured no topo para destacar a "
"cadeia primária + fallback antes dos providers completos."
)
assert "configuredRank" in js, (
"A UI deve calcular uma prioridade estável (primary -> fallback 1 -> fallback N) "
"para renderizar os modelos configurados no topo do dropdown."
)
assert "Object.entries(_badgeMap)" in js and "_normalizeConfiguredModelKey(existing.value)" in js, (
"renderModelDropdown() deve sintetizar entradas para modelos configurados ausentes "
"do catálogo atual, senão fallbacks locais/Ollama desaparecem da seção Configured."
)
# Chip-projected badge was removed in v0.50.243 (added too much width to the
# composer chip; signal value low since the model name is right next to it).
# Badges remain in the dropdown rows (model-opt-badge) for picker rows.
assert 'id="composerModelBadge"' not in html, (
"composer-model-badge chip projection was intentionally removed — "
"do not re-add it to the composer chip."
)
assert "composer-model-badge" not in css, (
"composer-model-badge CSS was intentionally removed alongside the chip span."
)
assert "composerModelBadge" not in js, (
"syncModelChip() must not reference composerModelBadge — the chip-projected "
"badge was removed because it added too much width to the composer chip."
)