{"repo":"NousResearch/hermes-agent","public_id":"16745","display_label":"Model alias resolution across CLI and runtime","summary":"These issues and pull requests address failures to resolve user-defined model aliases in the CLI, runtime, config, and migration tooling, where aliases were passed verbatim instead of being looked up against the configured alias mappings. Multiple contexts are affected: interactive chat, provider routing, and the claw migrate command.","subsystem":"hermes_cli","counts":{"members":12,"open_issues":3,"open_issues_distinct":3,"open_prs":2,"closed_issues":2,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16924},"resolution":{"state":"partially_resolved","by":"pr-16924,pr-16977"},"freshness":{"state":"verified","last_verified_at":"2026-04-28T11:58:14+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.15393939393939396},"fix_edges":[{"issue_number":16745,"pr_number":16977,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":16767,"pr_number":16924,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":16745,"title":"[Bug]: claw migrate: model alias copied verbatim instead of resolved to API ID","url":null,"state":"closed"},{"kind":"issue","number":16767,"title":"CLI doesn't honor user-defined providers via chat --provider or -m <alias>","url":null,"state":"closed"},{"kind":"issue","number":18954,"title":"Model aliases not resolved for custom providers","url":null,"state":"open"},{"kind":"issue","number":62491,"title":"hermes chat --model <alias> ignores model_aliases, falls through to default provider","url":null,"state":"open"},{"kind":"issue","number":62494,"title":"hermes chat --model <alias> ignores model_aliases, routes alias verbatim to default provider","url":null,"state":"open"},{"kind":"pr","number":16778,"title":"fix: resolve model aliases during claw migrate (#16745)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7996c1479","number":0},{"kind":"pr","value":"#16977","number":16977},{"kind":"pr","value":"#16745","number":16745}],"category":"merged_via","implication":"superseded"},"merged":false},{"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","implication":"superseded"},"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\": api_key,\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     \"[redacted:email]\": \"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"]}]},{"kind":"pr","number":16977,"title":"fix(claw-migrate): resolve model aliases against real OpenClaw catalog schema (salvage #16778)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py","tests/skills/test_openclaw_migration.py"]},{"kind":"pr","number":19213,"title":"fix(config): resolve direct model aliases at runtime","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#29198","number":29198},{"kind":"pr","value":"#29198","number":29198}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":44667,"title":"fix(runtime): handle api_key_env alias in _get_named_custom_provider","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":62534,"title":"fix(cli): resolve --model <alias> through model_aliases in interactive chat (#62491)","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"c1517f018","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}