gendesign/backend/tests/services/llm/test_provider.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

202 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