クイックスタート
キーの取得、タスク作成、結果の取得まで、最初から最後までの手順を説明します。
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 つあります。それぞれ個別にインストールし、専用のガイドを参照できます。
@spicyapi/sdk
TypeScript SDK:OpenAPI の型、自動調整する待機、アップロード、Webhook 検証
@spicyapi/cli
spicyapi コマンド:ターミナルからカタログ、見積もり、タスク作成と追跡
@spicyapi/mcp
カタログ・見積もり・タスクをコーディングエージェントに渡す MCP サーバー。課金操作ごとに確認
@spicyapi/skill
エージェントに導入できる Portable Agent Skill:作業手順と支出の境界
Node.js 以外にも公式 SDK が 4 つあり、すべて同じ流れを実装した MIT ライセンスです。現在、通常のコマンドでリポジトリから導入できるのは Go モジュールだけです。他の 3 ページでは、パッケージ公開まで使うインストール方法を示しています。
Python SDK
Python 3.11 以降向け spicyapi。実行時依存関係なし
Go SDK
Go 1.23 以降向け github.com/spicyapi-ai/spicy-go。標準ライブラリのみ
PHP SDK
PHP 8.1 以降向け spicyapi/spicyapi。ライブラリ依存なし
Java SDK
Java 17 以降向け ai.spicyapi:spicyapi-java。sealed 型によるエラー分類
ComfyUI を使う場合は、ComfyUI ノードで各画像・動画・音声モデルをキャンバスのノードとして利用し、実行前に価格を確認できます。
始める前に
必要なのは次の 4 点です。省略すると後の段階で失敗し、どれが足りないか分かりにくいことがあります。
- メール確認済みのアカウント。 spicyapi.aiで登録し、確認メールのリンクを開きます。確認まではキーによる呼び出しがすべて
403になります。 - 残高。 生成料金は米ドル残高から支払い、独自クレジットはありません。新規残高はゼロです。コンソール → 請求で入金してください。不足すると作成は業務コード
40201で失敗します。アカウント設定に登録、入金、キー作成の手順があります。 - ターミナル。 macOS は Terminal、Linux は任意のシェル、Windows は PowerShell です。コマンドが違う箇所には Windows PowerShell タブを設けています。macOS / Linux のコマンドは、Windows の WSL や Git Bash でもそのまま使えます。
- 数セントの予算。 手順 3 は実際の画像タスクを 1 件作り、成功すると課金されます。手順 2 でモデル価格を表示し、任意のコマンドで正確な金額を無料で事前確認できます。
まず比較する:アカウントなしでモデルと価格を確認
モデルと価格を見るだけならキーもアカウントも不要です。サイトの公開カタログは認証なしで読め、次のコマンドは何も置き換えず実行できます。
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 を含む全記録を取得できます。
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 キーは、コードが毎回送信するパスワードです。アカウントを識別し、課金先を決めます。
- コンソール → API キーを開き、キーを作成 を押します。
my-first-testなどの名前を付け、詳細設定 はそのままにします。sk-spicy-で始まるキーをコピーするか、ファイルとして保存 を押します。表示は一度だけで、紛失したら新しく作ります。- 続く手順を実行するターミナルで環境変数に保存します。環境変数はターミナルから各コマンドへ渡す名前付きの値で、キーをコマンドに毎回入力する必要がなくなります。
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 回で取得します。軽い一覧で何を呼べるか確認し、選んだモデルの個別記録でスキーマを読みます。カタログの取得は無料です。
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"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 で示します。
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 です。独立したコマンドで生成してから送信します。
SPICY_IDEMPOTENCY_KEY="$(uuidgen)"
# Linux without uuidgen: SPICY_IDEMPOTENCY_KEY="$(cat /proc/sys/kernel/random/uuid)"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/mp4jq があればコピー・貼り付けを省略できます。
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 を必須条件にします。
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"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.py | pip install requests | python3 generate.py(Windows:py generate.py) |
| Go 1.21 以降 | 空フォルダーの main.go | go mod init example | go run . |
3 例とも手順 1 の SPICY_API_KEY を読み、完了時に結果 URL を表示します。待機は最大 10 分で画像向けです。動画ではタスクの deadlineAt まで待ってください。
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);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())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ヘッダーにも入ります。問題報告時に伝えてください。

