diff --git a/CHANGELOG.md b/CHANGELOG.md index c785188f..4a1e094b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Fixed - Dark-mode panel header save buttons now use a theme-aware foreground token, keeping workspace and other detail-pane check icons visible on the default gold accent. (#2998) +- Custom provider `/v1/models` discovery now uses a short per-endpoint timeout and gracefully skips slow or unreachable providers, reducing cold `/api/models` cache rebuild latency. (#3024) ## [v0.51.145] — 2026-05-26 — Release DQ (stage-batch27 — sidebar running-state preservation) diff --git a/api/config.py b/api/config.py index 103c9f21..34dd5e90 100644 --- a/api/config.py +++ b/api/config.py @@ -70,6 +70,12 @@ PROJECTS_FILE = STATE_DIR / "projects.json" logger = logging.getLogger(__name__) +# Keep custom provider /v1/models probes below the frontend's generic request +# timeout even when one upstream is slow or unreachable. The models cache rebuild +# path probes configured custom endpoints serially, so each provider needs a +# short hard cap and graceful degradation. +CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS = 5.0 + def _env_mb_bytes(name: str, default_mb: int) -> int: """Parse an optional megabyte environment variable into bytes. @@ -3653,7 +3659,7 @@ def get_available_models() -> dict: 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 + with urllib.request.urlopen(req, timeout=CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS) as response: # nosec B310 data = json.loads(response.read().decode("utf-8")) return _extract_model_entries_from_payload(data, provider), None except urllib.error.HTTPError as exc: diff --git a/tests/test_issue2540_models_endpoint_error.py b/tests/test_issue2540_models_endpoint_error.py index 89dce0a0..b83cd9bc 100644 --- a/tests/test_issue2540_models_endpoint_error.py +++ b/tests/test_issue2540_models_endpoint_error.py @@ -112,6 +112,25 @@ def test_named_custom_provider_models_endpoint_5xx_preserves_status(monkeypatch, assert error["code"] == 502 assert "returned 502" in error["message"] +def test_named_custom_provider_models_endpoint_network_error_uses_short_timeout(monkeypatch, tmp_path): + observed_timeouts = [] + + def fake_urlopen(req, timeout=10): + observed_timeouts.append(timeout) + raise urllib.error.URLError("timed out") + + 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 observed_timeouts == [config.CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS] + assert max(observed_timeouts) <= 5.0 + def test_frontend_model_picker_renders_provider_endpoint_hint(): ui = open("static/ui.js", encoding="utf-8").read()