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.

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.5 Only 50% 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.5 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

  • 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/historyrequest_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/providerspricing.video and pricing.variants; GET /v1/service-typesvideo-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"}), unitvideo_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.

  • All endpoints — a client-supplied X-Request-ID is now validated before it is echoed and recorded — the header is still propagated as your correlation id, but only when it is at most 64 characters, built from letters, digits and - _ . :, and does not begin with async-. A value failing any of those is replaced by a server-generated id, exactly as an over-long one already was; you always get the id actually in effect back in the X-Request-ID response header, so read it there rather than assuming your value was kept. UUIDs, ULIDs, hex trace ids and W3C traceparent values are unaffected. The async- prefix is reserved because the router derives its own asynchronous-billing identifiers in that namespace.

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 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 + creditpending_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.

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": {
    }
}

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 — 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). 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 (/v1/messages) sealed requests are not supported yet, so anthropic-chat is not an accepted value. 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 (to pin), 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). 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. This endpoint is in limited availability and may be unavailable (404) in some environments. [beta]

Authorizations:
ApiKeyAuth
Request Body schema: application/json
required

Preview request (stripped inference body + service_type/model)

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

Responses

Request samples

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

Response samples

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

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"
}