fix(cli): stabilize custom provider identities

Use providers keys as the canonical custom-provider identity while accepting legacy bare keys, display-name slugs, bare custom fallback, and doubled custom prefixes across resolution, pickers, doctor, and runtime reverse lookup.

Co-authored-by: Bakhtier Sizhaev <bakhtiersizhaev@users.noreply.github.com>
This commit is contained in:
Gille 2026-07-29 16:26:56 -06:00 committed by kshitij
parent f5a18cde69
commit 2de1e86c16
10 changed files with 1053 additions and 67 deletions

View File

@ -980,11 +980,13 @@ def run_doctor(args):
try:
from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers
from hermes_cli.providers import (
custom_provider_aliases as _custom_provider_aliases,
normalize_provider as _normalize_catalog_provider,
resolve_provider_full as _resolve_provider_full,
)
except Exception:
_compatible_custom_providers = None
_custom_provider_aliases = None
_normalize_catalog_provider = None
_resolve_provider_full = None
@ -1007,8 +1009,11 @@ def run_doctor(args):
if not isinstance(entry, dict):
continue
name = str(entry.get("name") or "").strip()
if name:
known_providers.add("custom:" + name.lower().replace(" ", "-"))
provider_key = str(entry.get("provider_key") or "").strip()
if name and _custom_provider_aliases is not None:
known_providers.update(
_custom_provider_aliases(name, provider_key)
)
valid_provider_ids = set(known_providers)
provider_ids_to_accept = {provider} if provider else set()

View File

@ -3029,7 +3029,11 @@ def select_provider_and_model(args=None):
load_config,
get_env_value,
)
from hermes_cli.providers import resolve_provider_full
from hermes_cli.providers import (
custom_provider_aliases,
custom_provider_slug,
resolve_provider_full,
)
config = load_config()
current_model = config.get("model")
@ -3149,13 +3153,8 @@ def select_provider_and_model(args=None):
base_url = (entry.get("base_url") or "").strip()
if not name or not base_url:
continue
key = "custom:" + name.lower().replace(" ", "-")
provider_key = (entry.get("provider_key") or "").strip()
if provider_key:
try:
resolve_provider(provider_key)
except AuthError:
key = provider_key
key = custom_provider_slug(name, provider_key)
custom_provider_map[key] = {
"name": name,
"base_url": base_url,
@ -3183,6 +3182,16 @@ def select_provider_and_model(args=None):
config
) # key → {name, base_url, api_key}
def _canonical_named_custom_key(provider_id: str) -> str:
requested = str(provider_id or "").strip().lower()
for key, provider_info in _custom_provider_map.items():
if requested in custom_provider_aliases(
provider_info.get("name", ""),
provider_info.get("provider_key", ""),
):
return key
return provider_id
def _active_custom_key_from_base_url() -> str:
if effective_provider != "custom" or not isinstance(model_cfg, dict):
return ""
@ -3205,6 +3214,8 @@ def select_provider_and_model(args=None):
)
if active_def is not None:
active = active_def.id
if active_def.source == "user-config":
active = _canonical_named_custom_key(active)
else:
warning = (
f"Unknown provider '{effective_provider}'. Check 'hermes model' for "

View File

@ -26,6 +26,7 @@ import subprocess
import urllib.parse
from hermes_cli.config import clear_model_endpoint_credentials
from hermes_cli.providers import custom_provider_slug
# AWS cross-region inference profile prefixes. Any geo-prefixed profile only
@ -1636,7 +1637,7 @@ def _model_flow_named_custom(config, provider_info):
model = {"default": model} if model else {}
cfg["model"] = model
if provider_key:
model["provider"] = "custom:" + provider_key.strip().lower().replace(" ", "-")
model["provider"] = custom_provider_slug(name, provider_key)
model.pop("base_url", None)
model.pop("api_key", None)
else:

View File

@ -27,6 +27,7 @@ from typing import Any, List, NamedTuple, Optional
from hermes_cli.providers import (
ProviderDef,
custom_provider_aliases,
custom_provider_slug,
determine_api_mode,
get_label,
@ -1145,11 +1146,10 @@ def _resolve_named_custom_model_id(
for entry in custom_providers or []:
if not isinstance(entry, dict):
continue
entry_slugs = {
custom_provider_slug(str(entry.get(key) or "")).lower()
for key in ("name", "provider_key")
if str(entry.get(key) or "").strip()
}
entry_slugs = custom_provider_aliases(
str(entry.get("name") or ""),
str(entry.get("provider_key") or ""),
)
if provider not in entry_slugs or f"custom:{prefix}" not in entry_slugs:
continue
for model_id in _declared_model_ids(entry.get("models")):
@ -1684,9 +1684,12 @@ def switch_model(
continue
# Match by provider slug (custom:<name>) or by base_url
entry_name = entry.get("name", "")
entry_slug = f"custom:{entry_name}" if entry_name else ""
entry_aliases = custom_provider_aliases(
str(entry_name or ""),
str(entry.get("provider_key") or ""),
)
entry_url = entry.get("base_url", "")
if entry_slug == target_provider or entry_url == base_url:
if target_provider.lower() in entry_aliases or entry_url == base_url:
# Check if the requested model matches the entry's model
entry_model = entry.get("model", "")
entry_models = entry.get("models", {})
@ -2580,6 +2583,7 @@ def list_authenticated_providers(
"has_explicit_models": False,
"ep_cfg": ep_cfg, # used below for discover_models / api_key
"raw_names": [],
"aliases": set(),
}
# Aggregate models across all members of the group (preserve order).
for _m in entry_models:
@ -2593,6 +2597,9 @@ def list_authenticated_providers(
if entry_declared_models:
ep_groups[group_key]["has_explicit_models"] = True
ep_groups[group_key]["raw_names"].append(display_name)
ep_groups[group_key]["aliases"].update(
custom_provider_aliases(display_name, str(ep_name))
)
for grp in ep_groups.values():
ep_cfg = grp["ep_cfg"]
@ -2632,10 +2639,12 @@ def list_authenticated_providers(
has_explicit_models = bool(grp.get("has_explicit_models"))
_ep_url_norm = str(api_url).strip().rstrip("/").lower()
_ep_slug_norm = str(ep_name).strip().lower()
_ep_custom_slug_norm = custom_provider_slug(display_name).lower()
_ep_aliases = {
str(alias).lower() for alias in grp.get("aliases", set())
}
_ep_is_current = (
_ep_slug_norm == _current_provider_norm
or _ep_custom_slug_norm == _current_provider_norm
or _current_provider_norm in _ep_aliases
or (
_current_provider_norm == "custom"
and bool(_current_base_url_norm)
@ -2669,7 +2678,7 @@ def list_authenticated_providers(
"api_url": api_url,
})
seen_slugs.add(ep_name.lower())
seen_slugs.add(custom_provider_slug(display_name).lower())
seen_slugs.update(_ep_aliases)
# Record (display_name, api_url) for each raw entry that joined
# this group so section-4's _section3_emitted_pairs dedup can
# match per-model custom_providers rows ("Palantir Claude 4.7 Opus")
@ -2817,7 +2826,8 @@ def list_authenticated_providers(
# Reuse the prefix computed above as the row display name;
# fall back to the raw name if stripping left it empty.
display_name = _display_prefix or raw_name
slug = custom_provider_slug(display_name)
provider_key = str(entry.get("provider_key") or "").strip()
slug = custom_provider_slug(display_name, provider_key)
groups[group_key] = {
"slug": slug,
"name": display_name,
@ -2827,6 +2837,7 @@ def list_authenticated_providers(
"has_explicit_models": False,
"discover_models": discover,
"extra_headers": entry_extra_headers,
"aliases": set(),
}
else:
if api_key and not groups[group_key].get("api_key"):
@ -2837,6 +2848,12 @@ def list_authenticated_providers(
# honour that for the whole grouped row.
if not discover:
groups[group_key]["discover_models"] = False
groups[group_key]["aliases"].update(
custom_provider_aliases(
raw_name,
str(entry.get("provider_key") or ""),
)
)
# The singular ``model:`` field only holds the currently
# active model. Hermes's own writer (main.py::_save_custom_provider)
@ -2928,7 +2945,13 @@ def list_authenticated_providers(
# api_key is present. This supports endpoints that expose a
# full aggregator catalog via /models but only serve a subset
# (parity with section 3's user ``providers:`` behaviour).
_grp_is_current = slug.lower() == _current_provider_norm or (
_grp_is_current = (
slug.lower() == _current_provider_norm
or _current_provider_norm in {
str(alias).lower()
for alias in grp.get("aliases", set())
}
) or (
_current_provider_norm == "custom"
and bool(_current_base_url_norm)
and _grp_url_norm == _current_base_url_norm

View File

@ -708,14 +708,36 @@ def resolve_user_provider(name: str, user_config: Dict[str, Any]) -> Optional[Pr
)
def custom_provider_slug(display_name: str) -> str:
"""Build a canonical slug for a custom_providers entry.
def custom_provider_slug(display_name: str, provider_key: str = "") -> str:
"""Build the stable ``custom:`` identity for a configured provider.
Matches the convention used by runtime_provider and credential_pool
(``custom:<normalized-name>``). Centralised here so all call-sites
produce identical slugs.
Keyed ``providers:`` entries keep their config key as the durable
identity even when their display name changes. Legacy
``custom_providers:`` entries have no key, so their normalized display
name remains the identity.
"""
return "custom:" + display_name.strip().lower().replace(" ", "-")
identity = str(provider_key or "").strip() or str(display_name or "").strip()
normalized = identity.lower().replace(" ", "-")
return normalized if normalized.startswith("custom:") else f"custom:{normalized}"
def custom_provider_aliases(
display_name: str,
provider_key: str = "",
) -> frozenset[str]:
"""Return every current and legacy identity accepted for one endpoint."""
aliases: set[str] = set()
for value in (display_name, provider_key):
raw = str(value or "").strip().lower()
if not raw:
continue
normalized = raw.replace(" ", "-")
aliases.update({raw, normalized, custom_provider_slug(normalized)})
if normalized.startswith("custom:"):
suffix = normalized.split(":", 1)[1]
if suffix:
aliases.update({suffix, f"custom:{normalized}"})
return frozenset(aliases)
def resolve_custom_provider(
@ -734,7 +756,7 @@ def resolve_custom_provider(
# from a prior model-switch bug), fall back to the first custom
# provider entry so existing configs self-heal. (GH #17478)
bare_custom_fallback = requested == "custom"
first_valid: Optional[Tuple[str, str, Tuple[str, ...]]] = None
first_valid: Optional[Tuple[str, str, Tuple[str, ...], str]] = None
for entry in custom_providers:
if not isinstance(entry, dict):
@ -751,16 +773,22 @@ def resolve_custom_provider(
continue
key_env = (entry.get("key_env") or "").strip()
provider_key = (entry.get("provider_key") or "").strip()
env_vars: List[str] = []
if key_env:
env_vars.append(key_env)
# Stash the first valid entry for bare-"custom" fallback
if first_valid is None:
first_valid = (display_name, api_url, tuple(env_vars))
first_valid = (
display_name,
api_url,
tuple(env_vars),
custom_provider_slug(display_name, provider_key),
)
slug = custom_provider_slug(display_name)
if requested not in {display_name.lower(), slug}:
slug = custom_provider_slug(display_name, provider_key)
if requested not in custom_provider_aliases(display_name, provider_key):
continue
return ProviderDef(
@ -776,8 +804,7 @@ def resolve_custom_provider(
# Self-heal: bare "custom" matched nothing — return first valid entry
if bare_custom_fallback and first_valid:
dname, aurl, denv = first_valid
slug = custom_provider_slug(dname)
dname, aurl, denv, slug = first_valid
return ProviderDef(
id=slug,
name=dname,

View File

@ -42,6 +42,7 @@ from hermes_cli.config import (
load_config,
normalize_extra_headers,
)
from hermes_cli.providers import custom_provider_aliases, custom_provider_slug
from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname, env_int
@ -686,8 +687,6 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
# they're not configured.
if not is_provider_enabled(entry):
continue
# Match exact name or normalized name
name_norm = _normalize_custom_provider_name(ep_name)
# Resolve the API key from the env var name stored in key_env
key_env = str(entry.get("key_env", "") or "").strip()
resolved_api_key = _getenv(key_env, "").strip() if key_env else ""
@ -695,7 +694,11 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
if not resolved_api_key:
resolved_api_key = str(entry.get("api_key", "") or "").strip()
if requested_norm in {ep_name, name_norm, f"custom:{name_norm}"}:
display_name = entry.get("name", "")
if requested_norm in custom_provider_aliases(
str(display_name or ep_name),
str(ep_name),
):
# Found match by provider key
base_url = entry.get("api") or entry.get("url") or entry.get("base_url") or ""
if base_url:
@ -721,29 +724,6 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
result["api_mode"] = api_mode
_lift_max_output_tokens(entry, result)
return result
# Also check the 'name' field if present
display_name = entry.get("name", "")
if display_name:
display_norm = _normalize_custom_provider_name(display_name)
if requested_norm in {display_name, display_norm, f"custom:{display_norm}"}:
# Found match by display name
base_url = entry.get("api") or entry.get("url") or entry.get("base_url") or ""
if base_url:
result = {
"name": display_name,
"base_url": base_url.strip(),
"api_key": resolved_api_key,
"model": entry.get("default_model", ""),
}
extra_body = entry.get("extra_body")
if isinstance(extra_body, dict):
result["extra_body"] = dict(extra_body)
_lift_extra_headers(entry, result)
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
if api_mode:
result["api_mode"] = api_mode
_lift_max_output_tokens(entry, result)
return result
# Fall back to custom_providers: list (legacy format)
custom_providers = config.get("custom_providers")
@ -766,12 +746,8 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
base_url = entry.get("base_url")
if not isinstance(name, str) or not isinstance(base_url, str):
continue
name_norm = _normalize_custom_provider_name(name)
menu_key = f"custom:{name_norm}"
provider_key = str(entry.get("provider_key", "") or "").strip()
provider_key_norm = _normalize_custom_provider_name(provider_key) if provider_key else ""
provider_menu_key = f"custom:{provider_key_norm}" if provider_key_norm else ""
if requested_norm not in {name_norm, menu_key, provider_key_norm, provider_menu_key}:
if requested_norm not in custom_provider_aliases(name, provider_key):
continue
result = {
"name": name.strip(),
@ -846,7 +822,7 @@ def find_custom_provider_identity(base_url: str) -> Optional[str]:
entry.get("api") or entry.get("url") or entry.get("base_url") or ""
)
if _normalize_base_url_for_match(entry_url) == target:
return f"custom:{_normalize_custom_provider_name(str(ep_name))}"
return custom_provider_slug(str(ep_name), str(ep_name))
try:
custom_providers = get_compatible_custom_providers(config)
@ -859,7 +835,10 @@ def find_custom_provider_identity(base_url: str) -> Optional[str]:
if not isinstance(name, str) or not name.strip():
continue
if _normalize_base_url_for_match(entry.get("base_url")) == target:
return f"custom:{_normalize_custom_provider_name(name)}"
return custom_provider_slug(
name,
str(entry.get("provider_key", "") or ""),
)
return None

View File

@ -27,6 +27,69 @@ def test_matches_legacy_custom_providers_list(monkeypatch):
)
def test_matches_providers_dict_by_key(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {"providers": {"local": {"api": "http://127.0.0.1:8000/v1"}}},
)
assert (
rp.find_custom_provider_identity("http://127.0.0.1:8000/v1")
== "custom:local"
)
def test_matches_providers_dict_by_stable_key_not_display_name(monkeypatch):
config = {
"providers": {
"local-127.0.0.1:8000": {
"name": "Local Ollama",
"api": "http://127.0.0.1:8000/v1",
}
}
}
monkeypatch.setattr(
rp,
"load_config",
lambda: config,
)
slug = rp.find_custom_provider_identity("http://127.0.0.1:8000/v1")
assert slug == "custom:local-127.0.0.1:8000"
entry = rp._get_named_custom_provider(slug)
assert entry is not None
assert entry["name"] == "Local Ollama"
def test_match_ignores_trailing_slash_and_case(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{"name": "local", "base_url": "http://Localhost:8000/v1/"}
]
},
)
assert (
rp.find_custom_provider_identity("http://localhost:8000/v1")
== "custom:local"
)
def test_no_match_returns_none(monkeypatch):
monkeypatch.setattr(
rp,
"load_config",
lambda: {
"custom_providers": [
{"name": "other", "base_url": "https://elsewhere.example/v1"}
]
},
)
assert rp.find_custom_provider_identity("https://api.mimo.example/v1") is None
def test_empty_base_url_returns_none(monkeypatch):
monkeypatch.setattr(
rp, "load_config", lambda: {"custom_providers": [{"name": "x"}]}

View File

@ -327,6 +327,7 @@ class TestCustomProviderModelSwitch:
saved_text = config_path.read_text()
saved = yaml.safe_load(saved_text) or {}
entry = saved["providers"]["crs-henkee"]
assert saved["model"]["provider"] == "custom:crs-henkee"
assert "api_key" not in entry, (
f"providers.crs-henkee gained an api_key field: {entry.get('api_key')!r}"
)
@ -338,6 +339,53 @@ class TestCustomProviderModelSwitch:
# The synthesized template is also redundant here — key_env owns it.
assert "${HERMES_CRS_HENKEE_KEY}" not in saved_text
@pytest.mark.parametrize(
"stored_provider",
[
"local-127.0.0.1:11434",
"custom:local-ollama",
"custom:local-127.0.0.1:11434",
],
)
def test_picker_recognizes_current_provider_alias_when_name_differs(
self, config_home, monkeypatch, stored_provider
):
"""The classic picker maps legacy and stable IDs to the keyed row."""
from hermes_cli.main import select_provider_and_model
config_path = config_home / "config.yaml"
config_path.write_text(
"model:\n"
f" provider: {stored_provider}\n"
" default: qwen3.5:9b\n"
"providers:\n"
" local-127.0.0.1:11434:\n"
" name: Local Ollama\n"
" base_url: http://127.0.0.1:11434/v1\n"
" default_model: qwen3.5:9b\n"
" models:\n"
" qwen3.5:9b: {}\n"
"custom_providers: []\n",
encoding="utf-8",
)
captured = {}
def _capture_and_cancel(labels, default=0):
captured["labels"] = labels
captured["default"] = default
return len(labels) - 1
with patch(
"hermes_cli.main._prompt_provider_choice",
side_effect=_capture_and_cancel,
), patch("builtins.print"):
select_provider_and_model()
active_label = captured["labels"][captured["default"]]
assert "Local Ollama" in active_label
assert "currently active" in active_label
def test_key_env_providers_dict_preserves_existing_api_key(
self, config_home, monkeypatch
):

View File

@ -272,6 +272,410 @@ class TestDoctorMemoryProviderSection:
assert "Mem0" not in out
def test_mem0_provider_not_installed_shows_fail(self, monkeypatch, tmp_path):
# Make mem0 import fail
monkeypatch.setitem(sys.modules, "plugins.memory.mem0", None)
out = self._run_doctor_and_capture(monkeypatch, tmp_path, provider="mem0")
assert "Memory Provider" in out
assert "Built-in memory active" not in out
def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkeypatch, tmp_path):
helper = TestDoctorMemoryProviderSection()
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
real_which = doctor_mod.shutil.which
def fake_which(cmd):
if cmd in {"docker", "node", "npm"}:
return None
return real_which(cmd)
monkeypatch.setattr(doctor_mod.shutil, "which", fake_which)
out = helper._run_doctor_and_capture(monkeypatch, tmp_path, provider="")
assert "Docker backend is not available inside Termux" in out
assert "Node.js not found (browser tools are optional in the tested Termux path)" in out
assert "Install Node.js on Termux with: pkg install nodejs" in out
assert "Termux browser setup:" in out
assert "1) pkg install nodejs" in out
assert "2) npm install -g agent-browser" in out
assert "3) agent-browser install" in out
assert "Termux compatibility fallbacks:" in out
assert "use .[termux-all] for broad compatibility" in out
assert "Matrix E2EE extra is excluded on Termux" in out
assert "Local faster-whisper extra is excluded on Termux" in out
assert "STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY)." in out
assert "docker not found (optional)" not in out
def test_run_doctor_accepts_named_provider_from_providers_section(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
import yaml
(home / "config.yaml").write_text(
yaml.dump(
{
"model": {
"provider": "volcengine-plan",
"default": "doubao-seed-2.0-code",
},
"providers": {
"volcengine-plan": {
"name": "volcengine-plan",
"base_url": "https://ark.cn-beijing.volces.com/api/coding/v3",
"default_model": "doubao-seed-2.0-code",
"models": {"doubao-seed-2.0-code": {}},
}
},
}
)
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'volcengine-plan' is not a recognised provider" not in out
def test_run_doctor_accepts_stable_key_when_provider_name_differs(
monkeypatch, tmp_path
):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: custom:local-127.0.0.1:11434\n"
" default: qwen3.5:9b\n"
"providers:\n"
" local-127.0.0.1:11434:\n"
" name: Local Ollama\n"
" base_url: http://127.0.0.1:11434/v1\n"
" default_model: qwen3.5:9b\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert (
"model.provider 'custom:local-127.0.0.1:11434' is not a recognised provider"
not in out
)
assert "model.provider 'custom:local-127.0.0.1:11434' is unknown" not in out
def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: custom\n"
" default: local-model\n"
" base_url: http://localhost:8000/v1\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'custom' is not a recognised provider" not in out
def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: openrouter\n"
" default: openai/gpt-4.1-mini\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'openrouter' is set but no API key is configured" in out
assert "No credentials found for provider 'openrouter'." in out
@pytest.mark.parametrize(
("provider", "default_model"),
[
("opencode-zen", "anthropic/claude-sonnet-4.6"),
("kilocode", "anthropic/claude-sonnet-4.6"),
("kimi-coding", "kimi-k2"),
("nvidia", "qwen/qwen3.5-122b-a10b"),
("moa", "anthropic/claude-sonnet-4.6"),
],
)
def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
monkeypatch, tmp_path, provider, default_model
):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
f" provider: {provider}\n"
f" default: {default_model}\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert f"model.provider '{provider}' is not a recognised provider" not in out
assert f"model.provider '{provider}' is unknown" not in out
if provider in {"opencode-zen", "kilocode", "nvidia"}:
assert (
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'"
not in out
)
def test_run_doctor_accepts_vendor_slugs_for_named_custom_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
"model:\n"
" provider: custom:hpc-ai\n"
" default: deepseek/deepseek-v4-flash\n"
"custom_providers:\n"
" - name: hpc-ai\n"
" base_url: https://hpc-ai.example/v1\n"
" api_key: test-key\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'custom:hpc-ai' is not a recognised provider" not in out
assert "model.provider 'custom:hpc-ai' is unknown" not in out
assert (
"model.default 'deepseek/deepseek-v4-flash' uses a vendor/model slug but provider is "
"'custom:hpc-ai'"
not in out
)
assert "Either set model.provider to 'openrouter', or drop the vendor prefix." not in out
def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / ".env").write_text("KIMI_CN_API_KEY=***\n", encoding="utf-8")
(home / "config.yaml").write_text(
"model:\n"
" provider: kimi-coding-cn\n"
" default: kimi-k2.6\n",
encoding="utf-8",
)
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project")
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
(tmp_path / "project").mkdir(exist_ok=True)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: ([], []),
TOOLSET_REQUIREMENTS={},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "model.provider 'kimi-coding-cn' is not a recognised provider" not in out
def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser(monkeypatch, tmp_path):
home = tmp_path / ".hermes"
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("memory: {}\n", encoding="utf-8")
project = tmp_path / "project"
project.mkdir(exist_ok=True)
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor_mod, "_DHH", str(home))
monkeypatch.setattr(doctor_mod.shutil, "which", lambda cmd: "/data/data/com.termux/files/usr/bin/node" if cmd in {"node", "npm"} else None)
fake_model_tools = types.SimpleNamespace(
check_tool_availability=lambda *a, **kw: (["terminal"], [{"name": "browser", "env_vars": [], "tools": ["browser_navigate"]}]),
TOOLSET_REQUIREMENTS={
"terminal": {"name": "terminal"},
"browser": {"name": "browser"},
},
)
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
try:
from hermes_cli import auth as _auth_mod
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
except Exception:
pass
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
doctor_mod.run_doctor(Namespace(fix=False))
out = buf.getvalue()
assert "✓ browser" not in out
assert "browser" in out
assert "system dependency not met" in out
assert "agent-browser is not installed (expected in the tested Termux path)" in out
assert "npm install -g agent-browser && agent-browser install" in out
def _run_doctor_with_managed_agent_browser(monkeypatch, tmp_path, runnable):

View File

@ -63,6 +63,179 @@ def test_list_authenticated_providers_includes_custom_providers(monkeypatch):
def test_resolve_provider_full_finds_named_custom_provider():
"""Explicit /model --provider should resolve saved custom_providers entries."""
resolved = resolve_provider_full(
"custom:local-(127.0.0.1:4141)",
user_providers={},
custom_providers=[
{
"name": "Local (127.0.0.1:4141)",
"base_url": "http://127.0.0.1:4141/v1",
}
],
)
assert resolved is not None
assert resolved.id == "custom:local-(127.0.0.1:4141)"
assert resolved.name == "Local (127.0.0.1:4141)"
assert resolved.base_url == "http://127.0.0.1:4141/v1"
assert resolved.source == "user-config"
@pytest.mark.parametrize(
"requested",
[
"Local Ollama",
"local-ollama",
"local-127.0.0.1:11434",
"custom:local-ollama",
"custom:local-127.0.0.1:11434",
],
)
def test_keyed_custom_provider_legacy_aliases_resolve_to_stable_key(requested):
"""Every historical identity resolves, but keyed providers return one ID."""
resolved = resolve_provider_full(
requested,
user_providers={},
custom_providers=[
{
"name": "Local Ollama",
"provider_key": "local-127.0.0.1:11434",
"base_url": "http://127.0.0.1:11434/v1",
}
],
)
assert resolved is not None
assert resolved.id == "custom:local-127.0.0.1:11434"
assert resolved.name == "Local Ollama"
def test_keyed_custom_provider_bare_custom_fallback_uses_stable_key():
resolved = resolve_provider_full(
"custom",
user_providers={},
custom_providers=[
{
"name": "Local Ollama",
"provider_key": "local-127.0.0.1:11434",
"base_url": "http://127.0.0.1:11434/v1",
}
],
)
assert resolved is not None
assert resolved.id == "custom:local-127.0.0.1:11434"
@pytest.mark.parametrize(
"requested",
["foo", "custom:foo", "custom:custom:foo"],
)
def test_prefixed_provider_key_does_not_accumulate_custom_prefixes(requested):
"""Accept the historical doubled form without writing a third identity."""
resolved = resolve_provider_full(
requested,
user_providers={},
custom_providers=[
{
"name": "Foo Relay",
"provider_key": "custom:foo",
"base_url": "https://foo.example/v1",
}
],
)
assert resolved is not None
assert resolved.id == "custom:foo"
def test_list_authenticated_providers_includes_active_bare_custom_endpoint(monkeypatch):
"""Bare model.provider=custom + model.base_url should still populate /model.
Users can configure a one-off OpenAI-compatible endpoint directly under
``model:`` without a named ``providers:`` or ``custom_providers:`` row.
The gateway picker receives only the current model/base_url slice, so it
must surface that active endpoint rather than looking like config was
ignored.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
current_provider="custom",
current_base_url="https://www.ccsub.net/v1",
current_model="gpt-4o",
user_providers={},
custom_providers=[],
max_models=50,
)
bare_custom = next((p for p in providers if p["slug"] == "custom"), None)
assert bare_custom is not None
assert bare_custom["name"] == "Custom endpoint"
assert bare_custom["is_current"] is True
assert bare_custom["is_user_defined"] is True
assert bare_custom["models"] == ["gpt-4o"]
assert bare_custom["api_url"] == "https://www.ccsub.net/v1"
def test_list_authenticated_providers_can_probe_active_bare_custom_endpoint(monkeypatch):
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
monkeypatch.setattr(
"hermes_cli.models.fetch_api_models",
lambda api_key, api_url, **kwargs: ["gpt-4o", "gpt-4o-mini"],
)
providers = list_authenticated_providers(
current_provider="custom",
current_base_url="https://www.ccsub.net/v1",
current_model="gpt-4o",
user_providers={},
custom_providers=[],
probe_custom_providers=False,
probe_current_custom_provider=True,
)
bare_custom = next(p for p in providers if p["slug"] == "custom")
assert bare_custom["is_current"] is True
assert bare_custom["models"] == ["gpt-4o", "gpt-4o-mini"]
def test_switch_model_accepts_explicit_bare_custom_current_endpoint(monkeypatch):
"""Picker selections for bare custom endpoints should route to current base_url."""
monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION)
monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)
result = switch_model(
raw_input="gpt-4o-mini",
current_provider="custom",
current_model="gpt-4o",
current_base_url="https://www.ccsub.net/v1",
current_api_key="sk-test",
explicit_provider="custom",
user_providers={},
custom_providers=[],
)
assert result.success is True
assert result.target_provider == "custom"
assert result.provider_label == "Custom endpoint"
assert result.new_model == "gpt-4o-mini"
assert result.base_url == "https://www.ccsub.net/v1"
assert result.api_key == "sk-test"
def test_is_aggregator_recognizes_named_custom_provider():
assert providers_mod.is_aggregator("custom:hpc-ai") is True
assert providers_mod.is_aggregator("custom:litellm") is True
def test_is_aggregator_leaves_unknown_provider_non_aggregator():
assert providers_mod.is_aggregator("not-a-provider") is False
def test_is_routing_aggregator_excludes_flat_namespace_resellers():
@ -162,6 +335,258 @@ def test_list_authenticated_providers_bare_custom_slug_recovers(monkeypatch):
assert group["is_current"] is True
def test_compatible_keyed_provider_uses_stable_key_and_accepts_legacy_current_name(
monkeypatch,
):
"""The merged providers view keeps the config key while old IDs stay current."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
current_provider="custom:local-ollama",
user_providers={},
custom_providers=[
{
"name": "Local Ollama",
"provider_key": "local-127.0.0.1:11434",
"base_url": "http://127.0.0.1:11434/v1",
"model": "qwen3.5:9b",
}
],
max_models=50,
probe_custom_providers=False,
)
row = next(p for p in providers if p.get("is_user_defined"))
assert row["slug"] == "custom:local-127.0.0.1:11434"
assert row["is_current"] is True
def test_user_provider_row_recognizes_stable_custom_key_as_current(monkeypatch):
"""Section 3 keeps its legacy row slug but recognizes the stable ID."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
current_provider="custom:local-127.0.0.1:11434",
user_providers={
"local-127.0.0.1:11434": {
"name": "Local Ollama",
"base_url": "http://127.0.0.1:11434/v1",
"default_model": "qwen3.5:9b",
}
},
custom_providers=[],
max_models=50,
probe_custom_providers=False,
)
row = next(p for p in providers if p.get("is_user_defined"))
assert row["slug"] == "local-127.0.0.1:11434"
assert row["is_current"] is True
def test_list_authenticated_providers_distinct_endpoints_stay_separate(monkeypatch):
"""Entries with different base_urls must produce separate picker rows
even if some display names happen to be similar."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
user_providers={},
custom_providers=[
{"name": "Ollama — GLM 5.1", "base_url": "http://localhost:11434/v1",
"api_key": "ollama", "model": "glm-5.1"},
{"name": "Moonshot", "base_url": "https://api.moonshot.cn/v1",
"api_key": "sk-m", "model": "moonshot-v1"},
{"name": "Ollama — Qwen3-coder", "base_url": "http://localhost:11434/v1",
"api_key": "ollama", "model": "qwen3-coder"},
],
max_models=50,
probe_custom_providers=False,
)
custom_groups = [p for p in providers if p.get("is_user_defined")]
assert len(custom_groups) == 2
# Ollama endpoint collapses to one row with both models
ollama = next(p for p in custom_groups if p["name"] == "Ollama")
assert set(ollama["models"]) == {"glm-5.1", "qwen3-coder"}
moonshot = next(p for p in custom_groups if p["name"] == "Moonshot")
assert moonshot["models"] == ["moonshot-v1"]
def test_list_authenticated_providers_same_url_different_keys_disambiguated(monkeypatch):
"""Two custom_providers entries with the same base_url but different
api_keys (and identical cleaned names) must both stay visible in the
picker slug is suffixed to disambiguate."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
user_providers={},
custom_providers=[
{"name": "OpenAI — key A", "base_url": "https://api.openai.com/v1",
"api_key": "sk-AAA", "model": "gpt-5.4"},
{"name": "OpenAI — key B", "base_url": "https://api.openai.com/v1",
"api_key": "sk-BBB", "model": "gpt-4.6"},
],
max_models=50,
)
custom_groups = [p for p in providers if p.get("is_user_defined")]
assert len(custom_groups) == 2
slugs = sorted(p["slug"] for p in custom_groups)
# First group keeps the base slug, second gets a numeric suffix
assert slugs == ["custom:openai", "custom:openai-2"]
# Each row has a distinct model
models = {p["slug"]: p["models"] for p in custom_groups}
assert models["custom:openai"] == ["gpt-5.4"]
assert models["custom:openai-2"] == ["gpt-4.6"]
def test_list_authenticated_providers_same_url_different_key_env_and_api_mode_stay_separate(monkeypatch):
"""Same gateway host but different key_env/api_mode entries are distinct providers."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
providers = list_authenticated_providers(
current_provider="custom:gpt",
current_base_url="https://gateway.example.com",
user_providers={},
custom_providers=[
{
"name": "gpt",
"base_url": "https://gateway.example.com",
"key_env": "GPT_KEY",
"api_mode": "codex_responses",
"model": "gpt-5.5",
},
{
"name": "claude",
"base_url": "https://gateway.example.com",
"key_env": "CLAUDE_KEY",
"api_mode": "anthropic_messages",
"model": "claude-opus-4-8",
},
],
max_models=50,
)
custom = [p for p in providers if p.get("is_user_defined")]
by_slug = {p["slug"]: p for p in custom}
assert set(by_slug) == {"custom:gpt", "custom:claude"}
assert by_slug["custom:gpt"]["models"] == ["gpt-5.5"]
assert by_slug["custom:claude"]["models"] == ["claude-opus-4-8"]
assert by_slug["custom:gpt"]["is_current"] is True
assert by_slug["custom:claude"]["is_current"] is False
def test_list_authenticated_providers_total_models_reflects_grouped_count(monkeypatch):
"""After grouping six entries into one row, total_models must reflect
the full count, and every grouped model appears in the list."""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
entries = [
{"name": f"Ollama \u2014 Model {i}", "base_url": "http://localhost:11434/v1",
"api_key": "ollama", "model": f"model-{i}"}
for i in range(6)
]
providers = list_authenticated_providers(
user_providers={},
custom_providers=entries,
max_models=4,
probe_custom_providers=False,
)
groups = [p for p in providers if p.get("is_user_defined")]
assert len(groups) == 1
group = groups[0]
assert group["total_models"] == 6
# All six models are preserved in the grouped row.
assert sorted(group["models"]) == sorted(f"model-{i}" for i in range(6))
def test_lmstudio_picker_probes_active_config_base_url(monkeypatch):
"""When `provider: lmstudio` is saved with a remote base_url and no
LM_BASE_URL env var, the picker must probe the saved base_url not
127.0.0.1. Regression: prior behavior always probed localhost, so users
with LM Studio on a lab box saw the wrong (or empty) model list.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
monkeypatch.delenv("LM_BASE_URL", raising=False)
monkeypatch.delenv("LM_API_KEY", raising=False)
captured: dict = {}
def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
captured["base_url"] = base_url
captured["api_key"] = api_key
return ["qwen/qwen3-coder-30b"]
monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch)
list_authenticated_providers(
current_provider="lmstudio",
current_base_url="http://192.168.1.10:1234/v1",
current_model="qwen/qwen3-coder-30b",
)
assert captured["base_url"] == "http://192.168.1.10:1234/v1"
def test_lmstudio_picker_lm_base_url_env_wins_over_active_config(monkeypatch):
"""LM_BASE_URL env var must still take precedence over the saved
base_url so users can temporarily redirect the picker without editing
config.yaml.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
monkeypatch.setenv("LM_BASE_URL", "http://override.local:9999/v1")
monkeypatch.delenv("LM_API_KEY", raising=False)
captured: dict = {}
def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
captured["base_url"] = base_url
return []
monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch)
list_authenticated_providers(
current_provider="lmstudio",
current_base_url="http://192.168.1.10:1234/v1",
)
assert captured["base_url"] == "http://override.local:9999/v1"
def test_lmstudio_picker_skips_probe_when_not_configured(monkeypatch):
"""If the user has never configured LM Studio (no LM_API_KEY / LM_BASE_URL
and not on lmstudio), the picker must not pay the localhost probe cost
just to discover LM Studio is unavailable.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
monkeypatch.delenv("LM_BASE_URL", raising=False)
monkeypatch.delenv("LM_API_KEY", raising=False)
captured: dict = {}
def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
captured["base_url"] = base_url
return []
monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch)
list_authenticated_providers(
current_provider="openrouter",
current_base_url="https://openrouter.ai/api/v1",
)
assert "base_url" not in captured
def test_custom_providers_uses_live_models_for_multi_model_endpoint(monkeypatch):