Stage 389: PR #2626

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
nesquena-hermes 2026-05-20 16:41:45 +00:00
commit dd36d09f89
5 changed files with 212 additions and 18 deletions

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View file

@ -1934,6 +1934,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;}

View file

@ -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?`<span class="model-opt-badge model-opt-badge--${esc(m.badge.role||'configured')}">${esc(m.badge.label||'Configured')}</span>`:'';

View file

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