"""LLM provider abstraction (#960) — внешний OpenAI + шов под RU-hosted. ``LLMProvider`` — абстрактная база, чтобы будущий RU-hosted провайдер (GigaChat/YandexGPT — данные остаются в РФ) можно было добавить, НЕ трогая консьюмеров и клиент. Ключевой шов — свойство ``is_external``: - ``OpenAIProvider.is_external == True`` → клиент ОБЯЗАН прогнать нагрузку через redaction перед вызовом (см. client.complete). - будущий ``GigaChatProvider.is_external == False`` → redaction можно ослабить. HTTP — raw ``httpx`` (без openai-SDK: новая зависимость = pyproject + uv.lock и abort деплоя на lock-drift; httpx уже backend-mandated клиент). Поддержан tool/function-calling pass-through: ``tools`` в запросе, ``tool_calls`` в ответе. """ from __future__ import annotations import logging from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Any import httpx logger = logging.getLogger(__name__) class LLMProviderError(Exception): """Базовая ошибка провайдера (сеть/HTTP/парсинг). Ловится клиентом → fallback.""" class LLMTimeoutError(LLMProviderError): """Провайдер не ответил в пределах таймаута.""" class LLMRateLimitedError(LLMProviderError): """429/5xx от провайдера (после исчерпания ретраев на уровне провайдера).""" def __init__(self, message: str, *, status_code: int, retry_after: float | None = None) -> None: super().__init__(message) self.status_code = status_code self.retry_after = retry_after @dataclass(frozen=True, slots=True) class ToolCall: """Распарсенный tool/function call из ответа модели (pass-through, без exec).""" id: str name: str arguments: str # сырой JSON-строкой как отдаёт OpenAI; парсит вызывающий @dataclass(frozen=True, slots=True) class ProviderResponse: """Сырой ответ провайдера (до обёртки в LLMResult клиентом).""" content: str | None tool_calls: list[ToolCall] = field(default_factory=list) finish_reason: str | None = None prompt_tokens: int = 0 completion_tokens: int = 0 model: str = "" class LLMProvider(ABC): """Абстрактный провайдер. Реализации: OpenAIProvider (external) + future RU-hosted.""" @property @abstractmethod def is_external(self) -> bool: """True если провайдер выгружает данные за пределы РФ (=> redaction mandatory).""" @property @abstractmethod def model(self) -> str: """Идентификатор модели по умолчанию (для логов/оценки стоимости).""" @abstractmethod def complete( self, messages: list[dict[str, Any]], *, tools: list[dict[str, Any]] | None = None, max_output_tokens: int, ) -> ProviderResponse: """Один синхронный вызов chat-completions. Без ретраев/таймаута-логики выше — это делает client.complete (guardrails). Поднимает ``LLMProviderError`` на сбое. """ # ── OpenAI (external) ───────────────────────────────────────────────────────── def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> ProviderResponse: """Распарсить тело chat/completions OpenAI → ProviderResponse (с tool_calls).""" choices = data.get("choices") or [] if not choices: raise LLMProviderError("OpenAI response has no choices") choice = choices[0] or {} message = choice.get("message") or {} raw_tool_calls = message.get("tool_calls") or [] tool_calls: list[ToolCall] = [] for tc in raw_tool_calls: fn = (tc or {}).get("function") or {} tool_calls.append( ToolCall( id=str(tc.get("id", "")), name=str(fn.get("name", "")), arguments=str(fn.get("arguments", "")), ) ) usage = data.get("usage") or {} return ProviderResponse( content=message.get("content"), tool_calls=tool_calls, finish_reason=choice.get("finish_reason"), prompt_tokens=int(usage.get("prompt_tokens", 0) or 0), completion_tokens=int(usage.get("completion_tokens", 0) or 0), model=str(data.get("model") or fallback_model), ) class OpenAIProvider(LLMProvider): """OpenAI Chat Completions через raw httpx. ВНЕШНИЙ → redaction обязателен. Ключ передаётся в конструктор вызывающим (client читает его из settings); сам провайдер ключ нигде не логирует. Stateless относительно данных; один instance на вызов достаточно дёшев (httpx.Client создаётся на запрос — простота поверх пула; консьюмеры низкочастотные). """ def __init__( self, *, api_key: str, model: str, base_url: str = "https://api.openai.com/v1", timeout_s: float = 30.0, ) -> None: self._api_key = api_key self._model = model self._base_url = base_url.rstrip("/") self._timeout_s = timeout_s @property def is_external(self) -> bool: return True @property def model(self) -> str: return self._model def _post(self, body: dict[str, Any]) -> dict[str, Any]: """POST {base_url}/chat/completions. Вынесен отдельно для monkeypatch в тестах. Ключ кладётся в заголовок Authorization и НИКОГДА не логируется. Поднимает типизированные LLM*-ошибки — без ретраев (их делает client.complete). """ url = f"{self._base_url}/chat/completions" headers = { "Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json", } try: with httpx.Client(timeout=self._timeout_s) as client: resp = client.post(url, headers=headers, json=body) except httpx.TimeoutException as e: raise LLMTimeoutError(f"OpenAI request timed out after {self._timeout_s}s") from e except httpx.HTTPError as e: raise LLMProviderError(f"OpenAI network error: {e}") from e if resp.status_code == 429 or resp.status_code >= 500: retry_after = _parse_retry_after(resp.headers.get("Retry-After")) raise LLMRateLimitedError( f"OpenAI HTTP {resp.status_code}", status_code=resp.status_code, retry_after=retry_after, ) if resp.status_code != 200: # 4xx (кроме 429): тело может содержать описание ошибки, но НЕ наши данные # эха целиком — берём короткий префикс. Ключ в теле ответа не возвращается. raise LLMProviderError(f"OpenAI HTTP {resp.status_code}: {resp.text[:200]}") try: data: dict[str, Any] = resp.json() except ValueError as e: raise LLMProviderError(f"OpenAI returned non-JSON body: {e}") from e return data def complete( self, messages: list[dict[str, Any]], *, tools: list[dict[str, Any]] | None = None, max_output_tokens: int, ) -> ProviderResponse: body: dict[str, Any] = { "model": self._model, "messages": messages, "max_tokens": max_output_tokens, } if tools: # Clean pass-through tool-calling: прокидываем tools как есть, без agent-loop. body["tools"] = tools body["tool_choice"] = "auto" data = self._post(body) return _parse_openai_response(data, fallback_model=self._model) def _parse_retry_after(value: str | None) -> float | None: """Распарсить заголовок Retry-After (только числовая форма в секундах).""" if value and value.isdigit(): return float(value) return None __all__ = [ "LLMProvider", "LLMProviderError", "LLMRateLimitedError", "LLMTimeoutError", "OpenAIProvider", "ProviderResponse", "ToolCall", ]