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
This commit is contained in:
Michael Suchacz
2026-03-13 18:30:49 +01:00
committed by GitHub
parent 1152b61ebb
commit c3b6284955
34 changed files with 2034 additions and 262 deletions
+4
View File
@@ -130,6 +130,10 @@ func TypeMappings(gen *guts.GoParser) error {
"github.com/coder/serpent.URL": "string",
"github.com/coder/serpent.HostPort": "string",
"encoding/json.RawMessage": "map[string]string",
// decimal.Decimal preserves exact pricing precision (e.g. $3.50 per
// million tokens) and serializes as a JSON string to avoid
// floating-point loss in transit.
"github.com/shopspring/decimal.Decimal": "string",
})
if err != nil {
return xerrors.Errorf("include custom: %w", err)
+13 -1
View File
@@ -7,6 +7,8 @@ import (
"reflect"
"strings"
"github.com/shopspring/decimal"
"github.com/coder/coder/v2/codersdk"
)
@@ -117,11 +119,15 @@ func extractFields(t reflect.Type, prefix string, skip map[string]bool) FieldGro
// so that entire sub-objects can be marked hidden.
hidden := f.Tag.Get("hidden") == "true"
// decimal.Decimal is an opaque numeric type used for pricing
// precision; do not recurse into its internal struct fields.
isDecimal := ft == reflect.TypeOf(decimal.Decimal{})
// If the field is a struct (not a map), recurse to flatten
// its children using dot-separated names — unless the
// entire struct is marked hidden, in which case emit it
// as a single opaque field.
if ft.Kind() == reflect.Struct && !hidden {
if ft.Kind() == reflect.Struct && !hidden && !isDecimal {
nested := extractFields(ft, fullJSONName, nil)
fields = append(fields, nested.Fields...)
continue
@@ -206,6 +212,12 @@ func goTypeToSchemaType(t reflect.Type) string {
t = t.Elem()
}
// decimal.Decimal represents a precise numeric value and should
// map to the "number" schema type.
if t == reflect.TypeOf(decimal.Decimal{}) {
return "number"
}
switch t.Kind() {
case reflect.String:
return "string"