gendesign/backend/app/services/llm/provider.py
Light1YT 4af7ba5a40
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
feat(llm): foundational LLM infra package with §19 redaction + deterministic fallback (#960)
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
2026-06-08 15:44:16 +05:00

224 lines
8.9 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.

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