spicyapi文件
主要內容

快速入門

拿金鑰、建任務、取結果。從零到第一張圖的完整流程。

SpicyAPI 把影像、影片、語音等多模態生成模型收斂成一套身分驗證、任務與結果協定。切換模型時保留相同的呼叫流程,並按該模型的 inputSchema 組裝輸入。

本頁使用原生任務 API:https://api.spicyapi.ai/api/v1。文字與影片相容介面使用 https://api.spicyapi.ai/v1,兩者共用 API Key,但請求和回應形狀不同。

首次串接請依本頁選擇模型並取回結果;實際提交前依報價與協定相容確認費用。要串接聊天或串流輸出請看文字模型與串流,部署自己的整合前依正式環境串接指南逐項檢查,遇到問題直接查疑難排解與請求恢復

五步跑通

拿一把金鑰

控制台建立 API Key。金鑰形如 sk-spicy-…建立後只顯示一次,請當場存好。

export SPICY_API_KEY="sk-spicy-你的金鑰"

新金鑰預設帶一個較低的日消費上限,可在控制台調整。這是為了讓設定錯誤或金鑰洩露的損失有個天花板。詳見認證

選擇目前可呼叫的模型

已認證的即時目錄是模型 ID、目前的 Schema 與範例的唯一依據。組裝請求前先讀取目錄,只選擇同時滿足 enabled: trueavailable: true 的條目。下面還要求模型帶有伺服器端已驗證的輸入範例,避免猜欄位:

選擇一個即時文生圖模型(需要 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"

如果沒有相符的條目,就在建立付費任務之前停止。即時目錄為空時,首發快照、行銷頁或記憶中的模型 ID 都不能充當備用值。

提交任務

建一個文生圖任務
SPICY_IDEMPOTENCY_KEY="${SPICY_IDEMPOTENCY_KEY:-$(uuidgen)}"
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"

input 裡能放什麼由模型自己的 Schema 決定,多傳一個欄位就會 400。目前的 Schema 請從模型目錄讀取,它才是唯一依據。

連線不會被掛住,立刻回傳任務 ID:

HTTP/1.1 202 Accepted
{
  "code": 200,
  "msg": "success",
  "data": {
    "taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
    "state": "queued",
    "estimatedCost": "0.008",
    "deadlineAt": "2026-09-06T12:30:00Z"
  },
  "request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w2"
}

202 只表示受理,不表示生成完成

新建任務的 statequeued;如果是同一 Idempotency-Key 的安全重放,則回傳原任務的目前狀態。HTTP 202 表示請求已受理,不代表生成完成。首次受理時會凍結預估金額但尚未收費;凍結額也是這次任務的收費上限,少用會釋放差額,之後不會補扣超過凍結額的金額。回應本文沿用統一信封,所以業務 code 仍為 200

取結果

正式環境的請求如果帶了 callBackUrl,建議讓我們在任務進入終態時回呼你。上面的最小範例為了不需要公開端點也能執行,省略了回呼網址。回呼必須驗證簽章,見回呼

沒有設定回呼、或者想主動查一下:

curl "https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=job_01k3m8x9q2z4v7n5p6r8s0t1w3" \
  -H "Authorization: Bearer $SPICY_API_KEY"
{
  "code": 200,
  "msg": "success",
  "data": {
    "taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
    "model": "MODEL_ID_FROM_CATALOG",
    "state": "succeeded",
    "output": {
      "assets": [
        {
          "key": "tasks/2026/08/28/job_01k3m8x9q2z4v7n5p6r8s0t1w3/9f3c1d0a7b4e2f68c5a1d3e9b0472fa1.png",
          "url": "https://example.r2.cloudflarestorage.com/results/result.png?X-Amz-Signature=SIGNATURE_FROM_RESPONSE",
          "expiresAt": "2026-08-28T09:32:11Z",
          "mime": "image/png",
          "width": 1024,
          "height": 1024,
          "bytes": 1483920
        }
      ]
    },
    "cost": "0.008",
    "settled": true,
    "createdAt": "2026-08-28T09:12:04Z",
    "completedAt": "2026-08-28T09:12:11Z"
  }
}

下載輸出檔案

直接 GET 成功回應中的 data.output.assets[0].url,不攜帶 API Key,無需再申請下載票據。expiresAt 是本次短期連結有效期,到期可在14天結果留存期內重新輪詢。

curl "$RESULT_URL_FROM_RESPONSE" -o result.png

完整範例

下面三段依序讀取目錄、建立任務、退避輪詢並取得下載網址。正式環境請改用回呼,輪詢只作為備援。

Node.js 18+
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();
  // 判斷成功看 code,不要只看 res.ok —— 業務碼比 HTTP 狀態碼的表達力強得多
  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,
    }),
  });

  // 2 秒起步,每次乘 1.5,封頂 15 秒。第一次查詢幾乎必然還是 queued,
  // 起步給短一點只是浪費一次往返。
  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');
    }
    // 除 queued / running 外均按終態處理,可相容歷史 canceled 與未來新增終態。
    if (task.state !== 'queued' && task.state !== 'running') {
      throw new Error(`${task.state}: ${task.errorMessage ?? '無更多資訊'}`);
    }
  }
  throw new Error(`任務 ${taskId} 等待逾時`);
}

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()
    # 判斷成功看 code,不要只看 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"]

    # 2 秒起步,每次乘 1.5,封頂 15 秒
    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")
        # 除 queued / running 外均按終態處理。
        if task["state"] not in {"queued", "running"}:
            raise RuntimeError(f'{task["state"]}: {task.get("errorMessage", "無更多資訊")}')
    raise TimeoutError(f"任務 {task_id} 等待逾時")


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 是全站統一的回應外殼。Data 留成 RawMessage,
// 由各介面自己解成需要的形狀。
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
	}
	// 判斷成功看 code,不要只看 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
	}

	// 2 秒起步,每次乘 1.5,封頂 15 秒
	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")
		}
		// 判終態:不在這兩個中間態裡就是終態,不要逐個列舉終態
		if task.State != "queued" && task.State != "running" {
			return "", fmt.Errorf("%s: %s", task.State, task.ErrorMsg)
		}
	}
	return "", fmt.Errorf("任務 %s 等待逾時", 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業務碼200 表示成功。HTTP 狀態碼同時也會正確設定,兩者並行,你可以任選一種判斷方式——但只選一種,混著判斷遲早會踩到 code 與 HTTP 碼不同名的情況(例如餘額不足是 code: 40201 + HTTP 402)。
  • 失敗時 data 不出現(不是 null)。
  • request_id 成功失敗都帶,也在回應標頭 X-Request-Id 上。回報問題時附上它。

接下來

本頁目錄