Привет, Хабр! Я делаю 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
/source — пикер провайдеров: 9 источников со статусами подключения
/source — пикер провайдеров: 9 источников со статусами подключения

Ключевые требования:

  1. Общая история. Переключение источника не сбрасывает контекст — следующая модель видит всё, что было до неё.

  2. Общий агентный цикл. Инструменты (Bash, Read, Edit, Grep…), подтверждения опасных команд, лимиты итераций — всё работает одинаково поверх любого провайдера.

  3. Биллинг изолирован. Подписка пользователя — его подписка. Наш бэкенд в запросах к чужим 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() // пересоздать клиент под активную подписку

Звучит тривиально. Дьявол, как обычно, в реализациях.

Два мира: Anthropic-compat и OpenAI-compat

Все девять провайдеров сводятся к двум диалектам 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.

SSE-парсер: аккумуляция tool calls по индексам

Самая противная часть стриминга — инструменты. Модель отдаёт вызов инструмента не целиком, а размазанным по чанкам: в первом чанке имя функции и кусочек аргументов, дальше — только дельты аргументов. Склеивать надо по индексу:

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.

GLM-5.2 через Ollama Cloud отвечает на вопрос про interface в Go — виден reasoning
GLM-5.2 через Ollama Cloud отвечает на вопрос про interface в Go — виден reasoning

Динамические каталоги моделей

Хардкодить список моделей провайдера — путь к вечно протухшему каталогу. Для 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 и «гибкий» декодер.

/usage — живые квоты Kimi Code: тариф K3+HighSpeed, недельный лимит 48%, rolling 5ч окно
/usage — живые квоты Kimi Code: тариф K3+HighSpeed, недельный лимит 48%, rolling 5ч окно

На чём это реально работает: Kimi K3 и GLM-5.2

Пара слов о моделях, ради которых всё затевалось — оба флагмана из «дешёвых» подписок оказались рабочими лошадками, а не компромиссом.

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 токенов. По нашему опыту GLM-5.2 — лучший «рефакторщик» в этой ценовой категории: длинные правки по многим файлам держит стабильно.

Обе подписки покупаются без карты западного банка, что для части нашей аудитории решающий фактор.

Чужой CLI как LLM-провайдер

Отдельный трюк — источники 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). Пересоздание — копеечное, багов — на вечер.

Kimi K3 проверяет живой Kubernetes-кластер: 5 нод Ready, 89 подов Running
Kimi K3 проверяет живой Kubernetes-кластер: 5 нод Ready, 89 подов Running

Что в итоге

Ядро — интерфейс из пяти строк и четыре его реализации. Всё остальное — аккуратная сантехника: два диалекта 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 на ровном месте.

Комментарии (22)


  1. smarkelov
    31.07.2026 07:03

    opencode нет?


    1. Yason_DA Автор
      31.07.2026 07:03

      Есть, и он крупнее нас на порядок — 160k+ звёзд, 75+ провайдеров, LSP, десктоп и панель в IDE. В статье не упомянул, это мой промах.

      По сути opencode стоит в другом месте. Мы делегируем в чужой CLI только там, где у него своя подписка, которую человек уже оплатил: Claude Code с Pro/Max, Codex CLI с ChatGPT. Смысл делегирования — в чужой OAuth-сессии, доступа к которой у нас нет. У opencode своей подписки нет, он такой же BYO-key, как и мы: делегировать нечего, ключи будут те же самые.

      Чем отличаемся без маркетинга: оплата российской картой как альтернатива своим ключам, интерфейс на 5 языках, /usage с реальными квотами по каждому источнику (у Kimi Code — недельный лимит и 5-часовое окно). Встречно: opencode полноценно открытый и с огромным комьюнити, у нас BSL 1.1, source-available. Нужен вендоронейтральный харнесс — это к ним.


      1. smarkelov
        31.07.2026 07:03

        Хороший бот, молодец.


        1. Yason_DA Автор
          31.07.2026 07:03

          Заслуженно. Пишу статью про агента, отвечаю агентом — сам виноват.


  1. Bardakan
    31.07.2026 07:03

    1. Общая история. Переключение источника не сбрасывает контекст — следующая модель видит всё, что было до неё.

    А что вы тогда делаете, когда переключаетесь на модель с меньшим объемом контекста?

    И что с расходом токенов при переключении модели?


    1. Yason_DA Автор
      31.07.2026 07:03

      Оба вопроса бьют в слабое место, отвечу как есть.

      Меньшее окно. Защиты сейчас нет никакой: история уходит новой модели целиком, не влезло — прилетает ошибка провайдера про длину контекста. Автообрезки нет, есть ручной /compact (суммаризация всего, кроме system и последних 6 сообщений). Хуже того, в каталоге моделей у нас вообще нет поля с размером окна — то есть предупредить заранее агент технически не может. Чинить буду так: тянуть context window в каталог и при переключении на модель с меньшим окном предупреждать или сразу предлагать /compact.

      Токены. Переключение источника = вся история отправляется заново, у нового провайдера prompt-кэш холодный. Внутри одного провайдера кэш работает, между провайдерами не может физически. Поэтому первый запрос после переключения стоит как полный контекст, и частые прыжки реально дороже. Это прямая цена за общую историю: переключаться стоит осознанно, а не ради каждой реплики.


  1. Ryav
    31.07.2026 07:03

    Нет опасений бана, что используется подписка Kimi вне Kimi Code?


    1. Yason_DA Автор
      31.07.2026 07:03

      Вопрос по делу, и ответ неудобный: формально мы в серой зоне.

      Их собственные документы противоречат друг другу. Community Guidelines: “compatible with mainstream coding tools and agent frameworks (Kimi CLI, VS Code, Claude Code, OpenCode, OpenClaw, etc.)” — список открытый. А в Help Center про сторонних агентов жёстче: “Kimi Code benefits are only supported in Kimi Code CLI, Claude Code, and Roo Code” и “Using your API Key with unauthorized platforms or tools may be considered a violation and could result in restricted access”.

      Что мы точно не нарушаем:

      • не подменяем идентификатор клиента (у них прямой запрет “Don’t spoof or alter client identity information”) — под Claude Code не маскируемся, код открыт, проверяется;

      • использование персональное и интерактивное: это TUI, за которым сидит человек, а не батч-пайплайн;

      • не перепродаём — ключ лежит у пользователя на машине, наш бэкенд в этих запросах не участвует вообще.

      Если совсем честно: сейчас уходит дефолтный Go-шный User-Agent. Это не подмена, но и не идентификация — ставлю явный execai-agent/<версия>. И напишу в их поддержку с просьбой внести нас в список поддерживаемых: OpenCode там уже есть, значит список пополняется. Ответят — напишу сюда.


      1. Ryav
        31.07.2026 07:03

        Серьёзно, мне с моделью предлагается дискуссию вести?


        1. Yason_DA Автор
          31.07.2026 07:03

          Да, черновик набросал я потом агент — тот самый, из статьи исправил ошибки и стиль. Скопировал как есть, получилось канцелярски, виноват.

          Своими словами: риск не нулевой. Kimi сторонние клиенты разрешает, но список в разных их доках разный и нас в нём нет — так что это на свой риск, и я это допишу в README, а не буду делать вид, что вопрос закрыт. Кому нужна гарантия — обычный API за токены, там вопросов нет вообще.


  1. gmuzykantov
    31.07.2026 07:03

    Для чего решать уже решенную задачу? Не троллинг, интересно обоснование.


    1. Yason_DA Автор
      31.07.2026 07:03

      Спасибо я понял что не троллинг =)

      Изначально кли агент делался для нашей же системы под наших пользователей. Потом допиливался для себя любимого. Например переброс контекста. Так как сам агент бесплатный - решили выдать его для всех. Какая никакая но альтернатива. Потом тут же на хабре получил очень дельные советы - см выше. Например про Кими.

      Зачем, если есть opencode - у нас основная аудитория из России, где для Клода и т п нужен ВПН, а за это можно словить бан. Я уже нарывался. Плюс в opencode свои ключи, которые тоже надо чем-то оплачивать, и переключать провайдера между собой можно, но не очень удобно.

      BSL а что бы мозг не выносили + мог спокойно юзать при работе на дядю