From 2de1e86c160c1a2bcc0219529425f1c15800e396 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:26:56 -0600 Subject: [PATCH] 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 --- hermes_cli/doctor.py | 9 +- hermes_cli/main.py | 25 +- hermes_cli/model_setup_flows.py | 3 +- hermes_cli/model_switch.py | 47 +- hermes_cli/providers.py | 51 ++- hermes_cli/runtime_provider.py | 45 +- .../test_custom_provider_identity.py | 63 +++ .../test_custom_provider_model_switch.py | 48 ++ tests/hermes_cli/test_doctor.py | 404 +++++++++++++++++ .../test_model_switch_custom_providers.py | 425 ++++++++++++++++++ 10 files changed, 1053 insertions(+), 67 deletions(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index ca8b8897ae98e..18ea422f3e247 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -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() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 814fa7e8f327b..209ef0d083292 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 " diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index cea14a263fb6e..fd5a0265df8cf 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -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: diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 75a1e9d914d70..8da7910337f97 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -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:) 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 diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 17cae5d584fb2..8fc2ac3b41de5 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -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:``). 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, diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c140ee02b4c3a..c92f0b7fc9724 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -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 diff --git a/tests/hermes_cli/test_custom_provider_identity.py b/tests/hermes_cli/test_custom_provider_identity.py index f9a6304d37775..c4f36ea37f199 100644 --- a/tests/hermes_cli/test_custom_provider_identity.py +++ b/tests/hermes_cli/test_custom_provider_identity.py @@ -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"}]} diff --git a/tests/hermes_cli/test_custom_provider_model_switch.py b/tests/hermes_cli/test_custom_provider_model_switch.py index e24f6e8c67973..387332eb4f8ae 100644 --- a/tests/hermes_cli/test_custom_provider_model_switch.py +++ b/tests/hermes_cli/test_custom_provider_model_switch.py @@ -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 ): diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index b03aa0757b7ba..aae133a0eb59b 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -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): diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 221f279228b5d..e1eef0bc5a390 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -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):