Headers reference

Request headers

HeaderRequiredNotes
Authorization: Bearer sk_…yesVirtual key. sk_live_ or sk_test_.
Content-Type: application/jsonyesAll endpoints are JSON.
x-odnoga-end-userstrongly recommendedStable 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-keyoptionalDedupe retries inside a short window.
x-airouter-regionoptionalProcessing region (global / eu / us). Overrides the workspace default for this call.
X-Odnoga-Appstrongly recommendedWhich system is calling, e.g. salesdots. Slugified, ≤64 chars.
X-Odnoga-Functionstrongly recommendedWhich function/job inside it, e.g. lead-enrich. Slugified, ≤64 chars.
X-Odnoga-Envoptionalproduction, 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)

HeaderMeaning
x-airouter-request-idUUID of this request in odnoga. Log it — it is what Requests and requests.search look up.
x-airouter-vendorVendor that actually served the call (may differ from requested via fallback).
x-airouter-modelModel slug that actually served the call.
x-airouter-response-formatopenai or native — the shape of the body you just received (a workspace setting; see below). Streams are always openai.
x-airouter-prompt-tokensPrompt tokens billed for this request. 0 on cache hits.
x-airouter-completion-tokensCompletion tokens billed. 0 on cache hits.
x-airouter-total-tokensTotal tokens billed. 0 on cache hits.
x-airouter-cost-usdBilled cost for this single request, in USD. 0 on cache hits.
x-airouter-service-tierThe tier this request was billed at (standard, priority, …) — never the one it merely asked for.
x-airouter-tier-downgraded1 when the vendor could not honour the requested tier and the call was billed as standard. Absent otherwise.
x-airouter-regionProcessing region the request was priced under.
x-airouter-region-honored1 only when the call actually left through a regional vendor endpoint.
x-airouter-latency-msEnd-to-end latency measured at the gateway.
x-airouter-cachehit / miss / off.
x-airouter-fallback1 if a fallback model was used, 0 otherwise.
x-ratelimit-remaining-usdRemaining USD in the active budget window.
x-ratelimit-resetUnix seconds when the active window rolls over.
x-ratelimit-limit-concurrency / x-ratelimit-remaining-concurrencyWorkspace 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-scopePresent 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.
WarningAccompanies 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.

HeaderWhenMeaning
Deprecation: @<unix-seconds>the model has an announced retirement dateRFC 9745. The model still serves; move off it before the date.
Sunset: <HTTP-date>sameRFC 8594. The date the slug stops serving.
x-odnoga-model-sunsetsameThe same date as ISO-8601.
x-odnoga-model-replacementsame, when a replacement is namedThe slug to move to.
x-odnoga-model-fallback: <retired>-><served>the request named a retired model and the organisation enabled automatic fallbackThe same-vendor replacement served this call. x-airouter-model is the model that actually served.
x-odnoga-model-retiredsameThe 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-id
  • x-airouter-vendor, x-airouter-model
  • x-airouter-cost-usd, x-airouter-total-tokens, x-airouter-latency-ms
  • x-airouter-cache, x-airouter-fallback
  • x-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:

ToolScopeWhat it returns
functions.listusage:readEvery external function, with requests, errors, success rate, p50/p95 latency, tokens, cost, models used, last seen. Filter by tag, search, include_archived.
functions.getusage:readOne function in detail: error breakdown, model split, latency, recent request sample. Keyed by app + function.
functions.updateusage:writeRename, describe, tag or archive a function. Only the fields you pass change.
requests.searchusage:readIndividual 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.

ToolScopeEffect
account.limitsbudgets:readReads credit, cap, period, warn threshold, hard_stop, blocked.
account.budget.request_changebudgets:writeFiles a proposal (cap_usd / period / warn_at_pct / hard_stop + reason). Nothing changes.
account.budget.change_statusbudgets:readPolls 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.