AppleTokenAPI 文件
回主控台 1.2

AppleToken API

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

這個 API 是什麼

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

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

你只會看到 AppleToken 的命名與識別碼。模型名稱、任務 id、成品網址都是本平台自有的——底層用哪一家、哪一代模型,是我們的實作細節,不會出現在任何回應裡,也不需要你關心。

三件事值得先知道

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

五分鐘上手

1. 取得憑證

向 AppleToken 索取一組 API 憑證,格式長這樣:

at_live_9f2c81ab_kJ3nR7xQwZ0pLm5TfYbHs2VdEcAu1NgX

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

2. 看看有哪些模型

取得你這張憑證可以使用的模型與現行價格:

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

3. 先問價格

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

4. 送出任務

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

5. 輪詢到完成

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

6. 取件

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

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

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

對話是同步的,沒有任務 id、不需要輪詢:

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

max_tokens 不可省略——平台以它為上限預扣額度。完整契約見 POST /v1/chat/completions

認證

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

Authorization: Bearer at_live_9f2c81ab_kJ3nR7xQwZ0pLm5TfYbHs2VdEcAu1NgX
格式

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

憑證結構

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

保管建議

憑證失效時的行為

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

/v1/* 不使用 cookie,因此沒有 CSRF 相關要求;也不綁定來源 IP。

請求與回應慣例

請求

回應

錯誤

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

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

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

模型目錄

可用的模型與價格會隨時增減,所以不列在這份文件裡—— 這裡只講不會變的協定規則。你需要知道兩件事:id 是你在請求裡寫的字串, 命名穩定、不會因為底層更換而變動;modality 決定它走哪一組端點—— video/v1/videos/generationsimage/v1/images/generationstext/v1/chat/completions, 互相呼叫會得到 400

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

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

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

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

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

目錄可能是空的

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

兩種計價單位

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

單位怎麼算
output_tokens 影片依畫面尺寸與影格數換算成 token 量;文字直接用輸出的 token 數。再乘上單價。公式在此
video_seconds 秒數 × 單價。與解析度無關。

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

多段費率

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

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

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

三種輸入模式

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

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

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

參考素材的數量上限

模型參考素材總數組成上限純音訊參考
Seedance-2.550 圖片 30、影片 10、音訊 10支援
Seedance-2.0-fast-mini15 圖片 9、影片 3、音訊 3不支援,至少要有一張圖片或一段影片
有宣告 constraints.maxReferences 的模型以該欄位為準 總數以該欄位為準;上表兩組的組成上限仍然適用
其他模型沒有平台層的數量限制,但上游仍可能拒絕,建議先用 /v1/quote 確認
超量會在送件當下被擋下

上表列出的模型,平台會在送出前檢查數量並回 400 validation_error不會凍結額度、也不會產生任務。訊息會直接說明是哪一類超量,例如「最多接受 30 個圖片參考素材」。

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

計數看的是每個項目的 type沒有填 type 的項目計入總數,但不計入任何一種組成上限——這類項目平台不替你推斷,能不能用由上游決定。

影片參考與長度

input_referencestype: "video" 的項目時,成品長度由參考影片決定,你給的 seconds 不會改變輸出長度。

圖片素材的格式要求

含真人臉孔的參考素材

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

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

參數限制總表

長度 seconds

解析度 resolution 與尺寸 size

size 一樣要通過解析度檢查

只給 size 而不給 resolution 時,平台會把 size短邊換算出來,再套用該模型的解析度清單檢查。例如對只支援 480p / 720p 的模型送 "1920x1080"(短邊 1080),會直接回 400「{model} 支援的解析度為 480p、720p」,不會送到後端、也不會產生任何費用。

兩個細節值得注意:一是條件式費率是看 resolution 而不是 size—— 若某個模型的高費率條件寫的是解析度門檻,只給 size 會落在預設費率; 哪些模型有幾段費率請看 模型目錄(需登入)或 GET /v1/modelspricing。 二是短邊必須剛好等於清單裡的某個值(720p 清單接受短邊 720,不接受 700 或 768)。除非確有精確畫面需求,建議只用 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
未知的比例會被靜默改成 16:9

填入清單以外的值(例如 "2:1")不會報錯,系統會退回預設的 16:9以此計價。請只使用上表的值,並在送件前用 /v1/quote 確認算出來的價格符合預期。

音訊 generate_audio

對話參數

欄位限制不符合時
messages非空陣列,每個元素的 rolecontent 都必須是字串 400「缺少 messages」或「messages[i].content 必須是字串」
max_tokens必填,必須是大於 0 的數字(小數會被無條件捨去成整數) 400「max_tokens 必須是大於 0 的數字(平台以它為上限預扣額度,因此不可省略)」
temperature選填,必須是數字;平台不檢查範圍,原樣轉給後端 400「temperature 必須是數字」
stream選填,必須是布林值 true / false。平台只做真假值判斷,因此任何非空字串(包含 "false")都會被當成 true 而啟用串流 不報錯,但可能走到你沒預期的模式

GET /v1/models

GET/v1/models需要憑證

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

不帶任何參數時,行為與這個端點原本的樣子完全相同——回傳同一份完整清單,既有整合不需要修改。以下三個查詢參數都是選填,讓你在目錄變大之後可以自行篩選。

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

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

Query 參數

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

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

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

回應

{
  "data": [
    {
      "id": "Seedance-2.0-fast",
      "label": "Video Fast",
      "description": "生成較快,成本較低",
      "modality": "video",
      "constraints": {
        "minSeconds": 4,
        "maxSeconds": 15,
        "resolutions": ["480p", "720p"],
        "audio": true
      },
      "pricing": { "unit": "output_tokens", "from": 4.95, "to": 4.95 }
    },
    {
      "id": "veo-3.1-generate-001",
      "label": "Video Cinema",
      "description": "電影感畫質,固定 4/6/8 秒",
      "modality": "video",
      "constraints": {
        "allowedSeconds": [4, 6, 8],
        "resolutions": ["720p", "1080p"],
        "audio": true
      },
      "pricing": { "unit": "video_seconds", "from": 0.9, "to": 0.9 }
    },
    {
      "id": "ByteDance-Seed-1.8",
      "label": "Chat Pro",
      "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     }
        ]
      }
    }
  ]
}
欄位型別說明
idstring模型識別,用於所有請求的 model 欄位。
labelstring顯示名稱,適合直接放進你的 UI。
descriptionstring一句話定位說明。
modalitystring"video""image""text""audio""embedding" 其中之一(沒有 "chat" 這個值——對話模型的 modality"text")。決定這個模型走哪一組端點:"video"/v1/videos/generations"image"/v1/images/generations"text"/v1/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.maxOutputTokens / constraints.defaultMaxOutputTokensint輸出 token 的上限,以及你沒有指定 max_tokens 時預扣所依據的預設值。對話模型才有。
constraints.sizesstring[]允許的 size 值,含 2K 這類簡寫。僅圖片模型有。
constraints.minPixels / constraints.maxPixelsint兩個一起出現,代表這個模型除了 sizes 列舉的值之外,還接受任意 寬x高 像素字串,只要寬 × 高的總像素落在這個閉區間內。沒有這兩個欄位 = 只能用 sizes 裡的值。僅圖片模型有,見圖片端點
pricing.unitstring"output_tokens""video_seconds"
pricing.from / tofloat同一個計價單位(pricing.unit)之下的價格區間(USD):from 是最低單價、to 是最高單價。兩者相等表示單一費率,不等表示這個模型有多段費率;實際落在哪一段由請求的條件決定,用 /v1/quote 可以確認。它們不是「輸入單價/輸出單價」——輸入與輸出各自的單價請讀 pricing.components
pricing.componentsarray有多個計價單位的模型(例如輸入與輸出各有單價)才有,每個元素是 { "unit", "from", "to" },各單位有自己的區間。單一計價單位的模型不會有這個欄位,請用「這個欄位存不存在」判斷。詳見對話的預扣與串流計費
解析欄位時請採寬鬆策略

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

內嵌檔案自動轉存

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

不計費

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

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

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

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

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

POST /v1/quote

POST/v1/quote需要憑證

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

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

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

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

請求欄位

欄位型別必填說明
modelstring必填模型 id。
secondsnumber必填長度,必須 > 0 且為數字型別。
resolutionstring視模型token 制模型需要它(或 size)才能算量。
sizestring選填"1280x720"。提供時優先於 resolution 決定尺寸,但仍要通過解析度檢查。
aspect_ratiostring選填預設 "16:9"
input_referencesarray選填只用來判斷是否含影片參考。部分模型的費率會因此跳到另一段(見各模型的 pricing),內容不會被送到後端。
數量仍然會驗:超過上限的話估價就會回 400,不必等到送件,見參考素材的數量上限
上表是影片模型的欄位;圖片模型送的是圖片端點那一組

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

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

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

回應:兩條路徑

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

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

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

路徑一:純比價

{
  "model": "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": "imagen-4-fast",
  "unit": "request",
  "billable": { "request": 2 },
  "price": 0.12,
  "estimated": false,
  "available": 20.0
}

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

路徑二:報價單(帶了 prompt

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

{
  "model": "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": "Seedance-2.0-fast", "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_urlframe_imagesinput_references 在這裡的值,是轉存之後、實際會送給後端的網址不是你原本送來的 data URI。這正是要你核對的東西:你看到的網址,就是後端會去下載的那一條。
resolved.seed_note一句說明,告訴你這次有沒有帶 seed,以及那對結果的影響。沒帶 seed 時說明每次生成都會不同;有帶時說明上游會以它為隨機起點。
materials陣列,每個被轉存的素材一筆:field(來自哪個欄位)、indexupload_idbytesmimesha256(內容指紋),以及 urlpreview_url。這兩個網址目前是同一條,也與 resolved 裡對應欄位的值相同。沒有內嵌素材時是空陣列。
estimate{ points, basis, billable, unit };token 制模型另有 output_tokenspoints 就是這筆的估算金額,與外層的 price 相同。
estimate.basis"exact" 表示這是精算不是估計;"upper_bound" 表示只能給出上限,實收會等於或低於它。
balance{ available, sufficient, note }note 是一句固定說明,講的就是下面這則警告。
balance.sufficient呼叫當下的餘額夠不夠付這一筆。只是快照,不是保留。
報價單不凍結額度

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

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

估價與實收的關係

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

POST /v1/videos/generations

POST/v1/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 可以拿去輪詢。所以這個開關由我們為你的帳戶開啟,開啟前請先與我們確認,並確認你的程式碼已經會判斷 objectstatus

請求欄位

欄位型別必填說明
quote_idstring二擇一確認單編號,見送件前確認帶了它就不能再帶下表其他任何欄位,否則回 400——確認階段不允許改動內容。不帶則走直接送件,下面的必填欄位照舊。
confirmbool/string選填只在帶 quote_id 時有意義。true(或 "yes")送出、false(或 "cancel")取消;不帶則把確認內容再回給你看一次,不送出。
modelstring必填模型 id,必須是 modality: "video" 的模型。
promptstring必填內容描述。不可為空字串。
secondsnumber必填長度。數字型別,須符合模型限制。
resolutionstring視模型解析度,須在模型的支援清單內。
aspect_ratiostring選填沒帶時由後端決定預設比例,平台不會替你補上。但平台的計價在沒帶時一律以 "16:9" 估算,所以實際產出的比例與估價依據可能不同。
sizestring選填"1280x720",決定尺寸時優先於 resolution
generate_audiobool選填是否產生音軌。
negative_promptstring選填不希望出現的內容描述。
seednumber選填亂數種子,用於重現近似結果。平台不驗證其值。
output_formatstring選填輸出格式偏好。
image_urlstring選填圖生影的起始圖片直連網址或 data URI。沒有公開儲存空間?可直接以 data URI 內嵌,平台會自動代管(見內嵌檔案自動轉存)。
frame_imagesarray選填關鍵影格圖片,可為直連網址或 data URI。input_references 互斥
input_referencesarray選填多模態參考素材,項目形如 {"type":"image"|"video"|"audio","url":"…"}url 可為直連網址或 data URI。沒有公開儲存空間?可直接以 data URI 內嵌,平台會自動代管(見內嵌檔案自動轉存)。
數量有上限:圖片、影片、音訊分開計算,超過會在送件當下回 400,見參考素材的數量上限
{"type":"video"} 的參考素材時,seconds 只用來計價:你仍必須送一個大於 0 的 seconds(否則估不出價),但實際輸出長度由後端依參考影片決定,不是你送的那個數字。
post_processobject選填音軌風格後處理,形如 {"audio_style":"phone","intensity":3}。任務完成後平台會另外產出一支加了聲音效果的成品,原片不受影響,兩支都可取件(見音軌風格後處理)。
這是平台層參數,不會送到後端,也不影響生成內容。audio_style 可用值:phone(電話)、walkie(對講機)、radio(廣播)、vinyl(老唱片)、muffled(隔壁房間)、hall(大廳迴音)、lofi_16klofi_24k(降取樣率)、mute(去音軌);intensity15,預設 3lofi_*mute 忽略此值)。
原片沒有音軌時generate_audio: false)除 mute 外都無意義,後處理會以 no_audio_stream 失敗,但不影響原片的交付與計費
未列出的欄位會被丟棄,而且不會有任何警告

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

  • async — 影片一律以非同步模式送件,你無法改成同步等待。這是為了避免長影片把連線卡到逾時。
  • callbackUrl / webhook — 基於安全考量一律丟棄。目前沒有回呼機制;請用輪詢取得結果(額度結算不需要你輪詢,見結算章節)。
  • extra_body已不再支援。它曾經可以把任意欄位原樣直通後端,那等於在白名單上開一個洞,現在一併丟棄。你送了不會收到錯誤訊息,它只是完全沒有作用。

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

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

回應:直接送出

{
  "object": "video",
  "id": "job_1a2b3c4d5e6f7a8b9c0d",
  "status": "processing",
  "model": "Seedance-2.0-fast",
  "created_at": 1756300000,
  "price_reserved": 0.539055
}
欄位說明
id任務識別,格式 job_ + 20 個十六進位字元。請保存它——這是之後查詢與取件的唯一憑據。
status送件成功時為 "processing"
model你送出的模型 id。
price_reserved本次凍結的金額(USD)。
created_at建立時間(Unix 秒)。

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

200 不代表會成功

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

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

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

curl https://appletoken.app/v1/videos/generations \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "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": "Seedance-2.0-fast", "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": "/v1/videos/generations",
                 "body": { "quote_id": "q_a1b2c3d4e5f6a7b8c9d0", "confirm": true } },
    "cancel":  { "method": "POST", "url": "/v1/videos/generations",
                 "body": { "quote_id": "q_a1b2c3d4e5f6a7b8c9d0", "confirm": false } }
  },
  "message": "這是即將送出的內容,目前尚未送出、尚未扣點。…"
}
這個時候還沒有送出,也還沒有扣任何點數

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

欄位說明
status"needs_confirmation"。看到這個值就代表還沒送出,在等你回覆。
quote_id這張確認單的編號。確認時要帶它。有效期 15 分鐘。
resolved你送出的每一個參數,原樣回顯——包含 prompt。素材欄位(image_urlframe_imagesinput_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. model400
  2. 模型不在憑證的允許清單 → 404
  3. 長度或解析度不符模型限制、參考素材數量超過上限、缺必要計價參數 → 400(多個問題會用「;」串接在同一則訊息)
  4. 估價金額超過可用餘額 → 402,附 neededavailable
  5. prompt400刻意排在凍結之前,避免建立又立刻退還的無謂凍結)
  6. 凍結額度(此後才會產生費用紀錄)
  7. 送往後端;任何階段失敗都會退還凍結,帳本記 releasesubmit_failed

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

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

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

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

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

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

也可以自己先產生確認單

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

GET /v1/videos/{job_id}

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

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

回應

{
  "object": "video",
  "id": "job_1a2b3c4d5e6f7a8b9c0d",
  "status": "completed",
  "model": "Seedance-2.0-fast",
  "seconds": 5,
  "resolution": "720p",
  "created_at": 1756300000,
  "completed_at": 1756300182,
  "content": "/v1/videos/job_1a2b3c4d5e6f7a8b9c0d/content",
  "outputs": [
    { "type": "original",  "status": "completed",
      "content": "/v1/videos/job_1a2b3c4d5e6f7a8b9c0d/content?output=original" },
    { "type": "processed", "id": "pp_9f8e7d6c5b4a3210fedc", "status": "completed",
      "audio_style": "phone", "intensity": 3,
      "content": "/v1/videos/job_1a2b3c4d5e6f7a8b9c0d/content?output=processed" }
  ],
  "price_charged": 0.539055
}
status意義額度影響
processing仍在生成中。維持凍結。
completed已完成,可以取件。凍結轉為實扣,回傳 price_charged。由本次呼叫或背景程序完成,先到者生效。
failed生成失敗。凍結全額退還,price_charged0.0。同樣不需要你觸發。
建議的輪詢節奏

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

不想寫程式也拿得到

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

GET /v1/videos/{job_id}/content

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

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

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

音軌風格後處理

POST/v1/post-process需要憑證

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

兩種用法

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

請求

{
  "job_id": "job_1a2b3c4d5e6f7a8b9c0d",
  "audio_style": "phone",
  "intensity": 3
}
欄位
job_idasset_idurl來源三選一,放在 body 頂層(不是包在 source 裡):job_id(平台上已完成的任務)/asset_id(你上傳的素材)/url(內嵌的 data URI,只收 data:video/mp4;base64,…)。三個只能給一個。
audio_style九選一(見下方清單)。
intensity15整數,預設 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 /v1/post-process/{id} · GET /v1/post-process/{id}/content · GET /v1/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_failedupload_failed暫時性的,平台會自動重試,不會出現在最終狀態;重試到時限仍未成功會變成 postprocess_timeout

POST /v1/images/generations

POST/v1/images/generations需要憑證

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

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

影片是非同步的:送件先拿到 job_id,再輪詢狀態、最後取件。圖片沒有這一段——回應本身就是成品。/v1/jobs 不會列出圖片,/v1/usage 裡圖片那一列的 job_idnull,回應裡的 id不能拿去查詢。

請求欄位

欄位型別必填說明
modelstring必填模型 id,必須是 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" 把影像內容直接放進回應。兩個值所有圖片模型都收。
qualitystring視模型畫質檔位。只有部分模型有這個參數,其餘模型送了會回 400,訊息會列出可用值。
backgroundstring視模型背景處理方式。同上,只有部分模型支援。
negative_promptstring視模型不希望出現的內容。同上,只有部分模型支援。
seednumber視模型隨機種子,整數。同上,只有部分模型支援。

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

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

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

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

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

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

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

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

模型可用簡寫面積下限面積上限
Seedream 4.01K2K4K921,60016,777,216
Seedream 4.52K4K3,686,40016,777,216
Seedream 5.0-lite2K3K4K3,686,40016,777,216
Seedream 5.0-pro1K2K921,6004,624,220

品牌前綴不影響這張表:模型 id 去掉 ByteDance-Dola-NSFW- 之後對到哪一列,就套哪一列。例如 ByteDance-Seedream-4.0NSFW-Seedream-4.0 共用第一列,Dola-Seedream-5.0-proNSFW-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 /v1/models:每個圖片模型的 constraints 直接給出 sizesminPixelsmaxPixels。要寫進程式請讀那三個欄位,不要把上面的數字寫死。

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 的可用簡寫也一併更正了,從 2K3K4K 改成 1K2K——上表最後一列已經是更正後的值。依據是直接對後端送簡寫字串的實測:"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 以上)"
  }
}

message 已經寫成人話,並且列出了該模型接受的簡寫與面積下限但它不會講出上限——上限請讀 /v1/modelsconstraints.maxPixels

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

請求範例

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

回應

{
  "id": "img_1a2b3c4d5e6f7a8b9c0d",
  "created": 1774794546,
  "model": "imagen-4-fast",
  "data": [
    { "url": "https://…/tenants/…/images/….png" },
    { "url": "https://…/tenants/…/images/….png" }
  ],
  "price_charged": 0.12
}

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

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

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

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

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

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

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

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

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

錯誤

狀態碼code典型情況
400validation_errorprompt;模型不是 image 模型;n 送給不支援的模型或超出上限;size 不在該模型的可用值;輸入圖張數超過上限、或該模型不收輸入圖;帶輸入圖時又指定了 size;內嵌檔案的型別不支援、或內容與宣告的型別不符。
401 / 403unauthorized / forbidden憑證無效、已停用或已到期。
402quota_exceeded預扣金額超過 available,附 neededavailable不會留下凍結。
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 像素之間。上傳當下就會擋下,不會白等一趟;錯誤訊息會直接告訴你這張圖是幾乘幾、該放大還是縮小。
重複上傳同一個帳戶重複上傳同一份檔案,會拿回同一個 id,不會產生第二份,也不會再往上游傳一次。重試是安全的。(比對的是檔案內容的 sha256,與檔名無關。)
內容驗證檔案內容必須與宣稱的型別相符——副檔名對、內容不對的檔案會被擋下。
一次生成可引用幾個Seedance-2.0-fast-mini共 15 個(圖片 9、影片 3、音訊 3),不支援只給音訊,至少要有一張圖或一段影片。
Seedance-2.5共 50 個(圖片 30、影片 10、音訊 10),支援只給音訊。
資產數量沒有平台層的上限。列表一次最多回 100 筆。
保留期資產長期保留,直到你自己刪除。與影片素材的 30 天暫存不同——那是送件當下的暫存,這是可重複使用的資產。

怎麼上傳

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

第一步:上傳

POST/v1/assets需要憑證
curl https://appletoken.app/v1/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選填imagevideoaudio。不給就從檔案型別判斷,通常不需要指定。

回應是 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_referencesurl 就可以了,不必自己組。
statusprocessing 處理中(還不能用於生成)/active 可用/failed 失敗。
sha256檔案指紋。用來確認「這兩次送的是不是同一份檔案」。
activated_at變成可用的時間;還在處理中時是 null

第二步:等它變成 active

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

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

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

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

第三步:在送件時引用

{
  "model": "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_imagereference_videoreference_audio

帶影片參考時,seconds 必須是 -1(長度由輸入影片決定)——這條規則與資產無關,見影片送件

列出與刪除

GET/v1/assets需要憑證

列出你的資產,最新的在前。查詢參數:limit(1–100,預設 50)、statusprocessingactivefailed)、typeimagevideoaudio)。回應是 {"object":"list","data":[…]}

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

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

同一份檔案不會重複佔用

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

你的資產只有你看得到

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

錯誤

狀態情況
400缺少 source;型別不支援;內容與宣稱的型別不符;檔案超過 20 MB;圖片尺寸不在 300–6000px;引用的資產還在 processing
404資產不存在,或不屬於你
503素材資產功能尚未開通。這不是你的請求有問題,請聯絡我們

GET /v1/jobs

GET/v1/jobs需要憑證

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

Query 參數

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

回應

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

POST /v1/chat/completions

POST/v1/chat/completions需要憑證

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

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

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

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

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

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

請求欄位

欄位型別必填說明
modelstring必填模型 id,必須是 modality: "text" 的模型(也就是對話模型;目錄裡沒有 "chat" 這個 modality)。對影片模型呼叫會回 400「{model} 不是對話模型」。
messagesarray必填對話內容,非空陣列。每個元素形如 {"role": "user", "content": "…"},兩個欄位都必須是字串。
max_tokensnumber必填輸出 token 上限,必須大於 0。平台以它為上限預扣額度,因此不能省略——理由見下方。
streambool選填預設 false。設為 true 時改回 SSE 串流。必須傳布林值 true / false——平台只做真假值判斷,任何非空字串(包含 "false")都會被當成 true 而啟用串流。
temperaturenumber選填取樣溫度。平台不檢查範圍,原樣轉給後端。

其他欄位(top_pnstoptoolsuser 等)目前不被支援,會被靜默忽略

為什麼 max_tokens 一定要給

對話的用量到最後一刻才知道,但額度必須在呼叫後端之前就凍結——否則你可能在餘額只剩 0.01 美元時觸發一次十幾塊的長回應,帳算得出來卻收不到錢。max_tokens 是你對「這次最多產出多少」給出的承諾,平台用它算出最壞情況的價格並凍結那個金額。

這對你是實質約束,但代價很小:結算一律以實際用量重算,通常遠低於預扣,差額在請求結束的當下就還給你。要留意的只有一件事——max_tokens 設得太大會暫時佔住額度,讓同時進行的其他請求更容易撞到 402。請照實際需要設,不要一律填上限。

回應(非串流)

{
  "object": "chat.completion",
  "model": "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(平台的命名,不是後端的)。
choices目前永遠只有一個元素(不支援 n)。message.role 固定為 "assistant"
usage.prompt_tokens輸入 token 數。後端有回報就用實際值,否則用平台的估算值。
usage.completion_tokens輸出 token 數,同上。
price_charged本次實收金額(USD)。凍結已在回應送出前結算完畢,你不需要再查一次。

回應沒有 id 也沒有 created——對話不是可查詢的任務,沒有識別碼可以拿。要對帳請用 /v1/usage 裡對應那一列的 id(對話的列 job_idnull)。

回應(串流,stream: true

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

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

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

data: {"object":"chat.completion.chunk","model":"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_errormax_tokensmessages 為空、模型不是對話模型。
401 / 403unauthorized / forbidden憑證無效、已停用或已到期。
402quota_exceeded預扣金額超過 available,附 neededavailable不會留下凍結。
404not_found模型不在憑證的允許清單,或這個模型不存在——兩者回同一種錯誤。
502 / 503upstream_error / service_unavailable後端不可用,或這個模型不支援你要的模式(stream 與非串流分別檢查)。凍結全額退還。

GET /v1/me

GET/v1/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成功建立凍結的請求數——影片送件與對話請求都算,估價、查詢、取件、/v1/me 都不算。
last_used_at最後一次成功預扣的時間,同樣不因查詢而更新。
expires_at / expired到期時間與是否已過期。null 表示永不過期。
prefix / display憑證的遮罩形式,可安全顯示在介面或日誌中。

GET /v1/usage

GET/v1/usage需要憑證

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

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

Query 參數

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

回應

{
  "object": "list",
  "data": [
    {
      "id": "80421",
      "job_id": null,
      "model": "ByteDance-Seed-1.8",
      "modality": "text",
      "billable": { "input_tokens": 52, "output_tokens": 180 },
      "price": 0.004284,
      "status": "settled",
      "created_at": 1756300400
    },
    {
      "id": "80418",
      "job_id": "job_1a2b3c4d5e6f7a8b9c0d",
      "model": "Seedance-2.0-fast",
      "modality": "video",
      "billable": { "video_seconds": 5 },
      "price": 0.539055,
      "status": "settled",
      "created_at": 1756300182
    }
  ]
}
欄位型別說明
idstring這一列的識別碼,數字字串、單調遞增。它是唯一保證存在的識別碼——對話沒有 job_id,只能靠它。
job_idstring|null影片任務 id,與 POST /v1/videos/generations 回傳的 id 相同。對話與圖片沒有任務,這裡是 null
modelstring模型 id,與 /v1/modelsid 是同一個字串。
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 這種欄位。不需要(也無法)把兩筆事件配對起來
  • 還在進行中的請求不會出現。要看目前凍結的總額,請讀 /v1/mereserved
  • 全額退還的請求(送件失敗、生成失敗、零產出的串流)不會出現——沒有扣款就沒有這一列。
  • Σ price這張憑證的累計消費;/v1/meused整個帳戶的累計消費。同帳戶有多張憑證時,兩者不會相等。

額度模型

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

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 小時:上游暫時查不到結果(包含你自己綁定的上游金鑰額度不足這類情況)時,平台會持續重試到這個時限。超過 2 小時仍沒有結果,系統會自動判定為失敗(error.codegeneration_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 推出長邊。
可以自己算,但請用 /v1/quote 確認

上面的公式與平台實際使用的完全相同,你可以在客戶端預估費用。但 aspect_ratio 的靜默退回、模型費率分段等行為只有伺服器知道,正式報價請以 /v1/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.8max_tokens: 512

單價(取自 GET /v1/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 /v1/modelspricing 會多一個 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—— 請用「這個欄位存不存在」判斷,不要靠模型名稱猜。 unitfromto 描述的是主要單位(輸出優先), 分段計價時不能拿它當總價估算

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

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

預扣時的輸入 token 是估算值

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

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

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

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

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

關掉連線不等於取消收費

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

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

串流的閒置逾時

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

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

結算、退還與對帳

結算不需要你觸發

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

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

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

但你還是應該輪詢

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

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

四種結局

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

實收金額怎麼決定

結算金額可能超過預扣

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

對帳建議

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

錯誤格式與狀態碼

{
  "error": {
    "code": "validation_error",
    "message": "veo-3.1-generate-001 只接受 4/6/8 秒"
  }
}

該怎麼分支

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

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

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

反過來也不要只看 error.code請以 HTTP 狀態碼判斷這是不是請求端的錯誤:目前缺少必填欄位的請求(影片缺 prompt、對話缺 messages)會回 400,但 error.codeupstream_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}/v1/modelsid 為準,不做模糊比對。
validation_error400seconds 必須是數字傳了字串(如 "5")或非有限數值。表單取值最容易在這裡出錯。
validation_error400seconds 必須大於 0調整 seconds
validation_error400{id} 只接受 4/6/8 秒veo-3.1-generate-001 的固定長度限制。
validation_error400{id} 的長度需在 {min}–{max} 秒之間調整 seconds
validation_error400{id} 支援的解析度為 …改用清單內的解析度。只給 size 也會被檢查:短邊必須等於清單中的某個值。
validation_error400需要 seconds 才能報價…即使帶影片參考也必須提供 seconds 供估價。
validation_error400需要 resolution 或 size 才能精算 token 用量token 制模型至少要有其中之一。對 chat-* 呼叫 /v1/quote 也會撞到這則。
validation_error400無法解析的 size:{值} / 無法解析的解析度:{值}格式必須是 "1280x720""720p""720"
validation_error400這組參數沒有對應的計價規則參數組合超出模型的費率涵蓋範圍。實務上很少見,因為每個模型都有一條無條件的預設費率resolution(它的費率分段以解析度為條件)。
validation_error400缺少 messages / messages[i].content 必須是字串對話端點:messages 必須是非空陣列,元素的 rolecontent 都是字串。
validation_error400max_tokens 必須是大於 0 的數字(平台以它為上限預扣額度,因此不可省略)對話端點必填,見對話端點
validation_error400temperature 必須是數字型別錯誤,改送 JSON number。
validation_error400{id} 不是對話模型對影片模型呼叫了 /v1/chat/completions。以 modality 分類。
unauthorized401憑證格式不正確檢查 Bearer 前綴與空白。
unauthorized401憑證無效憑證不存在、機密錯誤或已被換發/撤銷。
quota_exceeded402額度不足neededavailablegrantedusedreserved
forbidden403這組憑證已被停用停用或已過期,兩者訊息相同。
not_found404未知的模型 {model}憑證設有模型白名單而這個模型不在其中。與模型不存在回同一種錯誤,避免拿來探測目錄。影片與對話同樣適用。
not_found404找不到這個任務任務不存在,或屬於別張憑證。
not_found404這個任務沒有可下載的內容尚未完成,或成品已失效。
not_found404not found路徑或 HTTP 方法不存在(例如對 /v1/quotePUT)。只有帶著有效憑證時才看得到這個回應:未認證的請求一律先回 401,不論路徑存不存在——否則 401 與 404 的差異會變成一個可以用來枚舉端點的訊號。
rate_limited429請求太頻繁,請稍後再試平台自己的限流,時間窗為 1 分鐘:帶 Authorization 標頭時每分鐘 600 次(以憑證分別計數),未帶憑證時每分鐘 20 次(以來源 IP 計數);/healthz 不受限。回應不含 retry_after,請用自己的指數退避。額度未被扣除。平台保留調整配額的權利,請不要把這些數字寫死進整合裡(見常見問題)。
upstream_error429(沿用後端的訊息,已清洗)上游服務對這次請求做了節流,平台原樣透傳狀態碼。這與上一列的 rate_limited兩種不同來源,請以 error.code 分辨。回應不含 retry_after,請用自己的指數退避。額度未被扣除。
upstream_error沿用後端生成服務回報錯誤後端拒絕了這次請求。訊息已清洗,不含任何上游識別。狀態碼不固定,請以 code 分支。
upstream_error沿用後端(通常 400)素材網址無法被下載。請確認它可以匿名開啟(不需登入、沒有防盜連、未過期);若你沒有可公開的儲存空間,可以直接把檔案以 data URI 內嵌在素材欄位裡(image_url / input_references / frame_images),平台會自動代管。後端抓不到你給的素材網址時的改寫訊息,兩條出路:改善網址的可存取性,或改用內嵌 data URI
upstream_error502暫時無法連線到生成服務,請稍後再試連線或逾時。退避重試,額度未被扣除。
upstream_error504生成服務逾時未回應串流專屬:相鄰兩個 chunk 的間隔超過上限(預設 60 秒)。已產生的部分照樣結算。
service_unavailable503這個模型目前沒有可用的費率該模型暫時沒有生效中的費率卡。請改用其他模型並回報。
service_unavailable503這個模型目前無法使用對應的後端暫時未接上。請改用其他模型或稍後再試。
service_unavailable503這個模型不支援串流 / 不支援非串流呼叫換一種 stream 設定,或換模型。
service_unavailable503平台尚未設定上游憑證,請聯絡管理者平台端設定問題,請直接聯繫我們。
service_unavailable503平台上游憑證異常,請聯絡管理者指的是你的帳戶所綁定的上游憑證,不是你的 at_live_ 憑證——換發後者不會有幫助。請直接聯絡我們。
internal_error500發生未預期的錯誤平台端的問題,請回報時間與 job_id常見的欄位型別錯誤(例如 seconds 傳成字串)在 v2 已改為 400 validation_error,若你仍收到 500,那不是你的參數造成的。

完整範例

影片:估價 → 送件 → 取件

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

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

BASE = "https://appletoken.app/v1"
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="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/v1'
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: '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/v1/quote \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"Seedance-2.0-fast","seconds":5,"resolution":"720p"}'

# 2) 送件,取出 job id
JOB=$(curl -s https://appletoken.app/v1/videos/generations \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"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/v1/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/v1/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/v1"
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": "Seedance-2.0-fast", "seconds": 5, "resolution": "720p"},
    {"model": "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 /v1/videos/{id},做法與上一個範例相同
// 同時開多張報價單 → 逐一確認送出。Node 18+ 內建 fetch,無需任何套件。
const BASE = 'https://appletoken.app/v1'
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: 'Seedance-2.0-fast', seconds: 5, resolution: '720p' },
  { model: '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 /v1/videos/{id},做法與上一個範例相同

對話:串流讀取

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

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

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


def chat_stream(messages, model="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/v1'
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: '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/v1/chat/completions \
  -H "Authorization: Bearer $APPLETOKEN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"ByteDance-Seed-1.8","max_tokens":512,"stream":true,
       "messages":[{"role":"user","content":"用三句話說明什麼是向量資料庫"}]}'

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

上線檢查清單

正確性

對話

韌性

成本控制

常見問題

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

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

有 webhook 或回呼通知嗎?

目前沒有,請求中的 callbackUrl 類欄位會被丟棄,請使用輪詢。但要澄清一個常見誤解:輪詢不是結算的前提。平台有背景程序負責結算與退還,你不查詢也不會有額度卡住。輪詢的目的是知道結果、把成品抓下來。

生成失敗要付錢嗎?

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

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

為什麼對話一定要給 max_tokens

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

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

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

對話可以先估價嗎?

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

可以同時送多個任務嗎?

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

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

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

底層用的是哪一家的模型?

這是 AppleToken 的實作細節,不對外揭露,也不需要你關心。我們保證的是文件所列的介面、能力與價格;底層供應商更換時,你的整合不需要任何修改。

價格會變嗎?

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

有速率限制嗎?

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

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

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

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

版本紀錄

版本歷程已移到獨立頁面

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

需要協助?

回報問題時請附上時間(含時區)、job_id、憑證 id 前綴(例如 at_live_9f2c81ab_…)與收到的 error.code。對話請求沒有 job_id,請改附 /v1/usage 裡對應那一列的 idcreated_at請勿在任何訊息中貼出完整憑證。