- BrainTools - https://www.braintools.ru -
Я делаю execai — терминальный AI-агент на Go (bubbletea), в духе Claude Code. Он читает файлы, гоняет shell-команды, ходит в kubernetes и стримит ответы в TUI.
В какой-то момент выяснилось, что пользователям нужен не «агент с одной моделью», а мультитул: у кого-то подписка Kimi Code за $19, у кого-то GLM Coding Plan за $18, у кого-то корпоративный ключ Anthropic, а кто-то хочет гонять Ollama локально и не платить вообще. И всё это — в одном чате, с общей историей, с переключением на лету.
Под катом — как устроена мультипровайдерная архитектура: один интерфейс из пяти строк, два несовместимых мира API (Anthropic-compat и OpenAI-compat), SSE-парсеры с аккумуляцией tool calls, динамические каталоги моделей, автодетект тарифа подписки и делегирование в чужие CLI. С реальным кодом и граблями, на которые мы наступили.
Хотелось вот такого UX:
/source ← меню: execai, zai, kimi, kimi-api, anthropic, openai,
claude-cli, codex-cli, ollama
/source kimi ← переключились на Kimi Code
объясни этот код ← отвечает Kimi K3
/source zai ← переключились на GLM-5.2
продолжай ← GLM видит ВСЮ историю разговора, включая ответы Kimi
Ключевые требования:
Общая история. Переключение источника не сбрасывает контекст — следующая модель видит всё, что было до неё.
Общий агентный цикл. Инструменты (Bash, Read, Edit, Grep…), подтверждения опасных команд, лимиты итераций — всё работает одинаково поверх любого провайдера.
Биллинг изолирован. Подписка пользователя — его подписка. Наш бэкенд в запросах к чужим API не участвует вообще.
Весь зоопарк провайдеров прячется за одним интерфейсом:
// StreamingLLM is the standard LLM-provider contract for the tool-use loop.
type StreamingLLM interface {
Stream(ctx context.Context, messages []AIMessage,
tools []map[string]any, cb StreamCallbacks) (*StreamResult, error)
}
На вход — история сообщений и JSON-схемы инструментов. На выход — результат:
type StreamResult struct {
Content string
ToolCalls []ToolCall
FinishReason string
}
// StreamCallbacks — UI callbacks (showing text deltas and tool_call starts).
type StreamCallbacks struct {
OnText func(string) // инкремент видимого текста
OnToolCall func(name string) // модель начала вызывать инструмент — UI покажет "▶ Bash…"
OnReasoning func(string) // chain-of-thought (thinking-модели) — рисуем приглушённо
}
Агентный цикл (tool-use loop) держит StreamingLLM и не знает, куда физически уходят запросы. Переключение источника — это буквально замена одного поля:
m.cli = m.makeLLMClient() // пересоздать клиент под активную подписку
Звучит тривиально. Дьявол, как обычно, в реализациях.
Все девять провайдеров сводятся к двум диалектам HTTP API.
OpenAI-compat (POST /v1/chat/completions, авторизация Bearer): модель отвечает SSE-чанками вида choices[].delta.content, инструменты приходят как delta.tool_calls[] с индексами.
Anthropic-compat (POST /v1/messages, заголовки x-api-key + anthropic-version): события message_start / content_block_delta / message_delta, у thinking-моделей отдельные thinking_delta.
Раскладка получилась такой:
|
Провайдер |
Диалект |
Endpoint |
|---|---|---|
|
execai (наш gateway) |
OpenAI-compat |
api.execai.ru |
|
Z.ai Coding Plan |
Anthropic-compat |
api.z.ai/api/anthropic |
|
Kimi Code (подписка) |
Anthropic-compat |
api.kimi.com/coding |
|
Moonshot Platform (pay-per-token) |
OpenAI-compat |
api.moonshot.ai/v1 |
|
Anthropic API |
Anthropic-compat |
api.anthropic.com |
|
OpenAI API |
OpenAI-compat |
api.openai.com/v1 |
|
Ollama cloud |
Anthropic-compat |
ollama.com |
|
Ollama local |
OpenAI-compat |
localhost:11434 |
|
Claude Code CLI / Codex CLI |
свой формат (об этом ниже) |
локальный бинарь |
Сюрприз №1, стоивший нам вечера: ключ Z.ai Coding Plan работает ТОЛЬКО через Anthropic-совместимый endpoint. Тот же самый ключ в OpenAI-совместимый /chat/completions возвращает 429 Insufficient balance — подписка и pay-per-token у них биллятся раздельно, и «баланс» на pay-per-token стороне нулевой. Мы долго думали, что ключ протух.
Сюрприз №2: у Kimi то же разделение, но жёстче. Kimi Code (kimi.com/code, подписка от $19/мес) и Moonshot Platform (platform.moonshot.ai, оплата за токены) — два разных продукта с разными ключами и разными endpoint’ами, ключи взаимно не подходят. Мы сделали их двумя разными источниками — kimi и kimi-api, чтобы пользователь не гадал.
В коде выбор клиента — обычный switch:
case subscriptions.SourceKimi:
// Kimi Code Coding Plan subscription (kimi.com/code).
// Endpoint: api.kimi.com/coding — Anthropic-compat + thinking.
base := active.BaseURL
if base == "" {
base = "https://api.kimi.com/coding"
}
return llm.NewAnthropicClient(base, active.APIKey, m.current.ID, m.cfg.ThinkingBudget)
case subscriptions.SourceKimiAPI:
// Moonshot Platform pay-per-token API key (platform.moonshot.ai).
base := active.BaseURL
if base == "" {
base = "https://api.moonshot.ai/v1"
}
return llm.NewGLMClient(base, active.APIKey, m.current.ID)
Итого на девять провайдеров хватает четырёх реализаций StreamingLLM: клиент нашего gateway, generic OpenAI-compat клиент, generic Anthropic-compat клиент и обёртки над локальными CLI.
Самая противная часть стриминга — инструменты. Модель отдаёт вызов инструмента не целиком, а размазанным по чанкам: в первом чанке имя функции и кусочек аргументов, дальше — только дельты аргументов. Склеивать надо по индексу:
for _, tc := range ch.Delta.ToolCalls {
idx := tc.Index
existing, ok := toolByIdx[idx]
if !ok {
existing = &ToolCall{Index: idx, ID: tc.ID, Type: tc.Type, Function: tc.Function}
toolByIdx[idx] = existing
if cb.OnToolCall != nil && tc.Function.Name != "" {
cb.OnToolCall(tc.Function.Name) // UI сразу показывает "▶ Bash…"
}
} else {
// Accumulate the arguments deltas.
existing.Function.Arguments += tc.Function.Arguments
}
}
Если склеить неаккуратно — получите невалидный JSON аргументов и инструмент, который «не парсится» через раз. Отдельная радость — reasoning_content: DeepSeek, GLM и Kimi шлют размышления в разных полях (reasoning, reasoning_content, thinking_delta у Anthropic-диалекта), и всё это надо сводить в один колбэк OnReasoning.
Хардкодить список моделей провайдера — путь к вечно протухшему каталогу. Для OpenAI-совместимых источников мы при подключении дёргаем GET /v1/models и строим каталог из того, что реально доступно этому ключу:
// buildOpenAIDynamicCatalog — primary picker takes the best of the available ones.
primaryOrder := []string{"gpt-5", "gpt-5-mini", "o3", "gpt-4.1", "gpt-4o", "o4-mini"}
idsSet := map[string]bool{}
for _, id := range ids {
idsSet[id] = true
}
primaryID := ""
for _, want := range primaryOrder {
if idsSet[want] { // первый по ПРИОРИТЕТУ, а не первый по списку сервера
primaryID = want
break
}
}
Грабля, которую поймал юнит-тест: первая версия итерировала по списку сервера и брала первый приоритетный — в итоге при ids = [gpt-4o, gpt-5] primary становился gpt-4o. Итерировать надо по списку приоритетов.
У Kimi Code есть приятная особенность: GET /coding/v1/models возвращает только те модели, которые доступны на вашем тарифе. Мы этим пользуемся — при подключении и при переключении источника агент сам определяет уровень подписки:
switch {
case has["k3"] && has["kimi-for-coding-highspeed"]:
return "K3 + HighSpeed"
case has["k3"]:
return "K3"
case has["kimi-for-coding"]:
return "K2.7 Code"
}
И статус-бар честно показывает src : kimi (K3 + HighSpeed) — пользователь видит, что его тариф реально включает флагманский Kimi K3, без походов в личный кабинет.
Тем же способом /usage показывает живые квоты: у Kimi Code есть недокументированный-но-стабильный GET /coding/v1/usages с недельным лимитом и rolling-окнами (мы нашли его в исходниках их собственного CLI на GitHub). Из смешного: числа used/limit сервер отдаёт то как int, то как строку — в зависимости от версии. Парсим через json.RawMessage и «гибкий» декодер.
Пара слов о моделях, ради которых всё затевалось — оба флагмана из «дешёвых» подписок оказались рабочими лошадками, а не компромиссом.
Kimi K3 (Moonshot AI, подписка Kimi Code: Moderato $19/мес, дальше Allegretto $39, Allegro $99, Vivace $199 — тарифы отличаются множителем квоты) — флагман с нативным thinking mode и контекстом до 256K (до 1M на старших тарифах). В агентных задачах ведёт себя очень уверенно: сам решает, когда дёрнуть kubectl, аккуратно строит цепочки инструментов, reasoning читается осмысленно. Важная деталь для интеграции: ID моделей строгие — сервер принимает k3, kimi-for-coding, kimi-for-coding-highspeed, а привычное kimi-k3 отдаёт 401, что при отладке выглядит как «ключ не подходит» и знатно путает.
GLM-5.2 (Z.ai, GLM Coding Plan: Lite $18/мес, Pro $72, Max $160; при годовой оплате заметно дешевле) — MoE 753B/40B с dual thinking, заточенный под кодинг. Из необычного — суффикс-модификатор контекста прямо в ID модели: glm-5.2[1m] включает окно в 1M токенов. По нашему опыту [1] GLM-5.2 — лучший «рефакторщик» в этой ценовой категории: длинные правки по многим файлам держит стабильно.
Обе подписки покупаются без карты западного банка, что для части нашей аудитории решающий фактор.
Отдельный трюк — источники claude-cli и codex-cli. Если у пользователя уже стоит Claude Code с подпиской Pro/Max или Codex CLI с ChatGPT-подпиской, мы не просим API-ключ: делегируем запросы в локальный бинарь, который сам ходит со своей OAuth-сессией.
cmd := exec.CommandContext(ctx, c.Path,
"--print", "--output-format", "stream-json", "--include-partial-messages")
cmd.Stdin = strings.NewReader(flattenMessagesToPrompt(messages))
claude -p --output-format stream-json отдаёт JSONL, внутри которого — знакомые Anthropic-события (message_start, content_block_delta…). Парсим их тем же кодом, что и прямой API. История разговора уходит одним плоским промтом с тегами ролей — session-id у чужого CLI нам недоступен, но для «продолжи мысль» этого достаточно.
Ограничение честно показываем пользователю: наши инструменты (Bash/Read/Write) через делегирование не работают — у Claude Code свои и своя система разрешений.
Самое коварное в мультипровайдерности — не подключение, а переключение. Три инварианта, выстраданные багами:
1. Одинаковые ID в разных каталогах. glm-5.2 есть и у Z.ai, и в Ollama cloud. Если при переключении искать модель только по ID — можно взять запись из чужого каталога с чужим Provider и уйти запросом не туда. Правило: при смене источника модель ищется в НОВОМ каталоге, и берётся именно его запись:
func pickForNewCatalog(catalog []llm.Model, current llm.Model) llm.Model {
for _, mm := range catalog {
if mm.ID == current.ID {
return mm // same ID — but THIS catalog's entry (правильный Provider)
}
}
// ID нет в новом каталоге — берём primary
...
}
2. Возврат на дефолтный источник должен восстанавливать снапшот. После /source zai → /source execai в каталоге не должно остаться GLM-моделей: иначе запрос уйдёт в наш gateway с provider=zai и получит 401. Держим снимок исходного каталога и восстанавливаем его.
3. Клиент пересоздаётся всегда. Ленивая оптимизация «клиент тот же, поменяю только модель» ломается на смене типа клиента (Anthropic-compat ↔ OpenAI-compat). Пересоздание — копеечное, багов — на вечер.
Ядро — интерфейс из пяти строк и четыре его реализации. Всё остальное — аккуратная сантехника: два диалекта SSE, склейка tool calls, динамические каталоги, инварианты переключения. Зато пользователь получает одну команду /source и свободу: Kimi K3 по подписке Kimi Code, GLM-5.2 по GLM Coding Plan, Claude по ключу, Ollama бесплатно локально — в одном чате с общей историей.
Код открыт (Business Source License): github.com/execai/execai-agent — там же README на пяти языках и бинарники под Linux/macOS/Windows. Поставить:
curl -fsSL https://raw.githubusercontent.com/execai/execai-agent/main/install.sh | bash
Вопросы по архитектуре с удовольствием отвечу в комментариях. В следующей статье — сага о том, как мы делали выделение текста «как в Claude Code» и поймали deadlock в bubbletea на ровном месте.
Автор: Yason_DA
Источник [2]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/33801
URLs in this post:
[1] опыту: http://www.braintools.ru/article/6952
[2] Источник: https://habr.com/ru/articles/1065118/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1065118
Нажмите здесь для печати.