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:
+220
-2
@@ -11683,7 +11683,7 @@ func assertChatCostSummary(t *testing.T, summary codersdk.ChatCostSummary, model
|
||||
|
||||
require.Equal(t, int64(1000), summary.TotalCostMicros)
|
||||
require.Equal(t, int64(2), summary.PricedMessageCount)
|
||||
require.Equal(t, int64(0), summary.UnpricedMessageCount)
|
||||
require.Equal(t, int64(0), summary.UnpricedMessagesHavingUsageCount)
|
||||
require.Equal(t, int64(200), summary.TotalInputTokens)
|
||||
require.Equal(t, int64(100), summary.TotalOutputTokens)
|
||||
require.Equal(t, int64(4000), summary.TotalRuntimeMs)
|
||||
@@ -11792,6 +11792,224 @@ func TestChatCostSummary_AdminDrilldown(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetChatCost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("BasicCost", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := seedChatCostFixture(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
cost, err := f.Client.GetChatCost(ctx, f.ChatID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, f.ChatID, cost.ChatID)
|
||||
require.Equal(t, int64(1000), cost.TotalCostMicros)
|
||||
require.Equal(t, int64(2), cost.PricedMessageCount)
|
||||
require.Equal(t, int64(0), cost.UnpricedMessagesHavingUsageCount)
|
||||
})
|
||||
|
||||
t.Run("RollsUpSubtree", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
rootChat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
OwnerID: firstUser.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "root chat",
|
||||
})
|
||||
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
|
||||
ChatID: rootChat.ID,
|
||||
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
|
||||
Role: database.ChatMessageRoleAssistant,
|
||||
TotalCostMicros: sql.NullInt64{Int64: 500, Valid: true},
|
||||
})
|
||||
|
||||
childChat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
OwnerID: firstUser.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "child chat",
|
||||
ParentChatID: uuid.NullUUID{UUID: rootChat.ID, Valid: true},
|
||||
RootChatID: uuid.NullUUID{UUID: rootChat.ID, Valid: true},
|
||||
})
|
||||
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
|
||||
ChatID: childChat.ID,
|
||||
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
|
||||
Role: database.ChatMessageRoleAssistant,
|
||||
TotalCostMicros: sql.NullInt64{Int64: 250, Valid: true},
|
||||
})
|
||||
|
||||
// root_chat_id is flattened to the top-level root at any depth,
|
||||
// so subtree traversal must follow parent_chat_id instead.
|
||||
grandchildChat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
OwnerID: firstUser.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "grandchild chat",
|
||||
ParentChatID: uuid.NullUUID{UUID: childChat.ID, Valid: true},
|
||||
RootChatID: uuid.NullUUID{UUID: rootChat.ID, Valid: true},
|
||||
})
|
||||
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
|
||||
ChatID: grandchildChat.ID,
|
||||
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
|
||||
Role: database.ChatMessageRoleAssistant,
|
||||
TotalCostMicros: sql.NullInt64{Int64: 100, Valid: true},
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// The root rolls up every descendant's cost.
|
||||
rootCost, err := client.GetChatCost(ctx, rootChat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rootChat.ID, rootCost.ChatID)
|
||||
require.Equal(t, int64(850), rootCost.TotalCostMicros)
|
||||
require.Equal(t, int64(3), rootCost.PricedMessageCount)
|
||||
|
||||
// A subagent reports only its own subtree: itself plus the
|
||||
// nested subagents it spawned, excluding the parent's spend.
|
||||
childCost, err := client.GetChatCost(ctx, childChat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, childChat.ID, childCost.ChatID)
|
||||
require.Equal(t, int64(350), childCost.TotalCostMicros)
|
||||
require.Equal(t, int64(2), childCost.PricedMessageCount)
|
||||
|
||||
// A leaf subagent reports only its own spend.
|
||||
grandchildCost, err := client.GetChatCost(ctx, grandchildChat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, grandchildChat.ID, grandchildCost.ChatID)
|
||||
require.Equal(t, int64(100), grandchildCost.TotalCostMicros)
|
||||
require.Equal(t, int64(1), grandchildCost.PricedMessageCount)
|
||||
})
|
||||
|
||||
t.Run("UnpricedMessages", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
OwnerID: firstUser.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "unpriced chat",
|
||||
})
|
||||
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
|
||||
ChatID: chat.ID,
|
||||
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
|
||||
Role: database.ChatMessageRoleAssistant,
|
||||
TotalCostMicros: sql.NullInt64{Int64: 400, Valid: true},
|
||||
})
|
||||
// Token usage but no cost (no model pricing) counts as unpriced.
|
||||
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
|
||||
ChatID: chat.ID,
|
||||
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
|
||||
Role: database.ChatMessageRoleAssistant,
|
||||
InputTokens: sql.NullInt64{Int64: 100, Valid: true},
|
||||
OutputTokens: sql.NullInt64{Int64: 50, Valid: true},
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
cost, err := client.GetChatCost(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(400), cost.TotalCostMicros)
|
||||
require.Equal(t, int64(1), cost.PricedMessageCount)
|
||||
require.Equal(t, int64(1), cost.UnpricedMessagesHavingUsageCount)
|
||||
})
|
||||
|
||||
t.Run("MemberCannotReadOtherUsersChat", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
|
||||
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
OwnerID: firstUser.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "owner chat",
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
_, err := memberClient.GetChatCost(ctx, chat.ID)
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("ZeroMessages", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
// No assistant messages must still return one zero-total row; a COALESCE
|
||||
// or :one regression would surface as sql.ErrNoRows -> 500.
|
||||
chat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
OwnerID: firstUser.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "empty chat",
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
cost, err := client.GetChatCost(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, chat.ID, cost.ChatID)
|
||||
require.Equal(t, int64(0), cost.TotalCostMicros)
|
||||
require.Equal(t, int64(0), cost.PricedMessageCount)
|
||||
require.Equal(t, int64(0), cost.UnpricedMessagesHavingUsageCount)
|
||||
})
|
||||
|
||||
t.Run("ExcludesNonAssistantMessages", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, db := newChatClientWithDatabase(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
modelConfig := createChatModelConfig(t, client)
|
||||
|
||||
chat := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
OwnerID: firstUser.UserID,
|
||||
LastModelConfigID: modelConfig.ID,
|
||||
Title: "mixed-role chat",
|
||||
})
|
||||
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
|
||||
ChatID: chat.ID,
|
||||
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
|
||||
Role: database.ChatMessageRoleAssistant,
|
||||
TotalCostMicros: sql.NullInt64{Int64: 600, Valid: true},
|
||||
})
|
||||
// User-role cost must be excluded; the query bills only assistant messages.
|
||||
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
|
||||
ChatID: chat.ID,
|
||||
Role: database.ChatMessageRoleUser,
|
||||
TotalCostMicros: sql.NullInt64{Int64: 999, Valid: true},
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
cost, err := client.GetChatCost(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(600), cost.TotalCostMicros)
|
||||
require.Equal(t, int64(1), cost.PricedMessageCount)
|
||||
require.Equal(t, int64(0), cost.UnpricedMessagesHavingUsageCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatCostUsers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -11985,7 +12203,7 @@ func TestChatCostSummary_UnpricedMessages(t *testing.T) {
|
||||
|
||||
require.Equal(t, int64(500), summary.TotalCostMicros)
|
||||
require.Equal(t, int64(1), summary.PricedMessageCount)
|
||||
require.Equal(t, int64(1), summary.UnpricedMessageCount)
|
||||
require.Equal(t, int64(1), summary.UnpricedMessagesHavingUsageCount)
|
||||
require.Equal(t, int64(300), summary.TotalInputTokens)
|
||||
require.Equal(t, int64(125), summary.TotalOutputTokens)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user