mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(model): /model command overhaul — Phases 2, 3, 5
* feat(model): persist base_url on /model switch, auto-detect for bare /model custom Phase 2+3 of the /model command overhaul: Phase 2 — Persist base_url on model switch: - CLI: save model.base_url when switching to a non-OpenRouter endpoint; clear it when switching away from custom to prevent stale URLs leaking into the new provider's resolution - Gateway: same logic using direct YAML write Phase 3 — Better feedback and edge cases: - Bare '/model custom' now auto-detects the model from the endpoint using _auto_detect_local_model() and saves all three config values (model, provider, base_url) atomically - Shows endpoint URL in success messages when switching to/from custom providers (both CLI and gateway) - Clear error messages when no custom endpoint is configured - Updated test assertions for the additional save_config_value call Fixes #2562 (Phase 2+3) * feat(model): support custom:name:model triple syntax for named custom providers Phase 5 of the /model command overhaul. Extends parse_model_input() to handle the triple syntax: /model custom:local-server:qwen → provider='custom:local-server', model='qwen' /model custom:my-model → provider='custom', model='my-model' (unchanged) The 'custom:local-server' provider string is already supported by _get_named_custom_provider() in runtime_provider.py, which matches it against the custom_providers list in config.yaml. This just wires the parsing so users can do it from the /model slash command. Added 4 tests covering single, triple, whitespace, and empty model cases.
This commit is contained in:
parent
2f1c4fb01f
commit
b641ee88f4
5 changed files with 166 additions and 15 deletions
60
cli.py
60
cli.py
|
|
@ -3571,6 +3571,43 @@ class HermesCLI:
|
|||
|
||||
raw_input = parts[1].strip()
|
||||
|
||||
# Handle bare "/model custom" — switch to custom provider
|
||||
# and auto-detect the model from the endpoint.
|
||||
if raw_input.strip().lower() == "custom":
|
||||
from hermes_cli.runtime_provider import (
|
||||
resolve_runtime_provider,
|
||||
_auto_detect_local_model,
|
||||
)
|
||||
try:
|
||||
runtime = resolve_runtime_provider(requested="custom")
|
||||
cust_base = runtime.get("base_url", "")
|
||||
cust_key = runtime.get("api_key", "")
|
||||
if not cust_base or "openrouter.ai" in cust_base:
|
||||
print("(>_<) No custom endpoint configured.")
|
||||
print(" Set model.base_url in config.yaml, or set OPENAI_BASE_URL in .env,")
|
||||
print(" or run: hermes setup → Custom OpenAI-compatible endpoint")
|
||||
return True
|
||||
detected_model = _auto_detect_local_model(cust_base)
|
||||
if detected_model:
|
||||
self.model = detected_model
|
||||
self.requested_provider = "custom"
|
||||
self.provider = "custom"
|
||||
self.api_key = cust_key
|
||||
self.base_url = cust_base
|
||||
self.agent = None
|
||||
save_config_value("model.default", detected_model)
|
||||
save_config_value("model.provider", "custom")
|
||||
save_config_value("model.base_url", cust_base)
|
||||
print(f"(^_^)b Model changed to: {detected_model} [provider: Custom]")
|
||||
print(f" Endpoint: {cust_base}")
|
||||
print(f" Status: connected (model auto-detected)")
|
||||
else:
|
||||
print(f"(>_<) Custom endpoint at {cust_base} is reachable but no single model was auto-detected.")
|
||||
print(f" Specify the model explicitly: /model custom:<model-name>")
|
||||
except Exception as e:
|
||||
print(f"(>_<) Could not resolve custom endpoint: {e}")
|
||||
return True
|
||||
|
||||
# Parse provider:model syntax (e.g. "openrouter:anthropic/claude-sonnet-4.5")
|
||||
current_provider = self.provider or self.requested_provider or "openrouter"
|
||||
target_provider, new_model = parse_model_input(raw_input, current_provider)
|
||||
|
|
@ -3642,6 +3679,14 @@ class HermesCLI:
|
|||
saved_model = save_config_value("model.default", new_model)
|
||||
if provider_changed:
|
||||
save_config_value("model.provider", target_provider)
|
||||
# Persist base_url for custom endpoints so it
|
||||
# survives restart; clear it when switching away
|
||||
# from custom to prevent stale URLs leaking into
|
||||
# the new provider's resolution (#2562 Phase 2).
|
||||
if base_url_for_probe and "openrouter.ai" not in (base_url_for_probe or ""):
|
||||
save_config_value("model.base_url", base_url_for_probe)
|
||||
else:
|
||||
save_config_value("model.base_url", None)
|
||||
if saved_model:
|
||||
print(f"(^_^)b Model changed to: {new_model}{provider_note} (saved to config)")
|
||||
else:
|
||||
|
|
@ -3653,12 +3698,17 @@ class HermesCLI:
|
|||
print(f" Reason: {message}")
|
||||
print(" Note: Model will revert on restart. Use a verified model to save to config.")
|
||||
|
||||
# Helpful hint when staying on a custom endpoint
|
||||
if is_custom and not provider_changed:
|
||||
endpoint = self.base_url or "custom endpoint"
|
||||
# Show endpoint info for custom providers
|
||||
_target_is_custom = target_provider == "custom" or (
|
||||
base_url_for_probe and "openrouter.ai" not in (base_url_for_probe or "")
|
||||
and ("localhost" in (base_url_for_probe or "") or "127.0.0.1" in (base_url_for_probe or ""))
|
||||
)
|
||||
if _target_is_custom or (is_custom and not provider_changed):
|
||||
endpoint = base_url_for_probe or self.base_url or "custom endpoint"
|
||||
print(f" Endpoint: {endpoint}")
|
||||
print(f" Tip: To switch providers, use /model provider:model")
|
||||
print(f" e.g. /model openai-codex:gpt-5.2-codex")
|
||||
if not provider_changed:
|
||||
print(f" Tip: To switch providers, use /model provider:model")
|
||||
print(f" e.g. /model openai-codex:gpt-5.2-codex")
|
||||
else:
|
||||
self._show_model_and_providers()
|
||||
elif canonical == "provider":
|
||||
|
|
|
|||
|
|
@ -2851,6 +2851,56 @@ class GatewayRunner:
|
|||
lines.append("Switch provider: `/model provider-name` or `/model provider:model-name`")
|
||||
return "\n".join(lines)
|
||||
|
||||
# Handle bare "/model custom" — switch to custom provider
|
||||
# and auto-detect the model from the endpoint.
|
||||
if args.strip().lower() == "custom":
|
||||
from hermes_cli.runtime_provider import (
|
||||
resolve_runtime_provider as _rtp_custom,
|
||||
_auto_detect_local_model,
|
||||
)
|
||||
try:
|
||||
runtime = _rtp_custom(requested="custom")
|
||||
cust_base = runtime.get("base_url", "")
|
||||
if not cust_base or "openrouter.ai" in cust_base:
|
||||
return (
|
||||
"⚠️ No custom endpoint configured.\n"
|
||||
"Set `model.base_url` in config.yaml, or `OPENAI_BASE_URL` in .env,\n"
|
||||
"or run: `hermes setup` → Custom OpenAI-compatible endpoint"
|
||||
)
|
||||
detected_model = _auto_detect_local_model(cust_base)
|
||||
if detected_model:
|
||||
try:
|
||||
user_config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") 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"] = detected_model
|
||||
user_config["model"]["provider"] = "custom"
|
||||
user_config["model"]["base_url"] = cust_base
|
||||
with open(config_path, 'w', encoding="utf-8") 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}"
|
||||
os.environ["HERMES_MODEL"] = detected_model
|
||||
os.environ["HERMES_INFERENCE_PROVIDER"] = "custom"
|
||||
self._effective_model = None
|
||||
self._effective_provider = None
|
||||
return (
|
||||
f"🤖 Model changed to `{detected_model}` (saved to config)\n"
|
||||
f"**Provider:** Custom\n"
|
||||
f"**Endpoint:** `{cust_base}`\n"
|
||||
f"_Model auto-detected from endpoint. Takes effect on next message._"
|
||||
)
|
||||
else:
|
||||
return (
|
||||
f"⚠️ Custom endpoint at `{cust_base}` is reachable but no single model was auto-detected.\n"
|
||||
f"Specify the model explicitly: `/model custom:<model-name>`"
|
||||
)
|
||||
except Exception as e:
|
||||
return f"⚠️ Could not resolve custom endpoint: {e}"
|
||||
|
||||
# Parse provider:model syntax
|
||||
target_provider, new_model = parse_model_input(args, current_provider)
|
||||
|
||||
|
|
@ -2925,6 +2975,13 @@ class GatewayRunner:
|
|||
user_config["model"]["default"] = new_model
|
||||
if provider_changed:
|
||||
user_config["model"]["provider"] = target_provider
|
||||
# Persist base_url for custom endpoints so it survives
|
||||
# restart; clear it when switching away from custom to
|
||||
# prevent stale URLs leaking (#2562 Phase 2).
|
||||
if base_url and "openrouter.ai" not in (base_url or ""):
|
||||
user_config["model"]["base_url"] = base_url
|
||||
else:
|
||||
user_config["model"].pop("base_url", None)
|
||||
with open(config_path, 'w', encoding="utf-8") as f:
|
||||
yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
|
||||
except Exception as e:
|
||||
|
|
@ -2950,15 +3007,20 @@ class GatewayRunner:
|
|||
self._effective_model = None
|
||||
self._effective_provider = None
|
||||
|
||||
# Helpful hint when staying on a custom/local endpoint
|
||||
# Show endpoint info for custom providers
|
||||
_target_is_custom = target_provider == "custom" or (
|
||||
base_url and "openrouter.ai" not in (base_url or "")
|
||||
and ("localhost" in (base_url or "") or "127.0.0.1" in (base_url or ""))
|
||||
)
|
||||
custom_hint = ""
|
||||
if is_custom and not provider_changed:
|
||||
endpoint = _resolved_base or "custom endpoint"
|
||||
custom_hint = (
|
||||
f"\n**Endpoint:** `{endpoint}`"
|
||||
"\n_To switch providers, use_ `/model provider:model`"
|
||||
"\n_e.g._ `/model openrouter:anthropic/claude-sonnet-4`"
|
||||
)
|
||||
if _target_is_custom or (is_custom and not provider_changed):
|
||||
endpoint = base_url or _resolved_base or "custom endpoint"
|
||||
custom_hint = f"\n**Endpoint:** `{endpoint}`"
|
||||
if not provider_changed:
|
||||
custom_hint += (
|
||||
"\n_To switch providers, use_ `/model provider:model`"
|
||||
"\n_e.g._ `/model openrouter:anthropic/claude-sonnet-4`"
|
||||
)
|
||||
|
||||
return f"🤖 Model changed to `{new_model}` ({persist_note}){provider_note}{warning}{custom_hint}\n_(takes effect on next message)_"
|
||||
|
||||
|
|
|
|||
|
|
@ -345,6 +345,15 @@ def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]:
|
|||
provider_part = stripped[:colon].strip().lower()
|
||||
model_part = stripped[colon + 1:].strip()
|
||||
if provider_part and model_part and provider_part in _KNOWN_PROVIDER_NAMES:
|
||||
# Support custom:name:model triple syntax for named custom
|
||||
# providers. ``custom:local:qwen`` → ("custom:local", "qwen").
|
||||
# Single colon ``custom:qwen`` → ("custom", "qwen") as before.
|
||||
if provider_part == "custom" and ":" in model_part:
|
||||
second_colon = model_part.find(":")
|
||||
custom_name = model_part[:second_colon].strip()
|
||||
actual_model = model_part[second_colon + 1:].strip()
|
||||
if custom_name and actual_model:
|
||||
return (f"custom:{custom_name}", actual_model)
|
||||
return (normalize_provider(provider_part), model_part)
|
||||
return (current_provider, stripped)
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,31 @@ class TestParseModelInput:
|
|||
assert provider == "openrouter"
|
||||
assert model == "http://localhost:8080/model"
|
||||
|
||||
def test_custom_colon_model_single(self):
|
||||
"""custom:model-name → anonymous custom provider."""
|
||||
provider, model = parse_model_input("custom:qwen-2.5", "openrouter")
|
||||
assert provider == "custom"
|
||||
assert model == "qwen-2.5"
|
||||
|
||||
def test_custom_triple_syntax(self):
|
||||
"""custom:name:model → named custom provider."""
|
||||
provider, model = parse_model_input("custom:local-server:qwen-2.5", "openrouter")
|
||||
assert provider == "custom:local-server"
|
||||
assert model == "qwen-2.5"
|
||||
|
||||
def test_custom_triple_spaces(self):
|
||||
"""Triple syntax should handle whitespace."""
|
||||
provider, model = parse_model_input("custom: my-server : my-model ", "openrouter")
|
||||
assert provider == "custom:my-server"
|
||||
assert model == "my-model"
|
||||
|
||||
def test_custom_triple_empty_model_falls_back(self):
|
||||
"""custom:name: with no model → treated as custom:name (bare)."""
|
||||
provider, model = parse_model_input("custom:name:", "openrouter")
|
||||
# Empty model after second colon → no triple match, falls through
|
||||
assert provider == "custom"
|
||||
assert model == "name:"
|
||||
|
||||
|
||||
# -- curated_models_for_provider ---------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -111,8 +111,13 @@ class TestModelCommand:
|
|||
assert cli_obj.model == "glm-5"
|
||||
assert cli_obj.provider == "zai"
|
||||
assert cli_obj.base_url == "https://api.z.ai/api/paas/v4"
|
||||
# Both model and provider should be saved
|
||||
assert save_mock.call_count == 2
|
||||
# Model, provider, and base_url should be saved
|
||||
assert save_mock.call_count == 3
|
||||
save_calls = [c.args for c in save_mock.call_args_list]
|
||||
assert ("model.default", "glm-5") in save_calls
|
||||
assert ("model.provider", "zai") in save_calls
|
||||
# base_url is also persisted on provider change (Phase 2 fix)
|
||||
assert any(c[0] == "model.base_url" for c in save_calls)
|
||||
|
||||
def test_provider_switch_fails_on_bad_credentials(self, capsys):
|
||||
cli_obj = self._make_cli()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue