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
204 lines
7.5 KiB
Python
204 lines
7.5 KiB
Python
"""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
|