Track what each request costs
Every response tells you what it cost. There is no separate metering call and no waiting for a statement to close: the Router attaches the exact charge for a request to that request’s own reply, alongside the identifier you need to look it up later. This page shows how to read that block and get it into your logs in a shape you can query.
Prerequisites
Section titled “Prerequisites”- An account with a funded balance. See Get 0G and fund your account.
- An
sk-API key from the 0G Private Computer Console, exported asZG_API_KEY.
The examples use glm-5, matching the live responses shown further down.
The cost model
Section titled “The cost model”Cost for a chat request is:
input_tokens × prompt_price + output_tokens × completion_pricePrices are declared by the provider that served the request, so the same model has a range rather than a single number, and the provider can differ between calls. Current per-model prices are listed in the Console; this page deliberately does not repeat them.
You do not have to compute any of that yourself. Every response carries an x_0g_trace block with the charge already resolved:
| Field | Meaning |
|---|---|
request_id |
Identifier for this call. The join key between your logs and support |
provider |
On-chain address of the provider that served it |
billing.input_cost |
Charge attributed to input tokens — the whole context you sent, not just the last message |
billing.output_cost |
Charge attributed to generated tokens |
billing.total_cost |
What this request debited from your balance |
Costs are integer strings, not decimals
Section titled “Costs are integer strings, not decimals”Every cost is a string holding an integer in the smallest unit of 0G — 1018 of them make one 0G, the same convention as wei on other EVM chains. A response reporting "total_cost": "12458460000000000" charged 0.01245846 0G.
Two consequences for your code:
- Do not parse them as floats. These values exceed
Number.MAX_SAFE_INTEGERin JavaScript and lose precision silently. UseBigIntin TypeScript andintin Python, converting to a decimal only for display. - Do not divide too early. Sum the integers, then divide once by 1018 at the end. Dividing per request and adding floats accumulates rounding error across a day of traffic.
const NEURON_PER_0G = 10n ** 18n;const total = BigInt(trace.billing.total_cost); // exactconst display = Number(total) / Number(NEURON_PER_0G); // display onlyLogging provider next to total_cost is what makes a price ceiling possible later: you cannot pick a sensible ceiling without knowing which providers your spend actually goes to.
Log the billing block
Section titled “Log the billing block”Write these as structured fields, not as a printed line. Cost per request only answers questions if you can group it by model, provider, and key.
curl https://router-api.0g.ai/v1/chat/completions \ --max-time 120 \ --fail-with-body \ --silent \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $ZG_API_KEY" \ -d '{ "model": "glm-5", "messages": [{"role": "user", "content": "Summarize this in one sentence."}] }' \ | jq -c '{ event: "inference", request_id: .x_0g_trace.request_id, provider: .x_0g_trace.provider, model: .model, input_cost: .x_0g_trace.billing.input_cost, output_cost: .x_0g_trace.billing.output_cost, total_cost: .x_0g_trace.billing.total_cost }'import OpenAI from "openai";
const apiKey = process.env.ZG_API_KEY;if (!apiKey) throw new Error("ZG_API_KEY is not set");
const client = new OpenAI({ apiKey, baseURL: process.env.ZG_BASE_URL ?? "https://router-api.0g.ai/v1", timeout: 120_000, maxRetries: 0, // classify by error.code instead of retrying blindly});
type ZgTrace = { request_id: string; provider: string; billing: { input_cost: string; output_cost: string; total_cost: string };};
export async function askAndLog(prompt: string): Promise<string> { try { const res = await client.chat.completions.create({ model: "glm-5", messages: [{ role: "user", content: prompt }], });
const trace = (res as unknown as { x_0g_trace?: ZgTrace }).x_0g_trace;
// Structured fields, so cost can be grouped by model and provider later. console.log( JSON.stringify({ event: "inference", request_id: trace?.request_id, provider: trace?.provider, model: res.model, input_cost: trace?.billing.input_cost, output_cost: trace?.billing.output_cost, total_cost: trace?.billing.total_cost, }), );
return res.choices[0].message.content ?? ""; } catch (err) { if (err instanceof OpenAI.APIError) { const body = err.error as { code?: string } | undefined; // request_id is present on failures too, and it is the only handle support has. console.error( JSON.stringify({ event: "inference_error", status: err.status, code: body?.code, request_id: (err.error as { request_id?: string } | undefined)?.request_id, }), ); } throw err; }}import jsonimport loggingimport os
from openai import APIStatusError, OpenAI
logger = logging.getLogger(__name__)
api_key = os.environ["ZG_API_KEY"]
client = OpenAI( api_key=api_key, base_url=os.environ.get("ZG_BASE_URL", "https://router-api.0g.ai/v1"), timeout=120.0, max_retries=0, # classify by error.code instead of retrying blindly)
def ask_and_log(prompt: str) -> str: try: res = client.chat.completions.create( model="glm-5", messages=[{"role": "user", "content": prompt}], ) except APIStatusError as err: body = err.response.json() # request_id is present on failures too, and it is the only handle support has. logger.error( "inference_error", extra={ "status": err.status_code, "code": (body.get("error") or {}).get("code"), "request_id": body.get("request_id"), }, ) raise
trace = (res.model_extra or {}).get("x_0g_trace", {}) billing = trace.get("billing", {})
# Structured fields, so cost can be grouped by model and provider later. logger.info( "inference", extra={ "request_id": trace.get("request_id"), "provider": trace.get("provider"), "model": res.model, "input_cost": billing.get("input_cost"), "output_cost": billing.get("output_cost"), "total_cost": billing.get("total_cost"), }, )
return res.choices[0].message.content or ""Expected output
Section titled “Expected output”The x_0g_trace block sits alongside the standard OpenAI-compatible fields:
{ "id": "chatcmpl-1c487c99-ff82-4b4d-9898-0bd11ba5acd9", "object": "chat.completion", "created": 1784535661, "model": "glm-5", "system_fingerprint": null, "choices": [ { "index": 0, "message": { "role": "assistant", "content": "Hello there, how are you?" }, "finish_reason": "stop", "logprobs": null } ], "usage": { "prompt_tokens": 11, "prompt_tokens_details": { "cached_tokens": 0 }, "completion_tokens": 142, "completion_tokens_details": { "reasoning_tokens": 133 }, "total_tokens": 153 }, "x_0g_trace": { "request_id": "65cc8e6e-e4d6-49b9-b6b1-7e3d2596436c", "provider": "0xB01EBd79c3fd63ff52fD47C3935119601EEe2FdB", "billing": { "input_cost": "32890000000000", "output_cost": "1914160000000000", "total_cost": "1947050000000000" } }}Live response, 2026-07-20. Note completion_tokens_details.reasoning_tokens: reasoning models bill for thinking tokens you never see in content, which is usually the reason a short answer costs more than expected.
A client that rejects unknown JSON keys will choke on x_0g_trace. Tolerate unrecognized fields.
Keep the request ID
Section titled “Keep the request ID”request_id is cheap to store and expensive to not have. The Router keeps billing metadata but never keeps your prompts or completions, so once a request is done, the ID is the only thing that ties a charge back to the call your system made.
Two things it buys you:
- Reconciliation. Your own per-request log, summed over a window, should match what the account usage endpoint reports for that window. Divergence is only diagnosable if both sides share an identifier. See Monitor balance and usage.
- Verification. Checking that a response really came from the enclave that claimed it is a per-response operation, and it starts from the identifiers on that response. See Verify a response came from the enclave.
Log it on failures as well as successes. Error bodies carry request_id at the top level, and a failed request is exactly the case you will be asked about.
Streaming
Section titled “Streaming”Streaming does give you the cost — in the last chunk. Intermediate chunks carry "usage": null and no trace, but the final chunk before data: [DONE] has an empty choices array, the complete usage, and the full x_0g_trace:
{ "id": "chatcmpl-2a23c437-775d-4f28-b25c-824e8ba14e0b", "object": "chat.completion.chunk", "model": "glm-5", "choices": [], "usage": { "prompt_tokens": 10, "completion_tokens": 922, "completion_tokens_details": { "reasoning_tokens": 919 }, "total_tokens": 932 }, "x_0g_trace": { "request_id": "8312e180-3ecb-43d3-8fd9-06fe5777a59a", "provider": "0xB01EBd79c3fd63ff52fD47C3935119601EEe2FdB", "billing": { "input_cost": "29900000000000", "output_cost": "12428560000000000", "total_cost": "12458460000000000" } }}Live response, 2026-07-20. Two things follow: a loop that stops at the first chunk with finish_reason: "stop" will miss the cost entirely, so read to [DONE]; and a client that assumes every chunk has choices[0] will throw on this one. See Stream responses.
Common errors
Section titled “Common errors”| Status | code |
Cause | Fix |
|---|---|---|---|
| 401 | invalid_api_key |
Key is wrong, or an mk- key was used for inference |
Send an sk- key on /v1/chat/completions |
| 402 | insufficient_balance |
Balance exhausted, so the request was never served and has no billing block | Fund the account; do not retry |
| 429 | rate_limit_exceeded |
Too many requests | Honour Retry-After |
| — | — | x_0g_trace is missing from a response |
The call was streamed, or a client library dropped unknown fields |
Full list with retry guidance: Error codes.
Next steps
Section titled “Next steps”- Monitor balance and usage — the account-level view your per-request logs reconcile against
- Cap what a single request may cost — turn observed cost into a ceiling the router enforces