Files
coder/coderd/chatd/chatcost/chatcost.go
T
Michael Suchacz c3b6284955 feat: add chat cost analytics backend (#23036)
Add cost tracking for LLM chat interactions with microdollar precision.

## Changes
- Add `chatcost` package for per-message cost calculation using
`shopspring/decimal` for intermediate arithmetic
- **Ceil rounding policy**: fractional micros round UP to next whole
micro (applied once after summing all components)
- Database migration: `total_cost_micros` BIGINT column with historical
backfill and `created_at` index
- API endpoints: per-user cost summary and admin rollup under
`/api/experimental/chats/cost/`
- SDK types: `ChatCostSummary`, `ChatCostModelBreakdown`,
`ChatCostUserRollup`
- Fix `modeloptionsgen` to handle `decimal.Decimal` as opaque numeric
type
- Update frontend pricing test fixtures for string decimal types

## Design decisions
- `NULL` = unpriced (no matching model config), `0` = free
- Reasoning tokens included in output tokens (no double-counting)
- Integer microdollars (BIGINT) for storage and API responses
- Price config uses `decimal.Decimal` for exact parsing; totals use
`int64`

Frontend: #23037
2026-03-13 18:30:49 +01:00

72 lines
2.5 KiB
Go

package chatcost
import (
"github.com/shopspring/decimal"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
)
// Returns cost in micros -- millionths of a dollar, rounded up to the next
// whole microdollar.
// Returns nil when pricing is not configured or when all priced usage fields
// are nil, allowing callers to distinguish "zero cost" from "unpriced".
func CalculateTotalCostMicros(
usage codersdk.ChatMessageUsage,
cost *codersdk.ModelCostConfig,
) *int64 {
if cost == nil {
return nil
}
// A cost config with no prices set means pricing is effectively
// unconfigured — return nil (unpriced) rather than zero.
if cost.InputPricePerMillionTokens == nil &&
cost.OutputPricePerMillionTokens == nil &&
cost.CacheReadPricePerMillionTokens == nil &&
cost.CacheWritePricePerMillionTokens == nil {
return nil
}
if usage.InputTokens == nil &&
usage.OutputTokens == nil &&
usage.ReasoningTokens == nil &&
usage.CacheCreationTokens == nil &&
usage.CacheReadTokens == nil {
return nil
}
// OutputTokens already includes reasoning tokens per provider
// semantics (e.g. OpenAI's completion_tokens encompasses
// reasoning_tokens). Adding ReasoningTokens here would
// double-count.
// Preserve nil when usage exists only in categories without configured
// pricing, so callers can distinguish "unpriced" from "priced at zero".
hasMatchingPrice := (usage.InputTokens != nil && cost.InputPricePerMillionTokens != nil) ||
(usage.OutputTokens != nil && cost.OutputPricePerMillionTokens != nil) ||
(usage.CacheReadTokens != nil && cost.CacheReadPricePerMillionTokens != nil) ||
(usage.CacheCreationTokens != nil && cost.CacheWritePricePerMillionTokens != nil)
if !hasMatchingPrice {
return nil
}
inputMicros := calcCost(usage.InputTokens, cost.InputPricePerMillionTokens)
outputMicros := calcCost(usage.OutputTokens, cost.OutputPricePerMillionTokens)
cacheReadMicros := calcCost(usage.CacheReadTokens, cost.CacheReadPricePerMillionTokens)
cacheWriteMicros := calcCost(usage.CacheCreationTokens, cost.CacheWritePricePerMillionTokens)
total := inputMicros.
Add(outputMicros).
Add(cacheReadMicros).
Add(cacheWriteMicros)
rounded := total.Ceil().IntPart()
return &rounded
}
// calcCost returns the cost in fractional microdollars (millionths of a USD)
// for the given token count at the specified per-million-token price.
func calcCost(tokens *int64, pricePerMillion *decimal.Decimal) decimal.Decimal {
return decimal.NewFromInt(ptr.NilToEmpty(tokens)).Mul(ptr.NilToEmpty(pricePerMillion))
}