У нас почти каждая заметная операция в продукте идёт через LLM: генерация follow-up, сборка КП, скоринг, саммари звонков. Пока провайдер один — это бомба замедленного действия. Он ложится по 503, упирается в рейт-лимит, или цена улетает, потому что дешёвый разбор команды почему-то крутится через флагманскую модель.

Поэтому мы сделали тонкий роутер. Не фреймворк, не «оркестратор агентов» — примерно 500 строк на NestJS, которые переезжают между нашими продуктами без правок. Расскажу, что внутри и на чём набили шишки.

Один клиент вместо зоопарка SDK

Почти все провайдеры отдают OpenAI-совместимый API. Значит, тащить пять разных SDK незачем — берём официальный openai и подменяем baseURL:

const groq    = new OpenAI({ apiKey: GROQ_KEY,     baseURL: 'https://api.groq.com/openai/v1' });
const mistral = new OpenAI({ apiKey: MISTRAL_KEY,  baseURL: 'https://api.mistral.ai/v1' });
const deepseek= new OpenAI({ apiKey: DEEPSEEK_KEY, baseURL: 'https://api.deepseek.com' });
const xai     = new OpenAI({ apiKey: XAI_KEY,      baseURL: 'https://api.x.ai/v1' });
const openai  = new OpenAI({ apiKey: OPENAI_KEY,   baseURL: 'https://api.openai.com/v1' });

Дальше один и тот же client.chat.completions.create(...) работает для всех. Клиент создаётся только при наличии ключа, иначе null. Побочный бонус: нет ключа — провайдер просто выпадает из работы, ничего не падает. Конфиг с тремя провайдерами и конфиг со всеми пятью гоняют один и тот же код.

Вызывающему коду незачем знать про модели

Ему важен класс задачи: написать качественно, распарсить дёшево, отдать строгий JSON, расшифровать аудио. Под каждый класс — своя стратегия, а за стратегией прячется упорядоченная цепочка «провайдер + модель»:

this.strategyChains = {
  // лучший русский (КП, follow-up): reasoning-модель впереди
  QUALITY:    [groqQuality, groqLarge, mistralMedium, openai, deepseek, xai],
  // скоринг, сравнения, саммари: мультиязычность + структура
  BALANCED:   [groqLarge, groqStructured, mistralMedium, groqQuality, openai, xai],
  // парсинг интента, извлечение JSON: что подешевле
  FAST:       [groqFast, groqStructured, mistralSmall, openai],
  // аудио → текст: Whisper turbo, потом base, потом OpenAI whisper-1
  TRANSCRIBE: [groqWhisperTurbo, groqWhisperBase, whisperOai],
}.mapValues(c => c.filter(Boolean)); // неконфигурированные выкидываем

В большинстве цепочек первым стоит Groq. Клиент один, но моделей под ним четыре — каждая под свою роль:

Роль

Модель

За что отвечает

quality

openai/gpt-oss-120b

reasoning, сильный текст, ~500 tok/s

large

llama-3.3-70b-versatile

мультиязычность, 131k контекста

structured

qwen/qwen3-32b

JSON-mode и structured output

fast

llama-3.1-8b-instant

0.05/0.08 за миллион, 560 tok/s

Mistral идёт кросс-провайдерным fallback’ом, дальше — OpenAI/DeepSeek/xAI, если их ключи заданы.

Сам fallback — это цикл, без магии

Идём по цепочке. На первом провайдере один раз ретраим с паузой. Успех — логируем стоимость и возвращаем. Не вышло — едем к следующему. Легли все — кидаем последнюю ошибку наверх:

for (let i = 0; i < chain.length; i++) {
  const provider = chain[i];
  try {
    const res = await this.callChat(provider, opts);
    if (i > 0) this.logger.warn(`AI fallback → ${provider.providerName} (${provider.model})`);
    await this.logCall(provider, opts, res, null);   // учёт стоимости
    return res;
  } catch (err) {
    lastError = err;
    if (i === 0) {                                    // ретраим только первого
      await sleep(2000);
      try { return await this.callChat(provider, opts); } catch (e) { lastError = e; }
    }
  }
}
throw lastError;

Отдельно стоит сказать про пустой ответ. Модель может вернуть finish_reason и при этом пустой content — формально успех, по факту мусор. Если такое отдать наверх как пустую строку, клиенту уходит follow-up из воздуха. Поэтому пустой контент мы считаем ошибкой и уходим к следующему провайдеру.

Где reasoning-модели кусаются

gpt-5 и gpt-oss-* не едят привычные max_tokens и temperature. У них max_completion_tokens и reasoning_effort, и reasoning-токены списываются из того же completion-бюджета. Поставишь бюджет впритык — модель «думает», упирается в лимит и не доходит до ответа. Поэтому закладываем с запасом:

const isReasoning = m.startsWith('gpt-5') || m.startsWith('openai/gpt-oss');
if (isReasoning) {
  params.max_completion_tokens = (opts.maxTokens ?? 2048) * 2;
  params.reasoning_effort = 'low';   // для нетворческих задач — дёшево и быстро
} else {
  params.temperature = opts.temperature ?? 0.5;
  params.max_tokens   = opts.maxTokens ?? 2048;
}

И ещё одна мелочь, на которую уходит полчаса недоумения: Qwen 3 подмешивает в ответ <think>…</think>. Это его внутренние рассуждения, не ответ пользователю. Вырезаем регуляркой. А когда просишь JSON, а модель отвечает «Конечно, вот ваш JSON: {…}» — отдельный stripMarkdown достаёт из этой преамбулы первый валидный объект или массив.

Whisper врёт, и это надо ловить

На тишине и шуме Whisper галлюцинирует — обычно повторяет одну фразу десятки раз. Принимать такое за транскрипт нельзя. Проверяем длину и долю уникальных слов; если повторов слишком много — бракуем и идём дальше по цепочке:

isTranscriptValid(text) {
  if (text.length < 50) return false;
  return this.uniqueWordRatio(text) >= 0.15;
}

Деньги считаем на каждый вызов

Каждый успешный запрос пересчитывается по прайсу провайдера (вход и выход за миллион токенов) и пишется в LLMCallLog: операция, стратегия, провайдер, модель, токены, стоимость в центах, длительность. Плюс в Prometheus. Без этого не видно, во сколько обходится конкретная фича и какой провайдер реально работает, а какой просто числится в цепочке для красоты.

const costUsd = tokensIn/1e6 * pricing.inputPerM + tokensOut/1e6 * pricing.outputPerM;
await prisma.lLMCallLog.create({ data: { operation, provider, model, tokensIn, tokensOut, costCents } });

Самое полезное здесь — добавить провайдера стоит одну строку new OpenAI({ baseURL }) плюс место в нужной цепочке. Вендор лёг ночью — утром это видно одной строкой AI fallback → … в логах, а не сорока сообщениями от клиентов.

А почему не готовый роутер?

Логичный вопрос — велосипед тут не обязателен, готовых решений хватает:

  • OpenRouter — hosted: один ключ, один OpenAI-совместимый эндпоинт, сам роутит по провайдерам с fallback и прайсингом. Ноль инфры. Если не хочется ничего держать у себя — начинать стоит отсюда.

  • LiteLLM — де-факто стандарт. SDK плюс прокси-гейтвей: 100+ провайдеров, fallback, retry, бюджеты, кэш, учёт стоимости, логи. Покрывает всё, что выше, и заметно больше.

  • Portkey — тот же набор как AI-gateway (open-source + hosted), плюс guardrails и observability.

  • Vercel AI SDK — если стек на TypeScript, даёт унифицированные провайдеры и fallback из коробки.

  • Есть и семантические роутеры (Not Diamond, Martian, Unify) — выбирают модель под конкретный запрос, а не статичной цепочкой. Это уже на шаг умнее.

Почему у нас всё-таки своё — не из любви к велосипедам. Нужен был тонкий слой без лишнего прокси-хопа в критическом пути, с учётом стоимости прямо в нашу БД рядом с доменными данными и с логикой вроде валидации Whisper-галлюцинаций и обработки reasoning-моделей под наши промпты. Под капотом всё равно официальный openai SDK — низкоуровневые ретраи и HTTP мы не переписывали.

Грубое правило: нужны бюджеты, кэш, дашборды и сотня провайдеров из коробки — берите LiteLLM или OpenRouter, своё не окупится. Нужен полный контроль и тесная интеграция со своим стеком, а провайдеров три-пять — тонкий слой поверх openai-SDK честно проще, чем кажется.

Минимальный рабочий роутер из этой статьи (без БД, фреймворка и секретов) выложил на гитхаб — можно склонировать и погонять: github.com/ai-sales-agency/wiin-examples.


Это кусок стека, который мы в wiin.agency сначала поставили себе, а потом стали внедрять клиентам — ИИ-агентов в отделы продаж. Пишем про то, что реально крутится в проде; остальное — в блоге.