{"repo":"NousResearch/hermes-agent","public_id":"16767","display_label":"CLI support for user-defined providers via --provider and -m","summary":"The three members concern the CLI not honoring user-defined providers when using `chat --provider` or `-m <alias>`. One appears to be an issue report and the other two are pull requests that propose changes to routing, with references to #16767.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16924,"merged_at":"2026-04-28T08:47:20+00:00"},"resolution":{"state":"resolved","by":"pr-16924"},"freshness":{"state":"stale","last_verified_at":"2026-04-28T08:47:20+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":16767,"pr_number":16924,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-28T08:47:20+00:00"}],"members":[{"kind":"issue","number":16767,"title":"CLI doesn't honor user-defined providers via chat --provider or -m <alias>","url":null,"state":"closed"},{"kind":"pr","number":16811,"title":"Fix CLI routing for user-defined providers (closes #16767)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16924","number":16924}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":16924,"title":"fix(cli): honor user-defined providers via chat --provider and -m <alias> (#16767)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","hermes_cli/model_switch.py","hermes_cli/oneshot.py","hermes_cli/runtime_provider.py","scripts/release.py","tests/hermes_cli/test_regression_16767.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -7903,32 +7903,12 @@ def main():\n     )\n     chat_parser.add_argument(\n         \"--provider\",\n-        choices=[\n-            \"auto\",\n-            \"openrouter\",\n-            \"nous\",\n-            \"openai-codex\",\n-            \"copilot-acp\",\n-            \"copilot\",\n-            \"anthropic\",\n-            \"gemini\",\n-            \"xai\",\n-            \"ollama-cloud\",\n-            \"huggingface\",\n-            \"zai\",\n-            \"kimi-coding\",\n-            \"kimi-coding-cn\",\n-            \"stepfun\",\n-            \"minimax\",\n-            \"minimax-cn\",\n-            \"kilocode\",\n-            \"xiaomi\",\n-            \"arcee\",\n-            \"gmi\",\n-            \"nvidia\",\n-        ],\n+        # No `choices=` here: user-defined providers from config.yaml `providers:`\n+        # are also valid values, and runtime resolution (resolve_runtime_provider)\n+        # handles validation/error reporting consistently with the top-level\n+        # `--provider` flag.\n         default=None,\n-        help=\"Inference provider (default: auto)\",\n+        help=\"Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.\",\n     )\n     chat_parser.add_argument(\n         \"-v\", \"--verbose\", action=\"store_true\", help=\"Verbose output\""]},{"file":"hermes_cli/model_switch.py","hunks":["@@ -213,10 +213,15 @@ def _load_direct_aliases() -> dict[str, DirectAlias]:\n \n \n def _ensure_direct_aliases() -> None:\n-    \"\"\"Lazy-load direct aliases on first use.\"\"\"\n-    global DIRECT_ALIASES\n+    \"\"\"Lazy-load direct aliases on first use.\n+\n+    Mutates the existing DIRECT_ALIASES dict in place rather than rebinding\n+    the module attribute. This keeps `from hermes_cli.model_switch import\n+    DIRECT_ALIASES` references valid in callers — rebinding would leave them\n+    pointing at a stale empty dict.\n+    \"\"\"\n     if not DIRECT_ALIASES:\n-        DIRECT_ALIASES = _load_direct_aliases()\n+        DIRECT_ALIASES.update(_load_direct_aliases())\n \n \n # ---------------------------------------------------------------------------"]},{"file":"hermes_cli/oneshot.py","hunks":["@@ -128,27 +128,44 @@ def _run_agent(\n     # the user's configured default provider, which may not host the model\n     # the caller just asked for.\n     effective_provider = (provider or \"\").strip() or None\n+    explicit_base_url_from_alias: Optional[str] = None\n     if effective_provider is None and (model or env_model):\n         # Only auto-detect when the model was explicitly requested via arg or\n         # env var (not when it came from config — that's the \"use my defaults\"\n         # path and the configured provider is already correct).\n         explicit_model = (model or \"\").strip() or env_model\n         if explicit_model:\n-            cfg_provider = \"\"\n-            if isinstance(model_cfg, dict):\n-                cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n-            current_provider = (\n-                cfg_provider\n-                or os.getenv(\"HERMES_INFERENCE_PROVIDER\", \"\").strip().lower()\n-                or \"auto\"\n-            )\n-            detected = detect_provider_for_model(explicit_model, current_provider)\n-            if detected:\n-                effective_provider, effective_model = detected\n+            # First check DIRECT_ALIASES populated from config.yaml `model_aliases:`.\n+            # These map a user-defined alias to (model, provider, base_url) for\n+            # endpoints not in any catalog (local servers, custom proxies, etc.).\n+            try:\n+                from hermes_cli import model_switch as _ms\n+                _ms._ensure_direct_aliases()\n+                direct = _ms.DIRECT_ALIASES.get(explicit_model.strip().lower())\n+            except Exception:\n+                direct = None\n+            if direct is not None:\n+                effective_model = direct.model\n+                effective_provider = direct.provider\n+                if direct.base_url:\n+                    explicit_base_url_from_alias = direct.base_url.rstrip(\"/\")\n+            else:\n+                cfg_provider = \"\"\n+                if isinstance(model_cfg, dict):\n+                    cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n+                current_provider = (\n+                    cfg_provider\n+                    or os.getenv(\"HERMES_INFERENCE_PROVIDER\", \"\").strip().lower()\n+                    or \"auto\"\n+                )\n+                detected = detect_provider_for_model(explicit_model, current_provider)\n+                if detected:\n+                    effective_provider, effective_model = detected\n \n     runtime = resolve_runtime_provider(\n         requested=effective_provider,\n         target_model=effective_model or None,\n+        explicit_base_url=explicit_base_url_from_alias,\n     )\n \n     # Pull in whatever toolsets the user has enabled for \"cli\"."]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -469,6 +469,30 @@ def _resolve_named_custom_runtime(\n     explicit_api_key: [redacted:secret-assignment] = None,\n     explicit_base_url: Optional[str] = None,\n ) -> Optional[Dict[str, Any]]:\n+    # Bare `provider=\"custom\"` with an explicit base_url (e.g. propagated\n+    # from a `model_aliases:` direct-alias resolution) — build a runtime\n+    # directly so the alias's base_url actually takes effect.\n+    requested_norm = (requested_provider or \"\").strip().lower()\n+    if requested_norm == \"custom\" and explicit_base_url:\n+        base_url = explicit_base_url.strip().rstrip(\"/\")\n+        api_key_candidates = [\n+            (explicit_api_key or \"\").strip(),\n+            os.getenv(\"OPENAI_API_KEY\", \"\").strip(),\n+            os.getenv(\"OPENROUTER_API_KEY\", \"\").strip(),\n+        ]\n+        api_key = next(\n+            (c for c in api_key_candidates if has_usable_secret(c)),\n+            \"\",\n+        ) or \"no-key-required\"\n+        return {\n+            \"provider\": \"custom\",\n+            \"api_mode\": _detect_api_mode_for_url(base_url) or \"chat_completions\",\n+            \"base_url\": base_url,\n+            \"api_key\": [redacted:secret-assignment]\n+            \"source\": \"direct-alias\",\n+            \"requested_provider\": requested_provider,\n+        }\n+\n     custom_provider = _get_named_custom_provider(requested_provider)\n     if not custom_provider:\n         return None"]},{"file":"scripts/release.py","hunks":["@@ -58,6 +58,7 @@\n     \"[redacted:email]\": \"DrStrangerUJN\",\n     \"[redacted:email]\": \"dlkakbs\",\n     \"[redacted:email]\": \"Mind-Dragon\",\n+    \"[redacted:email]\": \"westers\",\n     \"130918800+devorun@users.noreply.github.com\": \"devorun\",\n     \"[redacted:email]\": \"Sonoyunchu\",\n     \"[redacted:email]\": \"say8hi\","]},{"file":"tests/hermes_cli/test_regression_16767.py","hunks":["@@ -0,0 +1,58 @@\n+import pytest\n+import sys\n+from unittest.mock import patch\n+from pathlib import Path\n+\n+import hermes_cli.model_switch as ms\n+from hermes_cli.model_switch import DirectAlias\n+from hermes_cli.runtime_provider import _resolve_named_custom_runtime\n+\n+def test_ensure_direct_aliases_mutates_in_place(monkeypatch):\n+    \"\"\"_ensure_direct_aliases mutates DIRECT_ALIASES in place (guards against rebinding regression).\"\"\"\n+    # Ensure we start with an empty but existing dict to check for mutation vs rebinding\n+    ms.DIRECT_ALIASES.clear()\n+    initial_id = id(ms.DIRECT_ALIASES)\n+    \n+    mock_data = {\n+        \"my-custom-alias\": DirectAlias(\"custom-model:v1\", \"custom\", \"https://example.com/v1\")\n+    }\n+    monkeypatch.setattr(ms, \"_load_direct_aliases\", lambda: mock_data)\n+    \n+    ms._ensure_direct_aliases()\n+    \n+    assert id(ms.DIRECT_ALIASES) == initial_id, f\"DIRECT_ALIASES was rebound (ID changed from {initial_id} to {id(ms.DIRECT_ALIASES)})\"\n+    assert \"my-custom-alias\" in ms.DIRECT_ALIASES\n+    assert ms.DIRECT_ALIASES[\"my-custom-alias\"].model == \"custom-model:v1\"\n+\n+def test_chat_provider_argparse_acceptance(monkeypatch):\n+    \"\"\"chat --provider <user-defined> is accepted by argparse (guards against restrictive choices).\"\"\"\n+    recorded: dict[str, str] = {}\n+\n+    # Mock cmd_chat to record the provider passed to it\n+    def mock_cmd_chat(args):\n+        recorded[\"provider\"] = args.provider\n+\n+    monkeypatch.setattr(\"hermes_cli.main.cmd_chat\", mock_cmd_chat)\n+    monkeypatch.setattr(sys, \"argv\", [\"hermes\", \"chat\", \"--provider\", \"my-custom-key\"])\n+\n+    from hermes_cli.main import main\n+    main()\n+\n+    assert recorded[\"provider\"] == \"my-custom-key\"\n+\n+def test_resolve_named_custom_runtime_honors_explicit_base_url(monkeypatch):\n+    \"\"\"_resolve_named_custom_runtime honors (provider='custom', explicit_base_url=...).\"\"\"\n+    # Mock has_usable_secret to recognize our test key\n+    monkeypatch.setattr(\"hermes_cli.runtime_provider.has_usable_secret\", lambda x: x == \"test-api-key\")\n+    \n+    result = _resolve_named_custom_runtime(\n+        requested_provider=\"custom\",\n+        explicit_api_key=\"[redacted:secret-assignment]\",\n+        explicit_base_url=\"http://example.test:1234/v1\"\n+    )\n+    \n+    assert result is not None\n+    assert result[\"base_url\"] == \"http://example.test:1234/v1\"\n+    assert result[\"provider\"] == \"custom\"\n+    assert result[\"api_key\"] == \"test-api-key\"\n+    assert result[\"source\"] == \"direct-alias\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}