Пять LLM-провайдеров через один openai-клиент

от автора

У нас почти каждая заметная операция в продукте идёт через 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 → … в логах, а не сорока сообщениями от клиентов.

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


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

ссылка на оригинал статьи https://habr.com/ru/articles/1046902/