mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add chat summary tab in the right sidebar and per-chat cost endpoint (#26649)
Stacked on #26657 (the persisted whole-chat summary backend). Base branch is `chat-summary-62j9`; review/merge that first. Adds a reusable `ChatSummary` component. The summary text is the persisted whole-chat summary (`chat.summary`) introduced by #26657. It is generated asynchronously and may be `null` until the first summary is produced, in which case the popover renders a muted empty state. Live updates arrive via that PR's `chat_summary_change` watch event, which is already merged into the chat caches. Cost is served by a new per-chat endpoint, `GET /api/experimental/chats/{chat}/cost`, which rolls up assistant-message cost across a chat's root and child (subagent) chats and is authorized like the other `{chat}` routes (read on the chat, 404 otherwise). Visual and interaction coverage lives in `ChatSummary.stories.tsx` and `ChatSummaryPopover.stories.tsx` (including populated-summary, empty-state, and cost-loading cases). --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3488,6 +3488,13 @@ func (q *querier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]databa
|
||||
return q.db.GetChatModelConfigsForTelemetry(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetChatModelUsageCostByChatID(ctx context.Context, chatID uuid.UUID) (database.GetChatModelUsageCostByChatIDRow, error) {
|
||||
if _, err := q.GetChatByID(ctx, chatID); err != nil {
|
||||
return database.GetChatModelUsageCostByChatIDRow{}, err
|
||||
}
|
||||
return q.db.GetChatModelUsageCostByChatID(ctx, chatID)
|
||||
}
|
||||
|
||||
func (q *querier) GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) {
|
||||
// The personal model overrides flag is a deployment-wide setting read by
|
||||
// authenticated chat users. We only require that an explicit actor is
|
||||
|
||||
@@ -938,11 +938,11 @@ func (s *MethodTestSuite) TestChats() {
|
||||
EndDate: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
row := database.GetChatCostSummaryRow{
|
||||
TotalCostMicros: 987,
|
||||
PricedMessageCount: 12,
|
||||
UnpricedMessageCount: 2,
|
||||
TotalInputTokens: 400,
|
||||
TotalOutputTokens: 800,
|
||||
TotalCostMicros: 987,
|
||||
PricedMessageCount: 12,
|
||||
UnpricedMessagesHavingUsageCount: 2,
|
||||
TotalInputTokens: 400,
|
||||
TotalOutputTokens: 800,
|
||||
}
|
||||
dbm.EXPECT().GetChatCostSummary(gomock.Any(), arg).Return(row, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(rbac.ResourceChat.WithOwner(arg.OwnerID.String()).AnyOrganization(), policy.ActionRead).Returns(row)
|
||||
@@ -1069,6 +1069,13 @@ func (s *MethodTestSuite) TestChats() {
|
||||
dbm.EXPECT().GetChatMessagesByChatID(gomock.Any(), arg).Return(msgs, nil).AnyTimes()
|
||||
check.Args(arg).Asserts(chat, policy.ActionRead).Returns(msgs)
|
||||
}))
|
||||
s.Run("GetChatModelUsageCostByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
chat := testutil.Fake(s.T(), faker, database.Chat{})
|
||||
row := database.GetChatModelUsageCostByChatIDRow{ChatID: chat.ID, TotalCostMicros: 1000, PricedMessageCount: 2}
|
||||
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
|
||||
dbm.EXPECT().GetChatModelUsageCostByChatID(gomock.Any(), chat.ID).Return(row, nil).AnyTimes()
|
||||
check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(row)
|
||||
}))
|
||||
s.Run("GetChatMessagesByChatIDAscPaginated", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
chat := testutil.Fake(s.T(), faker, database.Chat{})
|
||||
msgs := []database.ChatMessage{testutil.Fake(s.T(), faker, database.ChatMessage{ChatID: chat.ID})}
|
||||
|
||||
+8
@@ -1713,6 +1713,14 @@ func (m queryMetricsStore) GetChatModelConfigsForTelemetry(ctx context.Context)
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetChatModelUsageCostByChatID(ctx context.Context, chatID uuid.UUID) (database.GetChatModelUsageCostByChatIDRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetChatModelUsageCostByChatID(ctx, chatID)
|
||||
m.queryLatencies.WithLabelValues("GetChatModelUsageCostByChatID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatModelUsageCostByChatID").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetChatPersonalModelOverridesEnabled(ctx)
|
||||
|
||||
Generated
+15
@@ -3163,6 +3163,21 @@ func (mr *MockStoreMockRecorder) GetChatModelConfigsForTelemetry(ctx any) *gomoc
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelConfigsForTelemetry", reflect.TypeOf((*MockStore)(nil).GetChatModelConfigsForTelemetry), ctx)
|
||||
}
|
||||
|
||||
// GetChatModelUsageCostByChatID mocks base method.
|
||||
func (m *MockStore) GetChatModelUsageCostByChatID(ctx context.Context, chatID uuid.UUID) (database.GetChatModelUsageCostByChatIDRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetChatModelUsageCostByChatID", ctx, chatID)
|
||||
ret0, _ := ret[0].(database.GetChatModelUsageCostByChatIDRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetChatModelUsageCostByChatID indicates an expected call of GetChatModelUsageCostByChatID.
|
||||
func (mr *MockStoreMockRecorder) GetChatModelUsageCostByChatID(ctx, chatID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatModelUsageCostByChatID", reflect.TypeOf((*MockStore)(nil).GetChatModelUsageCostByChatID), ctx, chatID)
|
||||
}
|
||||
|
||||
// GetChatPersonalModelOverridesEnabled mocks base method.
|
||||
func (m *MockStore) GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+5
@@ -456,6 +456,11 @@ type sqlcQuerier interface {
|
||||
// Returns all model configurations for telemetry snapshot collection.
|
||||
// deleted = false guarantees ai_provider_id is non-null, so INNER JOIN is safe.
|
||||
GetChatModelConfigsForTelemetry(ctx context.Context) ([]GetChatModelConfigsForTelemetryRow, error)
|
||||
// Assistant-message cost rolled up over the requested chat's subtree: the
|
||||
// chat itself plus every descendant reachable through parent_chat_id. A
|
||||
// root chat therefore reports its whole tree, while a subagent chat
|
||||
// reports only its own spend plus any nested subagents it spawned.
|
||||
GetChatModelUsageCostByChatID(ctx context.Context, chatID uuid.UUID) (GetChatModelUsageCostByChatIDRow, error)
|
||||
// GetChatPersonalModelOverridesEnabled returns whether users may configure
|
||||
// personal chat model overrides. It defaults to false when unset.
|
||||
GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error)
|
||||
|
||||
Generated
+71
-10
@@ -7557,7 +7557,7 @@ SELECT
|
||||
OR cm.cache_creation_tokens IS NOT NULL
|
||||
OR cm.cache_read_tokens IS NOT NULL
|
||||
)
|
||||
)::bigint AS unpriced_message_count,
|
||||
)::bigint AS unpriced_messages_having_usage_count,
|
||||
COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
|
||||
COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens,
|
||||
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
|
||||
@@ -7581,14 +7581,14 @@ type GetChatCostSummaryParams struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
TotalCacheReadTokens int64 `db:"total_cache_read_tokens" json:"total_cache_read_tokens"`
|
||||
TotalCacheCreationTokens int64 `db:"total_cache_creation_tokens" json:"total_cache_creation_tokens"`
|
||||
TotalRuntimeMs int64 `db:"total_runtime_ms" json:"total_runtime_ms"`
|
||||
TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"`
|
||||
PricedMessageCount int64 `db:"priced_message_count" json:"priced_message_count"`
|
||||
UnpricedMessagesHavingUsageCount int64 `db:"unpriced_messages_having_usage_count" json:"unpriced_messages_having_usage_count"`
|
||||
TotalInputTokens int64 `db:"total_input_tokens" json:"total_input_tokens"`
|
||||
TotalOutputTokens int64 `db:"total_output_tokens" json:"total_output_tokens"`
|
||||
TotalCacheReadTokens int64 `db:"total_cache_read_tokens" json:"total_cache_read_tokens"`
|
||||
TotalCacheCreationTokens int64 `db:"total_cache_creation_tokens" json:"total_cache_creation_tokens"`
|
||||
TotalRuntimeMs int64 `db:"total_runtime_ms" json:"total_runtime_ms"`
|
||||
}
|
||||
|
||||
// Aggregate cost summary for a single user within a date range.
|
||||
@@ -7599,7 +7599,7 @@ func (q *sqlQuerier) GetChatCostSummary(ctx context.Context, arg GetChatCostSumm
|
||||
err := row.Scan(
|
||||
&i.TotalCostMicros,
|
||||
&i.PricedMessageCount,
|
||||
&i.UnpricedMessageCount,
|
||||
&i.UnpricedMessagesHavingUsageCount,
|
||||
&i.TotalInputTokens,
|
||||
&i.TotalOutputTokens,
|
||||
&i.TotalCacheReadTokens,
|
||||
@@ -8363,6 +8363,67 @@ func (q *sqlQuerier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]Get
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getChatModelUsageCostByChatID = `-- name: GetChatModelUsageCostByChatID :one
|
||||
WITH RECURSIVE target AS (
|
||||
SELECT $1::uuid AS chat_id
|
||||
), subtree AS (
|
||||
SELECT chat_id AS id FROM target
|
||||
UNION ALL
|
||||
SELECT c.id
|
||||
FROM chats c
|
||||
JOIN subtree s ON c.parent_chat_id = s.id
|
||||
), costs AS (
|
||||
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_messages_having_usage_count
|
||||
FROM chat_messages cm
|
||||
JOIN subtree s ON s.id = cm.chat_id
|
||||
WHERE cm.role = 'assistant'
|
||||
)
|
||||
SELECT
|
||||
t.chat_id,
|
||||
costs.total_cost_micros,
|
||||
costs.priced_message_count,
|
||||
costs.unpriced_messages_having_usage_count
|
||||
FROM target t
|
||||
CROSS JOIN costs
|
||||
`
|
||||
|
||||
type GetChatModelUsageCostByChatIDRow struct {
|
||||
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
|
||||
TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"`
|
||||
PricedMessageCount int64 `db:"priced_message_count" json:"priced_message_count"`
|
||||
UnpricedMessagesHavingUsageCount int64 `db:"unpriced_messages_having_usage_count" json:"unpriced_messages_having_usage_count"`
|
||||
}
|
||||
|
||||
// Assistant-message cost rolled up over the requested chat's subtree: the
|
||||
// chat itself plus every descendant reachable through parent_chat_id. A
|
||||
// root chat therefore reports its whole tree, while a subagent chat
|
||||
// reports only its own spend plus any nested subagents it spawned.
|
||||
func (q *sqlQuerier) GetChatModelUsageCostByChatID(ctx context.Context, chatID uuid.UUID) (GetChatModelUsageCostByChatIDRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getChatModelUsageCostByChatID, chatID)
|
||||
var i GetChatModelUsageCostByChatIDRow
|
||||
err := row.Scan(
|
||||
&i.ChatID,
|
||||
&i.TotalCostMicros,
|
||||
&i.PricedMessageCount,
|
||||
&i.UnpricedMessagesHavingUsageCount,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getChatQueuedMessageByID = `-- name: GetChatQueuedMessageByID :one
|
||||
SELECT id, chat_id, content, created_at, model_config_id, position, created_by, reasoning_effort FROM chat_queued_messages
|
||||
WHERE id = $1::bigint AND chat_id = $2::uuid
|
||||
|
||||
@@ -2198,7 +2198,7 @@ SELECT
|
||||
OR cm.cache_creation_tokens IS NOT NULL
|
||||
OR cm.cache_read_tokens IS NOT NULL
|
||||
)
|
||||
)::bigint AS unpriced_message_count,
|
||||
)::bigint AS unpriced_messages_having_usage_count,
|
||||
COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
|
||||
COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens,
|
||||
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
|
||||
@@ -2295,6 +2295,47 @@ FROM chat_costs cc
|
||||
LEFT JOIN chats rc ON rc.id = cc.root_chat_id
|
||||
ORDER BY cc.total_cost_micros DESC;
|
||||
|
||||
-- name: GetChatModelUsageCostByChatID :one
|
||||
-- Assistant-message cost rolled up over the requested chat's subtree: the
|
||||
-- chat itself plus every descendant reachable through parent_chat_id. A
|
||||
-- root chat therefore reports its whole tree, while a subagent chat
|
||||
-- reports only its own spend plus any nested subagents it spawned.
|
||||
WITH RECURSIVE target AS (
|
||||
SELECT @chat_id::uuid AS chat_id
|
||||
), subtree AS (
|
||||
SELECT chat_id AS id FROM target
|
||||
UNION ALL
|
||||
SELECT c.id
|
||||
FROM chats c
|
||||
JOIN subtree s ON c.parent_chat_id = s.id
|
||||
), costs AS (
|
||||
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_messages_having_usage_count
|
||||
FROM chat_messages cm
|
||||
JOIN subtree s ON s.id = cm.chat_id
|
||||
WHERE cm.role = 'assistant'
|
||||
)
|
||||
SELECT
|
||||
t.chat_id,
|
||||
costs.total_cost_micros,
|
||||
costs.priced_message_count,
|
||||
costs.unpriced_messages_having_usage_count
|
||||
FROM target t
|
||||
CROSS JOIN costs;
|
||||
|
||||
-- name: GetChatCostPerUser :many
|
||||
-- Deployment-wide per-user cost rollup within a date range.
|
||||
-- Only counts assistant-role messages.
|
||||
|
||||
Reference in New Issue
Block a user