Headers reference
Request headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer sk_… | yes | Your odnoga API key. sk_live_ or sk_test_ — both behave the same and both bill; the prefix only labels the environment. |
Content-Type: application/json | yes | All endpoints are JSON. |
x-odnoga-end-user | strongly recommended | Stable per-user id, ≤256 chars. Powers metering, quotas, A/B stickiness. The pre-rebrand x-airouter-end-user is still honoured, as is the OpenAI-compatible user body field. |
Idempotency-Key (also x-odnoga-idempotency-key, x-airouter-idempotency-key) | optional | Non-streamed requests only: a retry with the same key and body replays the stored response instead of calling the vendor again (x-airouter-idempotent-replay: 1, not billed twice). Same key while the first call runs → 409 idempotency_in_progress; same key with a different body → 422 idempotency_key_reused. Ignored when "stream": true. See Retries and timeouts. |
x-airouter-timeout-ms | optional | Your deadline for the model, in milliseconds. odnoga waits at most 300 s by default; a smaller value (clamped to 1 000–300 000) makes a slow model fail over to the next one in your chain sooner. It can only shorten the deadline, never extend it. |
x-airouter-region | optional | Processing region (global / eu / us). Overrides the workspace default for this call. |
X-Odnoga-App | strongly recommended | Which system is calling, e.g. salesdots. Slugified, ≤64 chars. |
X-Odnoga-Function | strongly recommended | Which function/job inside it, e.g. lead-enrich. Slugified, ≤64 chars. |
X-Odnoga-Env | optional | production, staging, … |
x-odnoga-stream | optional | final on a streamed /v1/chat/completions call: the connection opens at once and is kept alive with : keepalive comments, but you receive one frame — the complete chat.completion with usage and _odnoga cost — then [DONE]. For callers that stream only to escape a ~150 s platform limit. See Streaming → Final-only mode. |
Identify your integrations
Label every call with the app and function that made it, and the workspace Functions view turns into a per-function line item: request volume, success rate, p50/p95 latency, tokens, cost, models used, and the exact error codes when something breaks — so you see which function in your system is failing, not just "a request failed".
curl https://api.odnoga.com/v1/chat/completions \
-H "Authorization: Bearer sk_live_…" \
-H "X-Odnoga-App: salesdots" \
-H "X-Odnoga-Function: lead-enrich" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
If your client cannot set headers (some SDKs and no-code tools), send the same values in the request body instead — they are stripped before the call reaches the vendor:
{ "model": "gpt-4o-mini", "messages": [], "metadata": { "app": "salesdots", "function": "lead-enrich" } }
Both headers are read on every endpoint — /v1/chat/completions, /v1/embeddings, /v1/messages, the native
endpoint, and the image and audio paths. (Before 2026-09-16 only the OpenAI-compatible chat path read them, so traffic
through the others was unattributable however carefully the client was configured.)
Unlabelled traffic is still visible — it is grouped by API key label, shown as unattributed. Inside odnoga you can
give any function a friendly display name, an owner/description and tags; the slug stays the key, so renaming never
breaks history.
Check how much of your spend has an owner
The Functions page leads with a coverage bar, and the MCP tool functions.list returns the same figures as
attribution:
{ "requests": 60059, "attributed_pct": 79.9,
"cost_usd": 71.85, "unattributed_cost_usd": 32.46, "unattributed_cost_pct": 45.2,
"top_unattributed": [ { "model_slug": "gpt-5.6-terra", "endpoint": "/v1/chat/completions",
"requests": 6383, "cost_usd": 23.11 } ] }
One trap worth knowing: unlabelled traffic still shows up in the Functions list, grouped under the API key's label
with the app shown as unattributed. That row reads like a function someone named, but nothing set it — which is why a
coverage figure saying "$32 has no caller" can sit directly above a list showing a "function" that spent $32. They are
the same fact; grouped_under_key_label names those rows so the two do not look like a contradiction.
Read the cost share, not the request share. They diverge, and only one of them is the bill: a thousand unlabelled
embedding calls are noise, ten unlabelled frontier calls are most of the invoice. top_unattributed gives the fix an
address — the models and endpoints the missing labels are arriving on.
Response headers (success)
| Header | Meaning |
|---|---|
x-airouter-request-id | UUID of this request in odnoga. Log it — it is what Requests and requests.search look up. |
x-airouter-vendor | Vendor that actually served the call (may differ from requested via fallback). |
x-airouter-model | Model slug that actually served the call. |
x-airouter-response-format | openai or native — the shape of the body you just received (a workspace setting; see below). Streams are always openai. |
x-airouter-prompt-tokens | Prompt tokens billed for this request. 0 on cache hits. |
x-airouter-completion-tokens | Completion tokens billed. 0 on cache hits. |
x-airouter-total-tokens | Total tokens billed. 0 on cache hits. |
x-airouter-cost-usd | What this single request is charged, in USD — the model's price plus odnoga's fees (on your own vendor key, odnoga's fees only). The same amount the request row and your balance show. 0 on cache hits. |
x-airouter-cost-basis | billed when x-airouter-cost-usd is the charged amount (normal). list in the rare case the charge could not be confirmed in time and the header shows the model's price only — the request row in Requests always holds the charged amount. |
x-airouter-service-tier | The tier this request was billed at (standard, priority, …) — never the one it merely asked for. |
x-airouter-tier-downgraded | 1 when the vendor could not honour the requested tier and the call was billed as standard. Absent otherwise. |
x-airouter-region | Processing region the request was priced under. |
x-airouter-region-honored | 1 only when the call actually left through a regional vendor endpoint. |
x-airouter-latency-ms | End-to-end latency measured at the gateway. |
x-airouter-cache | hit / miss / off. |
x-airouter-fallback | 1 if a fallback model was used, 0 otherwise. |
x-odnoga-route / x-odnoga-route-version / x-odnoga-route-chain | The route that served the call, its version, and the models it was going to try, in order. Only when a route served it. |
x-odnoga-route-connected | <app>/<function> when the route served the call because that function is connected to it — whatever model the code sent. |
x-ratelimit-remaining-usd | Remaining USD: the lower of your prepaid credit and the active budget window. Present when either applies. |
x-ratelimit-reset | Unix seconds when the active budget window rolls over. Only present when your workspace or organisation has a budget window; it is not a retry hint. |
x-ratelimit-limit-concurrency / x-ratelimit-remaining-concurrency | Workspace concurrency ceiling and how much of it is left right now. Size your worker pool on these instead of discovering the limit through 429s. |
x-ratelimit-limit-concurrency-tenant / x-ratelimit-remaining-concurrency-tenant | The same for the organisation-wide session pool shared by all your workspaces. |
x-ratelimit-limit-requests / x-ratelimit-remaining-requests | Requests-per-minute limit and what is left this minute. Only when an RPM limit is set for the workspace. |
x-ratelimit-limit-tokens / x-ratelimit-remaining-tokens | Tokens-per-minute limit and what is left this minute. Only when a TPM limit is set for the workspace. |
x-airouter-skipped-models | Models in your chain that were passed over before one served, as comma-separated model=reason (e.g. gpt-4o=cooldown:openai, o3=missing:vision, old-model=retired). Also sent on errors. |
x-airouter-key-source | pooled when odnoga's credential served the call, own when your own vendor key did. |
x-airouter-idempotent-replay | 1 when this response is a stored replay for a repeated idempotency key. |
x-odnoga-pool-exceeded / x-odnoga-pool-exceeded-scope | Present only when the call was served past your concurrent-session ceiling. 1 plus tenant or workspace. Nothing failed and nothing extra was charged — see Concurrent sessions. |
Warning | Accompanies the header above with a readable sentence naming the ceiling that was exceeded. |
x-odnoga-stream | On streams: final when final-only mode was honoured, tokens for a normal token stream. |
x-airouter-stripped | Parameters odnoga removed because this model does not accept them (comma-separated), e.g. temperature on a reasoning model. The call still ran. |
x-airouter-clamped | Parameters moved into the model's accepted range, as name=from->to. |
x-odnoga-dialect | What odnoga changed inside your JSON schemas so the vendor that answered accepts them, as surface:keyword=action(count) — e.g. tool_schema:$ref=inline(2) when that vendor does not take $ref in tool parameters, so the definitions were written out in place. surface is tool_schema (tool parameters) or response_schema (a strict json_schema answer — a non-strict one is always sent as written); action is drop, inline, or rewrite (oneOf sent as anyOf, allOf merged into its schema — for a vendor that refuses them in strict mode). Which keywords each vendor accepts is measured weekly, never guessed; a keyword the vendor accepts is always sent as you wrote it. Absent when nothing changed. |
x-airouter-set | Parameters odnoga set because the model requires them, as name=value — e.g. reasoning_effort=none on a model that accepts tools only at that effort. |
x-airouter-renamed | Parameters renamed to the model's own name for them, e.g. max_tokens->max_completion_tokens. |
Model lifecycle headers
Vendors retire models. odnoga announces a retirement ahead of time and never swaps a model silently.
| Header | When | Meaning |
|---|---|---|
Deprecation: @<unix-seconds> | the model has an announced retirement date | RFC 9745. The model still serves; move off it before the date. |
Sunset: <HTTP-date> | same | RFC 8594. The date the slug stops serving. |
x-odnoga-model-sunset | same | The same date as ISO-8601. |
x-odnoga-model-replacement | same, when a replacement is named | The slug to move to. |
x-odnoga-model-fallback: <retired>-><served> | the request named a retired model and the organisation enabled automatic fallback | The same-vendor replacement served this call. x-airouter-model is the model that actually served. |
x-odnoga-model-retired | same | The retired slug the request asked for. |
The _odnoga body echo carries the same facts as model_sunset_at, model_replacement and
model_fallback: { retired, served }.
Automatic fallback is off by default. With it off — which is where every organisation starts — a retired model
returns 400 model_deprecated and nothing is substituted (see the errors reference). Turn it on in
Organisation → Defaults → Retired models, or over MCP:
settings.model_lifecycle.get → the switch, plus the retired and sunsetting models YOU called in the last 30 days,
which of your functions called them, and whether each replacement can actually serve
settings.model_lifecycle.set → { "retired_model_fallback": true } (organisation-wide; organisation admins only)
It is a stopgap, not a migration: the response still names the retired slug in x-odnoga-model-retired so the caller
can be fixed. And it cannot save a model whose replacement is itself retired, or that has none named — check
replacement_live before relying on it. Organisation admins are emailed and shown an in-app notice when a retirement is
announced for a model they call, a week before the date, on the day, and once a day while an integration still
sends the old name.
Token headers are the billing record
Read tokens and cost from the headers, never by parsing the response body.
The body's shape is a workspace setting now (x-airouter-response-format:
openai or native), and in native mode every vendor spells usage differently
— Gemini usageMetadata.promptTokenCount, Claude usage.input_tokens — so a
body parser breaks the day the setting flips. The x-airouter-*-tokens headers
carry the same numbers odnoga bills from, whatever the body looks like. The
body's _odnoga block (attached in both modes) additionally carries
request_id, cost_usd (the charged amount, more decimal places than the
header) with cost_basis, the catalog
model slug, vendor_model provenance, tier, region and any warnings — it is
the assertable per-call record for tests.
End-user (EUT) responses are redacted
Responses served to an end-user token (eut_) never include your cost basis or
vendor identity: x-airouter-cost-usd, x-airouter-cost-basis, x-airouter-vendor and
x-airouter-region-endpoint are removed, and cost_usd / cost_basis / vendor /
vendor_model are stripped from the _odnoga block, on every response path.
End users keep request_id, the catalog model slug, tokens, region and tier
flags.
Response headers (error)
Errors still carry x-airouter-request-id so you can look the row up in Requests. Budget errors also carry x-ratelimit-remaining-usd and, when a budget window exists, x-ratelimit-reset. Rate-limit and capacity answers (429, 503 capacity_unavailable, 409 idempotency_in_progress) carry Retry-After (seconds) — honour it; the official SDKs already do. When every model in the chain was passed over, x-airouter-skipped-models says why and x-airouter-cooldown-until (unix seconds) says when the first one is expected back. A 503 vendor_unavailable or 503 billing_unavailable carries x-odnoga-ticket.
All of these headers are exposed to browsers (Access-Control-Expose-Headers), so a front-end can read them too.
What to log on the consumer side
At minimum, in every request log line:
x-airouter-request-idx-airouter-vendor,x-airouter-modelx-airouter-cost-usd,x-airouter-total-tokens,x-airouter-latency-msx-airouter-cache,x-airouter-fallbackx-airouter-response-format(so a shape change is visible in your own logs)
Then a single request_id cross-references your logs with the odnoga request log.
Reading the same data over MCP
Everything the Functions and Requests pages show is available to MCP clients (Claude, Cursor, Lovable) on the same identity headers:
| Tool | Scope | What it returns |
|---|---|---|
functions.list | usage:read | Every external function, with requests, errors, success rate, p50/p95 latency, tokens, cost, models used, last seen. Filter by tag, search, include_archived. |
functions.get | usage:read | One function in detail: error breakdown, model split, latency, recent request sample. Keyed by app + function. |
functions.update | usage:write | Rename, describe, tag or archive a function. Only the fields you pass change. |
requests.search | usage:read | Individual requests filtered by app, function, model, status (ok/error) and free text; paginated with limit/offset, each row carries total_count. |
usage:write is a mutating scope, so clients must be re-consented (remove and re-add
the odnoga MCP server) before functions.update becomes available.
Spend caps over MCP
There is no MCP tool that writes a workspace budget. An assistant that is blocked by a spend cap must not be able to lift its own cap.
| Tool | Scope | Effect |
|---|---|---|
account.limits | budgets:read | Reads credit, cap, period, warn threshold, hard_stop, blocked. |
account.budget.request_change | budgets:write | Files a proposal (cap_usd / period / warn_at_pct / hard_stop + reason). Nothing changes. |
account.budget.change_status | budgets:read | Polls for approved / rejected / expired. |
Proposals appear in Workspace → Budget → Pending approvals and only take effect when a workspace admin approves them. They expire after 24 hours.