CreativeLab API v2: один эндпоинт для видео, картинок и музыки

CreativeLab API v2: один эндпоинт для видео, картинок и музыки

В v1 настройки генерации жили внутри идентификатора модели. Нужно видео 1080p на пять секунд — берёшь seedance-1-pro-1080P-5s. Нужны те же пять секунд, но в 720p — это уже другой id, и его надо найти в каталоге. Разрешение, длительность, качество перемножались, каталог рос, а интеграция превращалась в таблицу соответствия «параметры пользователя → строка id».

Мы переписали медиа-API. v2 — не новые адреса для старых методов, а другой контракт: параметры переехали в тело запроса, цену можно узнать до генерации, а ретрай после сетевого таймаута больше не создаёт вторую задачу. v1 продолжает работать на том же ключе, но он заморожен: новые возможности выходят только в v2.

Один запрос вместо перебора тиров

JSON
// v1: настройки зашиты в id, менять нечего
POST https://gptunnel.ru/v1/media/create
{ "model": "seedance-1-pro-1080P-5s", "prompt": "cat", "ar": "16:9" }
JSON
// v2: базовая модель, настройки — в params
POST https://gptunnel.ru/api/v2/media/tasks
{
  "model": "seedance-1-pro",
  "prompt": "cat",
  "params": { "aspect_ratio": "16:9", "resolution": "1080P", "duration": 5 }
}

Разница не косметическая. В v1 из запроса читается только соотношение сторон (ar), всё остальное подставляется из дефолтов модели — поэтому часть настроек через v1 просто недоступна, независимо от того, что модель умеет. Тировые id вроде seedance-1-pro-1080P-5s в каталоге v2 не появятся: там базовая модель и список её параметров.

Базовый URL меняется с https://gptunnel.ru/v1/media на https://gptunnel.ru/api/v2/media. Ключ и заголовок Authorization те же, новый выпускать не нужно.

Схему параметров отдаёт сам каталог

Угадывать, что принимает конкретная модель, не нужно — это описано машиночитаемо:

bash
curl https://gptunnel.ru/api/v2/media/models \
  -H 'Authorization: YOUR_API_KEY'
JSON
{
  "id": "kling-v3",
  "type": "VIDEO",
  "price": 210,
  "prompt": "required",
  "params": [
    { "key": "resolution", "type": "enum", "default": "1080p",
      "options": [{ "value": "720p" }, { "value": "1080p" }] },
    { "key": "duration", "type": "number", "default": 5,
      "min": 5, "max": 10, "step": 5, "integer": true, "unit": "s" }
  ],
  "inputs": [
    { "key": "image", "kind": "image", "role": "first_frame",
      "required": false, "count": { "min": 0, "max": 1 },
      "formats": ["jpg", "png", "webp"] }
  ]
}

По этому ответу форма настроек в своём интерфейсе строится сама: params говорит, что можно задать (тип, допустимые значения, дефолт, границы), inputs — какие файлы модель принимает, в какую роль, сколько штук и в каких форматах. Поле prompt со значением required | optional | none снимает старую проблему v1, где промпт был обязателен даже для моделей, которым он не нужен.

Отдельно стоит запомнить: в params публикуется только то, что клиент вправе задать сам. У модели могут быть параметры с фиксированным значением — их видно в ответе задачи и расчёта цены, но изменить нельзя.

Цена — до генерации, а не по факту списания

Цена зависит не только от модели, но и от параметров: секунда видео, разрешение, качество. В v1 в каталоге была одна цена на id, и посчитать конкретную комбинацию было негде.

POST /api/v2/media/price принимает ту же пару { model, params }, что и создание задачи, и возвращает итог, ничего не запуская и не списывая:

JSON
{ "code": 0, "model": "kling-v3", "price": 210, "currency": "RUB",
  "params": { "resolution": "1080p", "duration": 5, "aspect_ratio": "16:9" } }

Цена считается той же функцией, что и фактическое списание, — «посчитали одно, списали другое» тут невозможно. Два практических следствия. Первое: params в ответе — это применённые значения: присланное плюс дефолты и фиксированные параметры модели; если присланного параметра в ответе нет, он не настраивается. Второе: этим же запросом удобно проверять тело перед боевым вызовом — валидация та же, но без денег. Оговорка одна: модели, которые тарифицируются ещё и по промпту или входным файлам (токены, мегапиксели), возвращают цену без этой части.

Ретрай перестал стоить денег

Классический сценарий: клиент получил сетевой таймаут, повторил запрос, задача создалась дважды, деньги списались дважды. Теперь у создания есть idempotency_key:

JSON
POST /api/v2/media/tasks
{
  "model": "seedance-1-pro",
  "prompt": "cat",
  "params": { "aspect_ratio": "16:9", "resolution": "1080P", "duration": 5 },
  "idempotency_key": "order-1042",
  "webhook_url": "https://your.app/hooks/media"
}

Повтор с тем же ключом вернёт ту же задачу. Если тот же ключ прислали с другим телом, ответ будет 409 (code: 23), а не тихий дубль: сервер сравнивает хеш запроса, а не только ключ.

Ключ должен быть привязан к бизнес-операции — id заказа, id сообщения, id строки в очереди, — а не генерироваться заново на каждую попытку. Случайный ключ на каждый ретрай не защищает ни от чего.

Один объект задачи вместо трёх форм ответа

POST /api/v2/media/tasks создаёт задачу для любой медиа-модели, и ответ везде одинаковый: id, status (queued | running | done | failed), params, price, result. Тот же объект возвращает GET /api/v2/media/tasks/:id и тот же уходит в webhook. В v1 форма ответа зависела от семейства модели: у обычных генераций результат приходил строкой в url, у музыки — массивом со своими именами полей, а часть моделей вообще жила на отдельных ветках.

Главная ловушка при переносе — читать result[0]. В v2 result всегда массив, и в нём может быть больше одного файла: модели с несколькими выходами возвращают все, а музыкальные (Suno, Mureka, MiniMax) отдают дорожки, у каждой свои url, title, duration, cover_url, lyric.

JavaScript
if (task.status === 'done') {
  for (const item of task.result) save(item.url)
}

Для быстрых моделей есть короткий синхронный режим: wait: true в теле создания держит соединение до готовности и отдаёт финальный объект сразу. Ожидание ограничено примерно 30 секундами; не успели — вернётся задача в текущем статусе, и результат забирается обычным способом. Писать поллинг ради картинки, которая рисуется шесть секунд, больше не нужно. Для видео на wait полагаться бессмысленно — там webhook или опрос. В v1 синхронный режим был отдельным эндпоинтом /v1/media/generate и работал ровно с одной моделью.

Ошибки: код для ветвления, title для точности

При успехе HTTP-статус 200 и code: 0, при ошибке — осмысленный статус и машиночитаемый code в теле. Каталог кодов расширился: 20 — модель не найдена, 22 — задача не найдена, 23 — конфликт идемпотентности, 25 — входной файл отклонён, 26 — промпт отклонён, 27 — комбинация параметров не поддерживается, 28 — временный сбой, повтор осмыслен. Коды, общие с v1 (3, 5, 99, 1001), сохранили значения — переписывать существующие обработчики под новую нумерацию не нужно.

Раньше причина отказа почти всегда приходила как code: 1: в каталоге было пять причин из четырёх с лишним десятков, остальные схлопывались во внутреннюю ошибку. Теперь у отказа есть код, а рядом — error.title, точный машиночитаемый идентификатор причины: ERR_IMAGE_TOO_LARGE и ERR_UNSUPPORTED_FILE_FORMAT оба дают code: 25, но различаются в title. Новые причины добавляются в title без изменения таблицы кодов, так что ветвиться в проде надёжнее по нему. Внутренние ошибки несут trace_id — с ним поддержка находит запрос сразу.

Если код ветвится по error.code при status: failed, обработчики стоит перечитать: часть отказов, раньше приходивших как 1, теперь приходит с 2528, 1001 или 1002.

Что поменять в коде

Было в v1Стало в v2
POST /v1/media/createPOST /api/v2/media/tasks
GET /v1/media/result?task_id=…GET /api/v2/media/tasks/:id
GET /v1/media/modelsGET /api/v2/media/models
POST /v1/media/generate (одна модель)wait: true в теле создания
POST /api/v2/media/price
ar: "16:9"params.aspect_ratio (имя — из схемы модели)
images: ["https://…"]inputs: { "<role>": ["https://…"] }
callback_urlwebhook_url
url в ответеresult[].url
code/message в корне задачиerror.code, error.title, error.message
modelration в каталогеmoderation

Отдельная поправка на строгость. Тело запроса в v2 валидируется строго: неизвестное поле верхнего уровня отклоняется с code: 3, а его имя перечислено в details. Это ровно тот случай, когда idempotencyKey вместо idempotency_key молча снимал защиту от двойного списания, а забытое поле ar из v1 подменяло соотношение сторон дефолтом — и всё это отвечало 200 OK. Внутри params правило мягче: неизвестный модели ключ не роняет запрос, а возвращается в ignored_params с причиной. Ошибкой остаётся недопустимое значение принимаемого параметра — duration: 999 при пределе 10 даст 400, а не тихую подстановку.

Что важно помнить в проде

  • Ссылки на результат живут 48 часов. Нужен файл дольше — забирай к себе сразу после done.
  • Webhook доставляется «хотя бы один раз». При неудаче мы повторим примерно через 5, 20, 60 и 180 секунд, так что обработчик должен быть идемпотентным по id задачи. Подпись лежит в заголовке X-Gptunnel-Signature в формате t=<unix>,v1=<hex>: это HMAC-SHA256 от строки "<t>.<сырое тело>" с API-ключом в качестве секрета. Адрес должен быть публичным HTTPS.
  • Файлы можно слать инлайном. inputs принимает не только публичные ссылки, но и data:-URL — до 20 МБ на файл и 50 МБ на всё тело. Битый файл отклоняется до создания задачи и до списания, с code: 25.
  • 28 — единственный код, который стоит повторять автоматически. Остальные 4xx повторять бессмысленно: ответ не изменится.
  • Снятые модели не ломают интеграцию. У модели с объявленным снятием в каталоге есть deprecated_at и deprecation_redirect_to. После даты задача автоматически уходит на аналог, списание идёт по запрошенной модели, о подмене говорят заголовки Deprecation, Sunset, x-model-requested, x-model-served. Аналога нет — создание вернёт 410 с code: 21.

Мигрировать можно по частям: v1 и v2 работают параллельно на одном ключе, так что разумно перевести один сценарий, посмотреть на метрики и продолжить.

Полная спецификация — в документации: О CreativeLab API, Список моделей, Создание задачи, Расчёт цены, Webhooks и Справочник ошибок.