diff --git a/CHANGELOG.md b/CHANGELOG.md index a884aeed..b2cfb564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Hermes Web UI -- Changelog +## Unreleased + +### Fixed + +- **Named custom provider routing for local endpoints** — `model.provider: ollama-local` now normalizes to the same `custom:ollama-local` slug used by the model picker when a matching `custom_providers[].name` exists. Auto-discovered `/v1/models` results for that endpoint are folded into the named custom group, and stale base-url-derived slugs such as `custom:local-(127.0.0.1:11434)` no longer create a second picker group that routes to an unsettable API-key env var. Fixes #1806. + ## [v0.51.18] — 2026-05-07 — 5-PR batch (4 contributor + 1 self-built UX polish) ### Fixed diff --git a/api/config.py b/api/config.py index 6e67800f..fed092bb 100644 --- a/api/config.py +++ b/api/config.py @@ -738,6 +738,74 @@ def _resolve_provider_alias(name: str) -> str: return _PROVIDER_ALIASES.get(raw, name) +def _custom_provider_slug_from_name(name: object) -> str: + raw = str(name or "").strip().lower() + if not raw: + return "" + if raw.startswith("custom:"): + return raw + return "custom:" + raw.replace(" ", "-") + + +def _custom_provider_entries(config_obj: dict | None = None) -> list[dict]: + source = config_obj if isinstance(config_obj, dict) else cfg + entries = source.get("custom_providers", []) + if not isinstance(entries, list): + return [] + return [entry for entry in entries if isinstance(entry, dict)] + + +def _named_custom_provider_slugs(config_obj: dict | None = None) -> set[str]: + return { + slug + for slug in ( + _custom_provider_slug_from_name(entry.get("name")) + for entry in _custom_provider_entries(config_obj) + ) + if slug + } + + +def _named_custom_provider_slug_for_provider( + provider: object, + config_obj: dict | None = None, +) -> str: + raw = str(provider or "").strip().lower() + if not raw: + return "" + raw_suffix = raw.removeprefix("custom:") + for entry in _custom_provider_entries(config_obj): + entry_name = str(entry.get("name") or "").strip().lower() + slug = _custom_provider_slug_from_name(entry_name) + if not entry_name or not slug: + continue + if raw in {entry_name, slug} or raw_suffix == slug.removeprefix("custom:"): + return slug + return "" + + +def _resolve_configured_provider_id( + provider: object, + config_obj: dict | None = None, + *, + base_url: object = None, +) -> str: + named_slug = _named_custom_provider_slug_for_provider(provider, config_obj) + if named_slug: + return named_slug + + resolved = _resolve_provider_alias(provider) + if ( + base_url + and str(resolved or "").strip().lower() == "custom" + ): + by_base_url = _named_custom_provider_slug_for_base_url(base_url, config_obj) + if by_base_url: + return by_base_url + + return resolved + + def _canonicalise_provider_id(name: object) -> str: """Normalise a provider id slug into a stable lowercase-hyphenated form. @@ -780,6 +848,34 @@ def _canonicalise_provider_id(name: object) -> str: return raw +def _normalize_base_url_for_match(value: object) -> str: + url = str(value or "").strip().rstrip("/") + if not url: + return "" + parsed_url = urlparse(url if "://" in url else f"http://{url}") + scheme = (parsed_url.scheme or "http").lower() + netloc = (parsed_url.netloc or parsed_url.path).lower().rstrip("/") + path = parsed_url.path.rstrip("/") + if not parsed_url.netloc: + path = "" + return f"{scheme}://{netloc}{path}" + + +def _named_custom_provider_slug_for_base_url( + base_url: object, + config_obj: dict | None = None, +) -> str: + target = _normalize_base_url_for_match(base_url) + if not target: + return "" + for entry in _custom_provider_entries(config_obj): + entry_base_url = _normalize_base_url_for_match(entry.get("base_url")) + if entry_base_url != target: + continue + return _custom_provider_slug_from_name(entry.get("name")) or "custom" + return "" + + # Well-known models per provider (used to populate dropdown for direct API providers) _PROVIDER_MODELS = { "anthropic": [ @@ -1322,8 +1418,12 @@ def resolve_model_provider(model_id: str) -> tuple: config_base_url = None model_cfg = cfg.get("model", {}) if isinstance(model_cfg, dict): - config_provider = model_cfg.get("provider") config_base_url = model_cfg.get("base_url") + config_provider = _resolve_configured_provider_id( + model_cfg.get("provider"), + cfg, + base_url=config_base_url, + ) # Heal legacy ``provider: local`` entries (written by WebUI < v0.50.252) # at read time. ``local`` is not a registered provider, so passing it @@ -2302,9 +2402,16 @@ def get_available_models() -> dict: if cfg_default: default_model = cfg_default - # Normalize active_provider to its canonical key + # Normalize active_provider to its canonical key. Named custom + # providers are first-class provider ids in WebUI routing; accept the + # user-facing name from config.yaml (``provider: ollama-local``) and + # route it through the same ``custom:`` slug the picker emits. if active_provider: - active_provider = _resolve_provider_alias(active_provider) + active_provider = _resolve_configured_provider_id( + active_provider, + cfg, + base_url=cfg_base_url, + ) # 2. Read auth store (active_provider fallback + credential_pool inspection) auth_store = {} @@ -2315,7 +2422,11 @@ def get_available_models() -> dict: auth_store = _j.loads(auth_store_path.read_text(encoding="utf-8")) if not active_provider: - active_provider = _resolve_provider_alias(auth_store.get("active_provider")) + active_provider = _resolve_configured_provider_id( + auth_store.get("active_provider"), + cfg, + base_url=cfg_base_url, + ) except Exception: logger.debug("Failed to load auth store from %s", auth_store_path) @@ -2506,18 +2617,6 @@ def get_available_models() -> dict: if _canonical in _PROVIDER_MODELS or _canonical in _cfg_providers or _pid_key in _cfg_providers: detected_providers.add(_canonical) - def _normalize_base_url_for_match(value: object) -> str: - url = str(value or "").strip().rstrip("/") - if not url: - return "" - parsed_url = urlparse(url if "://" in url else f"http://{url}") - scheme = (parsed_url.scheme or "http").lower() - netloc = (parsed_url.netloc or parsed_url.path).lower().rstrip("/") - path = parsed_url.path.rstrip("/") - if not parsed_url.netloc: - path = "" - return f"{scheme}://{netloc}{path}" - def _configured_provider_for_base_url(base_url: object) -> str: target = _normalize_base_url_for_match(base_url) if not target: @@ -2526,7 +2625,11 @@ def get_available_models() -> dict: if isinstance(model_cfg, dict): model_base_url = _normalize_base_url_for_match(model_cfg.get("base_url")) if model_base_url == target: - provider_hint = _resolve_provider_alias(model_cfg.get("provider")) + provider_hint = _resolve_configured_provider_id( + model_cfg.get("provider"), + cfg, + base_url=base_url, + ) if provider_hint: return str(provider_hint).strip().lower() @@ -2717,7 +2820,9 @@ def get_available_models() -> dict: if not isinstance(_cp, dict): continue _cp_name = (_cp.get("name") or "").strip() - _slug = ("custom:" + _cp_name.lower().replace(" ", "-")) if _cp_name else None + _slug = _custom_provider_slug_from_name(_cp_name) if _cp_name else None + if _slug and _slug not in _named_custom_groups: + _named_custom_groups[_slug] = (_cp_name, []) # Collect model IDs: singular "model" field first, then "models" dict keys _cp_model_ids: list[str] = [] @@ -2735,8 +2840,6 @@ def get_available_models() -> dict: _cp_label = _get_label_for_model(_cp_model, []) _seen_custom_ids.add(_cp_model) if _slug: - if _slug not in _named_custom_groups: - _named_custom_groups[_slug] = (_cp_name, []) detected_providers.add(_slug) _cp_option_id = _cp_model if active_provider != _slug and not _cp_option_id.startswith("@"): @@ -2762,6 +2865,14 @@ def get_available_models() -> dict: if not _has_unnamed: detected_providers.discard("custom") + _named_custom_slugs = _named_custom_provider_slugs(cfg) + _base_matched_named_slug = _named_custom_provider_slug_for_base_url(cfg_base_url, cfg) + if _base_matched_named_slug and _named_custom_slugs: + for _pid in list(detected_providers): + _pid_norm = str(_pid or "").strip().lower() + if _pid_norm.startswith("custom:") and _pid_norm not in _named_custom_slugs: + detected_providers.discard(_pid) + # Filter providers if providers.only_configured is set providers_cfg = cfg.get("providers", {}) only_show_configured = providers_cfg.get("only_configured", False) if isinstance(providers_cfg, dict) else False diff --git a/tests/test_issue1806_named_custom_provider_resolution.py b/tests/test_issue1806_named_custom_provider_resolution.py new file mode 100644 index 00000000..fd73a697 --- /dev/null +++ b/tests/test_issue1806_named_custom_provider_resolution.py @@ -0,0 +1,116 @@ +"""Regression tests for #1806 named custom provider routing. + +The WebUI must treat ``model.provider: `` as the +same provider slug the picker emits: ``custom:``. Otherwise a stale +agent-side base-url slug such as ``custom:local-(127.0.0.1:11434)`` can win +model selection and send runtime auth down an impossible env-var path. +""" + +from __future__ import annotations + +import json +import sys +import types + +import pytest + +import api.config as config + + +@pytest.fixture(autouse=True) +def _isolate_models_cache(tmp_path, monkeypatch): + monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") + config.invalidate_models_cache() + yield + config.invalidate_models_cache() + + +def _with_ollama_local_config(): + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + config.cfg.clear() + config.cfg.update( + { + "model": { + "default": "carnice-9b:latest", + "provider": "ollama-local", + "base_url": "http://127.0.0.1:11434/v1", + "api_key": "ollama", + }, + "custom_providers": [ + { + "name": "ollama-local", + "base_url": "http://127.0.0.1:11434/v1", + "api_key": "ollama", + "model": "carnice-9b:latest", + } + ], + } + ) + try: + config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime + except Exception: + config._cfg_mtime = 0.0 + + def restore(): + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime + config.invalidate_models_cache() + + return restore + + +def test_model_provider_name_resolves_to_named_custom_slug(): + restore = _with_ollama_local_config() + try: + model, provider, base_url = config.resolve_model_provider("carnice-9b:latest") + finally: + restore() + + assert model == "carnice-9b:latest" + assert provider == "custom:ollama-local" + assert base_url == "http://127.0.0.1:11434/v1" + + +def test_available_models_drops_base_url_derived_custom_slug(monkeypatch): + """A stale agent catalog slug must not create a second local custom group.""" + fake_models = types.ModuleType("hermes_cli.models") + fake_models.list_available_providers = lambda: [ + {"id": "custom:local-(127.0.0.1:11434)", "authenticated": True}, + ] + fake_auth = types.ModuleType("hermes_cli.auth") + fake_auth.get_auth_status = lambda _pid: {"key_source": "config_yaml"} + monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models) + monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth) + monkeypatch.setattr(config, "_get_auth_store_path", lambda: config.Path("/tmp/does-not-exist-auth.json")) + monkeypatch.setattr("socket.getaddrinfo", lambda *a, **k: []) + + class _Resp: + def read(self): + return json.dumps( + {"data": [{"id": "carnice-9b:latest", "name": "carnice-9b:latest"}]} + ).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: _Resp()) + + restore = _with_ollama_local_config() + try: + result = config.get_available_models() + finally: + restore() + + assert result["active_provider"] == "custom:ollama-local" + groups_by_id = {g["provider_id"]: g for g in result["groups"]} + assert "custom:ollama-local" in groups_by_id + assert "custom:local-(127.0.0.1:11434)" not in groups_by_id + assert "ollama-local" not in groups_by_id + + named_models = [m["id"] for m in groups_by_id["custom:ollama-local"]["models"]] + assert "carnice-9b:latest" in named_models