Model alias resolution across CLI and runtime

NousResearch/hermes-agent · cluster 16745 · hermes cli

Data as of

5 open items. Fix merged in #16924. Check it covers your case.

The short answer

3open issues
2open PRs
12items grouped
Known?
Yes. 12 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #16924. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #16924, #16977. That is separate from the fix event.
Still open
3 issues and 2 PRs.

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.

Open issues3

  • Issue#18954Model aliases not resolved for custom providers
  • Issue#62491hermes chat --model <alias> ignores model_aliases, falls through to default provider
  • Issue#62494hermes chat --model <alias> ignores model_aliases, routes alias verbatim to default provider

Open PRs2

  • PR#19213fix(config): resolve direct model aliases at runtime
  • PR#62534fix(cli): resolve --model <alias> through model_aliases in interactive chat (#62491)

Fixes on record

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

  • #16924 fix(cli): honor user-defined providers via chat --provider and -m <alias> (#16767)

    merged to main

    files: 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

    Show the diff
    • hermes_cli/main.py
      @@ -7903,32 +7903,12 @@ def main():
           )
           chat_parser.add_argument(
               "--provider",
      -        choices=[
      -            "auto",
      -            "openrouter",
      -            "nous",
      -            "openai-codex",
      -            "copilot-acp",
      -            "copilot",
      -            "anthropic",
      -            "gemini",
      -            "xai",
      -            "ollama-cloud",
      -            "huggingface",
      -            "zai",
      -            "kimi-coding",
      -            "kimi-coding-cn",
      -            "stepfun",
      -            "minimax",
      -            "minimax-cn",
      -            "kilocode",
      -            "xiaomi",
      -            "arcee",
      -            "gmi",
      -            "nvidia",
      -        ],
      +        # No `choices=` here: user-defined providers from config.yaml `providers:`
      +        # are also valid values, and runtime resolution (resolve_runtime_provider)
      +        # handles validation/error reporting consistently with the top-level
      +        # `--provider` flag.
               default=None,
      -        help="Inference provider (default: auto)",
      +        help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
           )
           chat_parser.add_argument(
               "-v", "--verbose", action="store_true", help="Verbose output"
    • hermes_cli/model_switch.py
      @@ -213,10 +213,15 @@ def _load_direct_aliases() -> dict[str, DirectAlias]:
       
       
       def _ensure_direct_aliases() -> None:
      -    """Lazy-load direct aliases on first use."""
      -    global DIRECT_ALIASES
      +    """Lazy-load direct aliases on first use.
      +
      +    Mutates the existing DIRECT_ALIASES dict in place rather than rebinding
      +    the module attribute. This keeps `from hermes_cli.model_switch import
      +    DIRECT_ALIASES` references valid in callers — rebinding would leave them
      +    pointing at a stale empty dict.
      +    """
           if not DIRECT_ALIASES:
      -        DIRECT_ALIASES = _load_direct_aliases()
      +        DIRECT_ALIASES.update(_load_direct_aliases())
       
       
       # ---------------------------------------------------------------------------
    • hermes_cli/oneshot.py
      @@ -128,27 +128,44 @@ def _run_agent(
           # the user's configured default provider, which may not host the model
           # the caller just asked for.
           effective_provider = (provider or "").strip() or None
      +    explicit_base_url_from_alias: Optional[str] = None
           if effective_provider is None and (model or env_model):
               # Only auto-detect when the model was explicitly requested via arg or
               # env var (not when it came from config — that's the "use my defaults"
               # path and the configured provider is already correct).
               explicit_model = (model or "").strip() or env_model
               if explicit_model:
      -            cfg_provider = ""
      -            if isinstance(model_cfg, dict):
      -                cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
      -            current_provider = (
      -                cfg_provider
      -                or os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
      -                or "auto"
      -            )
      -            detected = detect_provider_for_model(explicit_model, current_provider)
      -            if detected:
      -                effective_provider, effective_model = detected
      +            # First check DIRECT_ALIASES populated from config.yaml `model_aliases:`.
      +            # These map a user-defined alias to (model, provider, base_url) for
      +            # endpoints not in any catalog (local servers, custom proxies, etc.).
      +            try:
      +                from hermes_cli import model_switch as _ms
      +                _ms._ensure_direct_aliases()
      +                direct = _ms.DIRECT_ALIASES.get(explicit_model.strip().lower())
      +            except Exception:
      +                direct = None
      +            if direct is not None:
      +                effective_model = direct.model
      +                effective_provider = direct.provider
      +                if direct.base_url:
      +                    explicit_base_url_from_alias = direct.base_url.rstrip("/")
      +            else:
      +                cfg_provider = ""
      +                if isinstance(model_cfg, dict):
      +                    cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
      +                current_provider = (
      +                    cfg_provider
      +                    or os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
      +                    or "auto"
      +                )
      +                detected = detect_provider_for_model(explicit_model, current_provider)
      +                if detected:
      +                    effective_provider, effective_model = detected
       
           runtime = resolve_runtime_provider(
               requested=effective_provider,
               target_model=effective_model or None,
      +        explicit_base_url=explicit_base_url_from_alias,
           )
       
           # Pull in whatever toolsets the user has enabled for "cli".
    • hermes_cli/runtime_provider.py
      @@ -469,6 +469,30 @@ def _resolve_named_custom_runtime(
           explicit_api_key: [redacted:secret-assignment] = None,
           explicit_base_url: Optional[str] = None,
       ) -> Optional[Dict[str, Any]]:
      +    # Bare `provider="custom"` with an explicit base_url (e.g. propagated
      +    # from a `model_aliases:` direct-alias resolution) — build a runtime
      +    # directly so the alias's base_url actually takes effect.
      +    requested_norm = (requested_provider or "").strip().lower()
      +    if requested_norm == "custom" and explicit_base_url:
      +        base_url = explicit_base_url.strip().rstrip("/")
      +        api_key_candidates = [
      +            (explicit_api_key or "").strip(),
      +            os.getenv("OPENAI_API_KEY", "").strip(),
      +            os.getenv("OPENROUTER_API_KEY", "").strip(),
      +        ]
      +        api_key = next(
      +            (c for c in api_key_candidates if has_usable_secret(c)),
      +            "",
      +        ) or "no-key-required"
      +        return {
      +            "provider": "custom",
      +            "api_mode": _detect_api_mode_for_url(base_url) or "chat_completions",
      +            "base_url": base_url,
      +            "api_key": api_key,
      +            "source": "direct-alias",
      +            "requested_provider": requested_provider,
      +        }
      +
           custom_provider = _get_named_custom_provider(requested_provider)
           if not custom_provider:
               return None
    • scripts/release.py
      @@ -58,6 +58,7 @@
           "[redacted:email]": "DrStrangerUJN",
           "[redacted:email]": "dlkakbs",
           "[redacted:email]": "Mind-Dragon",
      +    "[redacted:email]": "westers",
           "[redacted:email]": "devorun",
           "[redacted:email]": "Sonoyunchu",
           "[redacted:email]": "say8hi",
    • tests/hermes_cli/test_regression_16767.py
      @@ -0,0 +1,58 @@
      +import pytest
      +import sys
      +from unittest.mock import patch
      +from pathlib import Path
      +
      +import hermes_cli.model_switch as ms
      +from hermes_cli.model_switch import DirectAlias
      +from hermes_cli.runtime_provider import _resolve_named_custom_runtime
      +
      +def test_ensure_direct_aliases_mutates_in_place(monkeypatch):
      +    """_ensure_direct_aliases mutates DIRECT_ALIASES in place (guards against rebinding regression)."""
      +    # Ensure we start with an empty but existing dict to check for mutation vs rebinding
      +    ms.DIRECT_ALIASES.clear()
      +    initial_id = id(ms.DIRECT_ALIASES)
      +    
      +    mock_data = {
      +        "my-custom-alias": DirectAlias("custom-model:v1", "custom", "https://example.com/v1")
      +    }
      +    monkeypatch.setattr(ms, "_load_direct_aliases", lambda: mock_data)
      +    
      +    ms._ensure_direct_aliases()
      +    
      +    assert id(ms.DIRECT_ALIASES) == initial_id, f"DIRECT_ALIASES was rebound (ID changed from {initial_id} to {id(ms.DIRECT_ALIASES)})"
      +    assert "my-custom-alias" in ms.DIRECT_ALIASES
      +    assert ms.DIRECT_ALIASES["my-custom-alias"].model == "custom-model:v1"
      +
      +def test_chat_provider_argparse_acceptance(monkeypatch):
      +    """chat --provider <user-defined> is accepted by argparse (guards against restrictive choices)."""
      +    recorded: dict[str, str] = {}
      +
      +    # Mock cmd_chat to record the provider passed to it
      +    def mock_cmd_chat(args):
      +        recorded["provider"] = args.provider
      +
      +    monkeypatch.setattr("hermes_cli.main.cmd_chat", mock_cmd_chat)
      +    monkeypatch.setattr(sys, "argv", ["hermes", "chat", "--provider", "my-custom-key"])
      +
      +    from hermes_cli.main import main
      +    main()
      +
      +    assert recorded["provider"] == "my-custom-key"
      +
      +def test_resolve_named_custom_runtime_honors_explicit_base_url(monkeypatch):
      +    """_resolve_named_custom_runtime honors (provider='custom', explicit_base_url=...)."""
      +    # Mock has_usable_secret to recognize our test key
      +    monkeypatch.setattr("hermes_cli.runtime_provider.has_usable_secret", lambda x: x == "test-api-key")
      +    
      +    result = _resolve_named_custom_runtime(
      +        requested_provider="custom",
      +        explicit_api_key="[redacted:secret-assignment]",
      +        explicit_base_url="http://example.test:1234/v1"
      +    )
      +    
      +    assert result is not None
      +    assert result["base_url"] == "http://example.test:1234/v1"
      +    assert result["provider"] == "custom"
      +    assert result["api_key"] == "test-api-key"
      +    assert result["source"] == "direct-alias"
  • #16977 fix(claw-migrate): resolve model aliases against real OpenClaw catalog schema (salvage #16778)

    merged to main

    files: optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py, tests/skills/test_openclaw_migration.py

3 PRs closed without merging

From the close comments: merged via another change 2 · superseded by another PR 1

  • #16778 fix: resolve model aliases during claw migrate (#16745) · merged via another change → 7996c14, #16977, #16745
  • #16811 Fix CLI routing for user-defined providers (closes #16767) · merged via another change → #16924
  • #44667 fix(runtime): handle api_key_env alias in _get_named_custom_provider · superseded by another PR
Closed issues · 2
  • Closed#16745[Bug]: claw migrate: model alias copied verbatim instead of resolved to API ID
  • Closed#16767CLI doesn't honor user-defined providers via chat --provider or -m <alias>
How this cluster was grouped
  • Link evidence (partly structural links): 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.15: 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

Subsystem
hermes cli
Merged PRs
#16924, #16977
Closed issues
2
Attempts
3 PRs closed without merging
Fix link checked
28 Apr 2026

For your agent

The same facts, typed.