Files
stack/tests/llm/test_client.py

273 lines
8.6 KiB
Python

"""``llm.client.LlmClient`` — httpx-only client for the llm service (#573).
All requests go through ``httpx.MockTransport``; no real network I/O.
"""
from __future__ import annotations
import json
import httpx
import pytest
from llm.client import LlmClient, _resolve_base_url
def _client(handler, **kw) -> LlmClient:
"""An LlmClient whose internal httpx.Client is wired to *handler*."""
c = LlmClient(base_url="http://llm-test:8000", **kw)
transport = httpx.MockTransport(handler)
c._client = lambda: httpx.Client( # noqa: SLF001 — test seam
base_url=c.base_url, timeout=c.timeout, transport=transport
)
return c
# ── base URL resolution ──────────────────────────────────────────────
def test_explicit_base_url_wins():
assert _resolve_base_url("http://example:9000/") == "http://example:9000"
def test_env_var_used_when_no_explicit_base_url(monkeypatch):
monkeypatch.setenv("LLM_URL", "http://from-env:1234/")
assert _resolve_base_url(None) == "http://from-env:1234"
def test_falls_back_to_compose_name_when_it_resolves(monkeypatch):
monkeypatch.delenv("LLM_URL", raising=False)
monkeypatch.setattr("llm.client.socket.gethostbyname", lambda host: "192.168.5.9")
assert _resolve_base_url(None) == "http://llm:8000"
def test_falls_back_to_localhost_when_compose_name_does_not_resolve(monkeypatch):
import socket
monkeypatch.delenv("LLM_URL", raising=False)
def _raise(host):
raise socket.gaierror("not found")
monkeypatch.setattr("llm.client.socket.gethostbyname", _raise)
assert _resolve_base_url(None) == "http://localhost:8000"
def test_default_constructor_resolves_base_url(monkeypatch):
monkeypatch.delenv("LLM_URL", raising=False)
monkeypatch.setattr(
"llm.client.socket.gethostbyname",
lambda host: (_ for _ in ()).throw(OSError("no")),
)
client = LlmClient()
assert client.base_url == "http://localhost:8000"
assert client.timeout == 30.0
# ── health ────────────────────────────────────────────────────────────
def test_health_true_on_ok():
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/health"
return httpx.Response(200, json={"status": "ok"})
assert _client(handler).health() is True
def test_health_false_on_bad_status():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(503, json={"status": "down"})
assert _client(handler).health() is False
def test_health_false_on_connect_error():
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused", request=request)
assert _client(handler).health() is False
def test_health_false_on_bad_json():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="not json")
assert _client(handler).health() is False
# ── search ────────────────────────────────────────────────────────────
def test_search_sends_expected_query_params_and_returns_results():
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["path"] = request.url.path
seen["params"] = dict(request.url.params)
return httpx.Response(
200,
json={
"query": "telehealth",
"filters": {},
"total": 1,
"results": [
{
"label": "CMS-2025-0304-1",
"url": "https://x",
"date": "2025-01-01",
"snippet": "hi",
}
],
},
)
results = _client(handler).search(
"telehealth",
collection="comments",
docket="CMS-2025-0304",
item_key="ABC123",
year=2025,
kind="comment",
limit=5,
offset=2,
)
assert seen["path"] == "/search"
assert seen["params"] == {
"q": "telehealth",
"collection": "comments",
"limit": "5",
"offset": "2",
"docket": "CMS-2025-0304",
"item_key": "ABC123",
"year": "2025",
"kind": "comment",
}
assert results == [
{
"label": "CMS-2025-0304-1",
"url": "https://x",
"date": "2025-01-01",
"snippet": "hi",
}
]
def test_search_omits_unset_optional_filters():
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["params"] = dict(request.url.params)
return httpx.Response(200, json={"results": []})
_client(handler).search("q")
assert seen["params"] == {
"q": "q",
"collection": "all",
"limit": "10",
"offset": "0",
}
def test_search_raises_httperror_on_bad_status():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(400, json={"detail": "unknown collection"})
with pytest.raises(httpx.HTTPError):
_client(handler).search("q", collection="nope")
def test_search_raises_httperror_on_connect_error():
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused", request=request)
with pytest.raises(httpx.HTTPError):
_client(handler).search("q")
# ── similar ───────────────────────────────────────────────────────────
def test_similar_hits_expected_path_and_returns_results():
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["path"] = request.url.path
seen["params"] = dict(request.url.params)
return httpx.Response(
200, json={"key": "ABC123", "total": 1, "results": [{"label": "x"}]}
)
results = _client(handler).similar("ABC123", collection="rules", limit=3)
assert seen["path"] == "/similar/ABC123"
assert seen["params"] == {"collection": "rules", "limit": "3"}
assert results == [{"label": "x"}]
def test_similar_raises_httperror_on_404():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(404, json={"detail": "no indexed chunks"})
with pytest.raises(httpx.HTTPError):
_client(handler).similar("nope")
# ── chat (SSE) ────────────────────────────────────────────────────────
def _sse_body(*events: dict) -> bytes:
return "".join(f"data: {json.dumps(e)}\n\n" for e in events).encode()
def test_chat_parses_sse_events_in_order():
events = [
{"type": "token", "text": "Hel"},
{"type": "token", "text": "lo"},
{"type": "done"},
]
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert json.loads(request.content) == {"question": "hi?", "mode": "auto"}
return httpx.Response(200, content=_sse_body(*events))
got = list(_client(handler).chat("hi?"))
assert got == events
def test_chat_includes_since_and_mode_when_given():
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["body"] = json.loads(request.content)
return httpx.Response(200, content=_sse_body({"type": "done"}))
list(_client(handler).chat("q", mode="timeline", since="2025-01-01"))
assert seen["body"] == {"question": "q", "mode": "timeline", "since": "2025-01-01"}
def test_chat_ignores_blank_and_non_data_lines():
raw = b': comment line\n\ndata: {"type": "done"}\n\n\n'
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=raw)
got = list(_client(handler).chat("q"))
assert got == [{"type": "done"}]
def test_chat_raises_httperror_on_bad_status():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(400, json={"detail": "empty question"})
with pytest.raises(httpx.HTTPError):
list(_client(handler).chat(""))
def test_chat_raises_httperror_on_connect_error():
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused", request=request)
with pytest.raises(httpx.HTTPError):
list(_client(handler).chat("q"))