0G Router API (1.0)

Download OpenAPI specification:

0G Router is an API gateway between users and the decentralized 0G Compute Provider network. It provides unified access, fee collection, and intelligent routing for AI inference services.

Balance model: Router ledger vs Payment Layer vault

User funds live in the shared 0G Payment Layer (PL) vault — a multi-app funding pool that other 0G products also draw from. Router does not drain a user's full vault balance up front; it pulls small amounts on demand into its own ledger as inference is consumed.

As a result, the /v1/account/balance endpoint returns the Router ledger only (deposit_balance + credit_balance). It deliberately excludes the PL vault, because vault balance is shared across consumer apps and is not yet committed to Router.

USD accounts: everything in this section describes accounts settling in 0G. USD settlement does not spend from the Payment Layer vault, so do not add a vault balance to either endpoint's response, and the admission rule below does not apply. A wallet that settled in 0G before switching may still have Router pulling its vault into the now-dormant 0G ledger — money neither endpoint reports while the account is in USD mode. See the two endpoints' own descriptions for what a USD account is served.

Picking an endpoint: /balance vs /funds

If you just want one display balance that already combines both sides, call /v1/account/funds. Router does the aggregation server-side and returns a net total = (deposit + credit − pending_charge) + vault_balance, plus the breakdown. Unlike /balance's total_balance (Router-ledger-only, always ≥ 0), /funds.total includes the full vault and may be negative when the user owes Router (pending_charge exceeds available funds) — render that as "owed to Router".

Use /v1/account/balance (not /funds) for settlement / SDK / mgmt-key integrations that depend on the Router-ledger-only, ≥ 0 figure. And note /funds.total is a wallet-display number: it is not the admission figure — the "can I submit a request" calculation below applies vault_ratio and is computed separately.

Admission rule

An inference request is admitted whenever either side has enough funds:

  • Router ledger covers min_cost, or
  • vault_balance × vault_ratio − pending_charge >= min_cost (deferred path; the shortfall is recorded as pending_charge and cleared from the next PL pull).

Otherwise the request returns 402 insufficient_balance.

Auto-pull from the vault (mainnet values)

After a user's first successful inference charge, the account becomes pull-eligible. A background worker then keeps the Router ledger topped up from the PL vault:

Parameter Mainnet value Meaning
Scan interval 3 s How often Router checks each pull-eligible account
Low watermark 0.1 0G Pull triggers when effective Router balance drops below this
High watermark 0.5 0G Target balance after a pull (≈ amount pulled per cycle)
Vault ratio 0.8 Only 80% of the PL vault counts toward admission (safety margin for the shared pool)
min_cost 0.01 0G Minimum cost a single request must be able to cover

Accounts that have only deposited to the vault but never sent an inference request will not trigger a pull.

Predicting "can submit" on the client

To gate a submit button before the first request, combine both sides:

available = router.total_balance + max(0, vault_balance × vault_ratio − pending_charge)

Use vault_ratio from the table above (0.8 on mainnet). Allow submission when available > 0. There is no need to mirror the min_cost floor on the client — Router enforces it at admission time and returns 402 if the request cannot be covered.

Changelog

### Unreleased lists API changes merged but not yet live on mainnet; on release each is cut into a dated ### vX.Y — YYYY-MM section. Endpoint and field stability is marked inline with [beta].

Unreleased

  • features.upstream_rate_limit enabled on mainnet; Baidu deepseek-v4-flash budget is RPM-only. Six hours of mainnet traffic show both Tencent and Baidu cap successes at ~60/min with 429s only above 60 attempts/min, so the Baidu tpm:1000000 entry (never binding, and exposed to the bytes/4 estimate over-counting) is dropped from router.upstream_budgets in both environments. No code change.

  • Inference endpoints — a model is no longer taken offline for USD-settled accounts because ONE of its endpoints is missing USD pricing. A model is usually served by several provider endpoints, and an endpoint can be reachable while the pricing feed has not yet published a USD rate for it. Previously that single gap made the whole model unavailable to every USD-settled account (501 usd_not_supported), even though its other endpoints were healthy and correctly priced — and the same model kept serving 0G-settled accounts normally, so the outage looked arbitrary from the outside. Routing now skips the endpoints it cannot bill in USD and serves the request from the ones it can. 501 usd_not_supported is unchanged in name, status and meaning, but is now returned only when none of the endpoints still eligible for your request can be billed in USD. Routing for 0G-settled accounts is untouched: those requests bill from the on-chain price and every endpoint stays selectable. POST /v1/routing/preview can now return the same 501 as well, for a USD-settled account whose requested model has no USD-billable endpoint at all — previously it had no billability opinion and would list endpoints an inference request was about to refuse. New error: pinning provider.address (or X-0G-Provider-Address) to an endpoint that publishes no USD pricing now returns 400 pinned_provider_not_usd_billable instead of being served and failing to settle — the same shape as the existing pinned_provider_exceeds_max_price, and only reachable on USD-settled accounts.

  • POST /v1/embeddings now accepts end-to-end-encrypted requests, and POST /v1/routing/preview accepts service_type: "embedding". A 0G Private Computer client may seal input into a top-level _e2ee object and omit it from the cleartext body; such a body is no longer rejected for missing input. model, dimensions and encoding_format stay readable for routing. The response seals data (the vectors) and carries the billable count in cleartext usage.prompt_tokens, which is what a sealed request is billed on. A sealed request that also carries a top-level input is rejected with 400 invalid_request, and one that carries attachments with 400 unsupported_with_e2ee. A sealed response without a positive usage.prompt_tokens is refused with 502 invalid_sealed_response rather than billed on an estimate. The preview accepts embedding only where /v1/embeddings is served. Unsealed embedding requests are unchanged. [beta]

  • GET /v1/account/balance and GET /v1/account/funds no longer answer 503 once an account has collected enough promotional-credit grants. For an account whose billing 0G Pay serves, these endpoints report deposit_balance and credit_balance as separate figures, and that split used to be assembled by listing the account's individual credit grants. Grants accumulate and are never removed — a daily reward mints one per day, and a fully spent or long-expired one is kept — so past a certain count the listing could no longer be read to the end, and rather than publish a breakdown it could not stand behind, the router answered 503 pay_gate_unavailable for every balance read on that account. Permanently: retrying did not help, and neither did spending the credit or topping up, because the count only grows. The split is now taken from the billing service directly, so the listing is not read at all on these endpoints and that failure is gone. No response field changes and no change to what the figures mean — the same amounts, by the same definition of what this service can spend; what changes is that they are reported for accounts that were receiving an error instead. The endpoints still answer 503 when the billing service genuinely cannot be reached, which remains retryable and honours Retry-After.

  • Inference endpoints — an end-to-end-encrypted request that also carried verify_tee, plugins, attachments or provider in its body failed outright with a decryption error; it now succeeds. A sealed request authenticates its own cleartext alongside its ciphertext: every cleartext field you send is bound into the envelope, and the enclave refuses to open it if any of them was added, removed or altered in transit. The router removes those four fields from every request body before forwarding, so the request died inside the enclave and surfaced as unseal request: open: chacha20poly1305: message authentication failed, an error naming nothing the caller could act on. The trigger needed no unusual client: verify_tee alone was enough, and a browser sending it on every chat made sealed models unusable there while the same request from curl without the field worked. The router no longer removes these four fields from a sealed request, and they behave as follows. provider is still honored — your routing preference is read as before, the key merely stays in the body, where an upstream that does not recognise it ignores it. plugins and attachments reach here only when empty or otherwise inert, since an active value (web search, a file attachment) has always been refused up front on a sealed request with a 400; that refusal is unchanged. verify_tee is now ignored rather than removed, which changes no response: x_0g_trace.tee_verified has never been reported for a sealed request — the signature covers the encrypted payload, so the router has no plaintext verdict to publish — and it remains absent. model is unaffected and is still resolved in transit: it is the one field the protocol leaves deliberately unbound so the router can resolve an alias or pick a fallback provider. Unencrypted requests are untouched in every respect.

  • Inference endpoints — a request now gets up to 4 provider attempts instead of 3 before the router gives up, and a retry never re-dials an upstream that has just rejected the request, even when a different provider address fronts that same upstream. When several providers serve one model through the same upstream vendor, a 429 or 5xx from that vendor via one provider used to be followed by the same failure via the next provider, spending the retry budget on a path that could not succeed while idle upstreams were never tried. POST /v1/routing/preview accordingly returns up to 4 candidates (it mirrors the router's own fallback chain). Requests that fail every attempt still return the last upstream error, so a fully saturated model now takes one attempt longer to report it.

  • POST /v1/auth/privy — an account's wallet is now the EVM wallet that has been linked to its Privy user LONGEST, not whichever embedded wallet linked_accounts listed first. One Privy app is a single shared user pool — this one carries 29 allowed origins across several products — and ANY of them, plus anyone holding the app secret, can provision an embedded wallet on an ALREADY EXISTING user with no involvement from that user and no signature from them. The old rule preferred wallet_client_type="privy" unconditionally, so such a provision MOVED a live account: the router began crediting a wallet that had appeared minutes earlier, the deposit/credit balance the user actually owns stayed orphaned under user_balances keyed by the address they signed up with, and from the user's side their funds had vanished with nothing anywhere saying why. Eight mainnet accounts were in that state when this shipped, every one a wallet-signup account handed 4-6 embedded wallets in a one-second burst weeks or months later (bursts observed on 07-03, 08-18, 08-27, 09-10, 09-11, 09-14 and 09-16, so this is an ongoing drip rather than one incident). The new key is first_verified_at, which Privy stamps at link time and no caller can backdate, so the answer is monotonic — a wallet linked later can never win and an account's address is fixed by its first wallet for as long as that wallet stays linked. Ties are real and are broken deterministically (first_verified_at is SECONDS, and a batch provision lands several wallets inside one): wallet_index ascending with "no index" — every external wallet, whose key the user proved they hold — first, then lowercase address, so the pick never depends on array order, which Privy neither documents nor returns in wallet_index order. A missing or non-positive timestamp sorts LAST rather than first, because reading "unknown" as "oldest" would hand the account to exactly the wallet whose provenance the API does not show us. Unchanged for the common case: an account whose only wallet is its embedded one — every social/email signup — resolves to the same address as before, as does one whose embedded wallet genuinely is the oldest. Measured over 26,086 enumerated accounts: 25,853 hold exactly one EVM wallet and are arithmetically unaffected, 14 hold several, 219 hold none, and 8 change. The four accounts holding two or more EXTERNAL wallets and no embedded one — the class where the old rule took linked_accounts order and this one takes the oldest — all resolve identically, because linked_accounts came back ordered by first_verified_at ascending in every multi-wallet account enumerated, and no enumerated wallet was missing the field. The enumeration is a floor, not a proof: Privy's user listing rate-limits and its cursor does not reliably reach the end, so read 8 as "at least 8". Not fixed here, and the reason this is not the whole answer: the router still RE-DERIVES the address on every authentication instead of persisting DID → address, and the same app secret that can link a wallet can also unlink the older one — which moves even this ordering. This closes the accidental move (another product provisioning a wallet), not the deliberate one; the seven already-moved accounts need a data fix, not a deploy.

  • GET /v1/account/balance and GET /v1/account/funds — corrected guidance for USD accounts: do NOT add a Payment Layer vault balance on top of these responses. Documentation only; no field, unit or value changed. The previous text described both endpoints in terms of the 0G settlement path, where funds arrive from the shared Payment Layer vault and the response is only part of the picture. USD settlement does not use that vault at all, so for an account with currency: "usd" this response is the whole picture, and /funds' payment_layer block is returned inert (balance: "0", shared_across_products: false, and queried_at is the response time, not a chain read) rather than describing a vault that does not participate. What that means for a client that implemented the documented "combine this with the vault balance" formula differs by endpoint. On /funds it was adding payment_layer.balance, which is "0" for a USD account, so the total was right anyway. On /balance the formula told you to read PaymentVault.balanceOf yourself, and a wallet that funded that vault before switching to USD settlement still has a non-zero balance there that USD settlement cannot spend — so the combined figure over-reported what the account could actually use. Neither endpoint's own response changed. /v1/account/balance also now states plainly, for an account 0G Pay bills, that a positive balance is not by itself a promise that a request will be admitted: an amount owed, a minimum-balance floor, or a hold on the account each refuse independently of the figure shown. That caveat is true of a USD account generally — total_balance never subtracts what you owe — so treat it as applying wherever you read a USD balance, not only there.

  • GET /v1/account/balance, GET /v1/account/funds — for an account whose billing is served by 0G Pay, these now report the balance held there, and a billing-service outage answers a retryable 503 instead of 500. The response shape, units and field meanings are unchanged (micro-USD integer strings; total_balance still excludes any amount owed, which stays on pending_charge). Two things to know. First, the figures are what you can spend on this service: promotional credit that has expired, or that was granted for a different 0G service, is not counted, so this can read lower than the balance 0G Pay's own wallet view shows — the trade is that the spendable figure agrees with the one an inference request is admitted against, so the figure you are shown is the one an inference request is measured against, rather than a larger number that admission then discounts. It is not a blanket promise of admission: total_balance deliberately does not subtract what you owe, so an account with pending_charge set can show a positive total and still be refused until that is cleared; a minimum-balance floor, where one is configured, refuses a positive balance that sits under it; and a hold placed on the account stops requests regardless of balance. Second, when the billing service cannot be reached these endpoints return 503 pay_gate_unavailable (honouring its Retry-After when one is sent) rather than a stale or empty figure: a balance is a number you act on, and showing the wrong one is worse than asking you to retry. Unaffected accounts — every account settling in 0G, and USD accounts not yet served by 0G Pay — are unchanged, including the 500 on a database read failure. Also fixed for every account: GET /v1/account/balance was MISSING from this reference entirely (its annotations were orphaned in the source, so the generator dropped the path); the endpoint itself always worked, and it is now listed with its responses like every other one.

  • Inference endpoints — for an account whose billing is served by 0G Pay, a refused request now says WHICH money problem stopped it, and a billing outage is no longer reported as "insufficient balance". Affects POST /v1/chat/completions, /v1/messages, /v1/embeddings, /v1/images/generations, /v1/images/edits, /v1/audio/transcriptions and the async submit endpoints, for accounts settling in USD. On the OpenAI-compatible endpoints the refusal error.code was always insufficient_balance; it can now be pay_no_balance (nothing to spend), pay_in_debt (an outstanding amount is owed), pay_insufficient_spendable (the balance does not cover this request) or pay_debt_threshold — all 402, all fixed by topping up — or pay_account_cap / pay_service_cap on 403 (type: "permission_error"), which topping up does NOT clear: a cap was set on the account or on this service and support has to raise it. /v1/messages is the one exception to the codes: its Anthropic-compatible error envelope has no code field, so it never carried insufficient_balance either — there the same distinctions arrive as the HTTP status (402 to top up, 403 for a cap, 503 to retry) plus the message, and a 403 still sets type: "permission_error". New: 503 pay_gate_unavailable when the billing service cannot be reached in time. That case previously could only have surfaced as a 402, which tells a client to stop and top up; it is a transient failure on our side, so it is now a retryable 503 (Retry-After is honoured when the billing service sends one) and an SDK should back off and retry rather than surface a payment error to the end user. One behaviour to know about: the balance a request is admitted against is what THIS service can actually spend, so promotional credit that has expired, or that is scoped to a different 0G service, does not count towards it — an account can be refused with pay_insufficient_spendable while a raw balance figure elsewhere still looks non-zero. Accounts not served by 0G Pay are byte-for-byte unchanged, including the existing 402 insufficient_balance. A client that branches only on the HTTP status needs no change; the new codes are additive. [beta]

  • GET /v1/models — added ByteDance Seedance 2.0 video-generation model (bytedance/seedance-2.0), alongside the existing Seedance 2.5 entry — this is an addition, not a replacement; 2.5 is unchanged and both are independently routable. Same client-facing scope as 2.5 (text-to-video and image-to-video via input_reference), so modalities.input is [text, image] and output is [video]; same three-endpoint async shape (POST /v1/videos, poll GET /v1/videos/{id}, then GET /v1/videos/{id}/content). Two things distinguish it from 2.5: duration range is [4, 15] seconds (narrower than 2.5's [4, 30]), and resolution is 480p/720p/1080p/4k — the one axis where 2.0 serves MORE than 2.5, which does not serve 4k at all (an equivalent 2.5 request is downgraded to 1080p/720p instead of being rejected). Optional passthrough fields seed and camera_fixed are accepted exactly as they are for 2.5; output_format is NOT a field on this model — 2.0's wire shape predates it, so a client-supplied value is silently ignored rather than forwarded or rejected. input_reference.file_id returns 400, same reasoning and same behavior as 2.5. Billing is on the vendor-reported token count (usage.completion_tokens), same as 2.5 — pricing.variants carries 4 video_token-unit rows, one per resolution. [beta]

  • GET /v1/models, GET /v1/providers, GET /v1/service-types — models whose only billing dimension is input (embeddings, audio transcription) are no longer missing from the catalog. A model is admitted to the catalog only once the router has a usable price for it, and that check demanded a per-token price on BOTH the input and the output side. An embedding model has no output side to price at all, and an audio transcription model bills per second of input, so neither could ever satisfy it: any such model served through an aggregating provider was dropped during provider sync and silently never appeared, in any of the three catalog endpoints, nor was it routable. Each is now admitted on its input price alone, and nothing is charged on the output side: a completion count echoed by a provider on such a response is excluded from the bill rather than priced, and is not folded into the input count either when the provider reports only a total. Chat, image and video models are unaffected; the admission rules for those are unchanged. No request or response shape changes — only which models the catalog contains.

  • POST /v1/messages — an internal failure while the router resolves the account's settlement mode now returns the Anthropic error envelope (type: "api_error"), matching every other error this endpoint emits. It previously returned the OpenAI-shaped envelope for this one case. Status code (500) and cause are unchanged; only the body shape is corrected. No other endpoint is affected.

  • x_0g_trace.billing.currency is now present on every response that carries a billing block, on all inference endpoints (/v1/chat/completions, /v1/messages, /v1/images/*, /v1/audio/transcriptions, and the async job / video polls). It names the unit of input_cost / output_cost / total_cost: "0g" means wei, "usd" means micro-USD. Until now the field appeared only on USD-settled accounts and was omitted for 0G ones, leaving the unit implicit — a client had to know that a missing currency meant wei. Purely additive: the three cost fields, their values and their formatting are unchanged, and a reader that ignored currency before can keep doing so. If you already branch on currency, treat "0g" exactly as you treated its absence.

  • POST /v1/routing/preview — each candidate now carries provider_identity, the second half of the pin. A provider row is keyed by (model, address, provider_identity): one broker address can front several same-model upstreams (say glm-5.2 served via aliyun and via zhipu), and those are separate candidates with their own price, latency and capabilities. Preview returned only the address, so two such candidates were indistinguishable in the reply — and pinning just X-0G-Provider-Address leaves the router to re-pick among that address's upstreams at execute time, which may not be the one you previewed. Pin both — send X-0G-Provider-Identity alongside X-0G-Provider-Address with the value from the candidate you chose — and you get exactly that upstream. Treat the value as opaque and pin back exactly what the candidate carries, pinning nothing where it carries nothing. The field is present whenever a provider reports the upstream it forwards to and omitted otherwise; that is a property of the provider, and its absence is not a statement that the provider serves only one upstream, so do not branch on it. This matters most for sealed (_e2ee) requests, where you fetch an enclave key and seal before sending: the seal itself is unaffected (the enclave key is per broker, so any upstream behind that address can open it), but the price you were quoted, the capabilities you selected on, and the upstream named in the broker's routing proof all belong to the candidate you picked, and only the full pin guarantees you are served by it. Falling back down the candidate list, likewise: exclude a failed candidate by (address, provider_identity), since re-pinning the address alone can hand you the same upstream again. Purely additive — a candidate with no identity to report has the response shape it always had, and an unset X-0G-Provider-Identity means what it always meant, so an existing caller sees no change in behaviour. [beta]

  • POST /v1/audio/transcriptions — end-to-end-encrypted (sealed) requests are now accepted, and unlike the other sealed endpoints this one changes the request's SHAPE: a sealed transcription is sent as JSON, not multipart/form-data. The multipart body has no top-level JSON object to seal over, so the sealed form is a JSON-ified request: put the audio in file_base64 (base64 of the same bytes you would have uploaded) and seal it — plus filename, language and prompt whenever you send them — into a self-contained top-level _e2ee object, with model and response_format left cleartext so the router selects a provider and bills exactly as before. The provider enclave converts it back to multipart inside the TEE, so the upstream sees the request it always did and the audio never exists in the clear outside the enclave and your client. Discover which enclave to seal to with POST /v1/routing/preview (service_type: "speech-to-text"), which already accepted that value. Three things to plan around, all of them narrower than the plaintext endpoint, and it is worth knowing which layer refuses each. response_format is mandatory on a sealed request and must be json or verbose_json: the other formats (text, srt, vtt) return a body with nowhere to put _e2ee, and an ABSENT value would take the server's default rather than one you chose, so both are refused — by the provider enclave, which is where the sealed profile is enforced, so it reaches you as a relayed upstream error rather than a router 400. Streaming is not available sealed — the profile defines no sealed stream frames — so stream: true on a sealed request is refused by the router with 400 unsupported_with_e2ee, rather than being served as a single non-streaming response without saying so. And the response must carry a cleartext audio duration (usage.seconds, or verbose_json's top-level duration), which is what the router and the broker bill on: a provider that reports neither gets the request failed with 502 invalid_sealed_response and nothing billed, rather than billed on a guess — the estimate the plaintext path falls back to reads the transcript, which is ciphertext here, so it would charge a flat figure unrelated to your audio. attachments is also refused here now, with 400 unsupported_with_e2ee, for the reason it already is on chat and images: file text is injected into the prompt, which is ciphertext. If you do not send _e2ee, nothing changes for you — a multipart upload, and a plain JSON file_base64 body, each take byte-for-byte the path they always did, including the 400 when file_base64 is missing. [beta]

  • POST /v1/chat/completions, POST /v1/messages, POST /v1/images/generations — on an end-to-end-encrypted (_e2ee) request, x_0g_trace.tee_verified is now ABSENT rather than false when you pass verify_tee. That field is the ROUTER's verdict on the provider's per-response TEE signature, and on a sealed request the router's verdict is not the one that matters. The enclave signs a binding over the CIPHERTEXT it returned, and it is your client — the side that sealed the request and holds the key — that can tie that binding to what it actually sent; no verdict from the router substitutes for that check, and publishing one invites you to skip it. What you got before depended on which provider served you, and neither answer was about whether your sealed response was sound. Routing does not filter on E2EE support, so a sealed request can land on any provider shape. On some the field came back false: the router judged the sealed signature against the shape a NON-sealed response uses, so a valid signature was reported as a failed attestation — a response nothing was wrong with, marked as failing. On others it came back true, and that was a real signal but a weaker one than it looked: it said an on-chain-acknowledged TEE signer had produced a signature for that request, NOT that the signature bound the ciphertext you received — nothing on that path compared the hashes inside the signed text against your actual exchange. Both now converge on no claim. So if you were reading true here, this does take a signal away — deliberately, because the replacement is strictly stronger: verify the signature yourself and you get content binding, which the router's true never gave you. It comes from the same broker endpoint your client already talks to, and what it signs is the ciphertext binding rather than a hash of the plaintext. Absent already meant "no claim" on this field (a standard trust-tier route reports it the same way), so a client that reads it as a tri-state needs no change; one that treats a missing field as false should stop doing that for sealed requests. Nothing changes for a request without _e2ee — plaintext traffic is verified exactly as before, on every endpoint. [beta]

  • POST /v1/messages — end-to-end-encrypted (sealed) requests are now accepted, the Anthropic counterpart of the sealed POST /v1/chat/completions path. Seal messages — and, per the Anthropic wire profile, system and tools — into a self-contained top-level _e2ee object and omit them from the cleartext body. model and max_tokens stay cleartext, so the router selects a provider and validates the request exactly as before, and it carries _e2ee through to the provider enclave without reading or decrypting it. Discover which enclave to seal to with POST /v1/routing/preview using {"service_type": "chatbot", "api_format": "anthropic"}. One thing to handle client-side that the OpenAI path does not need: on a sealed Anthropic STREAM the router cannot substitute your model alias back. There is no top-level model on those frames — the name lives at message.model inside message_start, which is authenticated as part of the seal (the input token count sits in the same object) — so what you receive is the model the provider actually served, and restoring your requested alias is the client's job, from the request it already holds. Non-streaming Anthropic responses do carry a top-level model and are unaffected. Two limits carry over from the chat path: plugins (web search) and attachments (files) are refused on a sealed request, since both work by injecting text into a prompt that is now ciphertext; and everything outside the sealed object stays visible to the router by construction (model, token counts, timing), because that is what routing and billing read. If you do not send _e2ee, nothing changes for you — a body without it takes byte-for-byte the path it always did, including the 400 when messages is missing. [beta]

  • POST /v1/routing/preview — new optional api_format field, so /v1/messages can be previewed. Preview answers "which providers would this request go to", and until now it could only be asked about /v1/chat/completions: service_type names the service, and chatbot serves two API surfaces. Send "api_format": "anthropic" to preview /v1/messages — candidates are narrowed to providers advertising the Anthropic format, which is the same filter the real request applies, so the order you get back is the order it would actually try. "openai" is accepted and previews /v1/chat/completions, but is informational: it narrows nothing, because /v1/chat/completions itself routes without a format filter (OpenAI is the default surface and a provider serving it need not enumerate it), and filtering here would hide providers your request would happily use. Omit the field and you preview either surface, exactly as before — existing calls are unaffected. The field applies to service_type: "chatbot" only; sending it with an image or audio service type is a 400, since those have one surface each. anthropic-chat is still not an accepted service_type — the surface is a separate axis and now has its own field. [beta]

  • POST /v1/chat/completions, POST /v1/messages, POST /v1/images/generations — plugins (web search) and attachments (files) are now REJECTED with 400 unsupported_with_e2ee on an end-to-end-encrypted (_e2ee) request, instead of one being silently ignored and the other failing at the provider. Both features work by injecting text into the prompt — search results and extracted file text are added to it before dispatch — and on a sealed request the prompt is ciphertext the router cannot read, let alone rewrite. So the combination cannot be honored, and this is a permanent limit rather than a gap to be filled. What changes for you: attachments + _e2ee used to be accepted, have its file read and parsed, and then fail at the provider with an error naming neither cause (the injected text invalidates the seal) — it now fails immediately and says why, before any file work. plugins: [{"id":"web"}] + _e2ee used to return a normal 200 with the search quietly not performed — it is now a 400, on the view that a caller who asked for search and was not given it is worse served than one who is told the request cannot be honored. Which field is rejected depends on the endpoint, because it follows what would actually have been injected. On the two chat endpoints both are. On /v1/images/generations only attachments is: file text is injected on every endpoint, but web search is only ever performed for chat, so a plugins value there asks for nothing and is served exactly as it is today. If you send either field unconditionally alongside sealed requests, drop it for those requests. An empty or inert value (plugins: [], a plugin id other than web, an attachment with no file_id) asks for no injection and is NOT rejected, so a client that always sends the key is unaffected. Nothing changes for a request without _e2ee. [beta]

  • POST /v1/images/generations — end-to-end-encrypted (sealed) requests are now accepted, the image counterpart of the sealed POST /v1/chat/completions path. Seal prompt into a self-contained top-level _e2ee object and omit it from the cleartext body; model, n and size stay cleartext, so the router selects a provider and validates the request exactly as before. The provider enclave returns the generated images sealed the same way — data moves inside _e2ee.ciphertext, and the images never exist in the clear outside the enclave and your client. Discover which enclave to seal to with POST /v1/routing/preview (service_type: "text-to-image"), which already accepted that value. Two things to plan around. response_format: "url" is refused for a sealed request (400): URL mode has the provider persist the images and serve them from a plain URL, which would put them outside the sealed channel — use b64_json, the default. And the response carries a new cleartext usage.output_images, the number of images actually delivered, which is what the router bills — it sits in usage because that is the billed quantity, and takes the output_ prefix OpenAI itself uses in that object (a token-billed image model populates usage with input_tokens / output_tokens, which are preserved untouched alongside it); it is authenticated (covered by the enclave's response signature), so a provider cannot inflate it unnoticed, and a sealed response that omits it is refused with a 502 rather than billed on a guess. Note that a provider may deliver fewer images than the n you asked for, and usage.output_images — not n — is what you pay for. If you do not send _e2ee, nothing changes for you: an ordinary image request takes byte-for-byte the path it always did, including the 400 when prompt is missing, and its response carries no usage.output_images. Everything outside the sealed object stays visible to the router by construction (model, image count, size, timing), since that is what routing and billing read. [beta]

  • GET /v1/models — created on a model served by several providers is now the EARLIEST of them, instead of whichever one the router happened to read first. The default (canonical) view folds every endpoint serving a model into one entry, and each of the other aggregated fields already had a stated rule — highest context_length, cheapest pricing, union of supported_parameters. created had none: it was copied from the first endpoint in the group, in an order that is not defined for endpoints of the same model, so the value could differ between two calls that returned identical data and could move without anything about the model changing. It is now the minimum across the group. What the field means is unchanged and worth stating plainly, because it is easy to read as more than it is: it is when the ROUTER first recorded an endpoint serving this model — not when the model was released, trained, or registered on chain. It is a router-side bookkeeping timestamp, so treat it as "seen since", and expect it to move forward if every endpoint of a model leaves the network and later returns. Nothing else about the field changes: same name, same type, still Unix seconds. ?legacy=true keeps its existing first-row behaviour, since that view is frozen for compatibility.

  • POST /v1/embeddings — new: text embeddings, in the OpenAI Embeddings API shape. Accepts {model, input, dimensions, encoding_format} and returns the provider's response unchanged aside from the usual trace injection; input may be a string or an array of strings, forwarded verbatim. Synchronous — there is no async job or poll step, unlike video. Billed on input tokens only: the response's usage carries no completion_tokens (an embedding call has no generation side), so completion pricing plays no part here — read prompt as the whole per-token cost. [beta]

  • GET /v1/videos — new: list your own video jobs, so a lost id is no longer a lost video. Every other video endpoint is keyed on the id POST /videos returned, so a script that died right after submitting, or a page reloaded before the id was stored, had no way back to a clip it had already paid for. This returns your jobs most recent first — id (send it back to GET /videos/{id} unmodified), model, status (in_progress | completed | failed), created_at (unix seconds), and the seconds / size the request asked for (seconds is quoted, matching the type an OpenAI-shaped client expects; both echo YOUR values and can differ from what was rendered and billed, since a model clamps a duration outside its range and downgrades a resolution it does not serve) — with limit defaulting to 20 and capped at 100. It is the ROUTER's record, not the provider's video object, and it contacts no provider: completed means the clip was delivered AND charged, while in_progress includes a clip the provider may already have finished but nobody has polled for. Poll GET /videos/{id} for the full object — that is also what settles the charge.

  • POST /v1/videos and every other inference endpoint — more of your Payment Layer balance now counts toward admission: 80%, up from 50%. Router admits a request against its own ledger plus a share of your Payment Layer vault, and that share was set when a request cost a fraction of a cent, where it was never reachable. A video clip costs tens of 0G and rides it entirely — the auto-pull only tops the Router ledger up to 0.5 0G — so a wallet holding 118 0G could be refused a 75 0G clip. The same wallet now clears roughly 94 0G. The available figure in the Predicting "can submit" on the client section moves with it; recompute with vault_ratio = 0.8. The 402 for a video clip you cannot afford now says WHY the figure it quotes is smaller than your wallet — that only part of the Payment Layer balance, a pool shared with other 0G products, backs a single request. Nothing about pricing or what you are charged changes.

  • GET /v1/admin/usage/stats — documented the coverage boundary of merged spend (no behaviour change) — a 0G-settled row only carries a USD equivalent if one could be derived at billing time (provider pricing_usd, else the limit-only usd.og_to_usd_rate). Where neither was available the row stores cost_usd_micro = 0, is counted in router_usd_unpriced_charges_total{fallback="zero"}, and is never backfilled — so merged spend, which sums that column, understates those rows permanently while the 0G ledger still holds them in full. On mainnet the boundary is 2026-07-17: ~49.8k 0G before it carries no USD figure, about 31% of all 0G consumption in the retained window, and the switch is clean (no mixed day). This is most likely to be hit by the shape most people call first — an undated group_by=user ranking is served from the all-time rollup, so it ALWAYS spans the boundary. ?currency=0g returns the complete pre-boundary figure but in wei, which cannot be added to a micro-USD one. Not backfilled because reconstructing it needs the provider USD-to-0G pricing ratio for that period: providers stores only current values with no history, and reverse-deriving the ratio from post-boundary data gives 0.1394–0.3209 across 33 days (22.7% coefficient of variation, with a distinct regime shift), so extrapolating it back 86 days is unverifiable. The endpoint description now states the boundary, how to re-derive it in any deployment, and that the two halves do not combine into one number. [beta]

  • GET /v1/providers, inference routing — an endpoint whose trust tier the router cannot classify now reports NO tier, where it previously reported private. Each provider's tier is derived from a value the provider itself registers on chain, which the router maps onto the three published tiers — standard, verified, private. Anything outside that set used to fall through to private, the strongest of the three: an endpoint the router could not actually classify was advertised as carrying the strongest guarantee on offer, and it satisfied every provider.trust_mode / X-0G-Provider-Trust-Mode request — private included. It now carries no tier at all, which changes four things. On GET /v1/providers the trust_mode field is absent for that entry rather than reading private; read absent as "not classified", and do not fill in a tier of your own. It is no longer selected by any trust-tier request, standard included — so if it were the only endpoint serving a model, a tier-pinned request for that model answers 503 no_provider_for_trust_mode instead of quietly routing somewhere unclassified. Pinning it by address alongside a trust mode answers 403 trust_mode_mismatch, where the pin used to be accepted. And your usage history records no trust mode for such requests rather than private, so a dimensions=trust_mode breakdown no longer folds them in with genuinely private traffic. Requests that do not ask for a tier are entirely unaffected and still route to these endpoints exactly as before — the tier is a filter, not an admission gate. Note the empty value that native TEE deployments register is a recognised one and still means private; this affects only values the router has no mapping for, of which there are none on the network today. So expect to observe nothing — the change is a guard so that a tier introduced in future is reported as unknown until the router learns it, rather than being published as the strongest one. [beta]

  • POST /v1/chat/completions — end-to-end-encrypted (sealed) requests are now accepted, and the provider pre-selection endpoint they depend on, POST /v1/routing/preview, is now available alongside them. Both previously answered only in limited environments; on this release they are live. A client may seal messages and tools into a self-contained top-level _e2ee object and omit them from the cleartext body: the router selects a provider on the cleartext model and bills on the response usage, and carries _e2ee through to the provider enclave without reading or decrypting it, streaming the sealed response back unchanged. Sealing requires knowing WHICH enclave will serve the request before it is sent — ciphertext sealed to one enclave cannot be re-routed to another — which is what /v1/routing/preview answers: given a metadata-only version of your request (prompt stripped), it returns, in fallback order, the providers the router would select, each with the address to pin and the broker endpoint to fetch the enclave key from, without executing, billing, or mutating routing state. A client walking that list therefore re-seals per candidate rather than relying on the router's own server-side fallback. If you do not send _e2ee, nothing changes for you — the reserved key never appears in an ordinary request, and a body without it takes byte-for-byte the path it always did, including the 400 when messages is missing. Presence of the _e2ee object is the only signal; there is no request header. One limit to plan around: everything outside the sealed object stays cleartext to the router by construction (model, token counts, timing), since that is what routing and billing read. (POST /v1/messages gained its own sealed path in this same release — see its entry above; on preview you reach that surface with api_format, not with a service_type of anthropic-chat.) Both surfaces are [beta]: the _e2ee envelope and the preview response shape may still change.

  • All endpoints — X-Request-ID is now always minted by the router, and an X-Request-ID you send is no longer adopted as the request's identifier. Previously, a value on the REQUEST header became that request's id: it was echoed back on the response, published as x_0g_trace.request_id, and recorded as request_id in your usage history. The router now mints a fresh id per request regardless of what you send. Read the RESPONSE header, and note this release is what finally makes that reliable: the X-Request-ID on the response is the router's stable handle for the request, and it is the value you will find in x_0g_trace.request_id and in GET /v1/account/usage/history. Two things had to change for that to be true. The id is no longer taken from your request, as above; and — also new here — a provider's own x-request-id is no longer forwarded onto that header. It previously was, and it OVERWROTE the router's value whenever the upstream returned one, which OpenAI/Anthropic-compatible stacks generally do, so the header could carry an id that appears in no 0G record at all. If you were reading a provider's id off X-Request-ID in order to raise something with the model vendor, note that this release removes that: the upstream service's own id is no longer surfaced on any response header. Tell us if you depend on it — it needs a header of its own rather than sharing the router's. What stops working is only the reverse direction: sending your own value and expecting to look the request up by it afterwards. Read the response header instead, or correlate across systems with W3C traceparent, which the router already honours on inbound requests and which its own logs join on. One improvement to expect if you were sending a value reused across several calls — a per-conversation id, as some agent frameworks emit: those calls were NOT all appearing in your usage history, and each now gets its own row, so both your history and your invoice may show requests you previously could not see. Nothing else changes: the header is present on every response as before, still exposed via CORS, field names and shapes are untouched, and the async endpoints (POST /v1/videos, POST /v1/async/jobs) always minted their own identifiers and are unaffected. Why it changed: this identifier is the primary key of the billing ledger, and a ledger key has to be owned by the system that keeps the ledger.

  • POST /v1/videos — the concurrency budget is now measured in money, so a short clip costs a short clip. Previously every unfinished job counted as one slot of a fixed nominal size, so a 4-second clip consumed the same budget as a 15-second one and an account could be refused a clip it could comfortably afford. Each job now reserves what it will actually cost — its own requested duration at the model dearest rate — and a new request is admitted when everything already running, plus this one, fits your spendable balance. Two consequences worth planning for: short clips go further, since the same balance buys more of them than before; and mixing lengths behaves the way the arithmetic says, so one long clip can consume the budget several short ones would have shared and a 402 right after a long submit is expected rather than a bug. The separate cap on how many may run at once is unchanged and still answers 429. One floor remains — a request for fewer seconds than the model will actually render is priced at the model minimum, because that is what gets billed.

  • POST /v1/videos — how many video jobs you may have running at once now scales with your balance instead of being one number for every account. A video is charged only after the clip is finished, so the router bounds how much unfinished (and therefore unbilled) generation one account can hold. That bound used to be a flat count, which was simultaneously too generous for an account that could not pay for the clips it queued and needlessly tight for a well-funded one. It is now derived from your spendable balance divided by what one clip on that model can cost at its most expensive tier, held between a floor and a ceiling — so adding funds raises your concurrency, and the ceiling still applies no matter how large the balance. Exceeding it answers 429 with code video_jobs_in_flight_limit, unchanged; the limit quoted in the message is now your account's, so do not hard-code it. If your balance does not stretch to a single clip of the length you asked for, that is 402 insufficient_balance rather than a concurrency error — nothing is queued and waiting will not help, so add funds or ask for fewer seconds. And if the balance backing your account cannot be read at that moment, the request answers 503 vault_unavailable — retry it, rather than reading either of the above and acting on it — read it from the message or simply retry after an earlier clip finishes. Funds held in a spending vault count toward that budget, so an account topped up on demand is measured by what it can actually pay rather than by whatever happened to be in its wallet at that instant; an account that cannot cover a single clip is refused rather than granted one on credit. A slot is released as soon as its clip is delivered and charged, or once the router stops trying to bill it — this did not change, and jobs already in flight are unaffected. One behaviour that did change beyond the number: submitting many videos simultaneously is now bounded by that limit as well. Previously a slot was only counted once the model provider had accepted the job, so a batch fired in parallel could all pass the check before any of them had been counted; a slot is now held from the moment the request is accepted. If you submit in parallel, expect 429 at your limit rather than partway through the batch, and either submit serially or retry the refused ones.

  • GET /v1/videos/{id}, GET /v1/async/jobs/{id} — verify_tee=true now answers on a re-poll, and tee_verified: false now means one specific thing — previously only the poll that SETTLED the charge could report tee_verified; ask again on a job that was already billed and the field was simply absent, so "generate the clip, then check whether it was attested" — the obvious way to use the flag — could never work. A re-poll now answers for as long as the provider still holds the signature, which is roughly twenty minutes after the job completes and is the provider's clock, not ours. Alongside that, the field's three states are now cleanly separated. false means the attestation is bad, decided from something we did read — either a signature was fetched and does not hold up (the signer does not recover, or the signed text is malformed), or the chain itself disqualifies the route before a signature is even requested — the provider's TEE signing key is not acknowledged on chain, or its registered verifiability mode is not one we recognise. That second case is worth knowing about because it is not a broken signer: it is an operational state, it applies to every response from that provider until the acknowledgement lands, and it is reported rather than hidden precisely so you can act on it. Absent means no verdict is available, and is never a failure: you did not ask, the route cannot be attested, or no signature could be obtained to check. That last case used to be reported as false, on every poll including the settling one, which accused providers that had done nothing wrong — the signature's clock starts when the provider notices the job finished rather than when you poll, a provider restart clears it early, and a job whose final output was never signed has its handle dropped deliberately. "Expired", "never issued" and "deliberately withheld" are indistinguishable from here, so we no longer guess. If you treat tee_verified != true as untrusted, nothing changes for you; if you branch specifically on false, note it no longer fires for a signature we simply could not obtain — but it still covers both shapes above, so it is not exclusively "the signature was forged". On GET /v1/videos/{id} a re-poll verdict also requires the provider to be running a broker new enough to re-advertise the lookup key on a status poll; against an older one the field stays absent, which is another reason absent must not be read as failure. Nothing else changes: the synchronous endpoints (/v1/chat/completions, /v1/messages, /v1/images/*, /v1/audio/transcriptions) report exactly what they reported before, including false for a signature the provider cannot produce — there the response is seconds old, so a miss is not expiry. Verification stays opt-in and costs a round trip to the provider, so ask once when you have the result rather than on every poll. One consequence of that round trip now being reachable per poll: the provider record verification checks against — its registered TEE signing key, and whether that key is still acknowledged on chain — is now read from the chain at most once a minute per provider rather than once per verification. So tee_verified can lag the chain by up to a minute in either direction: still true for a minute after a provider's key is de-acknowledged, and still false for a minute after a rotated key has been re-acknowledged. That applies to the synchronous endpoints too, and it does not widen an existing window: routing already filters on an acknowledgement flag refreshed on the same provider-sync cadence, so verification now lags by no more than routing always has. [beta]

  • POST /v1/messages with stream: true — read x_0g_trace and web-search annotations as fields on the message_delta event; the separate event: x_0g_trace and event: annotations frames are gone, with no compatibility alias. If you consumed either of those events with a raw SSE reader, that code stops receiving anything on upgrade — migrate before this release. If you use an SDK, nothing breaks just by parsing. Read the keys from the last message_delta before message_stop; there may be more than one, and only the last carries them. A second one appears whenever any other event (a ping, say) arrives between the model's message_delta and message_stop: the router then re-sends that same frame with the trace attached, identical in every other field. If the router cannot attach the trace to a valid message_delta, it emits none rather than inventing one — so treat a missing x_0g_trace as "not available for this response", not as an error. Why the events had to go: their type values ("x_0g_trace", "annotations") are not members of the Anthropic stream-event union, and strict clients validate every data: payload against that union keyed on type — so the unknown member failed validation and aborted the whole stream even though the model had already answered. Reported against Kilo Code, OpenCode and @ai-sdk/anthropic, which surface it as invalid_union / "No matching discriminator". Every data: line the router authors is now a real Anthropic stream event. Provider frames other than the final message_delta are forwarded unmodified, except that message_start.message.model is echoed back as the model you requested (unchanged behaviour). Reaching the values takes one extra step on some clients: the official SDKs pass unknown fields through, while @ai-sdk/anthropic (checked at 4.0.33) strips them from its parsed event object — and MessageStream.finalMessage() will not contain them either, because its snapshot copies known fields only. Iterate raw stream events and read the payload: for await (const event of stream) { if (event.type === 'message_delta') … }. Four behavioural notes. (1) message_delta and message_stop now arrive together at the end of the stream, rather than message_delta arriving mid-stream, message_stop closing the stream, and the trace following after it — the trace cannot be computed until token usage is final. (2) With verify_tee: true that wait also covers a TEE verification round-trip to the provider, bounded at 30 seconds with no keep-alive traffic in between, so set your client's read/idle timeout above 30s if you use it; requests without verify_tee wait only for the provider's own delta-to-stop gap. Response content and token counts are unaffected either way. (3) When the router has no message_delta to attach the trace to, it provides one: if a message_delta was already sent, that exact frame is re-sent with the trace added and otherwise identical values, so a client that assigns stop_reason/usage per message_delta (the official SDK does) sees no change — a client that accumulated usage across deltas would double-count the repeat. If the stream contained no message_delta at all, a minimal one is generated, carrying usage.output_tokens: 0 and an empty delta with no stop_reason: a generated frame has no stop_reason (the reverse does not hold — a provider may omit it too), so treat that frame's usage as advisory. The router's own token estimate is not published on the wire at all: x_0g_trace.billing carries costs (input_cost/output_cost/total_cost), not token counts. And if a real message_delta existed but could not be read, the trace is dropped rather than moved onto an invented frame — your stop_reason and usage are never overwritten to deliver a trace. (4) The message_delta frame the router rewrites is re-serialised, so JSON key order and string escaping may differ from the provider's own encoding on that one frame; numeric values are preserved exactly. Do not depend on byte-level formatting. Billing is unchanged, message_stop is still the last frame, non-streaming POST /v1/messages is unchanged, and POST /v1/chat/completions is untouched — its router-authored frames were already shaped as valid OpenAI chunks (they carry a choices array), which is why the same clients accepted them there.

  • POST /v1/chat/completions, POST /v1/messages — a streaming request REJECTED by the model provider now answers with Content-Type: application/json, not text/event-stream — when you send stream: true and the provider turns the request down (rate limit, a refused prompt, a provider-side error), what comes back is an error payload, not an SSE stream — but it was labelled as one. A strict SSE client parsed zero events out of it and reported "the stream opened, nothing arrived, the connection closed", instead of the error sitting in the body it never read. The status code and the body are unchanged: the provider's error payload is still proxied verbatim, so if a gateway in front of a provider returns a non-JSON body (an HTML block page, say) that body is now labelled JSON — read the status code, and treat the body as opaque if it does not parse. The boundary is the provider's status line, and it is worth knowing precisely: the JSON label applies to any reply other than 200. A stream the provider opened with 200 and then broke — before or after any event reached you — is still text/event-stream, still 200, and can still end with no events at all; so is a successful stream. Keep whatever handling you have for a stream that ends early, and keep reading the status code first. If you branch on Content-Type to decide whether to run your SSE parser, that branch now does the right thing on a rejection; if you worked around this by ignoring the header on a non-2xx, the workaround stays harmless.

  • Errors that originate at the model provider now arrive in the same envelope as every other error on the endpoint, instead of being relayed in whatever shape the provider emitted. Previously the error body you received depended on which layer failed: some providers answered {"error": "<a plain string>"} with no error.message at all, each model service had its own shape, and an edge firewall could return an HTML page. An OpenAI- or Anthropic-compatible SDK reads error.message, so most of those deserialized to nothing and surfaced as a parse failure rather than as the real API error. On /v1/chat/completions, /v1/images/* and /v1/audio/* the body is now always {"error": {"message", "type", "code"}}; on /v1/messages it is always {"type": "error", "error": {"type", "message"}}. Two things do not change: the HTTP status code is relayed exactly as the provider sent it — a 429 stays 429 so your client backs off, a 400 stays 400 so it does not retry a request that can never succeed — and a provider that already answered in the correct envelope is still passed through untouched, so a specific error.code such as context_length_exceeded reaches you intact. error.type reflects who actually failed: only a rejection attributable to your request reports invalid_request_error, so a fault on our side or the provider's is no longer reported as a mistake in your request. If you parsed the old provider-specific shapes, migrate to error.message — the provider's original body is preserved verbatim (truncated) at error.metadata.raw. Two related fixes: an upstream 401/403 keeps its status but now says in the message that it is our credential with the provider and not your API key, and a provider's Retry-After header is now forwarded on rate-limit responses instead of being dropped, so an SDK can honour the backoff the provider asked for rather than guessing.

  • POST /v1/videos, GET /v1/videos/{id}, GET /v1/videos/{id}/content — the video id you receive now names the provider that owns the job, so a poll or download can no longer be refused as ambiguous — the id is longer (up to 80 characters, previously up to 36) and has the shape zg_<provider>_<provider's own id>. Store it as text, not in a fixed 36-character column, and send it back exactly as given. One further thing to know if you reconcile spend by id: usage_logs.request_id deliberately still embeds the PROVIDER's raw id, not this one, so matching your held id against it no longer works — take the raw id from the composite (everything after the second _), or read x_0g_trace.request_id from the settling poll. Both endpoints keep taking the id straight from POST /v1/videos. One exception worth knowing if you use it: the older GET /v1/async/jobs/{id} route still answers with the provider's own id for a video job, and still requires provider_address — it is unchanged, and a new-format id is not valid there. provider_address stays optional and is still honoured for ids issued before this change; for a new id the provider named INSIDE the id wins, so pinning a different one no longer has an effect. Why it changed: the job id is chosen by the model provider, and nothing made it unique BETWEEN providers — two providers running the same broker image emit the same counter-shaped ids, so one id could legitimately belong to two different jobs. Resolving a provider from the id alone then had to refuse, and both owners received a permanent 400 telling them to pass a provider_address that an OpenAI-style client has no way to know, for a clip that had been generated and billed. That failure is now impossible by construction rather than merely unlikely. Ids issued before this change keep working unchanged. One new error: an id that is damaged in transit — the right shape but not intact — is answered 400 malformed_job_id ("use the id exactly as returned by POST /videos") instead of a length complaint about the whole string, which pointed at the wrong thing. An id that was never valid keeps its previous 400 invalid_request, unless it happens to share the new shape (over 36 characters and beginning zg_), in which case it too reports malformed_job_id — the check is on shape, and cannot tell where an id came from. Billing and polling cadence are unchanged. One incidental note on response bodies: the poll body is now re-serialised on every successful video poll rather than forwarded byte-for-byte, and the submit body always was. So JSON key order and string escaping may differ from the provider's own encoding on both. Apart from id itself, field names and values are unchanged, with two exceptions worth naming. A string containing invalid UTF-8 or an unpaired surrogate comes back with those bytes replaced by U+FFFD. And numbers are now preserved exactly as the provider wrote them — previously they were normalised through a 64-bit float, which silently altered integers above 2^53 and rejected anything outside float range. That is a fidelity fix, but it does mean an unusually long numeric literal now reaches you intact, and some strict parsers refuse those: CPython 3.11+ raises on integers over 4300 digits. If you parse with one of those and want to be safe against a misbehaving model provider, bound the response size before parsing. Do not depend on byte-level formatting. [beta]

  • POST /v1/messages — a model that does not speak the Anthropic wire format now returns 400, not 503 — asking /v1/messages for a model whose endpoints only serve the OpenAI format used to fail with 503 "No available providers for this request", byte-for-byte the error a real outage returns. Since SDKs treat 5xx as retryable and 4xx as final, a Messages-API-only client spent its whole retry budget on a request no retry can satisfy — the mismatch is permanent — and then surfaced it as an outage rather than as "use the other endpoint". It is now a 400 invalid_request_error naming the model and the formats it does serve: model "some-model" is not available on the anthropic API format (supported: openai); use POST /v1/chat/completions instead. If you branch on status codes, add a 400 arm here — this condition previously arrived as 503. Check a model's formats up front via supported_formats on GET /v1/models. Unchanged: a genuine supply outage still returns 503, an unknown model still 404, and a model reachable on the Anthropic format routes exactly as before.

  • POST /v1/chat/completions, /v1/messages, /v1/images/*, /v1/audio/transcriptions — the ZG-Res-Key response header now carries the PROVIDER's response id, passed through verbatim, instead of a router-generated value — it lets a client independently verify the provider's TEE signature for that specific response against the provider's signature endpoint. Two behavioural notes: it is now present only when the provider returns one (the router previously always emitted a value), so treat its absence as "no provider response id available" rather than an error; and its value is the provider's own — opaque and provider-scoped, not a router-owned token. The router's own stable, always-present per-response identifier is unchanged: read X-Request-ID (echoed on every response, and the value recorded in your usage history) when you need a handle the router will recognise. No request contract change.

  • POST /v1/videos, GET /v1/models — documented: size and seconds do not mean what the OpenAI Video API means by them — no behaviour change; the behaviour was undocumented and reads as a bug when you meet it. The request shape is OpenAI's, but the models behind it are not, and where a model's own limits differ they win. size names a resolution TIER, not output dimensions, because a video model advertises the tiers it is PRICED at rather than arbitrary sizes. Two spellings are accepted and they are not equivalent: pixel dimensions (1280x720 — OpenAI's spelling, and what an SDK sends) select the aspect ratio only, and only for text-to-video, so asking a 2K-only model for 1280x720 returns a 2560x1440 clip billed at the 2K rate rather than a 720p one; for image-to-video they have no effect at all, since the aspect ratio follows your reference image (a tier name still selects the tier there). A tier name (2K) addresses the tier directly — send only names that appear in that list, since it is the set we can price and it grows as a model adds tiers. Pixel dimensions are the safe thing to send blind; a tier name is what to send when you must have a specific tier. Read the tiers from pricing.variants[].dimensions.resolution on GET /v1/models. seconds is clamped to the model's supported range in BOTH directions, silently: below the minimum you get and pay for the minimum, above the maximum you get the maximum, and neither errors — so a request outside the range costs something other than what you asked for. Omitting either field is the recommended default. For both, the submit response echoes what you sent while the poll response reports what was actually rendered, so reconcile against the poll. Per-model limits (supported range, tiers, prompt length, accepted reference-image formats and dimensions) are stated in each model's description; a request that violates one is rejected by the model provider, so that error text and any numeric code in it are theirs. Billing is unaffected throughout — you are billed for the clip actually produced, at the per-second price published for its tier. [beta]

  • GET /v1/videos/{id}, GET /v1/async/jobs/{id} — fixed: x_0g_trace.billing on a re-poll now reports the amount you were CHARGED, not a fresh quote — an async job's trace is returned on every poll, not only the one that settles the charge. Polls after settlement were re-deriving the fee at the CURRENT price instead of reading the booked figure, so the reported cost could drift away from the charge over time. It drifts whenever the provider prices in USD, because its native-token rate is derived from a moving FX pair: one clip's trace read 6794472400000000000 when it settled and 6696290550000000000 five hours later, a 1.45% gap against a charge that had not changed. No money was ever wrong — the ledger, GET /v1/account/usage/history and your balance always agreed, and every one of them still does — but the trace is what many clients reconcile spend against, so it now carries the booked amount. The wire shape is unchanged (input_cost / output_cost / total_cost, currency on USD traces), and the settling poll is unaffected: it already reported the charge it had just written. Reconciling off GET /v1/account/usage/history was, and remains, the authoritative route. If the booked figure cannot be read the response falls back to the previous behaviour rather than failing your poll.

  • POST /v1/videos, GET /v1/videos/{id}, GET /v1/videos/{id}/content — async video generation — three new endpoints add video as a modality, in the OpenAI Video API shape. POST /v1/videos accepts {model, prompt, seconds, size} as JSON or as multipart/form-data (so an uploaded first-frame image can drive image-to-video) and returns {id, status, provider_address}; GET /v1/videos/{id} reports status and is what settles the charge once the job completes; GET /v1/videos/{id}/content streams the finished file. On both GETs provider_address is optional — it is resolved from the job id — so an OpenAI-native client can poll and download with no query parameters. Because generation takes minutes, billing happens at completion rather than at submit, and on the duration actually delivered — your requested seconds becomes the billing basis only when the provider's own figure is unusable — it completed the job but reported no duration at all (so that a delivered clip is never served free), or it reported one implausibly larger than you asked for, which is capped. Both cases are logged as degraded. The cap bounds the DURATION at a small multiple of what you requested, not the fee: on a video_clip table the price is a step function of duration, so a capped duration can still land on a dearer row than the one you asked for. The same precedence applies to size: the resolution the price is keyed on is the one the provider reports, falling back to the size you asked for when the provider does not echo it, which is the normal case for some upstreams — so a resolution-priced model is billed at its own tier rather than at the table's most expensive one. Two consequences worth coding against: the content endpoint charges before it streams if you never polled, so bytes are never delivered unbilled; and each account may hold only a small number of unfinished video jobs at once, so a submit can return 429 with code video_jobs_in_flight_limit while earlier clips are still rendering. seconds is REQUIRED — the one place this endpoint deviates from the upstream OpenAI Video API, where it is optional. Billing is per delivered second and the delivered figure comes from the provider, so your declared duration is the only reference the router can sanity-check it against; without one, a provider misreporting its units could charge orders of magnitude more. Omitting it returns 400 invalid_request with "seconds is required" — note this applies to the OpenAI SDK path too, including multipart image-to-video. A seconds value the model has no price for is likewise rejected up front with 400 and the list of durations that are available, rather than failing after the clip was generated. Other job states: 404 async_job_not_found on either GET means this router has no such job id — it never existed, or you pinned a provider_address other than the one that owns it — so re-check the id (and drop the pin, since it is resolved for you) rather than retrying; 409 video_not_ready from the content endpoint means keep polling; 424 video_job_failed means the job died at the provider and has no output. A USD-settling account can use video only when the model publishes a USD video price (pricing_usd.video or pricing_usd.variants); otherwise submit returns 501 usd_not_supported rather than a mis-priced charge. [beta]

  • GET /v1/account/usage/history — request_id VALUE format differs for VIDEO usage rows — the request_id on a usage row for a video job is async-<16 hex>-<job id>, while every other async modality uses async-<8 chars of the provider address>-<job id>. The field, its type and the async- prefix are unchanged, so a LIKE 'async-%' filter or an exact-match lookup still behaves identically. Only one thing breaks: if you PARSE the middle segment expecting the provider's address suffix, it will not match on a video row — read provider_address from the row itself instead, which is the stable way to get it for every modality. Nothing else about the field changed, and no other endpoint's request_id changed. [beta]

  • GET /v1/models, GET /v1/providers — pricing.video and pricing.variants; GET /v1/service-types — video-generation — the pricing object gains video, the flat price per generated second for a single-rate video model, and variants, a list of priced request shapes for a model whose price depends on the shape of the request rather than a single rate. Each variant carries dimensions (the axes it is keyed on, e.g. {"resolution":"2K","duration_seconds":"5"}), unit — video_second (multiply unit_price by the generated seconds) or video_clip (unit_price is the whole-clip total) — and unit_price, which is always the final per-unit price and never a multiplier to apply yourself. When variants is present, use it, and note that a table has one of two shapes, with different fallback rules for a request it does not name. The shape is told by the dimensions keys, not by unit. A resolution-only table (rows keyed on {"resolution": ...}) keeps video published: it is the rate for any resolution the table does NOT list, so match your resolution against variants, else use video × seconds. A bucketed table (rows keyed on {"resolution", "duration_seconds"}) omits video, because it is never the basis there, and a request the table does not name exactly is billed off the table itself — the row for your resolution with the smallest duration_seconds that is still ≥ your clip's duration, and if no row at your resolution covers it (or your resolution has no rows at all) the highest-priced row in the whole table. That last case can therefore be much dearer than the shape you asked for; it means the operator has not tabulated what you requested, and the router counts it so they can. A model with no variants at all bills video × generated seconds. Resolution matching is case- and whitespace-insensitive on our side. Duration here is the length actually delivered, which for a request carrying a reference image or video is the vendor's billed length (input + output) and so can exceed the seconds you asked for. GET /v1/service-types lists video-generation / "Video Generation" once a video provider is on the network. Purely additive — no existing field changes. A video-generation model still reports prompt / completion, unchanged. Note these are only per-TOKEN prices for chat: on every other modality completion is an echo of the on-chain output price whose meaning follows the modality — per image for text-to-image / image-editing (which is why image exists), and per generated second for video-generation (which is why video / variants exist). So compute video cost from video / variants, exactly as image cost is computed from image; do not multiply completion by a token count. One caveat specific to variants, because it is a whole price LIST rather than a single rate: GET /v1/models aggregates a model across every endpoint serving it and shows one endpoint's block, so when two endpoints of the same model publish different tables the quote you read may not be the table the request is billed against. Endpoints are per-provider, so read GET /v1/providers for the exact list, or pin provider.address if you need the quote and the charge to be the same table. This is a general property of the aggregated view rather than something new — the shown block always comes from one endpoint, and which endpoint the request routes to depends on the routing preferences you send (sort, a pinned address) and on health — but it is worth stating for variants specifically, because a table is a whole price list rather than a single rate, so two endpoints can differ in the SHAPE of what they charge and not just the amount. [beta]

  • All inference endpoints — the min_cost admission floor rises from 0.00001 0G to 0.01 0G — a request is admitted only when the account can cover min_cost, either from the Router ledger or through the vault deferred path (see the Admission rule table above, which now reads 0.01 0G). The practical effect is that an account whose spendable balance has fallen below 0.01 0G now receives 402 rather than being served one last request. No balance is lost — the remainder stays on the account and becomes spendable again on the next top-up — and accounts with vault funds are unaffected, because the vault side of the admission rule is unchanged. The old floor was low enough to be indistinguishable from zero, which let a nearly-empty account be served a request it could not pay for, leaving a debt (pending_charge) that the account could then only clear by funding its vault.

  • POST /v1/chat/completions, /v1/messages, /v1/images/*, /v1/audio/transcriptions, /v1/async/images/*, /v1/routing/preview — pinning a provider that doesn't serve the requested model now returns 400, not 500 — when a request pins a specific provider (provider.address in the body or the X-0G-Provider-Address header) whose address exists but does not serve the requested model (or whose service type / API format doesn't match the endpoint), the router now returns 400 with error code provider_model_mismatch instead of a generic 500 / 502. This is a deterministic bad pin — two constraints ("use this exact provider" and "serve this model") that don't intersect — so retrying won't help and the response now says so, letting the caller correct the pin. An unpinned request is unaffected (it routes normally to a provider that serves the model), as is a pin that resolves to no provider at all (still 400/provider not found).

  • GET /v1/account, POST /v1/account/onboarded (JWT; GET also accepts a management key with account:read) — account onboarding state — a new endpoint pair exposes whether the authenticated wallet has handled the new-user onboarding flow. GET /v1/account returns account metadata {address, created_at, onboarded_at}, where onboarded_at is an RFC3339 timestamp once the account has completed or dismissed onboarding and null until then, so a client can decide whether to show the flow. Because the value is stored per wallet on the server (not in the browser), it persists across a cleared cache, another browser, and another device. POST /v1/account/onboarded records that the flow was handled, setting onboarded_at to the server time; it is idempotent — the first call stamps the timestamp and later calls (a client retry, or a "dismiss" after a "complete") leave it unchanged — so both client paths can safely call and retry it. [beta]

  • POST /v1/chat/completions, /v1/messages, /v1/images/*, /v1/audio/transcriptions, /v1/async/images/* — transient vault-check failure now returns retryable 503 instead of 402 — a routing-mode (Payment Layer vault) user's request is admitted by checking their on-chain vault balance. When that chain read fails transiently (RPC blip), the pre-request balance gate previously returned 402 "Insufficient balance" — indistinguishable from a genuinely empty account, so a funded user saw "insufficient balance" and assumed their deposit was lost. It now returns 503 with error code vault_unavailable (OpenAI-style) / api_error (Anthropic-style) and is safe to retry; the next request self-heals once the RPC recovers. Behavior is still fail-closed (the request is never admitted while vault state is unknown — no unbounded deferred debt) and a genuine balance shortfall (vault read succeeds, funds insufficient) is unchanged at 402. No request contract change.

  • GET /v1/models — added ByteDance Seedance 2.5 video-generation model (bytedance/seedance-2.5) — text-to-video and image-to-video, so modalities.input is [text, image] and output is [video]. Submit with POST /v1/videos, poll GET /v1/videos/{id} until completed, then GET /v1/videos/{id}/content for the MP4 — the same three-endpoint shape every video model on this router uses. Resolution is 480p/720p/1080p; nothing above that is served, and rather than being rejected such a request is snapped down — a 4K pixel size renders at 1080p, while an unrecognised resolution token (4k, 2K) falls to the 720p default. Read the poll response's echoed resolution rather than assuming the tier you asked for. Duration range is [4, 30] seconds (seconds is required on this endpoint, as it is for every video model here). Optional passthrough fields: seed, camera_fixed, and output_format (string, default "mp4") — accepted on submit, echoed on poll, and not validated beyond presence and type. input_reference.image_url drives image-to-video; input_reference.file_id returns 400 for this model — Seedance has no client-usable file-handle namespace to resolve a file_id against, and silently falling back to text-to-video would bill you for a video you did not ask for, so it is a hard reject rather than a silent drop. Seedance itself also supports first+last-frame control and multimodal reference generation (multiple reference images/videos/audio composited per the prompt, including audio-only input), but the OpenAI Video API has no field to express either one, so this integration exposes no client-facing input for them: keys named last_frame_reference, reference_images, reference_videos or reference_audio are ignored, not rejected, and the request proceeds on whatever accepted fields accompanied them. Billing is on the vendor-reported token count (usage.completion_tokens), not a flat per-second rate: pricing.variants carries 4 video_token-unit rows dimensioned on {resolution, has_video_input}. Only the has_video_input=false rows are reachable from this endpoint, since there is no client-facing way to supply a reference video. [beta]

  • Inference endpoints — for an account whose billing is served by 0G Pay, x_0g_trace.billing reports what you were actually charged, and a priced request is never charged nothing. Affects POST /v1/chat/completions, /v1/messages, /v1/embeddings, /v1/images/generations, /v1/images/edits, /v1/audio/transcriptions and the async job / video polls. Field names, units and currency tag are unchanged (micro-USD integer strings, currency: "usd"), and input_cost + output_cost == total_cost still holds exactly. What changed is where the figure comes from: the block used to be priced separately from the charge and rounded on its own, so it could report a cost that differed from what was collected. It is now the charge itself — a request's billed dimensions summed at full precision and rounded up once to the next micro-USD. Two consequences worth knowing, both on very cheap requests only. A request whose dimensions each price below half a micro-USD is charged the one-micro minimum rather than nothing, so it contributes its cost to GET /v1/account/usage/* totals and counts against an API key's credit_limit; a request charged nothing would have been excluded from both. And the input/output split puts the rounding on the input side (input_cost is its own dimensions rounded up, output_cost the remainder), so on a request costing well under a micro the whole cost can appear as input_cost with output_cost: "0" — the same convention the 0G and non-Pay USD paths already use. A model priced free is unaffected: still "0", still charged nothing. Accounts settling in 0G, and USD accounts not billed by 0G Pay, are byte-for-byte unchanged. [beta]

Account

Get account

Returns non-financial metadata for the authenticated wallet: created_at and onboarded_at. onboarded_at is an RFC3339 timestamp once the account has completed or dismissed the new-user onboarding flow, and null until then — a client reads it to decide whether to show onboarding. Set it via POST /account/onboarded. The value is stored per wallet on the server, so it persists across a cleared cache, another browser, and another device.

Authorizations:
ManagementKeyAuth

Responses

Response samples

Content type
application/json
{
  • "address": "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
  • "created_at": "2026-01-15T10:30:00Z",
  • "onboarded_at": "2026-01-15T10:35:00Z"
}

Get account balance

Returns the caller's Router ledger balance: deposit_balance (funds Router has already pulled from the Payment Layer vault) plus credit_balance (promo / granted credit). total_balance is the sum of the two.

This endpoint deliberately does not include the PL vault balance. The vault is a shared pool across 0G apps; Router pulls from it incrementally on demand. To predict whether an inference request can be submitted, clients should combine this response with the PL vault balance — see the "Balance model" section in the API description for the formula.

pending_charge is any outstanding debt the user owes Router (created by the deferred-charge path when the ledger was short but the vault could cover the request); it is cleared automatically when the next vault pull lands on chain.

Accounts settling in USD (currency: "usd" in the response) are never served from the Payment Layer vault — USD settlement does not spend from it. Everything above this paragraph describes 0G-settling accounts only: for a USD account do not add a vault balance on top of this response, and there is no vault pull behind pending_charge either — an amount owed there is cleared by the account's own funds, not by a chain deposit. One caveat if this wallet settled in 0G before switching: Router may still be pulling its vault balance into the now-dormant 0G ledger, and neither this endpoint nor /funds reports that money while the account is in USD mode. Moving the account back to 0G settlement is what makes that money visible and spendable again — at the cost of moving billing off USD until you switch back, since only one ledger is live at a time (both are preserved, nothing is lost either way). The route that does it is session-authenticated, so it is a console action rather than something an sk- or mk- caller can perform.

A USD account whose billing 0G Pay serves is additionally answered from 0G Pay rather than from a balance Router holds. deposit_balance and credit_balance then carry the funds and promotional credit held there, reported as what this service can spend: promotional credit that has expired, or that was granted for a different 0G service, is excluded, so this can read lower than 0G Pay's own wallet view. It is the same figure an inference request is admitted against, but a positive value is not a promise of admission — an amount owed (pending_charge, which total_balance does not subtract), a minimum-balance floor, or a hold placed on the account each refuse independently of it. When the balance cannot be established — the billing service is unreachable, or answers something this endpoint will not act on — it returns 503 rather than a stale or empty figure.

Authorizations:
ManagementKeyAuth

Responses

Response samples

Content type
application/json
{
  • "address": "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
  • "balance": "1000000000000000000",
  • "credit_balance": "100000000000000000",
  • "currency": "0g",
  • "deposit_balance": "900000000000000000",
  • "pending_charge": "0",
  • "total_balance": "1000000000000000000"
}

Get unified account funds

Returns a single aggregated view of the caller's funds so clients don't have to combine /account/balance (Router ledger) with the on-chain PaymentVault.balanceOf themselves.

total = router.subtotal + payment_layer.balance, where router.subtotal = deposit + credit − pending_charge (the net Router-ledger position). total is the user's net spendable funds and may be negative when outstanding debt exceeds available funds — render the shortfall as "owed to Router". This differs from /account/balance's total_balance, which keeps a ≥0 contract for settlement / SDK callers and does NOT subtract pending_charge.

payment_layer.balance is the shared Payment Layer vault balance (shared_across_products: true — the vault is a pool across 0G apps, not owned exclusively by Router). queried_at is when that balance was actually read from chain; it is cached (~10s), so on a cache hit it can be up to that TTL in the past — treat it as the value's as-of time, not the request time.

For the Router-ledger-only view that settlement / SDK / mgmt-key integrations depend on, use /account/balance instead.

For accounts settling in USD (currency: "usd") the payment_layer block does not apply — the Payment Layer vault plays no part in USD settlement — and is returned inert: balance is always "0", shared_across_products is false, and queried_at is the time this response was produced, not a chain read. total is therefore the Router-side position alone, and is negative when the amount owed exceeds deposit + credit.

When 0G Pay serves that account's billing, the router figures come from there (deposit = funds, credit = promotional credit spendable on this service, pending_charge = amount owed), on the same spendable basis /account/balance documents, and the endpoint returns 503 when that balance cannot be established.

Authorizations:
ManagementKeyAuth

Responses

Response samples

Content type
application/json
{
  • "address": "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
  • "currency": "0g",
  • "payment_layer": {
    },
  • "router": {
    },
  • "total": "7390000000000000000"
}

Get daily usage statistics

Get usage statistics broken down by day for current user, with optional filters

Authorizations:
ManagementKeyAuth
query Parameters
api_key_id
string

Filter by API key ID

source
string

Filter by source: 'wallet' (JWT/browser), 'api_key' (any API key), or omit for all

start_date
string

Start date (YYYY-MM-DD, inclusive)

end_date
string

End date (YYYY-MM-DD, inclusive)

trust_mode
string

Filter by trust tier: 'standard' | 'verified' | 'private'. Omit for all tiers.

dimensions
string

Extra breakdown dimensions on top of (date, model). Only 'trust_mode' is supported: pass dimensions=trust_mode to split each day/model row per trust tier and emit the trust_mode field. Omit (default) for the legacy one-row-per (date, model) shape.

Responses

Response samples

Content type
application/json
{
  • "currency": "0g",
  • "data": [
    ],
  • "object": "list"
}

Get usage history

Get usage history for current user, with optional filters

Authorizations:
ManagementKeyAuth
query Parameters
limit
integer
Default: 20

Limit number of records

offset
integer
Default: 0

Offset for pagination (legacy). Ignored when cursor is set.

cursor
string

[beta] Opaque keyset cursor from a prior page's next_cursor. When set, pages via seek (constant per-page cost, independent of depth) and offset is ignored; also forces include_total=false.

include_total
boolean
Default: true

[beta] Include the full-set total count (default true). Set false to skip the expensive COUNT(*); recommended with cursor.

model_id
string

Filter by model ID

api_key_id
string

Filter by API key ID

source
string

Filter by source: 'wallet' (JWT/browser), 'api_key' (any API key), or omit for all

start_date
string

Start date (YYYY-MM-DD, inclusive)

end_date
string

End date (YYYY-MM-DD, inclusive)

trust_mode
string

Filter by trust tier: 'standard' | 'verified' | 'private'. Omit for all tiers.

Responses

Response samples

Content type
application/json
{
  • "currency": "0g",
  • "data": [
    ],
  • "limit": 20,
  • "next_cursor": "string",
  • "object": "list",
  • "offset": 0,
  • "total": 100
}

Get usage statistics

Get usage statistics for current user, optionally filtered by API key and date range

Authorizations:
ManagementKeyAuth
query Parameters
api_key_id
string

Filter by API key ID

source
string

Filter by source: 'wallet' (JWT/browser), 'api_key' (any API key), or omit for all

start_date
string

Start date (YYYY-MM-DD, inclusive)

end_date
string

End date (YYYY-MM-DD, inclusive)

trust_mode
string

Filter by trust tier: 'standard' | 'verified' | 'private'. Omit for all tiers.

Responses

Response samples

Content type
application/json
{
  • "completion_tokens": 4345,
  • "currency": "0g",
  • "prompt_tokens": 8000,
  • "total_cost": "1000000000000000",
  • "total_requests": 42,
  • "total_tokens": 12345
}

API Key

Get API key list

Get all API keys for current user

Authorizations:
ManagementKeyAuth
query Parameters
status
string

Filter: active / revoked / expired / all (default all)

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "object": "list"
}

Create API key

Create a new API key for LLM service access

Authorizations:
ManagementKeyAuth
Request Body schema: application/json

API key configuration

allowed_models
Array of strings

AllowedModels is an optional allowlist of model IDs this key may invoke. Nil = no restriction. Empty slice = explicit empty list, rejected at validation (a key that can call no models is useless).

allowed_providers
Array of strings

AllowedProviders is an optional allowlist of provider addresses (hex, 0x-prefixed) this key may target. Same nil-vs-empty convention as AllowedModels.

credit_limit
string

0G units; nil = unlimited

expiration
string

RFC3339, "no_expiration", or ""

name
string
reset_period
string

never/daily/weekly/monthly; "" → never

trust_mode
string

TrustMode optionally pins this key to a single trust tier. Accepted values: "standard" | "verified" | "private" | "" (no pin). The wire shape is a value type rather than a pointer because the create path has no "leave unchanged" semantics — absent / empty / explicit "" all mean "no pin".

Responses

Request samples

Content type
application/json
{
  • "allowed_models": [
    ],
  • "allowed_providers": [
    ],
  • "credit_limit": "string",
  • "expiration": "string",
  • "name": "string",
  • "reset_period": "string",
  • "trust_mode": "string"
}

Response samples

Content type
application/json
{
  • "allowed_models": [
    ],
  • "allowed_providers": [
    ],
  • "created_at": "2024-01-15T10:30:00Z",
  • "credit_limit": "1.5",
  • "currency": "0g",
  • "expires_at": "2025-02-15T10:30:00Z",
  • "key": "string",
  • "key_id": "abc12345",
  • "key_preview": "sk-abcde…",
  • "name": "my-api-key",
  • "reset_period": "monthly",
  • "revoked": false,
  • "status": "active",
  • "trust_mode": "verified",
  • "used": "0.42"
}

Revoke API key

Revoke a specific API key

Authorizations:
ManagementKeyAuth
path Parameters
keyId
required
string

API Key ID

Responses

Response samples

Content type
application/json
{
  • "key_id": "abc12345",
  • "message": "API key revoked"
}

Update API key

Update name / credit_limit / reset_period / expiration of an API key

Authorizations:
ManagementKeyAuth
path Parameters
keyId
required
string

API Key ID

Request Body schema: application/json
required

Patch payload

allowed_models
Array of strings

AllowedModels / AllowedProviders sparse-PATCH sentinels: nil → unchanged non-nil empty [] → clear the allowlist (no restriction) non-nil non-empty → replace the allowlist

Pointer to slice (rather than slice) so we can distinguish "leave alone" from "clear", matching how Scopes is handled on mgmt keys.

allowed_providers
Array of strings
credit_limit
string
expiration
string
name
string
reset_period
string
trust_mode
string

TrustMode sparse-PATCH sentinels: nil → unchanged; "" → clear the pin (key falls back to no trust-mode restriction); any of standard|verified|private → set the pin.

Responses

Request samples

Content type
application/json
{
  • "allowed_models": [
    ],
  • "allowed_providers": [
    ],
  • "credit_limit": "string",
  • "expiration": "string",
  • "name": "string",
  • "reset_period": "string",
  • "trust_mode": "string"
}

Response samples

Content type
application/json
{
  • "allowed_models": [
    ],
  • "allowed_providers": [
    ],
  • "created_at": "2024-01-15T10:30:00Z",
  • "credit_limit": "1.5",
  • "currency": "0g",
  • "expires_at": "2025-02-15T10:30:00Z",
  • "key_id": "abc12345",
  • "key_preview": "sk-abcde…",
  • "name": "my-api-key",
  • "reset_period": "monthly",
  • "revoked": false,
  • "status": "active",
  • "trust_mode": "verified",
  • "used": "0.42"
}

Inference

Submit async image edit job

Submit an image editing job to be processed asynchronously. The response includes jobId and provider_address — clients then poll /v1/async/jobs/{jobId} with ?provider_address=... to retrieve the result. Supports provider routing preferences via X-0G-Provider-* request headers (Address, Sort, Trust-Mode, Allow-Fallbacks). Multipart endpoints have no body-side routing surface — headers only.

Authorizations:
ApiKeyAuth
header Parameters
X-0G-Provider-Address
string

Routing: pin to a specific provider by on-chain address (0x-prefixed).

X-0G-Provider-Sort
string

Routing: provider sort strategy. latency | price.

X-0G-Provider-Trust-Mode
string

Routing: trust tier filter (verified | private). verified is a floor — private providers also satisfy it.

X-0G-Provider-Allow-Fallbacks
string

Routing: whether to retry on other providers after a failure. true | false. Defaults to false when an address is pinned, true otherwise.

Responses

Response samples

Content type
application/json
{ }

Submit async image generation job

Submit an image generation job to be processed asynchronously. The response includes jobId and provider_address — clients then poll /v1/async/jobs/{jobId} with ?provider_address=... to retrieve the result. Provider Routing: Use "provider": {...} body field OR X-0G-Provider-* request headers. Header > body precedence resolves same-field conflicts.

Authorizations:
ApiKeyAuth
header Parameters
X-0G-Provider-Address
string

Routing: pin to a specific provider by on-chain address (0x-prefixed).

X-0G-Provider-Sort
string

Routing: provider sort strategy. latency | price.

X-0G-Provider-Trust-Mode
string

Routing: trust tier filter (verified | private). verified is a floor — private providers also satisfy it.

X-0G-Provider-Allow-Fallbacks
string

Routing: whether to retry on other providers after a failure. true | false. Defaults to false when an address is pinned, true otherwise.

Request Body schema: application/json
required

Image generation request

model
string

Model ID

n
integer

Number of images to generate

prompt
string

Image description

response_format
string

Response format: url or b64_json

size
string

Image size

Responses

Request samples

Content type
application/json
{
  • "model": "dall-e-3",
  • "n": 1,
  • "prompt": "A beautiful sunset",
  • "response_format": "url",
  • "size": "1024x1024"
}

Response samples

Content type
application/json
{ }

Audio transcription

Transcribe audio file to text. Accepts either content type:

  • application/json (default, schema below): AudioTranscriptionRequest body with base64-encoded audio in file_base64.
  • multipart/form-data (OpenAI-style): file part with audio bytes + model form field (plus optional language, response_format). Schema not auto-documented here — matches the OpenAI /v1/audio/transcriptions shape. Supports provider routing preferences via X-0G-Provider-* request headers (Address, Sort, Trust-Mode, Allow-Fallbacks). Multipart requests have no body-side routing surface — headers only. JSON requests may also use the body provider: {...} field.
Authorizations:
ApiKeyAuth
header Parameters
X-0G-Provider-Address
string

Routing: pin to a specific provider by on-chain address (0x-prefixed).

X-0G-Provider-Sort
string

Routing: provider sort strategy. latency | price.

X-0G-Provider-Trust-Mode
string

Routing: trust tier filter (verified | private). verified is a floor — private providers also satisfy it.

X-0G-Provider-Allow-Fallbacks
string

Routing: whether to retry on other providers after a failure. true | false. Defaults to false when an address is pinned, true otherwise.

Request Body schema:
required

JSON-body shape (base64-encoded audio in file_base64). For multipart/form-data requests, send file + model form parts instead — see description.

file_base64
string

Base64 encoded audio file

filename
string

Filename

language
string

Audio language

model
string

Model ID

response_format
string

Response format

Responses

Request samples

Content type
{
  • "file_base64": "SGVsbG8gd29ybGQ=",
  • "filename": "audio.mp3",
  • "language": "en",
  • "model": "whisper-1",
  • "response_format": "json"
}

Response samples

Content type
application/json
{
  • "duration": 5.5,
  • "language": "en",
  • "text": "Hello, world!"
}

Chat completion

Send chat request to AI provider (OpenAI compatible API).

Web Search: Add "plugins": [{"id": "web"}] to enable real-time web search. Search results are injected into the prompt context and returned as url_citation annotations in the response. Works with any model. Multi-turn conversations automatically rewrite the query for better search relevance. The injected search context tokens are billed as normal input tokens.

File Attachments: Upload via POST /v1/files, then reference with "attachments": [{"file_id": "file-..."}]. Extracted text is injected into the system prompt and billed as normal input tokens.

Provider Routing: Use "provider": {"sort": "latency"|"price", "address": "0x...", "allow_fallbacks": true} to influence provider selection. address overrides sort. The X-0G-Provider-* request headers (documented below) are the equivalent canonical surface. Price Ceiling (header-only): set X-0G-Provider-Max-Price-Usd-Prompt / -Completion (USD per 1M tokens) or -Image (USD per generated image). Providers above the cap are excluded before sort/fallback runs. [beta]

E2EE (sealed requests): a 0G Private Computer sidecar may seal messages/tools into a top-level _e2ee object and omit them from the cleartext body. The router detects _e2ee, skips the messages-required check, and carries the _e2ee field through to the provider (which it never reads or decrypts); model and response usage stay cleartext for routing/billing. Presence of _e2ee is the only signal (no header). [beta]

Authorizations:
ApiKeyAuth
header Parameters
X-0G-Provider-Address
string

Routing: pin to a specific provider by on-chain address (0x-prefixed).

X-0G-Provider-Sort
string

Routing: provider sort strategy. latency | price.

X-0G-Provider-Trust-Mode
string

Routing: trust tier filter (verified | private). verified is a floor — private providers also satisfy it.

X-0G-Provider-Allow-Fallbacks
string

Routing: whether to retry on other providers after a failure. true | false. Defaults to false when an address is pinned, true otherwise.

Request Body schema: application/json
required

Chat request

Array of objects (github_com_0glabs_0g-router_pkg_inference.Attachment)

File attachments (uploaded via /v1/files)

max_tokens
integer

Maximum output tokens

Array of objects (github_com_0glabs_0g-router_pkg_inference.RequestMessage)

Message list (content may be string or multimodal array)

model
string

Model ID

Array of objects (github_com_0glabs_0g-router_pkg_inference.Plugin)

Plugins to enable (e.g. [{"id":"web"}] for web search)

object

Routing preferences (0G Router extension)

object

Output format: json_object or json_schema for structured output

stream
boolean

Whether to stream output

temperature
number

Temperature parameter (0-2)

tool_choice
any

Which tool to use: "none", "auto", "required", or {"type":"function","function":{"name":"..."}}

Array of objects (github_com_0glabs_0g-router_pkg_inference.Tool)

Tools (functions) the model may call

top_p
number

Top-p sampling parameter

Responses

Request samples

Content type
application/json
{
  • "attachments": [
    ],
  • "max_tokens": 1024,
  • "messages": [
    ],
  • "model": "qwen/qwen-2.5-7b-instruct",
  • "plugins": [
    ],
  • "provider": {
    },
  • "response_format": {
    },
  • "stream": false,
  • "temperature": 0.7,
  • "tool_choice": null,
  • "tools": [
    ],
  • "top_p": 0.9
}

Response samples

Content type
application/json
{
  • "choices": [
    ],
  • "created": 1677652288,
  • "id": "chatcmpl-123",
  • "model": "qwen/qwen-2.5-7b-instruct",
  • "object": "chat.completion",
  • "usage": {
    },
  • "x_0g_trace": {
    }
}

Create decisions

Ask a decision model (TypeSafe Jev) one or more typed questions about state. questions is an object of named questions, each {"type":"noul"|"choice"|"score","instructions":...,"criteria":...}; the response carries a typed answer with a probability per question, never generated text. Synchronous: one JSON response, no streaming. Supports provider routing preferences (see /chat/completions).

Authorizations:
ApiKeyAuth
header Parameters
X-0G-Provider-Address
string

Routing: pin to a specific provider by on-chain address (0x-prefixed).

X-0G-Provider-Sort
string

Routing: provider sort strategy. latency | price.

X-0G-Provider-Trust-Mode
string

Routing: trust tier filter (verified | private). verified is a floor — private providers also satisfy it.

X-0G-Provider-Allow-Fallbacks
string

Routing: whether to retry on other providers after a failure. true | false. Defaults to false when an address is pinned, true otherwise.

Request Body schema: application/json
required

Decisions request

model
string

Model ID

questions
Array of integers

Named typed questions

state
Array of integers

App state the questions are asked about

Responses

Request samples

Content type
application/json
{
  • "model": "jev-1.13",
  • "questions": [
    ],
  • "state": [
    ]
}

Response samples

Content type
application/json
{ }

Create embeddings

Create a vector embedding for the given input text. Supports provider routing preferences (see /chat/completions). E2EE (sealed requests): a 0G Private Computer client may seal input into a top-level _e2ee object and omit it from the cleartext body. The router skips the input-required check for such a body and forwards _e2ee to the provider without reading it; model, dimensions and encoding_format stay cleartext for routing. The response seals data (the vectors) and carries the billable usage.prompt_tokens in cleartext. A sealed request must not also carry a top-level input, and attachments is refused on a sealed request (unsupported_with_e2ee). [beta]

Authorizations:
ApiKeyAuth
header Parameters
X-0G-Provider-Address
string

Routing: pin to a specific provider by on-chain address (0x-prefixed).

X-0G-Provider-Sort
string

Routing: provider sort strategy. latency | price.

X-0G-Provider-Trust-Mode
string

Routing: trust tier filter (verified | private). verified is a floor — private providers also satisfy it.

X-0G-Provider-Allow-Fallbacks
string

Routing: whether to retry on other providers after a failure. true | false. Defaults to false when an address is pinned, true otherwise.

Request Body schema: application/json
required

Embedding request

input
Array of integers

Text (or array of texts / token arrays) to embed

model
string

Model ID

Responses

Request samples

Content type
application/json
{
  • "input": [
    ],
  • "model": "qwen3.7-text-embedding"
}

Response samples

Content type
application/json
{ }

Image editing

Edit an image using AI (inpainting, variations). Supports provider routing preferences via X-0G-Provider-* request headers (Address, Sort, Trust-Mode, Allow-Fallbacks). Multipart endpoints have no body-side routing surface — headers only.

Authorizations:
ApiKeyAuth
header Parameters
X-0G-Provider-Address
string

Routing: pin to a specific provider by on-chain address (0x-prefixed).

X-0G-Provider-Sort
string

Routing: provider sort strategy. latency | price.

X-0G-Provider-Trust-Mode
string

Routing: trust tier filter (verified | private). verified is a floor — private providers also satisfy it.

X-0G-Provider-Allow-Fallbacks
string

Routing: whether to retry on other providers after a failure. true | false. Defaults to false when an address is pinned, true otherwise.

Request Body schema: multipart/form-data
image
string <binary>

Source image file

prompt
required
string

Edit instruction

model
required
string

Model ID

Responses

Response samples

Content type
application/json
{
  • "created": 1677652288,
  • "data": [
    ]
}

Image generation

Generate images using AI. Supports provider routing preferences (see /chat/completions).

Authorizations:
ApiKeyAuth
header Parameters
X-0G-Provider-Address
string

Routing: pin to a specific provider by on-chain address (0x-prefixed).

X-0G-Provider-Sort
string

Routing: provider sort strategy. latency | price.

X-0G-Provider-Trust-Mode
string

Routing: trust tier filter (verified | private). verified is a floor — private providers also satisfy it.

X-0G-Provider-Allow-Fallbacks
string

Routing: whether to retry on other providers after a failure. true | false. Defaults to false when an address is pinned, true otherwise.

Request Body schema: application/json
required

Image generation request

model
string

Model ID

n
integer

Number of images to generate

prompt
string

Image description

response_format
string

Response format: url or b64_json

size
string

Image size

Responses

Request samples

Content type
application/json
{
  • "model": "dall-e-3",
  • "n": 1,
  • "prompt": "A beautiful sunset",
  • "response_format": "url",
  • "size": "1024x1024"
}

Response samples

Content type
application/json
{
  • "created": 1677652288,
  • "data": [
    ]
}

Anthropic Messages API

Send chat request using Anthropic Messages API format. Routes to providers that support the Anthropic format. Supports web search plugins, file attachments, and provider routing preferences (same as /chat/completions).

Streaming (stream: true) returns a Server-Sent Events stream of standard Anthropic events. Router-added data rides as extra top-level fields on the message_delta event — x_0g_trace, plus annotations when the web search plugin was used — so every data: payload the router authors stays a valid Anthropic stream event. Read them from the last message_delta; there can be more than one, and only the last carries them. x_0g_trace is omitted entirely when the router could not attach it to a valid message_delta, so treat its absence as "not available", not as an error. message_stop is the final frame whenever the provider sends one. Some clients strip unknown fields from their parsed event objects, so read the raw SSE payload if your SDK does. Non-streaming responses carry x_0g_trace at the top level of the response body instead.

Authorizations:
ApiKeyAuth
header Parameters
X-0G-Provider-Address
string

Routing: pin to a specific provider by on-chain address (0x-prefixed).

X-0G-Provider-Sort
string

Routing: provider sort strategy. latency | price.

X-0G-Provider-Trust-Mode
string

Routing: trust tier filter (verified | private). verified is a floor — private providers also satisfy it.

X-0G-Provider-Allow-Fallbacks
string

Routing: whether to retry on other providers after a failure. true | false. Defaults to false when an address is pinned, true otherwise.

Request Body schema: application/json
required

Anthropic Messages request

Array of objects (github_com_0glabs_0g-router_pkg_inference.Attachment)
max_tokens
integer
messages
Array of any

AnthropicMessage objects

metadata
any
model
string
Array of objects (github_com_0glabs_0g-router_pkg_inference.Plugin)

0G Router extensions (stripped before forwarding)

object (github_com_0glabs_0g-router_pkg_inference.ProviderPreferences)
stop_sequences
Array of strings
stream
boolean
system
any

string or []AnthropicContentBlock

temperature
number
tool_choice
any
tools
Array of any
top_k
integer
top_p
number

Responses

Request samples

Content type
application/json
{
  • "attachments": [
    ],
  • "max_tokens": 0,
  • "messages": [
    ],
  • "metadata": null,
  • "model": "string",
  • "plugins": [
    ],
  • "provider": {
    },
  • "stop_sequences": [
    ],
  • "stream": true,
  • "system": null,
  • "temperature": 0,
  • "tool_choice": null,
  • "tools": [
    ],
  • "top_k": 0,
  • "top_p": 0
}

Response samples

Content type
application/json
{
  • "content": [
    ],
  • "id": "string",
  • "model": "string",
  • "role": "string",
  • "stop_reason": "string",
  • "type": "string",
  • "usage": {
    }
}

Preview provider selection (E2EE)

Return, in fallback order, the provider(s) the router WOULD select for a request — WITHOUT executing, billing, or mutating routing state. Built for end-to-end-encrypted (sealed) requests: a sealed request must be encrypted to a specific provider enclave before it can be sent, so a gateway calls this first (with the sensitive messages / prompt stripped out), seals to the head candidate, and pins the real request to it via X-0G-Provider-Address + X-0G-Provider-Identity — falling back to the next candidate (re-sealing) on failure.

The body is the stripped inference request plus the control field service_type — the internal service type being previewed (chatbot | text-to-image | image-editing | speech-to-text | embedding). This is the SAME vocabulary returned by GET /v1/service-types (.type) and accepted by GET /v1/providers (?service_type=), so a caller discovering providers via the catalog feeds the same string straight in (it is NOT the model-modality type on /v1/models). anthropic-chat is not an accepted service_type — the API surface is a separate axis, see api_format. api_format is OPTIONAL and applies to service_type=chatbot only, which is the one service type serving two surfaces: anthropic previews /v1/messages (candidates are narrowed to providers advertising the Anthropic format), openai previews /v1/chat/completions, and omitting it previews either. openai is informational — it narrows nothing, because /v1/chat/completions itself routes without a format filter (OpenAI is the default surface, and a provider serving it need not enumerate it), so filtering here would hide providers your real request would use. Sending api_format with a non-chat service_type is a 400: image, audio and embedding have one surface each. embedding is accepted only where /v1/embeddings is served. model is OPTIONAL: given, candidates are that model's providers; omitted, candidates are ANY provider of the service type (a heterogeneous list — each candidate carries its own canonical_id / model_id). Routing preferences (provider / X-0G-Provider-*) and capability signals (tools, response_format, max_tokens, reasoning_effort) are read from the body/headers exactly as on the inference endpoints, so the previewed order matches what the request would select. The number of candidates is fixed at the router's provider-retry budget (the length of the fallback chain the router itself would try); a smaller healthy pool returns fewer. Each candidate returns the provider address and provider_identity (together, the pin — see below), its broker endpoint (to fetch the enclave key from), and the model it serves (canonical_id to put in the sealed request, model_id informational). provider_identity names the upstream a provider forwards to, and is present whenever that provider reports one — omitted otherwise, which is a property of the provider, not a promise that it serves only one upstream. Treat it as opaque: pin back exactly what the candidate carries (X-0G-Provider-Identity alongside X-0G-Provider-Address), including pinning nothing when it carries nothing, and infer nothing from its absence. Pin only the address where a candidate has an identity and the router re-picks among that address's upstreams at execute time, so you may be served by one other than the candidate you previewed and sealed against. A pinned provider.address returns just that provider. For the multipart inference endpoints (image-editing, speech-to-text) there is no JSON body to strip: synthesize this JSON body carrying service_type + model and forward routing prefs via X-0G-Provider-* headers — the uploaded blob (file / mask) and prompt need not (and should not) be included, as they are both sensitive and irrelevant to provider selection. [beta]

Authorizations:
ApiKeyAuth
Request Body schema: application/json
required

Preview request (stripped inference body + service_type/model)

api_format
string

OPTIONAL, chatbot only: openai | anthropic; empty = any format

model
string

OPTIONAL: canonical id (or alias / on-chain id); empty = any provider of this service type

service_type
string

chatbot | text-to-image | image-editing | speech-to-text | embedding

Responses

Request samples

Content type
application/json
{
  • "api_format": "string",
  • "model": "string",
  • "service_type": "string"
}

Response samples

Content type
application/json
{
  • "object": "string",
  • "providers": [
    ],
  • "service_type": "string"
}

List your video jobs

Return this account's video jobs, most recent first. It exists so a client that lost a job id — a script that died after POST /videos returned, a page reloaded before the id was stored — can find it again: without it a lost id is a lost video, since every other video endpoint is keyed on the id.

Each entry is the ROUTER's record of the job, not the provider's video object: id (send it back to GET /videos/{id} unmodified), model, created_at (unix seconds, submit time), and the seconds / size the request asked for — seconds is quoted as a string and size echoes what you sent (1080p), not the provider's rendered resolution. Both are the values you REQUESTED and can differ from what was rendered and billed — a model silently clamps a duration outside its range (MiniMax-H3 to 4-15s, ByteDance Seedance 2.5 to 4-30s) and may downgrade a resolution it does not serve, so a request for 1080p can be delivered at 720p while this still shows 1080p. GET /videos/{id} reports what the provider actually produced. Fields the provider owns, such as progress, are absent; a job still running has them there too. status is in_progress | completed | failed, derived from the router's own record — completed means the clip was delivered AND charged, and in_progress includes a clip the provider may already have finished but nobody has polled for yet. Poll GET /videos/{id} for the provider's full object; that is also what settles the charge.

Only jobs submitted through this router are visible, and only your own. The window is the most recent 100 jobs — there is no cursor, so an id lost further back than that cannot be recovered here.

A listed job is a router-side RECORD, not a guarantee the file still exists: the media is held by the model provider, not by this router, and vendors expire it (roughly a day to a week depending on the vendor). So status: completed on an older entry can still mean GET /videos/{id}/content no longer has anything to serve.

Authorizations:
ApiKeyAuth
query Parameters
limit
integer

How many jobs to return, most recent first. Default 20, maximum 100 — a larger value is clamped, not rejected. There is no pagination: only the most recent 100 jobs are reachable.

Responses

Response samples

Content type
application/json
{ }

Submit async video generation job

Submit a video generation job (OpenAI Video API shape). The response id names the provider that owns the job, so poll it at GET /videos/{id} and download from GET /videos/{id}/content with no other parameter. Store it as text — it is up to 80 characters, not the 36 the older format used — and send it back exactly as returned. provider_address is still accepted but is ignored when the id names one. Accepts application/json OR multipart/form-data. Use multipart to drive image-to-video: send the same fields as form values plus the reference image as a file part. Supports provider routing preferences via X-0G-Provider-* request headers (Address, Sort, Trust-Mode, Allow-Fallbacks), identical to the other inference endpoints. The request shape is the OpenAI Video API's, but the models behind it are not OpenAI's, and where a model's own limits differ from OpenAI's they win. Those limits are per-model — read them from the model's description and pricing.variants on GET /v1/models rather than assuming the OpenAI defaults — and a request that violates one is rejected by the model provider, so the error text and any numeric code in it are theirs, not ours. The two that differ most often are size and seconds. size names a resolution TIER, not output dimensions, because a video model advertises the tiers it is PRICED at rather than arbitrary sizes. Two spellings are accepted and they are not equivalent. Pixel dimensions (1280x720, OpenAI's spelling and what an SDK sends) select the ASPECT RATIO only, and only for text-to-video — the clip renders at whatever tier the model serves, so on a 2K-only model 1280x720 returns a 2560x1440 clip billed at the 2K rate, not a 720p one. For image-to-video the aspect ratio follows your reference image, so pixel dimensions have no effect there — a tier name still selects the tier. A tier name (2K) addresses the tier directly. Send only names that appear in that list — it is the set this endpoint can price, and it grows as a model adds tiers, so read it rather than hard-coding what a model serves today. Read the tiers from pricing.variants[].dimensions.resolution. Omitting size is the recommended default — the model's own default tier and aspect ratio apply. seconds is clamped to the model's supported range rather than rejected, in BOTH directions and without an error: ask for less than the minimum and you get (and pay for) the minimum; ask for more than the maximum and you get the maximum. The clip you are billed for is the one actually produced, so a request outside the range costs something other than what you asked for. The range is per-model and stated in the model's description. For both fields the submit response echoes what you SENT, while the poll response reports what was actually rendered. Reconcile against the poll.

Authorizations:
ApiKeyAuth
header Parameters
X-0G-Provider-Address
string

Routing: pin to a specific provider by on-chain address (0x-prefixed).

X-0G-Provider-Sort
string

Routing: provider sort strategy. latency | price.

X-0G-Provider-Trust-Mode
string

Routing: trust tier filter (verified | private). verified is a floor — private providers also satisfy it.

X-0G-Provider-Allow-Fallbacks
string

Routing: whether to retry on other providers after a failure. true | false. Defaults to false when an address is pinned, true otherwise.

Request Body schema:
required

Video generation request. model, prompt and seconds are REQUIRED (seconds is optional in the upstream OpenAI Video API but mandatory here — see the changelog); size is optional.

object

Responses

Request samples

Content type
{ }

Response samples

Content type
application/json
{ }

Poll async video generation job

Return a video job's current status. When the job has completed this is also what settles the charge. provider_address is optional — it is resolved from the job id when omitted, so an OpenAI-native client can call GET /videos/{id} with no query parameters.

Authorizations:
ApiKeyAuth
path Parameters
id
required
string

Video job id exactly as returned by POST /videos. It names the provider that owns the job, so it is longer than the provider's own id (up to 80 chars) — store it as text and echo it back unmodified. Ids issued before this became the format keep working.

query Parameters
provider_address
string

Pin the provider, for an id issued before ids named their own provider. Ignored when the id names one.

verify_tee
boolean

Verify the provider's TEE signature and include the trace

Responses

Response samples

Content type
application/json
{ }

Download a completed video

Stream the finished video file. If the job has not been charged yet (the client never polled GET /videos/{id}) it is charged before any bytes are sent. A job that is still running returns 409; a job that failed at the provider returns 424. provider_address is optional: an id issued by POST /videos names its own provider, and an older id is resolved by lookup.

Authorizations:
ApiKeyAuth
path Parameters
id
required
string

Video job id exactly as returned by POST /videos. It names the provider that owns the job, so it is longer than the provider's own id (up to 80 chars) — store it as text and echo it back unmodified. Ids issued before this became the format keep working.

query Parameters
provider_address
string

Pin the provider, for an id issued before ids named their own provider. Ignored when the id names one.

verify_tee
boolean

Verify the provider's TEE signature before billing

Responses

Models

Get model list

Get all available AI models. By default the response is the canonical view: one row per canonical_id, with curated registry metadata and endpoints aggregated (max context, union of params/formats, cheapest pricing); id is the canonical model id. A model stays listed even when all its endpoints are temporarily unhealthy (health is not a visibility filter; routing falls back to unhealthy endpoints so a listed model is always routable). To enumerate the endpoints behind a canonical id, call GET /v1/providers?canonical_id=<id>. provider_address uses OR (match any), input_modality and supported_parameter use AND (must match all). ?legacy=true returns the historical view keyed by the raw provider model_id (one row per on-chain model id, including ids not mapped to a registered canonical), for clients that have not migrated to canonical ids. This is a listing-only compatibility surface — routing remains canonical-only, so a raw id absent from the model registry is still 404 model_not_found on the inference path. [beta]

query Parameters
legacy
boolean

[beta] true: historical raw-model_id-keyed view (listing-only; an unregistered id it lists is still 404 on the inference path). Default false: canonical view (id = canonical_id).

pricing
string
Value: "usd"

[beta] 'usd': emit the OpenRouter-standard shape with per-token USD decimal strings under the top-level pricing field (for external pricing aggregators, e.g. TKX); models without a USD price are omitted. Default: native prices under pricing, USD under pricing_usd.

provider_address
Array of strings

Filter by provider address (OR: match any)

input_modality
Array of strings

Filter by input modality, e.g. text, image (AND: must support all)

supported_parameter
Array of strings

Filter by supported parameter, e.g. temperature (AND: must support all)

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "object": "list"
}

Provider

Get provider list

Get all TEE-acknowledged providers, optionally narrowed by service type, on-chain model id, and/or canonical id. model and canonical_id are independent filters and compose (ANDed): canonical_id alone lists every endpoint of a canonical, while model + canonical_id narrows to a specific endpoint within it. An empty value means "not filtered on" (so no filters = list all). Includes providers with unknown health status (is_healthy=null).

query Parameters
service_type
string

Filter by service type (chatbot, text-to-image, speech-to-text)

model
string

Filter by on-chain model ID (e.g. zai-org/GLM-5.1-FP8); exact model_id match. Empty = not filtered.

canonical_id
string

[beta] Filter by canonical model ID (e.g. glm-5.1); lists every endpoint serving that canonical. Composes (AND) with model. Empty = not filtered.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "object": "list"
}

Service Types

Get available service types

Get service types that have at least one TEE-acknowledged provider (health is not a filter — a service type stays listed even when its providers are temporarily unhealthy)

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "object": "list"
}