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:
@@ -179,7 +179,8 @@ INSERT INTO chat_messages (
|
||||
cache_creation_tokens,
|
||||
cache_read_tokens,
|
||||
context_limit,
|
||||
compressed
|
||||
compressed,
|
||||
total_cost_micros
|
||||
) VALUES (
|
||||
@chat_id::uuid,
|
||||
sqlc.narg('created_by')::uuid,
|
||||
@@ -195,7 +196,8 @@ INSERT INTO chat_messages (
|
||||
sqlc.narg('cache_creation_tokens')::bigint,
|
||||
sqlc.narg('cache_read_tokens')::bigint,
|
||||
sqlc.narg('context_limit')::bigint,
|
||||
COALESCE(sqlc.narg('compressed')::boolean, FALSE)
|
||||
COALESCE(sqlc.narg('compressed')::boolean, FALSE),
|
||||
sqlc.narg('total_cost_micros')::bigint
|
||||
)
|
||||
RETURNING
|
||||
*;
|
||||
@@ -481,3 +483,164 @@ SET
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
chat_id = @chat_id::uuid;
|
||||
|
||||
-- name: GetChatCostSummary :one
|
||||
-- Aggregate cost summary for a single user within a date range.
|
||||
-- Only counts assistant-role messages.
|
||||
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 = @owner_id::uuid
|
||||
AND cm.role = 'assistant'
|
||||
AND cm.created_at >= @start_date::timestamptz
|
||||
AND cm.created_at < @end_date::timestamptz;
|
||||
|
||||
-- name: GetChatCostPerModel :many
|
||||
-- Per-model cost breakdown for a single user within a date range.
|
||||
-- Only counts assistant-role messages that have a model_config_id.
|
||||
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 = @owner_id::uuid
|
||||
AND cm.role = 'assistant'
|
||||
AND cm.created_at >= @start_date::timestamptz
|
||||
AND cm.created_at < @end_date::timestamptz
|
||||
GROUP BY
|
||||
cmc.id, cmc.display_name, cmc.provider, cmc.model
|
||||
ORDER BY
|
||||
total_cost_micros DESC;
|
||||
|
||||
-- name: GetChatCostPerChat :many
|
||||
-- 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.
|
||||
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 = @owner_id::uuid
|
||||
AND cm.role = 'assistant'
|
||||
AND cm.created_at >= @start_date::timestamptz
|
||||
AND cm.created_at < @end_date::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;
|
||||
|
||||
-- name: GetChatCostPerUser :many
|
||||
-- Deployment-wide per-user cost rollup within a date range.
|
||||
-- Only counts assistant-role messages.
|
||||
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 >= @start_date::timestamptz
|
||||
AND cm.created_at < @end_date::timestamptz
|
||||
AND (
|
||||
@username::text = ''
|
||||
OR u.username ILIKE '%' || @username::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
|
||||
sqlc.arg('page_limit')::int
|
||||
OFFSET
|
||||
sqlc.arg('page_offset')::int;
|
||||
|
||||
Reference in New Issue
Block a user