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(" /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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ---------------------------------------------
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue