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) — тянуть зависимость ради адаптера нежелательно.
Рассмотренные варианты
- OpenAI-совместимый адаптер (выбран). Один адаптер на
base_url+api_keyиз конфига; работает с любым OpenAI-compatible free-провайдером (Groq, OpenRouter, Together, локальный vLLM/Ollama). Интерфейс провайдер-агностичный, смена провайдера = смена ENV.- Плюсы: минимум привязки, максимально «сменный», единый формат chat/embeddings, тесты на моках без живого ключа.
- Минусы: эмбеддинги доступны лишь у части OpenAI-compatible эндпоинтов (зависит от выбранного провайдера).
- Локальный Ollama (self-host). Без внешних ключей и гео/санкционных рисков; chat+embeddings на VPS. Минусы: нагрузка CPU/RAM на VPS заказчика, медленнее, нужен запущенный демон.
- Российский провайдер (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-retryOP-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 уточнит провайдер эмбеддингов (тот же интерфейс) без изменения абстракции.