Dashboard
Needs attention
3Top users by cost
Top endpoints & models
share of estimated costNeeds attention
on-call view · triage before metricsCost & error rate
overlaid · 24hCost
per hourError rate
%Top users
Alerts
3Credit Checks
Logs
Recent requests
from usage_ledger · newest firstUsers & Plans
New user
admin-provisioned · sign-in id may be a username or an email · no self-service signupLeave the password blank for API-key-only accounts — clients authenticate with a Bearer key (issue one under API Keys). A password is only needed for console/admin sign-in.
All accounts
Plan changes, disables and session revocations are recorded in the Audit Log. Your own account is protected from self-lockout.
Roles & Permissions
What each role can do
admin vs. regular user (free / pro)The admin plan has full console access and sees every account's data. A regular user (the free and pro plans) can sign in to the console too, but only ever sees their own records — the gateway scopes every query to the signed-in user.
| Area / capability | Admin | Regular user |
|---|---|---|
| Dashboard | All traffic | Own activity only |
| Logs | All requests · delete rows | Own requests · read-only |
| Analyst | All users | Own usage only |
| Photo Gallery | All images · delete | Own images · favourite, delete |
| Users & Plans | ✓ | — |
| Policies | ✓ | — |
| API Keys | ✓ | — |
| Image Costs | ✓ | — |
| Credit Checks | ✓ | — |
| Providers & Models | ✓ | — |
| Tests | ✓ | ✓ |
| Connect a Client | ✓ | ✓ |
| Release Notes | ✓ | ✓ |
| Notifications | ✓ | — |
| Audit Log | ✓ | — |
| Database size | ✓ | — |
A regular user may delete their own generated images from the Photo Gallery. This removes only the stored image — the billing / transaction record in the Logs (usage ledger) is always retained. Only admins can delete Logs rows.
Policies
Rate limits & quotas per plan
enforced live by the gateway — per-user token bucket (rpm) + monthly request quotaChanges apply immediately to new requests. Leave monthly requests blank for unlimited. Edits are audit-logged.
API Keys
Providers
Image Generation Models
Text & Chat Models
Image Understanding Models
Video Generation Models
Logs (Video)
Completed video links may expire after 24 hours. Save the output promptly; a pending task is not a completed or billed result.
Test a Provider & Model
Test Image Generation
Test Video Generation
Test Speech Generation
Test Image Understanding
Notifications
Alert rules
evaluated on live 24h stats · firing rules appear in “Needs attention” on the dashboardAdd rule
Email delivery is not configured yet (no SMTP) — alerts are in-console only.
Audit Log
Admin & security events
append-only · newest firstAnalyst
Analyst #2
Video Analysis
Image Costs
Photo Gallery
Database size
Preferences
Your settings
How this console looks to you. Stored in your browser, applied only to your view — changing them affects nobody else and alters no stored data.
Display timezone
timestamps in Logs, Analyst, and Audit LogChanges apply immediately. The timezone button in the top bar also switches this setting.
Display currency
cost figures in Dashboard, Logs, Analyst and Photo GalleryThe gateway records every cost in US$ — that is what is stored, and what the vendor invoices are reconciled against. This setting changes only how the figure is shown; nothing is re-billed and no stored number changes.
Administrator settings
Operational state for whoever runs this gateway. Visible to administrators only — it describes the service, not your view of it.
Daily spend warning
red bar on the Dashboard · push to admin iPhonesWhen today's spend across every vendor — counted from 00:00 server time — reaches this amount, the Dashboard shows a red bar at the top and every admin signed in on the iOS app gets a push notification, once per day. Set 0 to turn it off. The amount is in your display currency (Preferences above); it is stored in US$ at this month's rate, like every cost on the gateway.
Routine check schedule
last checked · next dueTracked review dates — see Maintain → Routine Check for the full checklist. The Dashboard warns when a check is overdue.
Connect a Client
How Android / iOS / Web and any HTTP client should call this gateway. Clients call your gateway, never the AI provider directly — provider keys never leave the server. Pick your platform:
Config
current environment| Base URL | {{BASE_URL}} |
| HTTP | http://api-gateway.wmazing.cloud |
| HTTPS | https://api-gateway.wmazing.cloud |
| API version prefix | /v1 |
| Auth header | Authorization: Bearer <API_KEY> |
| Content type | application/json |
| Client timeout | per model / async video Read the selected model timeout; video uses asynchronous saved tasks, not a whole-render HTTP wait. |
| Transport | HTTP and HTTPS supported — HTTPS is recommended; HTTP remains supported without an automatic redirect |
1 · Authenticate
Bearer API keyA client needs only its API key — send it on every call:
Authorization: Bearer <API_KEY>
The key authorises the request and identifies the account for rate limits, quotas and usage. The AI provider's keys stay on the gateway and are never exposed to clients.
One key works everywhere. Auth is per-user, not per-endpoint — the same key authenticates chat, image understanding (/v1/img2txt), usage, models and all three image routes. Image routes check the key before any provider work, so an image is never returned without a valid key.
Keys are issued manually by an administrator in the gateway console — there is no self-service signup and no automatic key generation. A client app or web page carries only its issued API key — it never holds an account email or password. Need a key issued, rotated, or revoked? Ask your gateway administrator.
2 · Call the gateway
Bearer auth required| Endpoint | Purpose |
|---|---|
| POST /v1/chat | live Chat completion. Body { messages:[{role,content}], model?, max_tokens? } — messages non-empty; model? defaults to the first enabled text model, max_tokens? is validated, not clamped — above the model's own max_output_tokens you get 400 naming the ceiling (default 1024; read the ceiling from GET /v1/models). A conversation longer than the model's context window is refused 413 before any provider call. "stream": true is live — the response becomes an SSE token stream and the generation wall no longer applies; see below. timeout_ms? lets you set your own window: it is clamped to the model's ceiling (you can ask for less, never more), and the value actually in force comes back as result.limits.timeout_ms before you send a single token. |
| POST /v1/img2txt | live Image understanding (看圖答問) — picture in, text out. Body { image | images:[…], prompt, model?, image_mime?, max_tokens?, timeout_ms? }, where prompt is the question about the picture (“How many people are in this image?”) and images takes up to 8. It is a text call that happens to take a picture: it resolves from the text catalog, is billed per token, and returns result.message.content. model? must be vision-capable — list them with GET /v1/models?vision=1; a non-vision model is refused 400 rather than silently answering from the prompt alone. No streaming on this route. |
| POST /v1/txt2image | live Text → image. Body { prompt, model?, size?, timeout_ms? } — no source image. Windows are per model and per capability; the reply carries result.limits (timeout_ms, timeout_source, timeout_clamped, buffered_strategy). |
| POST /v1/image2image | live Edit ONE source photo. Body { image, prompt, model?, image_mime?, size?, timeout_ms? } — image = base64 / data: URL. Windows are per model and per capability; the reply carries result.limits (timeout_ms, timeout_source, timeout_clamped, buffered_strategy). |
| POST /v1/multi_image2image | live Combine up to 16 source photos. Body { images:[…], prompt, model?, size?, timeout_ms? }. Budget minutes, not seconds — two references measured at 279s on one model. Windows are per model and per capability; the reply carries result.limits (timeout_ms, timeout_source, timeout_clamped, buffered_strategy). |
| GET /v1/video/models | Discover permitted video models; submit once, then refresh or recover the saved task. |
| POST /v1/video/tasks | Discover permitted video models; submit once, then refresh or recover the saved task. |
| GET /v1/video/tasks/{request_id} | Discover permitted video models; submit once, then refresh or recover the saved task. |
| GET /v1/video/tasks?client_token=UUID | Discover permitted video models; submit once, then refresh or recover the saved task. |
| GET /v1/usage | The caller's month-to-date usage + plan limit. Does not consume the monthly quota. |
| GET /v1/models | The text models this gateway offers (admin-enabled). Use a returned id as the model on /v1/chat — one key works for every model. Lets a client build a model picker instead of hard-coding the list. Text models only — image models are not listed here. Each entry carries max_input_tokens, max_output_tokens and limits_verified — read them before sizing a request rather than guessing. Each model carries configured — false means the gateway holds no active key for that vendor, so the model is enabled but will answer 503 PROVIDER_NOT_CONFIGURED. Call GET /v1/models?configured=1 to list only what will actually run; result.unconfigured counts what was left out. Each entry also carries timeout_ms — the window that model gets for one buffered generation, set per model from measurement rather than a flat default — plus timeout_source and stream_recommended. Read the window before the conversation starts, so both sides agree what "too long" means. tokens_per_sec and max_tokens_suggested are measured figures — null means never measured, not unlimited. They let a buffered client size its first request instead of finding the limit through timeouts; streaming is not bound by them. |
| GET /v1/image-models | The image models this gateway offers — the image analogue of GET /v1/models, which is text-only and never lists them. Use a returned id as the model for /v1/txt2image, /v1/image2image and /v1/multi_image2image; each entry's capabilities say which of the three it is valid for, and max_images is the multi-image reference cap. Each model carries servable — false means don't offer it: either the gateway holds no key for that vendor, or the vendor permanently refused it on the last real call (unservable_reason = no_provider_key or vendor_refused, with last_status/last_at as the evidence). A vendor can refuse a model the gateway has correctly enabled — an account that has not activated it, for instance — and no gateway setting fixes that. Call GET /v1/image-models?servable=1 to list only what will run; result.unservable counts the rest. |
Image generation is live Text → image, image → image and multi-image → image all return real results today (default model qwen-image-2.0, ~8–12 s). Chat is live /v1/chat proxies to the AI provider and returns a 200 completion — read result.message.content plus usage. Auth, rate limits, quotas and usage tracking are all live.
Image result: { type:"image", ai_type, model, images:[{url}|{b64,mime}], latency_ms, provider_request_id }. Qwen returns a signed URL that expires in ~24 h — fetch and cache it promptly, don't persist the URL. A model must be enabled for the requested capability, or the route returns 400 VALIDATION_ERROR "Model … is not enabled".
size is optional — and the rules are the vendor's, not the gateway's. Omit it and the model's own default applies; that is the safest choice. If you do send it, the format is WIDTHxHEIGHT with a lower-case x (1024x1024) — not width*height, not 2k, not an aspect ratio like 1:3. The gateway forwards your value to the vendor unchanged and validates nothing, so the vendor decides — and each one decides differently:
- OpenAI (
gpt-image-…): width and height must both be divisible by 16, and a size below the model's minimum pixel budget is refused.1664x2216and512x512are both rejected, for those two different reasons. - Volcengine / Ark (
doubao-seedream-…): the image must total at least 3,686,400 pixels.1536x2048is only 3,145,728 and is refused even though the format is correct.1k/2k/4kare also accepted here. - Qwen (
qwen-image-…): send the sameWIDTHxHEIGHTform — the gateway rewrites it to DashScope'swidth*heightfor you. This is the only vendor for which it does so, which is why a*that works against DashScope directly fails against every other model here.
A rejected size returns 400 VALIDATION_ERROR with the vendor's own sentence in error.message — for example "image size must be at least 3686400 pixels". Read that message and change the value: the same size retried returns the same 400 every time, and each attempt still costs a request against your quota.
partial_images is optional (integer 0–3) and works on every model. It asks the vendor to stream progressive previews while the image renders. The gateway clamps it to the vendor's ceiling and reports what it applied — never a vendor-based 400, the same contract as timeout_ms. Read the ceiling per model from GET /v1/image-models → max_partial_images (OpenAI 3, every other vendor 0 as of 2026-09-12). When you asked for 1 or more, the result carries partial_images:[{index,b64,mime}] together with the finished image — and it may be empty: OpenAI finishes a fast render before emitting a preview. Check limits.partial_images_requested / _applied / _max / _clamped. Each preview that arrives costs +100 image-output tokens (about $0.003) and is included in usage.
/v1/usage result: { period, plan, requests, monthly_request_limit (null = unlimited), prompt_tokens, completion_tokens, total_tokens, estimated_cost, source }. There is no per-minute field — derive the per-minute cap from the plan below.
Error handling
normalized envelopeEvery response carries an X-Request-Id header. On success the id is also the top-level request_id; on errors it is error.request_id (there is no top-level request_id on errors). Always read the HTTP status and log the request id. Success and error bodies differ:
// success (HTTP 2xx)
{ "request_id": "req_…", "result": { … }, "usage": null, "error": null }
// error (HTTP 4xx / 5xx)
{ "error": { "code": "RATE_LIMITED", "message": "…", "request_id": "req_…" } }
| HTTP | code | Meaning |
|---|---|---|
| 400 / 413 | VALIDATION_ERROR | Bad request shape, or body too large. JSON routes cap at 256 KB. Image routes accept up to 64 MB at the app, but the reverse proxy (nginx client_max_body_size, currently ~5 MB) is the limit you hit first — keep source images well under a few MB. |
| 401 | AUTH_REQUIRED / AUTH_INVALID | API key missing, invalid, or expired → supply a fresh key. |
| 403 | AUTH_INVALID | Account disabled (or admin-only route). |
| 429 | RATE_LIMITED / QUOTA_EXCEEDED | Too fast, or monthly quota used up. Honour Retry-After. |
| 503 | PROVIDER_TIMEOUT | The gateway abandoned a buffered generation at its 30s wall. Carries retriable: false — an identical request times out identically. Retry smaller (halve max_tokens), switch to stream: true, or not at all. |
| 503 | PROVIDER_NOT_CONFIGURED | No active provider key for that vendor, though GET /v1/models listed the model. Carries retriable: false and vendor. Pick another model — or use ?configured=1 so your picker never offers it. |
| 503 | PROVIDER_ERROR | A genuine transient upstream fault. Carries retriable: true — retry 1–2× with exponential backoff and jitter. |
Branch on error.retriable, not on the message text. These three used to arrive as one PROVIDER_ERROR, so a client had to string-match English prose to answer the only question that matters — is retrying worth anything? Treating every 503 as retriable costs three 30s failures, 90s before the user sees anything, and none of those retries can succeed.
Long answers — stream them
field-measured · 2026-08-16Send "stream": true and the 30-second wall stops applying. That is the fix; everything below it is what you need only if you cannot stream. The response becomes text/event-stream: one start event, a delta event per chunk of text, then exactly one done or error, then the literal data: [DONE] — so a client never has to guess how the stream ended.
POST /v1/chat { "model": "…", "messages": [ … ], "max_tokens": 2500, "stream": true }
event: start
data: {"request_id":"req_…","model":"claude-sonnet-5","limits":{…}}
event: delta
data: {"text":"Cache-Control"}
event: done
data: {"usage":{…},"limits":{…},"latency_ms":46013}
data: [DONE]
Why streaming removes the wall. A buffered call has to guess a timeout, because until the response lands it cannot tell a slow model from a dead one. A stream can tell — tokens are either arriving or they are not — so it is bounded by silence (30s with no data) rather than by total length, and answer size stops being a failure mode. An error event carries partial_chars: a stream that dies mid-answer still delivered real text, and you can keep it.
Measured on this gateway, 2026-08-17. Streaming helps in two distinct ways. It removes the wall: grok-4.6 at just max_tokens: 512 fails buffered with 503 PROVIDER_TIMEOUT, because it spends 18.2 seconds reasoning before its first token — streamed, the same call finishes in 18.9s. Note that it is thinking time, not output length, that blew the budget. And it removes the wait: claude-sonnet-5 at max_tokens: 2500 does complete buffered, in 24.8s — but with only ~5s of headroom, and the user sees nothing for 25 seconds. Streamed, the first token lands at 3.5s. Streaming costs the same and is logged the same: both paths write identical usage and trace rows.
If you cannot stream: sizing max_tokens
buffered calls onlyBuffered calls are still abandoned at 30 seconds. A model writes until it finishes or hits max_tokens, so that field sets the worst-case output length and therefore the worst-case time. Against a fixed 30s wall it is a time budget wearing a token label, and the safe value is per model, not per API:
safe max_tokens ≈ (that model's tokens/sec) × 25 # 25s, leaving margin
The old trap, now half-closed. The gateway used to clamp an oversized max_tokens and carry on, so probing for the largest accepted value returned the wrong ceiling. It now rejects with 400 and hands back max_output_tokens and max_tokens_suggested. What it still cannot do is check whether an in-range budget will finish in 30s — which is what max_tokens_suggested is for, and why streaming is the better answer.
Throughput measured against this gateway on 2026-08-16 with ~5,600-token prompts, non-streaming. These figures move as providers and hardware change — treat them as a starting point and record your own.
| Model | tokens / sec | Fits in one 30s call | Note |
|---|---|---|---|
| claude-haiku-4-5 | ~120 | ~3,600 | |
| deepseek-v4-pro | ~100 | ~3,000 | |
| deepseek-v4-flash | ~95 | ~2,800 | |
| doubao-seed-2-0-mini-260215 | ~90 | ~2,500 | |
| gpt-5.6-terra | ~90 | ~2,500 | borderline at 4,096 |
| qwen3.5-flash | ~85 | ~2,000 | times out at 4,096 |
Reasoning models are missing from this table on purpose. claude-opus-5, claude-sonnet-5, claude-fable-5, qwen3.8-max and grok-4.6 report tokens_per_sec: null because a rate does not describe them: their cost is a large fixed thinking period followed by a fast tail. grok-4.6 spent 18.2 seconds before its first token and then produced 35 tokens — which computes to 2 tok/s and a "safe budget" of zero. Stream these models rather than sizing a buffered budget for them; a buffered grok-4.6 call at 512 tokens returns 503 PROVIDER_TIMEOUT.
Need more output than one buffered call can hold? Stream it. If you genuinely cannot, the fallback is to continue, not enlarge:
| Step | What to do |
|---|---|
| 1 · Continue | Ask for a safe max_tokens. If usage.completion_tokens comes back at the budget, the answer was cut off — send [original prompt, assistant: text-so-far, "continue from exactly where you stopped"] and concatenate. Each part is its own sub-30s call. |
| 2 · Expect slowdown | Continuations get slower, because each one re-sends everything written so far. Part N runs nearer the wall than part 1 — and keep the parts you already have when one fails. |
| 3 · Halve and remember | On a timeout, halve that model's max_tokens and persist the learned value. There is rarely room to fail at 30s and then succeed within one request; the value of a failure is what it teaches the next one. |
| 4 · Promote carefully | Raise a learned-down budget only when a model's slowest call used well under the window (<70%). Part count alone is not evidence of headroom. |
| 5 · Don't hold the connection | Do not keep one HTTP request open for a whole multi-call answer. Behind a reverse proxy (nginx defaults to a 60s read timeout) it returns an opaque 504 — the gateway calls are legal, the transport is not. Run generation as a background job and let the client poll. |
Source: a production reporting feature integrating nine models, 2026-08-16. A report needing ~8,600 tokens completed in nine parts on the slowest model.
Timeouts, and knowing which of the three parties stopped
provider · gateway · clientThree parties sit on every call — the AI provider, this gateway, and your client — and any of them can end it. The gateway states its own window up front so you never have to guess which one gave up.
| Field | Where | What it tells you |
|---|---|---|
| timeout_ms | request (optional) | Your own window. Clamped to the model's ceiling — you may ask for less, never more, because the gateway's ceiling is itself held under the reverse proxy's limit. |
| limits.timeout_ms | response · SSE start | The window actually in force for this call. On a stream it arrives in the start event, before the first token, so both sides agree on "too long" from the outset. |
| limits.timeout_source | response | measured = set for this model from real verification runs; default = the standard window. |
| limits.timeout_clamped | response | true if your requested window was reduced. You are never silently given a different deal than you asked for. |
| limits.stream_recommended | response · GET /v1/models | This model — or this particular budget — is unlikely to finish buffered. Stream it. |
| limits.buffered_strategy | response · GET /v1/models | direct = one ordinary call. via_stream = the gateway reads the provider's own stream internally and hands you the finished reply — used for models that think for tens of seconds before writing, which no ordinary request can survive. Your code does not change. |
Some models are served by copying the provider's method. A model that thinks for a long time before emitting anything leaves the gateway holding a silent connection, unable to tell working from dead — grok-4.6 failed buffered at 30s and again at 55s. For those models the gateway now requests a stream from the vendor even when you asked for one reply, reads it as it arrives, and returns the assembled answer in the ordinary envelope. Because data is flowing, the call is judged on silence rather than total time. Measured: the same prompt fails at a 30s window and returns 4,361 characters in 57s on the model's own strategy. This is per model, not global — a direct call stays simpler wherever the ordinary window already fits.
Which party stopped? A gateway timeout is always 503 PROVIDER_TIMEOUT with retriable:false, timeout_ms, and a message naming the fix — never a silent hang. If you instead receive an opaque 504 with no JSON body, that is the reverse proxy, not the gateway: your own window exceeded the proxy's. If the connection simply ends mid-stream with no done and no error, the client or the network dropped it — every gateway-side ending is explicit.
Nothing is truncated in transit. Whatever the provider returns is passed through byte-for-byte: the gateway does not trim, summarise or re-encode replies on either path. Verified by asking a model for a 250-line ordered sequence and checking every line survived — buffered and streamed produced identical content, and what was stored matched what was delivered. The one deliberate limit is that stored trace text is capped at 64 KB per item for the Logs viewer; that caps what is kept for inspection, never what is sent to you.
Short answers and long answers want different handling. A short reply is fine buffered — one request, one JSON response, simplest to write. A long one should be streamed: buffered, its whole cost lands in one window and the user sees nothing until it finishes; streamed, the first token typically arrives in under a second and the window only ever measures silence. The dividing line is per model, and the gateway hands you the numbers to make that call — max_tokens_suggested for the budget, stream_recommended for the verdict.
Rate limits & quotas
per plan · tunable in Policies| Plan | Requests / min | Requests / month |
|---|---|---|
| free | 30 | 1,000 |
| pro | 120 | 100,000 |
| admin | 600 | unlimited |
On 429: respect Retry-After, then back off with jitter. For video network timeouts: recover the saved client_token; never automatically resubmit. An Idempotency-Key: <uuid> header is reserved for safe retries (not yet enforced server-side).
Dev log
feed back to improve the specPurpose: this spec is still evolving, and the author cannot see your environment or the points where it was unclear. A short dev log is the only feedback channel — we read these logs to correct and clarify the spec for the next developer. The platform prompts above already include the full instruction; keep a DEV_LOG.md and deliver it with your code.
Record at least:
| Log | What to capture |
|---|---|
| Build platform | OS + version, IDE / build tools + versions, language / SDK / runtime versions. |
| Deployment / target platform | Device, emulator, browser, or server where it runs — plus OS / version. |
| Tools used | AI assistant + model, libraries + versions, and anything else you relied on. |
| Failures & fixes | What failed and how you solved it — or could not solve, and why. |
| Spec gaps | Anything missing, ambiguous, wrong, or that you had to assume. |
Anything else that would help the next developer or improve the spec is welcome.
Video generation · Stages 1–4
Discover permitted video models; submit once, then refresh or recover the saved task.
Administrators can compare saved video generations and cost per requested second by model and resolution in Video Analysis. This read-only console page is not a client API and never starts a generation.
| Model / supported modes | Estimate: USD / million output tokens |
|---|---|
| dreamina-seedance-2-0-mini-260615 txt2vid / imgf_txt2vid / imgfl_txt2vid / imgrs_txt2vid | 3.50 (480p/720p) |
| dreamina-seedance-2-5-260628 imgf_txt2vid / imgfl_txt2vid / imgrs_txt2vid | 10.70 (480p/720p) 11.70 (1080p) |
Use the same gateway key when its provider mappings and mode credentials permit it. Never send the BytePlus key from a client.
GET /v1/video/models
POST /v1/video/tasks
GET /v1/video/tasks/{result.request_id}
GET /v1/video/tasks?client_token={PERSISTED_UUID}
Ordered images / provider roles
txt2vid: []
imgf_txt2vid: [FIRST_DATA_URL] -> first_frame
imgfl_txt2vid: [FIRST_DATA_URL, LAST_DATA_URL] -> first_frame, last_frame
imgrs_txt2vid: [REF1_DATA_URL, REF2_DATA_URL] -> reference_image, reference_image
Default 480p; choose only an enabled 480p/720p/1080p tier from model discovery. Mini supports 4–15 whole seconds and Seedance 2.5 supports 4–30. No audio. Prompt: 1–4000 characters. JPEG/PNG/WebP data URLs only; each encoded URL ≤8,000,000 characters, app JSON ≤16 MiB; live public proxy ≤5 MiB total JSON.
{
"client_token": "REPLACE_WITH_PERSISTED_UUID",
"model": "dreamina-seedance-2-0-mini-260615",
"mode": "txt2vid",
"prompt": "A man is trying to catch up a train",
"resolution": "480p",
"duration": 5,
"images": []
}
POST JSON (persist client_token before sending):
{
"client_token": "REPLACE_WITH_PERSISTED_UUID",
"model": "dreamina-seedance-2-0-mini-260615",
"mode": "txt2vid",
"prompt": "A man is trying to catch up a train",
"resolution": "480p",
"duration": 5,
"images": []
}
Save the UUID and immutable draft before POST. Read result.request_id and result.state, not HTTP 200 alone. Poll about every 5 seconds while active; after a timeout recover by client_token. Never automatically submit a new token.
Only succeeded is success. failed / cancelled / expired / rejected / submit_unknown require visible review. Submission timeout is not rendering duration. Download temporary output URLs promptly; use Refresh video status and Download video from server.
Costs are estimates, not confirmed charges. Missing input tokens and provider render time remain unknown. Polling does not create a second generation. Plan deduction, cash payable and balance must be checked separately.
Reference images guide content, not endpoints. Correct roles do not guarantee frame fidelity. Provider portrait restrictions and failed visual comparisons remain failures, not successful tests.
Android (Kotlin)
Prompt for your AI
paste into Claude / ChatGPT / CursorEdit the CONFIG block at the top (base URL + your API key), then copy the whole prompt and hand it to an AI coding assistant — it contains everything it needs to build the Android integration. The bottom of this page is a manual reference if you'd rather write it yourself.
# API_Gateway · spec version {{GATEWAY_VERSION}} · last updated {{SPEC_UPDATED}}
# TASK
Implement an Android (Kotlin) client for our AI Gateway, following the spec below.
# ===== CONFIG (edit these — kept at the TOP so they're easy to find & change) =====
const val BASE_URL = "{{BASE_URL}}"
const val API_KEY = "PASTE_YOUR_GATEWAY_API_KEY_HERE" // sent as: Authorization: Bearer <API_KEY>
// =====================================================================================
# ABOUT THE SYSTEM (read this)
Transport: HTTPS https://api-gateway.wmazing.cloud (recommended); HTTP http://api-gateway.wmazing.cloud is also supported. No forced redirect or HSTS upgrade. Set BASE_URL to either complete URL, without a trailing /v1.
The gateway sits in front of an AI provider. Clients call the gateway, never the
provider directly — provider keys never leave the server. All endpoints live under
BASE_URL + "/v1". Every response carries an X-Request-Id header for tracing. Success bodies (HTTP 2xx) are JSON
{ request_id, result, usage, error:null }; error bodies (HTTP 4xx/5xx) are { error:{ code, message,
request_id } } — there is NO top-level request_id/result/usage on errors, so read the id from
error.request_id or the X-Request-Id header.
# AUTH
Send your key on every call: Authorization: Bearer <API_KEY>
(Get the key from the gateway console. A 401 means the key is missing, invalid, or expired.)
# ENDPOINTS
POST /v1/chat { messages:[{role,content}], model?, max_tokens? } (messages must be non-empty)
LIVE — the gateway proxies to the AI provider. Success result:
{ type:"text", model, message:{role:"assistant",content}, latency_ms, provider_request_id },
plus a usage:{prompt_tokens,completion_tokens,total_tokens} block — parse result.message.content.
model? optional (defaults to the first enabled text model; or pass an id from GET /v1/models).
max_tokens? is VALIDATED against the model's own max_output_tokens, not clamped (default
1024). Above the ceiling you get 400 naming it, plus max_tokens_suggested. Read the
ceiling from GET /v1/models; the reply echoes what was applied in
result.limits.max_tokens_applied. A conversation longer than the model's context window
is refused with 413 before any provider call is spent.
"stream": true IS SUPPORTED — see "# STREAMING" below. Streaming is the right answer for
anything longer than a short reply: it is not subject to the buffered generation wall.
timeout_ms? optional — YOUR window, in milliseconds. Clamped to the model's ceiling: you may
ask for less, never more. The window actually in force comes back as
result.limits.timeout_ms (and in the SSE `start` event, BEFORE the first token), with
timeout_source (measured|default) and timeout_clamped so you are never silently given a
different deal than you asked for.
# --- Image understanding / 看圖答問 (LIVE) — image in, TEXT out ---
POST /v1/img2txt { image | images:[...], prompt, model?, image_mime?, max_tokens?, timeout_ms? }
image = base64 or a data: URL of ONE photo; images = an array (max 8) to ask about several
at once. prompt = the QUESTION about the picture ("How many people are in this image?").
This is a TEXT call that happens to take a picture: it resolves from the TEXT model catalog,
is billed per token, and returns a message — never an image. model? must be a VISION-capable
text model THAT THIS GATEWAY HAS ENABLED for it; discover them with
GET /v1/models?vision=1, which lists exactly those (each entry carries
capabilities.vision = the model can read images, and capabilities.img2txt = this gateway
accepts image questions on it; you need the second). Omit model? and the gateway picks the
first enabled one. A model that is not enabled — or cannot see at all — is refused 400
rather than silently answering from the prompt alone, which is what several providers
themselves do and the reason the check lives here instead of at the vendor.
Success result: { type:"text", ai_type:"text.img2txt", model, message:{role,content},
image_count, latency_ms, provider_request_id, limits } — read message.content for the answer.
No streaming on this route: the whole wait is the model LOOKING at the picture, which a
stream cannot make visible. Bodies allow up to 64 MB, like the image routes.
# --- Image generation (LIVE — gateway holds the provider credential) ---
# TIMING: image windows are PER MODEL AND PER CAPABILITY, and they are wide. Measured:
# qwen-image-2.0-pro 12s with no source photo, 20s with one, 279s with two.
# gpt-image-2 ~25-30s typically, but 60s has been seen on the same request.
# So do NOT assume ~10s and do NOT set a short client-side HTTP timeout: budget minutes
# for multi_image2image. Every image reply carries result.limits — timeout_ms (the window
# actually applied), timeout_source, timeout_clamped, buffered_strategy — so you can show a
# realistic progress expectation instead of guessing. Send timeout_ms to ask for a SHORTER
# window than the model's; you can never ask for a longer one, and if you try, the reply
# says so via timeout_clamped:true. buffered_strategy is "direct" or "via_stream": on
# via_stream the gateway is consuming the provider's own image stream on your behalf and
# judging the call by silence, which is why those models no longer time out.
POST /v1/txt2image { prompt, model?, size?, timeout_ms? } no source image.
POST /v1/image2image { image, prompt, model?, image_mime?, size?, timeout_ms? }
image = base64 or data: URL of ONE source photo.
POST /v1/multi_image2image { images:[...], prompt, model?, size?, timeout_ms? }
images = non-empty array of base64/data-URL photos (max 16).
model? defaults to qwen-image-2.0 (handles all three). One gateway key authenticates every
route (auth is per-user). Success result: { type:"image", ai_type, model,
images:[{url}|{b64,mime}], latency_ms, provider_request_id }. Qwen returns a SIGNED URL that
expires in ~24h — fetch/cache promptly, don't persist it. A model must be
enabled for the capability or you get 400 VALIDATION_ERROR "Model … is not enabled for <cap>".
Image bodies allow up to 64 MB (vs 256 KB JSON); nginx caps the body too — keep images small.
size? IS OPTIONAL AND THE RULES ARE THE VENDOR'S — the gateway forwards your value unchanged and
validates nothing. Omitting it (vendor default) is safest. If you send it: "WIDTHxHEIGHT", lower-case
x, e.g. "1024x1024". NOT "width*height", NOT "2k", NOT "1:3". Per vendor:
openai gpt-image-… both dimensions divisible by 16, and not below the model's min pixel budget.
ark doubao-seedream- at least 3,686,400 px total (1536x2048 = 3,145,728 is REFUSED); 1k/2k/4k ok.
qwen qwen-image-… send "WIDTHxHEIGHT" too — the gateway rewrites it to DashScope's
"width*height". It is the ONLY vendor it rewrites for, so a "*" copied
from DashScope docs fails on every other model here.
A bad size returns 400 VALIDATION_ERROR carrying the VENDOR'S OWN sentence in error.message
(e.g. "image size must be at least 3686400 pixels"). Read it and change the value — retrying the
same size returns the same 400 and still spends a request from the quota.
GET /v1/usage -> caller's month-to-date usage + plan limit
GET /v1/image-models -> the IMAGE models this gateway offers. /v1/models is TEXT ONLY and never lists an
image model, so this is the ONLY way to discover them; use a returned id as the "model" for
/v1/txt2image, /v1/image2image and /v1/multi_image2image. Each entry carries capabilities
{txt2image, img2image, multi_image} saying which of those three routes it is valid for, plus
max_images (the multi_image2image reference cap) and servable.
servable:false means DO NOT OFFER IT — either the gateway holds no key for that vendor, or the
vendor permanently refused the model on the last real call (unservable_reason tells you which:
no_provider_key | vendor_refused; last_status/last_at are the evidence). A vendor can refuse a
model the gateway has correctly enabled — e.g. an Ark account that has not activated it — and
no gateway setting fixes that. Use GET /v1/image-models?servable=1 so your picker never offers
one that cannot run; result.unservable counts what was left out.
GET /v1/models -> models this gateway offers; use a returned id as /v1/chat's "model" (one key works for all).
Each entry carries timeout_ms (the window that model gets for ONE buffered generation — set
per model from measurement, not a flat default), timeout_source and stream_recommended.
READ THE WINDOW BEFORE THE CONVERSATION STARTS so both sides agree what "too long" means.
Each entry carries max_input_tokens / max_output_tokens / limits_verified — read those
instead of guessing — plus `configured` (false = the gateway holds no active key for that
vendor, so the model is listed but answers 503 PROVIDER_NOT_CONFIGURED) and the measured
tokens_per_sec / max_tokens_suggested (null = never measured, NOT unlimited).
Use GET /v1/models?configured=1 to list only models that will actually run;
result.unconfigured counts what was filtered out. Build your picker from that.
GET /health -> liveness (no auth)
# ERRORS (read HTTP status + error.code, log request_id)
400/413 VALIDATION_ERROR · 401 AUTH_REQUIRED / AUTH_INVALID (key missing/invalid/expired) ·
429 RATE_LIMITED / QUOTA_EXCEEDED (honour Retry-After, back off with jitter) ·
503 PROVIDER_TIMEOUT / PROVIDER_NOT_CONFIGURED / PROVIDER_ERROR (see the fault table below).
# WHICH OF THE THREE PARTIES STOPPED?
Provider, gateway, client — any of them can end a call, and they need different responses:
503 PROVIDER_TIMEOUT (JSON body, retriable:false, carries timeout_ms + stream_recommended)
THE GATEWAY gave up at its window. The message names the fix. Never a silent hang.
504 with no JSON body
THE REVERSE PROXY, not the gateway — your requested window outran the proxy's own limit.
Lower timeout_ms, or stream.
stream ends with no `done` and no `error` event
THE CLIENT or the network dropped it. Every gateway-side ending is explicit: exactly one
`done` or one `error` always precedes `data: [DONE]`.
# CONTENT IS PASSED THROUGH WHOLE
The gateway does not trim, summarise or re-encode provider replies on either path. Verified by
requesting a 250-line ordered sequence and checking every line survived: buffered and streamed
produced identical content, and what the gateway stored matched what it delivered. Assemble deltas
by simple concatenation — they are raw fragments, not sentences, and a token may split mid-word.
# LONG-GENERATION MODELS (served by copying the provider's method)
A few models cannot be served by an ordinary buffered request AT ANY TIMEOUT: they think for tens of
seconds before emitting, so the gateway holds a silent socket and cannot tell working from dead.
For those, the gateway asks the vendor for a STREAM even when you sent stream:false, consumes it
server-side, and returns the assembled answer in the normal envelope. Bytes flow, so the call is
bounded by SILENCE rather than elapsed time.
· Your code does not change — same request, same response shape.
· result.limits.buffered_strategy tells you which path ran: "direct" or "via_stream".
· GET /v1/models publishes it per model, so a picker can show it up front.
Measured on grok-4.6: a 30s window fails; the model's own strategy returns 4,361 chars in 57s.
Per model only — a direct call stays simpler wherever the ordinary window already fits.
# SHORT VS LONG — DIFFERENT HANDLING
Short reply -> buffered is fine: one request, one JSON response, simplest to write.
Long reply -> stream it. Buffered, the whole cost lands inside one window and the user sees
nothing until it ends; streamed, the first token usually arrives under a second and
the window only ever measures SILENCE.
The dividing line is per model, and the gateway gives you the numbers: max_tokens_suggested for the
budget, stream_recommended for the verdict (it is also set per-CALL when your max_tokens exceeds
what that model was measured to finish in time).
# PROVIDER FAULTS — BRANCH ON error.retriable, NOT ON THE MESSAGE TEXT
Every 503 carries a code and a boolean, so you never have to string-match English prose:
PROVIDER_TIMEOUT retriable:false the gateway abandoned a BUFFERED generation at its 30s wall.
Do not retry unchanged — it times out identically. Retry
smaller (halve max_tokens), or switch to "stream": true.
PROVIDER_NOT_CONFIGURED retriable:false no active provider key for that vendor (the error carries
`vendor`). Permanent until an operator acts. Pick another
model, or use GET /v1/models?configured=1 in your picker.
PROVIDER_ERROR retriable:true a genuine transient upstream fault: retry 1–2× with
backoff + jitter.
Treating every 503 as retriable costs three 30s failures — 90+ seconds — before the user sees anything,
and none of those retries can succeed.
# STREAMING — THE ANSWER TO LONG REPLIES
Send "stream": true and the 30s generation wall stops applying. The response is text/event-stream:
event: start
data: {"request_id":"req_…","model":"…","limits":{…}}
event: delta
data: {"text":"Cache-Control"}
event: done
data: {"usage":{…},"limits":{…},"latency_ms":46013}
data: [DONE]
Exactly one `done` OR one `error` always arrives before `data: [DONE]`, so you never have to guess how the
stream ended. An `error` event carries code, retriable and partial_chars — a stream that dies mid-answer
still delivered real text, and you can keep it. Parse deltas incrementally; do not wait for the whole body.
Why this removes the wall: a buffered call must guess a timeout, because until the response lands it
cannot tell a slow model from a dead one. A stream can tell — tokens are either arriving or they are not —
so it is bounded by SILENCE (30s with no data), not by total length.
Measured here on 2026-08-17, two distinct wins:
- IT REMOVES THE WALL. grok-4.6 at just max_tokens 512 fails buffered with 503 PROVIDER_TIMEOUT,
because it spends 18.2 s reasoning before its first token; streamed, the same call finishes in
18.9 s. Thinking time blew the budget, not output length.
- IT REMOVES THE WAIT. claude-sonnet-5 at max_tokens 2500 does complete buffered, in 24.8 s — but
with ~5 s of headroom and nothing shown to the user for 25 s. Streamed, the first token lands at
3.5 s.
Streaming costs the same and is logged the same.
# IF YOU CANNOT STREAM — SIZING max_tokens FOR A BUFFERED CALL
BUFFERED GENERATIONS ARE STILL ABANDONED AT 30 SECONDS. A model writes until it finishes or hits
max_tokens, so max_tokens sets the worst-case output LENGTH and therefore the worst-case TIME. Against a
fixed 30s wall it is a time budget wearing a token label, so the safe value is per MODEL, not per API:
safe max_tokens ≈ (that model's tokens/sec) × 25 [25s, leaving margin]
The old trap, now half-closed: the gateway used to CLAMP an oversized max_tokens and carry on, so probing
for the largest accepted value returned the WRONG ceiling. It now REJECTS with 400 and hands back
max_output_tokens and max_tokens_suggested. What it still cannot do is check whether an in-range budget
will FINISH inside 30s — which is what max_tokens_suggested is for, and why streaming is the better answer.
Measured on this gateway, 2026-08-16, ~5,600-token prompts, non-streaming. These move as providers and
hardware change — treat them as a starting point and record your own:
model tok/s fits in one 30s call notes
claude-haiku-4-5 ~120 ~3,600
deepseek-v4-pro ~100 ~3,000
deepseek-v4-flash ~95 ~2,800
doubao-seed-2-0-mini-260215 ~90 ~2,500
gpt-5.6-terra ~90 ~2,500 borderline at 4,096
qwen3.5-flash ~85 ~2,000 times out at 4,096
REASONING MODELS ARE ABSENT FROM THIS TABLE ON PURPOSE. claude-opus-5, claude-sonnet-5,
claude-fable-5, qwen3.8-max and grok-4.6 report tokens_per_sec: null because a rate does not
describe them — their cost is a large fixed thinking period plus a fast tail. grok-4.6 spent
18.2 s before its first token and then emitted 35 tokens, which computes to 2 tok/s and a "safe"
budget of zero. STREAM these models instead of sizing a buffered budget: a buffered grok-4.6 call
at 512 tokens returns 503 PROVIDER_TIMEOUT.
If you need MORE output than one buffered call can hold, stream it. If you genuinely cannot, the fallback
is to CONTINUE, not enlarge:
1. Ask for a safe max_tokens. If usage.completion_tokens comes back AT the budget the answer was cut off,
so send [original prompt, assistant: text-so-far, "continue from exactly where you stopped"] and
concatenate. Each part is its own sub-30s call.
2. Continuations get SLOWER, because each one re-sends everything written so far. Expect part N to run
nearer the wall than part 1, and keep the finished parts when one fails.
3. On a timeout, HALVE that model's max_tokens and REMEMBER IT. There is rarely room to fail at 30s and
then succeed within the same request; the value of a failure is what it teaches the next request.
4. Raise a learned-down budget only when a model's slowest call used well under the window (<70%). Part
count alone is not evidence of headroom.
5. DO NOT hold one HTTP connection open for the whole thing. Behind a proxy (nginx defaults to a 60s read
timeout) a multi-call answer returns an opaque 504 — the gateway calls are legal, the transport is not.
Run generation as a background job and let the client poll.
# RATE LIMITS (per plan; tunable server-side)
free 30/min, 1,000/mo · pro 120/min, 100,000/mo · admin 600/min, unlimited.
# WHAT TO BUILD
Using OkHttp + Kotlin coroutines (no other heavy deps), produce one file that:
1. Keeps the CONFIG block above at the very TOP so BASE_URL / API_KEY are easy to change.
2. chat(messages): POST BASE_URL + "/v1/chat" with header Authorization: Bearer API_KEY and a JSON
body { messages:[{role,content}] }; return result, or surface error.code + request_id.
3. Handle 200 ok · 401 (key invalid/expired) · 429 (honour Retry-After, back off) ·
503 (PROVIDER_TIMEOUT / PROVIDER_NOT_CONFIGURED / PROVIDER_ERROR — branch on error.retriable; only the
last of the three is worth retrying).
4. A short demo calling chat(listOf(mapOf("role" to "user", "content" to "Hello"))).
Inject API_KEY at build/runtime; don't commit it to source control.
# Video integration — Stages 1–4 (GA00.0.19.142, 2026-10-14)
This current contract supersedes earlier Mini-only / unavailable-stage descriptions. Discover permitted models and modes at runtime; provider activation alone does not enable a gateway capability.
| Model ID | Available client modes | Estimated USD / million output tokens |
| :--- | :--- | ---: |
| dreamina-seedance-2-0-mini-260615 | txt2vid, imgf_txt2vid, imgfl_txt2vid, imgrs_txt2vid | 3.50 |
| dreamina-seedance-2-5-260628 | imgf_txt2vid, imgfl_txt2vid, imgrs_txt2vid | 10.70 at 480p/720p; 11.70 at 1080p |
# Routes and authentication
All four endpoints use Authorization: Bearer <gateway key>. Provider keys remain on the server. The same issued gateway key can be reused across video modes only when the corresponding active BytePlus video.<mode> credential and any explicit provider mapping permit it. Separate app settings do not require newly issued keys.
| Method | Path | Result |
| :--- | :--- | :--- |
| GET | /v1/video/models | result.models[]: id, vendor, modes, resolution, supported_resolutions, enabled_resolutions, duration_seconds, price_usd_per_m_completion_tokens; empty when no permitted credential exists |
| POST | /v1/video/tasks | Persist one UUID client_token before sending; 202 accepted, duplicate UUID returns saved task at 200 |
| GET | /v1/video/tasks/{request_id} | Refresh an owned task; 200 contains state, not proof of completion |
| GET | /v1/video/tasks?client_token=UUID | Recover saved task without another provider submission; 404 if absent |
Responses use {request_id, result, usage:null, error:null}; the top-level request_id identifies the HTTP request, while result.request_id identifies the saved generation task. Save the latter for polling. Another user's task returns 404.
# Submission and ordered references
POST JSON: {client_token:UUID, model, mode, prompt, resolution:"480p"|"720p"|"1080p", duration:5, images:[]}. A prompt of 1–4000 characters is required. supported_resolutions and enabled_resolutions list Mini 480p/720p and Seedance 2.5 480p/720p/1080p. Submit only an enabled resolution for the selected model; 1080p is 2.5-only. Whole-second duration is Mini 4–15 or 2.5 4–30; audio is off. txt2vid uses 16:9, image modes use adaptive ratio. Administrators can review recorded model/resolution usage at /video-analyst.html; it is not a public client API.
| Mode | images[] count and order | Provider roles assigned by gateway |
| :--- | :--- | :--- |
| txt2vid | 0 (omit or []) | none |
| imgf_txt2vid | 1: first frame | first_frame |
| imgfl_txt2vid | 2: first frame, then last frame | first_frame, last_frame |
| imgrs_txt2vid | exactly 2: reference 1, then reference 2 | reference_image, reference_image |
Each image is an original JPEG/PNG/WebP base64 data URL, no remote URL; the encoded URL must be at most 8,000,000 characters (keep original bytes below 5.9 MB). The app JSON ceiling is 16 MiB, but live nginx limits this public video route to 5 MiB total JSON (verified 2026-10-13). Keep the entire base64-expanded request below 5 MiB; this is a combined-body limit, not a per-image allowance. Do not reorder, crop or re-encode reference files silently. Clients send images[], not the provider's content/role schema. A reference image guides content, not a fixed first/last frame. Real-person inputs may be rejected by provider policy. Correct submission is not proof of visual adherence: compare actual sampled first/last frames separately; preserve failures in reports.
# Asynchronous status, recovery and downloads
Persist the immutable draft and UUID before POST. Poll the saved task approximately every 5 seconds while active, slowing down after failures or backgrounding. A provider POST has a 45-second submission timeout and status fetch a 20-second timeout; neither is the video render duration. Allow HTTP overhead (e.g. 60s submit / 30s status), then recover by UUID after a connection failure. Never automatically replace the token or resubmit an uncertain/rejected task. A 404 recovery is not permission for an automatic paid retry: ask the operator to review first. Idempotency-Key is not the video's duplicate mechanism; client_token is.
Read result.state even on HTTP 200: succeeded is the only successful terminal state; failed, cancelled, expired, rejected and submit_unknown stop automatic polling and require visible error/review. A rejected or uncertain submission can be returned as a saved task at HTTP 200. Validate result.output_url before playback/download; success without a usable URL is not a playable-video pass. Treat output URLs as temporary and download promptly. Use the UI labels Refresh video status and Download video from server. Keep references and signed URLs out of public logs.
# Tokens, response time and billing
result includes request_id, provider_task_id, model, mode, state, provider_status, completion_tokens, estimated_cost_usd, output_url, error_code, error_message, created_at, updated_at. Missing values are null, never invented zero. Input tokens and pure queue/render time are not published. Record client-observed end-to-end time separately from submit HTTP time; polling time is not provider render time.
cost_basis remains "estimated" and provider_confirmed_cost_usd remains null on this client API, even when a separate finance observation exists. Estimate = completion_tokens × selected-model rate / 1,000,000 (rounded to six decimals). For example, 48,437 Seedance 2.5 output tokens estimate US$0.518276. Polling and UUID reuse do not create a second generation charge/monthly generation-quota entry; normal authentication and transport errors still apply. Provider gross cost, plan deduction, cash payable and remaining balance are separate facts. A plan-covered charge is not a zero-cost generation. Confirm actual charges in the provider console with evidence; do not automatically mark the ledger confirmed.
# Platform handling
Android: persist draft/token before OkHttp POST; resume polling from stored result.request_id after process restart. iOS: persist draft/token before URLSession POST; keep the gateway key in Keychain, preserve original picker bytes, resume status after relaunch, download via URLSession for Save/Share. Web: keep privileged keys server-side in a backend-for-frontend, not a public bundle/localStorage; a controlled test bench can accept a short-lived restricted key in memory. Both HTTP and HTTPS gateway URLs are supported; HTTPS web pages must use the HTTPS URL to avoid mixed content. cURL: load the key from an environment variable and JSON from a file; retain the UUID and returned result.request_id, never use automatic POST retries.
# Verified test boundaries
Existing simulator reports are evidence, not a new test run: Mini first/last fidelity failures remain failures despite valid wire roles; Seedance 2.5 sampled endpoint composition passed with qualifications, not pixel identity or every-object motion. Reference-mode real-person rejection and the successful illustrated-reference retest remain separate cases. Full 2.0/Fast provider tests do not imply those models are exposed to public clients. No TestFlight or device-validation claim is made by this guide update.
# DEV LOG (required — we use this to improve the spec)
This spec is still evolving and the author cannot see your environment or where the spec was
unclear, so your log is the only feedback channel. Keep a DEV_LOG.md and deliver it with the code.
Purpose: capture real integration experience so the spec can be corrected and clarified.
Record at minimum:
- Build platform: OS + version, IDE / build tools + versions, language / SDK / runtime versions.
- Target / deployment platform: device or emulator + OS version.
- Tools used: AI assistant + model, libraries + versions, anything else.
- What failed and how you solved it — or could NOT solve, and why.
- Anything in the spec that was missing, ambiguous, wrong, or that you had to assume.
- MEASURED NUMBERS, not just prose: observed tokens/sec per model you used, the largest max_tokens each
one completed reliably, and any request that timed out (with its prompt size). Those three facts build
the table in "# LONG ANSWERS", they change as providers and hardware change, and nobody can supply them
from the server side.
- Any other detail that would help the next developer or improve the spec.
Manual reference
Reference implementation — copy it if you're integrating by hand.Call /v1/chat with your API key
// API_Gateway · spec version {{GATEWAY_VERSION}} · last updated {{SPEC_UPDATED}}
val base = "{{BASE_URL}}"
val apiKey = "YOUR_GATEWAY_API_KEY"
val client = OkHttpClient()
fun chat() {
val payload = """{"messages":[{"role":"user","content":"Hello"}]}"""
.toRequestBody("application/json".toMediaType())
val req = Request.Builder()
.url("$base/v1/chat")
.post(payload)
.addHeader("Authorization", "Bearer $apiKey")
.build()
client.newCall(req).execute().use { resp ->
val reqId = resp.header("X-Request-Id")
when (resp.code) {
200 -> { /* parse result.message.content */ }
401 -> { /* API key missing / invalid / expired */ }
429 -> { val retryAfter = resp.header("Retry-After"); /* backoff */ }
503 -> { /* PROVIDER_ERROR — upstream provider failed/timed out */ }
else -> { /* read error.message, log reqId=$reqId */ }
}
}
}
Inject the API key at build/runtime — don't commit it. Never embed provider API keys in the app; the gateway holds those.
Platform notes & special scenarios · video
Discover permitted video models; submit once, then refresh or recover the saved task.
Administrators can compare saved video generations and cost per requested second by model and resolution in Video Analysis. This read-only console page is not a client API and never starts a generation.
| Model / supported modes | Estimate: USD / million output tokens |
|---|---|
| dreamina-seedance-2-0-mini-260615 txt2vid / imgf_txt2vid / imgfl_txt2vid / imgrs_txt2vid | 3.50 (480p/720p) |
| dreamina-seedance-2-5-260628 imgf_txt2vid / imgfl_txt2vid / imgrs_txt2vid | 10.70 (480p/720p) 11.70 (1080p) |
Use the same gateway key when its provider mappings and mode credentials permit it. Never send the BytePlus key from a client.
GET /v1/video/models
POST /v1/video/tasks
GET /v1/video/tasks/{result.request_id}
GET /v1/video/tasks?client_token={PERSISTED_UUID}
Ordered images / provider roles
txt2vid: []
imgf_txt2vid: [FIRST_DATA_URL] -> first_frame
imgfl_txt2vid: [FIRST_DATA_URL, LAST_DATA_URL] -> first_frame, last_frame
imgrs_txt2vid: [REF1_DATA_URL, REF2_DATA_URL] -> reference_image, reference_image
Default 480p; choose only an enabled 480p/720p/1080p tier from model discovery. Mini supports 4–15 whole seconds and Seedance 2.5 supports 4–30. No audio. Prompt: 1–4000 characters. JPEG/PNG/WebP data URLs only; each encoded URL ≤8,000,000 characters, app JSON ≤16 MiB; live public proxy ≤5 MiB total JSON.
{
"client_token": "REPLACE_WITH_PERSISTED_UUID",
"model": "dreamina-seedance-2-0-mini-260615",
"mode": "txt2vid",
"prompt": "A man is trying to catch up a train",
"resolution": "480p",
"duration": 5,
"images": []
}
// Kotlin / OkHttp: key injected securely; draft persisted before this call.
val request = Request.Builder().url("$BASE_URL/v1/video/tasks")
.header("Authorization", "Bearer $API_KEY")
.post(savedDraftJson.toRequestBody("application/json".toMediaType())).build()
// Configure client callTimeout >= 60s; no automatic POST retry.
// Parse envelope.result.request_id/state; poll GET with the same bearer key.
Save the UUID and immutable draft before POST. Read result.request_id and result.state, not HTTP 200 alone. Poll about every 5 seconds while active; after a timeout recover by client_token. Never automatically submit a new token.
Only succeeded is success. failed / cancelled / expired / rejected / submit_unknown require visible review. Submission timeout is not rendering duration. Download temporary output URLs promptly; use Refresh video status and Download video from server.
Costs are estimates, not confirmed charges. Missing input tokens and provider render time remain unknown. Polling does not create a second generation. Plan deduction, cash payable and balance must be checked separately.
Reference images guide content, not endpoints. Correct roles do not guarantee frame fidelity. Provider portrait restrictions and failed visual comparisons remain failures, not successful tests.
iOS (Swift)
Prompt for your AI
paste into Claude / ChatGPT / CursorEdit the CONFIG block at the top (base URL + your API key), then copy the whole prompt and hand it to an AI coding assistant — it contains everything it needs to build the iOS integration. The bottom of this page is a manual reference if you'd rather write it yourself.
# API_Gateway · spec version {{GATEWAY_VERSION}} · last updated {{SPEC_UPDATED}}
# TASK
Implement an iOS (Swift) client for our AI Gateway, following the spec below.
# ===== CONFIG (edit these — kept at the TOP so they're easy to find & change) =====
let BASE_URL = "{{BASE_URL}}"
let API_KEY = "PASTE_YOUR_GATEWAY_API_KEY_HERE" // sent as: Authorization: Bearer <API_KEY>
// =====================================================================================
# ABOUT THE SYSTEM (read this)
Transport: HTTPS https://api-gateway.wmazing.cloud (recommended); HTTP http://api-gateway.wmazing.cloud is also supported. No forced redirect or HSTS upgrade. Set BASE_URL to either complete URL, without a trailing /v1.
The gateway sits in front of an AI provider. Clients call the gateway, never the
provider directly — provider keys never leave the server. All endpoints live under
BASE_URL + "/v1". Every response carries an X-Request-Id header for tracing. Success bodies (HTTP 2xx) are JSON
{ request_id, result, usage, error:null }; error bodies (HTTP 4xx/5xx) are { error:{ code, message,
request_id } } — there is NO top-level request_id/result/usage on errors, so read the id from
error.request_id or the X-Request-Id header.
# AUTH
Send your key on every call: Authorization: Bearer <API_KEY>
(Get the key from the gateway console. A 401 means the key is missing, invalid, or expired.)
# ENDPOINTS
POST /v1/chat { messages:[{role,content}], model?, max_tokens? } (messages must be non-empty)
LIVE — the gateway proxies to the AI provider. Success result:
{ type:"text", model, message:{role:"assistant",content}, latency_ms, provider_request_id },
plus a usage:{prompt_tokens,completion_tokens,total_tokens} block — parse result.message.content.
model? optional (defaults to the first enabled text model; or pass an id from GET /v1/models).
max_tokens? is VALIDATED against the model's own max_output_tokens, not clamped (default
1024). Above the ceiling you get 400 naming it, plus max_tokens_suggested. Read the
ceiling from GET /v1/models; the reply echoes what was applied in
result.limits.max_tokens_applied. A conversation longer than the model's context window
is refused with 413 before any provider call is spent.
"stream": true IS SUPPORTED — see "# STREAMING" below. Streaming is the right answer for
anything longer than a short reply: it is not subject to the buffered generation wall.
timeout_ms? optional — YOUR window, in milliseconds. Clamped to the model's ceiling: you may
ask for less, never more. The window actually in force comes back as
result.limits.timeout_ms (and in the SSE `start` event, BEFORE the first token), with
timeout_source (measured|default) and timeout_clamped so you are never silently given a
different deal than you asked for.
# --- Image understanding / 看圖答問 (LIVE) — image in, TEXT out ---
POST /v1/img2txt { image | images:[...], prompt, model?, image_mime?, max_tokens?, timeout_ms? }
image = base64 or a data: URL of ONE photo; images = an array (max 8) to ask about several
at once. prompt = the QUESTION about the picture ("How many people are in this image?").
This is a TEXT call that happens to take a picture: it resolves from the TEXT model catalog,
is billed per token, and returns a message — never an image. model? must be a VISION-capable
text model THAT THIS GATEWAY HAS ENABLED for it; discover them with
GET /v1/models?vision=1, which lists exactly those (each entry carries
capabilities.vision = the model can read images, and capabilities.img2txt = this gateway
accepts image questions on it; you need the second). Omit model? and the gateway picks the
first enabled one. A model that is not enabled — or cannot see at all — is refused 400
rather than silently answering from the prompt alone, which is what several providers
themselves do and the reason the check lives here instead of at the vendor.
Success result: { type:"text", ai_type:"text.img2txt", model, message:{role,content},
image_count, latency_ms, provider_request_id, limits } — read message.content for the answer.
No streaming on this route: the whole wait is the model LOOKING at the picture, which a
stream cannot make visible. Bodies allow up to 64 MB, like the image routes.
# --- Image generation (LIVE — gateway holds the provider credential) ---
# TIMING: image windows are PER MODEL AND PER CAPABILITY, and they are wide. Measured:
# qwen-image-2.0-pro 12s with no source photo, 20s with one, 279s with two.
# gpt-image-2 ~25-30s typically, but 60s has been seen on the same request.
# So do NOT assume ~10s and do NOT set a short client-side HTTP timeout: budget minutes
# for multi_image2image. Every image reply carries result.limits — timeout_ms (the window
# actually applied), timeout_source, timeout_clamped, buffered_strategy — so you can show a
# realistic progress expectation instead of guessing. Send timeout_ms to ask for a SHORTER
# window than the model's; you can never ask for a longer one, and if you try, the reply
# says so via timeout_clamped:true. buffered_strategy is "direct" or "via_stream": on
# via_stream the gateway is consuming the provider's own image stream on your behalf and
# judging the call by silence, which is why those models no longer time out.
POST /v1/txt2image { prompt, model?, size?, timeout_ms? } no source image.
POST /v1/image2image { image, prompt, model?, image_mime?, size?, timeout_ms? }
image = base64 or data: URL of ONE source photo.
POST /v1/multi_image2image { images:[...], prompt, model?, size?, timeout_ms? }
images = non-empty array of base64/data-URL photos (max 16).
model? defaults to qwen-image-2.0 (handles all three). One gateway key authenticates every
route (auth is per-user). Success result: { type:"image", ai_type, model,
images:[{url}|{b64,mime}], latency_ms, provider_request_id }. Qwen returns a SIGNED URL that
expires in ~24h — fetch/cache promptly, don't persist it. A model must be
enabled for the capability or you get 400 VALIDATION_ERROR "Model … is not enabled for <cap>".
Image bodies allow up to 64 MB (vs 256 KB JSON); nginx caps the body too — keep images small.
size? IS OPTIONAL AND THE RULES ARE THE VENDOR'S — the gateway forwards your value unchanged and
validates nothing. Omitting it (vendor default) is safest. If you send it: "WIDTHxHEIGHT", lower-case
x, e.g. "1024x1024". NOT "width*height", NOT "2k", NOT "1:3". Per vendor:
openai gpt-image-… both dimensions divisible by 16, and not below the model's min pixel budget.
ark doubao-seedream- at least 3,686,400 px total (1536x2048 = 3,145,728 is REFUSED); 1k/2k/4k ok.
qwen qwen-image-… send "WIDTHxHEIGHT" too — the gateway rewrites it to DashScope's
"width*height". It is the ONLY vendor it rewrites for, so a "*" copied
from DashScope docs fails on every other model here.
A bad size returns 400 VALIDATION_ERROR carrying the VENDOR'S OWN sentence in error.message
(e.g. "image size must be at least 3686400 pixels"). Read it and change the value — retrying the
same size returns the same 400 and still spends a request from the quota.
GET /v1/usage -> caller's month-to-date usage + plan limit
GET /v1/image-models -> the IMAGE models this gateway offers. /v1/models is TEXT ONLY and never lists an
image model, so this is the ONLY way to discover them; use a returned id as the "model" for
/v1/txt2image, /v1/image2image and /v1/multi_image2image. Each entry carries capabilities
{txt2image, img2image, multi_image} saying which of those three routes it is valid for, plus
max_images (the multi_image2image reference cap) and servable.
servable:false means DO NOT OFFER IT — either the gateway holds no key for that vendor, or the
vendor permanently refused the model on the last real call (unservable_reason tells you which:
no_provider_key | vendor_refused; last_status/last_at are the evidence). A vendor can refuse a
model the gateway has correctly enabled — e.g. an Ark account that has not activated it — and
no gateway setting fixes that. Use GET /v1/image-models?servable=1 so your picker never offers
one that cannot run; result.unservable counts what was left out.
GET /v1/models -> models this gateway offers; use a returned id as /v1/chat's "model" (one key works for all).
Each entry carries timeout_ms (the window that model gets for ONE buffered generation — set
per model from measurement, not a flat default), timeout_source and stream_recommended.
READ THE WINDOW BEFORE THE CONVERSATION STARTS so both sides agree what "too long" means.
Each entry carries max_input_tokens / max_output_tokens / limits_verified — read those
instead of guessing — plus `configured` (false = the gateway holds no active key for that
vendor, so the model is listed but answers 503 PROVIDER_NOT_CONFIGURED) and the measured
tokens_per_sec / max_tokens_suggested (null = never measured, NOT unlimited).
Use GET /v1/models?configured=1 to list only models that will actually run;
result.unconfigured counts what was filtered out. Build your picker from that.
GET /health -> liveness (no auth)
# ERRORS (read HTTP status + error.code, log request_id)
400/413 VALIDATION_ERROR · 401 AUTH_REQUIRED / AUTH_INVALID (key missing/invalid/expired) ·
429 RATE_LIMITED / QUOTA_EXCEEDED (honour Retry-After, back off with jitter) ·
503 PROVIDER_TIMEOUT / PROVIDER_NOT_CONFIGURED / PROVIDER_ERROR (see the fault table below).
# WHICH OF THE THREE PARTIES STOPPED?
Provider, gateway, client — any of them can end a call, and they need different responses:
503 PROVIDER_TIMEOUT (JSON body, retriable:false, carries timeout_ms + stream_recommended)
THE GATEWAY gave up at its window. The message names the fix. Never a silent hang.
504 with no JSON body
THE REVERSE PROXY, not the gateway — your requested window outran the proxy's own limit.
Lower timeout_ms, or stream.
stream ends with no `done` and no `error` event
THE CLIENT or the network dropped it. Every gateway-side ending is explicit: exactly one
`done` or one `error` always precedes `data: [DONE]`.
# CONTENT IS PASSED THROUGH WHOLE
The gateway does not trim, summarise or re-encode provider replies on either path. Verified by
requesting a 250-line ordered sequence and checking every line survived: buffered and streamed
produced identical content, and what the gateway stored matched what it delivered. Assemble deltas
by simple concatenation — they are raw fragments, not sentences, and a token may split mid-word.
# LONG-GENERATION MODELS (served by copying the provider's method)
A few models cannot be served by an ordinary buffered request AT ANY TIMEOUT: they think for tens of
seconds before emitting, so the gateway holds a silent socket and cannot tell working from dead.
For those, the gateway asks the vendor for a STREAM even when you sent stream:false, consumes it
server-side, and returns the assembled answer in the normal envelope. Bytes flow, so the call is
bounded by SILENCE rather than elapsed time.
· Your code does not change — same request, same response shape.
· result.limits.buffered_strategy tells you which path ran: "direct" or "via_stream".
· GET /v1/models publishes it per model, so a picker can show it up front.
Measured on grok-4.6: a 30s window fails; the model's own strategy returns 4,361 chars in 57s.
Per model only — a direct call stays simpler wherever the ordinary window already fits.
# SHORT VS LONG — DIFFERENT HANDLING
Short reply -> buffered is fine: one request, one JSON response, simplest to write.
Long reply -> stream it. Buffered, the whole cost lands inside one window and the user sees
nothing until it ends; streamed, the first token usually arrives under a second and
the window only ever measures SILENCE.
The dividing line is per model, and the gateway gives you the numbers: max_tokens_suggested for the
budget, stream_recommended for the verdict (it is also set per-CALL when your max_tokens exceeds
what that model was measured to finish in time).
# PROVIDER FAULTS — BRANCH ON error.retriable, NOT ON THE MESSAGE TEXT
Every 503 carries a code and a boolean, so you never have to string-match English prose:
PROVIDER_TIMEOUT retriable:false the gateway abandoned a BUFFERED generation at its 30s wall.
Do not retry unchanged — it times out identically. Retry
smaller (halve max_tokens), or switch to "stream": true.
PROVIDER_NOT_CONFIGURED retriable:false no active provider key for that vendor (the error carries
`vendor`). Permanent until an operator acts. Pick another
model, or use GET /v1/models?configured=1 in your picker.
PROVIDER_ERROR retriable:true a genuine transient upstream fault: retry 1–2× with
backoff + jitter.
Treating every 503 as retriable costs three 30s failures — 90+ seconds — before the user sees anything,
and none of those retries can succeed.
# STREAMING — THE ANSWER TO LONG REPLIES
Send "stream": true and the 30s generation wall stops applying. The response is text/event-stream:
event: start
data: {"request_id":"req_…","model":"…","limits":{…}}
event: delta
data: {"text":"Cache-Control"}
event: done
data: {"usage":{…},"limits":{…},"latency_ms":46013}
data: [DONE]
Exactly one `done` OR one `error` always arrives before `data: [DONE]`, so you never have to guess how the
stream ended. An `error` event carries code, retriable and partial_chars — a stream that dies mid-answer
still delivered real text, and you can keep it. Parse deltas incrementally; do not wait for the whole body.
Why this removes the wall: a buffered call must guess a timeout, because until the response lands it
cannot tell a slow model from a dead one. A stream can tell — tokens are either arriving or they are not —
so it is bounded by SILENCE (30s with no data), not by total length.
Measured here on 2026-08-17, two distinct wins:
- IT REMOVES THE WALL. grok-4.6 at just max_tokens 512 fails buffered with 503 PROVIDER_TIMEOUT,
because it spends 18.2 s reasoning before its first token; streamed, the same call finishes in
18.9 s. Thinking time blew the budget, not output length.
- IT REMOVES THE WAIT. claude-sonnet-5 at max_tokens 2500 does complete buffered, in 24.8 s — but
with ~5 s of headroom and nothing shown to the user for 25 s. Streamed, the first token lands at
3.5 s.
Streaming costs the same and is logged the same.
# IF YOU CANNOT STREAM — SIZING max_tokens FOR A BUFFERED CALL
BUFFERED GENERATIONS ARE STILL ABANDONED AT 30 SECONDS. A model writes until it finishes or hits
max_tokens, so max_tokens sets the worst-case output LENGTH and therefore the worst-case TIME. Against a
fixed 30s wall it is a time budget wearing a token label, so the safe value is per MODEL, not per API:
safe max_tokens ≈ (that model's tokens/sec) × 25 [25s, leaving margin]
The old trap, now half-closed: the gateway used to CLAMP an oversized max_tokens and carry on, so probing
for the largest accepted value returned the WRONG ceiling. It now REJECTS with 400 and hands back
max_output_tokens and max_tokens_suggested. What it still cannot do is check whether an in-range budget
will FINISH inside 30s — which is what max_tokens_suggested is for, and why streaming is the better answer.
Measured on this gateway, 2026-08-16, ~5,600-token prompts, non-streaming. These move as providers and
hardware change — treat them as a starting point and record your own:
model tok/s fits in one 30s call notes
claude-haiku-4-5 ~120 ~3,600
deepseek-v4-pro ~100 ~3,000
deepseek-v4-flash ~95 ~2,800
doubao-seed-2-0-mini-260215 ~90 ~2,500
gpt-5.6-terra ~90 ~2,500 borderline at 4,096
qwen3.5-flash ~85 ~2,000 times out at 4,096
REASONING MODELS ARE ABSENT FROM THIS TABLE ON PURPOSE. claude-opus-5, claude-sonnet-5,
claude-fable-5, qwen3.8-max and grok-4.6 report tokens_per_sec: null because a rate does not
describe them — their cost is a large fixed thinking period plus a fast tail. grok-4.6 spent
18.2 s before its first token and then emitted 35 tokens, which computes to 2 tok/s and a "safe"
budget of zero. STREAM these models instead of sizing a buffered budget: a buffered grok-4.6 call
at 512 tokens returns 503 PROVIDER_TIMEOUT.
If you need MORE output than one buffered call can hold, stream it. If you genuinely cannot, the fallback
is to CONTINUE, not enlarge:
1. Ask for a safe max_tokens. If usage.completion_tokens comes back AT the budget the answer was cut off,
so send [original prompt, assistant: text-so-far, "continue from exactly where you stopped"] and
concatenate. Each part is its own sub-30s call.
2. Continuations get SLOWER, because each one re-sends everything written so far. Expect part N to run
nearer the wall than part 1, and keep the finished parts when one fails.
3. On a timeout, HALVE that model's max_tokens and REMEMBER IT. There is rarely room to fail at 30s and
then succeed within the same request; the value of a failure is what it teaches the next request.
4. Raise a learned-down budget only when a model's slowest call used well under the window (<70%). Part
count alone is not evidence of headroom.
5. DO NOT hold one HTTP connection open for the whole thing. Behind a proxy (nginx defaults to a 60s read
timeout) a multi-call answer returns an opaque 504 — the gateway calls are legal, the transport is not.
Run generation as a background job and let the client poll.
# RATE LIMITS (per plan; tunable server-side)
free 30/min, 1,000/mo · pro 120/min, 100,000/mo · admin 600/min, unlimited.
# WHAT TO BUILD
Using async/await URLSession (no third-party dependencies), produce one file that:
1. Keeps the CONFIG block above at the very TOP so BASE_URL / API_KEY are easy to change.
2. chat(messages): POST BASE_URL + "/v1/chat" with header Authorization: Bearer API_KEY and JSON body
{ messages:[{role,content}] }; return result, or surface error.code + request_id.
3. Handle 200 ok · 401 (key invalid/expired) · 429 (honour Retry-After, back off) ·
503 (PROVIDER_TIMEOUT / PROVIDER_NOT_CONFIGURED / PROVIDER_ERROR — branch on error.retriable; only the
last of the three is worth retrying).
4. A short demo calling chat([["role": "user", "content": "Hello"]]).
Keep the API key out of source control (inject it; never commit it).
# Video integration — Stages 1–4 (GA00.0.19.142, 2026-10-14)
This current contract supersedes earlier Mini-only / unavailable-stage descriptions. Discover permitted models and modes at runtime; provider activation alone does not enable a gateway capability.
| Model ID | Available client modes | Estimated USD / million output tokens |
| :--- | :--- | ---: |
| dreamina-seedance-2-0-mini-260615 | txt2vid, imgf_txt2vid, imgfl_txt2vid, imgrs_txt2vid | 3.50 |
| dreamina-seedance-2-5-260628 | imgf_txt2vid, imgfl_txt2vid, imgrs_txt2vid | 10.70 at 480p/720p; 11.70 at 1080p |
# Routes and authentication
All four endpoints use Authorization: Bearer <gateway key>. Provider keys remain on the server. The same issued gateway key can be reused across video modes only when the corresponding active BytePlus video.<mode> credential and any explicit provider mapping permit it. Separate app settings do not require newly issued keys.
| Method | Path | Result |
| :--- | :--- | :--- |
| GET | /v1/video/models | result.models[]: id, vendor, modes, resolution, supported_resolutions, enabled_resolutions, duration_seconds, price_usd_per_m_completion_tokens; empty when no permitted credential exists |
| POST | /v1/video/tasks | Persist one UUID client_token before sending; 202 accepted, duplicate UUID returns saved task at 200 |
| GET | /v1/video/tasks/{request_id} | Refresh an owned task; 200 contains state, not proof of completion |
| GET | /v1/video/tasks?client_token=UUID | Recover saved task without another provider submission; 404 if absent |
Responses use {request_id, result, usage:null, error:null}; the top-level request_id identifies the HTTP request, while result.request_id identifies the saved generation task. Save the latter for polling. Another user's task returns 404.
# Submission and ordered references
POST JSON: {client_token:UUID, model, mode, prompt, resolution:"480p"|"720p"|"1080p", duration:5, images:[]}. A prompt of 1–4000 characters is required. supported_resolutions and enabled_resolutions list Mini 480p/720p and Seedance 2.5 480p/720p/1080p. Submit only an enabled resolution for the selected model; 1080p is 2.5-only. Whole-second duration is Mini 4–15 or 2.5 4–30; audio is off. txt2vid uses 16:9, image modes use adaptive ratio. Administrators can review recorded model/resolution usage at /video-analyst.html; it is not a public client API.
| Mode | images[] count and order | Provider roles assigned by gateway |
| :--- | :--- | :--- |
| txt2vid | 0 (omit or []) | none |
| imgf_txt2vid | 1: first frame | first_frame |
| imgfl_txt2vid | 2: first frame, then last frame | first_frame, last_frame |
| imgrs_txt2vid | exactly 2: reference 1, then reference 2 | reference_image, reference_image |
Each image is an original JPEG/PNG/WebP base64 data URL, no remote URL; the encoded URL must be at most 8,000,000 characters (keep original bytes below 5.9 MB). The app JSON ceiling is 16 MiB, but live nginx limits this public video route to 5 MiB total JSON (verified 2026-10-13). Keep the entire base64-expanded request below 5 MiB; this is a combined-body limit, not a per-image allowance. Do not reorder, crop or re-encode reference files silently. Clients send images[], not the provider's content/role schema. A reference image guides content, not a fixed first/last frame. Real-person inputs may be rejected by provider policy. Correct submission is not proof of visual adherence: compare actual sampled first/last frames separately; preserve failures in reports.
# Asynchronous status, recovery and downloads
Persist the immutable draft and UUID before POST. Poll the saved task approximately every 5 seconds while active, slowing down after failures or backgrounding. A provider POST has a 45-second submission timeout and status fetch a 20-second timeout; neither is the video render duration. Allow HTTP overhead (e.g. 60s submit / 30s status), then recover by UUID after a connection failure. Never automatically replace the token or resubmit an uncertain/rejected task. A 404 recovery is not permission for an automatic paid retry: ask the operator to review first. Idempotency-Key is not the video's duplicate mechanism; client_token is.
Read result.state even on HTTP 200: succeeded is the only successful terminal state; failed, cancelled, expired, rejected and submit_unknown stop automatic polling and require visible error/review. A rejected or uncertain submission can be returned as a saved task at HTTP 200. Validate result.output_url before playback/download; success without a usable URL is not a playable-video pass. Treat output URLs as temporary and download promptly. Use the UI labels Refresh video status and Download video from server. Keep references and signed URLs out of public logs.
# Tokens, response time and billing
result includes request_id, provider_task_id, model, mode, state, provider_status, completion_tokens, estimated_cost_usd, output_url, error_code, error_message, created_at, updated_at. Missing values are null, never invented zero. Input tokens and pure queue/render time are not published. Record client-observed end-to-end time separately from submit HTTP time; polling time is not provider render time.
cost_basis remains "estimated" and provider_confirmed_cost_usd remains null on this client API, even when a separate finance observation exists. Estimate = completion_tokens × selected-model rate / 1,000,000 (rounded to six decimals). For example, 48,437 Seedance 2.5 output tokens estimate US$0.518276. Polling and UUID reuse do not create a second generation charge/monthly generation-quota entry; normal authentication and transport errors still apply. Provider gross cost, plan deduction, cash payable and remaining balance are separate facts. A plan-covered charge is not a zero-cost generation. Confirm actual charges in the provider console with evidence; do not automatically mark the ledger confirmed.
# Platform handling
Android: persist draft/token before OkHttp POST; resume polling from stored result.request_id after process restart. iOS: persist draft/token before URLSession POST; keep the gateway key in Keychain, preserve original picker bytes, resume status after relaunch, download via URLSession for Save/Share. Web: keep privileged keys server-side in a backend-for-frontend, not a public bundle/localStorage; a controlled test bench can accept a short-lived restricted key in memory. Both HTTP and HTTPS gateway URLs are supported; HTTPS web pages must use the HTTPS URL to avoid mixed content. cURL: load the key from an environment variable and JSON from a file; retain the UUID and returned result.request_id, never use automatic POST retries.
# Verified test boundaries
Existing simulator reports are evidence, not a new test run: Mini first/last fidelity failures remain failures despite valid wire roles; Seedance 2.5 sampled endpoint composition passed with qualifications, not pixel identity or every-object motion. Reference-mode real-person rejection and the successful illustrated-reference retest remain separate cases. Full 2.0/Fast provider tests do not imply those models are exposed to public clients. No TestFlight or device-validation claim is made by this guide update.
# DEV LOG (required — we use this to improve the spec)
This spec is still evolving and the author cannot see your environment or where the spec was
unclear, so your log is the only feedback channel. Keep a DEV_LOG.md and deliver it with the code.
Purpose: capture real integration experience so the spec can be corrected and clarified.
Record at minimum:
- Build platform: OS + version, IDE / build tools + versions, language / SDK / runtime versions.
- Target / deployment platform: device or simulator + OS version.
- Tools used: AI assistant + model, libraries + versions, anything else.
- What failed and how you solved it — or could NOT solve, and why.
- Anything in the spec that was missing, ambiguous, wrong, or that you had to assume.
- MEASURED NUMBERS, not just prose: observed tokens/sec per model you used, the largest max_tokens each
one completed reliably, and any request that timed out (with its prompt size). Those three facts build
the table in "# LONG ANSWERS", they change as providers and hardware change, and nobody can supply them
from the server side.
- Any other detail that would help the next developer or improve the spec.
Manual reference
Reference implementation — copy it if you're integrating by hand.Call /v1/chat with your API key
// API_Gateway · spec version {{GATEWAY_VERSION}} · last updated {{SPEC_UPDATED}}
import Foundation
let base = URL(string: "{{BASE_URL}}")!
let apiKey = "YOUR_GATEWAY_API_KEY"
func chat() async throws {
var req = URLRequest(url: base.appendingPathComponent("v1/chat"))
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
req.httpBody = try JSONSerialization.data(withJSONObject: [
"messages": [["role": "user", "content": "Hello"]]
])
let (data, response) = try await URLSession.shared.data(for: req)
let http = response as! HTTPURLResponse
let reqId = http.value(forHTTPHeaderField: "X-Request-Id")
switch http.statusCode {
case 200: break // parse result.message.content
case 401: break // API key missing / invalid / expired
case 429: break // honour Retry-After, backoff
case 503: break // PROVIDER_ERROR — upstream provider failed/timed out
default: break // read error.message, log reqId
}
}
Inject the API key (don't commit it). Never ship provider API keys in the app bundle; the gateway holds those.
Platform notes & special scenarios
Everything above applies to any iOS client. The cases below are situational — read only the one that matches your setup.Optional HTTP · App Transport Security
when you choose HTTPUse https://api-gateway.wmazing.cloud with default iOS transport security. The optional HTTP endpoint is http://api-gateway.wmazing.cloud; choosing HTTP may require an explicit ATS exception for this hostname.
Only if your iOS client chooses HTTP, use a hostname-scoped exception in Info.plist:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>api-gateway.wmazing.cloud</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
HTTPS is available now and needs no cleartext exception. HTTP remains supported; this example does not change an installed app or its saved base URL.
Platform notes & special scenarios · video
Discover permitted video models; submit once, then refresh or recover the saved task.
Administrators can compare saved video generations and cost per requested second by model and resolution in Video Analysis. This read-only console page is not a client API and never starts a generation.
| Model / supported modes | Estimate: USD / million output tokens |
|---|---|
| dreamina-seedance-2-0-mini-260615 txt2vid / imgf_txt2vid / imgfl_txt2vid / imgrs_txt2vid | 3.50 (480p/720p) |
| dreamina-seedance-2-5-260628 imgf_txt2vid / imgfl_txt2vid / imgrs_txt2vid | 10.70 (480p/720p) 11.70 (1080p) |
Use the same gateway key when its provider mappings and mode credentials permit it. Never send the BytePlus key from a client.
GET /v1/video/models
POST /v1/video/tasks
GET /v1/video/tasks/{result.request_id}
GET /v1/video/tasks?client_token={PERSISTED_UUID}
Ordered images / provider roles
txt2vid: []
imgf_txt2vid: [FIRST_DATA_URL] -> first_frame
imgfl_txt2vid: [FIRST_DATA_URL, LAST_DATA_URL] -> first_frame, last_frame
imgrs_txt2vid: [REF1_DATA_URL, REF2_DATA_URL] -> reference_image, reference_image
Default 480p; choose only an enabled 480p/720p/1080p tier from model discovery. Mini supports 4–15 whole seconds and Seedance 2.5 supports 4–30. No audio. Prompt: 1–4000 characters. JPEG/PNG/WebP data URLs only; each encoded URL ≤8,000,000 characters, app JSON ≤16 MiB; live public proxy ≤5 MiB total JSON.
{
"client_token": "REPLACE_WITH_PERSISTED_UUID",
"model": "dreamina-seedance-2-0-mini-260615",
"mode": "txt2vid",
"prompt": "A man is trying to catch up a train",
"resolution": "480p",
"duration": 5,
"images": []
}
// Swift / URLSession: key from Keychain; save UUID and original image bytes first.
var request = URLRequest(url: baseURL.appendingPathComponent("v1/video/tasks"))
request.httpMethod = "POST"
request.timeoutInterval = 60
request.setValue("Bearer \(gatewayKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = savedDraftJSON
let (data, response) = try await URLSession.shared.data(for: request)
// Parse result.request_id/state. Resume owned-task GET after relaunch; never auto-resubmit.
Save the UUID and immutable draft before POST. Read result.request_id and result.state, not HTTP 200 alone. Poll about every 5 seconds while active; after a timeout recover by client_token. Never automatically submit a new token.
Only succeeded is success. failed / cancelled / expired / rejected / submit_unknown require visible review. Submission timeout is not rendering duration. Download temporary output URLs promptly; use Refresh video status and Download video from server.
Costs are estimates, not confirmed charges. Missing input tokens and provider render time remain unknown. Polling does not create a second generation. Plan deduction, cash payable and balance must be checked separately.
Reference images guide content, not endpoints. Correct roles do not guarantee frame fidelity. Provider portrait restrictions and failed visual comparisons remain failures, not successful tests.
Web (JavaScript / TypeScript)
Prompt for your AI
paste into Claude / ChatGPT / CursorEdit the CONFIG block at the top (base URL + your API key), then copy the whole prompt and hand it to an AI coding assistant — it contains everything it needs to build the Web integration. The bottom of this page is a manual reference if you'd rather write it yourself.
# API_Gateway · spec version {{GATEWAY_VERSION}} · last updated {{SPEC_UPDATED}}
# TASK
Implement a Web (JavaScript / TypeScript) client for our AI Gateway, following the spec below.
# ===== CONFIG (edit these — kept at the TOP so they're easy to find & change) =====
const BASE_URL = "{{BASE_URL}}";
const API_KEY = "PASTE_YOUR_GATEWAY_API_KEY_HERE"; // sent as: Authorization: Bearer <API_KEY>
// =====================================================================================
# ABOUT THE SYSTEM (read this)
Transport: HTTPS https://api-gateway.wmazing.cloud (recommended); HTTP http://api-gateway.wmazing.cloud is also supported. No forced redirect or HSTS upgrade. Set BASE_URL to either complete URL, without a trailing /v1.
The gateway sits in front of an AI provider. Clients call the gateway, never the
provider directly — provider keys never leave the server. All endpoints live under
BASE_URL + "/v1". Every response carries an X-Request-Id header for tracing. Success bodies (HTTP 2xx) are JSON
{ request_id, result, usage, error:null }; error bodies (HTTP 4xx/5xx) are { error:{ code, message,
request_id } } — there is NO top-level request_id/result/usage on errors, so read the id from
error.request_id or the X-Request-Id header.
# AUTH
Send your key on every call: Authorization: Bearer <API_KEY>
(Get the key from the gateway console. A 401 means the key is missing, invalid, or expired.)
# ENDPOINTS
POST /v1/chat { messages:[{role,content}], model?, max_tokens? } (messages must be non-empty)
LIVE — the gateway proxies to the AI provider. Success result:
{ type:"text", model, message:{role:"assistant",content}, latency_ms, provider_request_id },
plus a usage:{prompt_tokens,completion_tokens,total_tokens} block — parse result.message.content.
model? optional (defaults to the first enabled text model; or pass an id from GET /v1/models).
max_tokens? is VALIDATED against the model's own max_output_tokens, not clamped (default
1024). Above the ceiling you get 400 naming it, plus max_tokens_suggested. Read the
ceiling from GET /v1/models; the reply echoes what was applied in
result.limits.max_tokens_applied. A conversation longer than the model's context window
is refused with 413 before any provider call is spent.
"stream": true IS SUPPORTED — see "# STREAMING" below. Streaming is the right answer for
anything longer than a short reply: it is not subject to the buffered generation wall.
timeout_ms? optional — YOUR window, in milliseconds. Clamped to the model's ceiling: you may
ask for less, never more. The window actually in force comes back as
result.limits.timeout_ms (and in the SSE `start` event, BEFORE the first token), with
timeout_source (measured|default) and timeout_clamped so you are never silently given a
different deal than you asked for.
# --- Image understanding / 看圖答問 (LIVE) — image in, TEXT out ---
POST /v1/img2txt { image | images:[...], prompt, model?, image_mime?, max_tokens?, timeout_ms? }
image = base64 or a data: URL of ONE photo; images = an array (max 8) to ask about several
at once. prompt = the QUESTION about the picture ("How many people are in this image?").
This is a TEXT call that happens to take a picture: it resolves from the TEXT model catalog,
is billed per token, and returns a message — never an image. model? must be a VISION-capable
text model THAT THIS GATEWAY HAS ENABLED for it; discover them with
GET /v1/models?vision=1, which lists exactly those (each entry carries
capabilities.vision = the model can read images, and capabilities.img2txt = this gateway
accepts image questions on it; you need the second). Omit model? and the gateway picks the
first enabled one. A model that is not enabled — or cannot see at all — is refused 400
rather than silently answering from the prompt alone, which is what several providers
themselves do and the reason the check lives here instead of at the vendor.
Success result: { type:"text", ai_type:"text.img2txt", model, message:{role,content},
image_count, latency_ms, provider_request_id, limits } — read message.content for the answer.
No streaming on this route: the whole wait is the model LOOKING at the picture, which a
stream cannot make visible. Bodies allow up to 64 MB, like the image routes.
# --- Image generation (LIVE — gateway holds the provider credential) ---
# TIMING: image windows are PER MODEL AND PER CAPABILITY, and they are wide. Measured:
# qwen-image-2.0-pro 12s with no source photo, 20s with one, 279s with two.
# gpt-image-2 ~25-30s typically, but 60s has been seen on the same request.
# So do NOT assume ~10s and do NOT set a short client-side HTTP timeout: budget minutes
# for multi_image2image. Every image reply carries result.limits — timeout_ms (the window
# actually applied), timeout_source, timeout_clamped, buffered_strategy — so you can show a
# realistic progress expectation instead of guessing. Send timeout_ms to ask for a SHORTER
# window than the model's; you can never ask for a longer one, and if you try, the reply
# says so via timeout_clamped:true. buffered_strategy is "direct" or "via_stream": on
# via_stream the gateway is consuming the provider's own image stream on your behalf and
# judging the call by silence, which is why those models no longer time out.
POST /v1/txt2image { prompt, model?, size?, timeout_ms? } no source image.
POST /v1/image2image { image, prompt, model?, image_mime?, size?, timeout_ms? }
image = base64 or data: URL of ONE source photo.
POST /v1/multi_image2image { images:[...], prompt, model?, size?, timeout_ms? }
images = non-empty array of base64/data-URL photos (max 16).
model? defaults to qwen-image-2.0 (handles all three). One gateway key authenticates every
route (auth is per-user). Success result: { type:"image", ai_type, model,
images:[{url}|{b64,mime}], latency_ms, provider_request_id }. Qwen returns a SIGNED URL that
expires in ~24h — fetch/cache promptly, don't persist it. A model must be
enabled for the capability or you get 400 VALIDATION_ERROR "Model … is not enabled for <cap>".
Image bodies allow up to 64 MB (vs 256 KB JSON); nginx caps the body too — keep images small.
size? IS OPTIONAL AND THE RULES ARE THE VENDOR'S — the gateway forwards your value unchanged and
validates nothing. Omitting it (vendor default) is safest. If you send it: "WIDTHxHEIGHT", lower-case
x, e.g. "1024x1024". NOT "width*height", NOT "2k", NOT "1:3". Per vendor:
openai gpt-image-… both dimensions divisible by 16, and not below the model's min pixel budget.
ark doubao-seedream- at least 3,686,400 px total (1536x2048 = 3,145,728 is REFUSED); 1k/2k/4k ok.
qwen qwen-image-… send "WIDTHxHEIGHT" too — the gateway rewrites it to DashScope's
"width*height". It is the ONLY vendor it rewrites for, so a "*" copied
from DashScope docs fails on every other model here.
A bad size returns 400 VALIDATION_ERROR carrying the VENDOR'S OWN sentence in error.message
(e.g. "image size must be at least 3686400 pixels"). Read it and change the value — retrying the
same size returns the same 400 and still spends a request from the quota.
GET /v1/usage -> caller's month-to-date usage + plan limit
GET /v1/image-models -> the IMAGE models this gateway offers. /v1/models is TEXT ONLY and never lists an
image model, so this is the ONLY way to discover them; use a returned id as the "model" for
/v1/txt2image, /v1/image2image and /v1/multi_image2image. Each entry carries capabilities
{txt2image, img2image, multi_image} saying which of those three routes it is valid for, plus
max_images (the multi_image2image reference cap) and servable.
servable:false means DO NOT OFFER IT — either the gateway holds no key for that vendor, or the
vendor permanently refused the model on the last real call (unservable_reason tells you which:
no_provider_key | vendor_refused; last_status/last_at are the evidence). A vendor can refuse a
model the gateway has correctly enabled — e.g. an Ark account that has not activated it — and
no gateway setting fixes that. Use GET /v1/image-models?servable=1 so your picker never offers
one that cannot run; result.unservable counts what was left out.
GET /v1/models -> models this gateway offers; use a returned id as /v1/chat's "model" (one key works for all).
Each entry carries timeout_ms (the window that model gets for ONE buffered generation — set
per model from measurement, not a flat default), timeout_source and stream_recommended.
READ THE WINDOW BEFORE THE CONVERSATION STARTS so both sides agree what "too long" means.
Each entry carries max_input_tokens / max_output_tokens / limits_verified — read those
instead of guessing — plus `configured` (false = the gateway holds no active key for that
vendor, so the model is listed but answers 503 PROVIDER_NOT_CONFIGURED) and the measured
tokens_per_sec / max_tokens_suggested (null = never measured, NOT unlimited).
Use GET /v1/models?configured=1 to list only models that will actually run;
result.unconfigured counts what was filtered out. Build your picker from that.
GET /health -> liveness (no auth)
# ERRORS (read HTTP status + error.code, log request_id)
400/413 VALIDATION_ERROR · 401 AUTH_REQUIRED / AUTH_INVALID (key missing/invalid/expired) ·
429 RATE_LIMITED / QUOTA_EXCEEDED (honour Retry-After, back off with jitter) ·
503 PROVIDER_TIMEOUT / PROVIDER_NOT_CONFIGURED / PROVIDER_ERROR (see the fault table below).
# WHICH OF THE THREE PARTIES STOPPED?
Provider, gateway, client — any of them can end a call, and they need different responses:
503 PROVIDER_TIMEOUT (JSON body, retriable:false, carries timeout_ms + stream_recommended)
THE GATEWAY gave up at its window. The message names the fix. Never a silent hang.
504 with no JSON body
THE REVERSE PROXY, not the gateway — your requested window outran the proxy's own limit.
Lower timeout_ms, or stream.
stream ends with no `done` and no `error` event
THE CLIENT or the network dropped it. Every gateway-side ending is explicit: exactly one
`done` or one `error` always precedes `data: [DONE]`.
# CONTENT IS PASSED THROUGH WHOLE
The gateway does not trim, summarise or re-encode provider replies on either path. Verified by
requesting a 250-line ordered sequence and checking every line survived: buffered and streamed
produced identical content, and what the gateway stored matched what it delivered. Assemble deltas
by simple concatenation — they are raw fragments, not sentences, and a token may split mid-word.
# LONG-GENERATION MODELS (served by copying the provider's method)
A few models cannot be served by an ordinary buffered request AT ANY TIMEOUT: they think for tens of
seconds before emitting, so the gateway holds a silent socket and cannot tell working from dead.
For those, the gateway asks the vendor for a STREAM even when you sent stream:false, consumes it
server-side, and returns the assembled answer in the normal envelope. Bytes flow, so the call is
bounded by SILENCE rather than elapsed time.
· Your code does not change — same request, same response shape.
· result.limits.buffered_strategy tells you which path ran: "direct" or "via_stream".
· GET /v1/models publishes it per model, so a picker can show it up front.
Measured on grok-4.6: a 30s window fails; the model's own strategy returns 4,361 chars in 57s.
Per model only — a direct call stays simpler wherever the ordinary window already fits.
# SHORT VS LONG — DIFFERENT HANDLING
Short reply -> buffered is fine: one request, one JSON response, simplest to write.
Long reply -> stream it. Buffered, the whole cost lands inside one window and the user sees
nothing until it ends; streamed, the first token usually arrives under a second and
the window only ever measures SILENCE.
The dividing line is per model, and the gateway gives you the numbers: max_tokens_suggested for the
budget, stream_recommended for the verdict (it is also set per-CALL when your max_tokens exceeds
what that model was measured to finish in time).
# PROVIDER FAULTS — BRANCH ON error.retriable, NOT ON THE MESSAGE TEXT
Every 503 carries a code and a boolean, so you never have to string-match English prose:
PROVIDER_TIMEOUT retriable:false the gateway abandoned a BUFFERED generation at its 30s wall.
Do not retry unchanged — it times out identically. Retry
smaller (halve max_tokens), or switch to "stream": true.
PROVIDER_NOT_CONFIGURED retriable:false no active provider key for that vendor (the error carries
`vendor`). Permanent until an operator acts. Pick another
model, or use GET /v1/models?configured=1 in your picker.
PROVIDER_ERROR retriable:true a genuine transient upstream fault: retry 1–2× with
backoff + jitter.
Treating every 503 as retriable costs three 30s failures — 90+ seconds — before the user sees anything,
and none of those retries can succeed.
# STREAMING — THE ANSWER TO LONG REPLIES
Send "stream": true and the 30s generation wall stops applying. The response is text/event-stream:
event: start
data: {"request_id":"req_…","model":"…","limits":{…}}
event: delta
data: {"text":"Cache-Control"}
event: done
data: {"usage":{…},"limits":{…},"latency_ms":46013}
data: [DONE]
Exactly one `done` OR one `error` always arrives before `data: [DONE]`, so you never have to guess how the
stream ended. An `error` event carries code, retriable and partial_chars — a stream that dies mid-answer
still delivered real text, and you can keep it. Parse deltas incrementally; do not wait for the whole body.
Why this removes the wall: a buffered call must guess a timeout, because until the response lands it
cannot tell a slow model from a dead one. A stream can tell — tokens are either arriving or they are not —
so it is bounded by SILENCE (30s with no data), not by total length.
Measured here on 2026-08-17, two distinct wins:
- IT REMOVES THE WALL. grok-4.6 at just max_tokens 512 fails buffered with 503 PROVIDER_TIMEOUT,
because it spends 18.2 s reasoning before its first token; streamed, the same call finishes in
18.9 s. Thinking time blew the budget, not output length.
- IT REMOVES THE WAIT. claude-sonnet-5 at max_tokens 2500 does complete buffered, in 24.8 s — but
with ~5 s of headroom and nothing shown to the user for 25 s. Streamed, the first token lands at
3.5 s.
Streaming costs the same and is logged the same.
# IF YOU CANNOT STREAM — SIZING max_tokens FOR A BUFFERED CALL
BUFFERED GENERATIONS ARE STILL ABANDONED AT 30 SECONDS. A model writes until it finishes or hits
max_tokens, so max_tokens sets the worst-case output LENGTH and therefore the worst-case TIME. Against a
fixed 30s wall it is a time budget wearing a token label, so the safe value is per MODEL, not per API:
safe max_tokens ≈ (that model's tokens/sec) × 25 [25s, leaving margin]
The old trap, now half-closed: the gateway used to CLAMP an oversized max_tokens and carry on, so probing
for the largest accepted value returned the WRONG ceiling. It now REJECTS with 400 and hands back
max_output_tokens and max_tokens_suggested. What it still cannot do is check whether an in-range budget
will FINISH inside 30s — which is what max_tokens_suggested is for, and why streaming is the better answer.
Measured on this gateway, 2026-08-16, ~5,600-token prompts, non-streaming. These move as providers and
hardware change — treat them as a starting point and record your own:
model tok/s fits in one 30s call notes
claude-haiku-4-5 ~120 ~3,600
deepseek-v4-pro ~100 ~3,000
deepseek-v4-flash ~95 ~2,800
doubao-seed-2-0-mini-260215 ~90 ~2,500
gpt-5.6-terra ~90 ~2,500 borderline at 4,096
qwen3.5-flash ~85 ~2,000 times out at 4,096
REASONING MODELS ARE ABSENT FROM THIS TABLE ON PURPOSE. claude-opus-5, claude-sonnet-5,
claude-fable-5, qwen3.8-max and grok-4.6 report tokens_per_sec: null because a rate does not
describe them — their cost is a large fixed thinking period plus a fast tail. grok-4.6 spent
18.2 s before its first token and then emitted 35 tokens, which computes to 2 tok/s and a "safe"
budget of zero. STREAM these models instead of sizing a buffered budget: a buffered grok-4.6 call
at 512 tokens returns 503 PROVIDER_TIMEOUT.
If you need MORE output than one buffered call can hold, stream it. If you genuinely cannot, the fallback
is to CONTINUE, not enlarge:
1. Ask for a safe max_tokens. If usage.completion_tokens comes back AT the budget the answer was cut off,
so send [original prompt, assistant: text-so-far, "continue from exactly where you stopped"] and
concatenate. Each part is its own sub-30s call.
2. Continuations get SLOWER, because each one re-sends everything written so far. Expect part N to run
nearer the wall than part 1, and keep the finished parts when one fails.
3. On a timeout, HALVE that model's max_tokens and REMEMBER IT. There is rarely room to fail at 30s and
then succeed within the same request; the value of a failure is what it teaches the next request.
4. Raise a learned-down budget only when a model's slowest call used well under the window (<70%). Part
count alone is not evidence of headroom.
5. DO NOT hold one HTTP connection open for the whole thing. Behind a proxy (nginx defaults to a 60s read
timeout) a multi-call answer returns an opaque 504 — the gateway calls are legal, the transport is not.
Run generation as a background job and let the client poll.
# RATE LIMITS (per plan; tunable server-side)
free 30/min, 1,000/mo · pro 120/min, 100,000/mo · admin 600/min, unlimited.
# WHAT TO BUILD
Using the browser fetch API (no extra dependencies), produce one module that:
1. Keeps the CONFIG block above at the very TOP so BASE_URL / API_KEY are easy to change.
2. chat(messages): POST `${BASE_URL}/v1/chat` with header Authorization: Bearer ${API_KEY} and JSON body
{ messages:[{ role, content }] }; return result, or surface error.code + request_id.
3. Handle 200 ok · 401 (key invalid/expired) · 429 (honour Retry-After, back off) ·
503 (PROVIDER_TIMEOUT / PROVIDER_NOT_CONFIGURED / PROVIDER_ERROR — branch on error.retriable; only the
last of the three is worth retrying).
4. A tiny demo calling chat([{ role: "user", content: "Hello" }]).
Don't ship the key in public client JS — for production proxy via a same-site backend.
# Video integration — Stages 1–4 (GA00.0.19.142, 2026-10-14)
This current contract supersedes earlier Mini-only / unavailable-stage descriptions. Discover permitted models and modes at runtime; provider activation alone does not enable a gateway capability.
| Model ID | Available client modes | Estimated USD / million output tokens |
| :--- | :--- | ---: |
| dreamina-seedance-2-0-mini-260615 | txt2vid, imgf_txt2vid, imgfl_txt2vid, imgrs_txt2vid | 3.50 |
| dreamina-seedance-2-5-260628 | imgf_txt2vid, imgfl_txt2vid, imgrs_txt2vid | 10.70 at 480p/720p; 11.70 at 1080p |
# Routes and authentication
All four endpoints use Authorization: Bearer <gateway key>. Provider keys remain on the server. The same issued gateway key can be reused across video modes only when the corresponding active BytePlus video.<mode> credential and any explicit provider mapping permit it. Separate app settings do not require newly issued keys.
| Method | Path | Result |
| :--- | :--- | :--- |
| GET | /v1/video/models | result.models[]: id, vendor, modes, resolution, supported_resolutions, enabled_resolutions, duration_seconds, price_usd_per_m_completion_tokens; empty when no permitted credential exists |
| POST | /v1/video/tasks | Persist one UUID client_token before sending; 202 accepted, duplicate UUID returns saved task at 200 |
| GET | /v1/video/tasks/{request_id} | Refresh an owned task; 200 contains state, not proof of completion |
| GET | /v1/video/tasks?client_token=UUID | Recover saved task without another provider submission; 404 if absent |
Responses use {request_id, result, usage:null, error:null}; the top-level request_id identifies the HTTP request, while result.request_id identifies the saved generation task. Save the latter for polling. Another user's task returns 404.
# Submission and ordered references
POST JSON: {client_token:UUID, model, mode, prompt, resolution:"480p"|"720p"|"1080p", duration:5, images:[]}. A prompt of 1–4000 characters is required. supported_resolutions and enabled_resolutions list Mini 480p/720p and Seedance 2.5 480p/720p/1080p. Submit only an enabled resolution for the selected model; 1080p is 2.5-only. Whole-second duration is Mini 4–15 or 2.5 4–30; audio is off. txt2vid uses 16:9, image modes use adaptive ratio. Administrators can review recorded model/resolution usage at /video-analyst.html; it is not a public client API.
| Mode | images[] count and order | Provider roles assigned by gateway |
| :--- | :--- | :--- |
| txt2vid | 0 (omit or []) | none |
| imgf_txt2vid | 1: first frame | first_frame |
| imgfl_txt2vid | 2: first frame, then last frame | first_frame, last_frame |
| imgrs_txt2vid | exactly 2: reference 1, then reference 2 | reference_image, reference_image |
Each image is an original JPEG/PNG/WebP base64 data URL, no remote URL; the encoded URL must be at most 8,000,000 characters (keep original bytes below 5.9 MB). The app JSON ceiling is 16 MiB, but live nginx limits this public video route to 5 MiB total JSON (verified 2026-10-13). Keep the entire base64-expanded request below 5 MiB; this is a combined-body limit, not a per-image allowance. Do not reorder, crop or re-encode reference files silently. Clients send images[], not the provider's content/role schema. A reference image guides content, not a fixed first/last frame. Real-person inputs may be rejected by provider policy. Correct submission is not proof of visual adherence: compare actual sampled first/last frames separately; preserve failures in reports.
# Asynchronous status, recovery and downloads
Persist the immutable draft and UUID before POST. Poll the saved task approximately every 5 seconds while active, slowing down after failures or backgrounding. A provider POST has a 45-second submission timeout and status fetch a 20-second timeout; neither is the video render duration. Allow HTTP overhead (e.g. 60s submit / 30s status), then recover by UUID after a connection failure. Never automatically replace the token or resubmit an uncertain/rejected task. A 404 recovery is not permission for an automatic paid retry: ask the operator to review first. Idempotency-Key is not the video's duplicate mechanism; client_token is.
Read result.state even on HTTP 200: succeeded is the only successful terminal state; failed, cancelled, expired, rejected and submit_unknown stop automatic polling and require visible error/review. A rejected or uncertain submission can be returned as a saved task at HTTP 200. Validate result.output_url before playback/download; success without a usable URL is not a playable-video pass. Treat output URLs as temporary and download promptly. Use the UI labels Refresh video status and Download video from server. Keep references and signed URLs out of public logs.
# Tokens, response time and billing
result includes request_id, provider_task_id, model, mode, state, provider_status, completion_tokens, estimated_cost_usd, output_url, error_code, error_message, created_at, updated_at. Missing values are null, never invented zero. Input tokens and pure queue/render time are not published. Record client-observed end-to-end time separately from submit HTTP time; polling time is not provider render time.
cost_basis remains "estimated" and provider_confirmed_cost_usd remains null on this client API, even when a separate finance observation exists. Estimate = completion_tokens × selected-model rate / 1,000,000 (rounded to six decimals). For example, 48,437 Seedance 2.5 output tokens estimate US$0.518276. Polling and UUID reuse do not create a second generation charge/monthly generation-quota entry; normal authentication and transport errors still apply. Provider gross cost, plan deduction, cash payable and remaining balance are separate facts. A plan-covered charge is not a zero-cost generation. Confirm actual charges in the provider console with evidence; do not automatically mark the ledger confirmed.
# Platform handling
Android: persist draft/token before OkHttp POST; resume polling from stored result.request_id after process restart. iOS: persist draft/token before URLSession POST; keep the gateway key in Keychain, preserve original picker bytes, resume status after relaunch, download via URLSession for Save/Share. Web: keep privileged keys server-side in a backend-for-frontend, not a public bundle/localStorage; a controlled test bench can accept a short-lived restricted key in memory. Both HTTP and HTTPS gateway URLs are supported; HTTPS web pages must use the HTTPS URL to avoid mixed content. cURL: load the key from an environment variable and JSON from a file; retain the UUID and returned result.request_id, never use automatic POST retries.
# Verified test boundaries
Existing simulator reports are evidence, not a new test run: Mini first/last fidelity failures remain failures despite valid wire roles; Seedance 2.5 sampled endpoint composition passed with qualifications, not pixel identity or every-object motion. Reference-mode real-person rejection and the successful illustrated-reference retest remain separate cases. Full 2.0/Fast provider tests do not imply those models are exposed to public clients. No TestFlight or device-validation claim is made by this guide update.
# DEV LOG (required — we use this to improve the spec)
This spec is still evolving and the author cannot see your environment or where the spec was
unclear, so your log is the only feedback channel. Keep a DEV_LOG.md and deliver it with the code.
Purpose: capture real integration experience so the spec can be corrected and clarified.
Record at minimum:
- Build platform: OS + version, runtime / bundler / framework + versions, language (JS / TS) version.
- Target / deployment platform: browser(s) + versions, or Node version if server-side.
- Tools used: AI assistant + model, libraries + versions, anything else.
- What failed and how you solved it — or could NOT solve, and why.
- Anything in the spec that was missing, ambiguous, wrong, or that you had to assume.
- MEASURED NUMBERS, not just prose: observed tokens/sec per model you used, the largest max_tokens each
one completed reliably, and any request that timed out (with its prompt size). Those three facts build
the table in "# LONG ANSWERS", they change as providers and hardware change, and nobody can supply them
from the server side.
- Any other detail that would help the next developer or improve the spec.
Manual reference
Reference implementation — copy it if you're integrating by hand.Call /v1/chat with your API key
// API_Gateway · spec version {{GATEWAY_VERSION}} · last updated {{SPEC_UPDATED}}
const base = "{{BASE_URL}}";
const apiKey = "YOUR_GATEWAY_API_KEY";
async function chat(messages) {
const r = await fetch(`${base}/v1/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
},
body: JSON.stringify({ messages }),
});
const reqId = r.headers.get("X-Request-Id");
if (r.status === 429) {
const retryAfter = r.headers.get("Retry-After"); // backoff + retry
}
const data = await r.json();
if (!r.ok) {
// data.error.code: AUTH_INVALID | RATE_LIMITED | QUOTA_EXCEEDED | PROVIDER_ERROR …
console.error(data.error?.code, data.error?.message, reqId);
return;
}
return data.result; // { type:"text", message:{ role, content }, … }
}
chat([{ role: "user", content: "Hello" }]);
Don't ship the API key in public client JS — for production proxy via a same-site backend. Provider keys stay on the gateway, never in the client.
Platform notes & special scenarios
Everything above applies to any web client. The cases below are situational — read only the one that matches your setup.HTTPS page calling an HTTP gateway · mixed content
when you choose HTTPThe gateway supports both HTTP and HTTPS. An HTTPS web page should use https://api-gateway.wmazing.cloud; calling the HTTP endpoint from that page is blocked as mixed content.
Use the HTTPS gateway directly when the client architecture permits it, or a same-site backend proxy to keep privileged API keys off the browser. HTTP is an optional transport; the server does not redirect it to HTTPS.
Platform notes & special scenarios · video
Discover permitted video models; submit once, then refresh or recover the saved task.
Administrators can compare saved video generations and cost per requested second by model and resolution in Video Analysis. This read-only console page is not a client API and never starts a generation.
| Model / supported modes | Estimate: USD / million output tokens |
|---|---|
| dreamina-seedance-2-0-mini-260615 txt2vid / imgf_txt2vid / imgfl_txt2vid / imgrs_txt2vid | 3.50 (480p/720p) |
| dreamina-seedance-2-5-260628 imgf_txt2vid / imgfl_txt2vid / imgrs_txt2vid | 10.70 (480p/720p) 11.70 (1080p) |
Use the same gateway key when its provider mappings and mode credentials permit it. Never send the BytePlus key from a client.
GET /v1/video/models
POST /v1/video/tasks
GET /v1/video/tasks/{result.request_id}
GET /v1/video/tasks?client_token={PERSISTED_UUID}
Ordered images / provider roles
txt2vid: []
imgf_txt2vid: [FIRST_DATA_URL] -> first_frame
imgfl_txt2vid: [FIRST_DATA_URL, LAST_DATA_URL] -> first_frame, last_frame
imgrs_txt2vid: [REF1_DATA_URL, REF2_DATA_URL] -> reference_image, reference_image
Default 480p; choose only an enabled 480p/720p/1080p tier from model discovery. Mini supports 4–15 whole seconds and Seedance 2.5 supports 4–30. No audio. Prompt: 1–4000 characters. JPEG/PNG/WebP data URLs only; each encoded URL ≤8,000,000 characters, app JSON ≤16 MiB; live public proxy ≤5 MiB total JSON.
{
"client_token": "REPLACE_WITH_PERSISTED_UUID",
"model": "dreamina-seedance-2-0-mini-260615",
"mode": "txt2vid",
"prompt": "A man is trying to catch up a train",
"resolution": "480p",
"duration": 5,
"images": []
}
// Controlled test bench only: restricted key held in memory, never a public bundle.
// Production web: send through your authenticated backend-for-frontend.
const response = await fetch(baseURL + '/v1/video/tasks', {
method: 'POST', headers: {Authorization: 'Bearer ' + gatewayKey,
'Content-Type': 'application/json'}, body: JSON.stringify(savedDraft),
signal: AbortSignal.timeout(60000)
});
const envelope = await response.json();
if (!response.ok || envelope.error) throw new Error(envelope.error?.message || 'HTTP error');
const task = envelope.result; // inspect task.state; HTTP 200 alone is not success
// Poll task.request_id; network failure -> token lookup, not a fresh POST.
Save the UUID and immutable draft before POST. Read result.request_id and result.state, not HTTP 200 alone. Poll about every 5 seconds while active; after a timeout recover by client_token. Never automatically submit a new token.
Only succeeded is success. failed / cancelled / expired / rejected / submit_unknown require visible review. Submission timeout is not rendering duration. Download temporary output URLs promptly; use Refresh video status and Download video from server.
Costs are estimates, not confirmed charges. Missing input tokens and provider render time remain unknown. Polling does not create a second generation. Plan deduction, cash payable and balance must be checked separately.
Reference images guide content, not endpoints. Correct roles do not guarantee frame fidelity. Provider portrait restrictions and failed visual comparisons remain failures, not successful tests.
cURL / REST
Prompt for your AI
paste into Claude / ChatGPT / CursorEdit the CONFIG block at the top (base URL + your API key), then copy the whole prompt and hand it to an AI coding assistant — it contains everything it needs to build a shell / REST integration. The bottom of this page is a manual reference if you'd rather write it yourself.
# API_Gateway · spec version {{GATEWAY_VERSION}} · last updated {{SPEC_UPDATED}}
# TASK
Write a POSIX shell script (curl + jq) that talks to our AI Gateway, following the spec below.
# ===== CONFIG (edit these — kept at the TOP so they're easy to find & change) =====
BASE_URL="{{BASE_URL}}"
API_KEY="PASTE_YOUR_GATEWAY_API_KEY_HERE" # sent as: Authorization: Bearer <API_KEY>
# =====================================================================================
# ABOUT THE SYSTEM (read this)
Transport: HTTPS https://api-gateway.wmazing.cloud (recommended); HTTP http://api-gateway.wmazing.cloud is also supported. No forced redirect or HSTS upgrade. Set BASE_URL to either complete URL, without a trailing /v1.
The gateway sits in front of an AI provider. Clients call the gateway, never the
provider directly — provider keys never leave the server. All endpoints live under
$BASE_URL/v1. Every response carries an X-Request-Id header for tracing. Success bodies (HTTP 2xx) are JSON
{ request_id, result, usage, error:null }; error bodies (HTTP 4xx/5xx) are { error:{ code, message,
request_id } } — there is NO top-level request_id/result/usage on errors, so read the id from
error.request_id or the X-Request-Id header.
# AUTH
Send your key on every call: Authorization: Bearer <API_KEY>
(Get the key from the gateway console. A 401 means the key is missing, invalid, or expired.)
# ENDPOINTS
POST /v1/chat { messages:[{role,content}], model?, max_tokens? } (messages must be non-empty)
LIVE — the gateway proxies to the AI provider. Success result:
{ type:"text", model, message:{role:"assistant",content}, latency_ms, provider_request_id },
plus a usage:{prompt_tokens,completion_tokens,total_tokens} block — parse result.message.content.
model? optional (defaults to the first enabled text model; or pass an id from GET /v1/models).
max_tokens? is VALIDATED against the model's own max_output_tokens, not clamped (default
1024). Above the ceiling you get 400 naming it, plus max_tokens_suggested. Read the
ceiling from GET /v1/models; the reply echoes what was applied in
result.limits.max_tokens_applied. A conversation longer than the model's context window
is refused with 413 before any provider call is spent.
"stream": true IS SUPPORTED — see "# STREAMING" below. Streaming is the right answer for
anything longer than a short reply: it is not subject to the buffered generation wall.
timeout_ms? optional — YOUR window, in milliseconds. Clamped to the model's ceiling: you may
ask for less, never more. The window actually in force comes back as
result.limits.timeout_ms (and in the SSE `start` event, BEFORE the first token), with
timeout_source (measured|default) and timeout_clamped so you are never silently given a
different deal than you asked for.
# --- Image understanding / 看圖答問 (LIVE) — image in, TEXT out ---
POST /v1/img2txt { image | images:[...], prompt, model?, image_mime?, max_tokens?, timeout_ms? }
image = base64 or a data: URL of ONE photo; images = an array (max 8) to ask about several
at once. prompt = the QUESTION about the picture ("How many people are in this image?").
This is a TEXT call that happens to take a picture: it resolves from the TEXT model catalog,
is billed per token, and returns a message — never an image. model? must be a VISION-capable
text model THAT THIS GATEWAY HAS ENABLED for it; discover them with
GET /v1/models?vision=1, which lists exactly those (each entry carries
capabilities.vision = the model can read images, and capabilities.img2txt = this gateway
accepts image questions on it; you need the second). Omit model? and the gateway picks the
first enabled one. A model that is not enabled — or cannot see at all — is refused 400
rather than silently answering from the prompt alone, which is what several providers
themselves do and the reason the check lives here instead of at the vendor.
Success result: { type:"text", ai_type:"text.img2txt", model, message:{role,content},
image_count, latency_ms, provider_request_id, limits } — read message.content for the answer.
No streaming on this route: the whole wait is the model LOOKING at the picture, which a
stream cannot make visible. Bodies allow up to 64 MB, like the image routes.
# --- Image generation (LIVE — gateway holds the provider credential) ---
# TIMING: image windows are PER MODEL AND PER CAPABILITY, and they are wide. Measured:
# qwen-image-2.0-pro 12s with no source photo, 20s with one, 279s with two.
# gpt-image-2 ~25-30s typically, but 60s has been seen on the same request.
# So do NOT assume ~10s and do NOT set a short client-side HTTP timeout: budget minutes
# for multi_image2image. Every image reply carries result.limits — timeout_ms (the window
# actually applied), timeout_source, timeout_clamped, buffered_strategy — so you can show a
# realistic progress expectation instead of guessing. Send timeout_ms to ask for a SHORTER
# window than the model's; you can never ask for a longer one, and if you try, the reply
# says so via timeout_clamped:true. buffered_strategy is "direct" or "via_stream": on
# via_stream the gateway is consuming the provider's own image stream on your behalf and
# judging the call by silence, which is why those models no longer time out.
POST /v1/txt2image { prompt, model?, size?, timeout_ms? } no source image.
POST /v1/image2image { image, prompt, model?, image_mime?, size?, timeout_ms? }
image = base64 or data: URL of ONE source photo.
POST /v1/multi_image2image { images:[...], prompt, model?, size?, timeout_ms? }
images = non-empty array of base64/data-URL photos (max 16).
model? defaults to qwen-image-2.0 (handles all three). One gateway key authenticates every
route (auth is per-user). Success result: { type:"image", ai_type, model,
images:[{url}|{b64,mime}], latency_ms, provider_request_id }. Qwen returns a SIGNED URL that
expires in ~24h — fetch/cache promptly, don't persist it. A model must be
enabled for the capability or you get 400 VALIDATION_ERROR "Model … is not enabled for <cap>".
Image bodies allow up to 64 MB (vs 256 KB JSON); nginx caps the body too — keep images small.
size? IS OPTIONAL AND THE RULES ARE THE VENDOR'S — the gateway forwards your value unchanged and
validates nothing. Omitting it (vendor default) is safest. If you send it: "WIDTHxHEIGHT", lower-case
x, e.g. "1024x1024". NOT "width*height", NOT "2k", NOT "1:3". Per vendor:
openai gpt-image-… both dimensions divisible by 16, and not below the model's min pixel budget.
ark doubao-seedream- at least 3,686,400 px total (1536x2048 = 3,145,728 is REFUSED); 1k/2k/4k ok.
qwen qwen-image-… send "WIDTHxHEIGHT" too — the gateway rewrites it to DashScope's
"width*height". It is the ONLY vendor it rewrites for, so a "*" copied
from DashScope docs fails on every other model here.
A bad size returns 400 VALIDATION_ERROR carrying the VENDOR'S OWN sentence in error.message
(e.g. "image size must be at least 3686400 pixels"). Read it and change the value — retrying the
same size returns the same 400 and still spends a request from the quota.
GET /v1/usage -> caller's month-to-date usage + plan limit
GET /v1/image-models -> the IMAGE models this gateway offers. /v1/models is TEXT ONLY and never lists an
image model, so this is the ONLY way to discover them; use a returned id as the "model" for
/v1/txt2image, /v1/image2image and /v1/multi_image2image. Each entry carries capabilities
{txt2image, img2image, multi_image} saying which of those three routes it is valid for, plus
max_images (the multi_image2image reference cap) and servable.
servable:false means DO NOT OFFER IT — either the gateway holds no key for that vendor, or the
vendor permanently refused the model on the last real call (unservable_reason tells you which:
no_provider_key | vendor_refused; last_status/last_at are the evidence). A vendor can refuse a
model the gateway has correctly enabled — e.g. an Ark account that has not activated it — and
no gateway setting fixes that. Use GET /v1/image-models?servable=1 so your picker never offers
one that cannot run; result.unservable counts what was left out.
GET /v1/models -> models this gateway offers; use a returned id as /v1/chat's "model" (one key works for all).
Each entry carries timeout_ms (the window that model gets for ONE buffered generation — set
per model from measurement, not a flat default), timeout_source and stream_recommended.
READ THE WINDOW BEFORE THE CONVERSATION STARTS so both sides agree what "too long" means.
Each entry carries max_input_tokens / max_output_tokens / limits_verified — read those
instead of guessing — plus `configured` (false = the gateway holds no active key for that
vendor, so the model is listed but answers 503 PROVIDER_NOT_CONFIGURED) and the measured
tokens_per_sec / max_tokens_suggested (null = never measured, NOT unlimited).
Use GET /v1/models?configured=1 to list only models that will actually run;
result.unconfigured counts what was filtered out. Build your picker from that.
GET /health -> liveness (no auth)
# ERRORS (read HTTP status + error.code, print request_id)
400/413 VALIDATION_ERROR · 401 AUTH_REQUIRED / AUTH_INVALID (key missing/invalid/expired) ·
429 RATE_LIMITED / QUOTA_EXCEEDED (honour Retry-After) ·
503 PROVIDER_TIMEOUT / PROVIDER_NOT_CONFIGURED / PROVIDER_ERROR (see the fault table below).
# WHICH OF THE THREE PARTIES STOPPED?
Provider, gateway, client — any of them can end a call, and they need different responses:
503 PROVIDER_TIMEOUT (JSON body, retriable:false, carries timeout_ms + stream_recommended)
THE GATEWAY gave up at its window. The message names the fix. Never a silent hang.
504 with no JSON body
THE REVERSE PROXY, not the gateway — your requested window outran the proxy's own limit.
Lower timeout_ms, or stream.
stream ends with no `done` and no `error` event
THE CLIENT or the network dropped it. Every gateway-side ending is explicit: exactly one
`done` or one `error` always precedes `data: [DONE]`.
# CONTENT IS PASSED THROUGH WHOLE
The gateway does not trim, summarise or re-encode provider replies on either path. Verified by
requesting a 250-line ordered sequence and checking every line survived: buffered and streamed
produced identical content, and what the gateway stored matched what it delivered. Assemble deltas
by simple concatenation — they are raw fragments, not sentences, and a token may split mid-word.
# LONG-GENERATION MODELS (served by copying the provider's method)
A few models cannot be served by an ordinary buffered request AT ANY TIMEOUT: they think for tens of
seconds before emitting, so the gateway holds a silent socket and cannot tell working from dead.
For those, the gateway asks the vendor for a STREAM even when you sent stream:false, consumes it
server-side, and returns the assembled answer in the normal envelope. Bytes flow, so the call is
bounded by SILENCE rather than elapsed time.
· Your code does not change — same request, same response shape.
· result.limits.buffered_strategy tells you which path ran: "direct" or "via_stream".
· GET /v1/models publishes it per model, so a picker can show it up front.
Measured on grok-4.6: a 30s window fails; the model's own strategy returns 4,361 chars in 57s.
Per model only — a direct call stays simpler wherever the ordinary window already fits.
# SHORT VS LONG — DIFFERENT HANDLING
Short reply -> buffered is fine: one request, one JSON response, simplest to write.
Long reply -> stream it. Buffered, the whole cost lands inside one window and the user sees
nothing until it ends; streamed, the first token usually arrives under a second and
the window only ever measures SILENCE.
The dividing line is per model, and the gateway gives you the numbers: max_tokens_suggested for the
budget, stream_recommended for the verdict (it is also set per-CALL when your max_tokens exceeds
what that model was measured to finish in time).
# PROVIDER FAULTS — BRANCH ON error.retriable, NOT ON THE MESSAGE TEXT
Every 503 carries a code and a boolean, so you never have to string-match English prose:
PROVIDER_TIMEOUT retriable:false the gateway abandoned a BUFFERED generation at its 30s wall.
Do not retry unchanged — it times out identically. Retry
smaller (halve max_tokens), or switch to "stream": true.
PROVIDER_NOT_CONFIGURED retriable:false no active provider key for that vendor (the error carries
`vendor`). Permanent until an operator acts. Pick another
model, or use GET /v1/models?configured=1 in your picker.
PROVIDER_ERROR retriable:true a genuine transient upstream fault: retry 1–2× with
backoff + jitter.
Treating every 503 as retriable costs three 30s failures — 90+ seconds — before the user sees anything,
and none of those retries can succeed.
# STREAMING — THE ANSWER TO LONG REPLIES
Send "stream": true and the 30s generation wall stops applying. The response is text/event-stream:
event: start
data: {"request_id":"req_…","model":"…","limits":{…}}
event: delta
data: {"text":"Cache-Control"}
event: done
data: {"usage":{…},"limits":{…},"latency_ms":46013}
data: [DONE]
Exactly one `done` OR one `error` always arrives before `data: [DONE]`, so you never have to guess how the
stream ended. An `error` event carries code, retriable and partial_chars — a stream that dies mid-answer
still delivered real text, and you can keep it. Parse deltas incrementally; do not wait for the whole body.
Why this removes the wall: a buffered call must guess a timeout, because until the response lands it
cannot tell a slow model from a dead one. A stream can tell — tokens are either arriving or they are not —
so it is bounded by SILENCE (30s with no data), not by total length.
Measured here on 2026-08-17, two distinct wins:
- IT REMOVES THE WALL. grok-4.6 at just max_tokens 512 fails buffered with 503 PROVIDER_TIMEOUT,
because it spends 18.2 s reasoning before its first token; streamed, the same call finishes in
18.9 s. Thinking time blew the budget, not output length.
- IT REMOVES THE WAIT. claude-sonnet-5 at max_tokens 2500 does complete buffered, in 24.8 s — but
with ~5 s of headroom and nothing shown to the user for 25 s. Streamed, the first token lands at
3.5 s.
Streaming costs the same and is logged the same.
# IF YOU CANNOT STREAM — SIZING max_tokens FOR A BUFFERED CALL
BUFFERED GENERATIONS ARE STILL ABANDONED AT 30 SECONDS. A model writes until it finishes or hits
max_tokens, so max_tokens sets the worst-case output LENGTH and therefore the worst-case TIME. Against a
fixed 30s wall it is a time budget wearing a token label, so the safe value is per MODEL, not per API:
safe max_tokens ≈ (that model's tokens/sec) × 25 [25s, leaving margin]
The old trap, now half-closed: the gateway used to CLAMP an oversized max_tokens and carry on, so probing
for the largest accepted value returned the WRONG ceiling. It now REJECTS with 400 and hands back
max_output_tokens and max_tokens_suggested. What it still cannot do is check whether an in-range budget
will FINISH inside 30s — which is what max_tokens_suggested is for, and why streaming is the better answer.
Measured on this gateway, 2026-08-16, ~5,600-token prompts, non-streaming. These move as providers and
hardware change — treat them as a starting point and record your own:
model tok/s fits in one 30s call notes
claude-haiku-4-5 ~120 ~3,600
deepseek-v4-pro ~100 ~3,000
deepseek-v4-flash ~95 ~2,800
doubao-seed-2-0-mini-260215 ~90 ~2,500
gpt-5.6-terra ~90 ~2,500 borderline at 4,096
qwen3.5-flash ~85 ~2,000 times out at 4,096
REASONING MODELS ARE ABSENT FROM THIS TABLE ON PURPOSE. claude-opus-5, claude-sonnet-5,
claude-fable-5, qwen3.8-max and grok-4.6 report tokens_per_sec: null because a rate does not
describe them — their cost is a large fixed thinking period plus a fast tail. grok-4.6 spent
18.2 s before its first token and then emitted 35 tokens, which computes to 2 tok/s and a "safe"
budget of zero. STREAM these models instead of sizing a buffered budget: a buffered grok-4.6 call
at 512 tokens returns 503 PROVIDER_TIMEOUT.
If you need MORE output than one buffered call can hold, stream it. If you genuinely cannot, the fallback
is to CONTINUE, not enlarge:
1. Ask for a safe max_tokens. If usage.completion_tokens comes back AT the budget the answer was cut off,
so send [original prompt, assistant: text-so-far, "continue from exactly where you stopped"] and
concatenate. Each part is its own sub-30s call.
2. Continuations get SLOWER, because each one re-sends everything written so far. Expect part N to run
nearer the wall than part 1, and keep the finished parts when one fails.
3. On a timeout, HALVE that model's max_tokens and REMEMBER IT. There is rarely room to fail at 30s and
then succeed within the same request; the value of a failure is what it teaches the next request.
4. Raise a learned-down budget only when a model's slowest call used well under the window (<70%). Part
count alone is not evidence of headroom.
5. DO NOT hold one HTTP connection open for the whole thing. Behind a proxy (nginx defaults to a 60s read
timeout) a multi-call answer returns an opaque 504 — the gateway calls are legal, the transport is not.
Run generation as a background job and let the client poll.
# RATE LIMITS (per plan; tunable server-side)
free 30/min, 1,000/mo · pro 120/min, 100,000/mo · admin 600/min, unlimited.
# WHAT TO BUILD
A single shell script that:
1. Keeps the CONFIG block above at the very TOP so BASE_URL / API_KEY are easy to change.
2. Defines chat() that POSTs "$BASE_URL/v1/chat" with header "Authorization: Bearer $API_KEY"
and body { messages:[{role,content}] }, printing the response + X-Request-Id.
3. Echoes a clear message on non-2xx (429 -> show Retry-After, 503 -> upstream provider failed/timed out).
Use only curl and jq; keep the key in the CONFIG var, not scattered through the script.
# Video integration — Stages 1–4 (GA00.0.19.142, 2026-10-14)
This current contract supersedes earlier Mini-only / unavailable-stage descriptions. Discover permitted models and modes at runtime; provider activation alone does not enable a gateway capability.
| Model ID | Available client modes | Estimated USD / million output tokens |
| :--- | :--- | ---: |
| dreamina-seedance-2-0-mini-260615 | txt2vid, imgf_txt2vid, imgfl_txt2vid, imgrs_txt2vid | 3.50 |
| dreamina-seedance-2-5-260628 | imgf_txt2vid, imgfl_txt2vid, imgrs_txt2vid | 10.70 at 480p/720p; 11.70 at 1080p |
# Routes and authentication
All four endpoints use Authorization: Bearer <gateway key>. Provider keys remain on the server. The same issued gateway key can be reused across video modes only when the corresponding active BytePlus video.<mode> credential and any explicit provider mapping permit it. Separate app settings do not require newly issued keys.
| Method | Path | Result |
| :--- | :--- | :--- |
| GET | /v1/video/models | result.models[]: id, vendor, modes, resolution, supported_resolutions, enabled_resolutions, duration_seconds, price_usd_per_m_completion_tokens; empty when no permitted credential exists |
| POST | /v1/video/tasks | Persist one UUID client_token before sending; 202 accepted, duplicate UUID returns saved task at 200 |
| GET | /v1/video/tasks/{request_id} | Refresh an owned task; 200 contains state, not proof of completion |
| GET | /v1/video/tasks?client_token=UUID | Recover saved task without another provider submission; 404 if absent |
Responses use {request_id, result, usage:null, error:null}; the top-level request_id identifies the HTTP request, while result.request_id identifies the saved generation task. Save the latter for polling. Another user's task returns 404.
# Submission and ordered references
POST JSON: {client_token:UUID, model, mode, prompt, resolution:"480p"|"720p"|"1080p", duration:5, images:[]}. A prompt of 1–4000 characters is required. supported_resolutions and enabled_resolutions list Mini 480p/720p and Seedance 2.5 480p/720p/1080p. Submit only an enabled resolution for the selected model; 1080p is 2.5-only. Whole-second duration is Mini 4–15 or 2.5 4–30; audio is off. txt2vid uses 16:9, image modes use adaptive ratio. Administrators can review recorded model/resolution usage at /video-analyst.html; it is not a public client API.
| Mode | images[] count and order | Provider roles assigned by gateway |
| :--- | :--- | :--- |
| txt2vid | 0 (omit or []) | none |
| imgf_txt2vid | 1: first frame | first_frame |
| imgfl_txt2vid | 2: first frame, then last frame | first_frame, last_frame |
| imgrs_txt2vid | exactly 2: reference 1, then reference 2 | reference_image, reference_image |
Each image is an original JPEG/PNG/WebP base64 data URL, no remote URL; the encoded URL must be at most 8,000,000 characters (keep original bytes below 5.9 MB). The app JSON ceiling is 16 MiB, but live nginx limits this public video route to 5 MiB total JSON (verified 2026-10-13). Keep the entire base64-expanded request below 5 MiB; this is a combined-body limit, not a per-image allowance. Do not reorder, crop or re-encode reference files silently. Clients send images[], not the provider's content/role schema. A reference image guides content, not a fixed first/last frame. Real-person inputs may be rejected by provider policy. Correct submission is not proof of visual adherence: compare actual sampled first/last frames separately; preserve failures in reports.
# Asynchronous status, recovery and downloads
Persist the immutable draft and UUID before POST. Poll the saved task approximately every 5 seconds while active, slowing down after failures or backgrounding. A provider POST has a 45-second submission timeout and status fetch a 20-second timeout; neither is the video render duration. Allow HTTP overhead (e.g. 60s submit / 30s status), then recover by UUID after a connection failure. Never automatically replace the token or resubmit an uncertain/rejected task. A 404 recovery is not permission for an automatic paid retry: ask the operator to review first. Idempotency-Key is not the video's duplicate mechanism; client_token is.
Read result.state even on HTTP 200: succeeded is the only successful terminal state; failed, cancelled, expired, rejected and submit_unknown stop automatic polling and require visible error/review. A rejected or uncertain submission can be returned as a saved task at HTTP 200. Validate result.output_url before playback/download; success without a usable URL is not a playable-video pass. Treat output URLs as temporary and download promptly. Use the UI labels Refresh video status and Download video from server. Keep references and signed URLs out of public logs.
# Tokens, response time and billing
result includes request_id, provider_task_id, model, mode, state, provider_status, completion_tokens, estimated_cost_usd, output_url, error_code, error_message, created_at, updated_at. Missing values are null, never invented zero. Input tokens and pure queue/render time are not published. Record client-observed end-to-end time separately from submit HTTP time; polling time is not provider render time.
cost_basis remains "estimated" and provider_confirmed_cost_usd remains null on this client API, even when a separate finance observation exists. Estimate = completion_tokens × selected-model rate / 1,000,000 (rounded to six decimals). For example, 48,437 Seedance 2.5 output tokens estimate US$0.518276. Polling and UUID reuse do not create a second generation charge/monthly generation-quota entry; normal authentication and transport errors still apply. Provider gross cost, plan deduction, cash payable and remaining balance are separate facts. A plan-covered charge is not a zero-cost generation. Confirm actual charges in the provider console with evidence; do not automatically mark the ledger confirmed.
# Platform handling
Android: persist draft/token before OkHttp POST; resume polling from stored result.request_id after process restart. iOS: persist draft/token before URLSession POST; keep the gateway key in Keychain, preserve original picker bytes, resume status after relaunch, download via URLSession for Save/Share. Web: keep privileged keys server-side in a backend-for-frontend, not a public bundle/localStorage; a controlled test bench can accept a short-lived restricted key in memory. Both HTTP and HTTPS gateway URLs are supported; HTTPS web pages must use the HTTPS URL to avoid mixed content. cURL: load the key from an environment variable and JSON from a file; retain the UUID and returned result.request_id, never use automatic POST retries.
# Verified test boundaries
Existing simulator reports are evidence, not a new test run: Mini first/last fidelity failures remain failures despite valid wire roles; Seedance 2.5 sampled endpoint composition passed with qualifications, not pixel identity or every-object motion. Reference-mode real-person rejection and the successful illustrated-reference retest remain separate cases. Full 2.0/Fast provider tests do not imply those models are exposed to public clients. No TestFlight or device-validation claim is made by this guide update.
# DEV LOG (required — we use this to improve the spec)
This spec is still evolving and the author cannot see your environment or where the spec was
unclear, so your log is the only feedback channel. Keep a DEV_LOG.md and deliver it with the script.
Purpose: capture real integration experience so the spec can be corrected and clarified.
Record at minimum:
- Build platform: OS + version, shell + version, curl / jq versions.
- Target / deployment platform: where the script runs (CI, server, laptop) + OS.
- Tools used: AI assistant + model, any other utilities.
- What failed and how you solved it — or could NOT solve, and why.
- Anything in the spec that was missing, ambiguous, wrong, or that you had to assume.
- MEASURED NUMBERS, not just prose: observed tokens/sec per model you used, the largest max_tokens each
one completed reliably, and any request that timed out (with its prompt size). Those three facts build
the table in "# LONG ANSWERS", they change as providers and hardware change, and nobody can supply them
from the server side.
- Any other detail that would help the next developer or improve the spec.
Manual reference
Reference commands — run them by hand or paste into Postman / Insomnia.Call the gateway with your API key
# API_Gateway · spec version {{GATEWAY_VERSION}} · last updated {{SPEC_UPDATED}}
# Edit these:
BASE_URL="{{BASE_URL}}"
API_KEY="YOUR_GATEWAY_API_KEY"
# Chat (LIVE — returns a { type:"text", message:{role,content} } completion + usage)
curl -i -X POST "$BASE_URL/v1/chat" \
-H "Authorization: Bearer $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"messages":[{"role":"user","content":"Hello"}]}'
# Text -> image (LIVE today — result.images[0].url is a signed URL, expires ~24h)
curl -s -X POST "$BASE_URL/v1/txt2image" \
-H "Authorization: Bearer $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"prompt":"a red bicycle on a beach at sunset"}'
# Image understanding (LIVE) — picture IN, TEXT out. prompt is the QUESTION about the image.
# Discover which models accept a photo with: GET /v1/models?vision=1
curl -s -X POST "$BASE_URL/v1/img2txt" \
-H "Authorization: Bearer $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"image":"","prompt":"How many people are in this image?"}'
# Image -> image (LIVE) — one source photo.
curl -s -X POST "$BASE_URL/v1/image2image" \
-H "Authorization: Bearer $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"image":"","prompt":"make it winter"}'
# Several source photos -> one image (LIVE). Note "images":[…], not "image".
# max_images per model comes from GET /v1/image-models.
curl -s -X POST "$BASE_URL/v1/multi_image2image" \
-H "Authorization: Bearer $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"images":["",""],"prompt":"put them at the same table"}'
# Discover IMAGE models. /v1/models is TEXT ONLY and never lists an image model,
# so this is the only way to find them. ?servable=1 hides models that cannot run
# (no provider key, or the vendor refused them on the last call).
curl -s "$BASE_URL/v1/image-models?servable=1" -H "Authorization: Bearer $API_KEY"
# Your month-to-date usage + plan limit
curl -s "$BASE_URL/v1/usage" -H "Authorization: Bearer $API_KEY"
# Liveness (no auth)
curl -s "$BASE_URL/health"
Use -i to see the X-Request-Id and Retry-After headers. The same calls work from Postman, Insomnia, or any HTTP library.
Platform notes & special scenarios · video
Discover permitted video models; submit once, then refresh or recover the saved task.
Administrators can compare saved video generations and cost per requested second by model and resolution in Video Analysis. This read-only console page is not a client API and never starts a generation.
| Model / supported modes | Estimate: USD / million output tokens |
|---|---|
| dreamina-seedance-2-0-mini-260615 txt2vid / imgf_txt2vid / imgfl_txt2vid / imgrs_txt2vid | 3.50 (480p/720p) |
| dreamina-seedance-2-5-260628 imgf_txt2vid / imgfl_txt2vid / imgrs_txt2vid | 10.70 (480p/720p) 11.70 (1080p) |
Use the same gateway key when its provider mappings and mode credentials permit it. Never send the BytePlus key from a client.
GET /v1/video/models
POST /v1/video/tasks
GET /v1/video/tasks/{result.request_id}
GET /v1/video/tasks?client_token={PERSISTED_UUID}
Ordered images / provider roles
txt2vid: []
imgf_txt2vid: [FIRST_DATA_URL] -> first_frame
imgfl_txt2vid: [FIRST_DATA_URL, LAST_DATA_URL] -> first_frame, last_frame
imgrs_txt2vid: [REF1_DATA_URL, REF2_DATA_URL] -> reference_image, reference_image
Default 480p; choose only an enabled 480p/720p/1080p tier from model discovery. Mini supports 4–15 whole seconds and Seedance 2.5 supports 4–30. No audio. Prompt: 1–4000 characters. JPEG/PNG/WebP data URLs only; each encoded URL ≤8,000,000 characters, app JSON ≤16 MiB; live public proxy ≤5 MiB total JSON.
{
"client_token": "REPLACE_WITH_PERSISTED_UUID",
"model": "dreamina-seedance-2-0-mini-260615",
"mode": "txt2vid",
"prompt": "A man is trying to catch up a train",
"resolution": "480p",
"duration": 5,
"images": []
}
# Set GATEWAY_API_KEY in your shell without putting it in source/history.
# Save the JSON example below as video-request.json with a persisted UUID.
curl --max-time 60 -H "Authorization: Bearer $GATEWAY_API_KEY" \
-H 'Content-Type: application/json' --data-binary @video-request.json \
"{{BASE_URL}}/v1/video/tasks"
# Use result.request_id, NOT the top-level HTTP request_id:
curl --max-time 30 -H "Authorization: Bearer $GATEWAY_API_KEY" \
"{{BASE_URL}}/v1/video/tasks/SAVED_TASK_ID"
# Uncertain submit: recover first, never --retry the POST.
curl --max-time 30 -H "Authorization: Bearer $GATEWAY_API_KEY" \
"{{BASE_URL}}/v1/video/tasks?client_token=SAVED_UUID"
Save the UUID and immutable draft before POST. Read result.request_id and result.state, not HTTP 200 alone. Poll about every 5 seconds while active; after a timeout recover by client_token. Never automatically submit a new token.
Only succeeded is success. failed / cancelled / expired / rejected / submit_unknown require visible review. Submission timeout is not rendering duration. Download temporary output URLs promptly; use Refresh video status and Download video from server.
Costs are estimates, not confirmed charges. Missing input tokens and provider render time remain unknown. Polling does not create a second generation. Plan deduction, cash payable and balance must be checked separately.
Reference images guide content, not endpoints. Correct roles do not guarantee frame fidelity. Provider portrait restrictions and failed visual comparisons remain failures, not successful tests.
Routine Check
Gateway-specific things worth reviewing periodically — model coverage, provider communication, capability parity, limits and transport. Working with an AI agent, just ask it to "check for new models", "review the dev logs", or "endpoint parity" and it will run the matching block below and report findings.
Generic engineering hygiene (local↔cloud file sync, git state, deploy safety, doc-version bumps) is intentionally not here — it is governed by the AI Instruction rules and the API Dev Log Sync playbook. This page is about the product, not the plumbing. All checks are read-only; any change or deploy needs your approval.
This page mirrors the design doc UserSpecific/Design/API_Gateway - Regular routine check.md — edit them together.
Check schedule
last checked · next dueAsk the AI
trigger → action| If you say… | The AI runs |
|---|---|
| "any new models?" / "check for new models to add" | New AI model onboarding |
| "review the dev logs" / "communication improvements" | Gateway communication improvement |
| "provider / key health" / "is chat live yet" | Provider & credential health |
| "did the API surface change" / "endpoint parity" | Endpoint & capability parity |
| "review limits / quotas / plans" | Limits, quotas & plans |
| "TLS / security milestone" | Security & transport milestones |
| "is the model catalog fresh" | Model catalog freshness |
| "check credit" / "how much have we spent" / "reconcile the API balance" | Credit / spend reconciliation |
| "update the FX rates" / "add last month's exchange rate" | Display FX rates |
1 · New AI model onboarding
when a provider ships a model, or on a periodic scan| Step | What to do |
|---|---|
| Catalog seed | Add the model to providers/text-models.js or image-models.js (set max_imgs for multi-image) and bump that file's DATA_UPDATED. |
| DB sync | Run the catalog sync so the row lands in text_models / image_models. |
| Enable capability | Tick supported for the right capability (chat; or txt2image / image2image / multi_image2image) — the catalog tick, not the key's ai_type, is the gate. |
| Verify live | Run Test (Chat) / Test (Image) with a real provider key → a success flips the model to Verified. For an image model, a smoke test is all three capabilities (Txt2Image, Image2Image, Multi-Image2Image) on this bench with the fixtures in Testing/Smoke Test/; a pass is a decoded stored image, not an HTTP 200. Enable a capability before testing it. Leave 尺寸 on 預設. |
| Client visibility | Confirm a text model shows in GET /v1/models (clients build their picker from it). Image models are not listed there. |
| Document | Update the model-comparison docs and the per-vendor notes (rule BPT0010). |
2 · Gateway communication improvement
when a client build returns a Dev Log / Gateway API Reference| Step | What to do |
|---|---|
| Run the sync playbook | Reconcile the returned Gateway API Reference.md FINDINGS with the Connect-a-Client guide via the two-stage API Dev Log Sync playbook (Checking → Sync Up). |
| Ground-truth first | Confirm each claim against server.js / nginx before changing wording. |
| Promote findings | Fold verified experience into Connect-a-Client + Client Integration Guide + the Reference; correct anything contradicted. |
| Record vendor shapes | Capture confirmed per-vendor request/response shapes (BPT0010) so guesses become facts. |
| Spot recurring pain | Watch for repeated client friction (auth, error envelope, retries/backoff, timeouts, signed-URL caching) → improve the spec defaults, not just the prose. |
3 · Provider & credential health
periodic| Item | What to check |
|---|---|
| Provider keys | provider_keys active, not expired, correct vendor; rotate per provider policy. |
| Adapters working | Each vendor adapter still succeeds on the Test pages; watch for provider endpoint/param changes. |
| Chat status | /v1/chat is LIVE (GA00.0.19.27) — proxied to providers/chat.js; Connect-a-Client + Reference show chat live. |
4 · Endpoint & capability parity
when the /v1 surface changes| Item | What to check |
|---|---|
| Guides current | Any new/changed /v1 route or capability is reflected across all five Connect-a-Client screens, the Gateway API Reference, and the Client Integration Guide. |
| Known gap | Video discovery, submission, status and recovery are documented on all five integration pages and in the current Client Integration Guide. |
| Body limits accurate | JSON 256 KB; image app-limit 64 MB but the effective cap is nginx client_max_body_size (~5 MB). |
5 · Limits, quotas & plans
periodic / when tuning| Item | What to check |
|---|---|
| Plan policies | plan_policies per-minute + monthly for free / pro / admin still appropriate, and matching the Connect-a-Client rate-limit table. |
| Proxy ceiling | nginx client_max_body_size still sane for real image sizes. |
6 · Security & transport milestones
periodic| Item | What to check |
|---|---|
| Supported transports | Both https://api-gateway.wmazing.cloud and http://api-gateway.wmazing.cloud are supported. HTTPS is recommended; there is no forced redirect or HSTS upgrade. |
| Key exposure | HTTP sends gateway keys without transport encryption; HTTPS encrypts the connection. Both transports remain supported. |
7 · Model catalog freshness
periodic| Item | What to check |
|---|---|
| Seed age | DATA_UPDATED in the seed files not stale. |
| Drift | Provider pricing / context-window changes; deprecated or renamed models. Check each vendor's migration page by name — a retired slug can keep answering at a different price (e.g. grok-imagine-image-quality → 2.0 at low quality from 2026-11-02). |
| Scan coverage | A vendor's language-model list may not include its image models (xAI keeps them on a separate endpoint). "Nothing new" from a scan covers only what that list returns. |
| Regenerate | Refresh the model-comparison docs from the current catalog. |
8 · Credit / spend reconciliation
monthly — full playbook: AI Instruction - Check credit.md| Item | What to check |
|---|---|
| Log retention | xAI keeps ~30 days. Miss the window and per-request confirmation for that period is gone for good — this is why the check is monthly rather than "when curious". |
| Free quota | Qwen / Volcengine free tiers absorbed 100% of traffic to 2026-08-28 and then lapse — spend goes from ¥0 to real money with no code change. |
| Estimates vs invoice | est rows are a floor. Reconcile them against the vendor's own record and upgrade to alloc / confirmed. |
| Unlogged requests | 4 of 6 vendors showed requests the gateway never logged (2026-08-27). Recount every run — a growing gap is a defect, not noise. |
| Billed timeouts | Requests the gateway abandons are still billed ($1.43 measured). Check the count is not rising. |
The operator signs in to each vendor console; the AI never types credentials, never tops up, and makes no billable calls. Vendor consoles are read-only during a credit check.
9 · Display FX rates
monthly, after the 1stEvery cost figure can be read in USD / HKD / RMB (Settings → Preferences). The ledger never moves — usage_ledger.estimated_cost is US$ and stays US$ — only the multiplier used to display it.
| Item | What to check |
|---|---|
| Add the closed month | Fetch the month's daily rates, take the maximum per symbol, and add the row to DISPLAY_FX_PER_USD in providers/pricing.js. |
| Why the maximum | The presented figure must never understate what the spend is worth locally. The US$ ledger is already a floor, so converting at the dearest day keeps the second step from flattering the number too. |
| Why the last complete month | A running month is provisional and would move under the operator's feet; a spot rate would make one unchanged request show two different HK$ figures on two days. |
| Move the read date | Update DISPLAY_FX_SOURCE.read_on and the check schedule above. |
| Skipping is visible, not silent | With no row for the closed month the console falls back to the newest on file and says so on the Preferences panel — stale, but never quietly wrong. |
Not the same table as the cost-side FX_CNY_PER_USD: that converts a CNY-priced model into the US$ the ledger stores and wants the month's average; this one converts US$ out to what the operator reads, and wants the month's maximum.
What's New
Every release, with the date and time it went live and screenshots of the screens it changed. Always newest first — choose whether “newest” means the most recent deploy or the highest release number.
Times are the server clock (UTC+8) recorded at deploy.
Daily Digest
The changelog by day rather than by release. Every day that shipped something is one row: a summary of the day first, then each release with a line and a link to its full note. A day with nothing shipped has no row.
One cell per day; darker means more releases that day. Hover a month for its total, click a cell to jump to that day. Dates are the server clock (UTC+8).
Release Notes
Major items shipped in each web-server version, newest first. The server version is reported by GET /health; this list is the human-readable changelog.
Source of truth: website/public/version-registry.json — edit it to add a release entry.
Supported Versions
Which client API versions each platform may still use, newest first. A client identifies itself with the X-Client-Platform + X-Client-Version headers; a version marked unsupported is rejected with 426 Upgrade Required. Clients that send no version header are always accepted.
supported = current · deprecated = still accepted, please update · unsupported = blocked (426).