diff --git a/api/config.py b/api/config.py index f8a598a0..0953bfb8 100644 --- a/api/config.py +++ b/api/config.py @@ -2247,6 +2247,29 @@ def _model_matches_picker_selection( return not selected_provider or not candidate_provider or selected_provider == candidate_provider +def _openrouter_model_display_name(model_id: str) -> str: + """Return the OpenRouter display name (e.g. ``Ox Alpha``) for *model_id*. + + Reads only the local shared metadata disk cache written by hermes-agent + (``cache/openrouter_model_metadata.json``) — never touches the network. + Falls back to the raw id when the model is unknown or the cache is + unavailable, so picker rows are always populated (#7228). + """ + if not model_id: + return model_id + try: + from agent.model_metadata import _load_model_metadata_disk_cache + + cache = _load_model_metadata_disk_cache() or {} + except Exception: + return model_id + entry = cache.get(model_id) + if not isinstance(entry, dict): + return model_id + name = str(entry.get("name") or "").strip() + return name or model_id + + def _split_picker_overflow_models( ordered_models: list[dict], *, @@ -7790,7 +7813,13 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa for mid, _desc in live_curated: if mid and mid not in seen_ids: seen_ids.add(mid) - raw_models.append({"id": mid, "label": mid}) + # Ship the friendly display name (e.g. "Ox Alpha") + # from the local OpenRouter metadata cache instead + # of the raw id, so the picker search matches what + # users see in Hermes Desktop (#7228). + raw_models.append( + {"id": mid, "label": _openrouter_model_display_name(mid)} + ) except Exception: logger.warning("Failed to load OpenRouter curated catalog from hermes_cli") diff --git a/static/ui.js b/static/ui.js index 56af87da..f7506f41 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4572,10 +4572,16 @@ function renderModelDropdown(){ else if(_prevHasSearch){ for(const k in _groupOpenState) delete _groupOpenState[k]; } _prevHasSearch=hasSearch; const found=new Set(); + // Fold whitespace/hyphens/dots on both sides so "ox alpha", "ox-alpha" and + // "ox.alpha" all match the same model (OpenRouter display names use spaces, + // ids use slashes/hyphens) (#7228). + const _foldModelSearch=(s)=>String(s||'').toLowerCase().replace(/[\s._-]+/g,''); + const foldTerm=_foldModelSearch(term); for(const m of _modelData){ const name=m.name.toLowerCase(); const id=m.id.toLowerCase(); - if(name.includes(term)||id.includes(term)){ + if(name.includes(term)||id.includes(term) + ||_foldModelSearch(name).includes(foldTerm)||_foldModelSearch(id).includes(foldTerm)){ found.add(m.value); } } diff --git a/tests/test_issue7228_model_picker_search.py b/tests/test_issue7228_model_picker_search.py new file mode 100644 index 00000000..20729561 --- /dev/null +++ b/tests/test_issue7228_model_picker_search.py @@ -0,0 +1,64 @@ +"""Regression check for #7228 — model-picker search vs OpenRouter display names. + +Composer model-picker search was a literal substring on the rendered name and +id. OpenRouter overflow rows were labeled with the raw id (``stealth/ox-alpha``) +instead of the provider display name (``Ox Alpha``), and spaces were not +normalized to hyphens — so typing the name users see in Hermes Desktop +(``Ox Alpha``) always yielded "No models found". + +Fixed in three layers (issue requirement): + 1. backend: /api/models ships the friendly display name from the local + OpenRouter metadata disk cache for curated catalog rows; + 2. frontend: getModelLabel() resolves via the dynamic label map, which is + now hydrated with the display name; + 3. frontend: search folds whitespace/hyphens/dots on both sides so + ``ox alpha`` == ``ox-alpha`` == ``ox.alpha``. + +Verified at the source level so this stays fast. +""" +from pathlib import Path + +REPO = Path(__file__).parent.parent +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") +CONFIG_PY = (REPO / "api" / "config.py").read_text(encoding="utf-8") + + +def test_backend_has_openrouter_display_name_helper(): + # The helper must read only the local disk cache (no network). + assert "def _openrouter_model_display_name(model_id: str) -> str:" in CONFIG_PY + assert "_load_model_metadata_disk_cache" in CONFIG_PY + + +def test_backend_ships_display_name_not_raw_id(): + # Curated OpenRouter catalog rows must use the friendly display name + # instead of the raw id as the picker label (#7228). + snippet = CONFIG_PY[CONFIG_PY.index('fetch_openrouter_models as _fetch_or_models'):] + snippet = snippet[:snippet.index('except Exception')] + assert "_openrouter_model_display_name(mid)" in snippet + assert '{"id": mid, "label": mid}' not in snippet + + +def test_filter_models_folds_space_hyphen_dot(): + # Search must fold whitespace/hyphens/dots on both sides so display names + # with spaces match ids with hyphens ("ox alpha" == "ox-alpha"). + assert "replace(/[\\s._-]+/g,'')" in UI_JS + assert "_foldModelSearch(name).includes(foldTerm)" in UI_JS + assert "_foldModelSearch(id).includes(foldTerm)" in UI_JS + + +def test_get_model_label_uses_dynamic_map_first(): + # getModelLabel() must resolve through the dynamic label map, which is + # hydrated from m.label / overflowModel.label (now the display name). + idx = UI_JS.index("function getModelLabel(modelId){") + # The dynamic-map lookup must appear before the static-label fallback, + # so backend display names win over the hardcoded table. + assert "_dynamicModelLabels[modelId]" in UI_JS[idx:idx + 900] + assert UI_JS[idx:idx + 900].index("_dynamicModelLabels[modelId]") < \ + (UI_JS[idx:idx + 900].index("STATIC_LABELS") if "STATIC_LABELS" in UI_JS[idx:idx + 900] else 10**9) + # The dynamic map must be populated from the backend label, not the raw id. + assert "_dynamicModelLabels[m.id]=m.label||m.id" in UI_JS + + +def test_literal_substring_match_still_present(): + # Raw-id search ("stealth", "stealth/ox-alpha") must keep working. + assert "name.includes(term)||id.includes(term)" in UI_JS