mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Implements: https://linear.app/codercom/issue/AIGOV-448/use-decimal-for-cost-computation Follow-up to https://github.com/coder/coder/pull/26229 Follow-up to the AI Gateway cost-control work. Cost is computed per token category as `tokens × price / 1_000_000` in `int64`, then summed. This change makes an unrepresentable result a defined outcome instead of an accident of integer wrap-around. ## Motivation The intermediate `tokens × price` can exceed `int64`. Real usage cannot get there: at a $75/M model the product overflows at roughly 123 billion tokens in a single response, about six orders of magnitude above a maxed-out Opus request, so this is not a live incident. The problem is what happens if it ever does, because the sign of the wrapped value silently selects between two different failure modes, neither of which was chosen: 1. **Wraps positive.** A plausible-looking cost is stored, incremented into the user's daily spend, and enforced against their AI budget. No error, no signal, wrong number. 2. **Wraps negative.** The value violates `CHECK (cost_micros >= 0)`, the insert fails, the surrounding transaction rolls back, and `RecordTokenUsage` returns a Postgres constraint error that says nothing about overflow. The token usage record is lost entirely, along with its token counts. So the same class of bad input either corrupts budget accounting or discards an audit record, depending on arithmetic that nobody reasoned about. That is the undefined behaviour. ## Decision **An unrepresentable cost is treated as bad input, not a large bill.** Since real usage cannot produce one, it can only mean a wrong price row or implausible provider-reported token counts. In both cases the true cost is unknowable, so no number is stored. **Detect rather than avoid.** `computeCost` now evaluates in `decimal`, so nothing wraps, and range-checks the total against `[0, MaxInt64]` before converting back. Out of range returns `errCostOutOfRange`. Rejecting negatives in the same check also keeps them away from the non-negative column constraint, which would otherwise discard the record. **Log, do not block.** The error is swallowed at the call site: the record is written with token counts intact and `cost_micros` NULL, the spend update is skipped, and the condition is logged at ERROR. **Per-category truncation is unchanged.** Each category is still truncated independently rather than the total being rounded once, so a per-category breakdown recomputed from the snapshotted price columns sums exactly to the stored total. Every existing `computeCost` test case passes unmodified.
259 lines
7.3 KiB
Go
259 lines
7.3 KiB
Go
package aibridgedserver
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"math"
|
|
"testing"
|
|
|
|
"github.com/coder/coder/v2/coderd/aibridged/proto"
|
|
"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} }
|
|
|
|
const oneMicroPerToken = 1_000_000
|
|
bound := maxCostMicros.IntPart()
|
|
|
|
tests := []struct {
|
|
name string
|
|
price database.AIModelPrice
|
|
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int64
|
|
want int64
|
|
wantOutOfRange bool
|
|
}{
|
|
{
|
|
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,
|
|
},
|
|
{
|
|
// Each category costs 37.5 micro-units, so truncating per category
|
|
// gives 37 + 37 = 74.
|
|
name: "each category truncates before the sum",
|
|
price: database.AIModelPrice{
|
|
InputPrice: nullInt64(37_500_000),
|
|
OutputPrice: nullInt64(37_500_000),
|
|
},
|
|
inputTokens: 1,
|
|
outputTokens: 1,
|
|
want: 74,
|
|
},
|
|
{
|
|
name: "cost exactly at the bound is in range",
|
|
price: database.AIModelPrice{InputPrice: nullInt64(oneMicroPerToken)},
|
|
inputTokens: bound,
|
|
want: bound,
|
|
},
|
|
{
|
|
name: "cost one micro-unit above the bound is out of range",
|
|
price: database.AIModelPrice{InputPrice: nullInt64(oneMicroPerToken)},
|
|
inputTokens: bound + 1,
|
|
wantOutOfRange: true,
|
|
},
|
|
{
|
|
name: "cost of int64 max is out of range",
|
|
price: database.AIModelPrice{InputPrice: nullInt64(oneMicroPerToken)},
|
|
inputTokens: math.MaxInt64,
|
|
wantOutOfRange: true,
|
|
},
|
|
{
|
|
// Each category fits on its own; only their sum exceeds the bound,
|
|
// so the range check has to run on the total.
|
|
name: "sum of in-range categories above the bound is out of range",
|
|
price: database.AIModelPrice{
|
|
InputPrice: nullInt64(oneMicroPerToken),
|
|
OutputPrice: nullInt64(oneMicroPerToken),
|
|
},
|
|
inputTokens: bound/2 + 1,
|
|
outputTokens: bound/2 + 1,
|
|
wantOutOfRange: true,
|
|
},
|
|
{
|
|
// The cost column forbids negatives, so an implausible token count
|
|
// is rejected here rather than failing the insert.
|
|
name: "negative cost is out of range",
|
|
price: database.AIModelPrice{
|
|
InputPrice: nullInt64(3_000_000),
|
|
},
|
|
inputTokens: -1_000_000,
|
|
wantOutOfRange: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := computeCost(tt.price, tt.inputTokens, tt.outputTokens, tt.cacheReadTokens, tt.cacheWriteTokens)
|
|
if tt.wantOutOfRange {
|
|
if !errors.Is(err, errCostOutOfRange) {
|
|
t.Fatalf("computeCost error = %v, want errCostOutOfRange", err)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("computeCost error = %v, want nil", err)
|
|
}
|
|
if got != tt.want {
|
|
t.Fatalf("computeCost = %d, want %d", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateTokenUsage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bound := maxAllowedTokenUsage
|
|
|
|
tests := []struct {
|
|
name string
|
|
request *proto.RecordTokenUsageRequest
|
|
wantOutOfRange bool
|
|
}{
|
|
{
|
|
// A frontier-sized request, with one category at zero.
|
|
name: "plausible counts",
|
|
request: &proto.RecordTokenUsageRequest{
|
|
InputTokens: 1_000_000, OutputTokens: 128_000,
|
|
CacheReadInputTokens: 500_000,
|
|
},
|
|
},
|
|
{
|
|
// The bound is inclusive.
|
|
name: "every category exactly at the bound",
|
|
request: &proto.RecordTokenUsageRequest{
|
|
InputTokens: bound, OutputTokens: bound,
|
|
CacheReadInputTokens: bound, CacheWriteInputTokens: bound,
|
|
},
|
|
},
|
|
{
|
|
name: "above the bound",
|
|
request: &proto.RecordTokenUsageRequest{InputTokens: bound + 1},
|
|
wantOutOfRange: true,
|
|
},
|
|
{
|
|
// The last category is checked too, not just the first.
|
|
name: "negative cache write",
|
|
request: &proto.RecordTokenUsageRequest{CacheWriteInputTokens: -1},
|
|
wantOutOfRange: true,
|
|
},
|
|
{
|
|
// A negative offset by a larger positive still totals in range, so
|
|
// the check has to run per category rather than on the sum.
|
|
name: "negative input offset by positive output",
|
|
request: &proto.RecordTokenUsageRequest{
|
|
InputTokens: -1_000_000, OutputTokens: 2_000_000,
|
|
},
|
|
wantOutOfRange: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := validateTokenUsage(tt.request)
|
|
if tt.wantOutOfRange {
|
|
if !errors.Is(err, errTokenUsageOutOfRange) {
|
|
t.Fatalf("validateTokenUsage error = %v, want errTokenUsageOutOfRange", err)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("validateTokenUsage error = %v, want nil", err)
|
|
}
|
|
})
|
|
}
|
|
}
|