"""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