"""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, 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. 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 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), 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 e.__class__.__name__ == "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"]