Add endpoint probing behavior tests
ROADMAP "Backend → more tests around endpoint probing and provider setup".
TestSetupProbeSafety already covers _probe_endpoint's keyed/unkeyed curated
fallback; this adds the rest of the probe surface, with httpx faked the same
way (no network):
- _probe_endpoint: OpenAI {"data"} vs native Ollama {"models"} list parsing,
the /api/tags fallback for Ollama builds lacking /v1/models, and the
no-models-found result.
- _ping_endpoint (previously untested): 2xx reachable, auth failure (reached
but not reachable), the /login-redirect "that's Odysseus, not a model
server" trap, generic redirects, transport errors, and the native Ollama
/api/version fallback.
- _probe_single_model (previously untested): ok/fail/timeout status mapping,
dict/string upstream error extraction, and OpenAI vs Anthropic request
routing (x-api-key, /v1/messages, tool schema).
- _classify_endpoint: the Tailscale CGNAT 100.64.0.0/10 local range and its
boundaries.
This commit is contained in:
301
tests/test_endpoint_probing.py
Normal file
301
tests/test_endpoint_probing.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Endpoint probing behaviour (REAL routes.model_routes helpers).
|
||||
|
||||
ROADMAP "Backend → more tests around endpoint probing and provider setup".
|
||||
TestSetupProbeSafety in test_model_routes.py already covers the keyed-vs-unkeyed
|
||||
curated-fallback safety of `_probe_endpoint`. This module pins the rest of the
|
||||
probe surface that drives endpoint setup and degraded-state reporting:
|
||||
|
||||
* `_probe_endpoint` — OpenAI vs native-Ollama model-list parsing, the
|
||||
/api/tags fallback for Ollama builds without /v1/models, and the
|
||||
no-models-found result.
|
||||
* `_ping_endpoint` — reachability classification: 2xx, auth failures,
|
||||
the "this is Odysseus, not a model server" /login-redirect trap, generic
|
||||
redirects, transport errors, and the native-Ollama /api/version fallback.
|
||||
* `_probe_single_model` — ok/fail/timeout status mapping, upstream error-body
|
||||
extraction, and per-provider (OpenAI / Anthropic) request routing.
|
||||
* `_classify_endpoint` — the Tailscale CGNAT (100.64.0.0/10) "local" range.
|
||||
|
||||
HTTP is faked by monkeypatching `model_routes.httpx.{get,post}`, mirroring the
|
||||
established pattern in test_model_routes.py — no network, no server.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
# Match test_model_routes.py: if another test stubbed src.endpoint_resolver
|
||||
# during collection, drop the stub so the real URL helpers load here.
|
||||
_endpoint_resolver = sys.modules.get("src.endpoint_resolver")
|
||||
if _endpoint_resolver is not None and not getattr(_endpoint_resolver, "__file__", None):
|
||||
sys.modules.pop("src.endpoint_resolver", None)
|
||||
sys.modules.pop("routes.model_routes", None)
|
||||
|
||||
if "core.database" not in sys.modules:
|
||||
_core_db = types.ModuleType("core.database")
|
||||
for _name in [
|
||||
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
|
||||
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note",
|
||||
"CalendarCal", "CalendarEvent", "ScheduledTask", "TaskRun", "McpServer",
|
||||
]:
|
||||
setattr(_core_db, _name, MagicMock())
|
||||
sys.modules["core.database"] = _core_db
|
||||
|
||||
import routes.model_routes as model_routes
|
||||
import src.endpoint_resolver as endpoint_resolver
|
||||
from routes.model_routes import (
|
||||
_probe_endpoint,
|
||||
_ping_endpoint,
|
||||
_probe_single_model,
|
||||
_classify_endpoint,
|
||||
_PROVIDER_CURATED,
|
||||
)
|
||||
|
||||
|
||||
def _patch_resolve(monkeypatch):
|
||||
"""Neutralize DNS/Tailscale resolution and base normalization."""
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url, raising=False)
|
||||
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url.rstrip("/"))
|
||||
|
||||
|
||||
def _resp(status, *, json=None, headers=None, url="https://api.example.com/v1/models"):
|
||||
"""Build an httpx.Response with a request attached (so raise_for_status works)."""
|
||||
req = httpx.Request("GET", url)
|
||||
kwargs = {"request": req}
|
||||
if json is not None:
|
||||
kwargs["json"] = json
|
||||
if headers is not None:
|
||||
kwargs["headers"] = headers
|
||||
return httpx.Response(status, **kwargs)
|
||||
|
||||
|
||||
# ── _probe_endpoint: model-list parsing ──
|
||||
|
||||
class TestProbeEndpointParsing:
|
||||
def test_parses_openai_data_format(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
model_routes.httpx, "get",
|
||||
lambda url, headers=None, timeout=None: _resp(
|
||||
200, json={"data": [{"id": "gpt-4o"}, {"id": "gpt-4o-mini"}]}),
|
||||
)
|
||||
assert _probe_endpoint("https://api.example.com/v1", "key") == ["gpt-4o", "gpt-4o-mini"]
|
||||
|
||||
def test_parses_ollama_models_format(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
# No OpenAI-style "data"; fall back to the native {"models": [...]} shape,
|
||||
# honoring both the "name" and "model" keys.
|
||||
monkeypatch.setattr(
|
||||
model_routes.httpx, "get",
|
||||
lambda url, headers=None, timeout=None: _resp(
|
||||
200, json={"models": [{"name": "llama3:8b"}, {"model": "qwen3:4b"}]}),
|
||||
)
|
||||
assert _probe_endpoint("https://api.example.com/v1") == ["llama3:8b", "qwen3:4b"]
|
||||
|
||||
def test_falls_back_to_native_ollama_tags(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
seen = []
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
seen.append(url)
|
||||
if url.endswith("/api/tags"):
|
||||
return _resp(200, json={"models": [{"name": "llama3:8b"}]})
|
||||
# This Ollama build has no OpenAI-compatible /v1/models surface.
|
||||
return _resp(404)
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
|
||||
assert _probe_endpoint("http://localhost:11434/v1") == ["llama3:8b"]
|
||||
assert "http://localhost:11434/v1/models" in seen
|
||||
assert "http://localhost:11434/api/tags" in seen
|
||||
|
||||
def test_empty_list_with_no_curation_returns_empty(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
model_routes.httpx, "get",
|
||||
lambda url, headers=None, timeout=None: _resp(200, json={"data": []}),
|
||||
)
|
||||
assert _probe_endpoint("https://api.example.com/v1") == []
|
||||
|
||||
|
||||
# ── _ping_endpoint: reachability classification ──
|
||||
|
||||
class TestPingEndpoint:
|
||||
def test_reachable_on_2xx(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
model_routes.httpx, "get",
|
||||
lambda url, headers=None, timeout=None: _resp(200),
|
||||
)
|
||||
assert _ping_endpoint("https://api.example.com/v1", "key") == {
|
||||
"reachable": True, "status_code": 200, "error": None,
|
||||
}
|
||||
|
||||
def test_auth_failure_is_reached_but_not_reachable(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
# A 401 means the server answered — surface the status, not "offline".
|
||||
monkeypatch.setattr(
|
||||
model_routes.httpx, "get",
|
||||
lambda url, headers=None, timeout=None: _resp(401),
|
||||
)
|
||||
assert _ping_endpoint("https://api.example.com/v1", "bad") == {
|
||||
"reachable": False, "status_code": 401, "error": "HTTP 401",
|
||||
}
|
||||
|
||||
def test_detects_odysseus_login_redirect(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
return _resp(302, headers={"location": "/login?next=/"})
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
|
||||
result = _ping_endpoint("http://localhost:8080/v1")
|
||||
assert result["reachable"] is False
|
||||
assert result["status_code"] == 302
|
||||
assert "not a model server" in result["error"]
|
||||
|
||||
def test_generic_redirect_reported(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
return _resp(301, headers={"location": "https://elsewhere.example/"})
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
|
||||
assert _ping_endpoint("https://api.example.com/v1") == {
|
||||
"reachable": False, "status_code": 301, "error": "HTTP 301 redirect",
|
||||
}
|
||||
|
||||
def test_transport_error_is_unreachable(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
raise httpx.ConnectError("Connection refused")
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
|
||||
result = _ping_endpoint("https://api.example.com/v1")
|
||||
assert result["reachable"] is False
|
||||
assert result["status_code"] is None
|
||||
assert "Connection refused" in result["error"]
|
||||
|
||||
def test_ollama_native_version_fallback(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
if url.endswith("/api/version"):
|
||||
return _resp(200)
|
||||
# The OpenAI-compatible /v1/models surface is down on this build.
|
||||
return _resp(500)
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
|
||||
assert _ping_endpoint("http://localhost:11434/v1") == {
|
||||
"reachable": True, "status_code": 200, "error": None,
|
||||
}
|
||||
|
||||
|
||||
# ── _probe_single_model: completion probe ──
|
||||
|
||||
class TestProbeSingleModel:
|
||||
def test_ok_on_success(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
captured["url"] = url
|
||||
return _resp(200, json={"choices": [{"message": {"content": "OK"}}]})
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "post", fake_post)
|
||||
result = _probe_single_model("https://api.example.com/v1", "key", "gpt-4o")
|
||||
assert result["status"] == "ok"
|
||||
assert "latency_ms" in result
|
||||
assert captured["url"] == "https://api.example.com/v1/chat/completions"
|
||||
|
||||
def test_extracts_dict_error_message(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
model_routes.httpx, "post",
|
||||
lambda url, headers=None, json=None, timeout=None: _resp(
|
||||
400, json={"error": {"message": "model not found"}}),
|
||||
)
|
||||
result = _probe_single_model("https://api.example.com/v1", "key", "ghost")
|
||||
assert result["status"] == "fail"
|
||||
assert result["error"] == "model not found"
|
||||
|
||||
def test_extracts_string_error(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
model_routes.httpx, "post",
|
||||
lambda url, headers=None, json=None, timeout=None: _resp(
|
||||
403, json={"error": "forbidden"}),
|
||||
)
|
||||
result = _probe_single_model("https://api.example.com/v1", "key", "m")
|
||||
assert result["status"] == "fail"
|
||||
assert result["error"] == "forbidden"
|
||||
|
||||
def test_timeout(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
raise httpx.TimeoutException("timed out")
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "post", fake_post)
|
||||
result = _probe_single_model("https://api.example.com/v1", "key", "m", timeout=7)
|
||||
assert result["status"] == "timeout"
|
||||
assert "7s" in result["error"]
|
||||
|
||||
def test_transport_error_is_fail(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
raise httpx.ConnectError("refused")
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "post", fake_post)
|
||||
result = _probe_single_model("https://api.example.com/v1", "key", "m")
|
||||
assert result["status"] == "fail"
|
||||
assert "refused" in result["error"]
|
||||
|
||||
def test_routes_anthropic_messages_with_x_api_key(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
captured.update(url=url, headers=headers, payload=json)
|
||||
return _resp(200, json={"content": [{"type": "text", "text": "OK"}]})
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "post", fake_post)
|
||||
result = _probe_single_model("https://api.anthropic.com/v1", "sk-ant", "claude-sonnet-4-5")
|
||||
assert result["status"] == "ok"
|
||||
assert captured["url"] == "https://api.anthropic.com/v1/messages"
|
||||
assert captured["headers"].get("x-api-key") == "sk-ant"
|
||||
assert captured["payload"]["model"] == "claude-sonnet-4-5"
|
||||
|
||||
def test_with_tools_sends_anthropic_tool_schema(self, monkeypatch):
|
||||
_patch_resolve(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, headers=None, json=None, timeout=None):
|
||||
captured["payload"] = json
|
||||
return _resp(200, json={"content": []})
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "post", fake_post)
|
||||
_probe_single_model("https://api.anthropic.com/v1", "sk-ant", "claude-sonnet-4-5", with_tools=True)
|
||||
assert "input_schema" in captured["payload"]["tools"][0]
|
||||
|
||||
|
||||
# ── _classify_endpoint: Tailscale CGNAT range ──
|
||||
|
||||
class TestClassifyEndpointTailscale:
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://100.64.0.1:11434/v1", # bottom of 100.64.0.0/10
|
||||
"http://100.100.50.20:8080/v1",
|
||||
"http://100.127.255.254/v1", # top of the range
|
||||
])
|
||||
def test_cgnat_range_is_local(self, url):
|
||||
assert _classify_endpoint(url) == "local"
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"http://100.63.255.255/v1", # just below 100.64.0.0/10
|
||||
"http://100.128.0.1/v1", # just above
|
||||
"https://api.openai.com/v1", # public hostname
|
||||
])
|
||||
def test_outside_cgnat_is_api(self, url):
|
||||
assert _classify_endpoint(url) == "api"
|
||||
Reference in New Issue
Block a user