fix: load remote models for named custom providers

This commit is contained in:
Michael Lam
2026-05-18 01:07:09 -07:00
parent e6be01c4dd
commit 037652308d
3 changed files with 283 additions and 125 deletions

View File

@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- **PR #2515** (closes #2513) — Keep named custom-provider model pickers populated from each configured endpoint even when `custom_providers[].model` is present. The singular `model` field now acts as a sticky/fallback entry instead of preventing remote `/models` catalog entries or sibling custom providers from appearing.
## [v0.51.89] — 2026-05-18 — Release BM (stage-382 — 6-PR full sweep batch — runtime adapter approval/clarify seam + SOUL.md memory panel + #1855 resolve_model_provider fast-path + PWA sidebar spinner fix + /model active-provider preference + contributor contract docs index)
### Changed

View File

@@ -3087,101 +3087,72 @@ def get_available_models() -> dict:
return ""
# 4. Fetch models from custom endpoint if base_url is configured
auto_detected_models = []
auto_detected_models_by_provider: dict[str, list[dict]] = {}
if cfg_base_url:
def _models_endpoint_for_base_url(base_url: str) -> str:
base = str(base_url or "").strip().rstrip("/")
if base.endswith("/v1"):
return base + "/models"
return base + "/v1/models"
def _extract_model_entries_from_payload(data: object, provider: str) -> list[dict]:
models_list = []
if isinstance(data, dict):
if "data" in data and isinstance(data["data"], list):
models_list = data["data"]
elif "models" in data and isinstance(data["models"], list):
models_list = data["models"]
models = []
seen = set()
for model in models_list:
if not isinstance(model, dict):
continue
model_id = (
model.get("id", "")
or model.get("name", "")
or model.get("model", "")
)
model_name = model.get("name", "") or model.get("model", "") or model_id
model_id = str(model_id or "").strip()
model_name = str(model_name or "").strip()
if not model_id or not model_name or model_id in seen:
continue
seen.add(model_id)
label = _format_ollama_label(model_id) if provider in ("ollama", "ollama-cloud") else model_name
models.append({"id": model_id, "label": label})
return models
def _read_custom_endpoint_models(
base_url: object,
provider: str,
*,
api_key: object = "",
trusted_base_urls: tuple[object, ...] = (),
) -> list[dict]:
base = str(base_url or "").strip()
if not base:
return []
try:
import ipaddress
import urllib.request
base_url = cfg_base_url.strip()
if base_url.endswith("/v1"):
endpoint_url = base_url + "/models"
else:
endpoint_url = base_url.rstrip("/") + "/v1/models"
configured_provider = _configured_provider_for_base_url(base_url)
provider = configured_provider or "custom"
provider_from_config = bool(configured_provider)
parsed = urlparse(base_url if "://" in base_url else f"http://{base_url}")
host = (parsed.netloc or parsed.path).lower()
if parsed.hostname and not provider_from_config:
try:
addr = ipaddress.ip_address(parsed.hostname)
if addr.is_private or addr.is_loopback or addr.is_link_local:
if "ollama" in host or "127.0.0.1" in host or "localhost" in host:
provider = "ollama"
elif "lmstudio" in host or "lm-studio" in host:
provider = "lmstudio"
else:
# Unknown loopback/private endpoint: route through
# the generic ``custom`` provider so the agent's
# auxiliary client (compression, vision, web
# extraction) takes the OpenAI-compat custom path
# with ``no-key-required`` semantics. Writing
# ``provider: local`` here used to break
# compression mid-conversation because ``local``
# is not a registered provider in
# ``hermes_cli.auth.PROVIDER_REGISTRY`` — see #1384.
provider = "custom"
except ValueError:
pass
headers = {}
api_key = ""
if isinstance(model_cfg, dict):
api_key = (model_cfg.get("api_key") or "").strip()
if not api_key:
providers_cfg = cfg.get("providers", {})
if isinstance(providers_cfg, dict):
for provider_key in filter(None, [active_provider, "custom"]):
provider_cfg = providers_cfg.get(provider_key, {})
if isinstance(provider_cfg, dict):
api_key = (provider_cfg.get("api_key") or "").strip()
if api_key:
break
if not api_key:
api_key_vars = (
"HERMES_API_KEY",
"HERMES_OPENAI_API_KEY",
"OPENAI_API_KEY",
"LOCAL_API_KEY",
"OPENROUTER_API_KEY",
"API_KEY",
)
for key in api_key_vars:
api_key = (all_env.get(key) or os.getenv(key) or "").strip()
if api_key:
break
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
import socket
# Build set of hostnames from custom_providers config — these are
# user-explicitly configured endpoints and should not be blocked by SSRF.
_ssrf_trusted_hosts: set[str] = set()
# Also trust the base_url from model config (explicitly configured by user)
if cfg_base_url:
_base_parsed = urlparse(cfg_base_url if "://" in cfg_base_url else f"http://{cfg_base_url}")
if _base_parsed.hostname:
_ssrf_trusted_hosts.add(_base_parsed.hostname.lower())
_custom_providers_cfg = cfg.get("custom_providers", [])
if isinstance(_custom_providers_cfg, list):
for _cp in _custom_providers_cfg:
if not isinstance(_cp, dict):
continue
_cp_base = (_cp.get("base_url") or "").strip()
if _cp_base:
_cp_parsed = urlparse(_cp_base if "://" in _cp_base else f"http://{_cp_base}")
if _cp_parsed.hostname:
_ssrf_trusted_hosts.add(_cp_parsed.hostname.lower())
endpoint_url = _models_endpoint_for_base_url(base)
headers = {}
key = str(api_key or "").strip()
if key:
headers["Authorization"] = f"Bearer {key}"
parsed_url = urlparse(
endpoint_url if "://" in endpoint_url else f"http://{endpoint_url}"
)
# User-configured custom provider endpoints are explicitly trusted,
# but keep the same private-IP guard for non-matching targets used by
# the legacy active model.base_url path.
_ssrf_trusted_hosts: set[str] = set()
for trusted in (base, *trusted_base_urls):
_cp_parsed = urlparse(
str(trusted) if "://" in str(trusted) else f"http://{trusted}"
)
if _cp_parsed.hostname:
_ssrf_trusted_hosts.add(_cp_parsed.hostname.lower())
parsed_url = urlparse(endpoint_url if "://" in endpoint_url else f"http://{endpoint_url}")
if parsed_url.scheme not in ("", "http", "https"):
raise ValueError(f"Invalid URL scheme: {parsed_url.scheme}")
if parsed_url.hostname:
@@ -3190,53 +3161,106 @@ def get_available_models() -> dict:
for _, _, _, _, addr in resolved_ips:
addr_obj = ipaddress.ip_address(addr[0])
if addr_obj.is_private or addr_obj.is_loopback or addr_obj.is_link_local:
host_l = (parsed_url.hostname or "").lower()
is_known_local = any(
k in (parsed_url.hostname or "").lower()
for k in (
"ollama",
"localhost",
"127.0.0.1",
"lmstudio",
"lm-studio",
)
) or (parsed_url.hostname or "").lower() in _ssrf_trusted_hosts
k in host_l
for k in ("ollama", "localhost", "127.0.0.1", "lmstudio", "lm-studio")
) or host_l in _ssrf_trusted_hosts
if not is_known_local:
raise ValueError(
f"SSRF: resolved hostname to private IP {addr[0]}"
)
raise ValueError(f"SSRF: resolved hostname to private IP {addr[0]}")
except socket.gaierror:
pass
req = urllib.request.Request(endpoint_url, method="GET")
req.add_header("User-Agent", "OpenAI/Python 1.0")
for k, v in headers.items():
req.add_header(k, v)
with urllib.request.urlopen(req, timeout=10) as response: # nosec B310
data = json.loads(response.read().decode("utf-8"))
models_list = []
if "data" in data and isinstance(data["data"], list):
models_list = data["data"]
elif "models" in data and isinstance(data["models"], list):
models_list = data["models"]
for model in models_list:
if not isinstance(model, dict):
continue
model_id = (
model.get("id", "")
or model.get("name", "")
or model.get("model", "")
)
model_name = model.get("name", "") or model.get("model", "") or model_id
if model_id and model_name:
label = _format_ollama_label(model_id) if provider in ("ollama", "ollama-cloud") else model_name
auto_model = {"id": model_id, "label": label}
auto_detected_models.append(auto_model)
provider_key = provider.lower()
auto_detected_models_by_provider.setdefault(provider_key, []).append(auto_model)
detected_providers.add(provider_key)
return _extract_model_entries_from_payload(data, provider)
except Exception:
logger.debug("Custom endpoint unreachable or misconfigured for provider: %s", provider)
return []
# 4. Fetch models from custom endpoint if base_url is configured
auto_detected_models = []
auto_detected_models_by_provider: dict[str, list[dict]] = {}
if cfg_base_url:
base_url = cfg_base_url.strip()
configured_provider = _configured_provider_for_base_url(base_url)
provider = configured_provider or "custom"
provider_from_config = bool(configured_provider)
parsed = urlparse(base_url if "://" in base_url else f"http://{base_url}")
host = (parsed.netloc or parsed.path).lower()
if parsed.hostname and not provider_from_config:
try:
import ipaddress
addr = ipaddress.ip_address(parsed.hostname)
if addr.is_private or addr.is_loopback or addr.is_link_local:
if "ollama" in host or "127.0.0.1" in host or "localhost" in host:
provider = "ollama"
elif "lmstudio" in host or "lm-studio" in host:
provider = "lmstudio"
else:
# Unknown loopback/private endpoint: route through
# the generic ``custom`` provider so the agent's
# auxiliary client (compression, vision, web
# extraction) takes the OpenAI-compat custom path
# with ``no-key-required`` semantics. Writing
# ``provider: local`` here used to break
# compression mid-conversation because ``local``
# is not a registered provider in
# ``hermes_cli.auth.PROVIDER_REGISTRY`` — see #1384.
provider = "custom"
except ValueError:
pass
api_key = ""
if isinstance(model_cfg, dict):
api_key = (model_cfg.get("api_key") or "").strip()
if not api_key:
providers_cfg = cfg.get("providers", {})
if isinstance(providers_cfg, dict):
for provider_key in filter(None, [active_provider, "custom"]):
provider_cfg = providers_cfg.get(provider_key, {})
if isinstance(provider_cfg, dict):
api_key = (provider_cfg.get("api_key") or "").strip()
if api_key:
break
if not api_key:
api_key_vars = (
"HERMES_API_KEY",
"HERMES_OPENAI_API_KEY",
"OPENAI_API_KEY",
"LOCAL_API_KEY",
"OPENROUTER_API_KEY",
"API_KEY",
)
for key in api_key_vars:
api_key = (all_env.get(key) or os.getenv(key) or "").strip()
if api_key:
break
_trusted_custom_bases: list[object] = [cfg_base_url]
_custom_providers_for_trust = cfg.get("custom_providers", [])
if isinstance(_custom_providers_for_trust, list):
_trusted_custom_bases.extend(
_cp.get("base_url")
for _cp in _custom_providers_for_trust
if isinstance(_cp, dict) and _cp.get("base_url")
)
for auto_model in _read_custom_endpoint_models(
base_url,
provider,
api_key=api_key,
trusted_base_urls=tuple(_trusted_custom_bases),
):
auto_detected_models.append(auto_model)
provider_key = provider.lower()
auto_detected_models_by_provider.setdefault(provider_key, []).append(auto_model)
detected_providers.add(provider_key)
_custom_providers_cfg = cfg.get("custom_providers", [])
_named_custom_groups: dict = {}
@@ -3250,7 +3274,36 @@ def get_available_models() -> dict:
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_base_url = str(_cp.get("base_url") or "").strip()
if _slug and _cp_base_url:
_cp_api_key = str(_cp.get("api_key") or "").strip()
if not _cp_api_key:
_cp_key_env = str(_cp.get("key_env") or "").strip()
if _cp_key_env:
_cp_api_key = str(os.getenv(_cp_key_env) or "").strip()
_live_models = auto_detected_models_by_provider.get(_slug) or _read_custom_endpoint_models(
_cp_base_url,
_slug,
api_key=_cp_api_key,
trusted_base_urls=(_cp_base_url,),
)
for _live_model in _live_models:
_live_id = str(_live_model.get("id") or "").strip()
if not _live_id:
continue
_dedup_key = f"{_slug}:{_live_id}"
if _dedup_key in _seen_custom_ids:
continue
_seen_custom_ids.add(_dedup_key)
detected_providers.add(_slug)
_cp_option_id = _live_id
if active_provider != _slug and not _cp_option_id.startswith("@"):
_cp_option_id = f"@{_slug}:{_cp_option_id}"
_named_custom_groups[_slug][1].append(
{"id": _cp_option_id, "label": _live_model.get("label") or _get_label_for_model(_live_id, [])}
)
# Collect configured model IDs as a fallback/sticky entry after live discovery.
_cp_model_ids: list[str] = []
_cp_model = _cp.get("model", "")
if _cp_model:

View File

@@ -0,0 +1,101 @@
import json
import urllib.request
from api import config
def test_custom_provider_model_field_does_not_block_remote_catalog(monkeypatch, tmp_path):
"""custom_providers[].model is sticky metadata, not the whole picker catalog."""
class FakeResponse:
def __init__(self, payload):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return json.dumps(self.payload).encode("utf-8")
calls = []
def fake_urlopen(req, timeout=10):
url = getattr(req, "full_url", str(req))
calls.append(url)
if "alpha.example" in url:
return FakeResponse(
{
"data": [
{"id": "alpha/sticky", "name": "Alpha Sticky"},
{"id": "alpha/remote", "name": "Alpha Remote"},
]
}
)
if "beta.example" in url:
return FakeResponse(
{
"models": [
{"id": "beta/remote", "name": "Beta Remote"},
]
}
)
raise AssertionError(f"unexpected urlopen: {url}")
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json")
monkeypatch.setattr(config, "_get_auth_store_path", lambda: tmp_path / "auth.json")
old_cfg = config.cfg
old_mtime = config._cfg_mtime
old_cache = config._available_models_cache
old_cache_ts = config._available_models_cache_ts
old_cache_fp = config._available_models_cache_source_fingerprint
try:
config.cfg = {
"model": {"provider": "openai-codex", "default": "gpt-5.5"},
"providers": {},
"fallback_providers": [],
"custom_providers": [
{
"name": "Alpha Proxy",
"base_url": "https://alpha.example/v1",
"api_key": "alpha-key",
"model": "alpha/sticky",
},
{
"name": "Beta Proxy",
"base_url": "https://beta.example/v1",
"api_key": "beta-key",
"model": "beta/sticky",
},
],
}
config._cfg_mtime = 0.0
config._available_models_cache = None
config._available_models_cache_ts = 0.0
config._available_models_cache_source_fingerprint = None
data = config.get_available_models()
finally:
config.cfg = old_cfg
config._cfg_mtime = old_mtime
config._available_models_cache = old_cache
config._available_models_cache_ts = old_cache_ts
config._available_models_cache_source_fingerprint = old_cache_fp
groups = {group["provider_id"]: group for group in data["groups"]}
assert "custom:alpha-proxy" in groups
assert "custom:beta-proxy" in groups
alpha_ids = {model["id"] for model in groups["custom:alpha-proxy"]["models"]}
beta_ids = {model["id"] for model in groups["custom:beta-proxy"]["models"]}
assert "@custom:alpha-proxy:alpha/remote" in alpha_ids
assert "@custom:alpha-proxy:alpha/sticky" in alpha_ids
assert "@custom:beta-proxy:beta/remote" in beta_ids
assert "@custom:beta-proxy:beta/sticky" in beta_ids
assert any("alpha.example/v1/models" in url for url in calls)
assert any("beta.example/v1/models" in url for url in calls)