Back to posts
AINews

Your rate card is not your rate. Query what you paid per million tokens.

If you route through OpenRouter, your realized rate is queryable: two endpoints, an administrative key that cannot make inference calls, and four requests that give you effective dollars per million tokens per model, where the gap between that and the rate card comes from, and which models are leaking money on cache misses. Full request schema, the arithmetic, and the five ways the API will quietly hand you a wrong answer.

The rate card tells you what a model costs. It does not tell you what you paid.

Between the two sit a blend of models you did not plan in those proportions, a cache hit rate that is nothing like the one you assumed, per-request add-ons for web search and file parsing, BYOK fees on top of the provider's own bill, and a data-logging discount that arrives as a negative number. Add them up and the effective figure for a month of real traffic is routinely nowhere near the line you budgeted against.

If your traffic goes through OpenRouter, you do not have to reconstruct that from invoices. There is a beta Analytics API that will compute it, and the interesting part is not the endpoint. It is the credential.

The credential is the interesting part

The Analytics API refuses regular inference keys. It requires a management key, created at openrouter.ai/settings/management-keys, on a separate page from your normal API keys. Send a regular key and you get a 403.

The reason that matters: management keys are administrative only. OpenRouter's own documentation is explicit that they "cannot be used to make API calls to OpenRouter's completion endpoints." A process holding one can read your entire spend history and cannot itself add a dollar to it, which is the inverse of every inference key you have issued.

Be precise about how far that goes, because the distinction is easy to oversell. The same key class manages your API keys through the /api/v1/keys endpoints — listing, creating, updating and deleting them. So a management key is not harmless and it is not read-only in general. It cannot make an inference call, but it can mint a key that can. Treat it as an admin credential: one per job, out of shared logs, rotated like any other.

What the property does buy you is a real change in blast radius for this particular workload. A leaked inference key spends money immediately and quietly. A management key sitting in a cost-reporting cron job, a CI step that posts last week's blended rate into Slack, or the environment of a coding agent you have asked to audit your own bill does not spend anything by running, and the worst outcome of a loop that misbehaves is a 429. The spend path requires a deliberate extra step that shows up in your key list.

Two endpoints, both requiring that key:

GET  https://openrouter.ai/api/v1/analytics/meta     # available metrics, dimensions, operators, granularities
POST https://openrouter.ai/api/v1/analytics/query    # run a query, get rows back

Ask the API what it has before you guess

Start at meta, every time, on any account you have not queried recently:

export OR_MGMT_KEY="<your management key>"

curl -sS https://openrouter.ai/api/v1/analytics/meta \
  -H "Authorization: Bearer $OR_MGMT_KEY"

OpenRouter publishes metric and dimension names in several places and marks the meta response as the canonical one, telling readers to prefer it over the inline examples in the docs whenever the two disagree. Take that at face value. The names below are correct as of today; meta is correct on the day you run it.

The metrics that carry money are total_usage, credits_usage, byok_usage, byok_fees, usage_upstream, usage_cache, usage_data, usage_web and usage_file, all in USD. Token counts come from tokens_total, tokens_prompt and tokens_completion. The two that turn a bill into a diagnosis are cache_hit_rate, a ratio between 0 and 1, and request_count. Dimensions worth grouping on: model, provider, api_key_id, app, user, workspace.

The one query that gives you your real rate

No time bucketing, grouped by model, sorted by spend:

curl -sS -X POST https://openrouter.ai/api/v1/analytics/query \
  -H "Authorization: Bearer $OR_MGMT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "metrics": ["total_usage", "tokens_total", "request_count", "cache_hit_rate"],
    "dimensions": ["model"],
    "time_range": {
      "start": "2026-07-20T00:00:00Z",
      "end": "2026-08-20T00:00:00Z"
    },
    "order_by": { "field": "total_usage", "direction": "desc" },
    "limit": 25
  }'

Rows come back under data.data, each one a flat object keyed by the metrics and dimensions you asked for. Then the arithmetic that the rate card cannot do for you:

effective $/Mtok  =  total_usage / tokens_total * 1,000,000

Run it per row and the ranking usually surprises. The model at the top of your spend list is frequently not the model with the worst effective rate, and the model with the worst effective rate is frequently one nobody has looked at since it was wired in for a single feature.

Two honest caveats on that number. It blends prompt and completion tokens, which are priced differently, so it moves when your prompt-to-completion mix moves even if no rate changed anywhere. And total_usage includes BYOK inference cost, so a model you run on your own provider key is not free in this column. Neither caveat weakens it. It means the figure is only meaningful against your own previous figure, which is exactly the comparison worth making, and it is not comparable to a number somebody quotes you from their stack.

Where the gap comes from

The rate card assumed one price. Your effective rate came out higher. Decompose it:

curl -sS -X POST https://openrouter.ai/api/v1/analytics/query \
  -H "Authorization: Bearer $OR_MGMT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "metrics": ["credits_usage", "byok_usage", "byok_fees", "usage_upstream",
                "usage_cache", "usage_data", "usage_web", "usage_file"],
    "granularity": "day",
    "time_range": {
      "start": "2026-07-20T00:00:00Z",
      "end": "2026-08-20T00:00:00Z"
    }
  }'

usage_upstream is the raw inference the provider charged. usage_cache is what caching cost or saved you. usage_web and usage_file are the web-search and file-parsing add-ons, which are easy to forget because nobody chose them per request; a tool definition did. usage_data normally comes back negative, because it is the data-logging discount rather than a charge, and a summation routine that assumes every usage column is positive will report a number that is wrong in the direction that makes you relax.

Rows include a date__day field when you set granularity, so the same query answers the other question people bring to a bill: not what it was, but when it changed. Bucket by day or week, put the deploy dates next to it, and a spike stops being a mystery.

The models leaking money on cache misses

cache_hit_rate grouped by model, next to spend, is a two-column shortlist of where prompt caching would repay the work:

curl -sS -X POST https://openrouter.ai/api/v1/analytics/query \
  -H "Authorization: Bearer $OR_MGMT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "metrics": ["total_usage", "cache_hit_rate", "tokens_prompt", "request_count"],
    "dimensions": ["model"],
    "order_by": { "field": "total_usage", "direction": "desc" },
    "limit": 15
  }'

High spend with a hit rate near zero and a large tokens_prompt is the classic shape: a long, stable system prompt being re-billed at full rate on every request. That is a fixable defect rather than a cost of doing business, and caching fails silently on the way to fixing it — the ways a cache miss hides from you are worth reading before you conclude the caching is working.

When a model looks expensive but you cannot see why, add api_key_id as a second dimension and filter to that model. It resolves to the key's human-readable name in the results, which usually identifies the workload in one line.

{
  "metrics": ["total_usage", "tokens_total"],
  "dimensions": ["api_key_id"],
  "filters": [
    { "field": "model", "operator": "eq", "value": "anthropic/claude-sonnet-4" }
  ],
  "order_by": { "field": "total_usage", "direction": "desc" },
  "limit": 20
}

Filters take the underlying ID even where results show a friendly label: model wants the permaslug, user wants the Clerk user ID, workspace wants the UUID, app wants the numeric ID.

Five ways this hands you a confidently wrong number

The API is well behaved. The failure modes are all in the reading.

The default time range is the last 7 days. Omit time_range and you get a week, silently, no matter what question you thought you were asking. Any query whose answer you intend to quote as a monthly figure must state its range explicitly.

Count metrics come back as strings. request_count and the tokens_* family arrive as "1523". Cost and rate metrics arrive as real numbers. Divide a string by a number in a spreadsheet or a hastily written script and the result will look plausible. Parse the counts before the arithmetic.

metadata.truncated is the flag that invalidates totals. limit defaults to 1000 and accepts 1 to 10,000. When results hit the cap, data.metadata.truncated comes back true and the rows you have are a partial set. Summing them produces an under-count that reads like a real total. Check the flag before any figure that claims to be a sum or a ranking.

Two dimensions, twenty filters, and no more. A third dimension returns a 400. So does an invalid metric name, which is one more argument for starting at meta.

Some metrics only reach back 31 days. Latency and throughput metrics, generation_id and session_id, and the openrouter_usage, byok_fees, usage_file and usage_web_fetch columns are limited to a 31-day window. credits_usage, usage_upstream, usage_cache, usage_data and usage_web go back up to 365 days at daily granularity. The server decides based on what you asked for, so a quarterly view and a latency investigation are two different queries rather than one wide one. If a broad query returns 408, it timed out: narrow the range or drop the per-generation dimensions. Sustained polling hits 429 at 64 requests per minute, which is far above anything a weekly report needs.

What the number is for

An effective rate you measured is worth more than a rate card you read, for a reason that has nothing to do with OpenRouter: it is the only figure that survives a vendor changing the terms underneath it. Published prices now carry conditions — hours of the day, expiry dates, request fields that act as multipliers — and every one of those lands in your realized rate without changing a model ID you could grep for.

So the useful cadence is dull and short. Once a month, run the by-model query, compute effective dollars per million, and keep the number. Compare it to last month. When it moves and no rate card moved, the answer is in the decomposition query, and it is usually your traffic mix, a cache that stopped hitting, or an add-on nobody priced. Wire the whole thing to a management key and it costs nothing to run, forever, because the key cannot buy anything.

One thing to hold lightly: the Analytics API is in beta and the schema can move, which is the argument for reading meta rather than hardcoding the field list. Separately, Stripe announced on 19 August that it has agreed to acquire OpenRouter, a transaction the announcement describes as subject to customary closing conditions and expected to close in the coming weeks. OpenRouter's own statement says that if you build on it today, nothing about your integration changes. That is a commitment worth checking against rather than assuming, which is one more reason to have your own measured rate on file. Anyone whose fallback chain crosses vendors through a single provider should know what that dependency is doing in either case.

Get the next post when it ships

One email on Sunday with the new post and a short list of what shipped that week — new guides, tool updates, and a couple of links worth reading.