Files
vidconf/backend/tests/test_llm_client.py

207 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Тесты `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