Files
coder/coderd/aibridgedserver/cost.go
T
Yevhenii Shcherbina b6fcb9a30a feat: record cost on aibridge token usages (#26229)
Implements
https://linear.app/codercom/issue/AIGOV-286/add-interception-cost-calculation-to-aibridge-token-usages

Adds spend attribution to AI Gateway. After the upstream response, each
token-usage record now captures the user's effective group, the
per-token prices in effect at that moment, and a computed cost — so
spend is recorded as an immutable, point-in-time snapshot.

Concretely, `aibridge_token_usages` gains `effective_group_id`,
`input_price_micros`, `output_price_micros`, `cache_read_price_micros`,
`cache_write_price_micros`, and `cost_micros`. When a usage record is
written, the effective group is resolved (per-user override, else the
deployment budget policy), the `(provider, model)` price is looked up
and snapshotted onto the row, and cost is computed from the
provider-reported token counts. A model that isn't in the price table
records its tokens with a `NULL` cost; any *other* resolution failure
fails the write, so a `NULL` cost unambiguously means "model not priced"
rather than "lookup errored."

All values are stored in micro-units (1 unit = 1,000,000 micro-units;
Phase 1 assumes USD, so 1 micro-unit = $0.000001). Prices are quoted per
million tokens.

This also grants the AI Bridge RBAC subject `read` on `ai_model_prices`
(the per-interception price lookup needs it; it previously only had
`update` for the startup seeder).

## Cost precision

Cost is computed per token category as `tokens × price / 1_000_000` with
integer division, then the four categories are summed. The division is
done **per category** (not once over the summed numerator) on purpose:
it keeps the per-category line items summing exactly to the stored total
— no "the parts don't add up to the whole" in reporting).

Integer division truncates sub-micro-unit fractions. For example, a
cheap model at $0.10 per million tokens is a price of `100_000`; 9
tokens cost `9 × 100_000 / 1_000_000 = 900_000 / 1_000_000 = 0` (the
true 0.9 micro-units floors to 0). At real list prices this rarely bites
— $3/M input is a price of `3_000_000`, so even a single token is 3
micro-units. The per-record under-count is bounded below 1 micro-unit
per category, so under $0.000004 total across the four categories, which
is acceptable for list-price-based cost approximation.

## Overflow safety

`cost_micros` is a `BIGINT` (int64), and the largest intermediate value
is a single category's `tokens × price` before division. int64's ceiling
is ≈ `9.223e18`.

- At a steep $75/M model (price `75_000_000`), overflow would require
~123 billion tokens in one response: `123e9 × 75e6 = 9.225e18`, just
over the limit. `122e9` stays under at `9.15e18`.
- A realistically maxed-out Opus 4.8 response (≈1M input + 128K output
at list prices) costs about $15, with a numerator around `1.5e13` —
roughly six orders of magnitude below the ceiling.

So overflow is unreachable from real token counts.

### Multi-currency support

In the future, we may encounter issues with multi-currency support,
especially when dealing with currencies that have very large exchange
rates relative to USD, for example:

IRR: ~1,300,000 IRR ≈ 1 USD
VND: ~26,000 VND ≈ 1 USD

For currencies with such large denominations, numeric overflow is
technically possible, considering that we have only about six orders of
magnitude of headroom before reaching the limit (see above).

## `effective_group_id` has no foreign key

`effective_group_id` records the group a spend was attributed to, as an
immutable historical fact. It is intentionally **not** a foreign key, so
the record survives deletion of the group.

Alternatives were considered and rejected:

- **`ON DELETE SET NULL`** would mutate an "immutable" record — deleting
a group silently erases that interception's attribution and under-counts
the group's historical spend.
- **`RESTRICT` / `NO ACTION`** would block group deletion entirely
(groups are hard-deleted).
- **`CASCADE`** would delete spend history when a group is deleted — the
worst outcome for an audit record.

There is also no insert-time check that the group still exists: the id
comes from a budget that was just resolved, meaning it was valid at some
point.

## Open question: group name snapshotting

Should we also snapshot the group *name* onto each record? Two options:

- **Denormalize it now** — readable in historical reports even after a
group is deleted, but the snapshot can drift from the current name on
rename, raising a "show point-in-time vs. current name" question.
- **Postpone until needed** — it's a purely additive column later, and
the name is display-only (not correctness-bearing like the price). The
cost: names of groups deleted before the column is added can't be
backfilled.

Leaning toward postponing until a concrete reporting need settles the
drift question.
2026-06-16 20:09:00 +00:00

98 lines
3.9 KiB
Go

package aibridgedserver
import (
"context"
"database/sql"
"errors"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/aibridge/budget"
"github.com/coder/coder/v2/coderd/aibridged/proto"
"github.com/coder/coder/v2/coderd/database"
)
// tokensPerMillion is the divisor for prices, which are quoted per million
// tokens.
const tokensPerMillion = 1_000_000
// tokenUsageCost holds the cost-attribution columns snapshotted onto a token
// usage record. A field left unset (Valid == false) is recorded as SQL NULL; a
// price or cost of 0 is recorded as 0, which is distinct from NULL.
type tokenUsageCost struct {
effectiveGroupID uuid.NullUUID
inputPriceMicros sql.NullInt64
outputPriceMicros sql.NullInt64
cacheReadPriceMicros sql.NullInt64
cacheWritePriceMicros sql.NullInt64
costMicros sql.NullInt64
}
// resolveTokenUsageCost resolves the effective group and per-token prices for an
// interception and computes its cost. Two outcomes are expected and yield NULL
// columns rather than an error: a user with no configured budget (yields a NULL
// group) and a model absent from the price table (yields NULL prices and cost).
// Any other error is returned. A NULL cost unambiguously means "model not priced".
func (s *Server) resolveTokenUsageCost(ctx context.Context, intc database.AIBridgeInterception, in *proto.RecordTokenUsageRequest) (tokenUsageCost, error) {
var result tokenUsageCost
// Resolve the effective group for attribution. This is independent of
// whether the model is priced. ok is false when no budget is configured,
// which leaves the group attribution NULL.
effectiveBudget, ok, err := budget.ResolveUserAIBudget(ctx, s.store, intc.InitiatorID, s.budgetPolicy)
if err != nil {
return tokenUsageCost{}, xerrors.Errorf("resolve effective AI budget for user %q with policy %q: %w", intc.InitiatorID, s.budgetPolicy, err)
}
if ok {
result.effectiveGroupID = uuid.NullUUID{UUID: effectiveBudget.GroupID, Valid: true}
}
// Snapshot the price for this (provider, model) and compute cost.
price, err := s.store.GetAIModelPriceByProviderModel(ctx, database.GetAIModelPriceByProviderModelParams{
Provider: intc.Provider,
Model: intc.Model,
})
switch {
case errors.Is(err, sql.ErrNoRows):
// Model not in the price table: record tokens but leave cost NULL.
s.logger.Debug(ctx, "no price found for model, recording token usage with NULL cost",
slog.F("provider", intc.Provider), slog.F("model", intc.Model))
return result, nil
case err != nil:
return tokenUsageCost{}, xerrors.Errorf("look up model price for %s/%s: %w", intc.Provider, intc.Model, err)
}
result.inputPriceMicros = price.InputPrice
result.outputPriceMicros = price.OutputPrice
result.cacheReadPriceMicros = price.CacheReadPrice
result.cacheWritePriceMicros = price.CacheWritePrice
result.costMicros = sql.NullInt64{
Int64: computeCost(price,
in.GetInputTokens(), in.GetOutputTokens(),
in.GetCacheReadInputTokens(), in.GetCacheWriteInputTokens()),
Valid: true,
}
return result, nil
}
// computeCost returns the cost of an interception in micro-units, snapshotting
// the per-token prices from the price table. Prices are expressed per million
// tokens; a NULL price column is treated as zero (e.g. providers that do not
// charge for cache writes).
func computeCost(price database.AIModelPrice, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int64) int64 {
return tokenCost(inputTokens, price.InputPrice) +
tokenCost(outputTokens, price.OutputPrice) +
tokenCost(cacheReadTokens, price.CacheReadPrice) +
tokenCost(cacheWriteTokens, price.CacheWritePrice)
}
// tokenCost returns tokens * price / 1,000,000, treating a NULL price as zero.
func tokenCost(tokens int64, pricePerMillion sql.NullInt64) int64 {
if !pricePerMillion.Valid {
return 0
}
return tokens * pricePerMillion.Int64 / tokensPerMillion
}