← Back to home

Developer docs

Advanced usage & API reference

For people writing their own code: every request and response header, how to control output caps and routing, the full image and video APIs, and exactly how billing works.

Last updated
2026-10-14

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.

HeaderValueEffect
AuthorizationBearer ap_live_…Required on every /v1/* endpoint. See Authentication in the quick start
X-AGIPlan-Difficultydecimal 0–1Overrides 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-Msms, clamped to 5,000 – ceilingHow 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-Sceneweb / agent / batch / chatTells 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-Tokens1 / true / yes / onRequire 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-versione.g. 2023-06-01Send it as the official SDK does on /v1/messages
anthropic-betacomma-separatedForwarded 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).

HeaderExampleMeaning
x-agiplan-tracetr_9f3c…Trace ID of this call
x-agiplan-model-actualgemini-3.8-flashThe 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-requestedgemini-3.8-flashOnly 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-swapmissing:toolsComes with the previous header: which capability the named model lacked
x-agiplan-billed-groupfastWhich tier's quota the call was billed against
x-agiplan-failovertrueA route switch happened before the first byte. Present only when it happened
x-agiplan-quota-session / -week / -monthremaining=72%; resets=2026-10-11T08:00:00.000ZRemaining share and reset time of each quota window. Also sent on 429 so you can decide how long to back off
x-agiplan-dropped-paramstemperature,content[].image_urlParameters that were dropped or downgraded. We never swallow parameters silently
x-agiplan-max-tokens-default4096You sent no max_tokens; this is the value we filled in. It sizes the reservation and is really sent to the model
x-agiplan-cu51.462Image endpoints: CU charged for this call
retry-after30On 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 send max_tokens explicitly (max_output_tokens on Responses).
  • On Responses, if you omit max_output_tokens we 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 carry loose_max_tokens in capabilities on /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 skips loose_max_tokens lines, and auto or 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 400 strict_max_tokens_unavailable instead 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"}]}'
Require a strict output cap

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.

FlagMeaning
toolsTool calling (function calling). Required by coding agents such as Codex and Claude Code
visionImage input: OpenAI image_url, Anthropic image blocks and Responses input_image are all translated
jsonJSON mode / structured output (response_format)
longLong context
thinkingExtended thinking can be enabled
responsesNative 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
imageImage model: use /v1/images/generations and /v1/images/edits
videoVideo 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_tokensThe upstream does not guarantee max_tokens; see the previous section
thinking_forcedThinking 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.

ParameterNotes
modelDefaults 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
promptRequired
sizeDefaults 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
nDefaults to 1; 1–4. Billed per image actually produced: 3 out of 4 succeeding bills 3
qualityauto / 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_formatOmitted 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_format is reported truthfully as png / 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_image on image models in /v1/models gives, for 1k / 2k / 4k, cu (from: the cheapest line) and cu_max (the dearest), plus the pay-as-you-go CNY; pinnable lists each pinnable line with its own price.
  • References: the input images sent to /v1/images/edits cost nothing extra; you pay per output image, same as generation.
  • Common edits error 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.

EndpointNotes
POST /v1/videosSubmit; 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}/contentDownload the clip (mp4). Needs your key; supports Range (seeking)
GET /v1/videosThe 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
ParameterNotes
modelDefaults 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
promptRequired, up to 4,000 characters. Shot lists work best: duration, style, timed shots, dialogue, sound design, negatives
resolution480p / 768p / 1080p, per the model's pricing.per_second in /v1/models. Defaults to the model's lowest tier
secondsInteger, defaults to 5. Up to 15 s at 480p / 768p, 10 s at 1080p (duration also accepted)
aspect_ratio16:9 (landscape, default) or 9:16 (portrait)
imagesOptional 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
audiosOptional reference audio (voice, music), up to 3, same formats (counts toward the same base64 total as images)
assetsOptional ids of assets in the asset library (ast_…). Counted together with images / audios
seedOptional 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
Submit and poll (Python)
  • 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, status stays in_progress, progress restarts from 0, and retries counts 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's error).
  • 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.

EndpointDescription
POST /v1/assetsRegister 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}/confirmConfirm 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/assetsList 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 fieldDescription
kindimage or audio
mimeImages: image/png, image/jpeg, image/webp (no SVG). Audio: audio/mpeg, audio/wav, audio/mp4, audio/m4a, audio/aac, audio/ogg
bytesFile size in bytes. Images up to 20 MB, audio up to 50 MB; an upload larger than declared is deleted
sha256SHA-256 of the file contents (64 lowercase hex chars), used for de-duplication
nameOptional 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()
Upload a reference image and use it in a video (Python)
  • Referencing: put asset ids in assets on /v1/videos. They can be mixed with images / 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: images first, then the images in assets in 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) and asset-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_exceeded 429 / no_subscription 403, 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 / audios in 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).

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-actual tells you which model that was. So the pricing of 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/models gives a range per tier (cu to cu_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_tiers in /v1/models and 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 marked ttl: "1h" in cache_control are charged at the input rate × 2 (five-minute writes keep the cache-write rate); fast mode — speed: "fast" on /v1/messages multiplies the whole call by the model's fast factor; web search — the Responses web_search tool and Anthropic server-side search are charged per search; hosted image generation — the Responses image_generation tool 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_on is metered_lines_only, and some_lines_exempt: true is 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

SituationAdvice
429 quota_exceeded / concurrency_exceededCheck retry-after and the three quota headers; for concurrency, wait for earlier calls to finish
503 all_upstreams_failedRetry with exponential backoff; with auto the gateway has already tried other lines and tiers
400 upstream_rejected / capability_unavailableDo not retry unchanged — the answer will not change. Rewrite the prompt or drop the requirement
Mid-stream upstream_interruptedOutput 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 waitingHeaders 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_failedNot billed; resubmit

The full error-code table is in the Error codes section of the quick start.