gendesign/backend/tests/services/llm/test_client.py
bot-backend cdf493f345
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
2026-08-21 12:01:52 +00:00

315 lines
13 KiB
Python
Raw 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.

"""LLM client orchestration tests (#960).
Покрываем: disabled→fallback (БЕЗ сетевой попытки), no-key→fallback, call-cap,
redaction hard-block→fallback, happy-path (тело запроса проскраблено),
guardrails (timeout→fallback, 429-retry-then-fallback), complete_or_raise.
Сеть НЕ дёргается: либо внедряем fake-провайдера, либо ставим tripwire на httpx.Client.
"""
from __future__ import annotations
from typing import Any
import pytest
from app.core.config import settings
from app.services.llm import client as llm_client
from app.services.llm.client import LLMResult, LLMUnavailableError, complete, complete_or_raise
from app.services.llm.provider import (
LLMProvider,
LLMRateLimitedError,
LLMTimeoutError,
ProviderResponse,
ToolCall,
)
from app.services.llm.redaction import SafePayload
# ── Fake провайдеры (внедряются через provider=...) ────────────────────────────
class _FakeOpenAILike(LLMProvider):
"""Внешний (is_external=True) fake: записывает messages, отдаёт фикс. ответ."""
def __init__(self, response: ProviderResponse | None = None) -> None:
self.captured_messages: list[dict[str, Any]] | None = None
self.captured_tools: list[dict[str, Any]] | None = None
self.calls = 0
self._response = response or ProviderResponse(
content="ответ модели", prompt_tokens=10, completion_tokens=5, model="gpt-4o-mini"
)
@property
def is_external(self) -> bool:
return True
@property
def model(self) -> str:
return "gpt-4o-mini"
def complete(
self,
messages: list[dict[str, Any]],
*,
tools: list[dict[str, Any]] | None = None,
max_output_tokens: int,
) -> ProviderResponse:
self.calls += 1
self.captured_messages = messages
self.captured_tools = tools
return self._response
class _RaisingProvider(LLMProvider):
"""Внешний fake, всегда бросающий заданное исключение (для guardrail-тестов)."""
def __init__(self, exc: Exception) -> None:
self._exc = exc
self.calls = 0
@property
def is_external(self) -> bool:
return True
@property
def model(self) -> str:
return "gpt-4o-mini"
def complete(self, messages: Any, *, tools: Any = None, max_output_tokens: int) -> Any:
self.calls += 1
raise self._exc
@pytest.fixture
def _enabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""Включить LLM с fake-ключом (без реальных секретов)."""
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr(settings, "openai_api_key", "test-fake-key-not-real")
# ── Guard #1/#2: disabled / no key ─────────────────────────────────────────────
def test_disabled_returns_fallback_without_network(monkeypatch: pytest.MonkeyPatch) -> None:
"""llm_enabled=False → fallback, и НИ ОДНОЙ попытки создать httpx.Client."""
monkeypatch.setattr(settings, "llm_enabled", False)
def _tripwire(*a: Any, **k: Any) -> None:
raise AssertionError("network must NOT be touched when llm_enabled=False")
# Любая попытка сетевого вызова через httpx завалит тест.
monkeypatch.setattr("app.services.llm.provider.httpx.Client", _tripwire)
res = complete(system_prompt="sys", payload=SafePayload(text="hi"))
assert res.ok is False
assert res.fallback_used is True
assert res.reason == "disabled"
def test_enabled_but_no_key_returns_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
"""llm_enabled=True но ключ None → fallback (no_api_key), без сети."""
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr(settings, "openai_api_key", None)
def _tripwire(*a: Any, **k: Any) -> None:
raise AssertionError("no network without a key")
monkeypatch.setattr("app.services.llm.provider.httpx.Client", _tripwire)
res = complete(system_prompt="sys", payload=SafePayload(text="hi"))
assert res.reason == "no_api_key"
assert res.fallback_used is True
# ── Call cap ───────────────────────────────────────────────────────────────────
def test_call_cap_returns_fallback(_enabled: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""call_index >= llm_max_calls_per_request → fallback, провайдер не вызывается."""
monkeypatch.setattr(settings, "llm_max_calls_per_request", 2)
prov = _FakeOpenAILike()
res = complete(system_prompt="sys", payload=SafePayload(text="hi"), provider=prov, call_index=2)
assert res.reason == "call_cap"
assert prov.calls == 0
# ── Redaction integration ──────────────────────────────────────────────────────
def test_confidential_payload_returns_fallback(_enabled: None) -> None:
"""is_confidential=True (external) → fallback redaction_refused, провайдер не зван."""
prov = _FakeOpenAILike()
res = complete(
system_prompt="sys",
payload=SafePayload(text="секрет", is_confidential=True),
provider=prov,
)
assert res.reason == "redaction_refused"
assert prov.calls == 0
def test_request_body_is_redacted(_enabled: None) -> None:
"""ГЛАВНОЕ: тело запроса к провайдеру НЕ содержит сырого PII — оно проскраблено."""
prov = _FakeOpenAILike()
payload = SafePayload(
text="Свяжитесь +7 912 345 67 89 или mail@example.ru",
fields={"owner": "Иванов Иван Иванович", "zone": "Ц-1"},
)
res = complete(system_prompt="Ты ассистент", payload=payload, provider=prov)
assert res.ok is True
assert prov.captured_messages is not None
blob = str(prov.captured_messages)
# Сырого PII в отправленных messages быть не должно.
assert "+7 912 345 67 89" not in blob
assert "mail@example.ru" not in blob
assert "Иванов Иван Иванович" not in blob
# Зато плейсхолдеры и безопасные поля — на месте.
assert "[REDACTED:phone]" in blob
assert "[REDACTED:email]" in blob
assert "[REDACTED:name]" in blob
assert "Ц-1" in blob
# ── Happy path ─────────────────────────────────────────────────────────────────
def test_happy_path_returns_ok_result(_enabled: None) -> None:
"""Успешный ответ → ok=True, content и токены проброшены."""
prov = _FakeOpenAILike(
ProviderResponse(
content="готово", prompt_tokens=12, completion_tokens=4, model="gpt-4o-mini"
)
)
res = complete(system_prompt="sys", payload=SafePayload(text="вопрос"), provider=prov)
assert res.ok is True
assert res.content == "готово"
assert res.prompt_tokens == 12
assert res.completion_tokens == 4
assert res.model == "gpt-4o-mini"
def test_tool_calls_passed_through(_enabled: None) -> None:
"""tool_calls из ответа провайдера прокидываются в LLMResult."""
prov = _FakeOpenAILike(
ProviderResponse(
content=None,
tool_calls=[ToolCall(id="c1", name="extract", arguments='{"x":1}')],
model="gpt-4o-mini",
)
)
res = complete(system_prompt="sys", payload=SafePayload(text="q"), provider=prov)
assert res.ok is True
assert len(res.tool_calls) == 1
assert res.tool_calls[0].name == "extract"
def test_tools_forwarded_to_provider(_enabled: None) -> None:
"""tools прокидываются в provider.complete."""
prov = _FakeOpenAILike()
tools = [{"type": "function", "function": {"name": "f"}}]
complete(system_prompt="sys", payload=SafePayload(text="q"), provider=prov, tools=tools)
assert prov.captured_tools == tools
# ── Guardrails: timeout / rate-limit / retries ─────────────────────────────────
def test_timeout_degrades_to_fallback(_enabled: None) -> None:
"""LLMTimeoutError → fallback(timeout), без ретраев (1 вызов)."""
prov = _RaisingProvider(LLMTimeoutError("slow"))
res = complete(system_prompt="sys", payload=SafePayload(text="q"), provider=prov)
assert res.reason == "timeout"
assert res.fallback_used is True
assert prov.calls == 1
def test_rate_limited_retries_then_fallback(
_enabled: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""429 на всех попытках → ретраи (cap) затем fallback(rate_limited). sleep замокан."""
monkeypatch.setattr(settings, "llm_max_retries", 2)
# Не спим в тесте.
monkeypatch.setattr(llm_client.time, "sleep", lambda s: None)
prov = _RaisingProvider(LLMRateLimitedError("429", status_code=429, retry_after=None))
res = complete(system_prompt="sys", payload=SafePayload(text="q"), provider=prov)
assert res.reason == "rate_limited"
assert res.fallback_used is True
# 1 первичный + 2 ретрая = 3 вызова.
assert prov.calls == 3
def test_rate_limited_retry_after_capped(_enabled: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""#1209: серверный Retry-After (86400с при quota-exhaustion) должен капаться
_MAX_BACKOFF_S — иначе time.sleep блокирует anyio-threadpool на часы.
"""
monkeypatch.setattr(settings, "llm_max_retries", 1)
sleeps: list[float] = []
monkeypatch.setattr(llm_client.time, "sleep", lambda s: sleeps.append(s))
class _BigRetryAfterProvider(LLMProvider):
@property
def is_external(self) -> bool:
return True
@property
def model(self) -> str:
return "gpt-4o-mini"
def complete(self, messages: Any, *, tools: Any = None, max_output_tokens: int) -> Any:
raise LLMRateLimitedError("429", status_code=429, retry_after=86400.0)
prov = _BigRetryAfterProvider()
res = complete(system_prompt="sys", payload=SafePayload(text="q"), provider=prov)
assert res.ok is False
assert res.reason == "rate_limited"
# ровно один backoff (1 ретрай); значение должно быть капано _MAX_BACKOFF_S (30с).
assert len(sleeps) == 1
assert sleeps[0] == llm_client._MAX_BACKOFF_S
def test_rate_limited_then_success(_enabled: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""429 один раз, затем успех → ok (ретрай сработал)."""
monkeypatch.setattr(settings, "llm_max_retries", 2)
monkeypatch.setattr(llm_client.time, "sleep", lambda s: None)
class _FlakyProvider(LLMProvider):
def __init__(self) -> None:
self.calls = 0
@property
def is_external(self) -> bool:
return True
@property
def model(self) -> str:
return "gpt-4o-mini"
def complete(self, messages: Any, *, tools: Any = None, max_output_tokens: int) -> Any:
self.calls += 1
if self.calls == 1:
raise LLMRateLimitedError("429", status_code=429, retry_after=None)
return ProviderResponse(content="ок после ретрая", model="gpt-4o-mini")
prov = _FlakyProvider()
res = complete(system_prompt="sys", payload=SafePayload(text="q"), provider=prov)
assert res.ok is True
assert res.content == "ок после ретрая"
assert prov.calls == 2
# ── complete_or_raise ──────────────────────────────────────────────────────────
def test_complete_or_raise_raises_on_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
"""complete_or_raise бросает LLMUnavailableError когда LLM недоступен (disabled)."""
monkeypatch.setattr(settings, "llm_enabled", False)
with pytest.raises(LLMUnavailableError):
complete_or_raise(system_prompt="sys", payload=SafePayload(text="hi"))
def test_complete_or_raise_returns_on_success(_enabled: None) -> None:
prov = _FakeOpenAILike(ProviderResponse(content="ok", model="gpt-4o-mini"))
res = complete_or_raise(system_prompt="sys", payload=SafePayload(text="q"), provider=prov)
assert isinstance(res, LLMResult)
assert res.ok is True