gendesign/backend/tests/services/llm/test_provider.py
bot-backend 14818f0303
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m56s
CI Trade-In / backend-tests (pull_request) Successful in 5m25s
CI / backend-tests (pull_request) Successful in 18m18s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864)
Почему: файлы в main были отформатированы старым ruff (хук 0.7.4), а после
#3021 хук, CI и `uv run ruff format` — на 0.15.20. Без нормализации каждый
коммит, касающийся одного из этих 161 файлов, тащил бы посторонние хунки
на нетронутых строках (длинные assert-сообщения и т.п.).

Что: `ruff format` 0.15.20 по backend/ и tradein-mvp/backend/; ничего,
кроме форматирования (958+/1034−). `ruff check` 0.15.20 зелёный на обоих
проектах, повторный `format --check` — 1001 файл без изменений
(идемпотентно), compileall чистый.

Refs #2864
2026-08-21 16:39:28 +05: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