CLI support for user-defined providers via --provider and -m
Cluster 16767. Data as of
Nothing open on GitHub. Fix merged in #16924 on 28 Apr 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue 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
resolved, deciding PR #16924. That is separate from the fix event. - Still open
- Nothing open on GitHub.
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.
Open issues0
Open PRs0
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": [redacted:secret-assignment] + "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", "[email protected]": "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"
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#16811 Fix CLI routing for user-defined providers (closes #16767) · merged via another change →
#16924
Closed issues · 1
- Closed#16767CLI doesn't honor user-defined providers via chat --provider or -m <alias>
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: 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 LabAt a glance
- Merged PRs
- #16924
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 28 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.