mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+148
-13
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user