AppleTokenAPI 文件
回主控台 API v2.21 · 文件 2.1

AppleToken API

一把憑證,接上多個世代的影片生成模型與對話模型。額度、估價、計量與計費都在同一層完成——你不需要面對任何上游供應商的帳號、金鑰或帳單。

⚠ API v2:路徑改為 /v2,模型名稱必須指定上游

自 API v2.0 起,請求裡的 model 不再接受裸的模型名稱(例如 qwen3.8-flash-next),必須改用模型 ID(mdl_…)、slug(sailflow-qwen3-8-flash-next)或上游/模型(sailflow/qwen3.8-flash-next)三者之一。同一個名稱由兩家上游供應,就是兩個獨立的模型,各有自己的售價與指標。舊寫法會收到 400 model_ref_required。

這份文件寫的是 /v2。/v1 照舊可用:行為與原本相同(只收裸名稱、固定使用 Siraya),既有整合不改任何一行也能繼續跑;要用多上游與新的模型寫法,才需要換到 /v2。

看遷移指南 →

這個 API 是什麼

AppleToken 是一層生成服務的中介。你用我們發的憑證呼叫我們的端點,我們代為向後端算力供應商送件、把成品取回、依實際用量計費。目前提供兩類能力:影片生成(非同步任務)與對話(同步,可串流)。

基底網址
https://appletoken.app
版本前綴
/v2(本文件涵蓋的全部端點)
協定
HTTPS,HTTP/1.1
認證
Authorization: Bearer at_live_…
編碼
請求與回應一律 UTF-8 JSON(成品下載與對話串流除外)
金額單位
美元(USD)。所有價格、額度、帳務數字皆同。
設計原則

一個模型=一家上游供應的一個模型,而且上游是公開的。請求裡的 model 寫「上游/模型」(或 AppleToken 發的模型 ID/slug),GET /v2/models 會回每個模型的 provider 與 upstream_model,各家的價格與速度你可以自己比。仍然由 AppleToken 自有、不會外露的是:任務 id、成品網址、上游的進價、上游的任務/素材 id 與金鑰——你拿到的一律是本平台的識別碼與網址,也只跟 AppleToken 結帳。

三件事值得先知道

  1. 費用可以在送出前算準。 影片用 POST /v2/quote,不扣額度、不呼叫後端,直接回傳這組參數會花多少錢。對話沒有估價端點,但預扣公式是公開的,見對話的預扣與串流計費。
  2. 送件即預扣額度。 影片任務一旦送出就無法取消,額度會立刻凍結,等結算或退還;對話則以 max_tokens 為上限先凍結,結束後用實際用量結算。細節見額度模型。
  3. 結算由平台負責,不是由你的輪詢負責。 平台有背景程序主動追蹤每一筆任務並完成結算,你查不查都不影響帳務正確性,凍結不會因為你沒查詢而卡住。你仍然應該輪詢——那是你知道任務結果、能夠取件的唯一方式。詳見結算、退還與對帳。

五分鐘上手

1. 取得憑證

登入 主控台 → API 憑證,按「建立」就能自己產生一組,不需要另外申請。格式長這樣:

at_live_9f2c81ab_kJ3nR7xQwZ0pLm5TfYbHs2VdEcAu1NgX

憑證只會在建立與換發的當下顯示一次。沒有任何端點可以再把它讀出來——遺失就只能換發,舊的立即失效。

把它設成環境變數,下面每個範例都讀 APPLETOKEN_KEY:

export APPLETOKEN_KEY="at_live_…"

2. 看看有哪些模型

取得你這張憑證可以使用的模型與現行價格。這個請求不扣額度,餘額是 0 也能打——拿來確認憑證有效、連得上:

curl https://appletoken.app/v2/models \
  -H "Authorization: Bearer $APPLETOKEN_KEY"

同一個請求的 Python 與 Node.js 寫法:

import os, requests

r = requests.get("https://appletoken.app/v2/models",
                 headers={"Authorization": "Bearer " + os.environ["APPLETOKEN_KEY"]})
print(r.status_code, len(r.json()["data"]))
const r = await fetch('https://appletoken.app/v2/models', {
  headers: { Authorization: `Bearer ${process.env.APPLETOKEN_KEY}` },
})
console.log(r.status, (await r.json()).data.length)

3. 先問價格

curl https://appletoken.app/v2/quote \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "siraya/Seedance-2.0-fast",
    "seconds": 5,
    "resolution": "720p",
    "aspect_ratio": "16:9"
  }'
{
  "model": "siraya/Seedance-2.0-fast",
  "unit": "output_tokens",
  "billable": { "output_tokens": 108900 },
  "price": 0.539055,
  "estimated": false,
  "available": 20.0
}

4. 送出任務

curl https://appletoken.app/v2/videos/generations \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "siraya/Seedance-2.0-fast",
    "prompt": "清晨的漁港,漁船緩緩進港,海面反射金色晨光",
    "seconds": 5,
    "resolution": "720p",
    "aspect_ratio": "16:9",
    "generate_audio": true
  }'
{
  "object": "video",
  "id": "job_1a2b3c4d5e6f7a8b9c0d",
  "status": "processing",
  "model": "siraya/Seedance-2.0-fast",
  "created_at": 1756300000,
  "price_reserved": 0.539055
}

5. 輪詢到完成

curl https://appletoken.app/v2/videos/job_1a2b3c4d5e6f7a8b9c0d \
  -H "Authorization: Bearer $APPLETOKEN_KEY"
{
  "object": "video",
  "id": "job_1a2b3c4d5e6f7a8b9c0d",
  "status": "completed",
  "model": "siraya/Seedance-2.0-fast",
  "seconds": 5,
  "resolution": "720p",
  "created_at": 1756300000,
  "completed_at": 1756300182,
  "content": "/v2/videos/job_1a2b3c4d5e6f7a8b9c0d/content",
  "price_charged": 0.539055
}

6. 取件

curl https://appletoken.app/v2/videos/job_1a2b3c4d5e6f7a8b9c0d/content \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -o output.mp4
整份流程
quote  ──►  generations  ──►  videos/{id}  ──►  videos/{id}/content
估價         送出+預扣額度      輪詢+取得結果        下載 mp4
不扣額度     回 job_…            completed 才可取件    不影響額度

結算在背後獨立進行:平台的背景程序會追到每一筆任務的終局,
你的輪詢只是順手觸發同一段結算邏輯,兩者冪等。

7. 想要對話,只要一個呼叫

對話是同步的,沒有任務 id、不需要輪詢。它也是最便宜的第一次付費呼叫(一次約幾分錢),需要帳戶有餘額:主控台右上角「儲值」(最低 $10),或在同一個對話框輸入優惠碼。

curl https://appletoken.app/v2/chat/completions \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "siraya/ByteDance-Seed-1.8",
    "max_tokens": 512,
    "messages": [{ "role": "user", "content": "用三句話說明什麼是向量資料庫" }]
  }'

同一個請求的 Python 與 Node.js 寫法:

import os, requests

r = requests.post("https://appletoken.app/v2/chat/completions",
    headers={"Authorization": "Bearer " + os.environ["APPLETOKEN_KEY"]},
    json={"model": "siraya/ByteDance-Seed-1.8", "max_tokens": 512,
          "messages": [{"role": "user", "content": "用三句話說明什麼是向量資料庫"}]})
print(r.json()["choices"][0]["message"]["content"])
const r = await fetch('https://appletoken.app/v2/chat/completions', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.APPLETOKEN_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model: 'siraya/ByteDance-Seed-1.8', max_tokens: 512,
    messages: [{ role: 'user', content: '用三句話說明什麼是向量資料庫' }],
  }),
})
console.log((await r.json()).choices[0].message.content)

max_tokens 和 max_completion_tokens 都是選填;建議明確設定輸出預算,方便控制預扣。未設定時,平台使用模型目錄的預設或最大輸出量估算。完整契約見 POST /v2/chat/completions。

認證

所有需要認證的端點都用同一種方式:HTTP 標頭帶 Bearer 憑證。沒有 OAuth、沒有簽章、沒有時間戳。

Authorization: Bearer at_live_9f2c81ab_kJ3nR7xQwZ0pLm5TfYbHs2VdEcAu1NgX
格式

標頭是 Bearer 加空白再接憑證。Bearer 的大小寫不拘 (bearer、BEARER 都可以,這是 HTTP 規範對 auth-scheme 的要求), 中間的空白也允許多個。但憑證本身大小寫嚴格,且不能放在 query string 或 request body。

憑證結構

at_live_<id>_<secret>
         │      └─ 機密部分。伺服器只保存不可逆摘要,我們自己也還原不出來。
         └──────── 憑證 id,8 個十六進位字元。可公開,用於對帳與客服溝通。

保管建議

憑證失效時的行為

情況回應說明
未帶或格式不符401 unauthorized訊息為「憑證格式不正確」
憑證不存在,或機密部分錯誤401 unauthorized兩者回完全相同的訊息「憑證無效」,以避免憑證列舉
憑證被停用403 forbidden「這組憑證已被停用」
憑證已到期403 forbidden與停用共用同一句訊息,刻意不區分
來源 IP 不在這把憑證的 IP 白名單內403 ip_not_allowed「這組憑證設定了 IP 白名單,來源 IP {ip} 不在名單內」——訊息會寫出平台看到的來源 IP,見下方 IP 白名單
憑證已換發401 unauthorized舊憑證在換發當下立即失效,沒有寬限期
模型不在憑證的允許清單404 not_found「未知的模型 {model}」——與模型不存在時回同一種錯誤,避免拿來探測目錄。影片與對話同樣適用
額度不足402 quota_exceeded這不是認證問題,憑證仍然有效

/v2/* 不使用 cookie,因此沒有 CSRF 相關要求。憑證預設不綁定來源 IP,要綁定請設定下方的 IP 白名單。

IP 白名單(選用)

在前台「API 憑證」頁,每把憑證下方都可以設定「IP 白名單」。沒有設定的憑證可以從任何 IP 呼叫;設定後,只有名單內的來源 IP 能使用這把憑證,所有 API 端點都適用。

請求與回應慣例

請求

回應

錯誤

所有錯誤共用同一個外層結構:

{
  "error": {
    "code": "quota_exceeded",
    "message": "額度不足",
    "needed": 1.25,
    "available": 0.42
  }
}

code 是穩定的機器可讀識別,請用它來分支處理;message 是給人看的,措辭可能調整。部分錯誤會附加額外欄位(上例的 needed、available),詳見錯誤碼總表。

遷移指南:從 /v1 換到 /v2(v2.0)

一個模型=一家上游供應的一個模型。同一個名稱如果由兩家上游供應,在 AppleToken 裡就是兩個獨立的模型:各有自己的模型 ID、slug、售價,以及速度/穩定度/評分四個指標。所以請求裡的 model 必須明確指出你要的是哪一個——只寫名稱已經不夠。

你要改的有兩件事

① 把 base_url 從 https://appletoken.app/v1 換成 https://appletoken.app/v2(每一個端點都有,路徑的其餘部分不變)。② 把程式裡每一個 model 的值,從裸名稱改成下表三種新寫法之一。建議存模型 ID。其他參數與回應形狀都沒有換。

不換也可以:/v1 維持原本的行為,但不會再加新功能;多上游、模型 ID/slug、請求評價只在 /v2。

舊寫法與新寫法

寫法例子說明
舊:裸名稱qwen3.8-flash-next不再可用。分不出是哪一家,回 400 model_ref_required,並附上可用的候選。
模型 IDmdl_7k2m9q4x1c(示意)mdl_ 加 10 碼。由 AppleToken 發放,永久不變、永不重用。建議整合端存這個。
slugsailflow-qwen3-8-flash-next小寫英數字與連字號。由 AppleToken 發放,建立後永久不變。適合寫在人要讀的設定檔裡。
上游/模型sailflow/qwen3.8-flash-next斜線前是上游 id,斜線後是模型名。建立後永久不變,也就是回應標頭 X-AppleToken-Route 的值。

三種新寫法完全等價,指向同一個模型,而且在 AppleToken 內都是全域唯一的。格式互斥(ID 以 mdl_ 開頭、上游/模型含 /、slug 兩者都沒有),不會解析出兩種結果。ID 與 slug 是 AppleToken 自己的識別碼,不是上游的模型名;上游改名不會影響它們。下面三個請求打到的是同一個模型(mdl_7k2m9q4x1c 是示意用的 ID,實際的值請從 GET /v2/models 取得):

{ "model": "mdl_7k2m9q4x1c", … }
{ "model": "sailflow-qwen3-8-flash-next", … }
{ "model": "sailflow/qwen3.8-flash-next", … }

從 GET /v2/models 取得

GET /v2/models 的一列就是一個模型,id、slug、ref 都在上面,可以直接貼進 model。family 是原本的裸名稱,只用來把同名、不同家的模型排在一起比價,不能拿來呼叫。

# 列出所有模型的 id、slug、ref 與起價
curl -s https://appletoken.app/v2/models -H "Authorization: Bearer $APPLETOKEN_KEY" | jq -r '.data[] | [.id, .slug, .ref, .pricing.from] | @tsv'

# 原本叫 qwen3.8-flash-next 的模型有哪幾家(family 只能拿來找,不能拿來呼叫)
curl -s https://appletoken.app/v2/models -H "Authorization: Bearer $APPLETOKEN_KEY" | jq -r '.data[] | select(.family == "qwen3.8-flash-next") | "\(.id)\t\(.ref)\t\(.pricing.from)"'

遷移步驟

  1. 把 base_url 換成 https://appletoken.app/v2。同一把憑證、同一個帳戶與餘額在兩個版本都能用,可以一個端點一個端點慢慢換。
  2. 用上面的指令,把程式裡每個裸名稱對應到你要用的那一家。同名的兩家價格與速度可能不同,請依 pricing 與四個指標挑選。
  3. 在設定或資料庫裡存模型 ID(mdl_…)。三種寫法都不會變,但 ID 最短、最不容易被誤改。
  4. 把請求的 model 換成新寫法。在 /v2 送裸名稱會直接回 400 model_ref_required,照回應裡的 candidates 改即可。
  5. 如果你有帶 "fallback": true,把它拿掉(見下方)。
  6. 對帳程式改用 model_id 歸戶(見下方)。
  7. 讀 GET /v2/models 的程式改讀新形狀:default_route 與 routes[] 已移除,現在一列就是一個模型。

回應裡的 model

fallback 已不支援

以前 "fallback": true 的意思是「這家失敗時,改走同一個模型的另一家」。現在不同家就是不同的模型——換家等於換模型,平台不會替你做這個決定。帶 "fallback": true 會回 400 fallback_unsupported(false 或不帶照常)。需要備援的話,請在你的程式裡自己改送另一個模型的 ID。

{
  "error": {
    "code": "fallback_unsupported",
    "message": "fallback 已不支援:換一家上游就是換一個模型,請自行指定要用的模型",
    "param": "fallback",
    "docs_url": "https://appletoken.app/docs#model-migration"
  }
}

錯誤回應

送裸名稱會收到 400,candidates 列出可以改用的模型,docs_url 指向這一節:

{
  "error": {
    "code": "model_ref_required",
    "message": "模型名稱必須指定上游:'qwen3.8-flash-next' 已不再接受,請改用下列其中一個(或它的 id/slug),見遷移指南",
    "param": "model",
    "candidates": [
      { "id": "mdl_7k2m9q4x1c", "slug": "sailflow-qwen3-8-flash-next", "ref": "sailflow/qwen3.8-flash-next" }
    ],
    "docs_url": "https://appletoken.app/docs#model-migration"
  }
}

/v1 照舊可用

/v1 維持原本的行為,既有整合不需要改任何一行:

/v1 的完整說明保留在舊版(v1)文件。

模型目錄

可用的模型與價格會隨時增減,所以不列在這份文件裡—— 這裡只講不會變的協定規則。你需要知道兩件事:一個模型=一家上游供應的一個模型, 請求裡的 model 要寫它的模型 ID、slug 或「上游/模型」(見遷移指南), 三者都由 AppleToken 發放、永久不變;modality 決定它走哪一組端點—— video 走 /v2/videos/generations, image 走 /v2/images/generations, text 走 /v2/chat/completions, 互相呼叫會得到 400。

多家上游,同一套 API

不論模型由哪一家上游供應,端點、參數、回應欄位與錯誤碼都相同,唯一的差別是 model 要寫明上游。各模型能接受的秒數、解析度、畫面比例、參考素材數量與音訊,只寫在該模型在 GET /v2/models 的 constraints 裡——請以它為準,不要依上游名稱推斷。平台依 constraints 在送件前檢查,細節見參數限制總表。

每個影片模型另有 checked:平台送件前會依該模型 constraints 檢查的參數。不在 checked 裡的參數平台不先檢查,不合規的值要等任務終局才會以失敗出現。

當下可用的完整清單有兩個來源,內容相同

模型清單——網頁版,列出每個模型的參數限制與 完整費率階梯。要登入才看得到:費率是商業資訊, 只對已開通的帳號揭露,未登入點進去會被導到登入頁。還沒有帳號的話, 先註冊再回來看。

GET /v2/models——程式版, 整合時請從這裡讀取而不要寫死。兩者都直接從平台的 catalog 產生, 後台改完立刻反映,不會出現文件與實際不符。

你看到的目錄可能比公開清單更短

若你的憑證設有模型白名單,GET /v2/models 只會回白名單內的模型。清單上有的就是你能用的,不會出現叫了才發現沒權限。

目錄可能是空的

平台在整備上游對接期間,公開目錄可能不含任何模型。 我們不會列出還不能真正叫用的模型——你照著串接卻叫不動,那是我們的問題。 協定不受影響,這份文件描述的一切在目錄有內容後即刻適用。

三種計價單位

每個模型的 pricing.unit 會告訴你它用哪一種:

單位怎麼算
output_tokens 影片依畫面尺寸與影格數換算成 token 量;文字直接用輸出的 token 數。再乘上單價。公式在此。
video_seconds 秒數 × 單價。與解析度無關。
request 按張計價(圖片模型):一張一個固定價格,與提示詞長度無關;結算看實際回來幾張,不是請求的 n。見圖片端點。

有多段費率的模型,pricing.from 是最低單價、pricing.to 是最高單價。

多段費率

有些模型的單價不是固定值,會依請求的條件切換——例如帶影片參考、或解析度拉到 1080p 以上。 費率由上往下比對,第一條命中的勝出。

哪些模型有幾段、各段的條件與單價,列在 模型目錄(需登入)——那一頁直接從平台的 catalog 讀, 永遠是當下生效的數字。這裡不重複,免得兩邊對不上。 未登入時請改用 GET /v2/models,憑證即可讀取。

不確定會落在哪一段時,先呼叫 /v2/quote——它回的 price 就是實際會凍結的金額,不必自己推算。

三種輸入模式

所有影片模型共用 POST /v2/videos/generations 這一個端點,實際模式由你給的欄位決定:

模式關鍵欄位用途
文生影只給 prompt純文字描述生成。
圖生影image_url 或 frame_images以圖片作為起始畫面延伸出影片。
參考導引input_references提供多張圖、影片片段或音訊作為風格/內容/節奏參考。
三種模式互斥

frame_images 與 input_references 不可同時提供。兩者都需要時,請一律走 input_references,並在 prompt 裡描述期望的起始畫面——注意這不保證像素級一致。

起始/結尾畫面的比例與大小

部分模型要求 image_url/frame_images 的圖片比例與輸出畫面相同(容差 10%;輸出畫面由 resolution+aspect_ratio 或 size 決定),也可能限制首尾幀合計的檔案大小。不符合時平台在送件前回 400 validation_error,訊息會寫出實際尺寸與原因;不會凍結額度。請先把圖片裁成輸出比例:直接送出的話,畫面會被拉伸變形。

參考素材的數量上限

每個模型能帶多少參考素材,寫在該模型 GET /v2/models 的 constraints 裡,請以它為準:

欄位意義
constraints.maxReferences各類型加總的上限
constraints.references.image/.video/.audio各類型各自的上限
constraints.references.audioOnlyfalse=不能只給音訊,至少要有一張圖片或一段影片
"constraints": {
  "maxReferences": 15,
  "references": {"image": 9, "video": 3, "audio": 3, "audioOnly": false}
}
超量會在送件當下被擋下

有宣告上限的模型(checked 含 input_references),平台會在送出前檢查數量並回 400 validation_error(param: "input_references"、reason: "too_many"),不會凍結額度、也不會產生任務。

沒有宣告的模型不做預先檢查——那類請求會送出成功(狀態為 processing),錯誤在任務被追蹤到終局時才以 status: "failed" 出現。

計數看的是每個項目的 type。沒有填 type 的項目計入總數,但不計入任何一種類型的上限——這類項目平台不替你推斷。

影片參考素材的規格與拒收訊息

參考影片由生成服務在上傳當下判定,不合規會被拒收。平台不會預先擋件;被拒收時,平台會拿上傳時讀到的檔頭規格對照下表,在錯誤訊息裡指出是哪一條。宣告了參考影片規格的模型,數值見 GET /v2/models 的 constraints.referenceVideo(只揭露,平台不預先檢查)。

項目後端要求details.reasons 代碼
幀率24–60 fpsvideo_fps_out_of_range
總像素數(寬×高)409,600(640×640)~8,295,044(3326×2494);4K(3840×2160)在範圍內video_pixels_out_of_range
長寬比(寬÷高)0.4–2.5video_aspect_out_of_range
單邊長度300–6000 pxvideo_side_out_of_range
長度至少 2 秒;上限依模型,由生成服務在生成時判定video_too_short
格式mp4、mov;webm 可上傳但平台讀不出規格、無法解釋拒收原因—

拒收時的回應維持 502 upstream_error(既有的錯誤處理不必改),差別在 message 會寫明原因與實際規格,並多一個 details 欄位供程式分流:

{"error": {"code": "upstream_error",
  "message": "上游拒收這份影片素材:幀率 18 fps 不在 24–60 fps 之間(請在剪輯軟體匯出時改成 24、25 或 30 fps,解析度不必改)。(這支影片是 1920x1080、18 fps、7.8 秒)",
  "details": {"reasons": ["video_fps_out_of_range"],
              "video": {"width": 1920, "height": 1080, "fps": 18, "durationSeconds": 7.78}}}}
最常踩到的是幀率

剪輯軟體匯出成 18 或 20 fps 的檔案看起來完全正常(1080p、mp4),但後端一律拒收。匯出時把幀率設為 24、25 或 30 fps 即可,解析度不必改。2026-09-14 之前這種檔案只會拿到「upstream service error」,看起來像服務故障;現在訊息會直接說是幀率。

規格全部合格卻仍被拒、或檔案讀不出規格時,message 退回通用版(列出該檢查的項目),且沒有 details。

影片參考與長度:reference_mode

當 input_references 含 type: "video" 的項目時,可以用 reference_mode 告訴後端這支參考影片要怎麼用。它決定你給的 seconds 會不會成為輸出長度:

reference_mode用途seconds輸出長度
不帶(預設)由後端依 prompt 自行判斷必填,照你給的值送出後端判成延伸/參考生影片時等於 seconds;判成編輯時任務會失敗(見下方)
"auto"明確宣告「由後端依 prompt 判斷」同「不帶」同「不帶」
"edit"編輯:替換、增刪、修改原片內容平台一律改送 -1,你給的值只用來估價與預扣等於參考影片長度
"extend"延伸:接在原片之前或之後繼續發展整數,就是輸出長度;範圍見 constraints.modes.video_extend,沒宣告時 4–30等於 seconds;成品只有新增的部分,不含原片
"reference"參考生影片:只借用角色、風格、運鏡,拍新的畫面整數,就是輸出長度;範圍見 constraints.modes.video_reference,沒宣告時 4–30等於 seconds

圖片素材的格式要求

含真人臉孔的參考素材

包含可辨識真人臉孔的參考素材,不能直接給公開網址或內嵌 data URI——必須先上傳成資產,再用 asset:// 引用,做法見素材資產。直接送件會以任務失敗告終,且該次額度仍會走完預扣與退還流程。

名人與公眾人物的肖像一律不支援,上傳成資產也不能用。

參數限制總表

長度 seconds

解析度 resolution 與尺寸 size

只給 size 時的檢查

平台不會把 size 的短邊換算成解析度再檢查(舊版文件寫的「短邊必須等於清單裡的值」並沒有實作)。模型宣告了 constraints.sizes 時,size 必須是清單裡的值,否則在送件時回 400 validation_error,不會產生費用。其他模型的 size 原樣送出,由生成服務判定。

條件式費率是看 resolution 而不是 size—— 若某個模型的高費率條件寫的是解析度門檻,只給 size 會落在標準費率; 哪些模型有幾段費率請看 模型目錄(需登入)或 GET /v2/models 的 pricing。除非確有精確畫面需求,建議只用 resolution。

畫面比例 aspect_ratio

值比值720p 時的實際像素
16:9(預設)1.7781280 × 720
9:160.5625720 × 1280
1:11.0720 × 720
4:31.333960 × 720
3:40.75720 × 960
21:92.3331680 × 720
清單以外的比例不會報錯

填入上表以外的值(例如 "2:1")不會報錯:估價以 16:9 計算,實際送出時依模型處理——可能原值送出、在生成時才失敗,也可能改用 16:9。請只使用上表的值或 "adaptive"(由輸入素材決定比例,部分模型與模式適用),並在送件前用 /v2/quote 確認價格。模型宣告了 constraints.aspectRatios 時,比例必須在該清單內,否則回 400 validation_error。

音訊 generate_audio

對話參數

欄位限制不符合時
messages非空陣列,每個元素的 role 與 content 都必須是字串 400「缺少 messages」或「messages[i].content 必須是字串」
max_tokens / max_completion_tokens選填;提供時請使用大於 0 的數字(小數會被無條件捨去成整數);同時提供時後者優先。未提供時依模型目錄的上限估算 400:所選上限無效,或模型目錄也沒有可用上限
temperature選填,必須是數字;平台不檢查範圍,原樣轉給後端 400「temperature 必須是數字」
stream選填,必須是布林值 true / false。平台只做真假值判斷,因此任何非空字串(包含 "false")都會被當成 true 而啟用串流 不報錯,但可能走到你沒預期的模式

GET /v2/models

GET/v2/models需要憑證

取得模型目錄與現行價格。

一列就是一個模型(一家上游供應的一個模型)。同一個名稱由兩家上游供應,會出現兩列,各有自己的 id、slug、ref、售價與四個指標。每一列的 id、slug、ref 三個值都能直接放進請求的 model,見遷移指南。不帶任何參數時回傳完整清單;以下三個查詢參數都是選填。

回傳的目錄是依你的憑證過濾過的

若這張憑證設有模型白名單,這裡只會列出白名單內的模型—— 你看到的清單就是你實際能呼叫的清單,不會出現叫了才發現沒權限的情況。 沒有設白名單的憑證會看到全部可用模型。下面的 modality、q 篩選是疊加在這層過濾之上,不會取代它——即使關鍵字或模態命中白名單以外的模型,一樣不會出現在結果裡。

Query 參數

參數預設範圍說明
modality不篩選video / image / text / audio / embedding只回傳指定模態的模型。不在這個集合內回 400 validation_error,訊息會列出可用的值。
q不篩選最長 100 字元關鍵字比對,同時比對模型的 id、slug、ref、name、family、label、description 與上游(provider 的 id 與顯示名),不分大小寫、子字串比對。超過長度上限回 400 validation_error。
limit5001–500回傳筆數上限。這裡的預設值是 500,不是 /v2/jobs、/v2/usage 的 100——模型目錄不是時序資料,「最近 100 筆」對它沒有意義,沿用同一個預設反而會讓既有整合在無聲無息中少拿到模型。超出範圍同樣回 400 validation_error,不會被夾到邊界值。若目錄未來成長超過 500 個模型,這個上限會需要調整。

三個參數可以自由組合,例如同時用 modality 縮小模態、q 比對關鍵字。

curl "https://appletoken.app/v2/models?modality=text&q=gpt" \
  -H "Authorization: Bearer $APPLETOKEN_KEY"

回應

{
  "object": "list",
  "data": [
    {
      "id": "mdl_3h8r5t0v6w",
      "slug": "siraya-seedance-2-0-fast",
      "ref": "siraya/Seedance-2.0-fast",
      "provider": { "id": "siraya", "label": "Siraya" },
      "name": "Seedance-2.0-fast",
      "upstream_model": "SIRAYA-Seedance-2.0-fast",
      "family": "Seedance-2.0-fast",
      "label": "Video Fast · SIRAYA",
      "description": "生成較快,成本較低",
      "modality": "video",
      "constraints": {
        "minSeconds": 4,
        "maxSeconds": 15,
        "resolutions": ["480p", "720p"],
        "audio": true
      },
      "pricing": { "unit": "output_tokens", "from": 4.95, "to": 4.95 },
      "status": "active",
      "speed_p50_ms": 38100, "speed_basis": "submit_to_complete", "speed_response_p50_ms": null,
      "fail_rate": 0.008,
      "rating": { "avg": 4.4, "count": 31 },
      "sample": { "speed_n": 180, "speed_response_n": 0, "fail_n": 180, "window_days": 7 }
    },
    {
      "id": "mdl_8f2j6n0r4v",
      "slug": "sailflow-minimax-h3",
      "ref": "sailflow/minimax-h3",
      "provider": { "id": "sailflow", "label": "Sailflow" },
      "name": "minimax-h3",
      "upstream_model": "sailflow/minimax-h3",
      "family": "minimax-h3",
      "label": "MiniMax-H3",
      "description": "含音訊。秒數 4–15;720p 實際輸出 1280×704。",
      "modality": "video",
      "constraints": {
        "minSeconds": 4,
        "maxSeconds": 15,
        "resolutions": ["576p", "720p", "1024p"],
        "aspectRatios": ["16:9", "9:16", "1:1"],
        "audio": true
      },
      "pricing": { "unit": "video_seconds", "from": 0.01, "to": 0.025 },
      "status": "active",
      "speed_p50_ms": null, "speed_basis": "submit_to_complete", "speed_response_p50_ms": null,
      "fail_rate": null,
      "rating": null,
      "sample": { "speed_n": 12, "speed_response_n": 0, "fail_n": 12, "window_days": 7 },
      "reason": { "speed": "insufficient_samples", "stability": "insufficient_samples", "rating": "no_data" }
    },
    {
      "id": "mdl_9p4d2n7e5f",
      "slug": "siraya-bytedance-seed-1-8",
      "ref": "siraya/ByteDance-Seed-1.8",
      "provider": { "id": "siraya", "label": "Siraya" },
      "name": "ByteDance-Seed-1.8",
      "upstream_model": "ByteDance-Seed-1.8",
      "family": "ByteDance-Seed-1.8",
      "label": "Chat Pro · SIRAYA",
      "description": "長上下文與複雜推理,支援串流",
      "modality": "text",
      "constraints": {
        "maxOutputTokens": 8192,
        "defaultMaxOutputTokens": 2048
      },
      "pricing": {
        "unit": "output_tokens", "from": 3, "to": 3,
        "components": [
          { "unit": "input_tokens",  "from": 0.375, "to": 0.375 },
          { "unit": "output_tokens", "from": 3,     "to": 3     }
        ]
      },
      "status": "active",
      "speed_p50_ms": 820, "speed_basis": "first_token", "speed_response_p50_ms": 2950,
      "fail_rate": 0.002,
      "rating": { "avg": 4.6, "count": 88 },
      "sample": { "speed_n": 1240, "speed_response_n": 310, "fail_n": 1550, "window_days": 7 }
    }
  ]
}
欄位型別說明
idstring模型 ID,mdl_ 加 10 碼。由 AppleToken 發放,永久不變、永不重用。可直接放進請求的 model,建議整合端存這個。
slugstring由 AppleToken 發放的可讀識別碼(小寫英數字與連字號),建立後永久不變。可直接放進 model。
refstring「上游/模型」,例如 siraya/Seedance-2.0-fast。可直接放進 model,也是回應標頭 X-AppleToken-Route 的值。
providerobject{ id, label }:上游 id 與顯示名。
namestringref 斜線後面那一段。單獨拿來呼叫就是裸名稱,會回 400。
upstream_modelstring這家上游對這個模型的模型名,僅供參考,不能拿來呼叫。
familystring系列,也就是 v2.0 之前的裸名稱。同名、不同家的模型 family 相同,只用來分組與比價,不能拿來呼叫(拿它呼叫就是裸名稱,會回 400 model_ref_required)。
labelstring顯示名稱,適合直接放進你的 UI。
descriptionstring | null一句話定位說明。可能是 null(還沒有說明的模型),請不要假設一定是字串。
modalitystring"video"、"image"、"text"、"audio"、"embedding" 其中之一(沒有 "chat" 這個值——對話模型的 modality 是 "text")。決定這個模型走哪一組端點:"video" 走 /v2/videos/generations,"image" 走 /v2/images/generations,"text" 走 /v2/chat/completions。請用它來分類而不是靠 id 前綴猜。
constraintsobject這個模型的參數限制。所有限制都包在這個物件裡,鍵名是 camelCase(不是攤平在頂層、也不是 snake_case)。物件一定存在,但可能是空的 {}——沒有宣告任何限制的模型就是空物件。未知的鍵請忽略。
constraints.minSeconds / constraints.maxSecondsint長度區間。與 allowedSeconds 互斥,只會出現其中一組。僅影片模型有。
constraints.allowedSecondsint[]只接受列舉值的模型才有。沒有這個欄位也沒有 maxSeconds 時,平台套用 60 秒硬上限。
constraints.resolutionsstring[]允許的 resolution 值。僅影片模型有。
constraints.aspectRatiosstring[]允許的 aspect_ratio 值。有宣告的模型才有。
constraints.audiobool是否能產生音軌。僅影片模型有。
constraints.maxReferencesint可帶的參考素材數量上限。有宣告的模型才有。沒有這個欄位不代表沒有上限——部分模型的上限由平台內建,見參考素材的數量上限。
constraints.referencesobject參考素材的分類型上限:image/video/audio(int)與 audioOnly(bool)。見參考素材的數量上限。有宣告的模型才有。
constraints.combosobject解析度 → 允許的比例,例如 {"1024p": ["1:1"]}。只有部分組合合法的模型才有。揭露用;不合的組合在送件時回 400。
constraints.frameImagesobjectmax:frame_images 的張數上限。有宣告的模型才有。揭露用;超量在送件時回 400。
constraints.referenceVideoobject參考影片的規格(例如 minSeconds、fps)。只揭露,平台不預先檢查。
constraints.modesobject依模式而定的限制。鍵是平台的模式名稱:text、first_frame、first_last_frame、references、video_edit、video_extend、video_reference、video_auto;值可以含上面的秒數、解析度、比例欄位,覆寫頂層。aspectRatio: "inherit"=這個模式下比例跟隨輸入影片、不能指定;seconds: "inherit"=輸出長度由輸入影片決定。模式由請求欄位判定:有 reference_mode 照它(影片參考);有影片參考沒宣告是 video_auto(只套頂層);有 input_references 沒影片是 references;兩張 frame_images 是 first_last_frame;image_url 或一張首幀是 first_frame;其餘 text。
checkedstring[]影片模型才有,與 constraints 同層(新增欄位):平台在估價與送件前,會依這個模型的 constraints 先檢查的參數(seconds、resolution、aspect_ratio、input_references)。不在清單裡的參數平台不先檢查(部分仍可能在送件時被拒,例如 constraints.sizes)。與模型無關的平台通則(秒數為整數且不超過 60、reference_mode 的範圍)一律適用,不列在這裡。
constraints.maxOutputTokens / constraints.defaultMaxOutputTokensint輸出 token 的上限,以及你沒有指定 max_tokens 時預扣所依據的預設值。對話模型才有。
constraints.sizesstring[]允許的 size 值,含 2K 這類簡寫。圖片模型有;影片模型有宣告時才有,此時 size(寬x高)必須在清單內。
constraints.minPixels / constraints.maxPixelsint兩個一起出現,代表這個模型除了 sizes 列舉的值之外,還接受任意 寬x高 像素字串,只要寬 × 高的總像素落在這個閉區間內。沒有這兩個欄位 = 只能用 sizes 裡的值。僅圖片模型有,見圖片端點。
constraints.acceptsNbool接不接受 n。false 代表一次固定產生 1 張,連 "n": 1 都會回 400 validation_error——這個模型沒有 n 這個參數,不要送。僅圖片模型有。
constraints.maxNint一次最多產生幾張(n 的上限)。acceptsN 為 false 的模型是 1。僅圖片模型有。
constraints.maxInputImagesint輸入圖(參考圖/圖生圖的 image)張數上限。0 = 不接受輸入圖。僅圖片模型有。
constraints.responseFormatsstring[]可以指定的 response_format,目前恆為 ["url", "b64_json"],與上游支援哪幾種無關。僅圖片模型有。
constraints.qualitiesstring[]quality 的可用值。沒有這個欄位 = 這個模型不接受 quality。僅圖片模型有。
constraints.backgroundbool接不接受 background 參數。false 的模型送了會回 400 validation_error,不會被靜默忽略。僅圖片模型有。
constraints.negativePromptbool接不接受 negative_prompt 參數。false 的模型送了會回 400 validation_error。僅圖片模型有。
constraints.seedbool接不接受 seed 參數(整數)。false 的模型送了會回 400 validation_error。僅圖片模型有。
constraints.watermarkbool接不接受 watermark 參數。不送時的預設是「有浮水印」;true 的模型才能用 "watermark": false 關掉,false 的模型送了會回 400 validation_error。僅圖片模型有。
pricing.unitstring"output_tokens"、"video_seconds" 或 "request"(按張計價的圖片模型:一張一個固定價格,結算看實際回來幾張,見圖片端點)。
pricing.from / tofloat同一個計價單位(pricing.unit)之下的價格區間(USD):from 是最低單價、to 是最高單價。兩者相等表示單一費率,不等表示這個模型有多段費率;實際落在哪一段由請求的條件決定,用 /v2/quote 可以確認。它們不是「輸入單價/輸出單價」——輸入與輸出各自的單價請讀 pricing.components。
pricing.componentsarray有多個計價單位的模型(例如輸入與輸出各有單價)才有,每個元素是 { "unit", "from", "to" },各單位有自己的區間。單一計價單位的模型不會有這個欄位,請用「這個欄位存不存在」判斷。詳見對話的預扣與串流計費。
statusstringactive 或 paused(暫停中,指定它會回 503,reason: "route_paused")。
speed_p50_ms、speed_basisint|null / string過去 7 天成功請求的延遲中位數(毫秒)。基準依模態:文字=first_token(只算串流請求的首個 chunk)、圖片=response(完整回應)、影片=submit_to_complete(送件到完成,含上游排隊)。這是觀測值不是 SLA。
speed_response_p50_msint|null只有文字模型有值:過去 7 天非串流請求的完整回應延遲中位數(毫秒)。圖片、影片恆為 null(它們的完整回應就是 speed_p50_ms)。
fail_ratefloat|null過去 7 天的失敗率=上游失敗 ÷(成功+上游失敗)。只算上游的問題(連線錯、5xx、429、逾時、上游判 failed);你的 400/404、內容審核、額度不足不算進去。
ratingobject|null{ avg, count }:過去 30 天透過 請求評價 打的分數。avg 是每個帳戶算一票的平均(先算每個帳戶自己的平均,再對這些平均取平均),所以單一帳戶評再多次也只佔一票;count 是總評分則數。
sampleobject{ speed_n, speed_response_n, fail_n, window_days }:速度(兩種基準各自)與穩定的樣本數。
reasonobject指標為 null 時說明原因:insufficient_samples(樣本未達門檻)或 no_data(沒有任何資料)。有值的指標不會出現在這裡;文字模型多一個 speed_response 鍵。門檻:速度兩種基準各自需 30 筆、失敗率需 30 筆(7 天內);評分需 20 則且至少 3 個不同帳戶(30 天內)。
解析欄位時請採寬鬆策略

目錄未來可能新增模型、新增 modality,或補上欄位。請以 id 為準做對映,遇到未知欄位或未知 modality 忽略即可,不要用嚴格 schema 驗證擋掉整份回應。

選擇上游

選上游就是選模型。直接用那一列的 id、slug 或 ref 呼叫,就會固定由那一家供應,不會換家。這條規則對 對話、圖片、影片、報價一體適用。怎麼從舊寫法改過來,見遷移指南。

例外:auto/(v2.9)。把 model 寫成 auto/<模型名稱>,由平台在提供這個模型的上游中替你選一家,失敗時自動換家。目前只支援對話,見平台代選。

v2.0 已移除
  • default_route、routes[]:已移除。現在一列就是一個模型,沒有「預設走哪一家」這件事;原本 routes[] 裡每一項的售價與四個指標,現在就在每一列模型上。
  • 裸 model(例如 Seedance-2.0-fast)走預設上游:已移除,回 400 model_ref_required。
  • "fallback": true:已不支援,回 400 fallback_unsupported。換家就是換模型,請自行指定。
  • ?include=routes:不再有作用。

回應裡怎麼知道用了哪個模型

成功的生成與報價回應都帶兩個標頭(串流是 SSE 的 200 標頭):X-AppleToken-Route: siraya/Seedance-2.0-fast(上游/模型)與 X-AppleToken-Model-Id: mdl_3h8r5t0v6w。回應 body 的 model 照你送的原字串回。/v2/usage、/v2/jobs、/v2/videos/{job_id} 的 model 一律是「上游/模型」,並帶 model_id。

錯誤

狀態error.code情形
400model_ref_required送了裸名稱。回應附 candidates(可改用的 id/slug/ref)與 docs_url,見遷移指南。
400fallback_unsupported帶了 "fallback": true。
404not_foundID、slug 或「上游/模型」不存在,或不在憑證白名單內——與「未知的模型」回應完全相同。
503service_unavailablereason: "route_paused"(這個模型暫停中)、"circuit_open"(最近連續失敗,稍後再試)、"pricing"(這個模型目前無法報價)。
400validation_errorfallback 不是布林;或請求引用的素材(asset://)只在另一家上游有效。

音軌後處理跑在平台自己的服務上,不經過上游,不適用上游選擇。

內嵌檔案自動轉存

影片端點(POST /v2/videos/generations)的素材參數收的是網址,平台會把網址交給後端去下載——如果那個網址需要登入、是私有分享連結、或有防盜鏈,後端就抓不到,送件會失敗並回報「resource download failed」。若你沒有可公開存取的儲存空間,可以直接把檔案以 data URI 的形式放進素材參數——平台會在送件前自動把它存進暫存空間、換成一條可下載的網址再交給後端,呼叫方式完全不用改變。

不計費

這個轉存過程不呼叫任何模型,不扣額度。

做法適合
直接內嵌 data URI沒有可公開存取的儲存空間時。
自備公開網址你本來就有可匿名存取的儲存空間——大檔或重複使用的素材建議用這個,避免每次都傳一份 base64。

適用的參數:影片端點(POST /v2/videos/generations)的 image_url、input_references、frame_images——巢狀在物件或陣列裡的值也會被處理;以及圖片端點(POST /v2/images/generations)的 image,那裡只收圖片型別(image/png、image/jpeg、image/webp、image/gif)。對話端點(POST /v2/chat/completions)不支援這個行為。格式為 data:image/png;base64,<base64 內容>。

檔案較大或要重複使用,建議改用公開網址

base64 編碼會讓資料膨脹約 1.33 倍,而整個 request body 上限是 24 MB。檔案較大、或同一份素材要在多次請求裡重複使用時,建議改用你自己可公開存取的網址——避免每次請求都重新內嵌一份 base64。

POST /v2/quote

POST/v2/quote需要憑證

用一組參數換一個價格。不扣額度、不凍結、不送件、不呼叫後端,可以放心在使用者按下「生成」之前先呼叫,用來顯示預估費用。

影片與圖片可以報價,對話不行

這個端點的估價邏輯建立在送出前就算得出來的量上:影片是「畫面尺寸 × 影格數」或「秒數」,圖片是張數(按 token 計價的圖片模型則是每張的 token 上限)。兩者都報得出來。

對話模型沒有這種量——費用取決於實際產生多少 token,無法事先算準,因此對 chat-* 呼叫 /v2/quote 會回 400。對話改以 max_tokens 為上限預扣,公式完全公開,見對話的預扣與串流計費。

請求欄位

欄位型別必填說明
modelstring必填模型:模型 ID(mdl_…)、slug 或「上游/模型」三擇一,見遷移指南。裸名稱回 400 model_ref_required。
secondsnumber必填輸出影片的長度,單位是秒。必須是 JSON number 的整數;範圍規則與影片送件的 seconds 相同(模型沒宣告時為 1–60),範圍外回 400。
帶影片參考時:reference_mode 為 "edit" 時長度由參考影片決定,這個值只用來估價;其餘("extend"/"reference"/"auto"/沒指定)照這個值估價並送出。沒指定或 "auto" 時,後端若把任務判成編輯,任務會失敗並附上後端原文。見影片參考與長度。
resolutionstring視模型token 制模型需要它(或 size)才能算量。
sizestring選填"1280x720"。提供時優先於 resolution 決定尺寸,但仍要通過解析度檢查。
aspect_ratiostring選填預設 "16:9"。
input_referencesarray選填只用來判斷是否含影片參考。部分模型的費率會因此跳到另一段(見各模型的 pricing),內容不會被送到後端。
數量仍然會驗:超過上限的話估價就會回 400,不必等到送件,見參考素材的數量上限。
上表是影片模型的欄位;圖片模型送的是圖片端點那一組

要報價圖片模型,請送與 POST /v2/images/generations 相同的欄位(model、prompt,以及 n/size/image 等),不要送 seconds。平台跑的是與送件完全相同的那一套驗證,所以參數不合法時這裡就會回 400,而且不扣額度、不凍結。

圖片模型的 prompt 是必填——這是與影片不同的一點,理由同上:驗證與送件同一套。

prompt 在這個端點不是必填——只想問價格可以不給,價格本身與內容無關。但它是兩條回應路徑的分岔點:影片模型且帶了一個非空白的 prompt,回應就會多一張報價單,可以拿去確認後送件(見送件前確認);沒帶就是單純的價格查詢。只有空白字元的 prompt 視同沒帶。

回應:兩條路徑

這個端點有兩條回應路徑,分岔只看一件事:有沒有帶 prompt。

路徑什麼時候走這條回應
純比價沒帶 prompt,或模型不是影片模型只有下面那幾個欄位,與這個端點原本的回應一個字都沒變。
報價單影片模型且帶了非空白的 prompt純比價的欄位全部保留,再疊加一組報價單欄位(quote_id、resolved、materials 等)。
既有整合不受影響

純比價那條路徑的回應形狀完全沒有變動。只帶模型與秒數問價錢的程式碼不需要修改,也不會突然多收到欄位——報價單欄位只在你自己帶了 prompt 時才出現。

路徑一:純比價

{
  "model": "siraya/Seedance-2.0-fast",
  "unit": "output_tokens",
  "billable": { "output_tokens": 108900 },
  "price": 0.539055,
  "estimated": false,
  "available": 20.0
}
欄位說明
unit計價單位,"output_tokens" 或 "video_seconds"。
billable計費量。token 制為 {"output_tokens": N};秒制為 {"video_seconds": N}。
price本次預估費用(USD,6 位小數)。這也是送件時會被凍結的金額。
estimated這個價格是精算還是上限估計。false=精算,這個數字就是送件時會凍結、也是預期實收的金額——影片一律是這種(秒制與 token 制都是),按張計價的圖片模型也是。true=只給得出上限,實收會等於或低於它,最終金額以結算為準——目前只有按 token 計價的圖片模型會回這個值。
available呼叫當下帳戶的可用餘額。額度掛在帳戶上,名下所有憑證共用同一筆。這是即時快照,不代表送件當下仍然足夠。

圖片模型也走這條路徑(圖片沒有報價單,不會有 quote_id)。送出與送件相同的參數,回應形狀完全一樣:

{
  "model": "siraya/ByteDance-Seedream-4.5",
  "unit": "request",
  "billable": { "request": 2 },
  "price": 0.08,
  "estimated": false,
  "available": 20.0
}

estimated 說明這個數字的性質:按張計價的圖片模型 unit 是 "request"、estimated 為 false(精算,等於實收);按 token 計價的圖片模型 unit 是 "output_tokens"、estimated 為 true(上限估計,實收以結算為準)。

路徑二:報價單(帶了 prompt)

回應是路徑一的全部欄位,再加上下面這些。多出來的部分是讓你在送出前核對「實際會送給後端的到底是什麼」,確認無誤再憑 quote_id 送件——回 POST /v2/videos/generations 帶上 quote_id 與 confirm 就會真的送出,完整做法見送件前確認,兩個入口產生的是同一種報價單。

{
  "model": "siraya/Seedance-2.0-fast",
  "unit": "output_tokens",
  "billable": { "output_tokens": 108900 },
  "price": 0.539055,
  "estimated": false,
  "available": 20.0,

  "quote_id": "q_a1b2c3d4e5f6a7b8c9d0",
  "expires_at": "2026-09-02T10:30:00Z",
  "payload_digest": "sha256:3f2a…",

  "resolved": {
    "model": "siraya/Seedance-2.0-fast", "model_id": "mdl_3h8r5t0v6w", "prompt": "海邊的黃昏,鏡頭緩慢推近",
    "seconds": 5, "resolution": "720p", "aspect_ratio": "16:9",
    "image_url": "https://…/tenants/…/uploads/….jpg",
    "async": true, "seed": null,
    "seed_note": "未指定 seed,上游每次生成的結果都不同;要重現近似結果請指定 seed"
  },
  "materials": [
    { "field": "image_url", "index": 0, "upload_id": "…", "bytes": 545459,
      "mime": "image/jpeg", "sha256": "…",
      "url": "https://…/tenants/…/uploads/….jpg", "preview_url": "https://…" }
  ],
  "estimate": { "output_tokens": 108900, "points": 0.539055,
                "basis": "exact", "billable": { "output_tokens": 108900 },
                "unit": "output_tokens" },
  "balance": { "available": 20.0, "sufficient": true,
               "note": "此報價不保留額度,確認送出時才凍結" }
}
欄位說明(以下全部只在報價單路徑出現)
quote_id報價單編號。送件時憑它確認,用法見送件前確認。
expires_at報價單的有效期限,ISO 8601 時間字串(例如 "2026-09-02T10:30:00Z")。過期後這張單子不能再拿來送件。
payload_digest最終送件內容的指紋。確認送出時平台會重新組一次請求並比對這個值,內容被改過就不放行。
resolved你送出的每一個參數,原樣回顯(包含 prompt)。只有兩處不是原樣:model 換成對外的模型名稱,以及 seed 沒帶時補成 null,讓「這次沒帶」看得出來。
resolved 的素材欄位image_url/frame_images/input_references 在這裡的值,是轉存之後、實際會送給後端的網址,不是你原本送來的 data URI。這正是要你核對的東西:你看到的網址,就是後端會去下載的那一條。
resolved.seed_note一句說明,告訴你這次有沒有帶 seed,以及那對結果的影響。沒帶 seed 時說明每次生成都會不同;有帶時說明上游會以它為隨機起點。
materials陣列,每個被轉存的素材一筆:field(來自哪個欄位)、index、upload_id、bytes、mime、sha256(內容指紋),以及 url 與 preview_url。這兩個網址目前是同一條,也與 resolved 裡對應欄位的值相同。沒有內嵌素材時是空陣列。
estimate{ points, basis, billable, unit };token 制模型另有 output_tokens。points 就是這筆的估算金額,與外層的 price 相同。
estimate.basis"exact" 表示這是精算不是估計;"upper_bound" 表示只能給出上限,實收會等於或低於它。
balance{ available, sufficient, note }。note 是一句固定說明,講的就是下面這則警告。
balance.sufficient呼叫當下的餘額夠不夠付這一筆。只是快照,不是保留。
報價單不凍結額度

拿到 quote_id 不代表額度被保留下來了。balance.sufficient 是呼叫當下的快照,同帳戶的其他請求(或你自己接著送的別件)都可能把餘額用掉,因此它不保證你送件時仍然足夠。額度要到確認送出的當下才凍結。

這是刻意的:比價本來就會連打很多次,每次都凍結會把餘額卡光。

估價與實收的關係

對秒制模型,估價通常就是實收。對 token 制模型,最終金額以後端回報的實際 token 量重算,可能略高或略低於估價——差額會在結算時反映。詳見結算、退還與對帳。

POST /v2/videos/generations

POST/v2/videos/generations需要憑證

送出一個生成任務。這個呼叫會立即凍結額度,且送出後無法取消。

這條端點有兩種送法,用的是同一個網址、同一份請求欄位,差別只在回應。直接送出是預設:帶齊下表的參數一次送出,回應就是任務本身。先確認再送出則是這一次呼叫帶上 require_confirmation: true(或由帳戶層開啟「送件前確認」)——同樣的請求不會直接送出,平台先把「即將送出的內容」回給你一張確認單(列出即將送出的每一個參數與素材網址、預估點數),你核對無誤再回一個 confirm,才真的送出。像匯款——按下送出之後才跳確認,而不是要你先去別的地方領一張單子再回來。不帶 require_confirmation(或帶 false)、帳戶也沒開,就是直接送出。帳戶層的開關是下限:後台為你的帳戶開啟「送件前確認」之後,即使這一次帶 require_confirmation: false,一樣會回確認單。

帳戶層的「送件前確認」是下限,不是預設值——帶 require_confirmation: false 也繞不過

這個開關預設關閉,關閉時這條端點的行為完全不變。一旦由後台為你的帳戶開啟,它就凌駕請求裡的旗標:即使你明確帶 require_confirmation: false,這條端點也不會直接送件。開關是下限而不是預設值——開了之後,請求說不要不該把它關掉。

既有的自動化流程會在沒有任何徵兆的情況下停止送件——HTTP 狀態碼仍然是 200,但 object 從 "video" 變成 "video.confirmation",回應裡沒有 id 可以拿去輪詢。所以這個開關由我們為你的帳戶開啟,開啟前請先與我們確認,並確認你的程式碼已經會判斷 object 與 status。

請求欄位

欄位型別必填說明
quote_idstring二擇一確認單編號,見送件前確認。帶了它就不能再帶下表其他任何欄位,否則回 400——確認階段不允許改動內容。不帶則走直接送件,下面的必填欄位照舊。
confirmbool/string選填只在帶 quote_id 時有意義。true(或 "yes")送出、false(或 "cancel")取消;不帶則把確認內容再回給你看一次,不送出。
modelstring必填模型:模型 ID(mdl_…)、slug 或「上游/模型」三擇一,見遷移指南。裸名稱回 400 model_ref_required。必須是 modality: "video" 的模型。
promptstring必填內容描述。不可為空字串。
secondsnumber必填輸出影片的長度,單位是秒。必須是 JSON number 的整數(字串 "5" 會回 400)。
範圍:模型在 /v2/models 的 constraints 宣告了 allowedSeconds 或 minSeconds/maxSeconds 時以宣告為準;沒宣告時平台接受 1–60 的整數。不是整數或超出範圍,送件當下就回 400 validation_error,不會預扣額度。這個範圍是平台的防呆上限,各模型實際支援的長度由後端決定(例如 Seedance-2.5 為 4–30 秒);後端不接受時任務失敗、預扣退還,不收費。
帶影片參考時:reference_mode 為 "edit" 時長度由參考影片決定,這個值只用來估價與預扣(平台改送 -1);"extend"/"reference" 照這個值輸出(4–30 的整數);"auto" 或沒指定時也照這個值送出,但後端若把任務判成編輯,任務會失敗並附上後端原文。見影片參考與長度。
resolutionstring視模型解析度,須在模型的支援清單內。
aspect_ratiostring選填沒帶時由後端決定預設比例,平台不會替你補上。但平台的計價在沒帶時一律以 "16:9" 估算,所以實際產出的比例與估價依據可能不同。
sizestring選填"1280x720",決定尺寸時優先於 resolution。
generate_audiobool選填是否產生音軌。
negative_promptstring選填不希望出現的內容描述。
seednumber選填亂數種子,用於重現近似結果。平台不驗證其值。
output_formatstring選填輸出格式偏好。
watermarkbool選填浮水印開關,目前只對 HappyHorse 系列有作用。HappyHorse 的後端預設會加浮水印;送 false 可關閉、送 true 明確開啟。不帶時行為與過去相同(由後端預設,也就是有浮水印)。必須是 JSON 布林值,字串 "false" 不會被轉送;其他影片模型送了會被丟棄。
image_urlstring選填圖生影的起始圖片直連網址或 data URI。沒有公開儲存空間?可直接以 data URI 內嵌,平台會自動代管(見內嵌檔案自動轉存)。
frame_imagesarray選填關鍵影格圖片,可為直連網址或 data URI。與 input_references 互斥。
input_referencesarray選填多模態參考素材,項目形如 {"type":"image"|"video"|"audio","url":"…"},url 可為直連網址或 data URI。沒有公開儲存空間?可直接以 data URI 內嵌,平台會自動代管(見內嵌檔案自動轉存)。
帶 {"type":"video"} 的參考素材時,seconds 是否成為輸出長度取決於 reference_mode,見下一列與影片參考與長度。
reference_modestring選填只能與影片參考(input_references 含 {"type":"video"})一起用:"edit"/"extend"/"reference"/"auto"。extend 與 reference 會照你的 seconds(4–30 的整數)產出;edit 的長度由參考影片決定(平台改送 -1);不帶或 auto 時照你的 seconds 送出,後端判成編輯時任務會失敗。延伸的成品只有新增的部分。見影片參考與長度。
post_processobject選填音軌風格後處理,形如 {"audio_style":"phone","intensity":3}。任務完成後平台會另外產出一支加了聲音效果的成品,原片不受影響,兩支都可取件(見音軌風格後處理)。
這是平台層參數,不會送到後端,也不影響生成內容。audio_style 可用值:phone(電話)、walkie(對講機)、radio(廣播)、vinyl(老唱片)、muffled(隔壁房間)、hall(大廳迴音)、lofi_16k/lofi_24k(降取樣率)、mute(去音軌);intensity 為 1–5,預設 3(lofi_* 與 mute 忽略此值)。
原片沒有音軌時(generate_audio: false)除 mute 外都無意義,後處理會以 no_audio_stream 失敗,但不影響原片的交付與計費。
未列出的欄位會被丟棄,而且不會有任何警告

只有上表的欄位會被送到後端。其中兩個常見欄位是被刻意移除的:

  • async — 影片一律以非同步模式送件,你無法改成同步等待。這是為了避免長影片把連線卡到逾時。
  • callbackUrl(camelCase)/ webhook — 一律丟棄,不會轉給後端。平台自己的回呼請改用 callback_url,見回呼(Webhook);輪詢仍是正式的取件方式。
  • extra_body — 已不再支援。它曾經可以把任意欄位原樣直通後端,那等於在白名單上開一個洞,現在一併丟棄。你送了不會收到錯誤訊息,它只是完全沒有作用。

其他如 n、user、metadata 等欄位同樣會被忽略。stream 在這個端點也無效——串流只存在於對話端點。若你送出後發現某個參數毫無作用,請先確認它在上表之中。

一個例外:post_process 在上表之中,但它不會被送到後端——它描述的是成品產生「之後」平台要做的事,與生成內容無關。

回應:直接送出

{
  "object": "video",
  "id": "job_1a2b3c4d5e6f7a8b9c0d",
  "status": "processing",
  "model": "siraya/Seedance-2.0-fast",
  "model_id": "mdl_3h8r5t0v6w",
  "created_at": 1756300000,
  "price_reserved": 0.539055
}
欄位說明
id任務識別,格式 job_ + 20 個十六進位字元。請保存它——這是之後查詢與取件的唯一憑據。
status送件成功時為 "processing"。
model照你送出的原字串回(ID、slug 或「上游/模型」)。實際用到的模型 ID 在回應標頭 X-AppleToken-Model-Id。
price_reserved本次凍結的金額(USD)。
created_at建立時間(Unix 秒)。

回應中可能出現其他透傳欄位,但只有 id、status、model、price_reserved 是穩定契約,請勿依賴其餘欄位。

200 不代表會成功

部分參數組合的檢查發生在後端排程之後,因此送件會回成功、失敗卻延後到任務被追蹤到終局時才以 status: "failed" 出現。務必以查詢結果為準,不要在送件成功時就通知使用者完成。

回應:先確認再送出時的確認單

開啟送件前確認之後,你照常送出請求——欄位一個都不用改:

curl https://appletoken.app/v2/videos/generations \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "siraya/Seedance-2.0-fast",
    "prompt": "海邊的黃昏,鏡頭緩慢推近",
    "seconds": 5, "resolution": "720p",
    "image_url": "data:image/jpeg;base64,…"
  }'

回應不是任務,而是一張待確認的單子:

{
  "object": "video.confirmation",
  "status": "needs_confirmation",

  "quote_id": "q_a1b2c3d4e5f6a7b8c9d0",
  "expires_at": "2026-09-02T10:30:00Z",
  "payload_digest": "sha256:3f2a…",

  "resolved": {
    "model": "siraya/Seedance-2.0-fast", "model_id": "mdl_3h8r5t0v6w", "prompt": "海邊的黃昏,鏡頭緩慢推近",
    "seconds": 5, "resolution": "720p", "aspect_ratio": "16:9",
    "image_url": "https://…/tenants/…/uploads/….jpg",
    "async": true, "seed": null,
    "seed_note": "未指定 seed,上游每次生成的結果都不同;要重現近似結果請指定 seed"
  },
  "materials": [
    { "field": "image_url", "index": 0, "upload_id": "…", "bytes": 545459,
      "mime": "image/jpeg", "sha256": "…",
      "url": "https://…/tenants/…/uploads/….jpg", "preview_url": "https://…" }
  ],
  "estimate": { "output_tokens": 108900, "points": 0.539055, "basis": "exact" },
  "balance": { "available": 20.0, "sufficient": true,
               "note": "此報價不保留額度,確認送出時才凍結" },

  "next": {
    "confirm": { "method": "POST", "url": "/v2/videos/generations",
                 "body": { "quote_id": "q_a1b2c3d4e5f6a7b8c9d0", "confirm": true } },
    "cancel":  { "method": "POST", "url": "/v2/videos/generations",
                 "body": { "quote_id": "q_a1b2c3d4e5f6a7b8c9d0", "confirm": false } }
  },
  "message": "這是即將送出的內容,目前尚未送出、尚未扣點。…"
}
這個時候還沒有送出,也還沒有扣任何點數

後端完全沒有被呼叫,額度也沒有凍結。素材已經轉存到圖庫(所以 resolved 裡的網址是可以直接點開看的),但這是為了讓你核對,不是已經開始生成。

欄位說明
status"needs_confirmation"。看到這個值就代表還沒送出,在等你回覆。
quote_id這張確認單的編號。確認時要帶它。有效期 15 分鐘。
resolved你送出的每一個參數,原樣回顯——包含 prompt。素材欄位(image_url/frame_images/input_references)的值是轉存到圖庫之後、實際會送給後端的網址,不是你原本送來的 data URI。沒帶 seed 時會補一個 null 讓你看得到「這次沒帶」。async 是平台強制加的,照實顯示不藏起來。
materials內嵌素材逐件列出:第幾件、多大、什麼型別、sha256 指紋,以及轉存後的 url(可直接點開)。與 resolved 裡對應欄位的網址是同一條。
estimatebasis: "exact" 表示這是精算不是估計——影片的計費量在送出前就算得準。
payload_digest這張單子送出時,後端會收到的內容的指紋。
next下一步該打什麼,直接寫在回應裡,不必回頭翻文件。
balance.sufficient當下餘額夠不夠。這一步不凍結額度,所以是快照不是保留。

確認、取消,或再看一次

# 確認送出
-d '{"quote_id": "q_a1b2c3d4e5f6a7b8c9d0", "confirm": true}'

# 取消(要改內容就取消掉,重新送一次完整請求)
-d '{"quote_id": "q_a1b2c3d4e5f6a7b8c9d0", "confirm": false}'

# 只帶 quote_id:把確認內容再看一次,不會送出
-d '{"quote_id": "q_a1b2c3d4e5f6a7b8c9d0"}'

confirm 收布林值,也收人會直接打出來的字:"yes"/"y"/"confirm"/"ok" 都算確認,"no"/"cancel" 算取消。填了看不懂的值一律回 400——確認這件事上,猜你的意思是危險的。

確認成功後的回應與一般送件完全相同(多一個 quote_id 欄位),拿 id 去輪詢即可。取消則回 {"object":"video.confirmation","status":"cancelled"}。

你確認的,就是後端收到的

送出時平台會重新組一次請求,跟你確認的那份內容比對指紋(payload_digest),不一致就拒絕送出(回 409)。所以確認畫面上的內容與後端實際收到的內容一定相同,不是靠承諾,是每次都真的比對過。

確認階段的規則

同時開多張報價單

同一把憑證可以同時持有多張未確認的報價單,彼此完全獨立,不會互相覆蓋或取消。要對多個模型比價、或一次排好幾支影片再逐一確認,直接開多張就好。

做法見完整範例裡的「多張報價單 → 逐一確認送出」。

錯誤

直接送件時,驗證依下列順序進行,先觸發者先回:

  1. 缺 model → 400
  2. 模型不在憑證的允許清單 → 404
  3. 長度或解析度不符模型限制、參考素材數量超過上限、缺必要計價參數 → 400(多個問題會用「;」串接在同一則訊息)
  4. 估價金額超過可用餘額 → 402,附 needed 與 available
  5. 缺 prompt → 400(刻意排在凍結之前,避免建立又立刻退還的無謂凍結)
  6. 凍結額度(此後才會產生費用紀錄)
  7. 送往後端;任何階段失敗都會退還凍結,帳本記 release(submit_failed)

帶 quote_id 的確認呼叫走的是另一組檢查:

狀態情況
400帶 quote_id 時夾帶了生成參數;確認單已過期或已取消;confirm 的值看不懂
402確認送出時凍結額度,估價金額超過可用餘額 → 402,附 needed 與 available
404確認單不存在,或不屬於這把憑證
409確認後模型設定或費率有變動,內容已與確認當下不同;要取消一張已經送出的單子;或這張單子正在送出中

收到 402 時確認單會退回可用狀態:額度沒有被凍結,這張單子也沒有作廢。加值之後拿同一張 quote_id 再送一次確認即可,不必重新報價(只要還在 15 分鐘有效期內)。

為什麼同樣的參數會生出不一樣的影片

沒有指定 seed 時,後端每次都用新的隨機起點,所以即使模型、秒數、素材完全相同,結果也不會一樣。要重現近似的結果,請自行帶上 seed。確認單的 resolved.seed 會明白告訴你這次有沒有帶。

另外提醒:-fast 版與標準版是不同的模型,秒數與參考圖數量也都會顯著改變成品。resolved 與 materials 就是用來讓這些差異在送出前一眼看得出來。

也可以自己先產生確認單

不想開啟強制確認、但某些請求想先看一眼的話,在 /v2/quote 帶上 prompt 與素材,回應同樣會帶一個 quote_id,接著走上面的確認步驟即可。兩個入口產生的是同一種東西。

GET /v2/videos/{job_id}

GET/v2/videos/{job_id}需要憑證

查詢任務狀態。若任務已經完成或失敗,這個呼叫會順手完成結算或退還——但結算不依賴你的查詢:平台的背景程序會做同一件事,兩者走同一段程式碼且彼此冪等,先到者生效。

回應

{
  "object": "video",
  "id": "job_1a2b3c4d5e6f7a8b9c0d",
  "status": "completed",
  "model": "siraya/Seedance-2.0-fast",
  "model_id": "mdl_3h8r5t0v6w",
  "seconds": 5,
  "resolution": "720p",
  "created_at": 1756300000,
  "completed_at": 1756300182,
  "content": "/v2/videos/job_1a2b3c4d5e6f7a8b9c0d/content",
  "outputs": [
    { "type": "original",  "status": "completed",
      "content": "/v2/videos/job_1a2b3c4d5e6f7a8b9c0d/content?output=original" },
    { "type": "processed", "id": "pp_9f8e7d6c5b4a3210fedc", "status": "completed",
      "audio_style": "phone", "intensity": 3,
      "content": "/v2/videos/job_1a2b3c4d5e6f7a8b9c0d/content?output=processed" }
  ],
  "price_charged": 0.539055
}

失敗時的回應長這樣——注意 HTTP 狀態碼仍然是 200,任務失敗不是 HTTP 錯誤,請以 status 欄位判斷,不要只看狀態碼:

{
  "object": "video",
  "id": "job_1a2b3c4d5e6f7a8b9c0d",
  "status": "failed",
  "model": "siraya/Seedance-2.0-fast",
  "model_id": "mdl_3h8r5t0v6w",
  "created_at": 1756300000,
  "completed_at": 1756300182,
  "price_reserved": 1.096198,
  "error": {
    "code": "generation_failed",
    "message": "The request failed because the output audio may be related to copyright restrictions. Request id: 021700000000000000000000000000000000ffffc0a800000000",
    "upstream_message": "The request failed because the output audio may be related to copyright restrictions. Request id: 021700000000000000000000000000000000ffffc0a800000000"
  }
}
status意義額度影響
processing仍在生成中。維持凍結。
completed已完成,可以取件。凍結轉為實扣,回傳 price_charged。由本次呼叫或背景程序完成,先到者生效。
failed生成失敗。回應會帶 error 說明失敗原因。凍結全額退還。回應不會有 price_charged 這個欄位(不是 0.0),帶的是 price_reserved,也就是當初凍結、現已退還的金額。同樣不需要你觸發。
建議的輪詢節奏

首次等待 10 秒後開始查詢,之後每 5–10 秒一次,並設定總時限(建議 15 分鐘)。收到 429 時採指數退避。不要用小於 3 秒的間隔連續輪詢——平台自己也在背景輪詢後端,你查得再密也不會更快。

不想寫程式也拿得到

登入後台的「任務」頁會列出你的每一支任務,已完成的可以直接在頁面上預覽與下載;「用量」頁的每一筆也能展開詳情,回頭看當初送出的內容與拿回的結果。要確認「到底成功了沒」,這是最快的一條路,不必自己寫輪詢。

任務失敗的細分:error.reason(/v2)

error.code 維持 generation_failed,與以前完全相同。平台認得失敗原因時,/v2 另外多一個 error.reason(只新增欄位,認不出來就沒有這個欄位):

reason代表你該做的事
content_rejected內容審核未通過(輸入素材或生成結果,例如音軌被判定為敏感內容)換提示詞、素材或設定再送。原樣重送通常會得到同樣結果。
invalid_parameters送件後才被判定參數不合法(例如模式與比例、長度的組合不被接受)依 upstream_message 調整參數。

凍結一樣全額退還。舊版 API(v1 文件)不帶 reason,回應不變。回呼(webhook)的 data.error 用同一套:code 不變,認得時多一個 reason。

GET /v2/videos/{job_id}/content

GET/v2/videos/{job_id}/content需要憑證

下載成品。回應是 Content-Type: video/mp4 的原始位元組,不是 JSON、也不是轉址。

curl -f https://appletoken.app/v2/videos/job_1a2b3c4d5e6f7a8b9c0d/content \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -o output.mp4
# 取回後處理片(例如電話音效那一支)
curl -f "https://appletoken.app/v2/videos/job_1a2b3c4d5e6f7a8b9c0d/content?output=processed" \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -o output_phone.mp4

音軌風格後處理

POST/v2/post-process需要憑證

對影片的音軌套用聲音效果,例如讓對白聽起來像從電話裡傳出來。影像不會被重新編碼,只有音軌被處理,所以畫質與原片完全一致。

兩種用法

  1. 生成時順帶:在 POST /v2/videos/generations 帶 post_process,任務完成後自動產出第二支成品。
  2. 對既有影片加工:用下面的獨立端點,來源可以是平台上已完成的任務、你上傳的素材,或內嵌的 data URI。

請求

{
  "job_id": "job_1a2b3c4d5e6f7a8b9c0d",
  "audio_style": "phone",
  "intensity": 3
}
欄位
job_id / asset_id / url來源三選一,放在 body 頂層(不是包在 source 裡):job_id(平台上已完成的任務)/asset_id(你上傳的素材)/url(內嵌的 data URI,只收 data:video/mp4;base64,…)。三個只能給一個。
audio_style九選一(見下方清單)。
intensity1–5 的整數,預設 3。實際套用時會收斂成三個段位:1–2 走輕、3–4 走中、5 走重;lofi_* 與 mute 忽略此值。字串(例如 "3")會被拒。

回應 202

{
  "object": "video.post_process",
  "id": "pp_9f8e7d6c5b4a3210fedc",
  "status": "queued",
  "audio_style": "phone",
  "intensity": 3,
  "created_at": 1756300000
}

GET /v2/post-process/{id} · GET /v2/post-process/{id}/content · GET /v2/post-process

可用的聲音風格

phone電話:窄頻、單聲道、略帶失真
walkie對講機:更窄的頻寬、壓縮強烈
radio老收音機/廣播
vinyl老唱片:溫暖、輕微抖動
muffled隔壁房間/門後:悶住並帶迴音
hall大廳迴音
lofi_16k降到 16 kHz 取樣再還原
lofi_24k降到 24 kHz 取樣再還原
mute移除音軌

限制與計費

錯誤碼

no_audio_stream這支影片沒有可以處理的音軌(除 mute 外的效果都需要音軌)
unsupported_input格式不支援,或參數不合法
too_long超過長度上限
ffmpeg_failed處理過程失敗,請聯繫我們
insufficient_space平台暫時無法配置空間,請稍後再試
postprocess_timeout長時間未完成,已停止等待(凍結全額退還)
lambda_error後端處理失敗
lambda_bad_response後端回應無法解析
internal_error後端內部錯誤
postprocess_failed其他失敗。認不得的錯誤碼也會收斂成這一個,所以你的程式碼要有處理它的分支

錯誤只會回 code 與一句固定文案,不會帶內部細節(檔案路徑、堆疊、後端訊息一律不外流)。
download_failed 與 upload_failed 是暫時性的,平台會自動重試,不會出現在最終狀態;重試到時限仍未成功會變成 postprocess_timeout。

POST /v2/images/generations

POST/v2/images/generations需要憑證

圖片生成。與影片不同,這是同步端點:一次呼叫就直接拿到圖片,沒有任務 id、沒有輪詢、沒有取件。所有圖片模型共用這一個端點,帶不帶輸入圖都一樣。

圖片不是任務,不要拿去輪詢

影片是非同步的:送件先拿到 job_id,再輪詢狀態、最後取件。圖片沒有這一段——回應本身就是成品。/v2/jobs 不會列出圖片,/v2/usage 裡圖片那一列的 job_id 就是回應的 id(img_…),但那個 id 不能拿去 /v2/videos/{id} 查詢——圖片沒有可輪詢的任務。

請求欄位

欄位型別必填說明
modelstring必填模型:模型 ID(mdl_…)、slug 或「上游/模型」三擇一,見遷移指南。裸名稱回 400 model_ref_required。必須是 modality: "image" 的模型。拿影片或對話模型呼叫會回 400「{model} 不是 image 模型」。
promptstring必填文字描述,不可為空白。
nnumber視模型要產生幾張,省略時為 1 張。部分模型不支援 n,一次固定回 1 張,送了會回 400;支援的模型各有自己的上限。
sizestring視模型輸出尺寸。可用值依模型而異——有的收 "1024x1024" 這類寬×高像素值,有的收 "1K"/"2K"/"4K" 這類簡寫,有的兩種都收,也有模型完全不接受 size。不在該模型可用值之內會回 400,錯誤訊息會把可用值列出來。
imagestring|array選填輸入圖(以圖生圖/改圖),單一值或陣列都收。每一項可以是可公開存取的網址,也可以直接內嵌 base64 data URI——平台會自動轉存後再送出,你不需要自備公開儲存空間(與影片素材同一套行為,見內嵌檔案自動轉存)。接受幾張依模型而異,也有模型完全不接受輸入圖。部分模型帶輸入圖時尺寸由輸入圖決定,此時不能同時指定 size。
response_formatstring選填"url"(預設)或 "b64_json"。預設回平台代管的圖片網址;"b64_json" 把影像內容直接放進回應。兩個值所有圖片模型都收。
這條網址 24 小時後失效——它是有簽章、有到期時間的暫時連結,不是永久網址。存進資料庫當長期圖片連結會在隔天集體失效,請在取得後盡快下載並存到你自己的儲存空間。
qualitystring視模型畫質檔位。只有部分模型有這個參數,其餘模型送了會回 400,訊息會列出可用值。
backgroundstring視模型背景處理方式。同上,只有部分模型支援。
negative_promptstring視模型不希望出現的內容。同上,只有部分模型支援。
seednumber視模型隨機種子,整數。同上,只有部分模型支援。
watermarkboolean視模型浮水印開關。本平台預設為 false,也就是不加浮水印——要在圖片右下角加上「AI generated」標記,請明確送 true。目前只有 Seedream 系列支援,其餘模型送了會回 400。必須是 JSON 布林值,字串 "false" 會被擋下。

其他未列在上表的欄位會被靜默忽略,不會報錯。

能力依模型而異,這份文件刻意不列成一張表

size 的可用值、n 的上限、可接受的輸入圖張數,以及有沒有 quality/background/negative_prompt/seed——每個模型都不一樣,而且會隨模型改版調整。寫死一張對照表只會過期,所以這裡不寫。

做法是:直接送,看錯誤訊息。超出範圍一律回 400 validation_error,訊息會指出是哪一個參數、以及那個模型可以接受什麼值(例如「size 只接受 …」「每次固定產生 1 張圖,不接受 n」)。一次送出多個不合法的參數時,訊息會把它們全部列出來,不會只講第一個。

想在真正送出前先試,可以拿同一組參數打 /v2/quote——它跑的是同一套驗證,不合法時回同樣的 400,而且完全不扣額度。

唯一的例外是下面這一段。Seedream 系列的 size 另外有一道「面積」門檻,而錯誤訊息只講得出下限、講不出上限——問不出來的東西沒有理由不寫,所以那張表列在這裡。

浮水印:本平台預設關閉

Seedream 系列的模型原廠會在圖片右下角加上一個「AI generated」的圓角框標記,而本平台預設把它關掉——不帶 watermark 欄位時,你拿到的圖沒有這個標記。

要加上標記就送 "watermark": true。這是逐次請求的設定,沒有帳號層級的開關。

哪些模型支援,可以從 /v2/models 的 constraints.watermark 查到。不支援的模型本來就不會有這個標記,送 watermark 會回 400。

Seedream 系列的 size:簡寫與面積區間

size 有兩種寫法,兩種都收:簡寫(1K/2K/3K/4K,各模型可用的簡寫不同),或 寬x高 像素字串(例如 "1024x1024")。簡寫走的是白名單,通過之後不再套面積檢查;送像素字串時才會走下表的面積判定,看的是寬 × 高的總像素,不是個別邊長、也不是比例。

模型可用簡寫面積下限面積上限
Seedream 4.01K、2K、4K921,60016,777,216
Seedream 4.52K、4K3,686,40016,777,216
Seedream 5.0-lite2K、3K、4K3,686,40016,777,216
Seedream 5.0-pro1K、2K921,6004,624,220

品牌前綴不影響這張表:模型 id 去掉 ByteDance-/Dola-/NSFW- 之後對到哪一列,就套哪一列。例如 ByteDance-Seedream-4.0 與 NSFW-Seedream-4.0 共用第一列,Dola-Seedream-5.0-pro 與 NSFW-Dola-Seedream-5.0-pro 共用最後一列。

幾個算好的值可以直接對照:"1024x1024" 是 1,048,576、"1080x1920" 是 2,073,600,兩者對 4.0 與 5.0-pro 都可以,對 4.5 與 5.0-lite 都太小;"2560x1440" 剛好是 3,686,400,四個模型都收;"2048x2048" 是 4,194,304,仍在 5.0-pro 的上限之內;"4096x4096" 是 16,777,216,除了 5.0-pro 以外的三個收得下。兩端都是包含——剛好等於下限或上限都算通過。

這張表是現況快照,權威在 GET /v2/models:每個圖片模型的 constraints 直接給出 sizes、minPixels、maxPixels。要寫進程式請讀那三個欄位,不要把上面的數字寫死。

2026-09-07 的變更:先前 5.0-pro 沿用了 4.5 / 5.0-lite 的 3,686,400 下限,於是 1K/2K 級距的尺寸在平台這一層就被擋下(回 400,沒有送到後端、也沒有扣款)。實測後端對 5.0-pro 的 "1024x1024"、"768x1360"、"1080x1920" 都能正常生成,因此下限已放寬到 921,600;同時補上 4,624,220 的上限,那個數字是後端自己回報的、只在 5.0-pro 身上出現過。其餘三個模型這次一格都沒有變動。

同日的第二項變更:5.0-pro 的可用簡寫也一併更正了,從 2K/3K/4K 改成 1K/2K——上表最後一列已經是更正後的值。依據是直接對後端送簡寫字串的實測:"1K" 與 "2K" 都回 200,"3K" 與 "4K" 都回 400,訊息是 must be 'WIDTHxHEIGHT' or a supported size preset.。也就是說先前列出的三個簡寫裡有兩個從來就不成立,只是平台這一層放行了、由後端擋下。

面積門檻與簡寫清單是兩條各自獨立的檢查,分開看才不會誤判:送簡寫走的是 preset 查表,每個模型接受的 preset 不一樣,通過與否跟面積門檻完全無關;送 寬x高 像素字串走的才是上表的面積範圍檢查。還有一件事值得先知道:後端依 preset 實際產出的尺寸,不遵守本文件的像素對照——5.0-pro 送 "2K" 回來的是 2816×1584、送 "1K" 回來的是 1248×832,都不是把 K 數換算成正方形的結果。輸出尺寸要確定,請直接送像素字串。

錯誤訊息可以告訴你卡在哪一層:平台擋下時回的是下面那段繁體中文的 validation_error,它同一句話同時列出可用簡寫與面積的上下限,所以不會直接指出是簡寫還是像素字串出問題——拿你送出的值對照那兩項就知道了。若拿到的是英文的 must be 'WIDTHxHEIGHT' or a supported size preset.,那是後端回的,代表這個值通過了平台的檢查、被後端拒絕。

請把 error.message 顯示給你的使用者

尺寸不合法時回 400,body 就是標準的錯誤結構:

{
  "error": {
    "code": "validation_error",
    "message": "Dola-Seedream-5.0-pro 的 size 只接受 1K、2K,或寬×高像素值(總像素需在 921,600 到 4,624,220 之間)"
  }
}

message 已經寫成人話,並且列出了該模型接受的簡寫與面積的上下限。2026-09-08 之前這句話只講下限:於是送出的值超過上限時,訊息反而把人導向「是不是太小了」——例如對 5.0-pro 送 "3840x2160"(8,294,400)會看到「總像素需 921,600 以上」。現在上下限會出現在同一句裡。要寫進程式請讀 /v2/models 的 constraints.minPixels / constraints.maxPixels,不要去解析訊息文字——訊息的措辭會再變,那兩個欄位不會。

把這段訊息吞掉、只對使用者顯示「生成失敗」,使用者就只剩下試誤這一條路可走。請務必把 error.message 原文透出去。

Seedream 5.0-pro 的價格依「總像素」分兩段

後端對 5.0-pro(上表最後一列的兩個 id,含 NSFW 變體)在 2,610,000 總像素設了一道價格階梯,平台自 2026-09-08 起照它計價:

總像素(寬 × 高)單價
≤ 2,610,000$0.045 / 張
> 2,610,000$0.09 / 張

門檻是包含式——總像素剛好等於 2,610,000 走便宜的那一段。其餘三個 Seedream 模型(4.0、4.5、5.0-lite,及各自的 NSFW 變體)不分段,一張一個固定價格,不受這道階梯影響。

這道階梯與上表的面積範圍是兩回事:面積範圍決定「收不收這個 size」,這道階梯決定「收多少錢」,兩者的數字沒有任何關係。2,610,000 不會出現在 constraints 裡;它反映在 /v2/models 的 pricing.from 與 pricing.to 上——5.0-pro 這兩個值不相等(0.045 與 0.09),正是「這個模型有多段費率」的意思。

只有送 寬x高 才享分段價:送簡寫一律以較貴的那一段計價

分段是用你送出的 size 字串算出來的,不是用後端實際產出的尺寸算的。

  • 送 "1024x1024" 這類 寬x高 像素字串:平台算得出總像素,套用上表的分段。
  • 送 "1K"/"2K" 這類簡寫:平台算不出總像素——簡寫實際對應的尺寸由後端決定,而且不保證等於本文件的像素換算——因此一律落到 > 2,610,000 那一段。完全不送 size 時也一樣。

所以同一張圖用兩種寫法送,價格可能不同。舉一個實際的例子:對 Dola-Seedream-5.0-pro 送 "1024x1024"(1,048,576 總像素)收 $0.045;同一個模型送 "1K" 收 $0.09——即使後端這次實際產出的是 1248×832,也就是 1,038,336 總像素,本來屬於便宜的那一段。

這是刻意保留的行為,不是缺陷:計價只認你送出的值,不會事後依後端實際產出的尺寸重算。要享分段價,請送 寬x高 像素字串。

怎麼確認這次走到哪一段:送件前拿同一組參數打 POST /v2/quote,回應的 price 就是那一段的價格(不扣額度);送件後看回應的 price_charged,或 /v2/usage 這一列的 price。平台帳本內部另外記了一個 rate_snapshot.context.image_resolution,內容是當次計價採用的總像素數,送簡寫(或不送 size)時為 null,null 就是「沒有套到分段」的判別依據;這個欄位屬於平台內部資料,不會出現在 /v2/usage 的回應裡,對帳有疑問時請向平台索取。

請求範例

curl -X POST https://appletoken.app/v2/images/generations \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "siraya/ByteDance-Seedream-4.5",
    "prompt": "清晨的港口,薄霧,暖色調",
    "n": 2,
    "size": "1024x1024"
  }'

回應

{
  "id": "img_1a2b3c4d5e6f7a8b9c0d",
  "created": 1774794546,
  "model": "siraya/ByteDance-Seedream-4.5",
  "data": [
    { "url": "https://…/tenants/…/images/….png" },
    { "url": "https://…/tenants/…/images/….png" }
  ],
  "price_charged": 0.08
}

形狀與 OpenAI 的圖片端點相容,只多一個 price_charged。

欄位說明
id這次生成的識別碼,img_ 開頭。它不是任務 id,沒有任何端點可以拿它查詢。
created生成時間,Unix 秒。
model照你送出的原字串回(ID、slug 或「上游/模型」)。實際用到的模型 ID 在回應標頭 X-AppleToken-Model-Id。
data陣列,一張圖一個元素。
data[].urlresponse_format 為 "url"(預設)時出現:平台代管的圖片網址,不是後端的網址。建議下載後存到你自己的儲存空間。
data[].b64_jsonresponse_format 為 "b64_json" 時出現:base64 編碼的影像內容。與 url 只會出現其中一個。
data[].revised_prompt選擇性出現:這次生成有改寫過提示詞時才有,沒改寫就整個欄位不存在。
price_charged本次實收金額(USD)。凍結已在回應送出前結算完畢,你不需要再查一次。
依實際回來的張數計費

按張計價的模型(/v2/models 的 pricing.unit 為 "request"):一張一個固定價格,與提示詞長度無關。結算看的是實際回來幾張,不是你請求的 n——要三張只回兩張,就只收兩張的錢。

按 token 計價的模型(pricing.unit 為 "output_tokens"):與對話同一套。送出前只能給上限估計,結算改用後端回報的實際用量重算,可能略高或略低於預扣。

兩種都可以先用 POST /v2/quote 問價,回應的 estimated 會告訴你那是精算(false)還是上限估計(true)。

參數不合法:不扣款,也不會留下任何檔案

這個端點的執行順序是固定的:模型與能力檢查 → 參數驗證 → 內嵌檔案轉存 → 凍結額度 → 生成 → 結算 → 落地成網址。

所有在本地就判定得出的失敗——尺寸、張數、輸入圖張數,以及 response_format: "url" 需要的儲存設定——全部排在扣款與檔案轉存之前。因此參數不合法時你拿到的是 400,額度一分都沒有被動到,也不會產生任何暫存檔案。

生成本身失敗(後端錯誤)時,凍結原路退回、不記帳,同樣不收費。

錯誤

狀態碼code典型情況
400validation_error缺 prompt;模型不是 image 模型;n 送給不支援的模型或超出上限;size 不在該模型的可用值;輸入圖張數超過上限、或該模型不收輸入圖;帶輸入圖時又指定了 size;內嵌檔案的型別不支援、或內容與宣告的型別不符。
401 / 403unauthorized / forbidden憑證無效、已停用或已到期。
403ip_not_allowed這把憑證設定了 IP 白名單,來源 IP 不在名單內。
402quota_exceeded預扣金額超過 available,附 needed 與 available。不會留下凍結。
403membership_required兩個 API 版本都會回(舊版自 v2.16 起):這個模型標示 VIP,開放給 VIP 會員使用(目前是名稱以 NSFW 或 uncensored 開頭的系列)。/v2 附 model、required_plan、upgrade_url;舊版只有 code、message。不會留下凍結。
404not_found模型不在憑證的允許清單,或這個模型不存在——兩者回同一種錯誤。
502 / 503upstream_error / service_unavailable後端不可用;或這個模型平台尚未開放圖片生成;或你要 response_format: "url" 但平台的圖片儲存尚未設定(此時可改用 "b64_json" 直接取得影像內容)。凍結全額退還。

素材資產

資產是你存在平台上、可以重複使用的參考素材。它存在的理由只有一個:含人臉的參考素材,後端不接受公開網址——必須先上傳成資產,再用 asset:// 引用。

可以用來做什麼

用途你提供什麼得到什麼
角色一致性一張臉部照片(資產)同一個人出現在不同場景、不同背景的多支影片裡,臉不會換人。
動作參考一段動作影片 + 一張角色圖你的角色做出那段影片裡的動作。
換臉臉部照片(資產)把指定的臉套進生成的影片。

這三件事都需要人臉,所以都必須走資產。不含人臉的參考素材(風景、物件、色調參考…)照舊用公開網址或內嵌 data URI 即可,不必上傳成資產。

名人與公眾人物的肖像一律不支援

不論是換臉還是動作參考,上傳成資產也不能用。這是後端的政策,我們無法在本地事先判斷,只能把它的拒絕訊息如實轉達。

介面與限制

項目限制
支援格式圖片 PNG/JPEG/WebP/GIF 影片 MP4/MOV/WebM 音訊 MP3/WAV/M4A
單檔大小最大 20 MB
圖片尺寸長寬都必須在 300–6000 像素之間。上傳當下就會擋下,不會白等一趟;錯誤訊息會直接告訴你這張圖是幾乘幾、該放大還是縮小。
影片規格後端要求幀率 24–60 fps、總像素 640×640~3326×2494、長寬比 0.4–2.5、單邊 300–6000 px、長度 ≥2 秒。平台不預先擋件,但被拒收時錯誤訊息會指出是哪一條,見影片參考素材的規格與拒收訊息。
重複上傳同一個帳戶重複上傳同一份檔案,會拿回同一個 id,不會產生第二份,也不會再往上游傳一次。重試是安全的。(比對的是檔案內容的 sha256,與檔名無關。)
內容驗證檔案內容必須與宣稱的型別相符——副檔名對、內容不對的檔案會被擋下。
一次生成可引用幾個Seedance-2.0/-fast/-mini:共 15 個(圖片 9、影片 3、音訊 3),不支援只給音訊,至少要有一張圖或一段影片。
Seedance-2.5:共 50 個(圖片 30、影片 10、音訊 10),支援只給音訊。
資產數量沒有平台層的上限。列表一次最多回 100 筆。
保留期資產長期保留,直到你自己刪除。與影片素材的 30 天暫存不同——那是送件當下的暫存,這是可重複使用的資產。

怎麼上傳

三步:上傳 → 等它變成可用 → 在送件時引用。

第一步:上傳

POST/v2/assets需要憑證
curl https://appletoken.app/v2/assets \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAA…",
    "name": "主角臉部參考"
  }'
欄位型別必填說明
sourcestring必填內嵌檔案的 data URI,格式 data:<mime>;base64,<內容>——與影片送件的素材欄位同一種寫法,不必學新的。
namestring選填方便你自己辨認,不影響生成結果。
typestring選填image/video/audio。不給就從檔案型別判斷,通常不需要指定。

回應是 202——已收下,但還不能用:

{
  "id": "ast_a1b2c3d4e5f6a7b8c9d0",
  "object": "asset",
  "type": "image",
  "name": "主角臉部參考",
  "status": "processing",
  "bytes": 545459,
  "mime": "image/jpeg",
  "sha256": "3f2a…",
  "reference": "asset://ast_a1b2c3d4e5f6a7b8c9d0",
  "created_at": 1788000000,
  "activated_at": null
}
回應欄位說明
id這份資產的編號。後續讀取、刪除都用它。
reference直接複製這個字串貼進 input_references 的 url 就可以了,不必自己組。
statusprocessing 處理中(還不能用於生成)/active 可用/failed 失敗。
sha256檔案指紋。用來確認「這兩次送的是不是同一份檔案」。
activated_at變成可用的時間;還在處理中時是 null。

第二步:等它變成 active

GET/v2/assets/{id}需要憑證
curl https://appletoken.app/v2/assets/ast_a1b2c3d4e5f6a7b8c9d0 \
  -H "Authorization: Bearer $APPLETOKEN_KEY"

後端是非同步處理的,一般在幾秒到十幾秒內完成。建議每 3 秒查一次,直到 status 變成 active。

沒到 active 就拿去生成,不會報錯,而是給你錯的結果

後端對還在處理中的資產不會拒絕,而是生出一支沒有正確參考到素材的影片——那種失敗最難查,錢也照扣。所以我們直接擋在送件前,回 400 並告訴你它還在處理中。

第三步:在送件時引用

{
  "model": "siraya/Seedance-2.0",
  "prompt": "這個人在海邊散步,黃昏,鏡頭緩慢推近",
  "seconds": 5,
  "resolution": "720p",
  "input_references": [
    { "type": "image", "url": "asset://ast_a1b2c3d4e5f6a7b8c9d0", "role": "reference_image" },
    { "type": "image", "url": "https://your-cdn.com/背景參考.jpg",   "role": "reference_image" }
  ]
}

同一個請求裡可以混用:含人臉的用 asset://,其餘照舊給網址。role 依型別填 reference_image/reference_video/reference_audio。

帶影片參考時 seconds 怎麼填、輸出多長,取決於 reference_mode,見影片參考與長度——這條規則與資產無關。

列出與刪除

GET/v2/assets需要憑證

列出你的資產,最新的在前。查詢參數:limit(1–100,預設 50)、status(processing/active/failed)、type(image/video/audio)。回應是 {"object":"list","data":[…]}。

DELETE/v2/assets/{id}需要憑證

永久刪除,回 204。刪掉之後就不能再用於生成,已經送出的任務不受影響。

同一份檔案不會重複佔用

上傳前我們會算檔案指紋。同一份檔案重複上傳會直接回傳既有的資產(同一個 id),不會產生第二筆,也不會再送一次後端。所以重試是安全的。

你的資產只有你看得到

列表只會回你自己的;讀取、刪除、以及送件時的 asset:// 引用,都會先確認那份資產屬於你。引用不屬於你的資產一律回 404——與「不存在」同一種回應,不會告訴你那個編號是否真的存在。

錯誤

狀態情況
400缺少 source;型別不支援;內容與宣稱的型別不符;檔案超過 20 MB;圖片尺寸不在 300–6000px;引用的資產還在 processing
404資產不存在,或不屬於你
502upstream_error:後端拒收這份素材。影片會在 message 寫明是幀率/像素數/長寬比/長度哪一條不合,並帶 details.reasons,見影片參考素材的規格與拒收訊息
503素材資產功能尚未開通。這不是你的請求有問題,請聯絡我們

GET /v2/jobs

GET/v2/jobs需要憑證

列出這張憑證送出的影片任務,最新的在前。適合用在你自己的後台列出近期送件,不必逐一保存 job_id。

Query 參數

參數預設範圍說明
limit1001–500回傳筆數。超出範圍不會被夾到邊界值,而是回 400 validation_error;非整數或非數字同樣回 400。省略時才使用預設值。
curl "https://appletoken.app/v2/jobs?limit=20" \
  -H "Authorization: Bearer $APPLETOKEN_KEY"

回應

{
  "object": "list",
  "data": [
    {
      "id": "job_2b3c4d5e6f7a8b9c0d1e",
      "object": "video",
      "model": "siraya/veo-3.1-generate-001",
      "model_id": "mdl_2b6c8g1j4s",
      "status": "processing",
      "created_at": 1756300500,
      "completed_at": null
    },
    {
      "id": "job_1a2b3c4d5e6f7a8b9c0d",
      "object": "video",
      "model": "siraya/Seedance-2.0-fast",
      "model_id": "mdl_3h8r5t0v6w",
      "status": "completed",
      "created_at": 1756300000,
      "completed_at": 1756300182,
      "content": "/v2/videos/job_1a2b3c4d5e6f7a8b9c0d/content"
    }
  ]
}

POST /v2/chat/completions

POST/v2/chat/completions需要憑證

對話生成。與影片不同,這是同步端點:沒有任務 id、沒有輪詢、沒有取件。一次呼叫要嘛拿到完整回應,要嘛拿到一段 SSE 串流。

對話沒有「送件前確認」,也不會有 quote_id

影片可以先報價、確認、再送件(見送件前確認)。對話這條端點不行——不是還沒做,是這件事對對話沒有意義。

差別在計費的本質。影片的計費量在送出前就算得準:尺寸與秒數決定 token 數,報價等於實收,所以「確認這個數字」是有內容的。對話則取決於模型實際生成多少 token,事前不可知,平台能給的只有一個上限。要你確認一個上限沒有意義——你確認了 8,000 tokens,實際可能只用掉 300。

所以對話走的是另一套:以 max_tokens 為上限預扣,生成結束後以實際用量結算,多預扣的退回。公式完全公開,見對話的預扣與串流計費。

還有一個現實理由:這條端點與 OpenAI 的 /chat/completions 相容,多一道必要的往返會讓現成的 SDK 直接不能用。

請求欄位

欄位型別必填說明
modelstring必填模型:模型 ID(mdl_…)、slug 或「上游/模型」三擇一,見遷移指南。裸名稱回 400 model_ref_required。必須是 modality: "text" 的模型(也就是對話模型;目錄裡沒有 "chat" 這個 modality)。對影片模型呼叫會回 400「{model} 不是對話模型」。
messagesarray必填對話內容,非空陣列。每個元素形如 {"role": "user", "content": "…"},兩個欄位都必須是字串。
max_tokensnumber選填輸出預算;選填。提供時請使用大於 0 的數字;與 max_completion_tokens 同時提供時後者優先。
max_completion_tokensnumber選填與 max_tokens 相同用途;平台將它正規化為 max_tokens 轉送,並用它計算預扣。兩者同時提供時此欄優先。
reasoning_effortstring選填推理強度設定,字串;各模型接受的值及效果不同,平台原樣轉送。
reasoningobject選填推理設定物件;格式及支援情況依模型而異,平台原樣轉送。
thinkingobject選填思考設定物件;格式及支援情況依模型而異,平台原樣轉送。
streambool選填預設 false。設為 true 時改回 SSE 串流。必須傳布林值 true / false——平台只做真假值判斷,任何非空字串(包含 "false")都會被當成 true 而啟用串流。
temperaturenumber選填取樣溫度。平台不檢查範圍,原樣轉給後端。

其他白名單欄位包括 top_p、n、stop、tools、user 等;支援情況依模型而異,平台不保證它們都會生效。

下列參數只對指定模型(上游 siraya)原樣轉送;送到其他模型會被靜默忽略,與過去相同,不會報錯。值不經平台檢查,效果由模型決定,用量以回應的 usage 為準。

參數模型
functionsgpt-4.1、gpt-4.1-mini、gpt-5.4、gpt-5.4-mini、gpt-5.4-nano、gpt-5.6-luna
logit_biasgpt-4.1、gpt-4.1-mini
predictiongpt-4.1、gpt-4.1-mini
repetition_penaltyByteDance-Seed-SC、NSFW-Seed-SC

推理 token 可能佔用輸出預算,讓可見回答很短,甚至成功回應中沒有可見文字。這些推理控制僅在模型支援時有效,也不能保證固定用量或成本;模型若不遵守上限,實際用量仍可能超過預扣估算。請以回應中的實際用量與費用為準。

{"model":"YOUR_MODEL_ID","messages":[{"role":"user","content":"Hello"}],"max_completion_tokens":512,"reasoning_effort":"low"}
推理模型的 max_tokens 要給多少

推理模型會先「想」再回答。推理用掉的 token 算在 max_tokens 裡,也按 completion 計費(usage.completion_tokens_details.reasoning_tokens 是其中推理的部分)。預算不夠時,推理還沒想完就被截斷:finish_reason 會是 "length",content 可能是 null 或空字串,但這筆仍然成功並計費。

實測(2026-09-25,sailflow/qwen3.8-flash-next):一句話的簡單回答用了 27–29 個推理 token,寫一首俳句並解釋用了 275 個。建議推理模型的 max_tokens 至少 512,一般任務 1024 以上,再依實際 usage 調整。推理內容若上游有提供,會放在 message.reasoning_content(串流是 delta.reasoning_content)。

不保證遵守 max_tokens 的模型

2026-10-14 實測(上游 siraya,每筆都送 max_tokens: 24;Dola-Seed-2.0-mini、Dola-Seed-2.0-lite、NSFW-Seed-2.0-mini 是 2026-10-19 實測,mini 送 max_tokens: 8、其餘送 24):下列模型回傳的 completion_tokens 超過上限,多出來的主要是推理 token。上游不保證這些模型遵守 max_tokens,實際輸出可能超過你設的上限。

計費照實際用量(回應的 usage),不以 max_tokens 為上限;預扣仍以你送的 max_tokens 計算,所以結算金額可能高於預扣。表中是實測到的最大值,不是保證的上限。

模型送出的上限實測最多的 completion_tokens其中推理
Dola-Seed-2.0-mini814531452
Dola-Seed-2.1-turbo2411851166
NSFW-Seed-2.0-Code24576553
NSFW-Dola-Seed-2.1-turbo24545522
Dola-Seed-2.0-lite24430406
ByteDance-Seed-1.824191172
NSFW-Seed-2.0-mini24186162
Dola-Seed-2.0-pro24173153
grok-4-20-reasoning24165164
NSFW-Seed-2.0-pro24128108
kimi-k2.7-code245554
grok-4.5244633
deepseek-v4-pro243332
kimi-k3242724
為什麼建議明確設定輸出預算

平台在呼叫後端前,會依你指定的輸出預算估算並預扣額度。兩個欄位都未提供時,改用模型目錄的預設或最大輸出量;若按輸出 token 計價的模型也沒有可用上限,請求可能回 400。預扣是估算,模型實際用量可能超過它。

結束後依實際用量結算並退回多預扣的額度。設定過大的預算會暫時佔用額度,讓並行請求更容易遇到 402;請按需要設定。

回應(非串流)

{
  "object": "chat.completion",
  "model": "siraya/ByteDance-Seed-1.8",
  "choices": [
    {
      "index": 0,
      "message": { "role": "assistant", "content": "向量資料庫把文字…" },
      "finish_reason": "stop"
    }
  ],
  "usage": { "prompt_tokens": 52, "completion_tokens": 180 },
  "price_charged": 0.004284
}
欄位說明
object固定為 "chat.completion"。
model照你送出的原字串回(ID、slug 或「上游/模型」)。實際用到的模型 ID 在回應標頭 X-AppleToken-Model-Id。
choices上游回幾個就原樣回幾個,每個帶自己的 index。送了 n 會原樣轉給上游,實際回幾個由模型決定。預扣按輸出上限 × n 計算(輸入只算一次),結算照上游回報的 usage,n 份輸出都計費。message.role 固定為 "assistant"。
choices[].message.reasoning_content推理內容(只有推理模型且上游有提供時才會出現)。各家上游的原始欄位名不同,平台一律改成這個名稱;串流時在 delta.reasoning_content。
choices[].finish_reason"stop":正常結束。"length":用完 max_tokens 而截斷,推理模型可能全部用在推理、content 是空的,見推理模型的 max_tokens。
usage.prompt_tokens輸入 token 數。後端有回報就用實際值,否則用平台的估算值。
usage.completion_tokens輸出 token 數,同上。
usage.prompt_tokens_details / usage.completion_tokens_detailstoken 細項,例如輸入命中快取的 cached_tokens、輸出中推理的 reasoning_tokens。平台只轉出前者的 cached_tokens、audio_tokens、text_tokens、cache_write_tokens 與後者的 reasoning_tokens、audio_tokens、accepted_prediction_tokens、rejected_prediction_tokens。
service_tier / system_fingerprint上游回報的服務層級與後端組態指紋。
prompt_filter_results / choices[].content_filter_results上游的內容審核結果:前者針對輸入(每則提示一筆),後者針對每個 choice 的輸出。
choices[].message.refusal模型拒答時的說明文字,沒有拒答時是 null。
price_charged本次實收金額(USD)。凍結已在回應送出前結算完畢,你不需要再查一次。

usage.*_details、service_tier、system_fingerprint、prompt_filter_results、content_filter_results、refusal 是上游帶回、平台照轉的欄位:有沒有、長什麼樣由模型決定,平台不保證一定出現,程式請容許它們缺席。

回應有 id(chatcmpl_…,平台自發、與上游無關)與 created(Unix 秒),但對話不是可查詢的任務——這個 id 不能拿去 /v2/videos/{id}。要對帳請用 /v2/usage 裡對應那一列(它的 job_id 就是這個 id)。

回應(串流,stream: true)

回應標頭是 Content-Type: text/event-stream; charset=utf-8,狀態碼永遠是 200。內容為一連串 data: 事件,以空行分隔:

data: {"object":"chat.completion.chunk","model":"siraya/ByteDance-Seed-1.8","choices":[{"index":0,"delta":{"content":"向量"}}]}

data: {"object":"chat.completion.chunk","model":"siraya/ByteDance-Seed-1.8","choices":[{"index":0,"delta":{"content":"資料庫"}}]}

data: {"object":"chat.completion.chunk","model":"siraya/ByteDance-Seed-1.8","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":52,"completion_tokens":180},"price_charged":0.004284}

data: [DONE]
串流一旦開始,錯誤不走 HTTP 狀態碼

SSE 的標頭在第一個 chunk 之前就送出去了,之後再改狀態碼已經來不及。因此串流開始後的任何錯誤都以事件形式送出,形狀是這樣:

data: {"object":"error","error":{"code":"upstream_error","message":"生成服務回報錯誤"}}

data: [DONE]

你的串流讀取器必須檢查每一則事件的 object:"chat.completion.chunk" 是內容,"error" 是錯誤。只看 HTTP 狀態碼的客戶端會把失敗的請求當成成功、把半截回應當成完整回應。

錯誤事件之後一樣會送 data: [DONE]。此時額度已經結算完畢——已產生的部分照收,一個 token 都沒產出才全額退還。詳見對話的預扣與串流計費。

在串流開始「之前」發生的錯誤

驗證、額度檢查、模型解析都發生在第一個 chunk 之前,因此這些錯誤走正常的 HTTP 狀態碼與 JSON 錯誤格式:

狀態碼code典型情況
400validation_error模型沒有可用的輸出上限、messages 為空、模型不是對話模型。
401 / 403unauthorized / forbidden憑證無效、已停用或已到期。
403ip_not_allowed這把憑證設定了 IP 白名單,來源 IP 不在名單內。
402quota_exceeded預扣金額超過 available,附 needed 與 available。不會留下凍結。
403membership_required兩個 API 版本都會回(舊版自 v2.16 起):這個模型標示 VIP,開放給 VIP 會員使用(目前是名稱以 NSFW 或 uncensored 開頭的系列)。/v2 附 model、required_plan、upgrade_url;舊版只有 code、message。不會留下凍結。
404not_found模型不在憑證的允許清單,或這個模型不存在——兩者回同一種錯誤。
502 / 503upstream_error / service_unavailable後端不可用,或這個模型不支援你要的模式(stream 與非串流分別檢查)。凍結全額退還。

平台代選:auto/(v2.9)

用法

把 model 寫成 auto/<模型名稱>,例如 auto/qwen3.8-flash-next。模型名稱是 GET /v2/models 裡 ref 斜線後面那一段(同一個模型由幾家上游供應,這段就相同)。目前只支援 POST /v2/chat/completions(含串流)。

{"model":"auto/qwen3.8-flash-next","messages":[{"role":"user","content":"Hello"}],"max_tokens":256}

選擇規則

自動換家

只有在還沒送出任何內容給你之前才會換家:連線失敗、5xx、429、逾時。非串流在拿到完整回應之前、串流在第一個 chunk 之前。串流一旦開始就不會換家,之後的錯誤照串流錯誤事件處理,不會重複扣款。

回應

錯誤

狀態error.code情形
404not_found模型名稱不存在,或不在憑證白名單內。
503service_unavailablereason: "no_route_available":目前沒有符合選擇規則的上游。
400auto_unsupported在影片、圖片或報價端點使用 auto/。
400fallback_unsupported帶了 "fallback": true(auto/ 不需要這個參數)。

GET /v2/me

GET/v2/me需要憑證

查詢這張憑證的狀態,以及帳戶的額度。適合用在啟動自檢、餘額看板與低餘額告警。

額度是帳戶層級的:同一個帳戶的每一把憑證查到的餘額都相同,任何一把憑證的消費都會反映在這個數字上。

{
  "id": "9f2c81ab",
  "name": "production",
  "status": "active",
  "granted": 100.0,
  "used": 12.482,
  "reserved": 0.539055,
  "available": 86.978945,
  "allowed_models": null,
  "calls": 37,
  "created_at": 1756000000,
  "last_used_at": 1756300000,
  "expires_at": null,
  "expired": false,
  "prefix": "at_live_9f2c81ab_…",
  "display": "at_live_9f2c81ab_••••••••"
}
欄位說明
granted額度上限。由 AppleToken 設定,你無法自行調整。
used已結算的累計金額。
reserved目前凍結中的金額(已預扣但尚未結算的請求)。
availablegranted − used − reserved。這是實際可用的餘額。
allowed_models模型白名單。null 表示不限制。
calls成功建立凍結的請求數——影片送件與對話請求都算,估價、查詢、取件、/v2/me 都不算。
last_used_at最後一次成功預扣的時間,同樣不因查詢而更新。
expires_at / expired到期時間與是否已過期。null 表示永不過期。
prefix / display憑證的遮罩形式,可安全顯示在介面或日誌中。

GET /v2/usage

GET/v2/usage需要憑證

取得這張憑證的帳務流水,最新的在前。這是你端對帳的權威來源。

用量以憑證為界(哪支鑰匙做了什麼),額度則是帳戶共用的。同帳戶其他憑證的消費不會出現在這裡,但會影響 /v2/me 的餘額。

Query 參數

參數預設範圍說明
limit1001–500回傳筆數。超出範圍不會被夾到邊界值,而是回 400 validation_error;非整數或非數字同樣回 400。省略時才使用預設值。

回應

{
  "object": "list",
  "data": [
    {
      "id": "80421",
      "job_id": "chatcmpl_5e1f0a9c7b3d2e4f6a8b",
      "model": "siraya/ByteDance-Seed-1.8",
      "model_id": "mdl_9p4d2n7e5f",
      "modality": "text",
      "billable": { "input_tokens": 52, "output_tokens": 180 },
      "price": 0.004284,
      "status": "settled",
      "created_at": 1756300400
    },
    {
      "id": "80418",
      "job_id": "job_1a2b3c4d5e6f7a8b9c0d",
      "model": "siraya/Seedance-2.0-fast",
      "model_id": "mdl_3h8r5t0v6w",
      "modality": "video",
      "billable": { "video_seconds": 5 },
      "price": 0.539055,
      "status": "settled",
      "created_at": 1756300182
    }
  ]
}
欄位型別說明
idstring這一列的識別碼,數字字串、單調遞增。對帳與增量拉取以它為準。
job_idstring|null這筆請求的 id,與生成回應的 id 相同:影片是 job_…(POST /v2/videos/generations)、對話是 chatcmpl_…、圖片是 img_…。只有來源不是任務的獨立音軌後處理才是 null。它就是 評價 用的 request_id。
modelstring「上游/模型」,例如 siraya/Seedance-2.0-fast,與 /v2/models 的 ref 相同。不論當初用哪種寫法送,這裡都是同一個值。v2.0 之前的舊紀錄若對不到模型,保留舊名稱。
model_idstring|null模型 ID(mdl_…),對帳請以它歸戶。v2.0 之前的舊紀錄若對不到模型則為 null。(?include=route 仍然接受,但 model 已含上游,不再需要。)
modalitystring這筆消費屬於哪一種模態(video / image / text / audio / embedding)。用它拆分不同類型的成本。
billableobject實際計費的用量:鍵是計價單位、值是數量,例如 {"video_seconds": 5} 或 {"input_tokens": 52, "output_tokens": 180}。出現哪些鍵依模型而定,請以實際存在的鍵為準。
pricefloat這一列的實收金額(USD)。
statusstring目前一律是 "settled"。
created_atint結算寫進帳本的時間,Unix 秒。排序依據就是它(最新的在前)。
這裡只有已結算的扣款,沒有預扣、也沒有退還

每一列都是一筆已完成結算的消費。額度的預扣與退還不會在這裡留下任何一列。所以:

  • 沒有 event、也沒有 hold 這種欄位。不需要(也無法)把兩筆事件配對起來。
  • 還在進行中的請求不會出現。要看目前凍結的總額,請讀 /v2/me 的 reserved。
  • 全額退還的請求(送件失敗、生成失敗、零產出的串流)不會出現——沒有扣款就沒有這一列。
  • Σ price 是這張憑證的累計消費;/v2/me 的 used 是整個帳戶的累計消費。同帳戶有多張憑證時,兩者不會相等。

請求評價

POST/v2/ratings需要憑證
GET/v2/ratings/{request_id}需要憑證

對你自己的一筆請求打 1–5 分。分數會累積成平台對各模型「效果」的評比,讓所有整合者都受益;評分不影響計費,也不會改變任何既有請求的結果。

request_id 是什麼

就是生成回應裡的 id:對話的 chatcmpl_…、圖片的 img_…、影片的 job_…。它同時是 /v2/usage 每一列的 job_id,三種模態走同一條路。

請求

{
  "request_id": "chatcmpl_5e1f0a9c7b3d2e4f6a8b",
  "score": 4,
  "comment": "回答準確,但比上次慢",
  "tags": ["accurate", "slow"]
}
欄位必填說明
request_id是要評的請求,見上方。格式不合法回 400。
score是1–5 的 JSON 整數。"5"(字串)、4.5、0、6 一律回 400 validation_error,不會被夾到邊界值。
comment否最多 500 個字。只儲存、不會在任何介面渲染;省略或 null 都可以。
tags否最多 10 個,每個符合 ^[a-z0-9_-]{1,32}$(小寫英數、底線、連字號)。省略等同空陣列。

回應 200

{
  "object": "rating",
  "request_id": "chatcmpl_5e1f0a9c7b3d2e4f6a8b",
  "score": 4,
  "comment": "回答準確,但比上次慢",
  "tags": ["accurate", "slow"],
  "created_at": 1758460000,
  "updated_at": 1758460000
}

新建與覆寫都回 200、同一形狀。GET /v2/ratings/{request_id} 回同一形狀;沒評過的請求回 404。時間戳是 Unix 秒。

規則

錯誤

狀態error.code情形
400validation_errorscore 不是 1–5 的整數、comment 超過 500 字、tags 不合格式、request_id 缺少或格式不合法。
404not_found找不到這筆請求、它尚未結算、或它不屬於你的帳戶——三種情形回應完全相同,無法用來探測別人的請求是否存在。GET 對沒評過的請求也回這個。
401unauthorized沒有帶憑證、或憑證無效/已停用/已到期。評分一律要用 API 憑證;登入前台的瀏覽器工作階段(cookie)不算,公開的評比頁也沒有任何評分入口。
409conflict這筆請求已超過 30 天,不再接受評價。

評分按模型(一家上游的一個模型)聚合,聚合結果經 GET /v2/models 每一列的 rating 公開(每個帳戶算一票,需 20 則且至少 3 個帳戶);這條端點的回應本身不含上游資訊。

額度模型

每張憑證有三個彼此獨立的數字,恆等式永遠成立:

available  =  granted  −  used  −  reserved
   │            │          │         └─ 已預扣、尚未結算的請求金額
   │            │          └─────────── 已結算的實收累計
   │            └────────────────────── 額度上限(由 AppleToken 設定)
   └─────────────────────────────────── 你現在真正能用的錢

為什麼要「預扣」

生成請求一旦送出就無法取消,成本在那一刻就已經產生。因此平台在請求離開之前先凍結預估金額,確保不會出現超支後才發現餘額不足的情況。這也代表:

額度耗盡時

{
  "error": {
    "code": "quota_exceeded",
    "message": "額度不足",
    "needed": 1.25,
    "available": 0.42,
    "granted": 100.0,
    "used": 98.33,
    "reserved": 1.25
  }
}

先看 used:如果它接近 granted,就是單純用完了,請加值。

再看 reserved:這是正在進行中的請求總和。平台會主動追蹤每一筆任務並在它結束時結算,所以 reserved 高只代表你同時跑了很多任務,或者某些任務還在生成——它會自己降下來,不需要你做任何事。若要壓低瞬時的 reserved,方向是限制併發數,以及把對話的 max_tokens 設得貼近實際需要。

reserved 最長會停留多久

任務最多追蹤 2 小時:上游暫時查不到結果(包含平台的上游額度暫時不足這類情況;你不需要、也不能綁定上游金鑰,用哪一家上游由 model 的「上游/模型」決定)時,平台會持續重試到這個時限。超過 2 小時仍沒有結果,系統會自動判定為失敗(error.code 為 generation_timeout)並把凍結全額退還,不需要你做任何事。也因此,某筆 reserved 超過 2 小時仍未消失,才是值得回報的異常,請帶著憑證 id 前綴與大約時間聯繫我們。

用量與計價公式

token 制影片模型(output_tokens)

計費量由畫面尺寸與影格數決定,與內容複雜度無關,因此可以在送出前算得完全精確:

frames  =  24 × seconds + 1                    (影格率固定 24,不可調整)
tokens  =  round( 寬 × 高 ÷ 1024 × frames )
price   =  tokens ÷ 1,000,000 × 單價

實例:Seedance-2.0-fast、5 秒、720p、16:9

寬 × 高   =  1280 × 720  =  921,600 px
每格 token =  921,600 ÷ 1024  =  900
frames    =  24 × 5 + 1  =  121
tokens    =  900 × 121  =  108,900
price     =  108,900 ÷ 1,000,000 × 4.95  =  0.539055 USD

畫面尺寸的決定順序是:

  1. 有 size → 直接採用其中的寬高。
  2. 否則以 resolution 的數字為短邊,再用 aspect_ratio 推出長邊。
可以自己算,但請用 /v2/quote 確認

上面的公式與平台實際使用的完全相同,你可以在客戶端預估費用。但 aspect_ratio 的靜默退回、模型費率分段等行為只有伺服器知道,正式報價請以 /v2/quote 為準。

秒制影片模型(video_seconds)

price  =  round(seconds) × 單價          (與解析度、比例都無關)

例如 veo-3.1-generate-001 生成 8 秒:8 × 0.90 = 7.20 USD。

對話的預扣與串流計費

對話是唯一「用量到最後才知道」的能力,因此計費分成兩步:先按上限預扣,結束後按實際結算。兩步都用同一條公式。

公式

price  =  輸入 tokens ÷ 1,000,000 × 輸入單價
       +  輸出 tokens ÷ 1,000,000 × 輸出單價

預扣:輸入 tokens = 平台估算的 prompt 長度
      輸出 tokens = 你給的 max_tokens          ← 最壞情況

結算:輸入 tokens = 實際 prompt_tokens
      輸出 tokens = 實際 completion_tokens     ← 通常遠低於 max_tokens

實例:ByteDance-Seed-1.8,max_tokens: 512

單價(取自 GET /v2/models 的 pricing.components)
  輸入  0.375 / 1M tokens
  輸出  3.00  / 1M tokens

預扣(請求開始)
  估算輸入 48 tokens  ×  0.375 ÷ 1,000,000 =  0.000018
  上限輸出 512 tokens ×  3.00  ÷ 1,000,000 =  0.001536
  reserved                                 =  0.001554 USD

結算(請求結束,實際只產生 180 tokens)
  實際輸入 52 tokens  ×  0.375 ÷ 1,000,000 =  0.000020
  實際輸出 180 tokens ×  3.00  ÷ 1,000,000 =  0.000540
  actual                                   =  0.000560 USD
  delta = actual − reserved                =  −0.000994(退回你的餘額)
兩段單價從哪裡讀

分段計價的模型,GET /v2/models 的 pricing 會多一個 components 陣列,各單位有自己的區間:

"pricing": {
  "unit": "output_tokens", "from": 3, "to": 3,
  "components": [
    { "unit": "input_tokens",  "from": 0.375, "to": 0.375 },
    { "unit": "output_tokens", "from": 3,     "to": 3     }
  ]
}

單一計價單位的模型不會有 components—— 請用「這個欄位存不存在」判斷,不要靠模型名稱猜。 unit/from/to 描述的是主要單位(輸出優先), 分段計價時不能拿它當總價估算。

每一段先取到小數第六位,再相加

不是先加總再取位。這樣 components 裡各段的價格相加會剛好等於 price,明細對得起總額;若反過來,兩者可能差到 0.000001。

預扣時的輸入 token 是估算值

平台在呼叫後端之前還拿不到真正的 tokenizer 結果,因此用一個固定、可重算的近似式估算輸入長度(約每 4 個字元算 1 個 token,以 Unicode code point 計)。中文會被略微低估、英文會被略微高估,兩者都在同一個數量級內。這個估算值只影響預扣;結算一律採用後端回報的實際 prompt_tokens。

串流有三種結束方式,三種都會結算

這是串流計費最容易出錯的地方,所以平台把三條路徑收斂到同一個結算出口:

結束方式怎麼計費帳本 basis
後端正常結束並回報用量以後端回報的 prompt_tokens / completion_tokens 結算。actual
後端正常結束但沒回報用量以平台自行累計的輸出內容長度估算後結算,不是照預扣全收。stream
你的客戶端中途斷線,或後端中途斷線以已經產生的部分結算——不整筆退、也不照預扣全收。stream
唯一全額退還的情況

一個 token 都沒產出時(後端還沒開口就斷了、或你在第一個字之前就關閉連線),凍結全額退還,帳本記 release,reason 為 client_abort 或 upstream_failed。只要吐出了半句話,那半句話就要付錢。

關掉連線不等於取消收費

你的程式中止讀取、使用者關掉分頁、負載平衡器切斷連線——這些都會讓平台停止轉發並關閉對後端的連線,但已經產生的 token 照樣計費。成本在後端產生它們的當下就發生了,斷線只是你選擇不再接收。

因此「按下停止就不用付錢」是錯的預期。若要控制成本,正確的作法是把 max_tokens 設小,而不是靠中途斷線。

串流的閒置逾時

串流沒有整體逾時——長回應本來就會跑很久。但相鄰兩個 chunk 之間有間隔上限,預設 60 秒。後端連著卻不吐字超過這個時間,平台會中止串流,並照「已產生的部分」結算。

這是刻意的:整體逾時會砍掉正常的長回應,而「連著卻不吐字」一定是出事了,必須有上限,否則連線與凍結會一起無限期懸著。你會在串流中看到一個 upstream_error 事件(訊息「生成服務逾時未回應」),接著是 data: [DONE]。

結算、退還與對帳

結算不需要你觸發

平台有背景程序主動追蹤每一筆已送出的任務,在它完成或失敗時完成結算或退還。你查不查、你的程式有沒有崩潰、有沒有重啟,都不影響帳務正確性——凍結不會因為沒人查詢而卡在 reserved 裡。

你呼叫 GET /v2/videos/{job_id} 時,若任務已有結果,也會順手走同一段結算邏輯。兩者彼此冪等:同時發生只有一次生效,不會重複扣款。

對話沒有這個問題——它是同步的,結算在回應(或串流的最後一則 chunk)送出之前就已經完成。

但你還是應該輪詢

不輪詢不會讓你的額度卡住,但會讓你不知道任務結果——影片成品必須先確認 completed 才能取件,而成品保留時間有限。所以:

  • 把送出的 job_id 持久化,程式重啟後繼續查——理由是取件,不是額度。
  • 查詢到 completed 後盡快下載並存進你自己的儲存空間。
  • 查詢到 failed 時,退還早已完成(或即將完成),你要做的是決定要不要重送。

四種結局

結局觸發額度變化在 /v2/usage 的表現
完成並結算後端回報 completed(由背景程序或你的查詢先發現) reserved 減去凍結額,used 加上實際金額出現一列,status 為 "settled"
失敗退還後端回報 failed(同上) reserved 減去凍結額,used 不變不會出現——沒有扣款就沒有這一列
送件失敗退還請求未能送達後端 同上,在送件當下立即發生不會出現——沒有扣款就沒有這一列
對話結束回應或串流結束的當下(含中途斷線) 依串流的三種結束方式結算;零產出才退還有結算才會出現一列;零產出全額退還時不會出現

實收金額怎麼決定

結算金額可能超過預扣

當實際用量高於估價(最常見於帶影片參考、實際長度大於你填的 seconds 時),結算會以實際金額計算,此時 available 可能變成負數。這是刻意的——帳要真實,不能因為超支就少記。請在估價時保守取高值,並監控 /v2/me 的 available。

對帳建議

  1. 你端以 job_id 為主鍵記錄每個影片任務,並保存送件時回傳的 price_reserved;對話則記錄回應中的 price_charged。
  2. 定期拉 GET /v2/usage。用 created_at 圈出你要對的時間區間,並記下這一批裡最大的 id——下次只處理 id 比它大的列,就不會重複也不會漏。
  3. job_id 是 job_… 的列(影片)直接對回你自己的任務紀錄,核對 price 與你保存的 price_reserved。有差額是正常的:實收以實際用量重算,可能略高或略低。
  4. 對話與圖片的列,job_id 就是回應的 id(chatcmpl_…/img_…),用它對回你自己的請求紀錄,核對 price 與回應中的 price_charged。
  5. 你送出了、卻在 /v2/usage 找不到對應列的請求,只有兩種可能:還沒結算(仍在進行中)或已全額退還(送件失敗、生成失敗、零產出的串流)。兩者都不需要你做任何事——用 GET /v2/videos/{job_id} 查狀態就能分辨。只有某筆影片任務超過 2 小時仍停在 processing,才是值得回報的異常(正常情況下最晚 2 小時內就會被系統自動判定為失敗並退還)。
  6. 核對 Σ price:帳戶下只有這一張憑證時,它應該等於 /v2/me 的 used;有多張憑證時,各憑證的 Σ price 相加才等於 used。

錯誤格式與狀態碼

{
  "error": {
    "code": "validation_error",
    "message": "example-model 的長度需在 4–15 秒之間",
    "param": "seconds",
    "reason": "out_of_range",
    "value": 20,
    "allowed": {"min": 4, "max": 15}
  }
}

/v2 的影片參數 400 validation_error 另外帶下列機器可讀的欄位。code 與 message 與以前完全相同(只新增欄位);請用新欄位分流,不要解析 message:

欄位意義
param出錯的請求欄位,例如 seconds、resolution、size、aspect_ratio、input_references、frame_images
reasonout_of_range、not_allowed、not_integer、mode_conflict、too_many
value平台收到的值
allowed可接受的值:{"min", "max"}、{"values": [...]} 或 {"max"}
mode規則來自 constraints.modes 的某個模式時才有
errors同時有多個錯誤時才有:每一項都是上面的形狀(含 message),頂層放第一項

該怎麼分支

狀態碼代表你該做的事
400請求本身有問題修參數再送。重試沒有意義。
401憑證無效檢查標頭格式與憑證是否已換發。不要自動重試。
402額度不足停止送件、通知負責人加值。若 reserved 很高,那是進行中的請求,等它們結束即可。
403憑證被停用或已過期聯繫 AppleToken。不要自動重試。
404任務不存在、不屬於你,模型不在憑證的允許清單,或路徑/方法不存在確認 job_id 與使用的憑證是否為送件時的同一張;確認 model 出現在 /v2/models 的清單裡;確認方法是 GET 或 POST。
429請求太頻繁自行做指數退避後重試。先看 error.code 分辨來源:rate_limited 是平台的每分鐘限流(帶憑證 600 次/分,未帶憑證 20 次/分);upstream_error 則是上游的節流被原樣透傳。兩種都不含 retry_after,額度也都不會被扣。
500平台內部錯誤可重試一次;持續發生請回報並附上時間與 job_id。
502 / 503 / 504生成服務暫時不可用指數退避後重試。額度不會被扣掉(串流已產出的部分除外)。
不要只看 HTTP 狀態碼

當錯誤來自後端生成服務時,平台會沿用後端的狀態碼(可能是 400–599 任一值),但 code 通常是 upstream_error。請以 error.code 判斷責任歸屬:是你的參數問題,還是服務端的問題。

兩種例外(API v2.1 起,只限 /v2):後端判定參數不合法(upstream_code 為 invalid_request_error)時,code 是 validation_error,狀態碼與 message 照舊沿用後端;取件時成品已超過保存期限(完成後 30 天)或已無法取得,回 410 content_expired。兩者都照樣附上 upstream_message/upstream_code。

這類錯誤的 error 另外帶 upstream_message:後端回的原文(供應商名稱已替換成 upstream,Request id: … 完整保留);後端有給錯誤碼時再帶 upstream_code。message 可能是平台改寫過的說明(例如「素材網址無法被下載」),要知道後端實際說了什麼,請看 upstream_message。平台自己產生的錯誤(連線失敗、憑證異常、參數驗證)沒有這兩個欄位。

後端的 401 / 403 是例外:那代表平台自己的上游憑證出了問題,與你的憑證無關,因此會被轉成 503 service_unavailable,避免你誤以為要換自己的 key。

反過來也不要只看 error.code。請以 HTTP 狀態碼判斷這是不是請求端的錯誤:目前缺少必填欄位的請求(影片缺 prompt、對話缺 messages)會回 400,但 error.code 是 upstream_error 而不是 validation_error。凡是 4xx 都請當成要修參數,不要因為 code 看起來像服務端問題就重試。

對話串流沒有狀態碼可看

stream: true 的請求一旦開始送出內容,狀態碼就固定是 200,之後的錯誤全部以 {"object":"error", …} 事件送達。請務必檢查每一則 SSE 事件的 object 欄位,詳見 對話端點。

錯誤碼總表

code狀態碼典型訊息成因與處理
validation_error400request body 不是合法 JSON檢查 JSON 語法。
validation_error400Content-Type 必須是 application/json補上或修正 Content-Type 標頭。
validation_error400request body 過大超過 24 MB。內嵌多個 data URI 檔案時尤其容易撞到,大檔案建議改用可公開存取的網址。
validation_error400request body 必須是 JSON 物件最外層必須是 {}。
validation_error400缺少 model補上 model。
validation_error400缺少 prompt補上非空的 prompt。此檢查排在凍結之前,不會留下凍結。
validation_error400未知的模型 {id}以 /v2/models 的 id 為準,不做模糊比對。
validation_error400seconds 必須是數字傳了字串(如 "5")或非有限數值。表單取值最容易在這裡出錯。
validation_error400seconds 必須大於 0調整 seconds。
validation_error400{id} 的長度需在 {min}–{max} 秒之間 / {id} 只接受 {list} 秒調整 seconds。/v2 另帶 param: "seconds" 與 allowed;範圍來自模式子集時再帶 mode。
validation_error400{id} 支援的解析度為 …改用清單內的寫法(例如 "720p";有清單的模型不接受 "720")。/v2 另帶 param: "resolution" 與 allowed.values。
validation_error400{mode} 模式下比例跟隨輸入影片,不能指定 aspect_ratio只有宣告了 constraints.modes.<模式>.aspectRatio: "inherit" 的模型才會出現(reason: "mode_conflict")。拿掉 aspect_ratio 或填 "adaptive"。
validation_error400{id} 最多接受 {n} 個…參考素材/v2 另帶 reason: "too_many",allowed.max 是上限。
validation_error400需要 seconds 才能報價…即使帶影片參考也必須提供 seconds 供估價。
validation_error400需要 resolution 或 size 才能精算 token 用量token 制模型至少要有其中之一。對 chat-* 呼叫 /v2/quote 也會撞到這則。
validation_error400無法解析的 size:{值} / 無法解析的解析度:{值}格式必須是 "1280x720"、"720p" 或 "720"。
validation_error400這組參數沒有對應的計價規則參數組合超出模型的費率涵蓋範圍。實務上很少見,因為每個模型都有一條無條件的標準費率resolution(它的費率分段以解析度為條件)。
validation_error400缺少 messages / messages[i].content 必須是字串對話端點:messages 必須是非空陣列,元素的 role 與 content 都是字串。
validation_error400max_tokens 必須是大於 0 的數字(平台以它為上限預扣額度,因此不可省略)當所選輸出預算無效,或兩欄皆未提供且模型目錄沒有可用上限時;見對話端點。
validation_error400temperature 必須是數字型別錯誤,改送 JSON number。
validation_error400{id} 不是對話模型對影片模型呼叫了 /v2/chat/completions。以 modality 分類。
model_ref_required400模型名稱必須指定上游:'{model}' 已不再接受…送了裸名稱(v2.0 起)。改用模型 ID、slug 或「上游/模型」,回應附 param: "model"、candidates: [{ id, slug, ref }] 與 docs_url,範例見遷移指南。重試沒有意義。
fallback_unsupported400fallback 已不支援:換一家上游就是換一個模型…帶了 "fallback": true(v2.0 起)。拿掉它,需要備援請自行改送另一個模型;回應附 param: "fallback" 與 docs_url。
unauthorized401憑證格式不正確檢查 Bearer 前綴與空白。
unauthorized401憑證無效憑證不存在、機密錯誤或已被換發/撤銷。
quota_exceeded402額度不足附 needed/available/granted/used/reserved。
forbidden403這組憑證已被停用停用或已過期,兩者訊息相同。
ip_not_allowed403這組憑證設定了 IP 白名單,來源 IP {ip} 不在名單內把訊息裡的 IP 加進這把憑證的 IP 白名單,或請管理者清空名單。重試沒有意義。
not_found404未知的模型 {model}憑證設有模型白名單而這個模型不在其中。與模型不存在回同一種錯誤,避免拿來探測目錄。影片與對話同樣適用。
not_found404找不到這個任務任務不存在,或屬於別張憑證。
not_found404這個任務沒有可下載的內容尚未完成,或成品已失效。
not_found404not found路徑或 HTTP 方法不存在(例如對 /v2/quote 用 PUT)。只有帶著有效憑證時才看得到這個回應:未認證的請求一律先回 401,不論路徑存不存在——否則 401 與 404 的差異會變成一個可以用來枚舉端點的訊號。
rate_limited429請求太頻繁,請稍後再試平台自己的限流,時間窗為 1 分鐘:帶 Authorization 標頭時每分鐘 600 次(以憑證分別計數),未帶憑證時每分鐘 20 次(以來源 IP 計數);/healthz 不受限。回應不含 retry_after,請用自己的指數退避。額度未被扣除。平台保留調整配額的權利,請不要把這些數字寫死進整合裡(見常見問題)。
upstream_error429(沿用後端的訊息,已清洗)上游服務對這次請求做了節流,平台原樣透傳狀態碼。這與上一列的 rate_limited 是兩種不同來源,請以 error.code 分辨。回應不含 retry_after,請用自己的指數退避。額度未被扣除。
upstream_error429這個模型目前排隊中的任務已滿,請稍後再試這個模型同時進行中的影片任務已達上限(部分上游一次只能處理少量任務)。送件前就擋下,額度未被扣除、不會建立任務。等手上的任務完成後再送,或改用同一個 family 的另一個模型。
upstream_error沿用後端生成服務回報錯誤後端拒絕了這次請求。訊息已清洗:上游原文裡的品牌字樣與主機名會被正規化或移除,也不含上游的任務 id、進價與金鑰。狀態碼不固定,請以 code 分支。
validation_error沿用後端(通常 400)(沿用後端的訊息,已清洗)後端判定參數不合法(upstream_code 為 invalid_request_error,例如首幀生成時帶了 aspect_ratio)。依 message/upstream_message 修改參數後再送,額度未被扣除。
content_expired410這支影片的成品已超過保存期限(完成後 30 天內可下載),無法再取得,請重新生成。
(平台沒存到、後端也已沒有時)這支影片的成品已無法取得(可能已超過保存期限),請重新生成。
取件(GET /v2/videos/{id}/content)時成品已超過保存期限(完成後 30 天),或平台沒能存到而後端也已經沒有這支成品。重試不會成功,請重新生成;由後端判定的會附 upstream_message(後端原文)。
upstream_error沿用後端(通常 400)素材網址無法被下載。請確認它可以匿名開啟(不需登入、沒有防盜連、未過期);若你沒有可公開的儲存空間,可以直接把檔案以 data URI 內嵌在素材欄位裡(image_url / input_references / frame_images),平台會自動代管。後端抓不到你給的素材網址時的改寫訊息,兩條出路:改善網址的可存取性,或改用內嵌 data URI。
upstream_error502暫時無法連線到生成服務,請稍後再試連線或逾時。退避重試,額度未被扣除。
upstream_error504生成服務逾時未回應串流專屬:相鄰兩個 chunk 的間隔超過上限(預設 60 秒)。已產生的部分照樣結算。
service_unavailable503這個模型目前沒有可用的費率該模型暫時沒有生效中的費率卡。請改用其他模型並回報。
service_unavailable503這個模型暫時無法使用,請聯絡管理者平台暫停了這個模型的新請求(例如這家上游當日的平台用量已達上限),通常隔天(UTC)自動恢復。已送出的影片任務照常完成。請稍後再試,或改用同一個 family 的另一個模型。
service_unavailable503這個模型目前無法使用對應的後端暫時未接上。請改用其他模型或稍後再試。
service_unavailable503這個模型不支援串流 / 不支援非串流呼叫換一種 stream 設定,或換模型。
service_unavailable503平台尚未設定上游憑證,請聯絡管理者平台端設定問題,請直接聯繫我們。
service_unavailable503平台上游憑證異常,請聯絡管理者指的是平台自己的上游憑證,不是你的 at_live_ 憑證——換發後者不會有幫助。你不需要、也不能綁定上游金鑰:用哪一家上游,由 model 的「上游/模型」決定(見遷移指南)。請直接聯絡我們。
service_unavailable503平台上游服務暫時無法使用,請稍後再試平台在這家上游的額度暫時不足,與你的帳戶餘額無關。回應不含 reason。請退避後重試;影片任務若已送出,平台會繼續追蹤,不會丟掉。
internal_error500發生未預期的錯誤平台端的問題,請回報時間與 job_id。常見的欄位型別錯誤(例如 seconds 傳成字串)在 v2 已改為 400 validation_error,若你仍收到 500,那不是你的參數造成的。
not_found404未知的模型 <provider>/<model>模型 ID、slug 或「上游/模型」不存在(見遷移指南)。與白名單外/不存在的模型是同一種回應。
service_unavailable503模型 … 目前暫停供應(reason: route_paused)你指定的模型暫停中。改用同一個 family 的另一個模型(見 /v2/models)。
service_unavailable503… 最近連續失敗,暫停接單中(reason: circuit_open)你指定的模型暫時被熔斷。稍後重試,或自行改送另一個模型。

回呼(Webhook)

影片(POST /v2/videos/generations)與音軌後處理(POST /v2/post-process,或送件時帶 post_process)進入終態時,平台會對你事先登記的網址發一個簽章過的 POST。只有 /v2;舊版 v1 API 不論怎麼帶都不會通知。圖片與對話是同步回應,沒有回呼。

回呼是提醒,不是唯一管道

回呼可能延遲、可能重複、極少數情況可能送不到(你的伺服器長時間掛掉)。取件與結果仍以 GET /v2/videos/{id} 為準;請保留低頻率的輪詢或用 GET /v2/jobs 定期對帳,當作備援。

設定:先登記,再帶入

  1. 在主控台 Webhook 頁登記網址:只接受 https、443 埠,主機必須解析到公開網路位址;最多 5 個啟用中的網址。第一次登記時會產生簽章 secret,原文只顯示一次。
  2. 送件時二選一:
    • 什麼都不帶:通知「預設」網址(第一個登記的網址自動成為預設,可在主控台更換)。客戶端程式完全不用改。
    • 帶 callback_url(必須完全等於已登記的網址)或 callback_endpoint_id(主控台上的 whe_…),指定這一筆要通知哪一個。

不在清單裡、格式不合(http、非 443、內部網段)一律回 400 validation_error,不收件、不預扣。callbackUrl(camelCase)不是平台的欄位,會被丟棄。兩階段確認時,回呼設定可以帶在第一階段(之後確認出來的那支影片會通知)或第二階段。

curl https://appletoken.app/v2/videos/generations \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "siraya/Seedance-2.0-fast", "prompt": "…", "seconds": 5,
       "callback_url": "https://example.com/appletoken/webhook"}'

事件與內容

事件何時
video.completed / video.failed影片任務完成或失敗
postprocess.completed / postprocess.failed音軌後處理完成或失敗(訂閱了影片的通知,也會收到以這支影片為來源的後處理)

內容是精簡版:只有平台自己的欄位(id、狀態、時間、error.code、取件路徑)。失敗原因的完整文字、價格與用量請用 GET 查詢。content 是要帶憑證才拿得到的 API 路徑,不是公開下載網址。

POST /appletoken/webhook HTTP/1.1
Content-Type: application/json
User-Agent: AppleToken-Webhooks/1
X-AppleToken-Event-Id: evt_3f9c2a71b0d84e5a6c1f2e90
X-AppleToken-Event-Type: video.completed
X-AppleToken-Signature: t=1791262582,v1=5b1d…e04a

{
  "event_id": "evt_3f9c2a71b0d84e5a6c1f2e90",
  "type": "video.completed",
  "created_at": 1791262582,
  "data": {
    "id": "job_1a2b3c4d5e6f7a8b9c0d",
    "object": "video",
    "status": "completed",
    "completed_at": 1791262579,
    "error": null,
    "content": "/v2/videos/job_1a2b3c4d5e6f7a8b9c0d/content"
  }
}

驗證簽章

X-AppleToken-Signature 的格式是 t=<unix 秒>,v1=<hex>,v1 = HMAC-SHA256(secret, "<t>." + 原始 body)。請用收到的原始位元組計算、用定時比較,並拒絕時間差超過 5 分鐘的通知(防重放)。輪換 secret 後的 24 小時內,標頭會帶兩個 v1(新舊各一),符合任一即可。

import crypto from 'node:crypto';

// rawBody:收到的原始位元組(不要先 JSON.parse 再 stringify)
function verify(rawBody, header, secret) {
  const t = Number(/t=(\d+)/.exec(header)?.[1]);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return [...header.matchAll(/\bv1=([0-9a-f]{64})/g)].some((m) =>
    crypto.timingSafeEqual(Buffer.from(m[1]), Buffer.from(expected)));
}
import hashlib, hmac, re, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    m = re.search(r't=(\d+)', header)
    if not m or abs(time.time() - int(m.group(1))) > 300:
        return False
    expected = hmac.new(secret.encode(), m.group(1).encode() + b'.' + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(v, expected) for v in re.findall(r'v1=([0-9a-f]{64})', header))

送達、重試與去重

完整範例

影片:估價 → 送件 → 取件

以下三個範例做同一件事:估價 → 送件 → 輪詢到結束 → 下載成品。輪詢是為了知道結果並取件;額度的結算由平台在背景完成,不靠這段程式碼。

#!/usr/bin/env python3
"""AppleToken 最小客戶端 — 只用標準庫。"""
import json, os, time, urllib.request, urllib.error

BASE = "https://appletoken.app/v2"
KEY  = os.environ["APPLETOKEN_KEY"]


def call(path, payload=None, raw=False):
    data = json.dumps(payload).encode() if payload is not None else None
    req = urllib.request.Request(BASE + path, data=data,
                                 method="POST" if data else "GET")
    req.add_header("Authorization", "Bearer " + KEY)
    if data:
        req.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=120) as r:
            return r.read() if raw else json.loads(r.read())
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "replace")
        try:
            err = json.loads(body)["error"]
        except Exception:
            err = {"code": "http_%d" % e.code, "message": body[:200]}
        raise RuntimeError("%s: %s" % (err.get("code"), err.get("message")))


def generate(prompt, model="siraya/Seedance-2.0-fast", seconds=5, resolution="720p", **extra):
    params = {"model": model, "seconds": seconds, "resolution": resolution, **extra}

    quote = call("/quote", params)
    print("預估費用 %.6f USD,餘額 %.6f" % (quote["price"], quote["available"]))

    job = call("/videos/generations", {**params, "prompt": prompt})
    job_id = job["id"]
    print("已送出 %s,凍結 %.6f" % (job_id, job["price_reserved"]))

    # 輪詢是為了拿到結果與取件;額度另有平台的背景結算保底
    deadline = time.time() + 900
    time.sleep(10)
    while time.time() < deadline:
        st = call("/videos/" + job_id)
        if st["status"] == "completed":
            print("完成,實收 %.6f USD" % st.get("price_charged", 0))
            return job_id
        if st["status"] == "failed":
            raise RuntimeError("生成失敗,額度已全額退還:" + job_id)
        time.sleep(8)
    raise TimeoutError("等待逾時,請保留 job id 稍後續查:" + job_id)


if __name__ == "__main__":
    jid = generate("清晨的漁港,漁船緩緩進港", seconds=5, aspect_ratio="16:9")
    with open("output.mp4", "wb") as f:
        f.write(call("/videos/%s/content" % jid, raw=True))
    print("已存成 output.mp4")
// AppleToken 最小客戶端 — Node 18+ 內建 fetch,無需任何套件。
import { writeFile } from 'node:fs/promises'

const BASE = 'https://appletoken.app/v2'
const KEY  = process.env.APPLETOKEN_KEY

async function call(path, payload, raw = false) {
  const res = await fetch(BASE + path, {
    method: payload ? 'POST' : 'GET',
    headers: {
      Authorization: `Bearer ${KEY}`,
      ...(payload ? { 'Content-Type': 'application/json' } : {}),
    },
    body: payload ? JSON.stringify(payload) : undefined,
  })
  if (!res.ok) {
    const { error } = await res.json().catch(() => ({ error: {} }))
    throw new Error(`${error.code ?? res.status}: ${error.message ?? res.statusText}`)
  }
  return raw ? Buffer.from(await res.arrayBuffer()) : res.json()
}

const sleep = s => new Promise(r => setTimeout(r, s * 1000))

async function generate(prompt, opts = {}) {
  const params = { model: 'siraya/Seedance-2.0-fast', seconds: 5, resolution: '720p', ...opts }

  const quote = await call('/quote', params)
  console.log(`預估費用 ${quote.price} USD,餘額 ${quote.available}`)

  const job = await call('/videos/generations', { ...params, prompt })
  console.log(`已送出 ${job.id},凍結 ${job.price_reserved}`)

  // 輪詢是為了拿到結果與取件;額度另有平台的背景結算保底
  const deadline = Date.now() + 900_000
  await sleep(10)
  while (Date.now() < deadline) {
    const st = await call(`/videos/${job.id}`)
    if (st.status === 'completed') {
      console.log(`完成,實收 ${st.price_charged} USD`)
      return job.id
    }
    if (st.status === 'failed') throw new Error(`生成失敗,額度已退還:${job.id}`)
    await sleep(8)
  }
  throw new Error(`等待逾時,請保留 job id 稍後續查:${job.id}`)
}

const id = await generate('清晨的漁港,漁船緩緩進港', { aspect_ratio: '16:9' })
await writeFile('output.mp4', await call(`/videos/${id}/content`, undefined, true))
console.log('已存成 output.mp4')
# 1) 估價
curl -s https://appletoken.app/v2/quote \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"siraya/Seedance-2.0-fast","seconds":5,"resolution":"720p"}'

# 2) 送件,取出 job id
JOB=$(curl -s https://appletoken.app/v2/videos/generations \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"siraya/Seedance-2.0-fast","prompt":"清晨的漁港,漁船緩緩進港",
       "seconds":5,"resolution":"720p","generate_audio":true}' \
  | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')
echo "job: $JOB"

# 3) 輪詢到終局狀態(為了知道結果,不是為了釋放額度)
while true; do
  ST=$(curl -s "https://appletoken.app/v2/videos/$JOB" \
       -H "Authorization: Bearer $APPLETOKEN_KEY" \
       | python3 -c 'import sys,json; print(json.load(sys.stdin)["status"])')
  echo "status: $ST"
  [ "$ST" = "completed" ] && break
  [ "$ST" = "failed" ] && { echo "生成失敗,額度已退還"; exit 1; }
  sleep 8
done

# 4) 取件
curl -f "https://appletoken.app/v2/videos/$JOB/content" \
  -H "Authorization: Bearer $APPLETOKEN_KEY" -o output.mp4

影片:多張報價單 → 逐一確認送出

同時對幾個模型詢價,再逐一確認送出。重點有三個:每張報價單各自保存自己的 quote_id、確認時各自帶自己的那一張,以及額度到確認的當下才凍結——所以 402 只會出現在確認這一步,而且撞到之後那張報價單仍然可以用。

#!/usr/bin/env python3
"""同時開多張報價單 → 逐一確認送出 — 只用標準庫。"""
import json, os, urllib.request, urllib.error

BASE = "https://appletoken.app/v2"
KEY  = os.environ["APPLETOKEN_KEY"]


def call(path, payload=None):
    """回傳 (狀態碼, 回應內容);錯誤不丟例外,因為 402 要當成正常分支處理。"""
    data = json.dumps(payload).encode() if payload is not None else None
    req = urllib.request.Request(BASE + path, data=data,
                                 method="POST" if data else "GET")
    req.add_header("Authorization", "Bearer " + KEY)
    if data:
        req.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=120) as r:
            return r.status, json.loads(r.read())
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "replace")
        try:
            return e.code, json.loads(body)
        except Exception:
            return e.code, {"error": {"code": "http_%d" % e.code, "message": body[:200]}}


PROMPT = "清晨的漁港,漁船緩緩進港"
CANDIDATES = [
    {"model": "siraya/Seedance-2.0-fast", "seconds": 5, "resolution": "720p"},
    {"model": "siraya/Seedance-2.0",      "seconds": 5, "resolution": "1080p"},
]

# 1) 一次開多張報價單。彼此獨立,不會互相覆蓋;這一步不凍結任何額度。
slips = []
for params in CANDIDATES:
    status, body = call("/quote", dict(params, prompt=PROMPT))
    if status != 200:
        print("報價失敗 %s:%s" % (params["model"], body["error"]["message"]))
        continue
    slips.append({"model": params["model"],
                  "quote_id": body["quote_id"],      # 各自保存,確認時要帶對的那一張
                  "price": body["price"],
                  "expires_at": body["expires_at"]})  # 各自 15 分鐘
    print("%s 報價 %.6f USD,quote_id=%s,有效到 %s"
          % (params["model"], body["price"], body["quote_id"], body["expires_at"]))

# 2) 逐一確認送出。額度到這一刻才凍結,所以 402 只會在這裡出現。
jobs, retryable = [], []
for s in slips:
    status, body = call("/videos/generations",
                        {"quote_id": s["quote_id"], "confirm": True})
    if status == 200:
        jobs.append(body["id"])
        print("已送出 %s → %s(凍結 %.6f)"
              % (s["model"], body["id"], body["price_reserved"]))
        continue

    err = body["error"]
    if status == 402:
        # 餘額不足:額度沒被凍結,這張報價單退回可用狀態。
        # 加值後拿同一個 quote_id 再確認一次即可,不必重新報價。
        retryable.append(s)
        print("%s 餘額不足(需要 %s,可用 %s);quote_id 仍可用:%s"
              % (s["model"], err.get("needed"), err.get("available"), s["quote_id"]))
    elif status == 400:
        # 逾期或已取消:這張不能再用了,要重新報價
        print("%s 這張報價單不能用了:%s" % (s["model"], err["message"]))
    else:
        # 上游拒收等其他失敗:凍結已原路退回,報價單也退回可用狀態,可重試同一張
        retryable.append(s)
        print("%s 送出失敗(%s):%s;稍後可重試同一張"
              % (s["model"], err["code"], err["message"]))

print("已送出 %d 支,還有 %d 張報價單可以重試" % (len(jobs), len(retryable)))
# 之後對每個 job id 輪詢 GET /v2/videos/{id},做法與上一個範例相同
// 同時開多張報價單 → 逐一確認送出。Node 18+ 內建 fetch,無需任何套件。
const BASE = 'https://appletoken.app/v2'
const KEY  = process.env.APPLETOKEN_KEY

// 回傳 { status, body };錯誤不丟例外,因為 402 要當成正常分支處理。
async function call(path, payload) {
  const res = await fetch(BASE + path, {
    method: payload ? 'POST' : 'GET',
    headers: {
      Authorization: `Bearer ${KEY}`,
      ...(payload ? { 'Content-Type': 'application/json' } : {}),
    },
    body: payload ? JSON.stringify(payload) : undefined,
  })
  return { status: res.status, body: await res.json() }
}

const PROMPT = '清晨的漁港,漁船緩緩進港'
const CANDIDATES = [
  { model: 'siraya/Seedance-2.0-fast', seconds: 5, resolution: '720p' },
  { model: 'siraya/Seedance-2.0',      seconds: 5, resolution: '1080p' },
]

// 1) 一次開多張報價單。彼此獨立,不會互相覆蓋;這一步不凍結任何額度。
const slips = []
for (const params of CANDIDATES) {
  const { status, body } = await call('/quote', { ...params, prompt: PROMPT })
  if (status !== 200) {
    console.log(`報價失敗 ${params.model}:${body.error.message}`)
    continue
  }
  // 各自保存自己的 quote_id 與到期時間,確認時要帶對的那一張
  slips.push({ model: params.model, quoteId: body.quote_id,
               price: body.price, expiresAt: body.expires_at })
  console.log(`${params.model} 報價 ${body.price} USD,quote_id=${body.quote_id},有效到 ${body.expires_at}`)
}

// 2) 逐一確認送出。額度到這一刻才凍結,所以 402 只會在這裡出現。
const jobs = []
const retryable = []
for (const s of slips) {
  const { status, body } = await call('/videos/generations',
                                      { quote_id: s.quoteId, confirm: true })
  if (status === 200) {
    jobs.push(body.id)
    console.log(`已送出 ${s.model} → ${body.id}(凍結 ${body.price_reserved})`)
    continue
  }

  const err = body.error
  if (status === 402) {
    // 餘額不足:額度沒被凍結,這張報價單退回可用狀態。
    // 加值後拿同一個 quote_id 再確認一次即可,不必重新報價。
    retryable.push(s)
    console.log(`${s.model} 餘額不足(需要 ${err.price},可用 ${err.available});quote_id 仍可用:${s.quoteId}`)
  } else if (status === 400) {
    // 逾期或已取消:這張不能再用了,要重新報價
    console.log(`${s.model} 這張報價單不能用了:${err.message}`)
  } else {
    // 上游拒收等其他失敗:凍結已原路退回,報價單也退回可用狀態,可重試同一張
    retryable.push(s)
    console.log(`${s.model} 送出失敗(${err.code}):${err.message};稍後可重試同一張`)
  }
}

console.log(`已送出 ${jobs.length} 支,還有 ${retryable.length} 張報價單可以重試`)
// 之後對每個 job id 輪詢 GET /v2/videos/{id},做法與上一個範例相同

對話:串流讀取

重點只有兩個:每則事件都要檢查 object(錯誤不走狀態碼),以及從最後一則 chunk 取出 price_charged。

#!/usr/bin/env python3
"""AppleToken 對話串流 — 只用標準庫。"""
import json, os, urllib.request

BASE = "https://appletoken.app/v2"
KEY  = os.environ["APPLETOKEN_KEY"]


def chat_stream(messages, model="siraya/ByteDance-Seed-1.8", max_tokens=512):
    body = json.dumps({
        "model": model, "messages": messages,
        "max_tokens": max_tokens, "stream": True,
    }).encode()
    req = urllib.request.Request(BASE + "/chat/completions", data=body, method="POST")
    req.add_header("Authorization", "Bearer " + KEY)
    req.add_header("Content-Type", "application/json")

    charged = None
    with urllib.request.urlopen(req, timeout=300) as r:
        for raw in r:                      # SSE 一行一行讀
            line = raw.decode("utf-8").strip()
            if not line.startswith("data: "):
                continue
            payload = line[6:]
            if payload == "[DONE]":
                break
            ev = json.loads(payload)
            # 一定要檢查 object:串流開始後錯誤不走 HTTP 狀態碼
            if ev.get("object") == "error":
                raise RuntimeError("%s: %s" % (ev["error"]["code"], ev["error"]["message"]))
            delta = ev["choices"][0]["delta"].get("content")
            if delta:
                print(delta, end="", flush=True)
            if "price_charged" in ev:
                charged = ev["price_charged"]
    print()
    return charged


if __name__ == "__main__":
    cost = chat_stream([{"role": "user", "content": "用三句話說明什麼是向量資料庫"}])
    print("實收 %.6f USD" % cost)
// AppleToken 對話串流 — Node 18+ 內建 fetch,無需任何套件。
const BASE = 'https://appletoken.app/v2'
const KEY  = process.env.APPLETOKEN_KEY

async function chatStream(messages, opts = {}) {
  const res = await fetch(`${BASE}/chat/completions`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ model: 'siraya/ByteDance-Seed-1.8', max_tokens: 512, ...opts, messages, stream: true }),
  })
  // 串流「開始之前」的錯誤仍然是正常的 HTTP 狀態碼加 JSON
  if (!res.ok) {
    const { error } = await res.json().catch(() => ({ error: {} }))
    throw new Error(`${error.code ?? res.status}: ${error.message ?? res.statusText}`)
  }

  const reader = res.body.getReader()
  const decoder = new TextDecoder()
  let buffer = ''
  let charged = null

  for (;;) {
    const { value, done } = await reader.read()
    if (done) break
    buffer += decoder.decode(value, { stream: true })

    // SSE 事件可能被切在任何位置,只處理已經看到換行的完整行
    let nl
    while ((nl = buffer.indexOf('\n')) >= 0) {
      const line = buffer.slice(0, nl).trim()
      buffer = buffer.slice(nl + 1)
      if (!line.startsWith('data: ')) continue
      const payload = line.slice(6)
      if (payload === '[DONE]') return charged
      const ev = JSON.parse(payload)
      // 一定要檢查 object:串流開始後錯誤不走 HTTP 狀態碼
      if (ev.object === 'error') throw new Error(`${ev.error.code}: ${ev.error.message}`)
      const delta = ev.choices[0].delta.content
      if (delta) process.stdout.write(delta)
      if (ev.price_charged !== undefined) charged = ev.price_charged
    }
  }
  return charged
}

const cost = await chatStream([{ role: 'user', content: '用三句話說明什麼是向量資料庫' }])
console.log(`\n實收 ${cost} USD`)
# 串流:-N 關掉緩衝,才看得到逐段輸出
curl -N https://appletoken.app/v2/chat/completions \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"siraya/ByteDance-Seed-1.8","max_tokens":512,"stream":true,
       "messages":[{"role":"user","content":"用三句話說明什麼是向量資料庫"}]}'

# 非串流:一次拿完整回應,price_charged 就在裡面
curl -s https://appletoken.app/v2/chat/completions \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"siraya/ByteDance-Seed-1.8","max_tokens":256,
       "messages":[{"role":"user","content":"用一句話說明什麼是向量資料庫"}]}'

上線檢查清單

正確性

對話

韌性

成本控制

常見問題

可以取消已送出的任務嗎?

不行。成本在送出當下就已產生,因此沒有取消端點。請在送件前用 /v2/quote 確認參數與費用。對話串流可以中止讀取,但已產生的部分照樣計費。

有 webhook 或回呼通知嗎?

有,只限 /v2:在主控台的 Webhook 頁登記網址後,影片與後處理完成或失敗時平台會主動通知,見回呼(Webhook)。舊版 v1 API 沒有回呼,請求中的 callbackUrl 類欄位會被丟棄。回呼只是提醒,取件仍以輪詢或 GET 為準。另外要澄清一個常見誤解:輪詢不是結算的前提。平台有背景程序負責結算與退還,你不查詢也不會有額度卡住。

生成失敗要付錢嗎?

影片任務失敗時凍結全額退還,used 不受影響;退還由平台自動完成,不需要你輪詢到 failed。

對話則要看有沒有產出:一個 token 都沒產出才全額退還;只要吐出了內容(即使只有半句話、即使後來斷線),那部分照實計費。

為什麼對話一定要給 max_tokens?

因為額度必須在呼叫後端之前凍結,而對話的用量到最後一刻才知道。max_tokens 是「這次最多產出多少」的上限,平台用它算出最壞情況的價格先凍結起來。結算一律以實際用量重算,差額當場退回,所以你付的永遠是實際用量。詳見對話的預扣與串流計費。

串流到一半我把連線關掉,會被收多少?

收「已經產生的部分」。不會整筆退還,也不會照 max_tokens 的預扣全收。差額在斷線的當下就結算完畢。要少花錢,正確作法是把 max_tokens 設小。

對話可以先估價嗎?

/v2/quote 只支援影片模型。對話的費用取決於實際產生多少 token,事前無法算準——但預扣公式是公開的,你可以自己算出「這次最多會花多少」,那正是平台凍結的金額。

可以同時送多個任務嗎?

可以,只要 available 足以覆蓋所有請求的預扣總和。每個請求各持有一筆獨立凍結。建議自行限制併發數,並對 429 做退避。

為什麼 calls 的數字比我的呼叫次數少?

calls 只計算成功建立凍結的請求——影片送件與對話請求都算,估價、查詢、下載、/v2/me 都不算。

用的是哪一家上游的模型?

就是你在 model 裡指定的那一家。v2.0 起上游是公開的:model 寫「上游/模型」(例如 siraya/Seedance-2.0-fast),或寫那個模型的 ID/slug;GET /v2/models 每一列都有 provider 與 upstream_model。平台不會替你換家——換一家上游就是換一個模型,售價與指標也各自不同,要換請自己改 model(見遷移指南)。模型的 ID、slug 與「上游/模型」永久不變;模型下架後會從 /v2/models 消失,呼叫它回 404。

價格會變嗎?

可能會。請從 /v2/models 讀取價格而非寫死,並以 /v2/quote 作為每次影片交易的報價依據。價格調整會事先通知,且既有的請求一律以送出當下生效的費率結算。

有速率限制嗎?

有。平台以 1 分鐘為時間窗做限流:帶 Authorization 標頭的請求是每分鐘 600 次,以憑證分別計數;未帶憑證的請求是每分鐘 20 次,以來源 IP 計數。/healthz 不受限。超過時回 429,error.code 是 rate_limited,訊息是「請求太頻繁,請稍後再試」,不會帶 retry_after。平台保留調整配額的權利,請不要把這些數字寫死進你的整合裡,該做的指數退避照樣要做。

429 有兩種來源,請用 error.code 分辨:rate_limited 是上面說的平台限流;upstream_error 則是上游服務的節流被原樣透傳。兩者的回應都不會帶 retry_after,額度也都不會被扣——請用自己的指數退避重試。

要留意的是整合寫錯造成的失控迴圈——那種情況下額度會在幾秒內燒光,而通常要等到帳單才會發現。請自行限制併發與重試次數。/v2/* 的每一個端點都需要憑證,GET /v2/models 也不例外。

額度不足不會回 429,而是回 402 quota_exceeded,並附上這次需要的金額與當下的可用餘額。那不是節流,重試也不會變好——請先加值,或降低同時進行的請求數。

版本紀錄

v2.0(2026-09-27)· 破壞性變更

model 必須指定上游:改用模型 ID、slug 或「上游/模型」,裸名稱回 400 model_ref_required;GET /v2/models 改成一列一個模型,default_route/routes[] 已移除;fallback 已不支援。路徑改為 /v2;/v1 照舊可用、行為不變。見遷移指南。

版本歷程已移到獨立頁面

每一版改了什麼、哪些整合需要跟著調整,都列在版本歷程。那一頁由後台直接維護,發版當天就會更新,不必等這份文件重新發布;目前的 API 版本與文件版本也標示在那裡。

需要協助?

回報問題時請附上時間(含時區)、job_id、憑證 id 前綴(例如 at_live_9f2c81ab_…)與收到的 error.code。對話請求的 job_id 就是回應的 id(chatcmpl_…);找不到的話請附 /v2/usage 裡對應那一列的 id 與 created_at。請勿在任何訊息中貼出完整憑證。