Stage 313: PR #1818 — named custom provider routing by @franksong2702
This commit is contained in:
commit
6ab384618a
3 changed files with 253 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
151
api/config.py
151
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:<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
|
||||
|
|
|
|||
116
tests/test_issue1806_named_custom_provider_resolution.py
Normal file
116
tests/test_issue1806_named_custom_provider_resolution.py
Normal 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
|
||||
Loading…
Reference in a new issue