first commit
This commit is contained in:
206
backend/tests/test_llm_client.py
Normal file
206
backend/tests/test_llm_client.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Тесты `OpenAICompatClient` на мок-транспорте `httpx.MockTransport` (без сети).
|
||||
|
||||
Покрывает: успешный запрос, retry после 5xx и после сетевой ошибки → успех,
|
||||
исчерпание попыток → `LlmUnavailableError`, открытие circuit breaker после
|
||||
серии неудач (следующий вызов падает без HTTP-запроса) и его закрытие после
|
||||
`breaker_cooldown_s`, а также явное закрытие базового `httpx.Client`
|
||||
(`close()` и поддержка контекстного менеджера).
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from core.summarization import llm_client as llm_client_module
|
||||
from core.summarization.llm_client import LlmUnavailableError, OpenAICompatClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fast_backoff(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Свести паузы retry к нулю, чтобы тесты не ждали реальный backoff."""
|
||||
monkeypatch.setattr(llm_client_module, "_INITIAL_BACKOFF_S", 0.0)
|
||||
|
||||
|
||||
def _make_client(
|
||||
handler: Callable[[httpx.Request], httpx.Response], **kwargs: object
|
||||
) -> OpenAICompatClient:
|
||||
transport = httpx.MockTransport(handler)
|
||||
return OpenAICompatClient(
|
||||
base_url="http://llm.test/v1",
|
||||
model="qwen2.5-3b-instruct-q4_k_m",
|
||||
transport=transport,
|
||||
**kwargs, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _ok_response(content: str = "готовое резюме") -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"choices": [{"message": {"content": content}}]},
|
||||
)
|
||||
|
||||
|
||||
def test_complete_returns_content_on_success() -> None:
|
||||
calls: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request)
|
||||
assert request.url == "http://llm.test/v1/chat/completions"
|
||||
body = request.read()
|
||||
assert b'"role": "user"' in body or b'"role":"user"' in body
|
||||
return _ok_response("успех")
|
||||
|
||||
client = _make_client(handler)
|
||||
|
||||
result = client.complete("промпт")
|
||||
|
||||
assert result == "успех"
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_complete_uses_per_call_max_tokens_override() -> None:
|
||||
"""`max_tokens` явного вызова `complete()` перекрывает дефолт конструктора клиента
|
||||
(ADR-004: разные лимиты map/reduce у `QwenLocal`, один и тот же клиент)."""
|
||||
import json
|
||||
|
||||
captured: list[int] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.read())
|
||||
captured.append(body["max_tokens"])
|
||||
return _ok_response("ок")
|
||||
|
||||
client = _make_client(handler, max_tokens=1024)
|
||||
|
||||
client.complete("промпт map", max_tokens=1024)
|
||||
client.complete("промпт reduce", max_tokens=2048)
|
||||
client.complete("промпт без переопределения")
|
||||
|
||||
assert captured == [1024, 2048, 1024]
|
||||
|
||||
|
||||
def test_complete_retries_after_5xx_then_succeeds() -> None:
|
||||
attempts = {"count": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts["count"] += 1
|
||||
if attempts["count"] < 3:
|
||||
return httpx.Response(503)
|
||||
return _ok_response("успех после 503")
|
||||
|
||||
client = _make_client(handler, max_attempts=3)
|
||||
|
||||
result = client.complete("промпт")
|
||||
|
||||
assert result == "успех после 503"
|
||||
assert attempts["count"] == 3
|
||||
|
||||
|
||||
def test_complete_retries_after_network_error_then_succeeds() -> None:
|
||||
attempts = {"count": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts["count"] += 1
|
||||
if attempts["count"] == 1:
|
||||
raise httpx.ConnectError("соединение разорвано", request=request)
|
||||
return _ok_response("успех после обрыва сети")
|
||||
|
||||
client = _make_client(handler, max_attempts=3)
|
||||
|
||||
result = client.complete("промпт")
|
||||
|
||||
assert result == "успех после обрыва сети"
|
||||
assert attempts["count"] == 2
|
||||
|
||||
|
||||
def test_complete_raises_llm_unavailable_after_exhausting_attempts() -> None:
|
||||
attempts = {"count": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts["count"] += 1
|
||||
return httpx.Response(500)
|
||||
|
||||
client = _make_client(handler, max_attempts=3, breaker_threshold=100)
|
||||
|
||||
with pytest.raises(LlmUnavailableError):
|
||||
client.complete("промпт")
|
||||
|
||||
assert attempts["count"] == 3
|
||||
|
||||
|
||||
def test_breaker_opens_after_threshold_and_blocks_without_http_call() -> None:
|
||||
attempts = {"count": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts["count"] += 1
|
||||
return httpx.Response(500)
|
||||
|
||||
client = _make_client(handler, max_attempts=1, breaker_threshold=2, breaker_cooldown_s=60.0)
|
||||
|
||||
with pytest.raises(LlmUnavailableError):
|
||||
client.complete("промпт 1")
|
||||
with pytest.raises(LlmUnavailableError):
|
||||
client.complete("промпт 2")
|
||||
|
||||
assert attempts["count"] == 2 # обе неудачи дошли до сервера — порог достигнут
|
||||
|
||||
with pytest.raises(LlmUnavailableError):
|
||||
client.complete("промпт 3")
|
||||
|
||||
assert attempts["count"] == 2 # третий вызов не сделал HTTP-запрос — breaker открыт
|
||||
|
||||
|
||||
def test_breaker_closes_after_cooldown_elapses(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
attempts = {"count": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
attempts["count"] += 1
|
||||
if attempts["count"] <= 2:
|
||||
return httpx.Response(500)
|
||||
return _ok_response("успех после закрытия breaker")
|
||||
|
||||
client = _make_client(handler, max_attempts=1, breaker_threshold=2, breaker_cooldown_s=10.0)
|
||||
|
||||
with pytest.raises(LlmUnavailableError):
|
||||
client.complete("промпт 1")
|
||||
with pytest.raises(LlmUnavailableError):
|
||||
client.complete("промпт 2")
|
||||
assert attempts["count"] == 2
|
||||
|
||||
# Имитируем истечение окна отказа сдвигом монотонных часов вперёд (модуль
|
||||
# `time` — общий синглтон интерпретатора, поэтому патчим его напрямую:
|
||||
# изменение видно и внутри `core.summarization.llm_client`).
|
||||
real_monotonic = time.monotonic()
|
||||
monkeypatch.setattr(time, "monotonic", lambda: real_monotonic + 11.0)
|
||||
|
||||
result = client.complete("промпт 3")
|
||||
|
||||
assert result == "успех после закрытия breaker"
|
||||
|
||||
|
||||
def test_close_closes_underlying_http_client() -> None:
|
||||
"""`close()` закрывает базовый `httpx.Client` (освобождает пул соединений)."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return _ok_response()
|
||||
|
||||
client = _make_client(handler)
|
||||
|
||||
client.close()
|
||||
|
||||
assert client._client.is_closed
|
||||
|
||||
|
||||
def test_context_manager_closes_client_on_exit() -> None:
|
||||
"""`OpenAICompatClient` работает как контекстный менеджер, закрывающий клиент на выходе."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return _ok_response("успех в контексте")
|
||||
|
||||
with _make_client(handler) as client:
|
||||
assert client.complete("промпт") == "успех в контексте"
|
||||
assert not client._client.is_closed
|
||||
|
||||
assert client._client.is_closed
|
||||
Reference in New Issue
Block a user