← Блог

Как я подключил 350+ AI-моделей через один API и перестал переписывать интеграции

NeuralGate AI Gateway

Привет! Меня зовут Никита, я backend-разработчик, пишу на Go и PHP. Делаю свой продукт — AI-gateway, через который прошло 2M+ запросов к LLM за последние месяцы. В этой статье расскажу, зачем я его написал, покажу архитектуру и код, и честно объясню, где набил шишки.

Проблема, которую решаю: вы интегрировали OpenAI, потом понадобился Claude, потом заказчик хочет YandexGPT «потому что российское», а DeepSeek дешевле всех — и вот у вас четыре SDK, четыре формата ответов, четыре системы биллинга и ни одного нормального fallback'а.


Зоопарк LLM в 2026

Если вы уже в теме — смело пролистайте до «Проблема: каждый провайдер — свой мир»

Ещё в 2023 мир LLM был простым: GPT-4 для сложного, GPT-3.5 для дешёвого, done. Сегодня:

ПровайдерМоделиСильная сторона
OpenAIGPT-5, GPT-4o, o3/o4-miniУниверсальность, экосистема
AnthropicClaude Opus 4.6, Sonnet 4.6, Haiku 4.5Тексты, код, контекст 1M токенов
GoogleGemini 3 Pro, Flash 2.5Мультимодальность, скорость
ЯндексYandexGPT Lite/Pro/Pro 32KРусский язык, рос. юрисдикция
СберGigaChat/GigaChat ProРусский язык, compliance
DeepSeekV3.2, R1Reasoning, низкая цена
AlibabaQwen 3.6, Qwen3 235BБесплатные модели
xAIGrok 4Без цензуры, свежие данные
MetaLlama 4Open-source, self-hosted

Таблица актуальна на апрель 2026

Плюс open-source модели на HuggingFace (500K+ моделей) и локальный запуск через Ollama (тема для отдельной статьи).

Три тренда, важных для архитектуры:

Почему OpenAI API стал стандартом

Формат оказался настолько удачным, что его скопировали почти все: messages (массив {role, content}), model (строка), stream (boolean). Это стало lingua franca мира LLM. Любой существующий код работает без изменений — меняется только base_url.


Проблема: каждый провайдер — свой мир

ПровайдерAuthФормат messagesStreaming
OpenAIBearer token{role, content}SSE
Anthropicx-api-keysystem отдельноSSE (другой формат)
YandexGPTIAM + folder-idСвой форматgRPC stream
GigaChatOAuth2 (TTL 30 мин)Почти OpenAISSE (свой)
GeminiAPI keyparts, contentsSSE (ещё один)

Пять провайдеров — пять интеграций, пять обработчиков ошибок, пять форматов стриминга.

Решение: OpenAI-совместимый gateway на Go

Назвал его НейроГейт. Идея простая: один API, один формат, один ключ.

Ваш код  ──── OpenAI format ────►  НейроГейт (Go)  ──── Native API ────►  Провайдер
             POST /v1/chat/            translate &                         (OpenAI, Claude,
             completions               route                              YandexGPT, GigaChat)

С точки зрения вашего кода — вы работаете с OpenAI SDK:

import openai

client = openai.OpenAI(
    api_key="ng-proj-ваш-ключ",
    base_url="https://api.neuralgate.ru/v1"
)

# Работает одинаково для любой модели
response = client.chat.completions.create(
    model="claude-sonnet-4-20250514",
    messages=[{"role": "user", "content": "Привет!"}],
    stream=True
)

for chunk in response:
    print(chunk.choices[0].delta.content, end="")

Go-версия — аналогично:

cfg := openai.DefaultConfig("ng-proj-ваш-ключ")
cfg.BaseURL = "https://api.neuralgate.ru/v1"
client := openai.NewClientWithConfig(cfg)

stream, err := client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
    Model:    "yandexgpt-pro",
    Messages: []openai.ChatCompletionMessage{{Role: "user", Content: "Привет!"}},
})

Меняется одна строка — BaseURL. Всё остальное — ваш существующий код.

Стек

Почему без Nginx? Nginx добавляет latency на каждом SSE-чанке. Для 500 параллельных стримов по 10-60 секунд — ощутимо. Go'шный crypto/tls справляется.

Архитектура роутинга

Request Auth Rate Limit Route Translate Provider Translate Back Response

Auth

Ключ формата ng-proj-{32 hex}. Зарегистрирован как GitHub secret scanning prefix — если ваш ключ утечёт в публичный репо, GitHub автоматически уведомит нас.

Rate Limiting

Sliding window на Redis через ZRANGEBYSCORE + ZADD. RPM и TPM per key. Redis down — in-memory fallback:

func (rl *RateLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) bool {
    allowed, err := rl.redisAllow(ctx, key, limit, window)
    if err != nil {
        // Redis down — лучше пропустить лишний запрос, чем заблокировать всех
        rl.metrics.RedisFailover.Inc()
        return rl.memoryAllow(key, limit, window)
    }
    return allowed
}

Translate

Каждый провайдер имеет свой Translator:

type Translator interface {
    TranslateRequest(ctx context.Context, req *ChatCompletionRequest) (*http.Request, error)
    TranslateResponse(resp *http.Response) (*ChatCompletionResponse, error)
    TranslateStream(ctx context.Context, resp *http.Response) (<-chan ChatCompletionChunk, <-chan error)
}

YandexGPT: маппит model: "yandexgpt-pro"modelUri: "gpt://folder-id/yandexgpt/latest", добавляет IAM-токен. GigaChat: OAuth2 с auto-refresh (TTL 30 мин).

SSE Streaming: где живут баги

SSE Streaming

SSE — HTTP-ответ с Content-Type: text/event-stream, который шлёт данные порциями:

data: {"choices":[{"delta":{"content":"Привет"}}]}

data: {"choices":[{"delta":{"content":"! Как"}}]}

data: [DONE]

Каждый стрим — горутина с обработкой отмены и ошибок:

func (g *Gateway) streamResponse(ctx context.Context, w http.ResponseWriter,
    providerStream <-chan Chunk, errCh <-chan error, translator Translator) {

    flusher := w.(http.Flusher)

    for {
        select {
        case <-ctx.Done():
            // Клиент дисконнектился
            return
        case err := <-errCh:
            // Провайдер упал посреди стрима
            fmt.Fprintf(w, "data: {\"error\":\"%s\"}\n\n", err.Error())
            flusher.Flush()
            return
        case chunk, ok := <-providerStream:
            if !ok {
                fmt.Fprintf(w, "data: [DONE]\n\n")
                flusher.Flush()
                return
            }
            translated := translator.TranslateChunk(chunk)
            fmt.Fprintf(w, "data: %s\n\n", translated.JSON())
            flusher.Flush()
        }
    }
}

Flush() после каждого чанка — критично. ctx.Done() — критично. Без них горутина живёт вечно при дисконнекте.

Грабли, на которые мы наступили

Production Bugs

Каждая из этих проблем стоила часов дебага в продакшне. Делюсь, чтобы вы не наступали.

Грабля #1: YandexGPT шлёт невалидный JSON в середине стрима

Один из 500-600 стримов содержит обрезанный чанк: {"choices":[{"delta":{"conte и всё. Не HTTP-ошибка — просто битый JSON. Пики коррелируют с нагрузкой на стороне Яндекса.

Решение: трёхуровневый парсер: json.Decoder → ручное извлечение content → drop chunk + метрика stream_parse_errors в Grafana.

Урок: никогда не доверяй формату ответа LLM-провайдера на 100%. Defensive parsing обязательно.

Грабля #2: GigaChat OAuth token протухает посреди стрима

Access token TTL = 30 минут. Запрос на 29-й минуте, стрим 3 минуты — токен протух посередине. 80% ответа отправлено, повторить нельзя.

Решение: proactive refresh — фоновая горутина обновляет токен за 2 минуты до expiry.

Урок: OAuth2 с коротким TTL + long-running SSE = гарантированный баг.

Грабля #3: мультимодальный контент ломает парсер

Gemini и GPT-5 возвращают delta.content как массив вместо строки при генерации изображений. Translator дропал весь чанк. Изображения исчезали без ошибок в логах.

Решение: type switch — строка → старый путь, массив → итерируем по элементам.

Урок: мультимодальные модели нарушают «content = строка». Ловушка для любого gateway.

Грабля #4: race condition на балансе

Два параллельных запроса: оба читают 100₽, оба списывают 60₽. Итого: -120₽, баланс в минусе.

Решение: атомарный UPDATE ... WHERE balance >= $cost RETURNING balance. Плюс Redis mutex для длинных стримов.

Урок: read-then-write на балансе — классический race. Всегда атомарный UPDATE с WHERE.

Грабля #5: деньги зависли в резерве при disconnect

Reserve → Stream → Commit. Клиент закрыл вкладку на 40%. Провайдер потратил токены, Commit не вызван.

Решение: defer Release как safety net + sweep зависших резерваций старше 5 минут при старте.

Урок: двухфазные операции требуют recovery при всех вариантах отказа.

Грабля #6: revocation убивает активные стримы

При refresh JWT сразу отзывали старый токен. Но SSE-стрим на старом токене → 401 → обрыв.

Решение: не отзывать при refresh. Пусть старый живёт до expiry (7d TTL).

Урок: token revocation при refresh — антипаттерн для long-lived connections.

Грабля #7: LLM генерирует внутренние теги

System prompt: <!--buttons:[...]-->. Модель «заражается» и вставляет теги в ответ пользователю.

Решение: тройная защита — промпт + backend strip + frontend strip.

Урок: всё из system prompt LLM может скопировать в ответ. Sanitize output всегда.

Грабля #8: BLPOP блокирует shared Redis

BLPOP с таймаутом 120с блокирует соединение. Все остальные операции встают в очередь.

Решение: выделенное соединение per BLPOP.

Урок: blocking Redis commands — никогда на shared connection.

Грабля #9: Redis мьютекс зависает навечно

SETNX + EX 20. Crash — лок должен expire. Но иногда TTL = -1 (no expiry) при persistence restore.

Решение: проверять TTL при acquire, force delete если -1. Периодический sweep.

Урок: Redis TTL может стать -1. Не доверяйте EX — проверяйте TTL.

Грабля #10: TLS fingerprint выдаёт не-браузера

Go WebSocket к серверу — те же заголовки, но JA3 fingerprint ≠ браузерный. Сервер блокирует.

Решение: websocat (Rust CLI) как subprocess — browser-like TLS fingerprint.

Урок: TLS fingerprinting — реальность 2026. Нужны uTLS (Go) или websocat (Rust).

Circuit Breaker

Circuit Breaker Pattern
Closed → 5 ошибок/30с → Open → 30с → Half-Open → успех → Closed

Нюанс для стриминга: провайдер ответил 200 OK, начал стрим, умер на 40-м чанке. Считаем ошибкой для circuit breaker, только если получено менее 10% ожидаемых токенов.

Биллинг: pre-debit / post-adjust

// Фаза 1: резервируем по верхней границе
reservation, err := billing.Reserve(orgID, estimatedCost)
if err != nil {
    return ErrInsufficientBalance
}
defer billing.Release(reservation) // safety net

// Фаза 2: после ответа — списываем по факту
actualCost := calcCost(usage.PromptTokens, usage.CompletionTokens, route)
billing.Commit(reservation, actualCost)

Атомарный UPDATE balance SET amount = amount - $cost WHERE amount >= $cost. defer Release — safety net при crash. Sweep зависших резерваций при старте.

Latency: куда уходят миллисекунды

Этапp50p99
TLS termination0мс (keep-alive)15мс (новое)
Auth (Redis)2мс8мс
Rate limit3мс12мс
Route (memory)<1мс<1мс
Translation2мс5мс
Итого overhead~8мс~40мс
TLS к провайдеру30мс150мс
Итого до first byte~40мс~200мс

Зачем, если есть OpenRouter?

  1. Нет YandexGPT и GigaChat. Compliance-требование для РФ, не каприз.
  2. Биллинг в рублях. Счёт от юрлица, акт, договор-оферта. Без проблем с 115-ФЗ.
  3. Данные в РФ. Gateway в Яндекс Cloud. Промпты не через зарубежный роутер.
  4. BYOK. Свой ключ OpenAI/Anthropic/Яндекс. Или Ollama как custom provider.

Есть ещё LiteLLM — open-source Python-прокси. Для self-hosted — достойный выбор. НейроГейт — managed + рубли + российские модели.

Compliance и данные

Где сервер?Яндекс Cloud, РФ
Логируются промпты?Opt-in. По умолчанию — только метаданные
Только российские модели?Да, ограничение в настройках org
152-ФЗДанные в РФ, шифрование at rest
ЮрлицоИП, договор-оферта, акты
ОплатаYooKassa, SBP, счёт для юрлиц
ФСТЭКПока нет. Работаем над этим

Цифры

Попробовать

Бесплатный tier: Qwen, StepFun, Nemotron — 5 RPM. 100₽ welcome-бонус.

neuralgate.ru →

Telegram: @neuralgate_dev


Дальше в серии: бенчмарк YandexGPT vs Claude vs GPT-5 vs DeepSeek — с ценами в рублях. И отдельная статья про Ollama: локальный запуск LLM, квантизация, выбор GPU.