Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
306 lines
15 KiB
Python
306 lines
15 KiB
Python
"""LLM orchestration client (#960) — enabled-check → redact → guardrails → fallback.
|
||
|
||
Архитектурный принцип (жёсткий): forecasting-движок полностью детерминирован; LLM —
|
||
ОПЦИОНАЛЬНЫЙ слой, у которого ВСЕГДА есть детерминированный fallback. Поэтому
|
||
``complete`` НИКОГДА не падает наружу из-за LLM: любая проблема (выключено / таймаут /
|
||
сеть / ошибка провайдера / отказ redaction / превышение call-cap) превращается в
|
||
``LLMResult(ok=False, fallback_used=True)`` — вызывающий подставляет свой
|
||
детерминированный результат.
|
||
|
||
Гарантия «прод не позвонит в OpenAI случайно»: если ``settings.llm_enabled`` is False
|
||
ИЛИ ключ не задан — ``complete`` возвращает fallback СРАЗУ, не создавая провайдера и не
|
||
делая сетевых вызовов (guard #2). Это первая строка функции.
|
||
|
||
Sync-first: ядро синхронное (``httpx.Client``) — совпадает с синхронным
|
||
forecasting-движком и Celery-консьюмером (#956). Async-консьюмер (#957 chat) мостит
|
||
через ``anyio.to_thread.run_sync`` / FastAPI ``run_in_threadpool`` (НЕ делаем async
|
||
Celery task — backend.md).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
from app.core.config import settings
|
||
|
||
from .provider import (
|
||
LLMProvider,
|
||
LLMProviderError,
|
||
LLMRateLimitedError,
|
||
LLMTimeoutError,
|
||
OpenAIProvider,
|
||
ProviderResponse,
|
||
ToolCall,
|
||
)
|
||
from .redaction import RedactionRefusedError, SafePayload, scrub_safe_payload
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class LLMUnavailableError(Exception):
|
||
"""LLM-слой недоступен (выключен / нет ключа / сбой). Для вызывающих, которые
|
||
предпочитают исключение вместо проверки ``LLMResult.ok``. ``complete`` по умолчанию
|
||
НЕ бросает — возвращает fallback-результат; raise делает только ``complete_or_raise``.
|
||
"""
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class LLMResult:
|
||
"""Результат вызова. ``ok=False`` → вызывающий ОБЯЗАН использовать детерм. fallback.
|
||
|
||
Attributes:
|
||
ok: True только при успешном ответе провайдера.
|
||
content: Текст ответа модели (None при tool-only ответе или fallback).
|
||
tool_calls: Распарсенные tool/function calls (pass-through).
|
||
fallback_used: True если результат — сигнал к детерминированному fallback.
|
||
reason: Машиночитаемая причина fallback (disabled/timeout/rate_limited/
|
||
redaction_refused/provider_error/call_cap/no_api_key). None при ok.
|
||
finish_reason: причина завершения от провайдера (stop/length/content_filter/
|
||
tool_calls/…). None при fallback. Консьюмер ОБЯЗАН проверять её даже при
|
||
ok=True: 'length'/'content_filter' → ответ обрезан/отфильтрован (частичный
|
||
или пустой content) и не является полноценным результатом.
|
||
prompt_tokens / completion_tokens: для оценки стоимости (0 при fallback).
|
||
model: модель, ответившая на запрос ("" при fallback).
|
||
"""
|
||
|
||
ok: bool
|
||
content: str | None = None
|
||
tool_calls: list[ToolCall] = field(default_factory=list)
|
||
fallback_used: bool = False
|
||
reason: str | None = None
|
||
finish_reason: str | None = None
|
||
prompt_tokens: int = 0
|
||
completion_tokens: int = 0
|
||
model: str = ""
|
||
|
||
@classmethod
|
||
def fallback(cls, reason: str) -> LLMResult:
|
||
return cls(ok=False, fallback_used=True, reason=reason)
|
||
|
||
@classmethod
|
||
def from_provider(cls, resp: ProviderResponse) -> LLMResult:
|
||
return cls(
|
||
ok=True,
|
||
content=resp.content,
|
||
tool_calls=list(resp.tool_calls),
|
||
finish_reason=resp.finish_reason,
|
||
prompt_tokens=resp.prompt_tokens,
|
||
completion_tokens=resp.completion_tokens,
|
||
model=resp.model,
|
||
)
|
||
|
||
|
||
# Грубая оценка стоимости (USD / 1K токенов) для лог-хука tokens→$. Только для
|
||
# логирования/наблюдаемости — НЕ enforcement. Значения для gpt-4o-mini (ориентир,
|
||
# уточняется консьюмером при смене модели/тарифа).
|
||
_COST_PER_1K_PROMPT_USD = 0.00015
|
||
_COST_PER_1K_COMPLETION_USD = 0.00060
|
||
|
||
# #1209: общий потолок backoff'а между retry-попытками (секунды). Применяется
|
||
# к ОБЕИМ веткам — exponential backoff И серверному Retry-After. Provider'ы
|
||
# шлют Retry-After до 86400с при quota-exhaustion (видели у OpenAI / CDN-503);
|
||
# без капа time.sleep блокирует anyio-threadpool на часы → пул из ~40 потоков
|
||
# исчерпывается → стопор приложения (sync Depends(get_db) тоже сидят в этом
|
||
# пуле). 30с — компромисс между «дать провайдеру отдышаться» и «не убивать
|
||
# приложение». Реальный raw_wait логируется для наблюдаемости.
|
||
_MAX_BACKOFF_S: float = 30.0
|
||
|
||
|
||
def _estimate_cost_usd(prompt_tokens: int, completion_tokens: int) -> float:
|
||
return (
|
||
prompt_tokens / 1000.0 * _COST_PER_1K_PROMPT_USD
|
||
+ completion_tokens / 1000.0 * _COST_PER_1K_COMPLETION_USD
|
||
)
|
||
|
||
|
||
def _build_default_provider() -> LLMProvider | None:
|
||
"""Собрать провайдера из settings. None если ключ не задан (→ fallback у вызывающего).
|
||
|
||
Сейчас единственный провайдер — OpenAI (внешний). Будущий RU-hosted выбирается
|
||
здесь же по настройке (напр. settings.llm_provider) без изменений в complete.
|
||
"""
|
||
if not settings.openai_api_key:
|
||
return None
|
||
return OpenAIProvider(
|
||
api_key=settings.openai_api_key,
|
||
model=settings.llm_model,
|
||
base_url=settings.llm_base_url,
|
||
timeout_s=settings.llm_timeout_s,
|
||
)
|
||
|
||
|
||
def _messages_from_payload(system_prompt: str, payload: SafePayload) -> list[dict[str, Any]]:
|
||
"""Собрать chat-messages из системного промпта и (уже проскрабленной) нагрузки.
|
||
|
||
fields сериализуются в компактный «ключ: значение» блок и приклеиваются к user-тексту —
|
||
модель получает структурированный контекст, а вызывающий не пишет prompt-склейку сам.
|
||
"""
|
||
user_parts: list[str] = []
|
||
if payload.text:
|
||
user_parts.append(payload.text)
|
||
if payload.fields:
|
||
rendered = "\n".join(f"{k}: {v}" for k, v in payload.fields.items())
|
||
user_parts.append(rendered)
|
||
return [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": "\n\n".join(user_parts)},
|
||
]
|
||
|
||
|
||
def complete(
|
||
*,
|
||
system_prompt: str,
|
||
payload: SafePayload,
|
||
tools: list[dict[str, Any]] | None = None,
|
||
provider: LLMProvider | None = None,
|
||
call_index: int = 0,
|
||
) -> LLMResult:
|
||
"""Главная точка входа. Прогоняет нагрузку через все защиты и возвращает LLMResult.
|
||
|
||
Контракт безопасности (порядок строгий):
|
||
1. enabled-check: ``llm_enabled`` False / нет ключа → fallback БЕЗ сети.
|
||
2. call-cap: ``call_index >= llm_max_calls_per_request`` → fallback (анти-цикл).
|
||
3. redaction (mandatory на external-пути): hard-block confidential + regex-scrub.
|
||
4. provider call с таймаутом + ретраями (429/5xx, backoff, cap), circuit-breaker-lite.
|
||
5. любой сбой → fallback (система не падает из-за LLM).
|
||
|
||
Args:
|
||
system_prompt: системная инструкция (без данных — данные в ``payload``).
|
||
payload: ЯВНО собранная вызывающим ``SafePayload`` (allowlist-first контракт).
|
||
tools: опц. OpenAI tool/function-calling спецификации (clean pass-through).
|
||
provider: внедрить провайдера (тесты/будущий RU-hosted). None → из settings.
|
||
call_index: порядковый номер вызова в рамках одной логической операции
|
||
(для call-cap). Консьюмер инкрементит сам.
|
||
|
||
Returns:
|
||
``LLMResult`` — при ``ok=False`` вызывающий ОБЯЗАН подставить детерм. fallback.
|
||
"""
|
||
# ── Guard #1/#2: выключено или нет ключа → НИКАКОЙ сети ──────────────────
|
||
if not settings.llm_enabled:
|
||
logger.debug("llm: disabled (llm_enabled=False) → deterministic fallback")
|
||
return LLMResult.fallback("disabled")
|
||
|
||
prov = provider or _build_default_provider()
|
||
if prov is None:
|
||
logger.warning("llm: enabled but OPENAI_API_KEY not set → deterministic fallback")
|
||
return LLMResult.fallback("no_api_key")
|
||
|
||
# ── Per-request call cap (анти-цикл) ────────────────────────────────────
|
||
if call_index >= settings.llm_max_calls_per_request:
|
||
logger.warning(
|
||
"llm: call cap reached (call_index=%d >= %d) → fallback",
|
||
call_index,
|
||
settings.llm_max_calls_per_request,
|
||
)
|
||
return LLMResult.fallback("call_cap")
|
||
|
||
# ── Redaction (mandatory на external-пути) ──────────────────────────────
|
||
try:
|
||
safe = scrub_safe_payload(payload, is_external=prov.is_external)
|
||
except RedactionRefusedError:
|
||
# confidential нагрузка — НЕ отправляем наружу, деградируем в fallback.
|
||
return LLMResult.fallback("redaction_refused")
|
||
|
||
messages = _messages_from_payload(system_prompt, safe)
|
||
|
||
# ── Provider call с ретраями (circuit-breaker-lite) ─────────────────────
|
||
return _call_with_retries(prov, messages, tools)
|
||
|
||
|
||
def _call_with_retries(
|
||
provider: LLMProvider,
|
||
messages: list[dict[str, Any]],
|
||
tools: list[dict[str, Any]] | None,
|
||
) -> LLMResult:
|
||
"""Вызвать провайдера с bounded-ретраями на 429/5xx и backoff. Сбой → fallback."""
|
||
max_retries = settings.llm_max_retries
|
||
last_reason = "provider_error"
|
||
for attempt in range(max_retries + 1):
|
||
try:
|
||
resp = provider.complete(
|
||
messages,
|
||
tools=tools,
|
||
max_output_tokens=settings.llm_max_output_tokens,
|
||
)
|
||
except LLMRateLimitedError as e:
|
||
last_reason = "rate_limited"
|
||
if attempt < max_retries:
|
||
# #1209: cap И серверное Retry-After (раньше min(...,30) применялся
|
||
# только к exp.backoff). _MAX_BACKOFF_S — единый потолок для обеих
|
||
# веток, защищает anyio-threadpool от blocking на часы.
|
||
raw_wait = float(
|
||
e.retry_after if e.retry_after is not None else 2**attempt
|
||
)
|
||
wait = min(raw_wait, _MAX_BACKOFF_S)
|
||
logger.warning(
|
||
"llm: HTTP %s (attempt %d/%d), backing off %.1fs (raw=%.1fs)",
|
||
e.status_code,
|
||
attempt + 1,
|
||
max_retries + 1,
|
||
wait,
|
||
raw_wait,
|
||
)
|
||
time.sleep(wait)
|
||
continue
|
||
# Circuit-breaker-lite: исчерпали ретраи — не «молотим», деградируем.
|
||
logger.warning("llm: rate-limited after %d attempts → fallback", max_retries + 1)
|
||
return LLMResult.fallback("rate_limited")
|
||
except LLMProviderError as e:
|
||
# Таймаут (LLMTimeoutError) и прочие провайдер-ошибки: НЕ ретраим (таймаут уже
|
||
# «съел» бюджет времени; сетевые/4xx обычно не лечатся повтором тут).
|
||
reason = "timeout" if isinstance(e, LLMTimeoutError) else "provider_error"
|
||
logger.warning("llm: provider error (%s) → fallback: %s", reason, e)
|
||
return LLMResult.fallback(reason)
|
||
|
||
# Успех — лог-хук оценки стоимости (tokens→$), без содержимого ответа.
|
||
cost = _estimate_cost_usd(resp.prompt_tokens, resp.completion_tokens)
|
||
logger.info(
|
||
"llm: ok model=%s prompt_tokens=%d completion_tokens=%d est_cost_usd=%.5f",
|
||
resp.model,
|
||
resp.prompt_tokens,
|
||
resp.completion_tokens,
|
||
cost,
|
||
)
|
||
if settings.llm_daily_cost_cap_usd is not None:
|
||
# Сейчас только наблюдаемость: логируем приближение к потолку. Жёсткий
|
||
# enforcement (агрегация по дню) добавит консьюмер при необходимости.
|
||
logger.debug(
|
||
"llm: per-call est_cost_usd=%.5f (daily cap configured=%.2f)",
|
||
cost,
|
||
settings.llm_daily_cost_cap_usd,
|
||
)
|
||
return LLMResult.from_provider(resp)
|
||
|
||
# Недостижимо (цикл всегда return'ит), но mypy/читатель спокойнее с явным fallback.
|
||
return LLMResult.fallback(last_reason)
|
||
|
||
|
||
def complete_or_raise(
|
||
*,
|
||
system_prompt: str,
|
||
payload: SafePayload,
|
||
tools: list[dict[str, Any]] | None = None,
|
||
provider: LLMProvider | None = None,
|
||
call_index: int = 0,
|
||
) -> LLMResult:
|
||
"""Как ``complete``, но при недоступности LLM бросает ``LLMUnavailableError``.
|
||
|
||
Для вызывающих, которым удобнее try/except, чем проверка ``LLMResult.ok``. Семантика
|
||
fallback та же — просто упакована в исключение (вызывающий ловит и подставляет детерм.).
|
||
"""
|
||
result = complete(
|
||
system_prompt=system_prompt,
|
||
payload=payload,
|
||
tools=tools,
|
||
provider=provider,
|
||
call_index=call_index,
|
||
)
|
||
if not result.ok:
|
||
raise LLMUnavailableError(result.reason or "llm_unavailable")
|
||
return result
|
||
|
||
|
||
__all__ = ["LLMResult", "LLMUnavailableError", "complete", "complete_or_raise"]
|