273 lines
8.6 KiB
Python
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"))
|