NousResearch/hermes-agent

List-format provider models silently dropped or mishandled

Cluster 11017. Data as of

Nothing open on GitHub. Fix merged in #14495 on 23 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #14495. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #14495. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and fixes address a bug where list-format model entries in custom provider configurations were being silently dropped or mishandled, causing problems in the model picker. Multiple fix attempts target normalization and enumeration of dict-format and list-format models.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #12557 fix(model_switch): enumerate dict-format models in /model picker + section-3 refinements

    merged to main

    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

    Show the diff
    • hermes_cli/model_switch.py
      @@ -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)
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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: {})
      @@ -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

    files: hermes_cli/config.py, scripts/release.py, tests/hermes_cli/test_provider_config_validation.py

    Show the diff
    • hermes_cli/config.py
      @@ -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
      @@ -43,6 +43,7 @@
           "[redacted:email]": "teknium1",
           "[redacted:email]": "teknium1",
           "[email protected]": "teknium1",
      +    "[redacted:email]": "DrStrangerUJN",
           # contributors (from noreply pattern)
           "[redacted:email]": "xiaoqiang243",
           "[redacted:email]": "snreynolds",
    • tests/hermes_cli/test_provider_config_validation.py
      @@ -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

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #11130 fix #11017: handle list-type models in providers dict format · superseded by another PR → #12557
Closed issues · 1
  • Closed#11017Bug: providers: dict models list silently dropped, causes duplicate entry with 0 models in /model picker
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#12557, #14495
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
23 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.