Request headers
All three protocol endpoints (/v1/chat/completions, /v1/messages, /v1/responses) accept these headers. Names are case-insensitive. Everything except Authorization is an optional preference: out-of-range values are clamped, never rejected.
| Header | Value | Effect |
|---|---|---|
Authorization | Bearer ap_live_… | Required on every /v1/* endpoint. See Authentication in the quick start |
X-AGIPlan-Difficulty | decimal 0–1 | Overrides the difficulty estimate for this request. Only affects how auto, auto-fast (it sets the minimum tier) and custom models pick a tier; ignored when you name a model directly |
X-AGIPlan-First-Byte-Timeout-Ms | ms, clamped to 5,000 – ceiling | How long to wait for the first byte before abandoning a route and trying the next (switching before the first byte never gives you half an answer). The ceiling grows with input: 60 s, plus 15 s per 50K input tokens, max 180 s |
X-AGIPlan-Scene | web / agent / batch / chat | Tells the scheduler what kind of request this is: web means someone is watching for the first token, so speed wins; agent is a coding agent, so stability wins and the quality tolerance is tightest; batch means nobody is waiting, so cost wins; chat balances the three. Omit it and we infer the scene from the request (streaming or not, number of tools, turns, client identifier); unknown values are ignored. Affects which model auto picks and how we choose among the routes serving the same model. It stacks with the key's routing preference: the scene sets the baseline, and the key's preference (speed / cost / cache) shifts it by a bounded amount; an Auto preference leaves it unchanged |
X-AGIPlan-Strict-Max-Tokens | 1 / true / yes / on | Require a strict output cap: only route to lines that honour max_tokens; lines with the loose_max_tokens capability are skipped. See Output cap below |
anthropic-version | e.g. 2023-06-01 | Send it as the official SDK does on /v1/messages |
anthropic-beta | comma-separated | Forwarded to Claude lines through an allow-list; anything not on it is removed and listed in x-agiplan-dropped-params as anthropic-beta:name |
Response headers
Successful responses from the three text-protocol endpoints (/v1/chat/completions, /v1/messages, /v1/responses) carry these headers; refusals have no trace or actual model (nothing ran) but still carry the three quota headers, and a 429 also carries retry-after (rules in the table below). Image endpoints carry only x-agiplan-trace, x-agiplan-model-actual, x-agiplan-cu and (after a switch) x-agiplan-failover on success — no quota headers and no x-agiplan-billed-group. **The video endpoints carry no x-agiplan-* headers at all: report problems with the job id, and read the charge from the job's cu. When reporting a problem, include x-agiplan-trace** — it pulls up the full routing trace for that call (see Traces in the quick start).
| Header | Example | Meaning |
|---|---|---|
x-agiplan-trace | tr_9f3c… | Trace ID of this call |
x-agiplan-model-actual | gemini-3.8-flash | The model that actually served the request. With auto, a custom model, or a capability switch it can differ from what you sent |
x-agiplan-model-requested | gemini-3.8-flash | Only when the model you named was swapped: the model you named (the previous row is the one that served). See Capability flags |
x-agiplan-model-swap | missing:tools | Comes with the previous header: which capability the named model lacked |
x-agiplan-billed-group | fast | Which tier's quota the call was billed against |
x-agiplan-failover | true | A route switch happened before the first byte. Present only when it happened |
x-agiplan-quota-session / -week / -month | remaining=72%; resets=2026-10-11T08:00:00.000Z | Remaining share and reset time of each quota window. Also sent on 429 so you can decide how long to back off |
x-agiplan-dropped-params | temperature,content[].image_url | Parameters that were dropped or downgraded. We never swallow parameters silently |
x-agiplan-max-tokens-default | 4096 | You sent no max_tokens; this is the value we filled in. It sizes the reservation and is really sent to the model |
x-agiplan-cu | 51.462 | Image endpoints: CU charged for this call |
retry-after | 30 | On 429: suggested wait in seconds. Same rule on all five entry points (three text protocols, images, video): when a quota window blocks you, the seconds until it resets; when concurrency is full, 2; a 429 that can't say when it clears carries none. Result-link rate limits always send 60 |
Output cap (max_tokens)
- If omitted we fill in 4096 and say so in
x-agiplan-max-tokens-default. For longer answers sendmax_tokensexplicitly (max_output_tokenson Responses). - On Responses, if you omit
max_output_tokenswe do not fill in 4096 (that would also choke a reasoning model's thinking budget); we treat it as 64000: output is reserved at 16000, and the cap sent to the model is that reservation plus the per-request overrun allowance, so long answers still finish but output is no longer unbounded. - Reserved by the cap, billed by actual usage. A call reserves quota for the worst case (input + max_tokens) and settles on the usage the model reports, refunding the difference. An oversized cap only holds extra quota while the call runs; it never costs more.
- Some lines' upstreams do not honour
max_tokens: the model may produce more than the cap and still report a normal finish. These lines carryloose_max_tokensincapabilitieson/v1/models, shown as “Soft max_tokens” in the console. They are routed normally and output beyond the cap is billed at actual usage. - When you need a strict cap (token-budgeted jobs, code that depends on where output stops, batch work), send
X-AGIPlan-Strict-Max-Tokens: 1: routing skipsloose_max_tokenslines, andautoor tier names move to lines that honour the cap. If you name a model that only has such lines, or no tier we can lift to has a line that honours the cap, you get 400strict_max_tokens_unavailableinstead of a silently substituted model — retrying will not change the result; drop the header or pick another model / tier.
curl https://agiplan.dev/v1/chat/completions \ -H "Authorization: Bearer $AGIPLAN_KEY" \ -H "X-AGIPlan-Strict-Max-Tokens: 1" \ -H "Content-Type: application/json" \ -d '{"model":"auto","max_tokens":200,"messages":[{"role":"user","content":"Summarise this code in three sentences"}]}'
Capability flags
Each model in GET /v1/models has a capabilities array. Routing enforces it: if a request needs a capability (for example it has tools) and a line lacks it, that line is never chosen; if you name a model without it, the gateway doesn't fail the request — it switches to a model that has it and bills that model. A swap is always visible: the headers x-agiplan-model-actual (served), x-agiplan-model-requested (named) and x-agiplan-model-swap (what was missing); a top-level agiplan object in the response (model_requested, model_actual, missing_capabilities, notice) — in the body when not streaming, in the first frame when streaming (the first Chat Completions chunk, message_start.message in Messages, response.created.response in Responses), with nothing inserted into the model's output; the row in the console's usage detail is tagged "Requested X → served by Y (X lacks …)" and the "Model swapped only" filter finds them; and the first swap on a key in 24 hours leaves an in-app note under Notifications (no email). Which model a tool-less model switches to is stated on that model's entry under Models & prices. To always get the named model, don't send capabilities it lacks.
| Flag | Meaning |
|---|---|
tools | Tool calling (function calling). Required by coding agents such as Codex and Claude Code |
vision | Image input: OpenAI image_url, Anthropic image blocks and Responses input_image are all translated |
json | JSON mode / structured output (response_format) |
long | Long context |
thinking | Extended thinking can be enabled |
responses | Native Responses protocol; Codex-specific items pass through unchanged. Only requests whose history holds items like compaction / web_search_call / local_shell_call need it; apply_patch, namespace tools and include don't, so naming Claude still runs Claude |
image | Image model: use /v1/images/generations and /v1/images/edits |
video | Video model: use /v1/videos |
quality:<value> | quality values an image line accepts (e.g. quality:high). Lines that do not list the requested quality are skipped |
loose_max_tokens | The upstream does not guarantee max_tokens; see the previous section |
thinking_forced | Thinking cannot be turned off: the model always thinks first and thinking tokens count as output. auto and tier routing skip it when max_tokens is small (under 1024); if you name it directly, leave room in the output cap |
Images: /v1/images/generations and /v1/images/edits
Request bodies match OpenAI. edits takes multipart uploads of the source image (image or image[], up to 16) and an optional mask, each file (mask included) at most 10 MB; other parameters are the same.
| Parameter | Notes |
|---|---|
model | Defaults to gpt-image-2. An image model from /v1/models (kind: "image"). With the model name, its lines are tried cheapest first and you pay for the line that made the image. With a line name (the model's pinnable, e.g. gpt-image-2-vip, nano-banana-2-cl) you stay on that line at its price |
prompt | Required |
size | Defaults to 1024x1024. WIDTHxHEIGHT, e.g. 1024x1024, 2048x1152. Tiered by the long edge: ≤1024 is 1K, ≤2048 is 2K, ≤4096 is 4K, and priced per tier. Asking a model for a tier it cannot produce returns size_unavailable |
n | Defaults to 1; 1–4. Billed per image actually produced: 3 out of 4 succeeding bills 3 |
quality | auto / low / medium / high; some models add xhigh / max. Check the model's quality:* flags. If none of the model's routes for that size accept your value, you get 400 quality_unavailable with the accepted values in the message |
response_format | Omitted or b64_json: base64 (the default, as with OpenAI). url: a link on our own domain, https://agiplan.dev/v1/media/<id>, kept for 7 days |
- Responses look like
{created, data:[{b64_json}], output_format, size, quality}.output_formatis reported truthfully aspng/jpeg/webp— models differ, so pick the file extension from it rather than always saving.png. - Failed generations (moderation, upstream fault, timeout) are not billed. Moderation failures return 400
upstream_rejected; another route will usually refuse too, so rewrite the prompt. Generation cannot be cancelled once dispatched: if the client disconnects mid-generation the upstream usually finishes anyway, and completed images are billed per piece (we pay the upstream either way — same policy as streaming chat since 2026-10-12). - Prices:
pricing.per_imageon image models in/v1/modelsgives, for1k/2k/4k,cu(from: the cheapest line) andcu_max(the dearest), plus the pay-as-you-go CNY;pinnablelists each pinnable line with its own price. - References: the input images sent to
/v1/images/editscost nothing extra; you pay per output image, same as generation. - Common
editserror codes:invalid_multipart(400, not multipart/form-data),missing_image(400, no source image),too_many_images(400, more than 16),image_too_large(413, a file over 10 MB).
Video: /v1/videos (asynchronous)
A clip takes one to several minutes, so video is an asynchronous job: submit to get a job ID, poll for progress, fetch the clip when done. You are billed only when the clip is delivered.
| Endpoint | Notes |
|---|---|
POST /v1/videos | Submit; returns 202 with the job object |
GET /v1/videos/{id} | Poll. Every 3–5 seconds is fine; polling itself advances the job and never costs anything |
GET /v1/videos/{id}/content | Download the clip (mp4). Needs your key; supports Range (seeking) |
GET /v1/videos | The 50 most recent jobs for this key's account |
DELETE /v1/videos/{id} | Delete a finished job and its clip; needs the chat:write scope. Running jobs cannot be deleted (409) because generation has already started upstream |
| Parameter | Notes |
|---|---|
model | Defaults to minimax-h3. A video model. Billed per line like images: the model name starts on the cheapest line, a pinnable line name stays on that line |
prompt | Required, up to 4,000 characters. Shot lists work best: duration, style, timed shots, dialogue, sound design, negatives |
resolution | 480p / 768p / 1080p, per the model's pricing.per_second in /v1/models. Defaults to the model's lowest tier |
seconds | Integer, defaults to 5. Up to 15 s at 480p / 768p, 10 s at 1080p (duration also accepted) |
aspect_ratio | 16:9 (landscape, default) or 9:16 (portrait) |
images | Optional reference images, up to 9. http(s) URLs or data:image/…;base64,…. Base64 references may total at most 8 MB combined; use URLs or the asset library for larger files |
audios | Optional reference audio (voice, music), up to 3, same formats (counts toward the same base64 total as images) |
assets | Optional ids of assets in the asset library (ast_…). Counted together with images / audios |
seed | Optional integer; keeps results closer to the previous run when nothing else changes |
import time, requests H = {"Authorization": "Bearer ap_live_your_key"} job = requests.post("https://agiplan.dev/v1/videos", headers=H, json={ "model": "minimax-h3", "prompt": "6 seconds | premium product ad | macro photography ...", "resolution": "768p", "seconds": 6, "aspect_ratio": "16:9", }).json() while job["status"] in ("queued", "in_progress"): time.sleep(4) job = requests.get(f"https://agiplan.dev/v1/videos/{job['id']}", headers=H).json() if job["status"] == "completed": print(job["url"], job["cu"]) # url is on our domain, valid for 7 days else: print(job["error"]) # failures are not billed
- Job fields:
id,status(queued→in_progress→completed/failed),progress(0–100),model,resolution,seconds,aspect_ratio,cu,created_at,completed_at,url,expires_at,error,retries. - Automatic retry: if generation fails mid-way or stalls, the system may switch to another route and regenerate, at most 2 times. The job id stays the same,
statusstaysin_progress,progressrestarts from 0, andretriescounts the retries so far. Billing is unchanged: you are charged once, only when the clip is delivered. Jobs rejected by moderation and jobs with references in the request body (images/audios) are not retried automatically; jobs that only use library assets (assets) can be. - Billing: the job reserves per-second price × seconds at submit and is charged when the clip is delivered; failures, moderation rejections and timeouts (no clip after 25 minutes) are free and the reservation is released. A running job holds one concurrency slot.
- Common error codes:
invalid_resolution,invalid_duration,invalid_aspect_ratio,too_many_images,too_many_audios,invalid_asset,invalid_seed,prompt_too_long(bad parameters, 400);resolution_unavailable(503, no route can produce that resolution right now — pick another or retry later);video_not_found(404, no such job or not yours);video_not_ready(409, fetching the clip before it is done);video_in_progress(409, deleting a running job);assets_too_large(413, base64 references over the combined limit),too_many_asset_bytes(400, referenced assets over the combined limit);content_rejected,generation_failed(safe to resubmit),timeout,upstream_lost(reported in the job'serror). - Clips are kept for 7 days, then deleted. Download anything you want to keep.
Asset library: /v1/assets (upload references once, reuse them)
The asset library requires an active plan (personal or your organization's, including custom enterprise plans), or pay-as-you-go turned on with a positive balance. Sending and storage are both charged in CU with no free allowance, from your plan's quota like any other call (see "Charges" below). Store reference images and audio in the asset library first, then pass only asset ids when you generate a video. Files are uploaded straight to object storage and never pass through our gateway, so request-body limits don't apply; identical files are stored once and can be referenced again. References added in the console's online video page live in the same library.
| Endpoint | Description |
|---|---|
POST /v1/assets | Register a file to upload. Returns the asset object plus a one-time upload target. If the same file (by sha256) is already in your library you get that asset back with upload: null — no need to upload again |
PUT <upload.url> | PUT the raw file there, sending every header in upload.headers (the content type is part of the signature); the file size is signed too, so the body must be exactly the bytes you registered or storage rejects it (403). Valid for 15 minutes; no API key needed |
POST /v1/assets/{id}/confirm | Confirm once the upload finishes. We check size and file signature; on success status becomes ready and the asset can be referenced. Confirming twice is harmless |
GET /v1/assets | List assets (most recently used first, 60 per page, ?kind=image|audio, ?offset=) with usage (bytes used, count, cap, frozen state, the two CU rates, today's storage charge, this month's charges) |
GET /v1/assets/{id} | Fetch one asset |
DELETE /v1/assets/{id} | Delete, effective immediately. Video jobs that reference it and haven't finished may fail (failed jobs are not charged) |
| Register field | Description |
|---|---|
kind | image or audio |
mime | Images: image/png, image/jpeg, image/webp (no SVG). Audio: audio/mpeg, audio/wav, audio/mp4, audio/m4a, audio/aac, audio/ogg |
bytes | File size in bytes. Images up to 20 MB, audio up to 50 MB; an upload larger than declared is deleted |
sha256 | SHA-256 of the file contents (64 lowercase hex chars), used for de-duplication |
name | Optional file name, so you can recognise it in the console |
import hashlib, requests H = {"Authorization": "Bearer ap_live_your_key"} data = open("product.png", "rb").read() a = requests.post("https://agiplan.dev/v1/assets", headers=H, json={ "kind": "image", "mime": "image/png", "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest(), "name": "product.png", }).json() if a["upload"]: # None when the same file is already stored requests.put(a["upload"]["url"], headers=a["upload"]["headers"], data=data).raise_for_status() a = requests.post(f"https://agiplan.dev/v1/assets/{a['id']}/confirm", headers=H).json() job = requests.post("https://agiplan.dev/v1/videos", headers=H, json={ "model": "minimax-h3", "prompt": "The product from <Picture 1> slowly turns 360° on a white turntable...", "resolution": "768p", "seconds": 6, "aspect_ratio": "16:9", "assets": [a["id"]], }).json()
- Referencing: put asset ids in
assetson/v1/videos. They can be mixed withimages/audios, and counts are combined (up to 9 images, 3 audio clips); the assets referenced by one generation may total at most 100 MB. Ordering:imagesfirst, then the images inassetsin the order given —<Picture N>in your prompt counts in that order. - Retention: assets not referenced for 30 days are deleted automatically; every use resets the clock. Registrations not confirmed within 60 minutes are discarded.
- Limits: up to 200 GB and 500 assets, at most 300 registrations per day. When it's full, delete something first.
- Charges (in CU, no free allowance): each is its own usage record, listed separately in your console usage, with model ids
asset-transfer(sending) andasset-storage(storage). ① Sending: every generation — including automatic retries on another route — sends the referenced library assets to the model once, about 1053 CU per GB, charged at submission — if the generation fails the video isn't charged, the sending is; submissions that never went through aren't charged. ② Storage: assets in your library are charged once a day on the amount stored at the time, about 8.3 CU per GB per day. Both come from whoever pays for the call: plan quota, then pay-as-you-go from your balance when quota runs out or there's no plan (the key must allow pay-as-you-go). If sending can't be charged, the whole submission is refused (quota_exceeded429 /no_subscription403, with the message saying it's the asset sending). References attached directly to a request don't go through the library and aren't charged. - If storage can't be charged: the library can't be referenced and uploads are blocked (
asset_frozen/asset_library_frozen, 403); it resumes on the next charge (an hourly job) once quota resets or you top up, and the uncharged days aren't collected later. After 7 consecutive days, all assets are deleted (least recently used first). - Automatic retry: video jobs that only use library assets can be regenerated on another route if the upstream fails mid-way (we re-sign the asset links). Jobs with
images/audiosin the request body cannot. - Common error codes:
asset_not_found(404, no such asset, deleted, or not yours);asset_size_mismatch(400, the uploaded byte count differs from what was registered — upload again);asset_check_unavailable(503, we can't verify the file contents right now — confirm again shortly);asset_library_requires_plan_or_balance(403, no active plan, and pay-as-you-go is off or there's no balance);invalid_asset_type,invalid_asset(400);asset_too_large(413);asset_quota_exceeded,asset_count_exceeded(400, library full);asset_upload_rate(429);asset_not_uploaded,asset_bad_content(on confirm: upload not finished / content doesn't match the type);asset_intent_expired(410, register again);invalid_asset_ref(the asset doesn't exist, was deleted, or isn't yours).
Result links (/v1/media)
response_format: "url"images and a video job'surllook likehttps://agiplan.dev/v1/media/<id>. The link is the credential (a 128-bit random ID), so no key is needed and it can go straight into<img>/<video>.- Opening it redirects (302) to a storage URL valid for 2 minutes. Always use
/v1/media/<id>itself; do not store the redirect target. A download started within that time runs to the end; seeking after a long pause opens the link again. - To stop forwarded links from being abused, opens are capped per file per hour (30), per IP per minute (20) and across all of one account's files per hour (300); beyond that you get 429
rate_limitedwithretry-after: 60. Fetching/v1/videos/{id}/contentwith your key counts against the same caps. When site-wide egress runs too high we pause signing and return 503busy; storage being briefly unavailable returns 503storage_unavailable; a missing or expired file returns 404not_found. Normal playback, downloads and seeking never reach these caps. - Download traffic (charged from 2026-10-20, Beijing time; free before then): every open charges the owner once for the file's size (including opens by people the link was forwarded to; repeated opens of the same file from the same source within 2 minutes — a player reading in ranges, a page re-rendering — count once), at the same rate as sending assets, about 1053 CU / GB, with no free allowance. Like any other call it comes out of the owner's plan quota, then pay-as-you-go from the balance when quota runs out or there is no plan, and it is listed separately in usage as
media-download(result downloads). If it cannot be charged: within 1 hour of generation the file still opens and that open is not collected later; after that, other people opening it getmedia_traffic_unpaid(402) until the owner has quota or balance again; the owner can always view it in the console. - Kept for 7 days, then 404.
Billing, precisely
- Text: input, output, cache-read and cache-write tokens as reported by the model are priced separately (rates on Models & prices). Every line has its own rate; you pay the rate of the line that served you, and
x-agiplan-model-actualtells you which model that was. So thepricingof text models in/v1/models, and the prices marked "current lowest" on Models & prices, are the rates of the cheapest line available right now (lines tripped or drained don't count, and it changes as lines do); a given call may cost more, and usage details are the record. With no line available, the listed rate is the registered one. - When a model's reported usage is clearly wrong, output is billed as actually produced: if the model reports far more output tokens than the content actually delivered to you, at an impossible speed (over a thousand tokens per second across the whole call), output is billed by the content actually produced — the response's
usage(the final frame when streaming) carries that same number, matching what you're billed — and the call's trace in your usage details records what the model originally reported. Hidden reasoning (models that return only the conclusion and don't stream their reasoning) is unaffected: they report more, but at a normal speed. - Streams cut off mid-way: output already produced is billed. After a disconnect we read the upstream to the end to get the true usage, so a disconnect never skews billing either way. From the moment you disconnect, that call no longer counts against your concurrency limit, so you can send the next request right away.
- Images per image, video per second, billed per line like text: a model has several lines, each at its own price; we start on the cheapest and, if we switch, bill the line that served you, so
/v1/modelsgives a range per tier (cutocu_max). The hold uses the dearest candidate and the charge is the actual line; the difference is released at once. References cost nothing extra. Failures are free. - Failed attempts are never billed: route switches before the first byte, all lines failing (503) and refusals (4xx) cost nothing. When we switch, you pay the rate of the line that completed the request; the abandoned attempts (including a retry on the same line, and a duplicate sent to beat a slow first byte and then cancelled) are not passed on to you even if the upstream charged us for them.
- Pay-as-you-go from balance (quota used up, or no subscription): each call is converted at ¥1 = 500 CU and accumulated to 0.001 fen; we deduct once a whole fen has built up and carry the fraction to later calls instead of rounding each call up. If a call's actual usage exceeds your remaining balance or monthly ceiling when it completes, we deduct down to zero (or up to the ceiling) and do not charge the rest; pay-as-you-go then pauses until you top up or the ceiling resets next month (the 429 / 403 says whether it is the balance or the ceiling).
- The hold is estimated on the line routing picked first, tool definitions included; if we switch to a pricier line the hold is topped up for that line, and the charge is for the line that actually served you. The hold only affects concurrency and admission, never the amount charged.
- Billed on top of the list rates (only where the model's upstream really charges extra, by the upstream's rules; listed per model as
pricing_tiersin/v1/modelsand in the console's model details): long-context tier — when input (including cache) exceeds the threshold (272K for the GPT family today) the whole call is multiplied (input and cache × 2, output × 1.5); one-hour cache writes — cache writes markedttl: "1h"incache_controlare charged at the input rate × 2 (five-minute writes keep the cache-write rate); fast mode —speed: "fast"on/v1/messagesmultiplies the whole call by the model's fast factor; web search — the Responsesweb_searchtool and Anthropic server-side search are charged per search; hosted image generation — the Responsesimage_generationtool is charged per image. All of this is included in the call's CU, and the trace in your usage history says which item was added. They apply only when the line that actually served the call is billed per use; subscription-backed lines add none of them (so the same model may add them on one line and not another:pricing_tiers.applies_onismetered_lines_only, andsome_lines_exempt: trueis set when the model has subscription-backed lines). - Retired models: naming one returns
410 model_retired; nothing is sent or billed, and the message names a replacement.
When to retry
| Situation | Advice |
|---|---|
429 quota_exceeded / concurrency_exceeded | Check retry-after and the three quota headers; for concurrency, wait for earlier calls to finish |
503 all_upstreams_failed | Retry with exponential backoff; with auto the gateway has already tried other lines and tiers |
400 upstream_rejected / capability_unavailable | Do not retry unchanged — the answer will not change. Rewrite the prompt or drop the requirement |
Mid-stream upstream_interrupted | Output so far is billed; a stream that fails before producing anything is not billed. Start a new request |
| A stream that errors after more than 20 seconds of waiting | Headers have already gone out as 200 (while waiting we send a : keepalive comment every 15 seconds so idle connections are not cut); the error arrives as an in-stream error event with the same codes as above — handle it by code |
Video job generation_failed | Not billed; resubmit |
The full error-code table is in the Error codes section of the quick start.