Headers reference
Request headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer sk_… | yes | Virtual key. sk_live_ or sk_test_. |
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. |
x-airouter-idempotency-key | optional | Dedupe retries inside a short window. |
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, … |
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 | Billed cost for this single request, in USD. 0 on cache hits. |
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-ratelimit-remaining-usd | Remaining USD in the active budget window. |
x-ratelimit-reset | Unix seconds when the active window rolls over. |
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-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. |
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 (more decimal places than the header), 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-vendor and
x-airouter-region-endpoint are removed, and cost_usd / 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. Rate-limit / budget errors also carry x-ratelimit-remaining-usd and x-ratelimit-reset.
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.