spicyapiДокументация
Начало работы
Основное содержимое

Быстрый старт

Получите ключ, создайте задачу и заберите результат. Весь процесс от начала до конца.

SpicyAPI предоставляет генерацию изображений и видео через единый протокол аутентификации, задач и результатов, а текстовые модели — в привычных форматах OpenAI, Anthropic и Gemini. При смене модели сохраняйте тот же порядок вызовов, а input формируйте по её inputSchema — списку принимаемых полей, их типов и допустимых значений.

Руководство использует нативный API задач по адресу https://api.spicyapi.ai/api/v1. Эндпоинты совместимости для текста и видео используют https://api.spicyapi.ai/v1. Они принимают один и тот же API-ключ, но форматы запросов и ответов различаются.

Следуйте этой странице, чтобы выбрать модель и получить результат. Для точной цены до оплаты см. Расчёт цены и совместимость. Для чата — Текст и потоковые ответы. Перед выпуском интеграции пройдите Интеграция для рабочей среды; при ошибке на одном из шагов используйте Устранение неполадок. Незнакомые слова объясняются в глоссарии.

Хотите увидеть результат до написания кода?

Откройте любую модель на spicyapi.ai/explore и используйте вкладку Playground; вкладка API показывает тот же запрос в виде команды для копирования. Для работы из терминала без написания кода используйте CLI или поручите это AI-ассистенту через MCP-сервер.

Официальные инструменты

Прямые вызовы HTTP API полностью поддерживаются; именно их показывают дальнейшие шаги страницы. Если вы не хотите писать HTTP-вызовы самостоятельно, доступны четыре npm-пакета с лицензией MIT. Каждый устанавливается отдельно и имеет своё руководство:

Ещё четыре официальных SDK доступны для языков вне Node.js. Все реализуют тот же процесс и имеют лицензию MIT. Сейчас только модуль Go устанавливается из репозитория обычной командой; остальные три страницы показывают способ установки до публикации пакетов:

Работаете в ComfyUI? Узлы ComfyUI представляют каждую модель изображений, видео и аудио узлом на холсте и показывают её цену до запуска.

Перед началом

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

  1. Аккаунт с подтверждённой электронной почтой. Зарегистрируйтесь на spicyapi.ai и нажмите ссылку в письме подтверждения. До этого каждый вызов с вашим ключом возвращает 403.
  2. Баланс. Генерации оплачиваются из баланса в долларах США (баллов нет); новый аккаунт начинает с нуля. Пополните в разделе Консоль → Оплата. При недостатке средств создание задачи завершается кодом API 40201. Раздел Настройка аккаунта объясняет регистрацию, пополнение и создание ключа.
  3. Терминал. macOS: приложение Terminal. Linux: любая оболочка. Windows: PowerShell. Если команды отличаются, шаг содержит вкладку Windows PowerShell. При желании команды macOS / Linux можно без изменений запускать в WSL или Git Bash на Windows.
  4. Несколько центов. Шаг 3 создаёт настоящую задачу изображения, оплачиваемую при успехе. Шаг 2 показывает цену каждой модели; необязательная команда на том же шаге бесплатно сообщает точную сумму заранее.

Сначала ознакомьтесь: модели и цены без аккаунта

Для просмотра моделей и цен не нужны ни ключ, ни аккаунт. Публичный каталог сайта читается без авторизации; в этой команде ничего заменять не нужно:

Public catalogue — no key, no account
curl "https://api.spicyapi.ai/console/v1/catalog/models?locale=en"

Формат ответа тот же, что описан внизу страницы. В data.items каждая запись содержит:

  • model — точное значение, которое позже нужно передать как model (slug содержит то же значение);
  • callable — может ли модель выполняться прямо сейчас. Выбирайте записи с true: status описывает запланированное рабочее состояние, и модель live всё равно может временно быть недоступна для вызова;
  • modality и tasks — какой вид результата создаётся;
  • price — самая дешёвая категория: amount в USD за unit (per_image, per_second, per_request или per_1k_tokens), а также tiers, число категорий. Для текстовых моделей читайте price.perMillionTokens: там отдельные цены входных, кешированных входных и выходных токенов за миллион. Само amount — только выходной тариф.

Добавьте ID модели, чтобы получить полную запись одной модели, включая inputSchema, paramNotes и готовые examples:

One model, in full — replace MODEL_ID_FROM_CATALOG with a model value from the list above
curl "https://api.spicyapi.ai/console/v1/catalog/models/MODEL_ID_FROM_CATALOG?locale=en"

MODEL_ID_FROM_CATALOG — заполнитель, используемый в документации, а не настоящая модель. Всегда копируйте реальный ID из актуального каталога.

locale по умолчанию равен en; передайте ja, ko, de, fr, es, pt-BR, ru, it или pl для переведённых названий, описаний и заметок к параметрам. Любое другое значение возвращает 400. strict=1 исключает записи без опубликованного текста на этом языке вместо возврата английской версии; обычно он не нужен. Эта группа эндпоинтов ограничена по адресу источника, а не аккаунту, и имеет отдельный лимит — см. Ограничения частоты запросов. Она также кешируется, поэтому изменение цены может появиться здесь не сразу.

Если tiers больше 1, указанная price — самая дешёвая категория, а не цена ваших настроек. price.variant показывает настройки самой дешёвой категории (например, 720p или generate_audio=false;resolution=480p при сочетании нескольких полей). Поле inputSchema["x-pricing"].variantFields записи одной модели, если есть, перечисляет все поля выбора категории. Её pricingNote — общее напоминание о приоритете актуального расчёта цены, а не список полей. Для точной суммы запроса используйте POST /api/v1/jobs/quote, когда получите ключ.

Публичный каталог — для ознакомления, авторизованный — для вызовов

Публичный каталог отвечает на вопрос о том, что предлагается и по какой опубликованной цене. Он не отвечает, что может вызвать мой ключ и сколько заплатит мой аккаунт: оба условия зависят от аккаунта. Получив ключ, перейдите на GET /api/v1/models в шаге 2 и считайте его единственным источником доступных ID. Запомненный ID, снимок каталога при запуске или маркетинговая страница его не заменяют.

Пять шагов

Получите ключ

API-ключ — пароль, который код передаёт с каждым запросом. Он определяет аккаунт и используется для оплаты.

  1. Откройте Консоль → API-ключи и нажмите Создать ключ.
  2. Задайте имя, например my-first-test. Оставьте настройки Дополнительно как есть.
  3. Скопируйте ключ, начинающийся с sk-spicy-, или нажмите Сохранить в файл. Он показывается только один раз; при потере нужно создать новый.
  4. В терминале для следующих шагов сохраните его в переменной окружения — именованном значении, передаваемом терминалом каждой запускаемой команде. Тогда ключ не нужно вводить в саму команду:
export SPICY_API_KEY="sk-spicy-paste-your-key-here"

# Check: this should print sk-spicy-
echo "${SPICY_API_KEY:0:9}"

Что должно появиться: sk-spicy-. Значение сохраняется до закрытия окна терминала, поэтому все дальнейшие шаги выполняйте в том же окне.

Если кнопки Создать ключ нет, ваша роль в текущей команде не позволяет создавать ключи. Обратитесь к владельцу или используйте Создать свою команду на той же странице. У новых ключей нет лимита расходов, пока вы его не зададите; дневной лимит в консоли ограничивает последствия неверной настройки или утечки. См. Аутентификация.

Выберите доступную для вызова модель

Авторизованный каталог — единственный источник доступных ID моделей, текущих схем и примеров. Используйте два вызова: лёгкий список сообщает, что можно вызвать, затем одна запись даёт схему выбранной модели. Чтение каталога бесплатно.

1. What can this key call?
curl "https://api.spicyapi.ai/api/v1/models?modality=image&task=text-to-image" \
  -H "Authorization: Bearer $SPICY_API_KEY"

Ответ длинный. Нужная часть выглядит так (сокращённо):

{
  "code": 200,
  "msg": "success",
  "data": {
    "total": 1,
    "items": [
      {
        "model": "MODEL_ID_FROM_CATALOG",
        "displayName": "…",
        "modality": "image",
        "tasks": ["text-to-image"],
        "enabled": true,
        "available": true,
        "startingPrice": { "variant": "", "unit": "per_image", "price": "0.008", "currency": "USD" }
      }
    ]
  },
  "request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w2"
}

Выберите запись, где enabled и available одновременно равны true; запомните цену, скопируйте model и используйте его на следующих шагах. При установленном jq команда curl … | jq -r '.data.items[] | select(.enabled and .available) | .model' выводит только доступные для вызова ID.

# Replace MODEL_ID_FROM_CATALOG with the model value you copied
export SPICY_MODEL="MODEL_ID_FROM_CATALOG"
2. What fields does that one model take?
curl "https://api.spicyapi.ai/api/v1/models/${SPICY_MODEL}" \
  -H "Authorization: Bearer $SPICY_API_KEY"

Косые черты ID проходят в пути без изменений; кодируйте их через процентное представление только если HTTP-клиент требует один сегмент для параметра пути. inputSchema записи определяет input: required перечисляет обязательные поля, а properties описывает каждое. examples содержит входные данные, уже проверенные сервером. Как читать остальные поля, включая pricing, — в разделе Каталог моделей и схемы.

Запрашивайте схемы только там, где нужны. Добавление includeSchema=1&includeExamples=1 к вызову списка возвращает полную JSON Schema каждой включённой модели одним ответом, значительно увеличивая его размер. Чтение каталога использует лимит чтения аккаунта независимо от создания задач. Не перечитывайте каталог перед каждой задачей; см. Кеширование каталога.

Если подходящих записей нет, остановитесь до создания платной задачи. Снимок каталога при запуске, маркетинговая страница или запомненный ID не заменяют пустой актуальный каталог. Как выбрать модель программно — в разделе Выбор модели из кода.

Необязательно: сначала узнайте точную цену. Расчёт бесплатен, ничего не создаёт и не резервирует. data.estimatedCost — максимальная стоимость этого конкретного запроса в USD:

Optional quote (macOS / Linux)
curl -X POST https://api.spicyapi.ai/api/v1/jobs/quote \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  --data "{\"model\":\"${SPICY_MODEL}\",\"input\":{\"prompt\":\"A cinematic night portrait\"}}"

Создайте задачу

Задача — генерация в фоне: ID вы получаете сразу, результат — позже. Этот шаг стоит денег.

Каждая новая задача получает новый ключ идемпотентности: случайный ID, который вы прикрепляете, чтобы повторная отправка того же запроса, например после сетевой ошибки, не создала две задачи. Создайте его отдельной командой, затем отправьте задачу:

3a. A new key for each new image (run once per image)
SPICY_IDEMPOTENCY_KEY="$(uuidgen)"
# Linux without uuidgen: SPICY_IDEMPOTENCY_KEY="$(cat /proc/sys/kernel/random/uuid)"
3b. Send the task (safe to run again after a network error)
curl -X POST https://api.spicyapi.ai/api/v1/jobs/createTask \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $SPICY_IDEMPOTENCY_KEY" \
  --data "{\"model\":\"${SPICY_MODEL}\",\"input\":{\"prompt\":\"A cinematic night portrait\"}}"

Если 3b завершился тайм-аутом или соединение оборвалось, повторите 3b, не выполняя 3a: тот же ключ вернёт уже запущенную задачу, не заставляя платить дважды. Для другого изображения сначала выполните 3a. Ответ 409 означает повторное использование ключа с другим промптом или моделью. Подробнее — Идемпотентность.

Здесь отправляется одно поле. Если схема из шага 2 помечает другие поля обязательными, добавьте их: допустимый input определяет собственная схема каждой модели, а одно неизвестное поле — это 400.

Соединение не остаётся открытым. ID задачи возвращается сразу:

HTTP/1.1 202 Accepted
{
  "code": 200,
  "msg": "success",
  "data": {
    "taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
    "state": "queued",
    "estimatedCost": "0.008",
    "deadlineAt": "2026-08-28T09:22:04Z"
  },
  "request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w2"
}

На macOS / Linux скопируйте taskId ответа и сохраните его (PowerShell уже сделал это):

export SPICY_TASK_ID="paste-the-taskId-here"

202 означает принятие, а не завершение

Новая задача имеет state: "queued"; безопасный повтор с тем же Idempotency-Key возвращает исходную задачу в её текущем состоянии. HTTP 202 означает, что запрос принят, а не что генерация завершена. При первом принятии средства резервируются, а не списываются: estimatedCost откладывается из баланса. Резерв также ограничивает оплату задачи сверху: остаток освобождается, а позднее ничего сверх него не списывается. Неудавшиеся задачи обычно освобождают резерв; определённые отказы по содержанию, раскрытые на странице модели, оплачиваются по принятому расчёту. Проверяйте settled и cost. deadlineAt — последнее время завершения задачи, после которого она становится expired. Успешный код API code в общем формате остаётся 200. Принятую задачу отменить нельзя.

Получите результат

Если рабочий запрос содержит callBackUrl, мы обращаемся к вашему серверу, когда задача достигает конечного состояния. Обратный вызов (вебхук) — адрес HTTPS вашего сервера, вызываемый после завершения задачи, чтобы вам не приходилось постоянно проверять. Минимальный запрос выше не содержит его и может выполняться без публичного эндпоинта. Подпись обратного вызова обязательно проверяйте — см. Вебхуки.

Без эндпоинта обратного вызова запросите задачу:

curl "https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=${SPICY_TASK_ID}" \
  -H "Authorization: Bearer $SPICY_API_KEY"

Используйте создавший задачу API-ключ: другой получит 404. Смотрите data.state:

data.stateЗначениеЧто делать
queued или runningГенерация продолжается. Средства в резерве, не потраченыПодождите несколько секунд и повторите ту же команду. Изображения обычно занимают секунды, видео — минуты. deadlineAt задаёт верхнюю границу
succeededГотовоПерейдите к следующему шагу
failed или expiredЗадача не удалась. Резерв обычно освобождается; некоторые явно раскрытые на странице модели отказы по содержанию оплачиваются. Проверяйте settled и costПрочитайте data.errorCode (стабильный) и data.errorMessage (для человека), исправьте входные данные и начните с 3a

Общий code равен 200, даже если задача не удалась: code показывает успех чтения задачи. Собственный результат задачи всегда в data.state. Ответы на разных этапах выглядят так (сокращённо):

{
  "code": 200,
  "msg": "success",
  "data": {
    "taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
    "model": "MODEL_ID_FROM_CATALOG",
    "state": "running",
    "cost": "0.008",
    "settled": false,
    "createdAt": "2026-08-28T09:12:04Z",
    "deadlineAt": "2026-08-28T09:22:04Z"
  },
  "request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w4"
}

Пока settled равно false, cost показывает резерв, а не итоговое списание.

Скачайте файл результата

Возьмите data.output.assets[0].url успешного результата и скачайте файл напрямую. Не прикрепляйте API-ключ. Ссылка действует около 20 минут (expiresAt); если срок истёк, повторите шаг 4 — каждое чтение выдаёт новую ссылку. Сгенерированные файлы удаляются не позднее 14 дней, поэтому сохраняйте собственные копии нужных файлов.

# Paste data.output.assets[0].url. Keep the double quotes: the URL contains & characters.
RESULT_URL="paste-the-url-here"
curl -L "$RESULT_URL" -o result.png   # use result.mp4 when mime is video/mp4

При установленном jq можно обойтись без копирования и вставки:

RESULT_URL="$(curl -s "https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=${SPICY_TASK_ID}" \
  -H "Authorization: Bearer $SPICY_API_KEY" | jq -r '.data.output.assets[0].url')"

Что должно появиться: файл result.png в текущей папке терминала. Проверьте списание в разделе Консоль → Логи или в cost, когда settled равно true.

Текстовые модели: один запрос, ответ в том же ответе HTTP

Текстовые модели отвечают в том же HTTP-ответе: без опроса и скачивания. Заголовок ответа X-Spicy-Task-Id указывает задачу с оплатой, если её потребуется проверить позже. В шаге 2 запросите модели с modality=text и сохраните одну в SPICY_MODEL, затем:

curl https://api.spicyapi.ai/v1/chat/completions \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  --data "{\"model\":\"${SPICY_MODEL}\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in one sentence.\"}]}"

Ответ находится в choices[0].message.content. Вызов оплачивается по использованным токенам. Эндпоинт использует формат ответа OpenAI, а не общий {code, msg, data}. См. Текст и потоковые ответы.

Выбор модели из кода

Пять шагов выше предполагают участие человека. Рабочая интеграция выбирает модель самостоятельно непосредственно перед отправкой: модель, доступная на прошлой неделе, сегодня может быть приостановлена, а цены зависят от аккаунта. Запросите схему и проверенный сервером пример одним вызовом, затем проверьте enabled и available:

Select a live text-to-image model (requires jq)
CATALOG_JSON="$(curl --fail-with-body \
  "https://api.spicyapi.ai/api/v1/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1" \
  -H "Authorization: Bearer $SPICY_API_KEY")"

MODEL_ID="$(printf '%s' "$CATALOG_JSON" | jq -er \
  '[.data.items[] | select(.enabled == true and .available == true and (.examples | length > 0))][0].model')"
MODEL_INPUT="$(printf '%s' "$CATALOG_JSON" | jq -cer \
  '[.data.items[] | select(.enabled == true and .available == true and (.examples | length > 0))][0].examples[0].input')"

printf 'Selected %s\n' "$MODEL_ID"
Create the task from those two values (this costs money)
SPICY_IDEMPOTENCY_KEY="$(uuidgen)"   # new task, new key; reuse it only to resend this same request
TASK_PAYLOAD="$(jq -cn \
  --arg model "$MODEL_ID" \
  --argjson input "$MODEL_INPUT" \
  '{model: $model, input: $input}')"

curl -X POST https://api.spicyapi.ai/api/v1/jobs/createTask \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $SPICY_IDEMPOTENCY_KEY" \
  --data "$TASK_PAYLOAD"

Если выбор ничего не дал, остановитесь, не переходя на запомненный ID. Три программы ниже выполняют тот же выбор без jq.

Полный процесс

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

Запуск программ создаёт настоящую платную задачу

Каждая программа выбирает первую доступную модель генерации изображения из текста в вашем каталоге (не самую дешёвую) и отправляет один из проверенных примеров. Сначала проверьте её цену или измените выбор на конкретную нужную модель.

ЯзыкСохранить какСначала установитьЗапуск
Node.js 20 или новееgenerate.mjsничегоnode generate.mjs
Python 3.9 или новееgenerate.pypip install requestspython3 generate.py (Windows: py generate.py)
Go 1.21 или новееmain.go в пустой папкеgo mod init examplego run .

Все три читают ключ из SPICY_API_KEY (шаг 1) и выводят URL результата после завершения задачи. Они ждут до десяти минут, что подходит для моделей изображений; для видео ждите до deadlineAt задачи.

Node.js 20+
const BASE = 'https://api.spicyapi.ai/api/v1';
const KEY = process.env.SPICY_API_KEY;

const headers = {
  Authorization: `Bearer ${KEY}`,
  'Content-Type': 'application/json',
};
async function call(path, init) {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { ...headers, ...init?.headers },
    signal: AbortSignal.timeout(30_000),
  });
  const body = await res.json();
  // Branch on `code`, not on res.ok — the business code says far more
  if (body.code !== 200) {
    throw new Error(`${body.code} ${body.msg} (request_id=${body.request_id})`);
  }
  return body.data;
}

async function generate() {
  const catalog = await call(
    '/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1',
  );
  const selected = catalog.items.find(
    (item) => item.enabled && item.available && item.examples?.length,
  );
  if (!selected) throw new Error('No callable text-to-image model with a validated example');

  const idempotencyKey = crypto.randomUUID();
  const { taskId } = await call('/jobs/createTask', {
    method: 'POST',
    headers: { 'Idempotency-Key': idempotencyKey },
    body: JSON.stringify({
      model: selected.model,
      input: selected.examples[0].input,
    }),
  });

  // Start at 2s, multiply by 1.5, cap at 15s. The first check is almost
  // certainly still `queued`, so a shorter start just wastes a round trip.
  const deadline = Date.now() + 10 * 60_000;
  let wait = 2000;
  while (Date.now() < deadline) {
    await new Promise((r) => setTimeout(r, wait));
    wait = Math.min(wait * 1.5, 15000);

    const task = await call(`/jobs/recordInfo?taskId=${taskId}`);
    if (task.state === 'succeeded') {
      const asset = task.output?.assets?.find((item) => item.url);
      if (asset) return asset.url;
      if (task.output?.assets?.some((item) => item.pending)) continue;
      throw new Error('Result is unavailable');
    }
    // Anything other than queued / running is terminal. This also handles
    // historical canceled records and future terminal states safely.
    if (task.state !== 'queued' && task.state !== 'running') {
      throw new Error(`${task.state}: ${task.errorMessage ?? 'no detail'}`);
    }
  }
  throw new Error(`task ${taskId} timed out`);
}

generate().then(console.log);
Python 3.9+
import os, time, uuid, requests

BASE = "https://api.spicyapi.ai/api/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['SPICY_API_KEY']}",
    "Content-Type": "application/json",
}


def call(method: str, path: str, headers=None, **kwargs) -> dict:
    res = requests.request(
        method, f"{BASE}{path}", headers={**HEADERS, **(headers or {})}, timeout=30, **kwargs
    )
    body = res.json()
    # Branch on `code`, not on status_code
    if body["code"] != 200:
        raise RuntimeError(f'{body["code"]} {body["msg"]} (request_id={body.get("request_id")})')
    return body["data"]


def generate() -> str:
    catalog = call(
        "GET",
        "/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1",
    )
    selected = next(
        (
            item
            for item in catalog["items"]
            if item["enabled"] and item["available"] and item.get("examples")
        ),
        None,
    )
    if selected is None:
        raise RuntimeError("No callable text-to-image model with a validated example")

    idempotency_key = str(uuid.uuid4())
    task_id = call("POST", "/jobs/createTask", headers={"Idempotency-Key": idempotency_key}, json={
        "model": selected["model"],
        "input": selected["examples"][0]["input"],
    })["taskId"]

    # Start at 2s, multiply by 1.5, cap at 15s
    deadline = time.monotonic() + 600
    wait = 2.0
    while time.monotonic() < deadline:
        time.sleep(wait)
        wait = min(wait * 1.5, 15.0)

        task = call("GET", "/jobs/recordInfo", params={"taskId": task_id})
        if task["state"] == "succeeded":
            assets = task.get("output", {}).get("assets", [])
            ready = next((asset for asset in assets if asset.get("url")), None)
            if ready:
                return ready["url"]
            if any(asset.get("pending") for asset in assets):
                continue
            raise RuntimeError("Result is unavailable")
        # Anything other than queued / running is terminal.
        if task["state"] not in {"queued", "running"}:
            raise RuntimeError(f'{task["state"]}: {task.get("errorMessage", "no detail")}')
    raise TimeoutError(f"task {task_id} timed out")


print(generate())
Go 1.21+
package main

import (
	"bytes"
	"crypto/rand"
	"encoding/json"
	"encoding/hex"
	"fmt"
	"math"
	"net/http"
	"os"
	"time"
)

const base = "https://api.spicyapi.ai/api/v1"

// envelope is the shape every endpoint returns. Data stays raw so each
// call site can decode it into whatever it actually expects.
type envelope struct {
	Code      int             `json:"code"`
	Msg       string          `json:"msg"`
	Data      json.RawMessage `json:"data"`
	RequestID string          `json:"request_id"`
}

var client = &http.Client{Timeout: 30 * time.Second}

func newIdempotencyKey() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return "job-" + hex.EncodeToString(b)
}

func call(method, path string, body any, idempotencyKey string, out any) error {
	var rdr *bytes.Reader
	if body != nil {
		raw, err := json.Marshal(body)
		if err != nil {
			return err
		}
		rdr = bytes.NewReader(raw)
	} else {
		rdr = bytes.NewReader(nil)
	}

	req, err := http.NewRequest(method, base+path, rdr)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("SPICY_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	if idempotencyKey != "" {
		req.Header.Set("Idempotency-Key", idempotencyKey)
	}

	res, err := client.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()

	var env envelope
	if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
		return err
	}
	// Branch on Code, not on res.StatusCode
	if env.Code != 200 {
		return fmt.Errorf("%d %s (request_id=%s)", env.Code, env.Msg, env.RequestID)
	}
	return json.Unmarshal(env.Data, out)
}

func generate() (string, error) {
	var catalog struct {
		Items []struct {
			Model     string `json:"model"`
			Enabled   bool   `json:"enabled"`
			Available bool   `json:"available"`
			Examples  []struct {
				Input map[string]any `json:"input"`
			} `json:"examples"`
		} `json:"items"`
	}
	if err := call(http.MethodGet,
		"/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1",
		nil, "", &catalog); err != nil {
		return "", err
	}
	var modelID string
	var modelInput map[string]any
	for _, item := range catalog.Items {
		if item.Enabled && item.Available && len(item.Examples) > 0 {
			modelID, modelInput = item.Model, item.Examples[0].Input
			break
		}
	}
	if modelID == "" {
		return "", fmt.Errorf("no callable text-to-image model with a validated example")
	}

	var created struct {
		TaskID string `json:"taskId"`
	}
	err := call(http.MethodPost, "/jobs/createTask", map[string]any{
		"model": modelID,
		"input": modelInput,
	}, newIdempotencyKey(), &created)
	if err != nil {
		return "", err
	}

	// Start at 2s, multiply by 1.5, cap at 15s
	wait := 2 * time.Second
	deadline := time.Now().Add(10 * time.Minute)
	for time.Now().Before(deadline) {
		time.Sleep(wait)
		wait = time.Duration(math.Min(float64(wait)*1.5, float64(15*time.Second)))

		var task struct {
			State    string `json:"state"`
			ErrorMsg string `json:"errorMessage"`
			Output struct { Assets []struct { URL string `json:"url"`; Pending bool `json:"pending"` } `json:"assets"` } `json:"output"`
		}
		if err := call(http.MethodGet, "/jobs/recordInfo?taskId="+created.TaskID, nil, "", &task); err != nil {
			return "", err
		}

		if task.State == "succeeded" {
			pending := false
			for _, asset := range task.Output.Assets {
				if asset.URL != "" { return asset.URL, nil }
				pending = pending || asset.Pending
			}
			if pending { continue }
			return "", fmt.Errorf("result is unavailable")
		}
		// Terminal means "not one of the two in-flight states" — do not
		// enumerate the terminal states, that breaks when a new one appears
		if task.State != "queued" && task.State != "running" {
			return "", fmt.Errorf("%s: %s", task.State, task.ErrorMsg)
		}
	}
	return "", fmt.Errorf("task %s timed out", created.TaskID)
}

func main() {
	url, err := generate()
	if err != nil {
		panic(err)
	}
	fmt.Println(url)
}

Общий формат ответа

JSON-ответы нативного API задач используют этот формат; совместимые /v1 и SSE — свои форматы протоколов:

{ "code": 200, "msg": "success", "data": { }, "request_id": "req_…" }
  • code — код API, а не HTTP-статус. 200 означает успех. HTTP-статус также корректен, поэтому можно обрабатывать любой из них, но выберите один. Конкретные коды различаются (недостаточный баланс — code: 40201 с HTTP 402), и смешение рано или поздно приводит к пропуску случая.
  • При ошибке data отсутствует, а не равен null.
  • request_id есть и при успехе, и при ошибке; он также возвращается в заголовке ответа X-Request-Id. Указывайте его при сообщении о проблеме.

Дальше

На этой странице