diff --git a/CHANGELOG.md b/CHANGELOG.md index 185f15f1..51ed4b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Fixed + +- **PR #2626** by @Michaelyklam (closes #2540) — Surface named custom-provider `/models` endpoint failures in the model picker instead of silently showing an empty provider group. `/api/models` now carries a per-provider `models_endpoint_error` hint for auth, network, and HTTP failures while preserving configured fallback models. ## [v0.51.95] — 2026-05-20 — Release BS (stage-388 — 5-PR batch — live tool callback event dedup + browser-only dashboard links + messaging transcript merge alignment + Geist Contrast skin + SSE runtime diagnostics) diff --git a/api/config.py b/api/config.py index 9edc12bb..225c8e06 100644 --- a/api/config.py +++ b/api/config.py @@ -3169,18 +3169,45 @@ def get_available_models() -> dict: models.append({"id": model_id, "label": label}) return models + def _custom_endpoint_error( + provider: str, + exc: Exception, + *, + code: int | None = None, + ) -> dict: + provider_label = str(provider or "custom").replace("custom:", "") + status_code = code if code is not None else getattr(exc, "code", None) + if status_code in (401, 403): + return { + "kind": "auth", + "code": int(status_code), + "message": f"Models endpoint returned {status_code} — check the API key for {provider_label}.", + } + if isinstance(status_code, int): + return { + "kind": "http", + "code": int(status_code), + "message": f"Models endpoint returned {status_code} for {provider_label}; see logs.", + } + return { + "kind": "network", + "code": None, + "message": f"Models endpoint unreachable for {provider_label}; verify base_url.", + } + def _read_custom_endpoint_models( base_url: object, provider: str, *, api_key: object = "", trusted_base_urls: tuple[object, ...] = (), - ) -> list[dict]: + ) -> tuple[list[dict], dict | None]: base = str(base_url or "").strip() if not base: - return [] + return [], None try: import ipaddress + import urllib.error import urllib.request import socket @@ -3226,10 +3253,15 @@ def get_available_models() -> dict: req.add_header(k, v) with urllib.request.urlopen(req, timeout=10) as response: # nosec B310 data = json.loads(response.read().decode("utf-8")) - return _extract_model_entries_from_payload(data, provider) - except Exception: - logger.debug("Custom endpoint unreachable or misconfigured for provider: %s", provider) - return [] + return _extract_model_entries_from_payload(data, provider), None + except urllib.error.HTTPError as exc: + error = _custom_endpoint_error(provider, exc, code=getattr(exc, "code", None)) + logger.debug("Custom endpoint models fetch failed for provider %s: %s", provider, error) + return [], error + except Exception as exc: + error = _custom_endpoint_error(provider, exc) + logger.debug("Custom endpoint unreachable or misconfigured for provider %s: %s", provider, error) + return [], error # 4. Fetch models from custom endpoint if base_url is configured auto_detected_models = [] @@ -3300,12 +3332,13 @@ def get_available_models() -> dict: for _cp in _custom_providers_for_trust if isinstance(_cp, dict) and _cp.get("base_url") ) - for auto_model in _read_custom_endpoint_models( + _active_endpoint_models, _active_endpoint_error = _read_custom_endpoint_models( base_url, provider, api_key=api_key, trusted_base_urls=tuple(_trusted_custom_bases), - ): + ) + for auto_model in _active_endpoint_models: auto_detected_models.append(auto_model) provider_key = provider.lower() auto_detected_models_by_provider.setdefault(provider_key, []).append(auto_model) @@ -3313,6 +3346,7 @@ def get_available_models() -> dict: _custom_providers_cfg = cfg.get("custom_providers", []) _named_custom_groups: dict = {} + _named_custom_errors: dict[str, dict] = {} if isinstance(_custom_providers_cfg, list): _seen_custom_ids = set() for _cp in _custom_providers_cfg: @@ -3330,12 +3364,18 @@ def get_available_models() -> dict: _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,), - ) + _live_models = auto_detected_models_by_provider.get(_slug) + _live_error = None + if _live_models is None: + _live_models, _live_error = _read_custom_endpoint_models( + _cp_base_url, + _slug, + api_key=_cp_api_key, + trusted_base_urls=(_cp_base_url,), + ) + if _live_error: + _named_custom_errors[_slug] = _live_error + detected_providers.add(_slug) for _live_model in _live_models: _live_id = str(_live_model.get("id") or "").strip() if not _live_id: @@ -3471,8 +3511,11 @@ def get_available_models() -> dict: # changes (e.g. supporting model-less custom_providers entries). if not _nc_models: _nc_models = auto_detected_models_by_provider.get(pid, []) - if _nc_models: - groups.append({"provider": _nc_display, "provider_id": pid, "models": _nc_models}) + if _nc_models or pid in _named_custom_errors: + group = {"provider": _nc_display, "provider_id": pid, "models": _nc_models} + if pid in _named_custom_errors: + group["models_endpoint_error"] = _named_custom_errors[pid] + groups.append(group) continue provider_name = _PROVIDER_DISPLAY.get(pid, pid.title()) if pid == "openrouter": diff --git a/docs/pr-media/2540/model-endpoint-error-hint.png b/docs/pr-media/2540/model-endpoint-error-hint.png new file mode 100644 index 00000000..65c459d9 Binary files /dev/null and b/docs/pr-media/2540/model-endpoint-error-hint.png differ diff --git a/static/style.css b/static/style.css index 8a84a24d..46a5d236 100644 --- a/static/style.css +++ b/static/style.css @@ -1932,6 +1932,7 @@ .model-opt-badge--primary{background:rgba(50,184,198,.16);border-color:rgba(50,184,198,.32);color:#8fe7ef;} .model-opt-badge--fallback{background:rgba(255,184,77,.14);border-color:rgba(255,184,77,.28);color:#ffd18a;} .model-opt-id{display:block;font-size:10px;color:var(--muted);line-height:1.3;opacity:.72;word-break:break-word;} +.model-provider-hint{margin:2px 14px 6px;padding:7px 9px;border:1px solid var(--border2);border-radius:7px;background:color-mix(in srgb,var(--surface) 90%,var(--warning));color:var(--muted);font-size:11px;line-height:1.35;} .model-opt-provider{display:inline-flex;align-items:center;padding:1px 6px;border-radius:4px;font-size:9px;font-weight:600;letter-spacing:.03em;color:var(--muted);background:rgba(255,255,255,.05);border:1px solid var(--border2);margin-left:auto;white-space:nowrap;flex-shrink:0;} .model-custom-sep{padding-top:4px;border-top:1px solid var(--border);margin-top:4px;} .model-custom-row{display:flex;align-items:center;gap:6px;padding:6px 10px 8px;} diff --git a/static/ui.js b/static/ui.js index 76a44fae..52af6eb5 100644 --- a/static/ui.js +++ b/static/ui.js @@ -915,6 +915,9 @@ async function populateModelDropdown(){ // Per-page-load — not synced across browser tabs. window._defaultModel=data.default_model||null; window._configuredModelBadges=data.configured_model_badges||{}; + // Keep the g.extra_models label hydration path below in this function; tests + // assert populateModelDropdown preserves that full-catalog label contract. + window._modelEndpointErrors={}; const _synthGroupsFromConfigured=()=>{ const badgeMap=window._configuredModelBadges||{}; @@ -964,6 +967,11 @@ async function populateModelDropdown(){ const og=document.createElement('optgroup'); og.label=g.provider; if(g.provider_id) og.dataset.provider=g.provider_id; + if(g.models_endpoint_error){ + const errorKey=g.provider_id||g.provider||''; + og.dataset.modelsEndpointError=JSON.stringify(g.models_endpoint_error); + if(errorKey) window._modelEndpointErrors[errorKey]=g.models_endpoint_error; + } for(const m of (Array.isArray(g.models)?g.models:[])){ const opt=document.createElement('option'); opt.value=m.id; @@ -1218,12 +1226,19 @@ function renderModelDropdown(){ for(const child of Array.from(sel.children)){ if(child.tagName==='OPTGROUP'){ const providerId=child.dataset&&child.dataset.provider?child.dataset.provider:''; + let modelsEndpointError=null; + if(child.dataset&&child.dataset.modelsEndpointError){ + try{ modelsEndpointError=JSON.parse(child.dataset.modelsEndpointError); }catch(_e){ modelsEndpointError=null; } + } for(const opt of Array.from(child.children)){ const rawValue=String(opt.value||''); const displayName=rawValue.startsWith('@custom:') ? getModelLabel(rawValue) : (opt.textContent||getModelLabel(rawValue)); - _modelData.push({value:opt.value,name:esc(displayName),id:esc(opt.value),group:child.label||'',badge:_getConfiguredModelBadge(opt.value,_badgeMap,providerId)}); + _modelData.push({value:opt.value,name:esc(displayName),id:esc(opt.value),group:child.label||'',providerId,modelsEndpointError,badge:_getConfiguredModelBadge(opt.value,_badgeMap,providerId)}); + } + if(modelsEndpointError && !child.children.length){ + _modelData.push({value:`__models_endpoint_error__:${providerId||child.label||''}`,name:'',id:'',group:child.label||'',providerId,modelsEndpointError,endpointErrorOnly:true}); } } if(child.tagName==='OPTION'){ @@ -1362,8 +1377,17 @@ function renderModelDropdown(){ const _groupCounts={}; for(const m of _modelData){ if(configuredIds.has(m.value)) continue; - if(m.group) _groupCounts[m.group]=(_groupCounts[m.group]||0)+1; + if(m.group&&!m.endpointErrorOnly) _groupCounts[m.group]=(_groupCounts[m.group]||0)+1; } + const _renderProviderEndpointHint=(groupName)=>{ + if(!groupName) return; + const entry=_modelData.find(m=>m.group===groupName&&m.modelsEndpointError); + if(!entry||!entry.modelsEndpointError) return; + const hint=document.createElement('div'); + hint.className='model-provider-hint'; + hint.textContent=entry.modelsEndpointError.message||'Models endpoint could not be reached for this provider.'; + dd.appendChild(hint); + }; for(const m of _modelData){ if(configuredIds.has(m.value)||!matches(m)) continue; if(m.group&&m.group!==_lastGroup){ @@ -1372,8 +1396,10 @@ function renderModelDropdown(){ const count=_groupCounts[m.group]||0; heading.textContent=count>1?`${m.group} (${count})`:m.group; dd.appendChild(heading); + _renderProviderEndpointHint(m.group); _lastGroup=m.group; } + if(m.endpointErrorOnly) continue; const row=document.createElement('div'); row.className='model-opt'+(m.value===sel.value?' active':''); const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')}`:''; diff --git a/tests/test_issue2540_models_endpoint_error.py b/tests/test_issue2540_models_endpoint_error.py new file mode 100644 index 00000000..89dce0a0 --- /dev/null +++ b/tests/test_issue2540_models_endpoint_error.py @@ -0,0 +1,124 @@ +import urllib.error +import urllib.request +from email.message import Message + +from api import config + + +class _ConfigState: + def __enter__(self): + self.old_cfg = config.cfg + self.old_mtime = config._cfg_mtime + self.old_cache = config._available_models_cache + self.old_cache_ts = config._available_models_cache_ts + self.old_cache_fp = config._available_models_cache_source_fingerprint + config._cfg_mtime = 0.0 + config._available_models_cache = None + config._available_models_cache_ts = 0.0 + config._available_models_cache_source_fingerprint = None + return self + + def __exit__(self, exc_type, exc, tb): + config.cfg = self.old_cfg + config._cfg_mtime = self.old_mtime + config._available_models_cache = self.old_cache + config._available_models_cache_ts = self.old_cache_ts + config._available_models_cache_source_fingerprint = self.old_cache_fp + return False + + +def _configure_named_custom_provider(tmp_path, monkeypatch, *, model=None): + monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") + monkeypatch.setattr(config, "_get_auth_store_path", lambda: tmp_path / "auth.json") + entry = { + "name": "Broken Proxy", + "base_url": "https://broken.example/v1", + "api_key": "bad-key", + } + if model: + entry["model"] = model + config.cfg = { + "model": {"provider": "openai-codex", "default": "gpt-5.5"}, + "providers": {}, + "fallback_providers": [], + "custom_providers": [entry], + } + + +def _groups_by_provider(data): + return {group["provider_id"]: group for group in data["groups"]} + + +def test_named_custom_provider_models_endpoint_401_surfaces_error(monkeypatch, tmp_path): + def fake_urlopen(req, timeout=10): + raise urllib.error.HTTPError( + getattr(req, "full_url", "https://broken.example/v1/models"), + 401, + "Unauthorized", + hdrs=Message(), + fp=None, + ) + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + with _ConfigState(): + _configure_named_custom_provider(tmp_path, monkeypatch, model="broken/manual") + data = config.get_available_models() + + group = _groups_by_provider(data)["custom:broken-proxy"] + error = group["models_endpoint_error"] + assert error["kind"] == "auth" + assert error["code"] == 401 + assert "check the API key" in error["message"] + assert "broken-proxy" in error["message"] + assert "@custom:broken-proxy:broken/manual" in {m["id"] for m in group["models"]} + + +def test_named_custom_provider_models_endpoint_network_error_surfaces_empty_group(monkeypatch, tmp_path): + def fake_urlopen(req, timeout=10): + raise urllib.error.URLError("connection refused") + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + with _ConfigState(): + _configure_named_custom_provider(tmp_path, monkeypatch) + data = config.get_available_models() + + group = _groups_by_provider(data)["custom:broken-proxy"] + assert group["models"] == [] + assert group["models_endpoint_error"]["kind"] == "network" + assert group["models_endpoint_error"]["code"] is None + assert "verify base_url" in group["models_endpoint_error"]["message"] + + +def test_named_custom_provider_models_endpoint_5xx_preserves_status(monkeypatch, tmp_path): + def fake_urlopen(req, timeout=10): + raise urllib.error.HTTPError( + getattr(req, "full_url", "https://broken.example/v1/models"), + 502, + "Bad Gateway", + hdrs=Message(), + fp=None, + ) + + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + with _ConfigState(): + _configure_named_custom_provider(tmp_path, monkeypatch, model="broken/manual") + data = config.get_available_models() + + error = _groups_by_provider(data)["custom:broken-proxy"]["models_endpoint_error"] + assert error["kind"] == "http" + assert error["code"] == 502 + assert "returned 502" in error["message"] + + +def test_frontend_model_picker_renders_provider_endpoint_hint(): + ui = open("static/ui.js", encoding="utf-8").read() + css = open("static/style.css", encoding="utf-8").read() + + assert "models_endpoint_error" in ui + assert "dataset.modelsEndpointError" in ui + assert "model-provider-hint" in ui + assert "entry.modelsEndpointError.message" in ui + assert ".model-provider-hint" in css