gendesign/backend/tests/services/llm/test_provider.py
Light1YT 4af7ba5a40
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
feat(llm): foundational LLM infra package with §19 redaction + deterministic fallback (#960)
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
2026-06-08 15:44:16 +05:00

204 lines
7.5 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.

"""OpenAIProvider tests (#960) — request/response shape, tool-calling, error mapping.
Сеть НЕ дёргается: для shape-тестов мокаем OpenAIProvider._post; для error-mapping —
httpx.MockTransport (проверяем разбор статусов/таймаута на реальном httpx-слое). Ключ — fake.
"""
from __future__ import annotations
from typing import Any
import httpx
import pytest
from app.services.llm.provider import (
LLMProviderError,
LLMRateLimitedError,
LLMTimeoutError,
OpenAIProvider,
)
_FAKE_KEY = "test-fake-key-not-real"
# Реальный конструктор сохраняем ДО патча: иначе фабрика, дёргая httpx.Client,
# попадёт в саму себя (provider делает `import httpx`, поэтому патчим module-attr).
_REAL_HTTPX_CLIENT = httpx.Client
def _install_mock_transport(
monkeypatch: pytest.MonkeyPatch,
handler: Any,
) -> None:
"""Подменить httpx.Client в provider на клиент с MockTransport (без сети)."""
transport = httpx.MockTransport(handler)
def factory(**kw: Any) -> httpx.Client:
kw.pop("transport", None)
return _REAL_HTTPX_CLIENT(transport=transport, **kw)
monkeypatch.setattr("app.services.llm.provider.httpx.Client", factory)
def _provider() -> OpenAIProvider:
return OpenAIProvider(api_key=_FAKE_KEY, model="gpt-4o-mini")
def _chat_response(content: str = "привет") -> dict[str, Any]:
return {
"model": "gpt-4o-mini",
"choices": [
{"message": {"role": "assistant", "content": content}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
}
def _tool_call_response() -> dict[str, Any]:
return {
"model": "gpt-4o-mini",
"choices": [
{
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "extract_floors",
"arguments": '{"floors": 16}',
},
}
],
},
"finish_reason": "tool_calls",
}
],
"usage": {"prompt_tokens": 20, "completion_tokens": 8},
}
def test_complete_builds_request_body(monkeypatch: pytest.MonkeyPatch) -> None:
"""complete формирует корректное тело: model, messages, max_tokens."""
captured: dict[str, Any] = {}
def fake_post(self: OpenAIProvider, body: dict[str, Any]) -> dict[str, Any]:
captured["body"] = body
return _chat_response()
monkeypatch.setattr(OpenAIProvider, "_post", fake_post)
resp = _provider().complete(
[{"role": "user", "content": "привет"}], max_output_tokens=256
)
assert captured["body"]["model"] == "gpt-4o-mini"
assert captured["body"]["max_tokens"] == 256
assert captured["body"]["messages"][0]["content"] == "привет"
# Без tools — поля tools/tool_choice не добавляются.
assert "tools" not in captured["body"]
assert resp.content == "привет"
assert resp.prompt_tokens == 10
assert resp.completion_tokens == 5
def test_complete_passes_tools_through(monkeypatch: pytest.MonkeyPatch) -> None:
"""tools прокидываются в тело как есть + tool_choice=auto (clean pass-through)."""
captured: dict[str, Any] = {}
def fake_post(self: OpenAIProvider, body: dict[str, Any]) -> dict[str, Any]:
captured["body"] = body
return _tool_call_response()
monkeypatch.setattr(OpenAIProvider, "_post", fake_post)
tools = [
{
"type": "function",
"function": {
"name": "extract_floors",
"parameters": {"type": "object", "properties": {"floors": {"type": "integer"}}},
},
}
]
resp = _provider().complete(
[{"role": "user", "content": "сколько этажей"}],
tools=tools,
max_output_tokens=256,
)
assert captured["body"]["tools"] == tools
assert captured["body"]["tool_choice"] == "auto"
# Ответ tool_calls распарсен.
assert resp.content is None
assert len(resp.tool_calls) == 1
assert resp.tool_calls[0].name == "extract_floors"
assert resp.tool_calls[0].arguments == '{"floors": 16}'
assert resp.tool_calls[0].id == "call_1"
def test_post_maps_429_to_rate_limited(monkeypatch: pytest.MonkeyPatch) -> None:
"""429 → LLMRateLimitedError со status_code и retry_after из заголовка."""
def fake_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(429, headers={"Retry-After": "7"}, json={"error": "rate"})
_install_mock_transport(monkeypatch, fake_handler)
with pytest.raises(LLMRateLimitedError) as ei:
_provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)
assert ei.value.status_code == 429
assert ei.value.retry_after == 7.0
def test_post_maps_500_to_rate_limited(monkeypatch: pytest.MonkeyPatch) -> None:
"""5xx → LLMRateLimitedError (ретраебельно на уровне клиента)."""
def fake_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(503, json={"error": "unavailable"})
_install_mock_transport(monkeypatch, fake_handler)
with pytest.raises(LLMRateLimitedError) as ei:
_provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)
assert ei.value.status_code == 503
def test_post_maps_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
"""httpx.TimeoutException → LLMTimeoutError."""
def fake_handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("slow", request=request)
_install_mock_transport(monkeypatch, fake_handler)
with pytest.raises(LLMTimeoutError):
_provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)
def test_post_maps_4xx_to_provider_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""400 → LLMProviderError (не ретраебельно)."""
def fake_handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(400, json={"error": "bad request"})
_install_mock_transport(monkeypatch, fake_handler)
with pytest.raises(LLMProviderError):
_provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)
def test_post_sends_bearer_key_in_header(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ключ уходит в Authorization: Bearer — и НЕ попадает в тело запроса."""
seen: dict[str, Any] = {}
def fake_handler(request: httpx.Request) -> httpx.Response:
seen["auth"] = request.headers.get("Authorization")
seen["body"] = request.content.decode()
return httpx.Response(200, json=_chat_response())
_install_mock_transport(monkeypatch, fake_handler)
_provider().complete([{"role": "user", "content": "x"}], max_output_tokens=64)
assert seen["auth"] == f"Bearer {_FAKE_KEY}"
# Ключ не должен оказаться в JSON-теле.
assert _FAKE_KEY not in seen["body"]
def test_is_external_true() -> None:
"""OpenAIProvider — внешний (redaction mandatory)."""
assert _provider().is_external is True