Files
coder/coderd/aibridgedserver/cost_internal_test.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

125 lines
3.4 KiB
Go

package aibridgedserver
import (
"database/sql"
"testing"
"github.com/coder/coder/v2/coderd/database"
)
func TestComputeCost(t *testing.T) {
t.Parallel()
nullInt64 := func(v int64) sql.NullInt64 { return sql.NullInt64{Int64: v, Valid: true} }
tests := []struct {
name string
price database.AIModelPrice
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int64
want int64
}{
{
name: "all priced",
price: database.AIModelPrice{
InputPrice: nullInt64(3_000_000),
OutputPrice: nullInt64(6_000_000),
CacheReadPrice: nullInt64(300_000),
CacheWritePrice: nullInt64(3_750_000),
},
inputTokens: 100,
outputTokens: 200,
cacheReadTokens: 50,
cacheWriteTokens: 10,
// 300 + 1200 + 15 + 37 (10*3_750_000/1e6 = 37, integer division).
want: 1552,
},
{
name: "null cache write price treated as zero",
price: database.AIModelPrice{
InputPrice: nullInt64(3_000_000),
OutputPrice: nullInt64(6_000_000),
CacheReadPrice: nullInt64(300_000),
CacheWritePrice: sql.NullInt64{Valid: false},
},
inputTokens: 100,
outputTokens: 200,
cacheReadTokens: 50,
cacheWriteTokens: 10,
// 300 + 1200 + 15 + 0.
want: 1515,
},
{
name: "all prices null is zero cost",
price: database.AIModelPrice{},
inputTokens: 100,
outputTokens: 200,
cacheReadTokens: 50,
cacheWriteTokens: 10,
want: 0,
},
{
name: "zero tokens is zero cost",
price: database.AIModelPrice{
InputPrice: nullInt64(3_000_000),
OutputPrice: nullInt64(6_000_000),
},
want: 0,
},
{
name: "integer division truncates",
price: database.AIModelPrice{
// 1 token at 1 micro-unit per million tokens rounds down to 0.
InputPrice: nullInt64(1),
},
inputTokens: 1,
want: 0,
},
{
name: "price just below one micro-unit per token floors to zero",
price: database.AIModelPrice{
InputPrice: nullInt64(999_999),
},
inputTokens: 1, // 1 * 999_999 = 999_999, below 1_000_000
want: 0,
},
{
name: "sub-unit price summed across tokens still floors to zero",
price: database.AIModelPrice{
InputPrice: nullInt64(999),
},
inputTokens: 1000, // 1000 * 999 = 999_000, below 1_000_000
want: 0,
},
{
name: "sub-unit price crosses one micro-unit once the product reaches 1e6",
price: database.AIModelPrice{
InputPrice: nullInt64(999),
},
inputTokens: 1002, // 1002 * 999 = 1_000_998
want: 1,
},
{
// Stress the per-term numerator near the int64 ceiling. At a $75/M
// model the overflow point is ~123e9 tokens (123e9 * 75e6 = 9.225e18,
// just over int64 max 9.223e18); 122e9 stays just under.
name: "large token count at a high price does not overflow",
price: database.AIModelPrice{
InputPrice: nullInt64(75_000_000), // $75 per 1M tokens
},
inputTokens: 122_000_000_000, // 122e9 * 75e6 = 9.15e18 < int64 max
want: 9_150_000_000_000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := computeCost(tt.price, tt.inputTokens, tt.outputTokens, tt.cacheReadTokens, tt.cacheWriteTokens)
if got != tt.want {
t.Fatalf("computeCost = %d, want %d", got, tt.want)
}
})
}
}