All checks were successful
CI / changes (pull_request) Successful in 5s
CI / backend-tests (push) Successful in 6m25s
CI / backend-tests (pull_request) Successful in 6m20s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m38s
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 1m13s
CI / changes (push) Successful in 5s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Optional external-OpenAI layer over the deterministic forecasting engine. Gated by llm_enabled (default False) so prod makes no network calls until deliberately enabled. Allowlist-first SafePayload contract + is_confidential hard-block + RU-PII regex scrub (mandatory on the external path). Abstract LLMProvider seam (is_external) for a future RU-hosted provider. Sync httpx core (Celery-friendly); tool/function-calling pass-through; timeout + bounded 429/5xx retry + per-request call cap, all degrading to fallback. Raw httpx (no openai SDK -> no pyproject/lock drift). 47 tests, ruff + mypy clean. Refs #960
288 lines
12 KiB
Python
288 lines
12 KiB
Python
"""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_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
|