spicyapiドキュメント
はじめに
本文

クイックスタート

キーの取得、タスク作成、結果の取得まで、最初から最後までの手順を説明します。

SpicyAPI は画像・動画生成を共通の認証・タスク・結果プロトコルで提供し、テキストモデルは使い慣れた OpenAI、Anthropic、Gemini 形式で利用できます。モデルを切り替えても呼び出しの流れは同じで、そのモデルの inputSchema(受付項目・型・許可値の一覧)から input を組み立てます。

このガイドは https://api.spicyapi.ai/api/v1 のネイティブタスク API を使います。テキスト・動画の互換エンドポイントは https://api.spicyapi.ai/v1 です。どちらも同じ API キーを使いますが、リクエストとレスポンスの形式は異なります。

このページの手順でモデルを選び、結果を取得できます。支払前に正確な価格を確認するには 見積もりと互換性、チャットには テキストとストリーミングを参照してください。連携を本番に出す前は 本番への組み込み、途中で失敗した場合は トラブルシューティングをご覧ください。用語は 用語集で説明しています。

コードを書く前に結果を見たい場合

spicyapi.ai/exploreでモデルを開き、プレイグラウンド タブを使ってください。API タブには同じリクエストがコピーできるコマンドとして表示されます。コードを書かずターミナルから使うには CLI、AI アシスタントに操作させるには MCP サーバーを使えます。

公式ツール

HTTP API の直接呼び出しにも完全対応しており、以下はその方法を示します。HTTP 呼び出しを自分で書かずに使いたい場合は、MIT ライセンスの npm パッケージが 4 つあります。それぞれ個別にインストールし、専用のガイドを参照できます。

Node.js 以外にも公式 SDK が 4 つあり、すべて同じ流れを実装した MIT ライセンスです。現在、通常のコマンドでリポジトリから導入できるのは Go モジュールだけです。他の 3 ページでは、パッケージ公開まで使うインストール方法を示しています。

ComfyUI を使う場合は、ComfyUI ノードで各画像・動画・音声モデルをキャンバスのノードとして利用し、実行前に価格を確認できます。

始める前に

必要なのは次の 4 点です。省略すると後の段階で失敗し、どれが足りないか分かりにくいことがあります。

  1. メール確認済みのアカウント。 spicyapi.aiで登録し、確認メールのリンクを開きます。確認まではキーによる呼び出しがすべて 403 になります。
  2. 残高。 生成料金は米ドル残高から支払い、独自クレジットはありません。新規残高はゼロです。コンソール → 請求で入金してください。不足すると作成は業務コード 40201 で失敗します。アカウント設定に登録、入金、キー作成の手順があります。
  3. ターミナル。 macOS は Terminal、Linux は任意のシェル、Windows は PowerShell です。コマンドが違う箇所には Windows PowerShell タブを設けています。macOS / Linux のコマンドは、Windows の WSL や Git Bash でもそのまま使えます。
  4. 数セントの予算。 手順 3 は実際の画像タスクを 1 件作り、成功すると課金されます。手順 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:最安の区分。unit(per_image、per_second、per_request、per_1k_tokens)あたりの USD 金額 amount と、区分数 tiers を示します。テキストモデルは price.perMillionTokens を読み、100 万トークンあたりの入力・キャッシュ入力・出力価格を確認します。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 はライブ見積もりが正確な基準であることを示す一般的な注記で、項目一覧ではありません。リクエスト 1 件の正確な金額は、キー取得後に POST /api/v1/jobs/quoteで確認してください。

比較は公開カタログ、呼び出しは認証済みカタログを使います

公開カタログが答えるのは「何が販売され、表示価格はいくらか」です。「自分のキーで何を呼べるか」「自分のアカウントへの請求額」は個別条件なので分かりません。キーを取得したら手順 2 の GET /api/v1/models に切り替え、呼び出せる ID の唯一の情報源としてください。記憶した ID、公開時点の一覧、紹介ページを代用しないでください。

5 つの手順

キーを取得する

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、現在のスキーマ、例は認証済みカタログだけを基準にします。2 回で取得します。軽い一覧で何を呼べるか確認し、選んだモデルの個別記録でスキーマを読みます。カタログの取得は無料です。

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 がタイムアウトしたり切断した場合は、3a を実行せず 3b だけを再実行します。同じキーなら開始済みタスクを返し、二重払いを防ぎます。別の画像を作る場合は先に 3a を実行します。409 は異なるプロンプトやモデルで同じキーを再使用したことを示します。冪等性を参照してください。

ここでは 1 フィールドだけ送信しています。手順 2 のスキーマでほかの項目が必須なら追加してください。input はモデル固有のスキーマで決まり、不明な項目が 1 つでもあれば 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 です。共通形式の成功の業務 code は 200 のままです。受理済みタスクはキャンセルできません。

結果を取得する

本番のリクエストに callBackUrl を含めると、タスクが最終状態になった際にコールバックを送ります。コールバック(Webhook)は完了時に呼び出すサーバーの HTTPS アドレスで、何度も状態を尋ねる必要がなくなります。上の最小リクエストでは公開エンドポイントなしで動かせるよう省略しています。コールバックは必ず署名を検証してください。Webhookを参照してください。

コールバックがなければタスクを照会します。

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 ができます。料金は コンソール → ログ、または settled が true になった後の cost で確認できます。

テキストモデル:1 リクエストで回答を返す

テキストモデルは同じ HTTP レスポンスに回答を返すため、ポーリングもダウンロードも不要です。後で料金を確認する場合、X-Spicy-Task-Id ヘッダーが課金を記録するタスクを示します。手順 2 で代わりに modality=text を指定し、1 つの ID を 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} の共通形式ではありません。テキストとストリーミングを参照してください。

コードからモデルを選ぶ

上の 5 手順は人がモデルを選びます。本番の連携では送信直前にコードで選びます。先週呼べたモデルが今日は停止中の場合があり、価格もアカウントごとだからです。同じ呼び出しでスキーマとサーバー検証済みの例を取得し、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 に戻らずそこで停止します。以下の 3 プログラムは jq なしで同じ選択をします。

全体を組み合わせる

以下の 3 例はカタログ取得、タスク作成、待ち時間を延ばすポーリング、ダウンロード URL の表示まで行います。本番では Webhookを使い、ポーリングは代替手段にしてください。

実行すると実際の課金タスクを作成します

各例はカタログの最初の利用可能な文生画像モデルを選びます。最安のモデルではありません。検証済みの例を 1 件送信するため、先にそのモデルの価格を確認するか、希望するモデルを正確に選ぶよう変更してください。

言語保存名事前の導入実行
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.gogo mod init examplego run .

3 例とも手順 1 の SPICY_API_KEY を読み、完了時に結果 URL を表示します。待機は最大 10 分で画像向けです。動画ではタスクの 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)
}

レスポンスの共通形式

ネイティブタスク API の JSON は次の共通形式です。/v1 互換と SSE はそれぞれのプロトコル形式を使います。

{ "code": 200, "msg": "success", "data": { }, "request_id": "req_…" }
  • code は HTTP ステータスではなく業務コードで、200 が成功です。HTTP も正しく設定するため、どちらでも分岐できますが、片方を選んでください。特定コードでは異なり、残高不足は code: 40201、HTTP は 402 です。混在するとケースを取りこぼします。
  • 失敗時の data はなく、null ではありません。
  • request_id は成功・失敗の両方にあり、X-Request-Id ヘッダーにも入ります。問題報告時に伝えてください。

次に読む

このページの内容