Stage 313: PR #1818 — named custom provider routing by @franksong2702

This commit is contained in:
nesquena-hermes 2026-05-07 16:56:49 +00:00
commit 6ab384618a
3 changed files with 253 additions and 20 deletions

View file

@ -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

View file

@ -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:<name>`` 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

View file

@ -0,0 +1,116 @@
"""Regression tests for #1806 named custom provider routing.
The WebUI must treat ``model.provider: <custom_providers[].name>`` as the
same provider slug the picker emits: ``custom:<name>``. 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