Usage & Cost

CogniGate meters every request it serves and stores the result durably. It does not produce invoices. The distinction is deliberate: what a deployment charges, to whom, and on what terms are decisions CogniGate has no business making, so it publishes numbers and stops there.

What is metered

One row per served request, in the analytics database:

FieldWhat it is
request_idThe gateway's id — unique, and the idempotency key
client_request_idYour own correlation id, if you sent one
tenant_id, key_prefixWho
provider, modelWhat actually served it
requested_model, fallback_depthWhat was asked for, and how far the cascade went
prompt_tokens, completion_tokens, total_tokensThe counts
cost_usdWhat it cost, priced from the catalog (below)
charge_usdWhat the tenant owes for it (below)
billing_modeHow the charge was arrived at, as of that request
cached, streamedHow it was served
status_code, duration_msThe outcome
recorded_atWhen the gateway served it

There is no prompt and no completion text, and there is no column for either. That is a property of the schema rather than a policy applied to it — see Security.

requested_model beside model is what makes a fallback visible after the fact: a month of rows where fast was repeatedly served by the third candidate is a provider problem that no single response would have shown you.

How a record gets there

The gateway does not wait for the analytics service. Recording is enqueued after the response has been returned, and a background worker delivers it.

  • A full buffer drops records rather than requests. If usage cannot be delivered faster than it is produced, the gateway logs telemetry buffer full; usage records are being dropped and keeps serving. Losing a metering row is bad; refusing the request the customer is paying for is worse.
  • A delivery that fails is retried. An analytics outage costs no data as long as the buffer holds; the backlog drains when it recovers.
  • A 4xx is not retried. The analytics service answers 400 to a record it can never store — a missing request_id, tenant_id or recorded_at — and the gateway drops it after one attempt. Retrying forever against a rejection that will not change would wedge every record behind it.

Delivery is therefore at-least-once, and storage is exactly-once: request_id carries a unique constraint, and a second delivery of the same record is answered 200 instead of 201 and stored once. A retry cannot double-bill.

recorded_at is the gateway's timestamp, not the moment of arrival. A week of usage buffered through an outage belongs to the days it was served on, not to the day the service came back.

Where the cost figure comes from

Every row carries two money columns. cost_usd is what the request cost the operator: the provider's own rate, applied to the tokens actually spent. charge_usd is what the tenant owes for it. They are the same number until the operator says otherwise, and the gap between them is the margin.

Cost is computed by the gateway, per request, from the catalog entry that served it:

cost_usd = prompt_tokens / 1e6 * input_cost_per_mtok
         + completion_tokens / 1e6 * output_cost_per_mtok

Those two rates come from the provider's own /models response. They are not a standard field — OpenAI publishes no prices there at all — so they are read when present and ignored when absent.

A provider that publishes no prices produces cost_usd: 0. Not an estimate, not a default rate: a wrong number here would flow into billing, and no number is easier to notice than a plausible one that is wrong. Two other things go quiet with it — the cheapest and best cost tiers have nothing to order by and fall through to the tie-break, and a cost budget can never be reached.

Google's Gemini endpoint and Anthropic's compatibility layer are both in that position: neither listing carries a rate. To price them, put the figures from the vendor's pricing page in catalog.prices, keyed by provider kind and then model id:

yaml
catalog:
  prices:
    gemini:
      gemini-2.5-flash:
        input: 0.30
        output: 2.50

A rate configured there wins over the listing's own, and it applies to every tenant using that kind, since it is the vendor's price rather than an account's. Nothing is built in and nothing is guessed: an unlisted model still costs zero. The figures above are an example of the shape, not current pricing, so read the real ones off the vendor and keep them current, since a stale rate is a wrong number in a billing column rather than a missing one.

What the tenant is charged

One setting decides it, for the whole gateway:

yaml
billing:
    mode: passthrough
    markup_pct: 0
    cost_visibility: hidden
  • passthrough, the default, charges the provider's rate itself. The operator collects exactly what the traffic cost and keeps nothing. Rates come from the provider listing, or from catalog.prices where the listing carries none, so linking the charge to what the provider charges needs no second table to maintain.
  • markup charges the provider rate plus markup_pct. At markup_pct: 15 a request that cost $0.0020 is charged at $0.0023. A markup mode with no percentage is refused at startup rather than quietly behaving as passthrough.
  • absorb charges nothing. charge_usd is zero on every row and the whole bill stays with the operator, which is the shape an internal platform wants: usage is still attributed per team and per key, nobody is invoiced for it.

Two consequences worth knowing before you pick one.

A model with no rate costs zero, so it is charged zero in every mode. There is nothing to put a margin on, and inventing a rate would put an invented number in a billing column.

Quota caps are measured in cost, not in charge. A cost budget stops traffic on what it cost the operator, which is the figure that stays meaningful under absorb, where the charge is always zero and a cap on it could never fire. A quota can also be set in tokens or in requests, neither of which is about money at all, so a deployment that bills nobody can still hold a tenant to a per-day allowance. Set a quota has the shape of one.

How much of the cost a tenant sees

What the provider charged the operator and what the tenant owes are different numbers, and a tenant reading both could divide one by the other and read the margin straight off. cost_visibility decides how much of the first one it gets:

  • hidden, the default, publishes none of it. /v1/usage and /v1/usage/breakdown carry no cost_usd key at all, so a tenant reads charge_usd and nothing about what the traffic cost to buy. The key is absent rather than zero, because a published zero reads as free traffic.
  • hint rounds cost_usd to one significant figure on the data plane. $1.8734 is published as $2, $0.0018734 as $0.002. The tenant sees the order of magnitude of what its traffic cost, and dividing an exact charge of $2.1544 by a cost of $2 says nothing useful about a markup of 15%.
  • exact publishes the figure the gateway computed. Nothing is withheld, which suits passthrough, where the charge is the cost and there is no margin to withhold, and an internal platform under absorb, where teams want to know what they are spending the company.

A cost quota goes with the cost. Under hidden the slot is left out of the limits list on /v1/usage, because cap and remaining are denominated in the withheld figure and any two of them give the third. The tenant is not left guessing: the overall state still accounts for the slot, so a tenant on a spend cap reads soft-exceeded or hard-exceeded, and the rejection is still a 429 with budget_exceeded. Under hint the slot is published with a rounded consumed.

Four things stay exact whatever this is set to, and they are the ones that matter:

  • charge_usd, on both planes. It is what the tenant owes, and nobody should be billed on an approximation.
  • Everything the admin plane returns, including a margin_usd on GET /admin/v1/tenants/{id}/usage so an operator does not have to subtract.
  • The stored row, so a figure is never lost, only withheld or displayed coarsely.
  • Quota enforcement. What stops traffic is computed from the real position, never from what was published. Under hint a tenant sees a rounded consumed and a remaining derived from it rather than from the exact number: the tenant knows its own cap, so an exact remainder would subtract straight back to the figure the hint is meant to blur.

The setting is deployment-wide, like the mode itself. It is not a per-tenant knob, because a deployment showing one tenant exact costs and another rounded ones has told the first one what the second is being charged over.

Reading it back

Both planes read the same rows. A tenant reads its own with a data-plane key:

bash
curl "http://localhost:8080/v1/usage?window=month" \
  -H "Authorization: Bearer $CG_KEY"
json
{
    "object": "usage",
    "window": "month",
    "since": "2026-09-01T00:00:00Z",
    "until": "2026-09-03T11:04:22Z",
    "requests": 1482,
    "cached_requests": 96,
    "prompt_tokens": 391204,
    "completion_tokens": 88317,
    "total_tokens": 479521,
    "charge_usd": 2.1544,
    "state": "ok",
    "limits": []
}

There is no cost_usd in that body because cost_visibility is hidden. Set it to hint or exact and the key appears, rounded or not; the operator reads it on GET /admin/v1/tenants/{id}/usage either way, beside a margin_usd.

cached_requests is how many of requests the completion cache answered. Those rows carry no tokens and cost nothing, so a window whose spend looks low against its request count is explained by that number rather than by a metering fault.

window is day or month and the bounds are resolved for you, half-open, and returned. There is no arbitrary date range: a metering read is either "this billing period" or "today", and a free-form window would invite a tenant to scan its whole history on every dashboard refresh.

GET /v1/usage/breakdown takes the same window plus group_by, one of model, provider, key or client_request_id, and returns the same numbers per bucket under data, most expensive first. It is capped at the 200 costliest buckets and says "truncated": true when it had to cut. An operator reads any tenant's through GET /admin/v1/tenants/{id}/usage and .../usage/breakdown.

group_by=client_request_id is the one worth knowing about: it groups by the X-Client-Request-Id you sent, so a cost can be attributed back to whatever your own system calls the unit of work — a job, a document, an end user.

The monthly aggregate

A scheduled job runs at midnight on the first of each month, takes the tenants that produced usage in the preceding 30 days, and logs a priced total for each:

Invoice for Tenant: ten_9f2c41ab77d0 | Period: ... | Total Tokens: 479521 | Provider Cost: $1.8734 | Charged: $2.1544 | Margin: $0.2810

The figure it returns is the summed charge_usd, so it agrees with what the tenant reads back for the same window. It is a log line and nothing more: there is no invoice document, no ledger, and no route that reads one back.

billing_mode is stamped on each row rather than read from the configuration at aggregate time. Changing the mode changes what is charged from that point on, not what was charged before it, and a window that straddles the change should say so rather than reprice history.

It is there so that a deployment which has to bill something has a worked starting point in the right place. The numbers a real billing system should read are the ones under Reading it back.

Verifying that metering works

bash
docker compose logs analytics | grep "Started CognigateApplication"
docker compose logs gateway   | grep "usage record"

A silent gateway log is the healthy case. usage record could not be persisted is the one to act on — most often a mismatched ANALYTICS_TOKEN, which the analytics service answers 401 and the gateway correctly refuses to retry. Troubleshooting has the rest of that chain.