# NousResearch/hermes-agent · Custom provider models missing or incomplete in model picker

## Open now

- 7 issues, 4 PRs open on GitHub.
- #8758: Bug: named custom provider model discovery ignores models mapping and probe fallback
- #20582: Model picker only shows one model for custom providers
- #47704: \[BUG\] Model picker dedup overfilters non-routing aggregators (opencode-go, huggingface, novita, etc.)
- #48885: \[Bug\]: Desktop UI model picker does not show custom/local provider models (hounddog_local / LM Studio)
- #49126: \[Bug\]: Global aggregator dedup strips overlapping models from named providers configured under providers:
- #49656: Desktop: custom_providers not shown in model provider selector UI
- #63211: Model picker hides custom base_url models until manual refresh; stale providers remain visible
- PR #47727: fix(cli): narrow model picker dedup to routing aggregators only
- PR #48902: fix(model-picker): support model-keyed user providers
- PR #61606: feat(lmstudio): add local model management
- PR #116765: fix(desktop): keep a configured custom provider visible in the model pickers

These issues and fixes concern custom/user-defined providers not surfacing their full model lists in the /model picker across CLI, TUI, and desktop interfaces. Work spans model discovery, normalization, deduplication, and merging of configured and live-discovered models.

## TL;DR

- 76 member(s): 7 open issue(s), 28 closed issue(s), 4 open PR(s), 6 merged PR(s), 31 PR(s) closed without merging.
- A fix was merged to main in PR #12557. Verify it addresses your case.
- **Recommended:** read PR #12557 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-12557,pr-21211,pr-61928`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.03`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #8758: Bug: named custom provider model discovery ignores models mapping and probe fallback
- #20582: Model picker only shows one model for custom providers
- #47704: \[BUG\] Model picker dedup overfilters non-routing aggregators (opencode-go, huggingface, novita, etc.)
- #48885: \[Bug\]: Desktop UI model picker does not show custom/local provider models (hounddog_local / LM Studio)
- #49126: \[Bug\]: Global aggregator dedup strips overlapping models from named providers configured under providers:
- #49656: Desktop: custom_providers not shown in model provider selector UI
- #63211: Model picker hides custom base_url models until manual refresh; stale providers remain visible

## Closed / duplicate issues

- #6999: Bug: /model shows only default_model for user-defined providers (providers: config)
- #7103: Support dynamic model discovery via /v1/models for user-defined providers
- #8216: Bug: /model picker ignores custom_providers.models and only shows one model
- #9140: fix(model): /model picker shows 0 models for custom_providers with models: field
- #9148: Bug: /model picker shows 0 models for custom_providers with models: dict
- #10011: Auto-discover models from custom provider endpoints in /model picker
- #11499: \[Bug\]: Telegram model picker does not list models from providers.custom.models dict
- #11677: \[Bug\]: Custom Model Provider is not Enumerating Models for Model Picker, only the Default Model
- #16438: \[Bug\]:  TUI /model picker does not show custom_providers defined in config.yaml
- #19071: custom_providers: /model picker ignores live /v1/models endpoint, shows stale static subset
- #30202: Feature: /model command should enumerate custom/local provider models
- #33595: \[Bug\]: Telegram model picker skips live /v1/models discovery for providers: entries without api_key
- #40480: Desktop App: Custom provider models not shown in model selection dropdown
- #40542: \[Bug\]: /model picker only shows 1 model for local custom providers without api_key (while hermes model CLI shows all)
- #43409: feat: /model interactive picker should list custom_providers models
- #44283: feat: Show custom_providers in model/provider selector (desktop + CLI)
- #44434: feat: Show custom_providers in model/provider selector (desktop + CLI)
- #44435: feat: Show custom_providers in model/provider selector (desktop + CLI)
- #47042: \[BUG\] Desktop model picker hides custom providers due to is_aggregator() false positive in dedup logic
- #47124: \[Bug\]: Custom endpoints do not appear in the Agent because their models are wiped by the aggregator deduplication logic
- #47132: bug(inventory): named custom providers lose all models in GUI picker after 60cc42e38
- #47155: Regression: /model hides custom:* provider after deduplication empties its model list
- #47258: UI model picker shows 0 models for custom_providers entries
- #47386: Custom providers with models: list show 0 models in Desktop/TUI model picker
- #47402: Model picker shows 0 models for custom: providers due to is_aggregator() treating all custom: slugs as aggregators
- #48990: Bug: custom_providers models silently stripped from /model picker
- #55573: \[Feature\]: Support multi-model configuration/selection for custom OpenAI-compatible providers
- #59560: \[Bug\]: custom_providers.models list is ignored — combo/9router models are not detected/selectable in Hermes Desktop

## Prior & competing PRs

- **#8223: fix(cli): include custom provider model maps in /model picker**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#11505: fix(cli): support custom provider model dictionaries in model picker**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#11499`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#12557: fix(model_switch): enumerate dict-format models in /model picker + section-3 refinements**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/model_switch.py`, `scripts/release.py`, `tests/hermes_cli/test_model_switch_custom_providers.py`, `tests/hermes_cli/test_user_providers_model_switch.py`
  - fix hunks:
  - `hermes_cli/model_switch.py`

    ```diff
    @@ -1039,17 +1039,38 @@ def list_authenticated_providers(
             for ep_name, ep_cfg in user_providers.items():
                 if not isinstance(ep_cfg, dict):
                     continue
    +            # Skip if this slug was already emitted (e.g. canonical provider
    +            # with the same name) or will be picked up by section 4.
    +            if ep_name.lower() in seen_slugs:
    +                continue
                 display_name = ep_cfg.get("name", "") or ep_name
    -            api_url = ep_cfg.get("api", "") or ep_cfg.get("url", "") or ""
    -            default_model = ep_cfg.get("default_model", "")
    +            # ``base_url`` is Hermes's canonical write key (matches
    +            # custom_providers and _save_custom_provider); ``api`` / ``url``
    +            # remain as fallbacks for hand-edited / legacy configs.
    +            api_url = (
    +                ep_cfg.get("base_url", "")
    +                or ep_cfg.get("api", "")
    +                or ep_cfg.get("url", "")
    +                or ""
    +            )
    +            # ``default_model`` is the legacy key; ``model`` matches what
    +            # custom_providers entries use, so accept either.
    +            default_model = ep_cfg.get("default_model", "") or ep_cfg.get("model", "")
     
                 # Build models list from both default_model and full models array
                 models_list = []
                 if default_model:
                     models_list.append(default_model)
    -            # Also include the full models list from config
    +            # Also include the full models list from config.
    +            # Hermes writes ``models:`` as a dict keyed by model id
    +            # (see hermes_cli/main.py::_save_custom_provider); older
    +            # configs or hand-edited files may still use a list.
                 cfg_models = ep_cfg.get("models", [])
    -            if isinstance(cfg_models, list):
    +            if isinstance(cfg_models, dict):
    +                for m in cfg_models:
    +                    if m and m not in models_list:
    +                        models_list.append(m)
    +            elif isinstance(cfg_models, list):
                     for m in cfg_models:
                         if m and m not in models_list:
                             models_list.append(m)
    ```

    ```diff
    @@ -1066,6 +1087,7 @@ def list_authenticated_providers(
                     "source": "user-config",
                     "api_url": api_url,
                 })
    +            seen_slugs.add(ep_name.lower())
     
         # --- 4. Saved custom providers from config ---
         # Each ``custom_providers`` entry represents one model under a named
    ```

    ```diff
    @@ -1100,10 +1122,27 @@ def list_authenticated_providers(
                         "api_url": api_url,
                         "models": [],
                     }
    +            # The singular ``model:`` field only holds the currently
    +            # active model. Hermes's own writer (main.py::_save_custom_provider)
    +            # stores every configured model as a dict under ``models:``;
    +            # downstream readers (agent/models_dev.py, gateway/run.py,
    +            # run_agent.py, hermes_cli/config.py) already consume that dict.
    +            # The /model picker previously ignored it, so multi-model
    +            # custom providers appeared to have only the active model.
                 default_model = (entry.get("model") or "").strip()
                 if default_model and default_model not in groups[slug]["models"]:
                     groups[slug]["models"].append(default_model)
     
    +            cfg_models = entry.get("models", {})
    +            if isinstance(cfg_models, dict):
    +                for m in cfg_models:
    +                    if m and m not in groups[slug]["models"]:
    +                        groups[slug]["models"].append(m)
    +            elif isinstance(cfg_models, list):
    +                for m in cfg_models:
    +                    if m and m not in groups[slug]["models"]:
    +                        groups[slug]["models"].append(m)
    +
             for slug, grp in groups.items():
                 if slug.lower() in seen_slugs:
                     continue
    ```
  - `scripts/release.py`

    ```diff
    @@ -177,6 +177,7 @@
         "[redacted:email]": "duerzy",
         "[redacted:email]": "emozilla",
         "[redacted:email]": "fancydirty",
    +    "[redacted:email]": "farion1231",
         "[redacted:email]": "flobo3",
         "[redacted:email]": "pefontana",
         "[redacted:email]": "fxfitz",
    ```
  - `tests/hermes_cli/test_model_switch_custom_providers.py`

    ```diff
    @@ -156,3 +156,100 @@ def test_list_deduplicates_same_model_in_group(monkeypatch):
         assert len(my_rows) == 1
         assert my_rows[0]["models"] == ["llama3", "mistral"]
         assert my_rows[0]["total_models"] == 2
    +
    +
    +def test_list_enumerates_dict_format_models_alongside_default(monkeypatch):
    +    """custom_providers entry with dict-format ``models:`` plus singular
    +    ``model:`` should surface the default and every dict key.
    +
    +    Regression: Hermes's own writer stores configured models as a dict
    +    keyed by model id, but the /model picker previously only honored the
    +    singular ``model:`` field, so multi-model custom providers appeared
    +    to have only the active model.
    +    """
    +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    +    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    +
    +    providers = list_authenticated_providers(
    +        current_provider="openai-codex",
    +        user_providers={},
    +        custom_providers=[
    +            {
    +                "name": "DeepSeek",
    +                "base_url": "https://api.deepseek.com",
    +                "api_mode": "chat_completions",
    +                "model": "deepseek-chat",
    +                "models": {
    +                    "deepseek-chat": {"context_length": 128000},
    +                    "deepseek-reasoner": {"context_length": 128000},
    +                },
    +            }
    +        ],
    +        max_models=50,
    +    )
    +
    +    ds_rows = [p for p in providers if p["name"] == "DeepSeek"]
    +    assert len(ds_rows) == 1
    +    assert ds_rows[0]["models"] == ["deepseek-chat", "deepseek-reasoner"]
    +    assert ds_rows[0]["total_models"] == 2
    +
    +
    +def test_list_enumerates_dict_format_models_without_singular_model(monkeypatch):
    +    """Dict-format ``models:`` with no singular ``model:`` should still
    +    enumerate every dict key (previously the picker reported 0 models)."""
    +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    +    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    +
    +    providers = list_authenticated_providers(
    +        current_provider="openai-codex",
    +        user_providers={},
    +        custom_providers=[
    +            {
    +                "name": "Thor",
    +                "base_url": "http://thor.lab:8337/v1",
    +                "models": {
    +                    "gemma-4-26B-A4B-it-MXFP4_MOE": {"context_length": 262144},
    +                    "Qwen3.5-35B-A3B-MXFP4_MOE": {"context_length": 262144},
    +                    "gemma-4-31B-it-Q4_K_M": {"context_length": 262144},
    +                },
    +            }
    +        ],
    +        max_models=50,
    +    )
    +
    +    thor_rows = [p for p in providers if p["name"] == "Thor"]
    +    assert len(thor_rows) == 1
    +    assert set(thor_rows[0]["models"]) == {
    +        "gemma-4-26B-A4B-it-MXFP4_MOE",
    +        "Qwen3.5-35B-A3B-MXFP4_MOE",
    +        "gemma-4-31B-it-Q4_K_M",
    +    }
    +    assert thor_rows[0]["total_models"] == 3
    +
    +
    +def test_list_dedupes_dict_model_matching_singular_default(monkeypatch):
    +    """When the singular ``model:`` is also a key in the ``models:`` dict,
    +    it must appear exactly once in the picker."""
    +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    +    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    +
    +    providers = list_authenticated_providers(
    +        current_provider="openai-codex",
    +        user_providers={},
    +        custom_providers=[
    +            {
    +                "name": "DeepSeek",
    +                "base_url": "https://api.deepseek.com",
    +                "model": "deepseek-chat",
    +                "models": {
    +                    "deepseek-chat": {"context_length": 128000},
    +                    "deepseek-reasoner": {"context_length": 128000},
    +                },
    +            }
    +        ],
    +        max_models=50,
    +    )
    +
    +    ds_rows = [p for p in providers if p["name"] == "DeepSeek"]
    +    assert ds_rows[0]["models"].count("deepseek-chat") == 1
    +    assert ds_rows[0]["models"] == ["deepseek-chat", "deepseek-reasoner"]
    ```
  - `tests/hermes_cli/test_user_providers_model_switch.py`

    ```diff
    @@ -86,6 +86,117 @@ def test_list_authenticated_providers_dedupes_models_when_default_in_list(monkey
         assert user_prov["models"].count("model-a") == 1, "model-a should not be duplicated"
     
     
    +def test_list_authenticated_providers_enumerates_dict_format_models(monkeypatch):
    +    """providers: dict entries with ``models:`` as a dict keyed by model id
    +    (canonical Hermes write format) should surface every key in the picker.
    +
    +    Regression: the ``providers:`` dict path previously only accepted
    +    list-format ``models:`` and silently dropped dict-format entries,
    +    even though Hermes's own writer and downstream readers use dict format.
    +    """
    +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    +    monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
    +
    +    user_providers = {
    +        "local-ollama": {
    +            "name": "Local Ollama",
    +            "api": "http://localhost:11434/v1",
    +            "default_model": "minimax-m2.7:cloud",
    +            "models": {
    +                "minimax-m2.7:cloud": {"context_length": 196608},
    +                "kimi-k2.5:cloud": {"context_length": 200000},
    +                "glm-5.1:cloud": {"context_length": 202752},
    +            },
    +        }
    +    }
    +
    +    providers = list_authenticated_providers(
    +        current_provider="local-ollama",
    +        user_providers=user_providers,
    +        custom_providers=[],
    +        max_models=50,
    +    )
    +
    +    user_prov = next(
    +        (p for p in providers if p.get("is_user_defined") and p["slug"] == "local-ollama"),
    +        None,
    +    )
    +
    +    assert user_prov is not None
    +    assert user_prov["total_models"] == 3
    +    assert user_prov["models"] == [
    +        "minimax-m2.7:cloud",
    +        "kimi-k2.5:cloud",
    +        "glm-5.1:cloud",
    +    ]
    +
    +
    +def test_list_authenticated_providers_dict_models_without_default_model(monkeypatch):
    +    """Dict-format ``models:`` without a ``default_model`` must still expose
    +    every dict key, not collapse to an empty list."""
    +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    +    monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
    +
    +    user_providers = {
    +        "multimodel": {
    +            "api": "http://example.com/v1",
    +            "models": {
    +                "alpha": {"context_length": 8192},
    +                "beta": {"context_length": 16384},
    +            },
    +        }
    +    }
    +
    +    providers = list_authenticated_providers(
    +        current_provider="",
    +        user_providers=user_providers,
    +        custom_providers=[],
    +    )
    +
    +    user_prov = next(
    +        (p for p in providers if p.get("is_user_defined") and p["slug"] == "multimodel"),
    +        None,
    +    )
    +
    +    assert user_prov is not None
    +    assert user_prov["total_models"] == 2
    +    assert set(user_prov["models"]) == {"alpha", "beta"}
    +
    +
    +def test_list_authenticated_providers_dict_models_dedupe_with_default(monkeypatch):
    +    """When ``default_model`` is also a key in the ``models:`` dict, it must
    +    appear exactly once (list already had this for list-format models)."""
    +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    +    monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
    +
    +    user_providers = {
    +        "my-provider": {
    +            "api": "http://example.com/v1",
    +            "default_model": "model-a",
    +            "models": {
    +                "model-a": {"context_length": 8192},
    +                "model-b": {"context_length": 16384},
    +                "model-c": {"context_length": 32768},
    +            },
    +        }
    +    }
    +
    +    providers = list_authenticated_providers(
    +        current_provider="my-provider",
    +        user_providers=user_providers,
    +        custom_providers=[],
    +    )
    +
    +    user_prov = next(
    +        (p for p in providers if p.get("is_user_defined")),
    +        None,
    +    )
    +
    +    assert user_prov is not None
    +    assert user_prov["total_models"] == 3
    +    assert user_prov["models"].count("model-a") == 1
    +
    +
     def test_list_authenticated_providers_fallback_to_default_only(monkeypatch):
         """When no models array is provided, should fall back to default_model."""
         monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    ```

    ```diff
    @@ -116,6 +227,83 @@ def test_list_authenticated_providers_fallback_to_default_only(monkeypatch):
         assert user_prov["models"] == ["single-model"]
     
     
    +def test_list_authenticated_providers_accepts_base_url_and_singular_model(monkeypatch):
    +    """providers: dict entries written in canonical Hermes shape
    +    (``base_url`` + singular ``model``) should resolve the same as the
    +    legacy ``api`` + ``default_model`` shape.
    +
    +    Regression: section 3 previously only read ``api``/``url`` and
    +    ``default_model``, so new-shape entries written by Hermes's own writer
    +    surfaced with empty ``api_url`` and no default.
    +    """
    +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    +    monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
    +
    +    user_providers = {
    +        "custom": {
    +            "base_url": "http://example.com/v1",
    +            "model": "gpt-5.4",
    +            "models": {
    +                "gpt-5.4": {},
    +                "grok-4.20-beta": {},
    +                "minimax-m2.7": {},
    +            },
    +        }
    +    }
    +
    +    providers = list_authenticated_providers(
    +        current_provider="custom",
    +        user_providers=user_providers,
    +        custom_providers=[],
    +        max_models=50,
    +    )
    +
    +    custom = next((p for p in providers if p["slug"] == "custom"), None)
    +    assert custom is not None
    +    assert custom["api_url"] == "http://example.com/v1"
    +    assert custom["models"] == ["gpt-5.4", "grok-4.20-beta", "minimax-m2.7"]
    +    assert custom["total_models"] == 3
    +
    +
    +def test_list_authenticated_providers_dedupes_when_user_and_custom_overlap(monkeypatch):
    +    """When the same slug appears in both ``providers:`` dict and
    +    ``custom_providers:`` list, emit exactly one row (providers: dict wins
    +    since it is processed first).
    +
    +    Regression: section 3 previously had no ``seen_slugs`` check, so
    +    overlapping entries produced two picker rows for the same provider.
    +    """
    +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    +    monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
    +
    +    providers = list_authenticated_providers(
    +        current_provider="custom",
    +        user_providers={
    +            "custom": {
    +                "base_url": "http://example.com/v1",
    +                "model": "gpt-5.4",
    +                "models": {
    +                    "gpt-5.4": {},
    +                    "grok-4.20-beta": {},
    +                },
    +            }
    +        },
    +        custom_providers=[
    +            {
    +                "name": "custom",
    +                "base_url": "http://example.com/v1",
    +                "model": "legacy-only-model",
    +            }
    +        ],
    +        max_models=50,
    +    )
    +
    +    matches = [p for p in providers if p["slug"] == "custom"]
    +    assert len(matches) == 1
    +    # providers: dict wins — legacy-only-model is suppressed.
    +    assert matches[0]["models"] == ["gpt-5.4", "grok-4.20-beta"]
    +
    +
     # =============================================================================
     # Tests for _get_named_custom_provider with providers: dict
     # =============================================================================
    ```
- **#14495: fix(config): preserve list-format models in custom_providers normalize (#13509)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/config.py`, `scripts/release.py`, `tests/hermes_cli/test_provider_config_validation.py`
  - fix hunks:
  - `hermes_cli/config.py`

    ```diff
    @@ -2055,6 +2055,14 @@ def _normalize_custom_provider_entry(
         models = entry.get("models")
         if isinstance(models, dict) and models:
             normalized["models"] = models
    +    elif isinstance(models, list) and models:
    +        # Hand-edited configs (and older Hermes versions) write ``models`` as
    +        # a plain list of model ids. Preserve them by converting to the dict
    +        # shape downstream code expects; otherwise normalize silently drops
    +        # the list and /model shows the provider with (0) models.
    +        normalized["models"] = {
    +            str(m): {} for m in models if isinstance(m, str) and m.strip()
    +        }
     
         context_length = entry.get("context_length")
         if isinstance(context_length, int) and context_length > 0:
    ```
  - `scripts/release.py`

    ```diff
    @@ -43,6 +43,7 @@
         "[redacted:email]": "teknium1",
         "[redacted:email]": "teknium1",
         "[redacted:email]": "teknium1",
    +    "[redacted:email]": "DrStrangerUJN",
         # contributors (from noreply pattern)
         "[redacted:email]": "xiaoqiang243",
         "[redacted:email]": "snreynolds",
    ```
  - `tests/hermes_cli/test_provider_config_validation.py`

    ```diff
    @@ -135,3 +135,48 @@ def test_no_name_returns_none(self):
             }
             result = _normalize_custom_provider_entry(entry, provider_key="")
             assert result is None
    +
    +    def test_models_list_converted_to_dict(self):
    +        """List-format models should be preserved as an empty-value dict so
    +        /model picks them up instead of showing the provider with (0) models."""
    +        entry = {
    +            "name": "tencent-coding-plan",
    +            "base_url": "https://api.lkeap.cloud.tencent.com/coding/v3",
    +            "models": ["glm-5", "kimi-k2.5", "minimax-m2.5"],
    +        }
    +        result = _normalize_custom_provider_entry(entry)
    +        assert result is not None
    +        assert result["models"] == {"glm-5": {}, "kimi-k2.5": {}, "minimax-m2.5": {}}
    +
    +    def test_models_dict_preserved(self):
    +        """Dict-format models should pass through unchanged."""
    +        entry = {
    +            "name": "acme",
    +            "base_url": "https://api.example.com/v1",
    +            "models": {"gpt-foo": {"context_length": 32000}},
    +        }
    +        result = _normalize_custom_provider_entry(entry)
    +        assert result is not None
    +        assert result["models"] == {"gpt-foo": {"context_length": 32000}}
    +
    +    def test_models_list_filters_empty_and_non_string(self):
    +        """List entries that are empty strings or non-strings are skipped."""
    +        entry = {
    +            "name": "acme",
    +            "base_url": "https://api.example.com/v1",
    +            "models": ["valid", "", None, 42, "  ", "also-valid"],
    +        }
    +        result = _normalize_custom_provider_entry(entry)
    +        assert result is not None
    +        assert result["models"] == {"valid": {}, "also-valid": {}}
    +
    +    def test_models_empty_list_omitted(self):
    +        """Empty list (falsy) should not produce a models key."""
    +        entry = {
    +            "name": "acme",
    +            "base_url": "https://api.example.com/v1",
    +            "models": [],
    +        }
    +        result = _normalize_custom_provider_entry(entry)
    +        assert result is not None
    +        assert "models" not in result
    ```
- **#17023: fix(model): prefer live /v1/models for user-defined providers (salvage #16747)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/model_switch.py`, `scripts/release.py`, `tests/hermes_cli/test_user_providers_model_switch.py`
  - fix hunks:
  - `hermes_cli/model_switch.py`

    ```diff
    @@ -1346,8 +1346,23 @@ def list_authenticated_providers(
                         if fb:
                             models_list = list(fb)
     
    -            # Try to probe /v1/models if URL is set (but don't block on it)
    -            # For now just show what we know from config
    +            # Prefer the endpoint's live /models list when credentials are
    +            # available. This keeps OpenAI-compatible relays (for example CRS)
    +            # in sync when the server catalog changes without requiring the
    +            # user to mirror every model into config.yaml.
    +            api_key = [redacted:secret-assignment]"api_key", "") or "").strip()
    +            if not api_key:
    +                key_env = str(ep_cfg.get("key_env", "") or "").strip()
    +                api_key = [redacted:secret-assignment] "").strip() if key_env else ""
    +            if api_url and api_key:
    +                try:
    +                    from hermes_cli.models import fetch_api_models
    +                    live_models = fetch_api_models(api_key, api_url)
    +                    if live_models:
    +                        models_list = live_models
    +                except Exception:
    +                    pass
    +
                 results.append({
                     "slug": ep_name,
                     "name": display_name,
    ```
  - `scripts/release.py`

    ```diff
    @@ -53,6 +53,7 @@
         "[redacted:email]": "cyclingwithelephants",
         "[redacted:email]": "liizfq",
         "[redacted:email]": "dhabibi",
    +    "[redacted:email]": "JayGwod",
         "[redacted:email]": "johnncenae",
         "[redacted:email]": "ThomassJonax",
         "[redacted:email]": "yes999zc",
    ```
  - `tests/hermes_cli/test_user_providers_model_switch.py`

    ```diff
    @@ -131,6 +131,55 @@ def test_list_authenticated_providers_enumerates_dict_format_models(monkeypatch)
         ]
     
     
    +def test_list_authenticated_providers_uses_live_models_for_user_provider(monkeypatch):
    +    """User-defined OpenAI-compatible providers should prefer live /models.
    +
    +    Regression: CRS-style providers with a stale config ``models:`` dict kept
    +    showing only the configured subset in the /model picker, even though their
    +    /v1/models endpoint exposed newly added models.
    +    """
    +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    +    monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
    +    monkeypatch.setenv("CRS_TEST_KEY", "sk-test")
    +
    +    calls = []
    +
    +    def fake_fetch_api_models(api_key, base_url):
    +        calls.append((api_key, base_url))
    +        return ["old-configured-model", "new-live-model"]
    +
    +    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models)
    +
    +    user_providers = {
    +        "crs-henkee": {
    +            "name": "CRS Henkee",
    +            "base_url": "http://127.0.0.1:3000/api/v1",
    +            "key_env": "CRS_TEST_KEY",
    +            "model": "old-configured-model",
    +            "models": {
    +                "old-configured-model": {"context_length": 200000},
    +            },
    +        }
    +    }
    +
    +    providers = list_authenticated_providers(
    +        current_provider="crs-henkee",
    +        user_providers=user_providers,
    +        custom_providers=[],
    +        max_models=50,
    +    )
    +
    +    user_prov = next(
    +        (p for p in providers if p.get("is_user_defined") and p["slug"] == "crs-henkee"),
    +        None,
    +    )
    +
    +    assert user_prov is not None
    +    assert calls == [("sk-test", "http://127.0.0.1:3000/api/v1")]
    +    assert user_prov["models"] == ["old-configured-model", "new-live-model"]
    +    assert user_prov["total_models"] == 2
    +
    +
     def test_list_authenticated_providers_dict_models_without_default_model(monkeypatch):
         """Dict-format ``models:`` without a ``default_model`` must still expose
         every dict key, not collapse to an empty list."""
    ```
- **#19072: fix(model_switch): live model discovery for custom_providers in /model picker**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#20763: fix: live model discovery for custom providers in /model picker**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#21211: fix(model_switch): live model discovery for custom_providers in /model picker (salvage #19072)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/model_switch.py`, `tests/hermes_cli/test_model_switch_custom_providers.py`
  - fix hunks:
  - `hermes_cli/model_switch.py`

    ```diff
    @@ -1637,7 +1637,8 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
                             groups[group_key]["models"].append(m)
     
             _section4_emitted_slugs: set = set()
    -        for grp in groups.values():
    +        for grp_key, grp in groups.items():
    +            api_url, api_key = [redacted:secret-assignment]
                 slug = grp["slug"]
                 # If the slug is already claimed by a built-in / overlay /
                 # user-provider row (sections 1-3), skip this custom group
    ```

    ```diff
    @@ -1675,6 +1676,18 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
                 _grp_url_norm = _pair_key[1]
                 if _grp_url_norm and _grp_url_norm in _builtin_endpoints:
                     continue
    +            # Live model discovery from custom provider endpoints (matches
    +            # Section 3 behavior for user ``providers:`` entries).
    +            if api_url and api_key:
    +                try:
    +                    from hermes_cli.models import fetch_api_models
    +
    +                    live_models = fetch_api_models(api_key, api_url)
    +                    if live_models:
    +                        grp["models"] = live_models
    +                        grp["total_models"] = len(live_models)
    +                except Exception:
    +                    pass
                 results.append({
                     "slug": slug,
                     "name": grp["name"],
    ```
  - `tests/hermes_cli/test_model_switch_custom_providers.py`

    ```diff
    @@ -506,3 +506,64 @@ def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
         )
     
         assert "base_url" not in captured
    +
    +
    +def test_custom_providers_uses_live_models_for_multi_model_endpoint(monkeypatch):
    +    """Custom providers with api_key + base_url should prefer live /models.
    +
    +    Custom providers (section 4 of list_authenticated_providers) point at
    +    gateways like Bifrost that expose hundreds of models.  Reading only the
    +    static ``models:`` dict from config.yaml leaves the /model picker with
    +    a stale subset.  Live discovery fills the picker with all available
    +    models from the endpoint.
    +    """
    +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    +    monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
    +
    +    calls = []
    +
    +    def fake_fetch_api_models(api_key, base_url):
    +        calls.append((api_key, base_url))
    +        return ["gateway-model-a", "gateway-model-b", "gateway-model-c"]
    +
    +    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models)
    +
    +    custom_providers = [
    +        {
    +            "name": "my-gateway",
    +            "api_key": "sk-gateway-key",
    +            "base_url": "https://gateway.example.com/v1",
    +            "model": "gateway-model-a",
    +            "models": {
    +                "gateway-model-a": {"context_length": 128000},
    +                "gateway-model-b": {"context_length": 128000},
    +            },
    +        }
    +    ]
    +
    +    providers = list_authenticated_providers(
    +        current_provider="openrouter",
    +        current_base_url="https://openrouter.ai/api/v1",
    +        custom_providers=custom_providers,
    +        max_models=50,
    +    )
    +
    +    gateway_prov = next(
    +        (
    +            p
    +            for p in providers
    +            if p.get("api_url") == "https://gateway.example.com/v1"
    +        ),
    +        None,
    +    )
    +
    +    assert gateway_prov is not None, "Custom provider group not found in results"
    +    assert calls == [("sk-gateway-key", "https://gateway.example.com/v1")], (
    +        "fetch_api_models must be called with the custom provider's credentials"
    +    )
    +    assert gateway_prov["models"] == [
    +        "gateway-model-a",
    +        "gateway-model-b",
    +        "gateway-model-c",
    +    ], "Live models must replace the static subset"
    +    assert gateway_prov["total_models"] == 3
    ```
- **#45597: fix(model): show bare custom endpoints in gateway picker**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/model_switch.py`, `tests/hermes_cli/test_model_switch_custom_providers.py`
- **#61928: fix(model): discover no-key custom provider catalogs**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/model_switch.py`, `tests/hermes_cli/test_model_switch_custom_providers.py`
- **#7132: feat: auto-discover models for custom/user-defined providers via /v1/models**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#21211`, `#21211`, `#17023`
- **#8770: fix(models): list named custom provider models**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#21211`, `#21211`, `#19072`
- **#9134: fix(model): pass api_mode through validation chain and support custom:name:model triple syntax**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#9152: fix(model): api_mode propagation, triple syntax, and custom provider model listing**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#10326: fix(config): support models list in custom_providers picker (#8216)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#12557`
- **#11403: fix(model_switch): read models dict keys from custom_providers entries**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#12557`
- **#11534: fix(model): support providers.*.models dict in Telegram /model picker**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#12557`, `#12505`
- **#11681: fix(model-picker): expose full custom provider model maps (#11677)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#11677`
- **#11968: fix: include all models from custom_providers\[\].models in model picker**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#12557`
- **#12505: fix(model_switch): enumerate dict-format models in /model picker**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#12557`
- **#15081: fix: support list format for custom_providers\[\].models in _normalize_custom_provider_entry**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#14495`, `#15081`, `#15081`, `#14495`
- **#16460: fix(cli): include custom_providers in list_available_providers for TUI /model picker**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#20751: fix: live model discovery for custom providers in /model picker**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#19072`, `#20763`, `#20582`
- **#20760: fix(cli): probe /v1/models for custom providers in model picker**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
- **#20919: fix(models): show configured custom provider model in /model picker**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
- **#33601: fix(model): probe keyless user provider catalogs**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `1039e90b5e2a2ab8b005a780ca12b9cdd52362db`
- **#33966: fix(cli): probe live /v1/models for keyless providers in model picker**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#33601`, `#33595`
- **#40529: fix(cli): surface inline custom provider model in the picker (#40480)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `74c5158b1`, `#45597`, `#40480`
- **#40554: fix(cli): probe /v1/models live when custom provider has no api_key**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#61928`, `#55158`, `#40542`
- **#47050: fix(inventory): skip self-dedup when custom provider is also classified as aggregator**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `b7fa62c53019a7e44599de4f6cdd30071ee7b7e4`
- **#47133: fix(picker): skip user-defined providers in aggregator dedup (#47042)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#46921`, `#47050`, `#47042`, `#45954`, `#46829`
- **#47727: fix(cli): narrow model picker dedup to routing aggregators only**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `hermes_cli/inventory.py`, `hermes_cli/providers.py`
- **#48902: fix(model-picker): support model-keyed user providers**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `hermes_cli/config.py`, `hermes_cli/model_switch.py`, `hermes_cli/providers.py`, `tests/hermes_cli/test_user_providers_model_switch.py`, `tests/test_tui_gateway_server.py`
- **#49150: fix(cli): preserve named provider aggregator models (#49126)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `a6ce9b2fbbdfbe1fecf6c72d28d02a72adccf82f`, `#49273`
- **#49273: fix(cli): restrict aggregator dedup to custom proxy providers (#49126)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#49150`, `#49126`, `#49150`
- **#55158: fix(model): probe no-key custom provider catalogs**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#61928`
- **#59912: fix(model-switch): merge configured models with live /models discovery instead of replacing**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#60320: fix(model-switch): merge configured custom_providers models with live discovery**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#36834`, `#36834`, `#59912`, `#36834`, `#59560`, `#35145`
- **#60503: fix(model-switch): merge configured custom_providers.models**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#59560`, `#36834`
- **#61606: feat(lmstudio): add local model management**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `agent/agent_runtime_helpers.py`, `agent/agent_runtime_helpers_model_switch.py`, `apps/desktop/src/app/settings/config-subpages.ts`, `apps/desktop/src/app/settings/constants.ts`, `hermes_cli/config_defaults.py`, `hermes_cli/models_lmstudio_instances.py`, `hermes_cli/models_local.py`, `hermes_cli/web_server_config.py`, `run_agent.py`, `tests/hermes_cli/test_lmstudio_instance_ownership.py`, `website/docs/integrations/providers.md`
- **#116765: fix(desktop): keep a configured custom provider visible in the model pickers**. open
  - maintainer direction: category `silent_close`; implication `active`
  - files: `apps/desktop/src/app/shell/model-catalog-menu.test.tsx`, `apps/desktop/src/app/shell/model-catalog-menu.tsx`, `apps/desktop/src/app/shell/model-menu-panel.tsx`, `apps/desktop/src/components/model-picker.test.tsx`, `apps/desktop/src/components/model-picker.tsx`, `apps/desktop/src/i18n/en.ts`, `apps/desktop/src/i18n/types.ts`, `apps/desktop/src/lib/model-options.ts`

## Fix status

- Merged to main in PR #12557 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-10`.
- Subsystem: `apps/desktop`
