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:
parent
f5a18cde69
commit
2de1e86c16
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}]}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in New Issue