fix: load remote models for named custom providers
This commit is contained in:
@@ -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
|
||||
|
||||
303
api/config.py
303
api/config.py
@@ -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:
|
||||
|
||||
101
tests/test_issue2513_custom_provider_remote_models.py
Normal file
101
tests/test_issue2513_custom_provider_remote_models.py
Normal 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)
|
||||
Reference in New Issue
Block a user