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
+365 -8
View File
@@ -3229,6 +3229,353 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch
return i, err
}
const getChatCostPerChat = `-- name: GetChatCostPerChat :many
WITH chat_costs AS (
SELECT
COALESCE(c.root_chat_id, c.id) AS root_chat_id,
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
COUNT(*) FILTER (
WHERE cm.input_tokens IS NOT NULL
OR cm.output_tokens IS NOT NULL
OR cm.reasoning_tokens IS NOT NULL
OR cm.cache_creation_tokens IS NOT NULL
OR cm.cache_read_tokens IS NOT NULL
)::bigint AS message_count,
COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens
FROM chat_messages cm
JOIN chats c ON c.id = cm.chat_id
WHERE c.owner_id = $1::uuid
AND cm.role = 'assistant'
AND cm.created_at >= $2::timestamptz
AND cm.created_at < $3::timestamptz
GROUP BY COALESCE(c.root_chat_id, c.id)
)
SELECT
cc.root_chat_id,
COALESCE(rc.title, '') AS chat_title,
cc.total_cost_micros,
cc.message_count,
cc.total_input_tokens,
cc.total_output_tokens
FROM chat_costs cc
LEFT JOIN chats rc ON rc.id = cc.root_chat_id
ORDER BY cc.total_cost_micros DESC
`
type GetChatCostPerChatParams struct {
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
StartDate time.Time `db:"start_date" json:"start_date"`
EndDate time.Time `db:"end_date" json:"end_date"`
}
type GetChatCostPerChatRow struct {
RootChatID uuid.UUID `db:"root_chat_id" json:"root_chat_id"`
ChatTitle string `db:"chat_title" json:"chat_title"`
TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"`
MessageCount int64 `db:"message_count" json:"message_count"`
TotalInputTokens int64 `db:"total_input_tokens" json:"total_input_tokens"`
TotalOutputTokens int64 `db:"total_output_tokens" json:"total_output_tokens"`
}
// Per-root-chat cost breakdown for a single user within a date range.
// Groups by root_chat_id so forked chats roll up under their root.
// Only counts assistant-role messages.
func (q *sqlQuerier) GetChatCostPerChat(ctx context.Context, arg GetChatCostPerChatParams) ([]GetChatCostPerChatRow, error) {
rows, err := q.db.QueryContext(ctx, getChatCostPerChat, arg.OwnerID, arg.StartDate, arg.EndDate)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetChatCostPerChatRow
for rows.Next() {
var i GetChatCostPerChatRow
if err := rows.Scan(
&i.RootChatID,
&i.ChatTitle,
&i.TotalCostMicros,
&i.MessageCount,
&i.TotalInputTokens,
&i.TotalOutputTokens,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getChatCostPerModel = `-- name: GetChatCostPerModel :many
SELECT
cmc.id AS model_config_id,
cmc.display_name,
cmc.provider,
cmc.model,
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
COUNT(*) FILTER (
WHERE cm.input_tokens IS NOT NULL
OR cm.output_tokens IS NOT NULL
OR cm.reasoning_tokens IS NOT NULL
OR cm.cache_creation_tokens IS NOT NULL
OR cm.cache_read_tokens IS NOT NULL
)::bigint AS message_count,
COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens
FROM
chat_messages cm
JOIN
chats c ON c.id = cm.chat_id
JOIN
chat_model_configs cmc ON cmc.id = cm.model_config_id
WHERE
c.owner_id = $1::uuid
AND cm.role = 'assistant'
AND cm.created_at >= $2::timestamptz
AND cm.created_at < $3::timestamptz
GROUP BY
cmc.id, cmc.display_name, cmc.provider, cmc.model
ORDER BY
total_cost_micros DESC
`
type GetChatCostPerModelParams struct {
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
StartDate time.Time `db:"start_date" json:"start_date"`
EndDate time.Time `db:"end_date" json:"end_date"`
}
type GetChatCostPerModelRow struct {
ModelConfigID uuid.UUID `db:"model_config_id" json:"model_config_id"`
DisplayName string `db:"display_name" json:"display_name"`
Provider string `db:"provider" json:"provider"`
Model string `db:"model" json:"model"`
TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"`
MessageCount int64 `db:"message_count" json:"message_count"`
TotalInputTokens int64 `db:"total_input_tokens" json:"total_input_tokens"`
TotalOutputTokens int64 `db:"total_output_tokens" json:"total_output_tokens"`
}
// Per-model cost breakdown for a single user within a date range.
// Only counts assistant-role messages that have a model_config_id.
func (q *sqlQuerier) GetChatCostPerModel(ctx context.Context, arg GetChatCostPerModelParams) ([]GetChatCostPerModelRow, error) {
rows, err := q.db.QueryContext(ctx, getChatCostPerModel, arg.OwnerID, arg.StartDate, arg.EndDate)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetChatCostPerModelRow
for rows.Next() {
var i GetChatCostPerModelRow
if err := rows.Scan(
&i.ModelConfigID,
&i.DisplayName,
&i.Provider,
&i.Model,
&i.TotalCostMicros,
&i.MessageCount,
&i.TotalInputTokens,
&i.TotalOutputTokens,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getChatCostPerUser = `-- name: GetChatCostPerUser :many
WITH chat_cost_users AS (
SELECT
c.owner_id AS user_id,
u.username,
u.name,
u.avatar_url,
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
COUNT(*) FILTER (
WHERE cm.input_tokens IS NOT NULL
OR cm.output_tokens IS NOT NULL
OR cm.reasoning_tokens IS NOT NULL
OR cm.cache_creation_tokens IS NOT NULL
OR cm.cache_read_tokens IS NOT NULL
)::bigint AS message_count,
COUNT(DISTINCT COALESCE(c.root_chat_id, c.id))::bigint AS chat_count,
COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens
FROM
chat_messages cm
JOIN
chats c ON c.id = cm.chat_id
JOIN
users u ON u.id = c.owner_id
WHERE
cm.role = 'assistant'
AND cm.created_at >= $3::timestamptz
AND cm.created_at < $4::timestamptz
AND (
$5::text = ''
OR u.username ILIKE '%' || $5::text || '%'
)
GROUP BY
c.owner_id,
u.username,
u.name,
u.avatar_url
)
SELECT
user_id,
username,
name,
avatar_url,
total_cost_micros,
message_count,
chat_count,
total_input_tokens,
total_output_tokens,
COUNT(*) OVER()::bigint AS total_count
FROM
chat_cost_users
ORDER BY
total_cost_micros DESC,
username ASC
LIMIT
$2::int
OFFSET
$1::int
`
type GetChatCostPerUserParams struct {
PageOffset int32 `db:"page_offset" json:"page_offset"`
PageLimit int32 `db:"page_limit" json:"page_limit"`
StartDate time.Time `db:"start_date" json:"start_date"`
EndDate time.Time `db:"end_date" json:"end_date"`
Username string `db:"username" json:"username"`
}
type GetChatCostPerUserRow struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
Username string `db:"username" json:"username"`
Name string `db:"name" json:"name"`
AvatarURL string `db:"avatar_url" json:"avatar_url"`
TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"`
MessageCount int64 `db:"message_count" json:"message_count"`
ChatCount int64 `db:"chat_count" json:"chat_count"`
TotalInputTokens int64 `db:"total_input_tokens" json:"total_input_tokens"`
TotalOutputTokens int64 `db:"total_output_tokens" json:"total_output_tokens"`
TotalCount int64 `db:"total_count" json:"total_count"`
}
// Deployment-wide per-user cost rollup within a date range.
// Only counts assistant-role messages.
func (q *sqlQuerier) GetChatCostPerUser(ctx context.Context, arg GetChatCostPerUserParams) ([]GetChatCostPerUserRow, error) {
rows, err := q.db.QueryContext(ctx, getChatCostPerUser,
arg.PageOffset,
arg.PageLimit,
arg.StartDate,
arg.EndDate,
arg.Username,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetChatCostPerUserRow
for rows.Next() {
var i GetChatCostPerUserRow
if err := rows.Scan(
&i.UserID,
&i.Username,
&i.Name,
&i.AvatarURL,
&i.TotalCostMicros,
&i.MessageCount,
&i.ChatCount,
&i.TotalInputTokens,
&i.TotalOutputTokens,
&i.TotalCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getChatCostSummary = `-- name: GetChatCostSummary :one
SELECT
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
COUNT(*) FILTER (
WHERE cm.total_cost_micros IS NOT NULL
)::bigint AS priced_message_count,
COUNT(*) FILTER (
WHERE cm.total_cost_micros IS NULL
AND (
cm.input_tokens IS NOT NULL
OR cm.output_tokens IS NOT NULL
OR cm.reasoning_tokens IS NOT NULL
OR cm.cache_creation_tokens IS NOT NULL
OR cm.cache_read_tokens IS NOT NULL
)
)::bigint AS unpriced_message_count,
COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens
FROM
chat_messages cm
JOIN
chats c ON c.id = cm.chat_id
WHERE
c.owner_id = $1::uuid
AND cm.role = 'assistant'
AND cm.created_at >= $2::timestamptz
AND cm.created_at < $3::timestamptz
`
type GetChatCostSummaryParams struct {
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
StartDate time.Time `db:"start_date" json:"start_date"`
EndDate time.Time `db:"end_date" json:"end_date"`
}
type GetChatCostSummaryRow struct {
TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"`
PricedMessageCount int64 `db:"priced_message_count" json:"priced_message_count"`
UnpricedMessageCount int64 `db:"unpriced_message_count" json:"unpriced_message_count"`
TotalInputTokens int64 `db:"total_input_tokens" json:"total_input_tokens"`
TotalOutputTokens int64 `db:"total_output_tokens" json:"total_output_tokens"`
}
// Aggregate cost summary for a single user within a date range.
// Only counts assistant-role messages.
func (q *sqlQuerier) GetChatCostSummary(ctx context.Context, arg GetChatCostSummaryParams) (GetChatCostSummaryRow, error) {
row := q.db.QueryRowContext(ctx, getChatCostSummary, arg.OwnerID, arg.StartDate, arg.EndDate)
var i GetChatCostSummaryRow
err := row.Scan(
&i.TotalCostMicros,
&i.PricedMessageCount,
&i.UnpricedMessageCount,
&i.TotalInputTokens,
&i.TotalOutputTokens,
)
return i, err
}
const getChatDiffStatusByChatID = `-- name: GetChatDiffStatusByChatID :one
SELECT
chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft
@@ -3311,7 +3658,7 @@ func (q *sqlQuerier) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds [
const getChatMessageByID = `-- name: GetChatMessageByID :one
SELECT
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros
FROM
chat_messages
WHERE
@@ -3339,13 +3686,14 @@ func (q *sqlQuerier) GetChatMessageByID(ctx context.Context, id int64) (ChatMess
&i.Compressed,
&i.CreatedBy,
&i.ContentVersion,
&i.TotalCostMicros,
)
return i, err
}
const getChatMessagesByChatID = `-- name: GetChatMessagesByChatID :many
SELECT
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros
FROM
chat_messages
WHERE
@@ -3388,6 +3736,7 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes
&i.Compressed,
&i.CreatedBy,
&i.ContentVersion,
&i.TotalCostMicros,
); err != nil {
return nil, err
}
@@ -3419,7 +3768,7 @@ WITH latest_compressed_summary AS (
1
)
SELECT
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros
FROM
chat_messages
WHERE
@@ -3486,6 +3835,7 @@ func (q *sqlQuerier) GetChatMessagesForPromptByChatID(ctx context.Context, chatI
&i.Compressed,
&i.CreatedBy,
&i.ContentVersion,
&i.TotalCostMicros,
); err != nil {
return nil, err
}
@@ -3629,7 +3979,7 @@ func (q *sqlQuerier) GetChatsByOwnerID(ctx context.Context, arg GetChatsByOwnerI
const getLastChatMessageByRole = `-- name: GetLastChatMessageByRole :one
SELECT
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros
FROM
chat_messages
WHERE
@@ -3667,6 +4017,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh
&i.Compressed,
&i.CreatedBy,
&i.ContentVersion,
&i.TotalCostMicros,
)
return i, err
}
@@ -3806,7 +4157,8 @@ INSERT INTO chat_messages (
cache_creation_tokens,
cache_read_tokens,
context_limit,
compressed
compressed,
total_cost_micros
) VALUES (
$1::uuid,
$2::uuid,
@@ -3822,10 +4174,11 @@ INSERT INTO chat_messages (
$12::bigint,
$13::bigint,
$14::bigint,
COALESCE($15::boolean, FALSE)
COALESCE($15::boolean, FALSE),
$16::bigint
)
RETURNING
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros
`
type InsertChatMessageParams struct {
@@ -3844,6 +4197,7 @@ type InsertChatMessageParams struct {
CacheReadTokens sql.NullInt64 `db:"cache_read_tokens" json:"cache_read_tokens"`
ContextLimit sql.NullInt64 `db:"context_limit" json:"context_limit"`
Compressed sql.NullBool `db:"compressed" json:"compressed"`
TotalCostMicros sql.NullInt64 `db:"total_cost_micros" json:"total_cost_micros"`
}
func (q *sqlQuerier) InsertChatMessage(ctx context.Context, arg InsertChatMessageParams) (ChatMessage, error) {
@@ -3863,6 +4217,7 @@ func (q *sqlQuerier) InsertChatMessage(ctx context.Context, arg InsertChatMessag
arg.CacheReadTokens,
arg.ContextLimit,
arg.Compressed,
arg.TotalCostMicros,
)
var i ChatMessage
err := row.Scan(
@@ -3883,6 +4238,7 @@ func (q *sqlQuerier) InsertChatMessage(ctx context.Context, arg InsertChatMessag
&i.Compressed,
&i.CreatedBy,
&i.ContentVersion,
&i.TotalCostMicros,
)
return i, err
}
@@ -4017,7 +4373,7 @@ SET
WHERE
id = $3::bigint
RETURNING
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version
id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros
`
type UpdateChatMessageByIDParams struct {
@@ -4047,6 +4403,7 @@ func (q *sqlQuerier) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMe
&i.Compressed,
&i.CreatedBy,
&i.ContentVersion,
&i.TotalCostMicros,
)
return i, err
}