feat: /provider command + fix gateway bugs + harden parse_model_input
/provider command (CLI + gateway):
Shows all providers with auth status (✓/✗), aliases, and active marker.
Users can now discover what provider names work with provider:model syntax.
Gateway bugs fixed:
- Config was saved even when validation.persist=False (told user 'session
only' but actually persisted the unvalidated model)
- HERMES_INFERENCE_PROVIDER env var not set on provider switch, causing
the switch to be silently overridden if that env var was already set
parse_model_input hardened:
- Colon only treated as provider delimiter if left side is a recognized
provider name or alias. 'anthropic/claude-3.5-sonnet:beta' now passes
through as a model name instead of trying provider='anthropic/claude-3.5-sonnet'.
- HTTP URLs, random colons no longer misinterpreted.
56 tests passing across model validation, CLI commands, and integration.
This commit is contained in:
parent
34792dd907
commit
666f2dd486
6 changed files with 169 additions and 20 deletions
29
cli.py
29
cli.py
|
|
@ -2161,6 +2161,35 @@ class HermesCLI:
|
||||||
print(" Usage: /model <model-name>")
|
print(" Usage: /model <model-name>")
|
||||||
print(" /model provider:model-name (to switch provider)")
|
print(" /model provider:model-name (to switch provider)")
|
||||||
print(" Example: /model openrouter:anthropic/claude-sonnet-4.5")
|
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"):
|
elif cmd_lower.startswith("/prompt"):
|
||||||
# Use original case so prompt text isn't lowercased
|
# Use original case so prompt text isn't lowercased
|
||||||
self._handle_prompt_command(cmd_original)
|
self._handle_prompt_command(cmd_original)
|
||||||
|
|
|
||||||
|
|
@ -734,6 +734,9 @@ class GatewayRunner:
|
||||||
if command == "model":
|
if command == "model":
|
||||||
return await self._handle_model_command(event)
|
return await self._handle_model_command(event)
|
||||||
|
|
||||||
|
if command == "provider":
|
||||||
|
return await self._handle_provider_command(event)
|
||||||
|
|
||||||
if command == "personality":
|
if command == "personality":
|
||||||
return await self._handle_personality_command(event)
|
return await self._handle_personality_command(event)
|
||||||
|
|
||||||
|
|
@ -1292,6 +1295,7 @@ class GatewayRunner:
|
||||||
"`/status` — Show session info",
|
"`/status` — Show session info",
|
||||||
"`/stop` — Interrupt the running agent",
|
"`/stop` — Interrupt the running agent",
|
||||||
"`/model [provider:model]` — Show/change model (or switch provider)",
|
"`/model [provider:model]` — Show/change model (or switch provider)",
|
||||||
|
"`/provider` — Show available providers and auth status",
|
||||||
"`/personality [name]` — Set a personality",
|
"`/personality [name]` — Set a personality",
|
||||||
"`/retry` — Retry your last message",
|
"`/retry` — Retry your last message",
|
||||||
"`/undo` — Remove the last exchange",
|
"`/undo` — Remove the last exchange",
|
||||||
|
|
@ -1412,23 +1416,27 @@ class GatewayRunner:
|
||||||
if not validation.get("accepted"):
|
if not validation.get("accepted"):
|
||||||
return f"⚠️ {validation.get('message')}"
|
return f"⚠️ {validation.get('message')}"
|
||||||
|
|
||||||
# Write to config.yaml
|
# Persist to config only if validation approves
|
||||||
try:
|
if validation.get("persist"):
|
||||||
user_config = {}
|
try:
|
||||||
if config_path.exists():
|
user_config = {}
|
||||||
with open(config_path) as f:
|
if config_path.exists():
|
||||||
user_config = yaml.safe_load(f) or {}
|
with open(config_path) as f:
|
||||||
if "model" not in user_config or not isinstance(user_config["model"], dict):
|
user_config = yaml.safe_load(f) or {}
|
||||||
user_config["model"] = {}
|
if "model" not in user_config or not isinstance(user_config["model"], dict):
|
||||||
user_config["model"]["default"] = new_model
|
user_config["model"] = {}
|
||||||
if provider_changed:
|
user_config["model"]["default"] = new_model
|
||||||
user_config["model"]["provider"] = target_provider
|
if provider_changed:
|
||||||
with open(config_path, 'w') as f:
|
user_config["model"]["provider"] = target_provider
|
||||||
yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
|
with open(config_path, 'w') as f:
|
||||||
except Exception as e:
|
yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
|
||||||
return f"⚠️ Failed to save model change: {e}"
|
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
|
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_label = _PROVIDER_LABELS.get(target_provider, target_provider)
|
||||||
provider_note = f"\n**Provider:** {provider_label}" if provider_changed else ""
|
provider_note = f"\n**Provider:** {provider_label}" if provider_changed else ""
|
||||||
|
|
@ -1440,6 +1448,56 @@ class GatewayRunner:
|
||||||
persist_note = "saved to config" if validation.get("persist") else "session only"
|
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)_"
|
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:
|
async def _handle_personality_command(self, event: MessageEvent) -> str:
|
||||||
"""Handle /personality command - list or set a personality."""
|
"""Handle /personality command - list or set a personality."""
|
||||||
import yaml
|
import yaml
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ COMMANDS = {
|
||||||
"/tools": "List available tools",
|
"/tools": "List available tools",
|
||||||
"/toolsets": "List available toolsets",
|
"/toolsets": "List available toolsets",
|
||||||
"/model": "Show or change the current model",
|
"/model": "Show or change the current model",
|
||||||
|
"/provider": "Show available providers and current provider",
|
||||||
"/prompt": "View/set custom system prompt",
|
"/prompt": "View/set custom system prompt",
|
||||||
"/personality": "Set a predefined personality",
|
"/personality": "Set a predefined personality",
|
||||||
"/clear": "Clear screen and reset conversation (fresh start)",
|
"/clear": "Clear screen and reset conversation (fresh start)",
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,51 @@ def menu_labels() -> list[str]:
|
||||||
return labels
|
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]:
|
def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]:
|
||||||
"""Parse ``/model`` input into ``(provider, model)``.
|
"""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")
|
anthropic/claude-sonnet-4.5 → (current_provider, "anthropic/claude-sonnet-4.5")
|
||||||
gpt-5.4 → (current_provider, "gpt-5.4")
|
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
|
Returns ``(provider, model)`` where *provider* is either the explicit
|
||||||
provider from the input or *current_provider* if none was specified.
|
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:
|
if colon > 0:
|
||||||
provider_part = stripped[:colon].strip().lower()
|
provider_part = stripped[:colon].strip().lower()
|
||||||
model_part = stripped[colon + 1:].strip()
|
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 (normalize_provider(provider_part), model_part)
|
||||||
return (current_provider, stripped)
|
return (current_provider, stripped)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,11 @@ from hermes_cli.commands import COMMANDS, SlashCommandCompleter
|
||||||
|
|
||||||
# All commands that must be present in the shared COMMANDS dict.
|
# All commands that must be present in the shared COMMANDS dict.
|
||||||
EXPECTED_COMMANDS = {
|
EXPECTED_COMMANDS = {
|
||||||
"/help", "/tools", "/toolsets", "/model", "/prompt", "/personality",
|
"/help", "/tools", "/toolsets", "/model", "/provider", "/prompt",
|
||||||
"/clear", "/history", "/new", "/reset", "/retry", "/undo", "/save",
|
"/personality", "/clear", "/history", "/new", "/reset", "/retry",
|
||||||
"/config", "/cron", "/skills", "/platforms", "/verbose", "/compress",
|
"/undo", "/save", "/config", "/cron", "/skills", "/platforms",
|
||||||
"/usage", "/insights", "/paste", "/reload-mcp", "/quit",
|
"/verbose", "/compress", "/usage", "/insights", "/paste",
|
||||||
|
"/reload-mcp", "/quit",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,17 @@ class TestParseModelInput:
|
||||||
assert provider == "openrouter"
|
assert provider == "openrouter"
|
||||||
assert model == ":something"
|
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 ---------------------------------------------
|
# -- curated_models_for_provider ---------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue