Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2854,6 +2854,7 @@ async def _handle_provider_command(self, event: MessageEvent) -> str:

# Resolve current provider from config
current_provider = "openrouter"
config_base_url = None
config_path = _hermes_home / 'config.yaml'
try:
if config_path.exists():
Expand All @@ -2862,20 +2863,44 @@ async def _handle_provider_command(self, event: MessageEvent) -> str:
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
current_provider = model_cfg.get("provider", current_provider)
config_base_url = model_cfg.get("base_url") or None
except Exception:
pass

current_provider = normalize_provider(current_provider)
if current_provider == "auto":
# Check custom providers by name in config.yaml
try:
from hermes_cli.auth import resolve_provider as _resolve_provider
current_provider = _resolve_provider(current_provider)
from hermes_cli.config import load_config as _load_config
full_cfg = _load_config()
custom_providers = full_cfg.get("custom_providers") or []
current_model = ""
try:
current_model = (full_cfg.get("model") or {}).get("default", "") or ""
except Exception:
pass
for cp in custom_providers:
if isinstance(cp, dict) and cp.get("name") and current_model:
cp_name = cp["name"]
if current_model.startswith(f"{cp_name}/") or current_model.startswith(f"{cp_name}:"):
current_provider = cp_name
break
except Exception:
current_provider = "openrouter"
pass

# Detect custom endpoint
if current_provider == "openrouter" and os.getenv("OPENAI_BASE_URL", "").strip():
current_provider = "custom"
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"

# Detect custom endpoint: config.yaml base_url or OPENAI_BASE_URL env var
# take precedence over auto-resolved openrouter when the user explicitly
# configured a custom endpoint
if current_provider == "openrouter":
if config_base_url or os.getenv("OPENAI_BASE_URL", "").strip():
current_provider = "custom"

current_label = _PROVIDER_LABELS.get(current_provider, current_provider)

Expand Down
37 changes: 33 additions & 4 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1116,25 +1116,54 @@ def _model_flow_custom(config):
from hermes_cli.auth import _save_model_choice, deactivate_provider
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config

current_url = get_env_value("OPENAI_BASE_URL") or ""
# Read saved values from config.yaml first, then fall back to env vars.
# This ensures a previously configured custom endpoint is offered as the
# default rather than requiring the user to re-type it every time.
_cfg = load_config()
_model_cfg = _cfg.get("model") or {}
current_url = (
_model_cfg.get("base_url", "").strip()
or get_env_value("OPENAI_BASE_URL")
or ""
)
current_key = get_env_value("OPENAI_API_KEY") or ""
current_model = (
_model_cfg.get("default", "").strip()
or get_env_value("HERMES_MODEL")
or ""
)

print("Custom OpenAI-compatible endpoint configuration:")
if current_url:
print(f" Current URL: {current_url}")
if current_key:
print(f" Current key: {current_key[:8]}...")
if current_model:
print(f" Current model: {current_model}")
print()
print(" Press Enter to keep the current value shown in [brackets].")
print()

try:
base_url = input(f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: ").strip()
api_key = input(f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: ").strip()
model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip()
_url_hint = current_url or "e.g. https://api.example.com/v1"
base_url = input(f"API base URL [{_url_hint}]: ").strip()
_key_hint = (current_key[:8] + "...") if current_key else "optional, press Enter to skip"
api_key = input(f"API key [{_key_hint}]: ").strip()
_model_hint = current_model or "e.g. gpt-4, llama-3-70b"
model_name = input(f"Model name [{_model_hint}]: ").strip()
context_length_str = input("Context length in tokens [leave blank for auto-detect]: ").strip()
except (KeyboardInterrupt, EOFError):
print("\nCancelled.")
return

# Apply defaults: empty input → keep current value
if not base_url and current_url:
base_url = current_url
if not api_key and current_key:
api_key = current_key
if not model_name and current_model:
model_name = current_model

context_length = None
if context_length_str:
try:
Expand Down
28 changes: 27 additions & 1 deletion hermes_cli/model_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,33 @@ def switch_model(
api_key=api_key,
base_url=base_url,
)
except Exception:
except Exception as _val_exc:
_exc_str = str(_val_exc)
# For custom/local endpoints, surface connection errors explicitly so
# the user knows their endpoint is unreachable (fixes silent failures
# where "Connection refused" was swallowed and reported as success).
_is_custom_endpoint = target_provider == "custom" or (
base_url
and "openrouter.ai" not in base_url
and ("localhost" in base_url or "127.0.0.1" in base_url or "0.0.0.0" in base_url)
)
_is_connection_error = any(
kw in _exc_str.lower()
for kw in ("connection refused", "connectionrefused", "connect error",
"connecterror", "connection error", "econnrefused",
"timed out", "timeout", "name or service not known",
"no route to host", "network is unreachable", "401", "403", "404")
)
if _is_custom_endpoint and _is_connection_error:
return ModelSwitchResult(
success=False,
new_model=new_model,
target_provider=target_provider,
base_url=base_url,
error_message=f"Could not reach custom endpoint: {_exc_str}",
)
# For cloud providers or unrecognised errors, fall back to accepting
# the model so a temporary API outage doesn't block the switch.
validation = {
"accepted": True,
"persist": True,
Expand Down
6 changes: 6 additions & 0 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,12 @@ def _resolve_openrouter_runtime(
if requested_norm == "auto":
if (not cfg_provider or cfg_provider == "auto") and not env_openai_base_url:
use_config_base_url = True
elif cfg_provider == "custom":
# Config has provider:custom with a base_url — honour it even
# when requested_norm is "auto" so that OPENROUTER_API_KEY in
# .env doesn't silently override an explicitly saved custom
# endpoint on CLI restart (fixes #3263 config persistence loss).
use_config_base_url = True
elif requested_norm == "custom" and cfg_provider == "custom":
# provider: custom — use base_url from config (Fixes #1760).
use_config_base_url = True
Expand Down
14 changes: 12 additions & 2 deletions hermes_cli/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,18 @@ def _effective_provider_label() -> str:
except AuthError:
effective = requested or "auto"

if effective == "openrouter" and get_env_value("OPENAI_BASE_URL"):
effective = "custom"
if effective == "openrouter":
# Check config.yaml base_url in addition to OPENAI_BASE_URL env var
config_base_url = None
try:
cfg = load_config()
model_cfg = cfg.get("model") or {}
if isinstance(model_cfg, dict):
config_base_url = model_cfg.get("base_url") or None
except Exception:
pass
if config_base_url or get_env_value("OPENAI_BASE_URL"):
effective = "custom"

return provider_label(effective)

Expand Down
200 changes: 200 additions & 0 deletions tests/test_model_switcher_custom_3263.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
"""Tests for model switcher custom endpoint fixes (#3263).

Covers:
1. Silent validation failure surfaces connection errors for custom endpoints
2. _model_flow_custom pre-fills base_url / model from config.yaml
3. runtime_provider honours config.yaml custom endpoint over OPENROUTER_API_KEY
"""
from unittest.mock import MagicMock, patch


# ---------------------------------------------------------------------------
# 1. Silent validation failure — model_switch.py
# ---------------------------------------------------------------------------

def _make_switch_result(raw_input, base_url, exc):
"""Run switch_model with a validate_requested_model that raises exc."""
from hermes_cli.model_switch import switch_model

def _fake_resolve(requested=None, **kw):
return {"provider": "custom", "api_key": "fake", "base_url": base_url,
"api_mode": "chat_completions", "source": "config"}

with patch("hermes_cli.runtime_provider.resolve_runtime_provider", side_effect=_fake_resolve), \
patch("hermes_cli.models.validate_requested_model", side_effect=exc), \
patch("hermes_cli.models.detect_provider_for_model", return_value=None), \
patch("hermes_cli.models.parse_model_input", return_value=("custom", "llama3")):
return switch_model(raw_input, current_provider="custom", current_base_url=base_url)


def test_connection_refused_surfaced_for_localhost():
result = _make_switch_result(
"llama3",
"http://localhost:11434/v1",
ConnectionError("Connection refused"),
)
assert result.success is False
assert "connection" in result.error_message.lower() or "refused" in result.error_message.lower()


def test_timeout_surfaced_for_custom_endpoint():
result = _make_switch_result(
"llama3",
"http://127.0.0.1:8080/v1",
TimeoutError("timed out"),
)
assert result.success is False
assert result.error_message != ""


def test_404_surfaced_for_custom_endpoint():
result = _make_switch_result(
"llama3",
"http://localhost:11434/v1",
Exception("404 Not Found"),
)
assert result.success is False


def test_cloud_provider_validation_error_still_accepts():
"""Cloud provider validation errors should NOT block the switch (temporary outages)."""
from hermes_cli.model_switch import switch_model

def _fake_resolve(requested=None, **kw):
return {"provider": "openrouter", "api_key": "sk-test", "base_url": "https://openrouter.ai/api/v1",
"api_mode": "chat_completions", "source": "env"}

with patch("hermes_cli.runtime_provider.resolve_runtime_provider", side_effect=_fake_resolve), \
patch("hermes_cli.models.validate_requested_model", side_effect=Exception("temporary error")), \
patch("hermes_cli.models.detect_provider_for_model", return_value=None), \
patch("hermes_cli.models.parse_model_input", return_value=("openrouter", "gpt-4o")):
result = switch_model("gpt-4o", current_provider="openrouter",
current_base_url="https://openrouter.ai/api/v1")

# Cloud provider: accept despite error (temporary outage tolerance)
assert result.success is True


# ---------------------------------------------------------------------------
# 2. Pre-fill from config.yaml — _model_flow_custom
# ---------------------------------------------------------------------------

def test_model_flow_custom_prefills_from_config(monkeypatch):
"""When config.yaml has base_url and model.default, they appear as defaults."""
import hermes_cli.main as _main

saved_inputs = []

def _fake_input(prompt=""):
saved_inputs.append(prompt)
return "" # empty — should fall back to config default

fake_cfg = {
"model": {
"base_url": "http://localhost:11434/v1",
"default": "llama3",
"provider": "custom",
}
}
applied = {}

monkeypatch.setattr("builtins.input", _fake_input)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: fake_cfg)
monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key: "")

with patch("hermes_cli.models.probe_api_models",
return_value={"models": ["llama3"], "probed_url": "http://localhost:11434/v1/models"}), \
patch("hermes_cli.auth._save_model_choice", side_effect=lambda n: applied.update(model=n)), \
patch("hermes_cli.config.save_env_value"), \
patch("hermes_cli.config.save_config"), \
patch("hermes_cli.auth.deactivate_provider"), \
patch("hermes_cli.main._save_custom_provider"):
_main._model_flow_custom(fake_cfg)

url_prompt = next((p for p in saved_inputs if "base URL" in p), "")
assert "localhost:11434" in url_prompt, f"URL not pre-filled in prompt: {url_prompt!r}"

model_prompt = next((p for p in saved_inputs if "Model name" in p), "")
assert "llama3" in model_prompt, f"Model not pre-filled in prompt: {model_prompt!r}"

assert applied.get("model") == "llama3", "Empty input should default to config model"


def test_model_flow_custom_uses_config_base_url_when_empty_input(monkeypatch):
"""Empty URL input with existing config should keep the existing URL for probing."""
import hermes_cli.main as _main

fake_cfg = {
"model": {
"base_url": "http://localhost:11434/v1",
"default": "llama3",
"provider": "custom",
}
}
probed = {}

monkeypatch.setattr("builtins.input", lambda p="": "")
monkeypatch.setattr("hermes_cli.config.load_config", lambda: fake_cfg)
monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key: "")

def _capture_probe(key, url):
probed["url"] = url
return {"models": ["llama3"], "probed_url": url}

with patch("hermes_cli.models.probe_api_models", side_effect=_capture_probe), \
patch("hermes_cli.auth._save_model_choice"), \
patch("hermes_cli.config.save_env_value"), \
patch("hermes_cli.config.save_config"), \
patch("hermes_cli.auth.deactivate_provider"), \
patch("hermes_cli.main._save_custom_provider"):
_main._model_flow_custom(fake_cfg)

assert probed.get("url") == "http://localhost:11434/v1", (
f"Expected config URL to be used for probing, got: {probed.get('url')!r}"
)


# ---------------------------------------------------------------------------
# 3. runtime_provider — config.yaml custom takes precedence over OPENROUTER_API_KEY
# ---------------------------------------------------------------------------

def test_custom_config_wins_over_openrouter_key(monkeypatch):
"""When config has provider:custom + base_url, OPENROUTER_API_KEY must not flip base_url."""
import os
import hermes_cli.runtime_provider as rp

monkeypatch.setenv("OPENROUTER_API_KEY", "sk-openrouter-test")
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)

cfg = {
"model": {
"provider": "custom",
"base_url": "http://localhost:11434/v1",
"default": "llama3",
}
}

with patch("hermes_cli.runtime_provider.load_config", return_value=cfg):
result = rp.resolve_runtime_provider(requested="auto")

assert "openrouter.ai" not in result["base_url"], (
f"Expected custom base_url to win, got: {result['base_url']!r}"
)
assert "localhost" in result["base_url"]


def test_openrouter_key_still_works_without_custom_config(monkeypatch):
"""Without a custom config, OPENROUTER_API_KEY should still route to OpenRouter."""
import hermes_cli.runtime_provider as rp

monkeypatch.setenv("OPENROUTER_API_KEY", "sk-openrouter-test")
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)

cfg = {"model": {"provider": "openrouter", "default": "gpt-4o"}}

with patch("hermes_cli.runtime_provider.load_config", return_value=cfg):
result = rp.resolve_runtime_provider(requested="auto")

assert "openrouter.ai" in result["base_url"]
Loading