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
- 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.
Next step: read #16924, then check whether your report is one of the 3 still open.
Read #16924Pick up a Lab taskThese 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
Open PRs2
Fixes on record
-
#16924 fix(cli): honor user-defined providers via chat --provider and -m <alias> (#16767)
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.pyShow 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)
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
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.00means 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