非同步任務模型
createTask 與 recordInfo 的完整契約、六種狀態的含義、以及回呼與輪詢該怎麼選。
生成是非同步的:提交與取結果是兩次獨立的呼叫。
影像幾秒,影片常常幾分鐘。做成同步介面的話,你的程式要掛在連線上等,中途任何一次網路抖動都會讓你既拿不到結果、又不知道該不該重試——而重試一次同步生成請求,你不知道上一次是不是其實已經在跑了。
拆開之後,任務 ID 成為冪等的錨點:連線斷了重連,拿著同一個任務 ID 接著查就行。
一、建任務
POST /api/v1/jobs/createTask請求標頭
| 標頭 | 必填 | 說明 |
|---|---|---|
Authorization | 是 | Bearer sk-spicy-… |
Content-Type | 是 | application/json |
Idempotency-Key | 否 | 強烈建議帶。見冪等鍵 |
請求本文
欄位
型別
建任務、串流請求與報價都會先驗證 callBackUrl:只接受使用 80 或 443 埠、可公開存取的 http/https 網址,拒絕 URL 中的使用者名稱或密碼、內部網路 IP 以及無法解析的主機名稱。網址無效時回傳 HTTP 400,信封內為業務 code: 400、msg: "Invalid callback URL",並附帶 request_id;不會回傳提交的 URL、主機名稱、IP 或 DNS 明細。實際建立連線時還會再次檢查目標,詳見回呼。
回應
HTTP/1.1 202 AcceptedHTTP 狀態是 202 Accepted;回應本文仍使用統一信封,因此成功時業務欄位 code 為 200。
{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"state": "queued",
"estimatedCost": "0.008"
},
"request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w2"
}| 欄位 | 說明 |
|---|---|
taskId | 任務 ID,job_ 字首。後續全靠它 |
state | 新建任務為 queued;同鍵冪等重放為原任務的目前狀態 |
estimatedCost | 受理時凍結的金額,形如 "0.008"(美元,字串),也是本任務最終收費上限。少用釋放差額;之後不會補扣超過凍結額的金額 |
金額是字串,但裡面沒有貨幣符號
estimatedCost、cost、餘額三項都是形如 "0.008" 的十進位字串:沒有 $、沒有千分位,可以直接 parseFloat / float() 解析。
用字串而不是 JSON 數字,是因為 JSON 的數字在多數語言裡會解析成 float64,而 0.008 在二進位浮點裡不精確——價格一旦進過一次浮點,就再也說不清「到底扣了多少」。要精確對帳請用十進位型別(decimal.Decimal、BigDecimal)解析,不要用 float。
貨幣恆為美元,見計費。
範例
下文的 MODEL_ID_FROM_CATALOG 是明確的結構預留位置。請從同一條即時目錄記錄中取得 model 與符合 Schema 的輸入,一起替換預留位置和示意用的 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: order-8814-render-1" \
-d '{
"model": "MODEL_ID_FROM_CATALOG",
"input": {
"prompt": "slow dolly in, rain on the window",
"image_url": "https://example.com/reference.png",
"duration_seconds": 5,
"resolution": "768p"
},
"callBackUrl": "https://your-app.example.com/hooks/spicy"
}'const res = await fetch('https://api.spicyapi.ai/api/v1/jobs/createTask', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SPICY_API_KEY}`,
'Content-Type': 'application/json',
// 每個「邏輯請求」一個鍵,重試時重複使用同一個
'Idempotency-Key': 'order-8814-render-1',
},
body: JSON.stringify({
model: 'MODEL_ID_FROM_CATALOG',
input: { prompt: 'a folded paper lantern, hard side light' },
callBackUrl: 'https://your-app.example.com/hooks/spicy',
}),
});
const body = await res.json();
if (res.status !== 202 || body.code !== 200) throw new Error(`${res.status} ${body.code} ${body.msg}`);
console.log(body.data.taskId);import os, requests
res = requests.post(
"https://api.spicyapi.ai/api/v1/jobs/createTask",
headers={
"Authorization": f"Bearer {os.environ['SPICY_API_KEY']}",
"Content-Type": "application/json",
# 每個「邏輯請求」一個鍵,重試時重複使用同一個
"Idempotency-Key": "order-8814-render-1",
},
json={
"model": "MODEL_ID_FROM_CATALOG",
"input": {"prompt": "a folded paper lantern, hard side light"},
"callBackUrl": "https://your-app.example.com/hooks/spicy",
},
)
body = res.json()
if res.status_code != 202 or body["code"] != 200:
raise RuntimeError(f'{res.status_code} {body["code"]} {body["msg"]}')
print(body["data"]["taskId"])202 的 state 不一定總是 queued
首次受理會建立 queued 任務。如果同一 API Key 用同一 Idempotency-Key 和相同請求重放,這個 202 會回傳原任務的目前狀態,它可能已經是 running 或終態,但不會再建立任務或再次凍結金額。
二、狀態機
queued ──→ running ──┬──→ succeeded
├──→ failed
└──→ expired任務一經受理不可撤銷,queued 與 running 都沒有取消操作。介面仍能讀取舊系統留下的
canceled 記錄,但新任務不會由使用者或營運操作進入該狀態。
| 狀態 | 含義 | 是否終態 | 計費 |
|---|---|---|---|
queued | 已受理並凍結預估金額,等待 worker 領取 | 否 | 已凍結,尚未收費 |
running | 模型正在生成,等待結果 | 否 | 已凍結,尚未收費 |
succeeded | 生成成功 | 是 | 按實際用量結算,不超過凍結額 |
failed | 生成失敗 | 是 | 全額退回 |
canceled | 僅相容歷史記錄;不再提供取消入口 | 是 | 全額退回 |
expired | 超出該模型的最大執行時長仍未出結果 | 是 | 全額退回 |
失敗不計費是承諾,不是盡力而為
除 succeeded 外的所有終態都會全額釋放凍結金額。即使我們的程式在中途崩潰,補救掃描也會在下一輪把錢釋放回可用餘額。你不需要為此做任何事,也不需要來找我們對帳。
判終態的正確寫法是 state 不在 {queued, running} 裡,而不是逐個列舉終態——這樣既能相容舊的 canceled 記錄,也不會在將來新增終態時無限輪詢。
三、查詢任務
GET /api/v1/jobs/recordInfo?taskId=job_…只有一個查詢參數 taskId,必填。任務不屬於本帳號時回傳 404(與「不存在」回傳同一個回應,避免靠差異列舉別人的任務 ID)。
回應欄位
| 欄位 | 型別 | 說明 |
|---|---|---|
taskId | string | 任務 ID |
model | string | 模型識別碼 |
state | string | 見上表 |
input | object | 已驗證的參數;內嵌圖片會正規化為平台檔案 URI。保留期過後不再回傳 |
output | object | 生成結果的描述。只在 succeeded 時有意義 |
errorCode | string | 失敗原因的機器可讀識別碼。僅失敗時出現 |
errorMessage | string | 失敗原因的說明,預設英文,可改用其他語言(見錯誤說明的語言)。僅失敗時出現 |
cost | string | 已結算則是實收金額,未結算則是凍結金額;最終收費不會超過凍結額 |
settled | boolean | 計費是否已終結。為 false 時 cost 仍可能變動 |
createdAt | string | RFC 3339 時間 |
deadlineAt | string | 伺服器端在受理時儲存的任務執行截止時間;歷史回呼可能沒有此欄位 |
completedAt | string | RFC 3339 時間。僅終態時出現 |
四種不同的期限
- SDK
timeoutMs可以設定,控制本機提交與等待的總時長;停止等待不會取消遠端任務。儲存onAccepted回傳的任務 ID,隨後繼續查詢原任務。 deadlineAt是伺服器端的實際執行期限,冪等重放不延長,明確重試所建立的新任務有自己的期限。不要把它當成本機倒數計時的退款依據,仍需檢查任務終態與settled。output.assets[].expiresAt是臨時結果連結到期時間,通常20分鐘。重新查詢可續簽,並不建立新任務。- 生成媒體在完成後保留14天,平台上傳的輸入保留1天;換新連結不會延長素材本身的留存期。
簡單指令碼可以呼叫 client.run(input, { idempotencyKey, onAccepted, timeoutMs }),由 SDK 完成一次提交和退避輪詢;預設從2秒退避到最多10秒。後端整合優先使用 Webhook,收到完整結果並驗證簽章後即可直接使用,無需再查詢一次。
直接使用結果網址
就緒的輸出檔案包含 url、expiresAt、key 和媒體後設資料。直接 GET output.assets[].url,不攜帶 API Key;URL 到期可重新輪詢。原 /common/download-url 保留為選用介面。連結通常20分鐘有效且不超過14天結果留存期。
{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "MODEL_ID_FROM_CATALOG",
"state": "succeeded",
"input": { "prompt": "a folded paper lantern, hard side light" },
"output": {
"assets": [
{
"key": "tasks/2026/08/28/job_01k3m8x9q2z4v7n5p6r8s0t1w3/9f3c1d0a7b4e2f68c5a1d3e9b0472fa1.png",
"url": "https://example.r2.cloudflarestorage.com/results/result.mp4?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"
}
}{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "MODEL_ID_FROM_CATALOG",
"state": "failed",
"errorCode": "upstream_failed",
"errorMessage": "Generation failed; the charge has been refunded",
"cost": "0",
"settled": true,
"createdAt": "2026-08-28T09:12:04Z",
"completedAt": "2026-08-28T09:12:39Z"
}
}查詢成功 ≠ 任務成功
上面兩個回應的 code 都是 200——查詢本身成功了。任務失敗體現在 data.state 裡。業務碼說的是「這次傳遞怎麼樣」,state 說的是「那個任務怎麼樣」,別混著判斷。
回呼推過去的請求本文與這裡逐字同形,同樣是 code: 200 的信封 + 同一組 data 欄位。所以這一節的解析程式碼可以原封不動地重複使用在回呼端點上,兩個出口只需要一個解析器。
output 的形狀
影像與影片模型的輸出檔案在 output.assets[],文字模型的輸出在 output.text。
| 欄位 | 型別 | 說明 |
|---|---|---|
key | string | 物件鍵,取得下載網址時用它。pending 為真時為空 |
url | string | 可直接 GET 的已簽章 URL,無需攜帶 API Key;pending 或 unavailable 時省略 |
expiresAt | string | 本次 URL 的 RFC 3339 有效期,可在留存期內重新輪詢重新整理 |
mime | string | 內容型別,例如 image/png、video/mp4 |
width · height | integer | 畫素尺寸。音訊與文字結果不含此欄位 |
durationSeconds | number | 時長(秒)。影片與音訊結果才有 |
bytes | integer | 位元組數 |
pending | boolean | 見下 |
unavailable | boolean | 見下 |
取不到的欄位不會出現(不是 null 也不是 0),解析時請當作選用欄位處理。
還有兩個狀態旗標要處理:
pending: true—— 輸出檔案還在轉存進我們的儲存空間,key暫時為空。等幾秒再取。此時呼叫download-url會拿到409。unavailable: true—— 資產已超過留存期,或多次轉存失敗後無法取回。再等沒有意義,重新生成即可。
四、回呼優先,輪詢備援
| 回呼 | 輪詢 | |
|---|---|---|
| 延遲 | 生成完成的一刻 | 取決於你的輪詢間隔 |
| 請求數 | 每個任務 1 次(我們發起) | 每個任務 N 次(你發起) |
| 佔不佔你的速率限制額度 | 不佔 | 佔,見速率限制 |
| 要不要對外公開的入口 | 要 | 不要 |
正式環境請用回呼。 一個跑三分鐘的影片任務,每秒輪詢一次就是 180 次幾乎沒有資訊增量的請求。目前開放 API 的 1000 次 / 10 秒高位保險絲不會限制正常付費生成,但緊密輪詢會製造無意義流量,並在用戶端失控時與新任務提交爭用同一個桶。
輪詢只在這兩種情況下才是合理選擇:本機除錯,以及你的服務沒有對外公開的入口。
輪詢要退避
recordInfo 與 createTask 共用同一個速率限制桶。輪詢打得越兇,你能提交的新任務就越少——最壞情況是輪詢把額度吃光,導致新任務提交回傳 429。
建議的退避形態:
- 起步 2–3 秒。再短沒有意義,影像模型最快也要幾秒。
- 每次乘 1.5,封頂 10–15 秒。
- 設一個總逾時,超過之後停止輪詢並按失敗處理——不要寫出一個能無限輪詢下去的迴圈。
async function waitForTask(taskId, { timeoutMs = 10 * 60 * 1000 } = {}) {
const deadline = Date.now() + timeoutMs;
let wait = 2000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, wait));
wait = Math.min(wait * 1.5, 15000);
const res = await fetch(
`https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=${taskId}`,
{ headers: { Authorization: `Bearer ${process.env.SPICY_API_KEY}` } },
);
const body = await res.json();
// 觸發速率限制時就依 Retry-After 等待,不要把它當成任務失敗
if (body.code === 429) {
wait = Number(res.headers.get('retry-after') ?? 1) * 1000;
continue;
}
if (body.code !== 200) throw new Error(`${body.code} ${body.msg}`);
// 判終態:不在這兩個中間態裡就是終態,不要逐個列舉終態
const { state } = body.data;
if (state !== 'queued' && state !== 'running') return body.data;
}
throw new Error('等待任務逾時');
}import os, time, requests
BASE = "https://api.spicyapi.ai/api/v1"
AUTH = {"Authorization": f"Bearer {os.environ['SPICY_API_KEY']}"}
def wait_for_task(task_id: str, timeout: float = 600.0) -> dict:
deadline = time.monotonic() + timeout
wait = 2.0
while time.monotonic() < deadline:
time.sleep(wait)
wait = min(wait * 1.5, 15.0)
res = requests.get(f"{BASE}/jobs/recordInfo", headers=AUTH, params={"taskId": task_id})
body = res.json()
# 觸發速率限制時就依 Retry-After 等待,不要把它當成任務失敗
if body["code"] == 429:
wait = float(res.headers.get("retry-after", 1))
continue
if body["code"] != 200:
raise RuntimeError(f'{body["code"]} {body["msg"]}')
# 判終態:不在這兩個中間態裡就是終態
if body["data"]["state"] not in ("queued", "running"):
return body["data"]
raise TimeoutError("等待任務逾時")五、查餘額
GET /api/v1/chat/creditcurl https://api.spicyapi.ai/api/v1/chat/credit \
-H "Authorization: Bearer $SPICY_API_KEY"{
"code": 200,
"msg": "success",
"data": {
"available": "128.42",
"held": "0.36",
"total": "128.78"
}
}available 能用於新任務,held 是在途任務凍結的部分,total 是兩者之和。計費口徑見計費。
任務歷史與恢復
GET /api/v1/jobs 分頁列出目前使用的 API Key 所建立的可見任務,可用於應用程式重啟或漏收回呼後的追查。其他 Key 的任務與已隱藏記錄不會出現。列表只有任務 ID、模型、狀態、費用、結算狀態和時間;不回傳提示詞、輸入、輸出或媒體連結。找到目標任務後,用 recordInfo 取得結果。
支援 state、精確目錄 model 或已宣告別名,以及 UTC 日期 from / to(YYYY-MM-DD,左閉右開)。預設查詢截至 UTC 明天的七天,最長 92 天;limit 預設 20,最大 100。
curl --fail-with-body \
'https://api.spicyapi.ai/api/v1/jobs?from=2026-09-01&to=2026-09-07&state=failed&limit=20' \
-H "Authorization: Bearer $SPICY_API_KEY"讀取 data.items;當 data.hasMore 為 true,把 data.nextCursor 作為下一頁的 cursor,並保持全部篩選條件和明確的日期範圍不變。列表按 createdAt、任務 ID 由新到舊排列;分頁讀取的是即時狀態,並非凍結快照。不要自行構造游標。查詢歷史不會新建或重試任務。
SDK client.listTasks(...)、CLI spicyapi tasks list 和唯讀 MCP 工具 spicyapi_tasks_list 提供相同能力。cost 保持 USD 十進位字串;settled 為 false 時,它仍是凍結估算額,不能當成最終收費。
建任務的回應遺失時,應先攜帶原 Idempotency-Key 重試完全相同的請求。歷史頁暫時沒有記錄,不代表可以換新冪等鍵重複建立付費任務。

