Знакомьтесь, OctoHub: единая входная дверь для каждой LLM, к которой обращаются ваши агенты

Ваш агент только что выполнил задачу. Он сделал сорок вызовов моделей. Часть ушла на локальную машину с Ollama, часть — в Anthropic, один — в OpenAI, потому что локальная модель подавилась длинным контекстом. Задача завершена. Теперь ответьте мне на три вопроса: что именно он отправил, сколько это стоило и какой именно upstream ответил.

Если вы были как мы, честный ответ — «пришлось бы добавить print-ы и запустить заново». Панели провайдеров показывают вам свой кусок. Логи вашего агента показывают свой кусок. Никто не показывает вам запрос целиком, каким он ушёл по проводу, с пометкой, какой ключ его выпустил и какой провайдер его принял. Самое полезное представление — каждый запрос через единую панель — это именно то, чего никто вам не даёт, потому что нет единого места, через которое проходят запросы.

OctoHub — это то самое единое место. Это самостоятельно размещаемый LLM-прокси, который вы ставите перед своими агентами. Они общаются с одним эндпоинтом; OctoHub общается с тем, кому вы скажете, и записывает всё, что происходит между ними. Сегодня мы открываем его исходный код.


Что это, на одной схеме

            ┌──────────────────┐
 agent   →  │  OctoHub proxy   │  →  openai
            │  (Rust / hyper)  │  →  anthropic
            │                  │  →  ollama (your GPU)
            └──────────────────┘  →  openrouter
                     │
                     └─→ your DB (SQLite / MySQL / PostgreSQL)
                         (api_keys, completions, embeddings)

Один бинарник на Rust, построенный на hyper. Клиенты бьют в один HTTP-эндпоинт. Прокси разрешает имя модели, выбирает upstream, перенаправляет вызов через octolib — нашу клиентскую библиотеку для LLM, ту же самую, что каждый инструмент Muvon использует для общения с моделями — и сохраняет запрос, ответ, счётчики токенов и задержку. Сам прокси не хранит состояние между запросами. Состояние живёт в двух местах: octohub.toml для конфигурации и база данных для ключей, логов и использования.

Намеренно он не является рядом вещей. Это не чат-интерфейс — направьте на него своё приложение. Это не семантический маршрутизатор, выбирающий «лучшую» модель для промпта; выбор модели — задача вызывающей стороны. Это не векторное хранилище; эмбеддинги проходят насквозь и логируются, но OctoHub их не индексирует. Он добавляет аутентификацию, логирование, балансировку нагрузки и стабильный интерфейс. Он не пытается умничать с тем, что возвращают провайдеры — каждое значимое поле возвращается клиенту как есть.


Зачем мы это построили

Мы не ставили целью построить прокси. Мы гоняли Octomind, наш рантайм для агентов, на смеси моделей — самостоятельно размещённый флот на наших собственных GPU для дешёвой высокообъёмной работы, фронтирные API для трудного. Связка работала. Что не работало — это видеть её.

В тот момент, когда у вас больше одной модели за одним приложением, вы теряете единую панель. У каждого провайдера своя приборная панель, свой учёт токенов, своё представление о том, что такое «запрос». У вашего агента свои логи, которые говорят, что он думал, что отправил. Когда прогон стоит дороже ожидаемого или модель начинает возвращать мусор, вы сшиваете три неполных истории и угадываете по швам.

Мы подробнее писали об этой конкретной боли — желании видеть, что агент действительно сделал, а не что он заявил — в той наблюдаемости, которую мы хотели. OctoHub — это инфраструктурный ответ на неё. Поставьте один прокси на пути, и единая панель существует по построению: есть буквально одно место, через которое проходит каждый запрос, значит, есть одно место, где его записать.

Вторая причина — балансировка нагрузки. Мы уже гоняли одного агента по многим моделям вручную — подмены конфигов, переменные окружения, смена имени модели на каждый прогон. Мы хотели алиас модели, означающий «любой из этих upstream-ов, твой выбор», и лимит конкурентности, чтобы всплеск вызовов агента не расплавил локальную GPU-машину. И то, и другое — место в прокси, а не разбросанное по каждому клиенту.


Как через него течёт запрос

Возьмём POST /v1/completions. Вот что OctoHub с ним делает:

  1. Аутентификация. Токен Authorization: Bearer — это клиентский API-ключ из таблицы api_keys. OctoHub его ищет, проверяет, что он активен, и помечает запрос ID ключа. (Если запустить сервер без мастер-ключа, отключается только админ-API — при старте печатается предупреждение; запросы completions и embeddings по-прежнему требуют валидный клиентский ключ.)
  2. Проверка списка разрешённых. Клиентские ключи можно ограничить набором моделей. Ключ, привязанный к ["gpt", "anthropic:claude-haiku-4-5"], запросивший что-то иное, получает 403.
  3. Разрешение модели. Если поле model — это алиас из [models], OctoHub раскрывает его в строку provider:model. Если это уже голый provider:model, он проходит насквозь. Алиасы, отображающиеся в список, стартуют со случайной позиции и берут первого провайдера, который может принять — это и есть балансировка нагрузки.
  4. Получение разрешения провайдера. Если вы задали лимит конкурентности для этого провайдера, запрос ждёт свободного слота. Нет слота — нет 429: HTTP-соединение просто остаётся открытым, пока слот не освободится — но не дольше таймаута очереди (60 с по умолчанию), после которого вернётся 503. Намеренный троттлинг, записываемый как время ожидания в очереди.
  5. Вызов upstream через octolib, с настраиваемым дедлайном операции.
  6. Сохранение и ответ. Полный запрос, ответ, счётчики токенов, стоимость, разрешённый провайдер и задержка идут в таблицу completions. Клиент получает ответ upstream как есть, плюс заголовок X-Request-Id.

Этот X-Request-Id — ключ для соединения данных. Это либо значение, которое вы передали (провалидированное, возвращённое обратно), либо свежий ULID. Он появляется как req_id в каждой строке лога этого запроса. Возьмите ответ с ошибкой, схватите заголовок, сделайте grep по логам — у вас вся история.


Алиасы моделей и балансировка нагрузки

Секция [models] — это место, где одно имя веером раскрывается во множество upstream-ов:

[models]
# Один алиас, один upstream
"sonnet"  = ["anthropic:claude-sonnet-5"]

# Один алиас, несколько upstream-ов — OctoHub на каждый запрос выбирает один случайно
"workhorse" = ["ollama:kimi-k2.6", "ollama:minimax-m3", "openrouter:google/gemini-3.1-pro-preview"]

[embedding_models]
"voyage" = ["voyage:voyage-4"]

Клиент, запросивший workhorse, получает один из трёх — OctoHub стартует со случайной позиции и берёт первого провайдера, чьи окна рейтов пропускают запрос. Это вся модель балансировки нагрузки — простая, предсказуемая и ровно достаточная, чтобы распределить трафик агентов по флоту или по ключам без отдельного маршрутизатора. Сегодня нет взвешенной маршрутизации; провайдеры на кулдауне по ошибкам уходят назад за здоровыми, а запросы, продолжающие цепочку, остаются на провайдере, который обслужил предыдущий ход. Мы предпочитаем выпустить честную версию этого, а не намекать на более умный планировщик, чем тот, что существует.

Клиенты также могут полностью обойти алиасы и отправить голый provider:model вроде openai:gpt-5.5. Таблица алиасов — это удобство, а не барьер; барьер — это список разрешённых для ключа.


Модель auto: говорите зачем, а не какую

С версии 0.6.0 есть второй способ выбрать модель, и именно его чаще всего используют наши собственные агенты. Вместо того чтобы называть модель, клиент отправляет "model": "auto" плюс заголовок X-Model-Purpose — любую строку на ваш вкус — а OctoHub разрешает назначение в алиас:

[auto]
default = "workhorse"
compression = "cheap"
supervisor = "sonnet"

Назначения иерархичны и делятся по -: supervisor-gate откатывается к supervisor, затем к default. Одна строка supervisor покрывает все назначения supervisor-*, пока вы не закрепите конкретное — вы определяете ровно столько строк, сколько у вас есть мнений. Отсутствующее или опечатанное назначение деградирует к default, но никогда не падает.

Зачем это? Потому что вызывающая сторона обычно знает, какого рода вызов она делает — проход компрессии, проверка gate, основной цикл, — а оператор знает, какого уровня этот род вызова заслуживает. Маршрутизация по назначениям помещает это решение в конфиг прокси (или в карту переопределений на владельца через PUT /v1/admin/owners/:owner/auto, которая полностью перекрывает конфигурационный минимум), а не зашивает имена моделей в агента. Octomind из коробки шлёт main, compression и семейство supervisor-*.


Конкурентность по провайдерам

Ваш фронтирный API спокойно принимает тридцать два параллельных запроса. Ваша единственная GPU-машина с Ollama — нет. Поэтому OctoHub ограничивает число запросов «в полёте» на провайдера:

[providers.ollama]
concurrency = 5

[providers.openai]
concurrency = 32

Запросы сверх лимита встают в очередь внутри процесса OctoHub — соединение клиента блокируется, пока не откроется слот. Провайдеры, которые вы не перечислите, работают без ограничений. Лимитер локален для процесса и считает completions и embeddings вместе, поскольку оба текут через одно и то же upstream-соединение. Это семафор на провайдера, ничего экзотического, но это значит, что агент, веером бросивший сорок вызовов, не уронит сервер моделей, на который они приземляются.

Конкурентность — не единственный регулятор. Каждый провайдер также принимает рейт-лимиты с фиксированным окном — requests_per_minute, tokens_per_minute, requests_per_day, tokens_per_day — и алиас с несколькими upstream-ами переключается на следующего кандидата, когда окно заполнено. Это и есть те «окна рейтов», которые проверяет выбор алиаса: то, что провайдер исчерпал дневной бюджет токенов, не роняет запрос — трафик просто смещается к следующему upstream-у, у которого ещё есть запас.


Наблюдаемость, которую вы реально получаете

Это та часть, ради которой мы построили OctoHub, так что у неё две поверхности.

Структурированные логи в stdout — красивые, если вы в TTY, иначе JSON. Каждый завершённый запрос выдаёт одну строку:

{
	"level": "INFO",
	"message": "request completed",
	"req_id": "01HMQGSB3R",
	"route": "/v1/completions",
	"status": 200,
	"dur_ms": 1523,
	"api_key_id": 1,
	"model": "workhorse",
	"provider": "ollama",
	"queued_ms": 0,
	"tok_in": 56,
	"tok_out": 120
}

Вы видите ключ, который его выпустил, имя модели, которое запросил клиент, провайдера, который реально ответил, сколько он простоял в очереди, сколько занял, и токены на входе и выходе. Поле provider — это ответ на вопрос «на какой upstream упал случайный выбор» — без перезапуска чего-либо.

Метрики Prometheus на отдельном порту (127.0.0.1:9090 по умолчанию, GET /metrics). У всего префикс octohub_:

Метрика О чём говорит
octohub_requests_total объём запросов по route, method, status
octohub_request_duration_seconds гистограмма сквозной задержки
octohub_completions_total объём completions по model, provider, status
octohub_completion_tokens_total токены вход/выход по model и provider
octohub_provider_queue_wait_seconds время ожидания разрешения конкурентности
octohub_provider_in_flight активные запросы у каждого provider прямо сейчас

Гистограмма ожидания в очереди — это опережающий индикатор насыщения: когда P99 ползёт вверх, ваш флот становится узким местом раньше, чем какой-либо запрос истечёт по таймауту. Пара строк PromQL дают вам долю ошибок по модели и токены на выходе в секунду по провайдеру. Включите per_key = true, и метрики completions обретут метку api_key_id, чтобы вы могли выставлять счета или относить стоимость на клиента. (Следите за кардинальностью, если выпускаете тысячи ключей.)

Для полной записи — не агрегатов, а реальных байтов промпта и ответа — админ-API отдаёт сырую историю completions прямо из базы данных.


Мультитенантные ключи и админ-API

У OctoHub два слоя аутентификации. Мастер-ключ (задаётся в octohub.toml) защищает админ-API. Клиентские ключи — выпускаемые через этот админ-API, хранимые в базе данных — аутентифицируют эндпоинты completions и embeddings. Каждый completion помечается выпустившим ключом, и именно это заставляет работать потенантный учёт использования.

Есть shell-обёртка octohub-admin.sh для повседневной эксплуатации:

export OCTOHUB_MASTER_KEY=your-master-secret

# Выпустить клиентский ключ, ограниченный двумя моделями
./octohub-admin.sh keys create ci-pipeline --allowed-models gpt,anthropic:claude-haiku-4-5

# Дневное использование для ключей 1 и 2
./octohub-admin.sh usage --bucket day --key 1,2

# Вытащить последние 20 сырых completions — полный вход и выход
./octohub-admin.sh completions --limit 20

Ключи отзываются, никогда не удаляются — записи использования привязаны к ID ключа, поэтому история сохраняется. Использование агрегируется по hour, day, week или month, с фильтрацией по ключу и диапазону времени. Те же данные доступны как обычный HTTP, если вы предпочитаете не пользоваться скриптом.

Ещё две операционные поверхности, которые стоит знать. GET /v1/admin/status показывает здоровье по моделям, наблюдаемое из реального трафика, — не синтетический зонд, так что одиночный сбой не рисует красную лампочку на вашей панели. А отправка процессу SIGHUP перезагружает octohub.toml на месте — новые алиасы, новые лимиты, без рестарта.


Запустите за 5 минут

OctoHub — это один бинарник. Соберите его, напишите конфиг, запустите, выпустите ключ.

# 1. Сборка из исходников
git clone https://github.com/Muvon/octohub
cd octohub && cargo build --release

Напишите минимальный octohub.toml:

[server]
host = "127.0.0.1"
port = 8080
api_key = "your-master-secret"        # включает auth + админ-API
db_url = "sqlite://octohub.db"         # схема создаётся при первом запуске

[models]
"workhorse" = ["ollama:kimi-k2.6", "openrouter:google/gemini-3.1-pro-preview"]

[metrics]
enabled = true
bind = "127.0.0.1:9090"

[providers.ollama]
concurrency = 5

Запустите его и создайте клиентский ключ:

# 2. Запуск сервера (укажите конфиг через -c, если он не в текущей папке)
./target/release/octohub

# 3. Выпуск клиентского ключа
curl -X POST http://127.0.0.1:8080/v1/admin/keys \
  -H "Authorization: Bearer your-master-secret" \
  -H "Content-Type: application/json" \
  -d '{"name": "my-app"}'
# → {"id": 1, "key": "abc...xyz", ...}   ← сохраните, показывается один раз

# 4. Сделать completion через прокси
curl -X POST http://127.0.0.1:8080/v1/completions \
  -H "Authorization: Bearer abc...xyz" \
  -H "Content-Type: application/json" \
  -d '{"model": "workhorse", "input": "Explain Rust in one sentence."}'

База данных стартует как SQLite — без настройки. Направьте db_url на MySQL или PostgreSQL, когда перерастёте; схема создаётся автоматически при первом подключении. API-ключи провайдеров (OPENAI_API_KEY, ANTHROPIC_API_KEY и компания) живут в окружении, читаемые octolib ровно так, как ожидают провайдеры.

OctoHub также говорит на классическом OpenAI Chat Completions по адресу POST /v1/chat/completions, так что любой совместимый с OpenAI SDK или инструмент направляется на него как на base URL «один в один». (Одна честная оговорка: стриминг не реализован — запрос с "stream": true получает 501.)


Как направить на него Octomind

OctoHub и Octomind построены друг для друга, и octolib поставляет нативный провайдер octohub: — без прослойки совместимости с OpenAI, он говорит на Responses API OctoHub напрямую. Две переменные окружения связывают их:

export OCTOHUB_API_URL=http://127.0.0.1:8080   # ваш сервер OctoHub
export OCTOHUB_API_KEY=abc...xyz               # клиентский ключ, который вы выпустили

Теперь любая ссылка на модель в Octomind вида octohub:<alias> маршрутизируется через прокси:

octomind run --model octohub:workhorse developer:general

Агент думает, что говорит с одним провайдером. За прокси workhorse веером раскрывается по вашему флоту, каждый вызов логируется со стоимостью и upstream-ом, который ответил, а лимит конкурентности удерживает GPU-машину на ногах. Агент остаётся простым; видимость живёт там, где запросы реально пересекают провод.

И это не лабораторная установка. Octomind Cloud — наш управляемый рантайм агентов — гоняет каждого клиентского агента через OctoHub в продакшене. Вызовы приходят с пометками назначений (main, compression, supervisor-gate), модель auto маршрутизирует каждый на нужный уровень, и каждый запрос приземляется в журнал со стоимостью и upstream-ом, который ответил. Тот же бинарник, который вы можете клонировать, стоит перед нашим флотом.


Открытый код, Rust, Apache-2.0

OctoHub лежит на GitHub под Apache-2.0. Это один бинарник на Rust, построенный на hyper — маленький, быстрый и лёгкий по зависимостям. SQLite по умолчанию, так что поднимать нечего, MySQL и PostgreSQL — когда понадобятся. Конфигурация — это один файл TOML плюс горстка override-ов окружения OCTOHUB_* для того, что вы крутите по развёртываниям (OCTOHUB_DB_URL, OCTOHUB_LOG_FORMAT, OCTOHUB_METRICS_BIND).

Это ранняя стадия — версия 0.6.4, честное число. Он делает то, что заявляет: одна входная дверь, алиасы моделей, маршрутизация auto по назначениям, балансировка нагрузки с failover и кулдауном, конкурентность и рейт-лимиты по провайдерам, проверки модальности, пропускающие провайдеров, которые не справляются с вашими изображениями или видео, мультитенантные ключи, полное логирование запросов и эндпоинт Prometheus. Поддержка провайдеров продолжает расти — Google Studio появился в 0.6.2 вдобавок к существующим двадцати с лишним. Он пока не делает стриминг или взвешенную маршрутизацию, и я предпочту сказать вам это, а не дать узнать в продакшене.

Если вы гоняете больше одной модели за одним приложением — а если вы строите агентов, то гоняете — OctoHub даёт вам ту единую панель, существование которой остальной стек тихо предполагает. Клонируйте его, направьте на него агента и смотрите, как прогон проходит мимо со стоимостью в придачу.

— Don

OctoHub — открытый код под Apache-2.0, разработан Muvon Un Limited. Берите на GitHub — issues и pull request-ы приветствуются.