Some checks failed
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (push) Successful in 1m45s
CI / openapi-codegen-check (pull_request) Successful in 1m43s
CI / backend-tests (push) Failing after 8m36s
CI / backend-tests (pull_request) Failing after 8m36s
Имплементация фиксов 2-го аудита backend/app/** (после merge #1543). Воркер на файл, точечные правки. Верификация: py_compile 58/58 .py. Полностью исправлено (82). Оставлены открытыми (13): partial/needs-cross-file/needs-leha — #1569, #1590, #1593, #1606, #1609, #1617, #1633, #1635, #1637, #1638, #1640, #1642, #1650. Closes #1560 Closes #1561 Closes #1562 Closes #1563 Closes #1564 Closes #1565 Closes #1566 Closes #1567 Closes #1570 Closes #1571 Closes #1572 Closes #1573 Closes #1574 Closes #1576 Closes #1577 Closes #1578 Closes #1579 Closes #1580 Closes #1581 Closes #1582 Closes #1583 Closes #1584 Closes #1585 Closes #1586 Closes #1587 Closes #1588 Closes #1589 Closes #1591 Closes #1592 Closes #1594 Closes #1595 Closes #1596 Closes #1597 Closes #1598 Closes #1599 Closes #1600 Closes #1601 Closes #1602 Closes #1603 Closes #1604 Closes #1605 Closes #1607 Closes #1608 Closes #1610 Closes #1611 Closes #1612 Closes #1613 Closes #1614 Closes #1615 Closes #1616 Closes #1618 Closes #1619 Closes #1620 Closes #1621 Closes #1622 Closes #1623 Closes #1624 Closes #1625 Closes #1626 Closes #1627 Closes #1628 Closes #1629 Closes #1630 Closes #1631 Closes #1632 Closes #1634 Closes #1636 Closes #1639 Closes #1641 Closes #1643 Closes #1644 Closes #1645 Closes #1646 Closes #1647 Closes #1648 Closes #1649 Closes #1651 Closes #1652 Closes #1653 Closes #1654 Closes #1655 Closes #1656
239 lines
9.7 KiB
Python
239 lines
9.7 KiB
Python
"""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 _coerce_token_count(value: Any) -> int:
|
||
"""usage-токены → int, толерантно к мусору (str "abc"/None/dict → 0).
|
||
|
||
OpenAI обычно отдаёт int, но через внешний прокси/нестандартный провайдер поле
|
||
может прийти нечисловым. int() на таком значении бросил бы ValueError/TypeError
|
||
мимо LLM*-контракта (его ловит client._call_with_retries) и пробил бы инвариант
|
||
«complete никогда не падает наружу» (#1601). Невалидный токен-счётчик — не повод
|
||
ронять ответ: деградируем до 0.
|
||
"""
|
||
try:
|
||
return int(value or 0)
|
||
except (ValueError, TypeError):
|
||
return 0
|
||
|
||
|
||
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=_coerce_token_count(usage.get("prompt_tokens", 0)),
|
||
completion_tokens=_coerce_token_count(usage.get("completion_tokens", 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",
|
||
]
|