Перейти к основному содержимому

0014. Абстракция LLM-провайдера через OpenAI-совместимый адаптер

Копия ADR из репозитория: backend/docs/adr/0014-llm-provider-abstraction.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-06-24
  • Связанные задачи: OP-47 (F7 · общий слой интеграции LLM)

Контекст и проблема​

Модули 5.5, 5.9, 5.12, 5.13 используют LLM. ТЗ: «на старте — любая бесплатная LLM», при этом провайдер должен быть сменным. Нужен единый интерфейс генерации с подключаемыми провайдерами и базовой устойчивостью (таймауты, повторы, учёт использования).

Развилка требовала решения человека: какой конкретный бесплатный провайдер взять стартовым адаптером. Ограничения, влияющие на выбор:

  • RU-VPS заказчика (optimawb.ru): многие западные провайдеры (OpenAI, Anthropic, Google) гео-блокируют / санкционно недоступны с российских IP.
  • OP-49 (RAG) далее потребует эмбеддингов — интерфейс должен предусматривать embeddings.
  • В package.json нет HTTP-клиента (axios/@nestjs/axios) — тянуть зависимость ради адаптера нежелательно.

Рассмотренные варианты​

  1. OpenAI-совместимый адаптер (выбран). Один адаптер на base_url+api_key из конфига; работает с любым OpenAI-compatible free-провайдером (Groq, OpenRouter, Together, локальный vLLM/Ollama). Интерфейс провайдер-агностичный, смена провайдера = смена ENV.
    • Плюсы: минимум привязки, максимально «сменный», единый формат chat/embeddings, тесты на моках без живого ключа.
    • Минусы: эмбеддинги доступны лишь у части OpenAI-compatible эндпоинтов (зависит от выбранного провайдера).
  2. Локальный Ollama (self-host). Без внешних ключей и гео/санкционных рисков; chat+embeddings на VPS. Минусы: нагрузка CPU/RAM на VPS заказчика, медленнее, нужен запущенный демон.
  3. Российский провайдер (GigaChat/YandexGPT). Гарантированная достижимость с RU-VPS, free/trial. Минусы: собственный API-формат (не OpenAI) → отдельный адаптер, регистрация/токен у провайдера.

Решение принято человеком (Pavel, 2026-06-24) — вариант 1. Варианты 2 и 3 остаются реализуемыми позже как дополнительные адаптеры за тем же интерфейсом LlmProvider (на то и абстракция).

Решение​

Модуль src/modules/llm/ (по аналогии с modules/wb-api):

  • Интерфейс LlmProvider (llm.types.ts): name, chat(request, options?), generate(request, options?) (тонкая обёртка над chat), embeddings(request, options?). Типы запросов/ответов нейтральны к провайдеру; LlmUsage несёт promptTokens/completionTokens/ totalTokens.
  • OpenAiCompatibleProvider на нативном fetch (Node 22) + AbortSignal.timeout — без axios. POST {baseUrl}/chat/completions и POST {baseUrl}/embeddings, Authorization: Bearer <key> (заголовок ставится только если ключ задан — локальные эндпоинты могут не требовать его).
  • Устойчивость (llm-retry.ts, зеркало wb-retry OP-34): retry на 429/5xx/сетевых сбоях с экспоненциальным backoff + jitter; на 401/403/4xx — без повторов; 429 с Retry-After → его значение (с верхним пределом LLM_MAX_RETRY_AFTER_MS).
  • Выбор провайдера из конфига. Фабрика DI-токена LLM_PROVIDER (llm.module.ts) свитчит по LLM_PROVIDER (пока единственный кейс openai-compatible). Модуль @Global() — общий слой F7, фиче-модули инжектят LLM_PROVIDER без явного импорта.
  • Логирование запросов (DoD): structured Logger-события с model, токенами, durationMs, status, code. В лог не попадают ни api-key, ни контент промптов/ответов (приватность, согласовано с философией редактирования секретов OP-44).

Последствия​

  • Появился общий слой F7: фиче-модули (5.5/5.9/5.12/5.13) генерируют через LLM_PROVIDER и не знают о конкретном провайдере.
  • Старт без LLM-конфига. LLM_* в env-схеме — опциональны (кроме дефолтов таймаутов/retry); отсутствие LLM_BASE_URL/LLM_MODEL бросает LlmConfigError в момент вызова, а не на bootstrap → приложение и существующие e2e стартуют без настроенного LLM.
  • Конкретный free-провайдер по умолчанию документируется в backend/.env.example (закомментированный блок) — выбирается достижимый с RU-VPS OpenAI-compatible эндпоинт.
  • Без новых зависимостей — нативный fetch.
  • Расчёт стоимости в валюте — отложен (follow-up): нужна per-model прайс-таблица; пока «учёт использования» = токены из usage (в ответе и логах).
  • Эмбеддинги выбранного дефолтного эндпоинта могут быть недоступны — тогда OP-49 уточнит провайдер эмбеддингов (тот же интерфейс) без изменения абстракции.