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:
| Field | What it is |
|---|---|
request_id | The gateway's id — unique, and the idempotency key |
client_request_id | Your own correlation id, if you sent one |
tenant_id, key_prefix | Who |
provider, model | What actually served it |
requested_model, fallback_depth | What was asked for, and how far the cascade went |
prompt_tokens, completion_tokens, total_tokens | The counts |
cost_usd | What it cost, priced from the catalog (below) |
charge_usd | What the tenant owes for it (below) |
billing_mode | How the charge was arrived at, as of that request |
cached, streamed | How it was served |
status_code, duration_ms | The outcome |
recorded_at | When 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 droppedand 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
400to a record it can never store — a missingrequest_id,tenant_idorrecorded_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:
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:
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 fromcatalog.priceswhere the listing carries none, so linking the charge to what the provider charges needs no second table to maintain.markupcharges the provider rate plusmarkup_pct. Atmarkup_pct: 15a request that cost $0.0020 is charged at $0.0023. Amarkupmode with no percentage is refused at startup rather than quietly behaving as passthrough.absorbcharges nothing.charge_usdis 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/usageand/v1/usage/breakdowncarry nocost_usdkey at all, so a tenant readscharge_usdand nothing about what the traffic cost to buy. The key is absent rather than zero, because a published zero reads as free traffic.hintroundscost_usdto 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%.exactpublishes the figure the gateway computed. Nothing is withheld, which suitspassthrough, where the charge is the cost and there is no margin to withhold, and an internal platform underabsorb, 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_usdonGET /admin/v1/tenants/{id}/usageso 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
hinta tenant sees a roundedconsumedand aremainingderived 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:
curl "http://localhost:8080/v1/usage?window=month" \
-H "Authorization: Bearer $CG_KEY"
{
"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
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.