diff --git a/cli.py b/cli.py index 7783e736ac..efdaeee5b2 100644 --- a/cli.py +++ b/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:") + 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": diff --git a/gateway/run.py b/gateway/run.py index 411027a69e..91276c2a39 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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:`" + ) + 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)_" diff --git a/hermes_cli/models.py b/hermes_cli/models.py index aaa86ca972..50778e2a91 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -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) diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 9d4b670b00..2e05ce7eec 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -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 --------------------------------------------- diff --git a/tests/test_cli_model_command.py b/tests/test_cli_model_command.py index 2a6042a700..995b9ad949 100644 --- a/tests/test_cli_model_command.py +++ b/tests/test_cli_model_command.py @@ -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()