diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 7835248668..e2bc5f8659 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1039,9 +1039,23 @@ 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 = [] @@ -1073,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 --git a/scripts/release.py b/scripts/release.py index a20c3c134f..f94166868e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -177,6 +177,7 @@ AUTHOR_MAP = { "duerzy@gmail.com": "duerzy", "emozilla@nousresearch.com": "emozilla", "fancydirty@gmail.com": "fancydirty", + "farion1231@gmail.com": "farion1231", "floptopbot33@gmail.com": "flobo3", "fontana.pedro93@gmail.com": "pefontana", "francis.x.fitzpatrick@gmail.com": "fxfitz", diff --git a/tests/hermes_cli/test_user_providers_model_switch.py b/tests/hermes_cli/test_user_providers_model_switch.py index 4c4c61a55f..9c0cfcf687 100644 --- a/tests/hermes_cli/test_user_providers_model_switch.py +++ b/tests/hermes_cli/test_user_providers_model_switch.py @@ -227,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 # =============================================================================