diff --git a/cli.py b/cli.py index d7a5bcaa1d..0d0d312292 100755 --- a/cli.py +++ b/cli.py @@ -2161,6 +2161,35 @@ class HermesCLI: print(" Usage: /model ") print(" /model provider:model-name (to switch provider)") print(" Example: /model openrouter:anthropic/claude-sonnet-4.5") + print(" See /provider for available providers") + elif cmd_lower == "/provider": + from hermes_cli.models import list_available_providers, normalize_provider, _PROVIDER_LABELS + from hermes_cli.auth import resolve_provider as _resolve_provider + # Resolve current provider + raw_provider = normalize_provider(self.provider) + if raw_provider == "auto": + try: + current = _resolve_provider( + self.requested_provider, + explicit_api_key=self._explicit_api_key, + explicit_base_url=self._explicit_base_url, + ) + except Exception: + current = "openrouter" + else: + current = raw_provider + current_label = _PROVIDER_LABELS.get(current, current) + print(f"\n Current provider: {current_label} ({current})\n") + providers = list_available_providers() + print(" Available providers:") + for p in providers: + marker = " ← active" if p["id"] == current else "" + auth = "✓" if p["authenticated"] else "✗" + aliases = f" (also: {', '.join(p['aliases'])})" if p["aliases"] else "" + print(f" [{auth}] {p['id']:<14} {p['label']}{aliases}{marker}") + print() + print(" Switch: /model provider:model-name") + print(" Setup: hermes setup") elif cmd_lower.startswith("/prompt"): # Use original case so prompt text isn't lowercased self._handle_prompt_command(cmd_original) diff --git a/gateway/run.py b/gateway/run.py index d1dcd8976e..a79c86eeb7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -734,6 +734,9 @@ class GatewayRunner: if command == "model": return await self._handle_model_command(event) + if command == "provider": + return await self._handle_provider_command(event) + if command == "personality": return await self._handle_personality_command(event) @@ -1292,6 +1295,7 @@ class GatewayRunner: "`/status` — Show session info", "`/stop` — Interrupt the running agent", "`/model [provider:model]` — Show/change model (or switch provider)", + "`/provider` — Show available providers and auth status", "`/personality [name]` — Set a personality", "`/retry` — Retry your last message", "`/undo` — Remove the last exchange", @@ -1412,23 +1416,27 @@ class GatewayRunner: if not validation.get("accepted"): return f"⚠️ {validation.get('message')}" - # Write to config.yaml - try: - user_config = {} - if config_path.exists(): - with open(config_path) as f: - user_config = yaml.safe_load(f) or {} - if "model" not in user_config or not isinstance(user_config["model"], dict): - user_config["model"] = {} - user_config["model"]["default"] = new_model - if provider_changed: - user_config["model"]["provider"] = target_provider - with open(config_path, 'w') as f: - yaml.dump(user_config, f, default_flow_style=False, sort_keys=False) - except Exception as e: - return f"⚠️ Failed to save model change: {e}" + # Persist to config only if validation approves + if validation.get("persist"): + try: + user_config = {} + if config_path.exists(): + with open(config_path) as f: + user_config = yaml.safe_load(f) or {} + if "model" not in user_config or not isinstance(user_config["model"], dict): + user_config["model"] = {} + user_config["model"]["default"] = new_model + if provider_changed: + user_config["model"]["provider"] = target_provider + with open(config_path, 'w') as f: + yaml.dump(user_config, f, default_flow_style=False, sort_keys=False) + except Exception as e: + return f"⚠️ Failed to save model change: {e}" + # Set env vars so the next agent run picks up the change os.environ["HERMES_MODEL"] = new_model + if provider_changed: + os.environ["HERMES_INFERENCE_PROVIDER"] = target_provider provider_label = _PROVIDER_LABELS.get(target_provider, target_provider) provider_note = f"\n**Provider:** {provider_label}" if provider_changed else "" @@ -1439,6 +1447,56 @@ class GatewayRunner: persist_note = "saved to config" if validation.get("persist") else "session only" return f"🤖 Model changed to `{new_model}` ({persist_note}){provider_note}{warning}\n_(takes effect on next message)_" + + async def _handle_provider_command(self, event: MessageEvent) -> str: + """Handle /provider command - show available providers.""" + import yaml + from hermes_cli.models import ( + list_available_providers, + normalize_provider, + _PROVIDER_LABELS, + ) + + # Resolve current provider from config + current_provider = "openrouter" + config_path = _hermes_home / 'config.yaml' + try: + if config_path.exists(): + with open(config_path) as f: + cfg = yaml.safe_load(f) or {} + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + current_provider = model_cfg.get("provider", current_provider) + except Exception: + pass + + current_provider = normalize_provider(current_provider) + if current_provider == "auto": + try: + from hermes_cli.auth import resolve_provider as _resolve_provider + current_provider = _resolve_provider(current_provider) + except Exception: + current_provider = "openrouter" + + current_label = _PROVIDER_LABELS.get(current_provider, current_provider) + + lines = [ + f"🔌 **Current provider:** {current_label} (`{current_provider}`)", + "", + "**Available providers:**", + ] + + providers = list_available_providers() + for p in providers: + marker = " ← active" if p["id"] == current_provider else "" + auth = "✅" if p["authenticated"] else "❌" + aliases = f" _(also: {', '.join(p['aliases'])})_" if p["aliases"] else "" + lines.append(f"{auth} `{p['id']}` — {p['label']}{aliases}{marker}") + + lines.append("") + lines.append("Switch: `/model provider:model-name`") + lines.append("Setup: `hermes setup`") + return "\n".join(lines) async def _handle_personality_command(self, event: MessageEvent) -> str: """Handle /personality command - list or set a personality.""" diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 4d3448fbe1..61c5864fd6 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -18,6 +18,7 @@ COMMANDS = { "/tools": "List available tools", "/toolsets": "List available toolsets", "/model": "Show or change the current model", + "/provider": "Show available providers and current provider", "/prompt": "View/set custom system prompt", "/personality": "Set a predefined personality", "/clear": "Clear screen and reset conversation (fresh start)", diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 80e09fea1e..723f226ead 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -91,6 +91,51 @@ def menu_labels() -> list[str]: return labels +# All provider IDs and aliases that are valid for the provider:model syntax. +_KNOWN_PROVIDER_NAMES: set[str] = ( + set(_PROVIDER_LABELS.keys()) + | set(_PROVIDER_ALIASES.keys()) + | {"openrouter", "custom"} +) + + +def list_available_providers() -> list[dict[str, str]]: + """Return info about all providers the user could use with ``provider:model``. + + Each dict has ``id``, ``label``, and ``aliases``. + Checks which providers have valid credentials configured. + """ + # Canonical providers in display order + _PROVIDER_ORDER = [ + "openrouter", "nous", "openai-codex", + "zai", "kimi-coding", "minimax", "minimax-cn", + ] + # Build reverse alias map + aliases_for: dict[str, list[str]] = {} + for alias, canonical in _PROVIDER_ALIASES.items(): + aliases_for.setdefault(canonical, []).append(alias) + + result = [] + for pid in _PROVIDER_ORDER: + label = _PROVIDER_LABELS.get(pid, pid) + alias_list = aliases_for.get(pid, []) + # Check if this provider has credentials available + has_creds = False + try: + from hermes_cli.runtime_provider import resolve_runtime_provider + runtime = resolve_runtime_provider(requested=pid) + has_creds = bool(runtime.get("api_key")) + except Exception: + pass + result.append({ + "id": pid, + "label": label, + "aliases": alias_list, + "authenticated": has_creds, + }) + return result + + def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]: """Parse ``/model`` input into ``(provider, model)``. @@ -101,6 +146,10 @@ def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]: anthropic/claude-sonnet-4.5 → (current_provider, "anthropic/claude-sonnet-4.5") gpt-5.4 → (current_provider, "gpt-5.4") + The colon is only treated as a provider delimiter if the left side is a + recognized provider name or alias. This avoids misinterpreting model names + that happen to contain colons (e.g. ``anthropic/claude-3.5-sonnet:beta``). + Returns ``(provider, model)`` where *provider* is either the explicit provider from the input or *current_provider* if none was specified. """ @@ -109,7 +158,7 @@ def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]: if colon > 0: provider_part = stripped[:colon].strip().lower() model_part = stripped[colon + 1:].strip() - if provider_part and model_part: + if provider_part and model_part and provider_part in _KNOWN_PROVIDER_NAMES: return (normalize_provider(provider_part), model_part) return (current_provider, stripped) diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index b73cc737e5..adbf677b64 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -8,10 +8,11 @@ from hermes_cli.commands import COMMANDS, SlashCommandCompleter # All commands that must be present in the shared COMMANDS dict. EXPECTED_COMMANDS = { - "/help", "/tools", "/toolsets", "/model", "/prompt", "/personality", - "/clear", "/history", "/new", "/reset", "/retry", "/undo", "/save", - "/config", "/cron", "/skills", "/platforms", "/verbose", "/compress", - "/usage", "/insights", "/paste", "/reload-mcp", "/quit", + "/help", "/tools", "/toolsets", "/model", "/provider", "/prompt", + "/personality", "/clear", "/history", "/new", "/reset", "/retry", + "/undo", "/save", "/config", "/cron", "/skills", "/platforms", + "/verbose", "/compress", "/usage", "/insights", "/paste", + "/reload-mcp", "/quit", } diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 36ef37d184..71d47136cf 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -67,6 +67,17 @@ class TestParseModelInput: assert provider == "openrouter" assert model == ":something" + def test_unknown_prefix_colon_not_treated_as_provider(self): + """Colons are only provider delimiters if the left side is a known provider.""" + provider, model = parse_model_input("anthropic/claude-3.5-sonnet:beta", "openrouter") + assert provider == "openrouter" + assert model == "anthropic/claude-3.5-sonnet:beta" + + def test_http_url_not_treated_as_provider(self): + provider, model = parse_model_input("http://localhost:8080/model", "openrouter") + assert provider == "openrouter" + assert model == "http://localhost:8080/model" + # -- curated_models_for_provider ---------------------------------------------