fix(model-routes): harden _probe_endpoint against malformed model-list responses (#4789)

* fix(model-routes): harden _probe_endpoint against malformed model-list responses

_probe_endpoint parsed model lists with data.get(...) at four sites without
checking that data is a dict, and built the list with a truthiness-only
filter. A /models (or /api/tags) endpoint returning HTTP 200 with valid but
non-dict JSON ([], "x", null, 123) made data.get(...) raise AttributeError,
and a non-string id like 123 passed the filter and then hit .startswith() /
.lower() in the Z.AI/Kimi curated merge and _is_chat_model(). Both errors are
swallowed by the broad except Exception, but the comprehension dies mid-list
so the ENTIRE probed model list is discarded and the endpoint silently
degrades — masking a misconfigured/non-compliant upstream as "no models".

- Guard each data.get(...) with isinstance(data, dict) so a non-dict body
  falls through the existing `or []` default.
- Restrict the OpenAI and Ollama model-list comprehensions to non-empty str
  values, protecting the .startswith() merges and both _is_chat_model calls.
- Add an isinstance guard at the top of _is_chat_model (defense in depth for
  all four call sites).

No behavior change for well-formed {"data":[...]} / {"models":[...]}
responses. Adds regression tests (non-dict body via caplog, mixed/all
non-string ids, _is_chat_model boundary) that fail before the fix and pass
after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(model-routes): extract _openai_model_ids / _ollama_model_names helpers

Per review on #4789: the malformed-response guards were inlined four times in
_probe_endpoint (two OpenAI-id comprehensions, two Ollama-name comprehensions).
Pull each into a small, directly-testable helper so the security-relevant
parsing lives in one place and a future malformed-shape fix doesn't have to be
applied in four spots (CONTRIBUTING flags repeated logic for this reason).

Behavior is unchanged. Adds direct unit tests for both helpers (non-dict body,
non-string ids, non-dict entries, name>model precedence).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Solanki Sumit
2026-06-24 22:35:31 +05:30
committed by GitHub
parent 4e46e415ea
commit 22379fe736
3 changed files with 108 additions and 4 deletions
+66
View File
@@ -53,6 +53,8 @@ with preserve_import_state("core.database", "src.database", "core.session_manage
_resolve_probe_key,
_classify_endpoint,
_rewrite_loopback_for_docker,
_openai_model_ids,
_ollama_model_names,
_PROVIDER_CURATED,
)
@@ -74,6 +76,33 @@ def _resp(status, *, json=None, headers=None, url="https://api.example.com/v1/mo
return httpx.Response(status, **kwargs)
# ── _openai_model_ids / _ollama_model_names: parsing helpers ──
class TestModelListHelpers:
@pytest.mark.parametrize("data,expected", [
({"data": [{"id": "gpt-4o"}, {"id": "gpt-4o-mini"}]}, ["gpt-4o", "gpt-4o-mini"]),
({"data": [{"id": None}, {"id": 123}, {"id": "gpt-4o"}]}, ["gpt-4o"]), # non-string ids dropped
({"data": ["x", {"id": "ok"}]}, ["ok"]), # non-dict entries dropped
({"data": []}, []),
({"data": "oops"}, []), # non-list "data"
([], []), ("nope", []), (None, []), (123, []), # non-dict body
])
def test_openai_model_ids(self, data, expected):
assert _openai_model_ids(data) == expected
@pytest.mark.parametrize("data,expected", [
({"models": [{"name": "llama3:8b"}, {"model": "qwen3:4b"}]}, ["llama3:8b", "qwen3:4b"]),
({"models": [{"name": "a", "model": "b"}]}, ["a"]), # name precedence over model
({"models": [{"name": 123}, {"model": None}, {"name": "ok"}]}, ["ok"]), # non-string values dropped
({"models": ["x", {"name": "ok"}]}, ["ok"]), # non-dict entries dropped
({"models": []}, []),
({"models": "oops"}, []),
([], []), (None, []), (42, []), # non-dict body
])
def test_ollama_model_names(self, data, expected):
assert _ollama_model_names(data) == expected
# ── _probe_endpoint: model-list parsing ──
class TestProbeEndpointParsing:
@@ -121,6 +150,43 @@ class TestProbeEndpointParsing:
)
assert _probe_endpoint("https://api.example.com/v1") == []
@pytest.mark.parametrize("body", [[], "invalid", 123, True])
def test_non_dict_json_body_degrades_to_empty(self, monkeypatch, caplog, body):
# HTTP 200 with valid-but-non-dict JSON must not crash the probe with an
# AttributeError (data.get(...) on a list/str/int); it should fall through
# to the empty/curated path. caplog gives this test teeth: pre-fix the
# swallowed AttributeError logs "Failed to probe"; post-fix it does not.
_patch_resolve(monkeypatch)
monkeypatch.setattr(
model_routes.httpx, "get",
lambda url, headers=None, timeout=None, verify=None, **kwargs: _resp(200, json=body),
)
with caplog.at_level("WARNING", logger="routes.model_routes"):
assert _probe_endpoint("https://api.example.com/v1") == []
assert "Failed to probe" not in caplog.text
def test_skips_non_string_model_ids(self, monkeypatch):
# A non-compliant upstream returns int/None IDs alongside a valid one.
# The probe must not crash on .lower()/.startswith and must still surface
# the valid string model.
_patch_resolve(monkeypatch)
monkeypatch.setattr(
model_routes.httpx, "get",
lambda url, headers=None, timeout=None, verify=None, **kwargs: _resp(
200, json={"data": [{"id": None}, {"id": 123}, {"id": "gpt-4o"}]}),
)
assert _probe_endpoint("https://api.example.com/v1", "key") == ["gpt-4o"]
def test_all_non_string_ids_returns_empty(self, monkeypatch):
# Every id is non-string -> empty result, no exception, no curated leak.
_patch_resolve(monkeypatch)
monkeypatch.setattr(
model_routes.httpx, "get",
lambda url, headers=None, timeout=None, verify=None, **kwargs: _resp(
200, json={"data": [{"id": 123}, {"id": None}]}),
)
assert _probe_endpoint("https://api.example.com/v1") == []
def test_chatgpt_subscription_probe_uses_discovery_only(self, monkeypatch):
_patch_resolve(monkeypatch)
calls = []
+6
View File
@@ -403,6 +403,12 @@ class TestIsChatModel:
def test_legacy_openai_instruct_is_not_chat(self):
assert _is_chat_model("gpt-3.5-turbo-instruct") is False
@pytest.mark.parametrize("bad", [None, 123, 4.5, ["x"], {"a": 1}])
def test_non_string_id_is_treated_as_chat(self, bad):
# Defensive boundary: a non-compliant upstream can yield a non-string
# model id; it must not crash on .lower() (treated as chat-capable).
assert _is_chat_model(bad) is True
# ── _classify_endpoint ──