OctoHub 0.8.0: прокси отрастил единый медиа-API
Месяц назад вопрос, ради которого мы построили OctoHub, звучал так: ваш агент сделал сорок вызовов моделей — что он отправил, сколько это стоило и какой upstream ответил?
Вот тот же вопрос, только деньги переехали. Ваш агент сгенерировал одиннадцать картинок, пока перебирал варианты дизайна, потом девятисекундное видео, потом зачитал сводку вслух синтетическим голосом. Задействованы четыре провайдера. Три из них тарифицируют не в токенах — в GPU-секундах, секундах видео, символах. Один вообще не сказал, во сколько это обошлось. Теперь: какому клиенту выставлять счёт?
Для chat completions мы на это ответили. Для всего остального ответом по-прежнему оставалась электронная таблица.
OctoHub 0.8.0 закрывает эту дыру. Генерация изображений, генерация видео, синтез речи и транскрипция теперь идут через тот же прокси, те же клиентские ключи, те же списки разрешённых, тот же журнал запросов и ту же колонку стоимости, что и /v1/completions. Пять провайдеров — fal, ElevenLabs, Replicate, Runway и OpenRouter — за одним JSON-конвертом.
Разделение труда, благодаря которому всё вышло небольшим
Всё это легло поверх octolib 0.36.1, помеченного тегом в тот же день, — именно там на самом деле живёт медиа-стек: типизированные структуры запросов для четырёх задач, адаптеры провайдеров, жизненный цикл задачи, дескрипторы возможностей и справочная таблица тарифов, чтобы оценивать то, что провайдеры сами оценивать не станут. Про тот релиз мы писали в волне релизов начала сентября.
Всё, что на стороне OctoHub, следует из одного правила: всё, что про модели, — в octolib, всё, что про тенантов, — здесь. Грамматика маршрутизации, адаптеры, хендлы задач и ценообразование — за octolib. Ключи, квоты, персистентность, метрики и внешний API — за OctoHub.
Из-за этого правила конфиг не обзавёлся новым синтаксисом. Медиа-алиас — это алиас модели:
[media_models]
"flux" = ["fal:fal-ai/flux/dev", "replicate:black-forest-labs/flux-1.1-pro"]
"veo" = ["openrouter:google/veo-3.1"]
"tts" = ["elevenlabs:eleven_flash_v2_5"]
[providers.fal] # concurrency and rate windows, unchanged
concurrency = 8
requests_per_minute = 60
Та же грамматика provider:model, та же семантика «список значит балансировку», тот же лимитер по провайдерам. Медиа никогда не пополняет tokens_per_minute — медиа-провайдеры не сообщают о токенах, — но окна лимитов заведены по имени провайдера и общие с completions, так что на провайдере, который обслуживает и то и другое, токенный бюджет, уже израсходованный вашим чат-трафиком, развернёт и медиа-запрос.
Плохая конфигурация падает при старте, а не на первом платном запросе: неизвестный провайдер, кривой provider:model, пустой список зеркал или алиас, конфликтующий с [models], — всё это не даст серверу подняться.
Четыре задачи, один конверт
POST /v1/images/generations generate | edit | inpaint | variation
POST /v1/videos text_to_video | image_to_video | reference_to_video | extend | edit
POST /v1/audio/speech
POST /v1/audio/transcriptions
GET /v1/media/{id} fetch or advance a job
POST /v1/media/{id}/cancel
GET /v1/media/models capabilities, parameters, reference price
Это клиентские эндпоинты, аутентифицируемые ровно так же, как completions, — тот же bearer-ключ, тот же список разрешённых моделей на ключ, та же корреляция по X-Request-Id:
curl -sX POST http://127.0.0.1:8080/v1/images/generations \
-H "Authorization: Bearer <client-key>" \
-d '{"model":"flux","prompt":"a red panda astronaut","count":2,"size":"1024x1024"}'
Любой ответ — картинка, видео, голос, транскрипт, завершённый или ещё выполняющийся — это один и тот же объект:
{
"id": "med_9f3c1e0b…", "object": "media", "task": "text_to_image",
"status": "succeeded", "model": "fal-ai/flux/dev", "provider": "fal",
"progress": 1.0,
"artifacts": [ { "kind": "image", "media_type": "image/png",
"source": { "type": "url", "value": "https://…" },
"size_bytes": 812345, "expires_at": 1767225600 } ],
"usage": { "cost": 0.08, "cost_source": "provider", "currency": "USD", … },
"warnings": [], "safety": { "status": "passed", … }, "error": null
}
Одна форма — это один путь кода для сохранения, один формат строки для журнала и одна вещь, которую разбирает ваш клиент. Транскрипция — единственная задача, у которой полезная нагрузка — не артефакт, поэтому она добавляет объект result с text, language, segments и words.
Два намеренных отступления от API OpenAI, заявленных сразу, а не обнаруженных в бою: всё передаётся JSON-ом и никогда — multipart/form-data — бинарные входы — это объекты {"type":"url"…} или {"type":"base64"…} — и edit, inpaint и variation для изображений живут в поле mode, а не в отдельных путях. /v1/images/generations заимствует у OpenAI путь и написание model / prompt / size, но не формат обмена: число картинок задаётся полем count, а не n, и в ответе приходит показанный выше конверт, а не привычные для OpenAI {"created", "data"}. SDK OpenAI это не разберёт — ходите обычным HTTP или через тонкую обёртку.
Задача, которая переживает породивший её запрос
Вот часть, которая по-настоящему отличается от проксирования chat completion, и именно здесь лежат проектные решения.
Completion — это один вызов, который либо вернул результат, либо упал. Медиа-задача тратит деньги на стороне upstream в тот момент, когда провайдер её принимает, и после этого может выполняться минутами. Видео — это не медленный запрос; это покупка, за которой следует ожидание. Всё остальное — следствие того, что мы отнеслись к этому серьёзно:
Строка пишется до ожидания, а не после. Как только запрос принял провайдер с очередью — fal, Replicate, Runway, видео у OpenRouter, — OctoHub сохраняет запись и JobHandle, не содержащий учётных данных, и только потом начинает ждать. Рестарт, таймаут, отвалившийся клиент — ничто из этого не оставит бесхозной задачу, за которую вы уже заплатили. Хендл лежит в вашей базе, и задачу из него можно возобновить. (У ElevenLabs и синхронных эндпоинтов OpenRouter очереди нет, и хендл возвращать неоткуда: всю работу они делают прямо внутри вызова отправки, так что прерываться там негде и возобновлять нечего.)
202 — это не ошибка. Отправьте wait: false — и получите id, как только запрос примет провайдер. Отправьте wait: true, превысьте server.upstream_timeout_secs — и получите тот же 202 со status: "queued" или "running". Удалённая работа продолжается, id живой, ничего не потеряно. Опросите GET /v1/media/{id}, когда будете готовы.
Задачу двигает опрос — фонового воркера нет. Это намеренная не-цель: воркер означал бы планировщик, аренды и второй режим отказа для задач, которых никто не ждёт. Следствие написано в документации, а не спрятано: задача, которую вы ни разу не опросили, остаётся queued, и её стоимость никогда не записывается. Чтение уже завершённой задачи бесплатно и никогда не тарифицируется повторно — терминальная строка отдаётся из базы вообще без обращения к upstream.
Разрешение провайдера покрывает только отправку. Лимитер конкурентности по провайдерам в OctoHub держит слот только на время вызова отправки и сразу его отпускает. Четырёхминутное видео не держит один из ваших восьми слотов fal четыре минуты. А вот слот на тенанта удерживается весь запрос целиком, так что медиа-работа клиента съедает тот же бюджет, что и его completions.
Failover случается на отправке, где это безопасно. Включите server.failover_on_error — по умолчанию он выключен, ровно как и для completions, — и сбой провайдера на отправке отбросит этого кандидата, а запрос уйдёт на следующее зеркало в алиасе. Тот же сбой идёт в зачёт серии неудач провайдера: задайте server.provider_error_cooldown_secs (по умолчанию 0, то есть выключено) — и три подряд отказа на стороне провайдера отправят его в кулдаун, который не блокирует кандидата, а ставит его позади здоровых. Если оставить всё по умолчанию, сбой уйдёт прямиком вызывающей стороне. В любом случае после того, как задача принята, переключать уже нечего — за неё заплачено.
Доступ к записям ограничен тем ключом, который их создал. Чужой id возвращает 404, а не 403, — вам не дают узнать даже того, что такой id существует.
Проблема параметров и честный ответ на неё
У каждого медиа-провайдера своё представление о том, как выглядит запрос. fal хочет num_inference_steps и guidance_scale; какие-то эндпоинты называют промпт text; Runway продаёт кредиты и мыслит собственными именами моделей. Единый API обязан что-то с этим решить, и есть два плохих ответа: выставить только пересечение (бесполезно) или изобрести слой перевода, притворяющийся, что всё одинаково (враньё, причём дорогое).
Ответ OctoHub состоит из трёх частей:
Переносимое ядро с единым написанием везде — prompt, count, seed, size, duration_secs, negative_prompt, output_format. size принимает "1024x1024" или "16:9"; всё прочее — 400. Переносимость здесь означает одно имя, а не всеобщую поддержку: у Runway нет ничего равнозначного count, negative_prompt и output_format, и у видео-эндпоинта OpenRouter тоже, так что при строгой политике по умолчанию на этих провайдерах они приведут к 400, а не будут тихо проигнорированы, — ей и посвящена третья часть ниже.
Аварийный люк, который пропускает что угодно как есть, с пространством имён по провайдеру:
"provider_options": {
"fal": { "input": { "num_inference_steps": 28, "guidance_scale": 3.5 },
"field_map": { "prompt": "text" } }
}
field_map сопоставляет переносимое имя с тем, как поле реально называется на эндпоинте, — так переносимый prompt продолжает работать с эндпоинтом, у которого поле называется text. Когда алиас растянут по нескольким провайдерам, шлите сразу все пространства имён: вперёд уйдёт только пространство победившего кандидата, остальные отбросятся, — именно это и делает мультипровайдерный алиас вообще пригодным к использованию.
Политика на случай, когда параметр невозможно применить. unsupported_parameters: "error" (по умолчанию) падает до того, как потрачены деньги, — правильный вариант для продакшена. "warn_and_drop" отбрасывает параметр и возвращает предупреждение — полезно, когда один алиас веером раскрывается по провайдерам с неравной поддержкой. Выбираете вы, на каждый запрос, потому что только вы знаете, что именно имели в виду.
А GET /v1/media/models говорит, что есть что, до того как вы потратите хоть что-нибудь: флаги возможностей исполнения и параметров каждого сконфигурированного кандидата, лимиты, собственная JSON Schema его адаптера для provider_options и справочная цена. Две шероховатости в 0.8.0 — вы всё равно на них наткнётесь: каждого кандидата опрашивают через адаптер изображений, поэтому в поле tasks неизменно стоит ["text_to_image"], даже у видео-алиаса, а у кандидата elevenlabs адаптера изображений нет вовсе — он возвращается с ценой и null вместо дескриптора. Многие поля возможностей честно показывают unknown — адаптеры не могут знать схему каждого эндпоинта, и сказать это лучше, чем уверенно ответить неправильно. Ровно за этим и существует аварийный люк.
Стоимость, которая отказывается угадывать
Это та фича, ради которой релиз на самом деле и делался, и то единственное место, где мы упирались сильнее всего.
usage.cost — это то число, которое идёт в счёт. usage.cost_source говорит, откуда оно взялось:
cost_source |
Что значит |
|---|---|
provider |
Upstream вернул реальные доллары. Так делают OpenRouter и Replicate. |
estimate |
Посчитано локально по справочной таблице тарифов octolib. |
unavailable |
Оценить не удалось никак — cost равен null. |
null — это не ноль. Запрос, который ничем не удалось оценить, записывается как запрос без цены, но никогда — как бесплатный. Он попадает в octohub_media_cost_unknown_total и несёт предупреждение cost_unavailable — вместо того чтобы тихо занижать вашу общую сумму трат и делать дашборд красивее реальности.
Оценки берутся из справочной таблицы octolib, которая знает про единицы измерения, потому что про них знают провайдеры: ElevenLabs тарифицирует символы, Runway продаёт секунды видео, пересчитанные из кредитов, fal откатывается к реальному времени работы GPU, потому что это единственная величина, о которой сообщают метрики его очереди. Там, где тариф был бы догадкой, тарифа нет: модели сообщества на Replicate тарифицируют GPU-секунды на неизвестном классе GPU, поэтому они не разрешаются ни во что и остаются без цены, вместо того чтобы получить штамп с правдоподобным числом.
Известный пробел, о котором мы говорим прямо: транскрипция у ElevenLabs остаётся без цены. Scribe тарифицирует длительность входного аудио, а её в ответе не возвращают, и ни один справочный тариф её не покрывает. У остальных провайдеров транскрипция число получает: fal проваливается в свой общий тариф за GPU-секунду, а Replicate и OpenRouter считают по той сумме в долларах, которую сообщил upstream. Когда сможем оценивать Scribe — будем; до тех пор это unavailable, а не 0.00.
На стороне агрегатов GET /v1/admin/usage обзавёлся media_count и total_cost — и теперь это сумма completions, embeddings и медиа: одно число, которое вы реально поставите в счёт. GET /v1/admin/media перечисляет отдельные записи с теми же фильтрами, что и два других эндпоинта, сначала самые свежие, включая задачи в полёте — с нетерминальным статусом и null в completed_at. Задача и есть эта запись, только в более раннем статусе; отдельной очереди, в которую можно заглянуть, нет.
Prometheus — по задаче, модели и провайдеру, плюс метка api_key_id на счётчике запросов, если включён metrics.per_key. Счётчики стоимости меткой ключа не размечаются: траты по тенантам берутся из GET /v1/admin/usage, где они точные, а не выборочные:
octohub_media_requests_total{task,model,provider,status}
octohub_media_duration_seconds{task,model,provider}
octohub_media_cost_microusd_total{task,model,provider,source}
octohub_media_cost_unknown_total{task,model,provider}
Стоимости считаются в микродолларах, потому что счётчик в долларах для картинок по $0.003 — это генератор ошибок округления. Гейджа незавершённых задач намеренно нет: точный пришлось бы считать по нетерминальным строкам в базе, а внутрипроцессный счётчик соврёт в тот же момент, когда задачу опросит другая реплика или она переживёт рестарт.
Чему мы сказали нет
Фичи, которых здесь нет, несут не меньшую нагрузку, чем те, что есть:
OctoHub не становится хранилищем блобов. Здесь нет ни объектного хранилища, ни CDN, ни жизненного цикла артефактов, и сам он артефакты за вас не скачивает. Ответил провайдер ссылкой — ссылка и хранится: в строке лежат URL и метаданные, больше ничего, и это основная часть трафика. Исключение, под которое стоит закладывать место, — провайдер, который отдаёт саму полезную нагрузку: речь ElevenLabs так делает всегда, fal, Replicate и OpenRouter — иногда. Эти байты уходят в ответ в base64, и тот же base64 сохраняется в колонке result записи, потому что именно эта строка потом и проигрывается на GET /v1/media/{id} без обращения к upstream. MP3 в таблице media занимает примерно 4/3 собственного размера, так что при активном синтезе речи закладывайтесь на таблицу, которая наполовину бухгалтерская книга, наполовину медиатека.
Пути к файлам от клиента отклоняются. MediaSource в octolib поддерживает file, provider_file и object_storage; здесь все три возвращают 400. Путь в запросе к серверу — это просьба прочитать файловую систему сервера, то есть дыра SSRF/LFI, а не фича. Размер встроенного base64 проверяется по media.max_source_bytes (20 MiB по умолчанию) до того, как что-либо доедет до провайдера, и полезная нагрузка никогда не попадает в базу: сохранённый запрос хранит форму и число байт, но не сами байты.
Учётные данные upstream остаются на сервере. Ключи провайдеров берутся из окружения сервера (FAL_API_KEY, ELEVENLABS_API_KEY и так далее) и никогда не принимаются от клиента. provider_options.<provider>.cost_estimate отклоняется сразу — цена разрешается на стороне сервера, и не клиенту решать, сколько он вам должен.
Чего ещё нет в 0.8.0: потокового TTS и multipart/form-data. И то и другое добавляется сверху, когда кому-то это действительно понадобится, — вот только потоковому TTS сначала нужен свой ответ про стоимость: путь потокового синтеза речи в octolib вообще не сообщает никакого usage.
Обновление
Таблица media создаётся при старте вместе с остальными — на SQLite, MySQL и PostgreSQL, без шага миграции. Существующий конфиг продолжает работать: если вы не добавите [media_models], новым эндпоинтам просто нечего маршрутизировать, а остальной прокси ведёт себя ровно так же, как в 0.7.
# Linux x86_64, static musl build
curl -fsSL https://github.com/Muvon/octohub/releases/download/0.8.0/octohub-0.8.0-x86_64-unknown-linux-musl.tar.gz | tar xz
./octohub
Готовые бинарники есть под шесть платформ (Linux musl, macOS, Windows — x86_64 и ARM64), либо собирайте из исходников через cargo build --release.
Релиз помечен как ломающий по одной причине: трейт Storage оброс медиа-методами. Это важно, только если вы поддерживаете собственный storage-бэкенд поверх внутренностей OctoHub; если вы просто запускаете бинарник, менять нечего. Ещё одна мелочь, которую стоит знать: с 0.7.12 OctoHub прокидывает заголовки атрибуции насквозь, а с этого цикла представляется upstream-у как Octohub/<version>, а не как дефолтное значение octolib, — так что панели на стороне провайдера называют тот прокси, который сделал вызов.
Суть
Смысл ставить прокси перед своими моделями никогда не был в маршрутизации. Он был в том, что единственное место, через которое проходит каждый запрос, — это единственное место, где существует вся правда целиком. Этот аргумент не слабеет, когда запросы начинают возвращать пиксели и звук вместо токенов, — он крепнет, потому что именно на медиа стоимость одного запроса перестаёт быть ошибкой округления и становится счётом.
Начиная с 0.8.0 ответ на вопрос «какой клиент сгенерировал это видео, у какого провайдера и сколько это стоило» — это одна строка в вашей собственной базе, рядом с chat completions, в той же валюте, с колонкой, которая честно признаётся, когда не знает никто.
— Don
OctoHub — открытый код под Apache-2.0, разработан Muvon Un Limited. Берите на GitHub — issues и pull request-ы приветствуются. Полная документация по медиа: doc/11-media.md.



