Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(coderd/database): count finished chat requests without token usage
A request that fails upstream still ends but records no token usage, so the
inner join dropped it from request_count. Left-join eligible usage instead,
and guard the unpriced check on the usage row existing so a request with no
usage is not reported as unpriced.
  • Loading branch information
ibetitsmike committed Jul 30, 2026
commit e225bccacfaebecd3bd0bf14c4e0dc920a583acc
8 changes: 6 additions & 2 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions coderd/database/queries/aibridge.sql
Original file line number Diff line number Diff line change
Expand Up @@ -675,12 +675,16 @@ WITH per_request AS (
-- One row per interception. A request records one token usage per provider
-- response, so aggregating here keeps the outer counts per request and
-- flags a request whose cost is partial because some usage was unpriced.
-- The usage join is a LEFT JOIN so a request that ended without eligible
-- usage, such as one that failed upstream, still counts as a request. The
-- tu.id guard keeps that row from reading as unpriced usage, since the
-- unmatched side is all NULL.
SELECT
SUM(tu.cost_micros) AS cost_micros,
BOOL_OR(tu.cost_micros IS NULL) AS has_unpriced_usage
BOOL_OR(tu.id IS NOT NULL AND tu.cost_micros IS NULL) AS has_unpriced_usage
FROM aibridge_interceptions i
JOIN chats c ON c.id::text = i.session_id AND c.owner_id = i.initiator_id
JOIN aibridge_token_usages tu ON tu.interception_id = i.id AND tu.effective_group_id IS NOT NULL
LEFT JOIN aibridge_token_usages tu ON tu.interception_id = i.id AND tu.effective_group_id IS NOT NULL
WHERE (
-- Spelled out instead of COALESCE(c.root_chat_id, c.id) so each branch
-- stays a plain comparison against an indexed column.
Expand Down
39 changes: 36 additions & 3 deletions coderd/exp_chats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11799,6 +11799,37 @@ func TestGetChatCost(t *testing.T) {
require.Equal(t, int64(0), cost.UnpricedRequestCount)
})

t.Run("RequestWithoutUsage", func(t *testing.T) {
t.Parallel()

client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}

chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "failed request chat",
})
seedChatGatewayRequest(t, db, firstUser.UserID, chat.ID, database.InsertAIBridgeTokenUsageParams{
EffectiveGroupID: everyoneGroup,
CostMicros: sql.NullInt64{Int64: 200, Valid: true},
})
// A request that fails upstream still ends, but records no usage. It
// counts as a request, adds no cost, and is not unpriced usage.
seedChatGatewayRequest(t, db, firstUser.UserID, chat.ID)

ctx := testutil.Context(t, testutil.WaitLong)

cost, err := client.GetChatCost(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, int64(200), cost.TotalCostMicros)
require.Equal(t, int64(2), cost.RequestCount)
require.Equal(t, int64(0), cost.UnpricedRequestCount)
})

t.Run("IsolatesSiblingChatTrees", func(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -11856,7 +11887,7 @@ func TestGetChatCost(t *testing.T) {
}
})

t.Run("ExcludesUnattributedUsage", func(t *testing.T) {
t.Run("ExcludesUnattributedUsageFromCost", func(t *testing.T) {
t.Parallel()

client, db := newChatClientWithDatabase(t)
Expand All @@ -11870,7 +11901,8 @@ func TestGetChatCost(t *testing.T) {
Title: "legacy chat",
})
// Usage recorded before group attribution existed never reached
// ai_user_daily_spend, so it must not appear as chat spend either.
// ai_user_daily_spend, so it must not appear as chat spend either. The
// request itself still finished, so it stays in the request count.
seedChatGatewayRequest(t, db, firstUser.UserID, chat.ID, database.InsertAIBridgeTokenUsageParams{
Comment on lines +11903 to +11906

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 💪

CostMicros: sql.NullInt64{Int64: 900, Valid: true},
})
Expand All @@ -11880,7 +11912,8 @@ func TestGetChatCost(t *testing.T) {
cost, err := client.GetChatCost(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, int64(0), cost.TotalCostMicros)
require.Equal(t, int64(0), cost.RequestCount)
require.Equal(t, int64(1), cost.RequestCount)
require.Equal(t, int64(0), cost.UnpricedRequestCount)
})

t.Run("ExcludesForeignAndUnfinishedRequests", func(t *testing.T) {
Expand Down
2 changes: 2 additions & 0 deletions codersdk/chats.go
Original file line number Diff line number Diff line change
Expand Up @@ -1992,6 +1992,8 @@ type ChatCostChatBreakdown struct {

// ChatCost is the AI Gateway cost for the requested chat's whole tree.
// Root and subagent chats report the same total.
// RequestCount counts every finished request in the tree, including ones that
// recorded no billable usage at all, such as a request that failed upstream.
// UnpricedRequestCount counts requests with at least one usage record whose
// model had no recorded price; RequestCount includes them and
// TotalCostMicros omits only their unpriced usage.
Expand Down
2 changes: 2 additions & 0 deletions site/src/api/typesGenerated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading