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
+148 -13
View File
@@ -7,10 +7,13 @@ import (
"io"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
@@ -198,11 +201,11 @@ func ChatMessageFileReference(fileName string, startLine, endLine int, content s
}
// ChatMessageSource builds a source chat message part.
func ChatMessageSource(sourceID, url, title string) ChatMessagePart {
func ChatMessageSource(sourceID, sourceURL, title string) ChatMessagePart {
return ChatMessagePart{
Type: ChatMessagePartTypeSource,
SourceID: sourceID,
URL: url,
URL: sourceURL,
Title: title,
}
}
@@ -519,13 +522,10 @@ type ChatModelVercelProviderOptions struct {
// ModelCostConfig stores pricing metadata for a chat model.
type ModelCostConfig struct {
// Pricing is stored as configuration metadata and currently only needs to
// round-trip cleanly through the API and admin UI. If we later use these
// values for billing-grade arithmetic, switch to a fixed-point type.
InputPricePerMillionTokens *float64 `json:"input_price_per_million_tokens,omitempty" description:"Input token price in USD per 1M tokens"`
OutputPricePerMillionTokens *float64 `json:"output_price_per_million_tokens,omitempty" description:"Output token price in USD per 1M tokens"`
CacheReadPricePerMillionTokens *float64 `json:"cache_read_price_per_million_tokens,omitempty" description:"Cache read token price in USD per 1M tokens"`
CacheWritePricePerMillionTokens *float64 `json:"cache_write_price_per_million_tokens,omitempty" description:"Cache write or cache creation token price in USD per 1M tokens"`
InputPricePerMillionTokens *decimal.Decimal `json:"input_price_per_million_tokens,omitempty" description:"Input token price in USD per 1M tokens"`
OutputPricePerMillionTokens *decimal.Decimal `json:"output_price_per_million_tokens,omitempty" description:"Output token price in USD per 1M tokens"`
CacheReadPricePerMillionTokens *decimal.Decimal `json:"cache_read_price_per_million_tokens,omitempty" description:"Cache read token price in USD per 1M tokens"`
CacheWritePricePerMillionTokens *decimal.Decimal `json:"cache_write_price_per_million_tokens,omitempty" description:"Cache write or cache creation token price in USD per 1M tokens"`
}
// ChatModelCallConfig configures per-call model behavior defaults.
@@ -546,10 +546,10 @@ func (c *ChatModelCallConfig) UnmarshalJSON(data []byte) error {
type chatModelCallConfigAlias ChatModelCallConfig
aux := struct {
*chatModelCallConfigAlias
InputPricePerMillionTokens *float64 `json:"input_price_per_million_tokens,omitempty"`
OutputPricePerMillionTokens *float64 `json:"output_price_per_million_tokens,omitempty"`
CacheReadPricePerMillionTokens *float64 `json:"cache_read_price_per_million_tokens,omitempty"`
CacheWritePricePerMillionTokens *float64 `json:"cache_write_price_per_million_tokens,omitempty"`
InputPricePerMillionTokens *decimal.Decimal `json:"input_price_per_million_tokens,omitempty"`
OutputPricePerMillionTokens *decimal.Decimal `json:"output_price_per_million_tokens,omitempty"`
CacheReadPricePerMillionTokens *decimal.Decimal `json:"cache_read_price_per_million_tokens,omitempty"`
CacheWritePricePerMillionTokens *decimal.Decimal `json:"cache_write_price_per_million_tokens,omitempty"`
}{
chatModelCallConfigAlias: (*chatModelCallConfigAlias)(c),
}
@@ -710,6 +710,76 @@ type chatStreamEnvelope struct {
Data json.RawMessage `json:"data,omitempty"`
}
// ChatCostSummaryOptions are optional query parameters for GetChatCostSummary.
type ChatCostSummaryOptions struct {
StartDate time.Time
EndDate time.Time
}
// ChatCostUsersOptions are optional query parameters for GetChatCostUsers.
type ChatCostUsersOptions struct {
StartDate time.Time
EndDate time.Time
Username string
Pagination
}
// ChatCostSummary is the response from the chat cost summary endpoint.
type ChatCostSummary struct {
StartDate time.Time `json:"start_date" format:"date-time"`
EndDate time.Time `json:"end_date" format:"date-time"`
TotalCostMicros int64 `json:"total_cost_micros"`
PricedMessageCount int64 `json:"priced_message_count"`
UnpricedMessageCount int64 `json:"unpriced_message_count"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
ByModel []ChatCostModelBreakdown `json:"by_model"`
ByChat []ChatCostChatBreakdown `json:"by_chat"`
}
// ChatCostModelBreakdown contains per-model cost aggregation.
type ChatCostModelBreakdown struct {
ModelConfigID uuid.UUID `json:"model_config_id" format:"uuid"`
DisplayName string `json:"display_name"`
Provider string `json:"provider"`
Model string `json:"model"`
TotalCostMicros int64 `json:"total_cost_micros"`
MessageCount int64 `json:"message_count"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
}
// ChatCostChatBreakdown contains per-root-chat cost aggregation.
type ChatCostChatBreakdown struct {
RootChatID uuid.UUID `json:"root_chat_id" format:"uuid"`
ChatTitle string `json:"chat_title"`
TotalCostMicros int64 `json:"total_cost_micros"`
MessageCount int64 `json:"message_count"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
}
// ChatCostUserRollup contains per-user cost aggregation for admin views.
type ChatCostUserRollup struct {
UserID uuid.UUID `json:"user_id" format:"uuid"`
Username string `json:"username"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
TotalCostMicros int64 `json:"total_cost_micros"`
MessageCount int64 `json:"message_count"`
ChatCount int64 `json:"chat_count"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
}
// ChatCostUsersResponse is the response from the admin chat cost users endpoint.
type ChatCostUsersResponse struct {
StartDate time.Time `json:"start_date" format:"date-time"`
EndDate time.Time `json:"end_date" format:"date-time"`
Count int64 `json:"count"`
Users []ChatCostUserRollup `json:"users"`
}
// ListChatsOptions are optional parameters for ListChats.
type ListChatsOptions struct {
Query string
@@ -872,6 +942,71 @@ func (c *Client) DeleteChatModelConfig(ctx context.Context, modelConfigID uuid.U
return nil
}
// GetChatCostSummary returns an aggregate cost summary for the specified
// user. Zero-valued StartDate or EndDate fields are omitted from the
// request, letting the server apply its own defaults (typically the last
// 30 days).
func (c *Client) GetChatCostSummary(ctx context.Context, user string, opts ChatCostSummaryOptions) (ChatCostSummary, error) {
qp := url.Values{}
if !opts.StartDate.IsZero() {
qp.Set("start_date", opts.StartDate.Format(time.RFC3339))
}
if !opts.EndDate.IsZero() {
qp.Set("end_date", opts.EndDate.Format(time.RFC3339))
}
reqURL := fmt.Sprintf("/api/experimental/chats/cost/%s/summary", user)
if len(qp) > 0 {
reqURL += "?" + qp.Encode()
}
res, err := c.Request(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return ChatCostSummary{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ChatCostSummary{}, ReadBodyAsError(res)
}
var summary ChatCostSummary
return summary, json.NewDecoder(res.Body).Decode(&summary)
}
// GetChatCostUsers returns a per-user cost rollup for the deployment
// (admin only). Zero-valued StartDate or EndDate fields are omitted from
// the request, letting the server apply its own defaults (typically the
// last 30 days).
func (c *Client) GetChatCostUsers(ctx context.Context, opts ChatCostUsersOptions) (ChatCostUsersResponse, error) {
qp := url.Values{}
if !opts.StartDate.IsZero() {
qp.Set("start_date", opts.StartDate.Format(time.RFC3339))
}
if !opts.EndDate.IsZero() {
qp.Set("end_date", opts.EndDate.Format(time.RFC3339))
}
if opts.Username != "" {
qp.Set("username", opts.Username)
}
if opts.Limit > 0 {
qp.Set("limit", strconv.Itoa(opts.Limit))
}
if opts.Offset > 0 {
qp.Set("offset", strconv.Itoa(opts.Offset))
}
reqURL := "/api/experimental/chats/cost/users"
if len(qp) > 0 {
reqURL += "?" + qp.Encode()
}
res, err := c.Request(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return ChatCostUsersResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ChatCostUsersResponse{}, ReadBodyAsError(res)
}
var resp ChatCostUsersResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// GetChatSystemPrompt returns the deployment-wide chat system prompt.
func (c *Client) GetChatSystemPrompt(ctx context.Context) (ChatSystemPromptResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/system-prompt", nil)
+63
View File
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -109,3 +110,65 @@ func TestChatMessagePart_StripInternal(t *testing.T) {
assert.Equal(t, codersdk.ChatMessagePartTypeText, part.Type)
})
}
func TestModelCostConfig_LegacyNumericJSON(t *testing.T) {
t.Parallel()
var decoded codersdk.ModelCostConfig
err := json.Unmarshal([]byte("{\"input_price_per_million_tokens\": 1.5}"), &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.InputPricePerMillionTokens)
require.True(t, decoded.InputPricePerMillionTokens.Equal(decimal.RequireFromString("1.5")))
}
func TestModelCostConfig_QuotedDecimalJSON(t *testing.T) {
t.Parallel()
var decoded codersdk.ModelCostConfig
err := json.Unmarshal([]byte("{\"input_price_per_million_tokens\": \"1.5\"}"), &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.InputPricePerMillionTokens)
require.True(t, decoded.InputPricePerMillionTokens.Equal(decimal.RequireFromString("1.5")))
}
func TestModelCostConfig_NilVsZero(t *testing.T) {
t.Parallel()
zero := decimal.Zero
raw, err := json.Marshal(struct {
Nil codersdk.ModelCostConfig `json:"nil"`
Zero codersdk.ModelCostConfig `json:"zero"`
}{
Nil: codersdk.ModelCostConfig{},
Zero: codersdk.ModelCostConfig{InputPricePerMillionTokens: &zero},
})
require.NoError(t, err)
require.Contains(t, string(raw), "\"zero\":{\"input_price_per_million_tokens\":\"0\"}")
require.Contains(t, string(raw), "\"nil\":{}")
}
func TestChatModelCallConfig_UnmarshalLegacyPricing(t *testing.T) {
t.Parallel()
var decoded codersdk.ChatModelCallConfig
err := json.Unmarshal([]byte("{\"input_price_per_million_tokens\": 1.5}"), &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.Cost)
require.NotNil(t, decoded.Cost.InputPricePerMillionTokens)
require.True(t, decoded.Cost.InputPricePerMillionTokens.Equal(decimal.RequireFromString("1.5")))
}
func TestChatCostSummary_JSONRoundTrip(t *testing.T) {
t.Parallel()
original := codersdk.ChatCostSummary{
TotalCostMicros: 123,
}
raw, err := json.Marshal(original)
require.NoError(t, err)
var decoded codersdk.ChatCostSummary
err = json.Unmarshal(raw, &decoded)
require.NoError(t, err)
require.Equal(t, original.TotalCostMicros, decoded.TotalCostMicros)
}