feat: remove native chat cost tracking in favor of AI Gateway cost data (#27330)

## Stack Context

This stack makes AI Gateway data and budgets the source of truth for AI
spend controls.

1. Re-back the per-chat cost endpoint with AI Gateway data (#27328,
merged).
2. Remove native chat usage limits (#27329, merged).
3. **This PR, now based on `main`:** remove native chat cost tracking
and its dedicated admin UI.

## Summary

Removes native per-message price calculation, model pricing fields, cost
persistence, aggregate cost queries, and admin cost API types. It also
deletes the Analytics and Spend pages plus their legacy redirects. The
AI Gateway-backed per-chat cost row and compact budget indicators
remain.

The spend documentation is renamed to `spend-management.md` and updated
for the remaining surfaces, group budget APIs, CSV export, upgrade
handling for native pricing and cost history, and the absence of a
deployment-wide spend dashboard. The per-chat cost API documents that
data follows AI Gateway retention and reports zero after all matching
requests are purged.

No schema is dropped in this release. `chat_messages.total_cost_micros`
remains nullable and unwritten so replicas from the previous release can
continue inserting messages during rolling upgrades. #27600 tracks
removal after the compatibility window.

> Mux prepared this PR on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-08-04 12:27:38 +02:00
committed by GitHub
parent 0b8b48913f
commit 6b8f820493
72 changed files with 159 additions and 5303 deletions
-7
View File
@@ -1349,13 +1349,6 @@ func New(options *Options) *API {
r.Post("/", api.postChats)
r.Get("/models", api.listChatModels)
r.Get("/watch", api.watchChats)
r.Route("/cost", func(r chi.Router) {
r.Get("/users", api.chatCostUsers)
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Get("/summary", api.chatCostSummary)
})
})
r.Route("/files", func(r chi.Router) {
r.Use(httpmw.RateLimit(options.FilesRateLimit, time.Minute))
r.Post("/", api.postChatFile)
-49
View File
@@ -1992,13 +1992,6 @@ func (q *querier) CountConnectionLogs(ctx context.Context, arg database.CountCon
return q.db.CountAuthorizedConnectionLogs(ctx, arg, prep)
}
func (q *querier) CountEnabledModelsWithoutPricing(ctx context.Context) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
return 0, err
}
return q.db.CountEnabledModelsWithoutPricing(ctx)
}
func (q *querier) CountInProgressPrebuilds(ctx context.Context) ([]database.CountInProgressPrebuildsRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspace.All()); err != nil {
return nil, err
@@ -3136,41 +3129,6 @@ func (q *querier) GetChatComputerUseProvider(ctx context.Context) (string, error
return q.db.GetChatComputerUseProvider(ctx)
}
func (q *querier) GetChatCostPerChat(ctx context.Context, arg database.GetChatCostPerChatParams) ([]database.GetChatCostPerChatRow, error) {
// The owner's chats, may cross orgs. AnyOrganization() authorizes
// the caller if they hold read permission on chats owned by
// arg.OwnerID in any org they belong to.
// TODO(CODAGT-161): the underlying SQL queries filter only by owner_id, not
// organization_id.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithOwner(arg.OwnerID.String()).AnyOrganization()); err != nil {
return nil, err
}
return q.db.GetChatCostPerChat(ctx, arg)
}
func (q *querier) GetChatCostPerModel(ctx context.Context, arg database.GetChatCostPerModelParams) ([]database.GetChatCostPerModelRow, error) {
// See GetChatCostPerChat for the authorization rationale.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithOwner(arg.OwnerID.String()).AnyOrganization()); err != nil {
return nil, err
}
return q.db.GetChatCostPerModel(ctx, arg)
}
func (q *querier) GetChatCostPerUser(ctx context.Context, arg database.GetChatCostPerUserParams) ([]database.GetChatCostPerUserRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat); err != nil {
return nil, err
}
return q.db.GetChatCostPerUser(ctx, arg)
}
func (q *querier) GetChatCostSummary(ctx context.Context, arg database.GetChatCostSummaryParams) (database.GetChatCostSummaryRow, error) {
// See GetChatCostPerChat for the authorization rationale.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithOwner(arg.OwnerID.String()).AnyOrganization()); err != nil {
return database.GetChatCostSummaryRow{}, err
}
return q.db.GetChatCostSummary(ctx, arg)
}
func (q *querier) GetChatDebugLoggingAllowUsers(ctx context.Context) (bool, error) {
// The allow-users flag is a deployment-wide setting read by any
// authenticated chat user. We only require that an explicit actor
@@ -3508,13 +3466,6 @@ 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
-86
View File
@@ -872,85 +872,6 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().SoftDeleteContextFileMessages(gomock.Any(), chat.ID).Return(nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns()
}))
s.Run("GetChatCostPerChat", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.GetChatCostPerChatParams{
OwnerID: uuid.New(),
StartDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
EndDate: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC),
}
rows := []database.GetChatCostPerChatRow{{
RootChatID: uuid.New(),
ChatTitle: "chat-cost",
TotalCostMicros: 123,
MessageCount: 4,
TotalInputTokens: 55,
TotalOutputTokens: 89,
}}
dbm.EXPECT().GetChatCostPerChat(gomock.Any(), arg).Return(rows, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChat.WithOwner(arg.OwnerID.String()).AnyOrganization(), policy.ActionRead).Returns(rows)
}))
s.Run("GetChatCostPerModel", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.GetChatCostPerModelParams{
OwnerID: uuid.New(),
StartDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
EndDate: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC),
}
rows := []database.GetChatCostPerModelRow{{
ModelConfigID: uuid.New(),
DisplayName: "GPT 4.1",
Provider: "openai",
Model: "gpt-4.1",
TotalCostMicros: 456,
MessageCount: 7,
TotalInputTokens: 144,
TotalOutputTokens: 233,
}}
dbm.EXPECT().GetChatCostPerModel(gomock.Any(), arg).Return(rows, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChat.WithOwner(arg.OwnerID.String()).AnyOrganization(), policy.ActionRead).Returns(rows)
}))
s.Run("GetChatCostPerUser", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.GetChatCostPerUserParams{
PageOffset: 0,
PageLimit: 25,
StartDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
EndDate: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC),
Username: "cost-user",
}
rows := []database.GetChatCostPerUserRow{{
UserID: uuid.New(),
Username: "cost-user",
Name: "Cost User",
AvatarURL: "https://example.com/avatar.png",
TotalCostMicros: 789,
MessageCount: 11,
ChatCount: 3,
TotalInputTokens: 377,
TotalOutputTokens: 610,
TotalCount: 1,
}}
dbm.EXPECT().GetChatCostPerUser(gomock.Any(), arg).Return(rows, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionRead).Returns(rows)
}))
s.Run("GetChatCostSummary", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.GetChatCostSummaryParams{
OwnerID: uuid.New(),
StartDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
EndDate: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC),
}
row := database.GetChatCostSummaryRow{
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)
}))
s.Run("CountEnabledModelsWithoutPricing", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().CountEnabledModelsWithoutPricing(gomock.Any()).Return(int64(3), nil).AnyTimes()
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(int64(3))
}))
s.Run("GetChatDiffStatusByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
diffStatus := testutil.Fake(s.T(), faker, database.ChatDiffStatus{ChatID: chat.ID})
@@ -1076,13 +997,6 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().GetAIBridgeChatCost(gomock.Any(), chat.ID).Return(row, nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionRead).Returns(row)
}))
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})}
-1
View File
@@ -141,7 +141,6 @@ func ChatMessage(t testing.TB, db database.Store, seed database.ChatMessage) dat
CacheReadTokens: []int64{seed.CacheReadTokens.Int64},
ContextLimit: []int64{seed.ContextLimit.Int64},
Compressed: []bool{seed.Compressed},
TotalCostMicros: []int64{seed.TotalCostMicros.Int64},
RuntimeMs: []int64{seed.RuntimeMs.Int64},
})
require.NoError(t, err, "insert chat message")
-2
View File
@@ -397,7 +397,6 @@ func TestGenerator(t *testing.T) {
CacheReadTokens: sql.NullInt64{Int64: 66, Valid: true},
ContextLimit: sql.NullInt64{Int64: 77, Valid: true},
Compressed: true,
TotalCostMicros: sql.NullInt64{Int64: 88, Valid: true},
})
require.Equal(t, database.ChatMessageRoleAssistant, msg2.Role)
require.True(t, msg2.Content.Valid)
@@ -410,7 +409,6 @@ func TestGenerator(t *testing.T) {
require.Equal(t, sql.NullInt64{Int64: 66, Valid: true}, msg2.CacheReadTokens)
require.Equal(t, sql.NullInt64{Int64: 77, Valid: true}, msg2.ContextLimit)
require.True(t, msg2.Compressed)
require.Equal(t, sql.NullInt64{Int64: 88, Valid: true}, msg2.TotalCostMicros)
})
t.Run("MCPServerConfig", func(t *testing.T) {
-48
View File
@@ -345,14 +345,6 @@ func (m queryMetricsStore) CountConnectionLogs(ctx context.Context, arg database
return r0, r1
}
func (m queryMetricsStore) CountEnabledModelsWithoutPricing(ctx context.Context) (int64, error) {
start := time.Now()
r0, r1 := m.s.CountEnabledModelsWithoutPricing(ctx)
m.queryLatencies.WithLabelValues("CountEnabledModelsWithoutPricing").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CountEnabledModelsWithoutPricing").Inc()
return r0, r1
}
func (m queryMetricsStore) CountInProgressPrebuilds(ctx context.Context) ([]database.CountInProgressPrebuildsRow, error) {
start := time.Now()
r0, r1 := m.s.CountInProgressPrebuilds(ctx)
@@ -1465,38 +1457,6 @@ func (m queryMetricsStore) GetChatComputerUseProvider(ctx context.Context) (stri
return r0, r1
}
func (m queryMetricsStore) GetChatCostPerChat(ctx context.Context, arg database.GetChatCostPerChatParams) ([]database.GetChatCostPerChatRow, error) {
start := time.Now()
r0, r1 := m.s.GetChatCostPerChat(ctx, arg)
m.queryLatencies.WithLabelValues("GetChatCostPerChat").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatCostPerChat").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatCostPerModel(ctx context.Context, arg database.GetChatCostPerModelParams) ([]database.GetChatCostPerModelRow, error) {
start := time.Now()
r0, r1 := m.s.GetChatCostPerModel(ctx, arg)
m.queryLatencies.WithLabelValues("GetChatCostPerModel").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatCostPerModel").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatCostPerUser(ctx context.Context, arg database.GetChatCostPerUserParams) ([]database.GetChatCostPerUserRow, error) {
start := time.Now()
r0, r1 := m.s.GetChatCostPerUser(ctx, arg)
m.queryLatencies.WithLabelValues("GetChatCostPerUser").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatCostPerUser").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatCostSummary(ctx context.Context, arg database.GetChatCostSummaryParams) (database.GetChatCostSummaryRow, error) {
start := time.Now()
r0, r1 := m.s.GetChatCostSummary(ctx, arg)
m.queryLatencies.WithLabelValues("GetChatCostSummary").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatCostSummary").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatDebugLoggingAllowUsers(ctx context.Context) (bool, error) {
start := time.Now()
r0, r1 := m.s.GetChatDebugLoggingAllowUsers(ctx)
@@ -1729,14 +1689,6 @@ 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)
-90
View File
@@ -528,21 +528,6 @@ func (mr *MockStoreMockRecorder) CountConnectionLogs(ctx, arg any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountConnectionLogs", reflect.TypeOf((*MockStore)(nil).CountConnectionLogs), ctx, arg)
}
// CountEnabledModelsWithoutPricing mocks base method.
func (m *MockStore) CountEnabledModelsWithoutPricing(ctx context.Context) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountEnabledModelsWithoutPricing", ctx)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountEnabledModelsWithoutPricing indicates an expected call of CountEnabledModelsWithoutPricing.
func (mr *MockStoreMockRecorder) CountEnabledModelsWithoutPricing(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountEnabledModelsWithoutPricing", reflect.TypeOf((*MockStore)(nil).CountEnabledModelsWithoutPricing), ctx)
}
// CountInProgressPrebuilds mocks base method.
func (m *MockStore) CountInProgressPrebuilds(ctx context.Context) ([]database.CountInProgressPrebuildsRow, error) {
m.ctrl.T.Helper()
@@ -2700,66 +2685,6 @@ func (mr *MockStoreMockRecorder) GetChatComputerUseProvider(ctx any) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatComputerUseProvider", reflect.TypeOf((*MockStore)(nil).GetChatComputerUseProvider), ctx)
}
// GetChatCostPerChat mocks base method.
func (m *MockStore) GetChatCostPerChat(ctx context.Context, arg database.GetChatCostPerChatParams) ([]database.GetChatCostPerChatRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatCostPerChat", ctx, arg)
ret0, _ := ret[0].([]database.GetChatCostPerChatRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatCostPerChat indicates an expected call of GetChatCostPerChat.
func (mr *MockStoreMockRecorder) GetChatCostPerChat(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatCostPerChat", reflect.TypeOf((*MockStore)(nil).GetChatCostPerChat), ctx, arg)
}
// GetChatCostPerModel mocks base method.
func (m *MockStore) GetChatCostPerModel(ctx context.Context, arg database.GetChatCostPerModelParams) ([]database.GetChatCostPerModelRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatCostPerModel", ctx, arg)
ret0, _ := ret[0].([]database.GetChatCostPerModelRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatCostPerModel indicates an expected call of GetChatCostPerModel.
func (mr *MockStoreMockRecorder) GetChatCostPerModel(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatCostPerModel", reflect.TypeOf((*MockStore)(nil).GetChatCostPerModel), ctx, arg)
}
// GetChatCostPerUser mocks base method.
func (m *MockStore) GetChatCostPerUser(ctx context.Context, arg database.GetChatCostPerUserParams) ([]database.GetChatCostPerUserRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatCostPerUser", ctx, arg)
ret0, _ := ret[0].([]database.GetChatCostPerUserRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatCostPerUser indicates an expected call of GetChatCostPerUser.
func (mr *MockStoreMockRecorder) GetChatCostPerUser(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatCostPerUser", reflect.TypeOf((*MockStore)(nil).GetChatCostPerUser), ctx, arg)
}
// GetChatCostSummary mocks base method.
func (m *MockStore) GetChatCostSummary(ctx context.Context, arg database.GetChatCostSummaryParams) (database.GetChatCostSummaryRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatCostSummary", ctx, arg)
ret0, _ := ret[0].(database.GetChatCostSummaryRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatCostSummary indicates an expected call of GetChatCostSummary.
func (mr *MockStoreMockRecorder) GetChatCostSummary(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatCostSummary", reflect.TypeOf((*MockStore)(nil).GetChatCostSummary), ctx, arg)
}
// GetChatDebugLoggingAllowUsers mocks base method.
func (m *MockStore) GetChatDebugLoggingAllowUsers(ctx context.Context) (bool, error) {
m.ctrl.T.Helper()
@@ -3195,21 +3120,6 @@ 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()
-21
View File
@@ -100,9 +100,6 @@ type sqlcQuerier interface {
// whether the chat is in a "1" sub-state.
CountChatQueuedMessages(ctx context.Context, chatID uuid.UUID) (int64, error)
CountConnectionLogs(ctx context.Context, arg CountConnectionLogsParams) (int64, error)
// Counts enabled, non-deleted model configs that lack both input and
// output pricing in their JSONB options.cost configuration.
CountEnabledModelsWithoutPricing(ctx context.Context) (int64, error)
// CountInProgressPrebuilds returns the number of in-progress prebuilds, grouped by preset ID and transition.
// Prebuild considered in-progress if it's in the "pending", "starting", "stopping", or "deleting" state.
CountInProgressPrebuilds(ctx context.Context) ([]CountInProgressPrebuildsRow, error)
@@ -419,19 +416,6 @@ type sqlcQuerier interface {
GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Chat, error)
GetChatCompactionModelOverride(ctx context.Context) (string, error)
GetChatComputerUseProvider(ctx context.Context) (string, error)
// 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.
GetChatCostPerChat(ctx context.Context, arg GetChatCostPerChatParams) ([]GetChatCostPerChatRow, error)
// Per-model cost breakdown for a single user within a date range.
// Only counts assistant-role messages that have a model_config_id.
GetChatCostPerModel(ctx context.Context, arg GetChatCostPerModelParams) ([]GetChatCostPerModelRow, error)
// Deployment-wide per-user cost rollup within a date range.
// Only counts assistant-role messages.
GetChatCostPerUser(ctx context.Context, arg GetChatCostPerUserParams) ([]GetChatCostPerUserRow, error)
// Aggregate cost summary for a single user within a date range.
// Only counts assistant-role messages.
GetChatCostSummary(ctx context.Context, arg GetChatCostSummaryParams) (GetChatCostSummaryRow, error)
// GetChatDebugLoggingAllowUsers returns the runtime admin setting that
// allows users to opt into chat debug logging when the deployment does
// not already force debug logging on globally.
@@ -497,11 +481,6 @@ 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)
-13
View File
@@ -12276,7 +12276,6 @@ func TestInsertChatMessages(t *testing.T) {
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
@@ -12337,7 +12336,6 @@ func TestInsertChatMessages(t *testing.T) {
CacheReadTokens: []int64{0, 0, 0},
ContextLimit: []int64{0, 0, 0},
Compressed: []bool{false, false, false},
TotalCostMicros: []int64{0, 100, 0},
RuntimeMs: []int64{0, 500, 0},
})
require.NoError(t, err)
@@ -12367,10 +12365,6 @@ func TestInsertChatMessages(t *testing.T) {
require.Equal(t, int64(20), msgs[1].OutputTokens.Int64)
// Verify cost: assistant has cost, others NULL.
require.True(t, msgs[1].TotalCostMicros.Valid)
require.Equal(t, int64(100), msgs[1].TotalCostMicros.Int64)
require.False(t, msgs[0].TotalCostMicros.Valid)
require.False(t, msgs[2].TotalCostMicros.Valid)
// Verify runtime_ms on assistant message.
require.True(t, msgs[1].RuntimeMs.Valid)
@@ -12414,7 +12408,6 @@ func insertChatMessagesInvertedTimestamps(t *testing.T, db database.Store, sqlDB
CacheReadTokens: make([]int64, count),
ContextLimit: make([]int64, count),
Compressed: make([]bool, count),
TotalCostMicros: make([]int64, count),
RuntimeMs: make([]int64, count),
})
require.NoError(t, err)
@@ -12586,7 +12579,6 @@ func TestGetChatMessagesForPromptByChatID(t *testing.T) {
CacheCreationTokens: []int64{0},
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
@@ -15610,7 +15602,6 @@ func TestUpdateChatLastTurnSummary(t *testing.T) {
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
@@ -15732,7 +15723,6 @@ func TestUpdateChatSummary(t *testing.T) {
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
@@ -17511,7 +17501,6 @@ func TestGetChatsFilter(t *testing.T) {
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
@@ -17753,7 +17742,6 @@ func TestGetChatsSearch(t *testing.T) {
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
@@ -17986,7 +17974,6 @@ func TestChatHasUnread(t *testing.T) {
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
+1 -485
View File
@@ -6992,30 +6992,6 @@ func (q *sqlQuerier) CountChatQueuedMessages(ctx context.Context, chatID uuid.UU
return count, err
}
const countEnabledModelsWithoutPricing = `-- name: CountEnabledModelsWithoutPricing :one
SELECT COUNT(*)::bigint AS count
FROM chat_model_configs
WHERE enabled = TRUE
AND deleted = FALSE
AND (
options->'cost' IS NULL
OR options->'cost' = 'null'::jsonb
OR (
(options->'cost'->>'input_price_per_million_tokens' IS NULL)
AND (options->'cost'->>'output_price_per_million_tokens' IS NULL)
)
)
`
// Counts enabled, non-deleted model configs that lack both input and
// output pricing in their JSONB options.cost configuration.
func (q *sqlQuerier) CountEnabledModelsWithoutPricing(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countEnabledModelsWithoutPricing)
var count int64
err := row.Scan(&count)
return count, err
}
const deleteAllChatHeartbeats = `-- name: DeleteAllChatHeartbeats :exec
DELETE FROM chat_heartbeats WHERE chat_id = $1::uuid
`
@@ -7705,398 +7681,6 @@ 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,
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
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,
cc.total_cache_read_tokens,
cc.total_cache_creation_tokens,
cc.total_runtime_ms
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"`
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"`
}
// 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,
&i.TotalCacheReadTokens,
&i.TotalCacheCreationTokens,
&i.TotalRuntimeMs,
); 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,
COALESCE(ap.type::text, '')::text AS 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,
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
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
LEFT JOIN
ai_providers ap ON ap.id = cmc.ai_provider_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, ap.type, 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"`
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"`
}
// 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,
&i.TotalCacheReadTokens,
&i.TotalCacheCreationTokens,
&i.TotalRuntimeMs,
); 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,
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
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 || '%'
OR u.name 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,
total_cache_read_tokens,
total_cache_creation_tokens,
total_runtime_ms,
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"`
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"`
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.TotalCacheReadTokens,
&i.TotalCacheCreationTokens,
&i.TotalRuntimeMs,
&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_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,
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
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"`
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.
// 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.UnpricedMessagesHavingUsageCount,
&i.TotalInputTokens,
&i.TotalOutputTokens,
&i.TotalCacheReadTokens,
&i.TotalCacheCreationTokens,
&i.TotalRuntimeMs,
)
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, author_login, author_avatar_url, base_branch, pr_number, commits, approved, reviewer_count, head_branch
@@ -8340,7 +7924,6 @@ SELECT
COALESCE(SUM(cm.reasoning_tokens), 0)::bigint AS total_reasoning_tokens,
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms,
COUNT(DISTINCT cm.model_config_id)::bigint AS distinct_model_count,
COUNT(*) FILTER (WHERE cm.compressed)::bigint AS compressed_message_count
@@ -8362,7 +7945,6 @@ type GetChatMessageSummariesPerChatRow struct {
TotalReasoningTokens int64 `db:"total_reasoning_tokens" json:"total_reasoning_tokens"`
TotalCacheCreationTokens int64 `db:"total_cache_creation_tokens" json:"total_cache_creation_tokens"`
TotalCacheReadTokens int64 `db:"total_cache_read_tokens" json:"total_cache_read_tokens"`
TotalCostMicros int64 `db:"total_cost_micros" json:"total_cost_micros"`
TotalRuntimeMs int64 `db:"total_runtime_ms" json:"total_runtime_ms"`
DistinctModelCount int64 `db:"distinct_model_count" json:"distinct_model_count"`
CompressedMessageCount int64 `db:"compressed_message_count" json:"compressed_message_count"`
@@ -8392,7 +7974,6 @@ func (q *sqlQuerier) GetChatMessageSummariesPerChat(ctx context.Context, created
&i.TotalReasoningTokens,
&i.TotalCacheCreationTokens,
&i.TotalCacheReadTokens,
&i.TotalCostMicros,
&i.TotalRuntimeMs,
&i.DistinctModelCount,
&i.CompressedMessageCount,
@@ -8855,67 +8436,6 @@ 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
@@ -10645,7 +10165,6 @@ inserted AS (
cache_read_tokens,
context_limit,
compressed,
total_cost_micros,
runtime_ms
)
SELECT
@@ -10666,8 +10185,7 @@ inserted AS (
NULLIF(($14::bigint[])[allocated.ord], 0),
NULLIF(($15::bigint[])[allocated.ord], 0),
($16::boolean[])[allocated.ord],
NULLIF(($17::bigint[])[allocated.ord], 0),
NULLIF(($18::bigint[])[allocated.ord], 0)
NULLIF(($17::bigint[])[allocated.ord], 0)
FROM allocated
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, total_cost_micros, runtime_ms, deleted, provider_response_id, revision, reasoning_effort, search_tsv
)
@@ -10693,7 +10211,6 @@ type InsertChatMessagesParams struct {
CacheReadTokens []int64 `db:"cache_read_tokens" json:"cache_read_tokens"`
ContextLimit []int64 `db:"context_limit" json:"context_limit"`
Compressed []bool `db:"compressed" json:"compressed"`
TotalCostMicros []int64 `db:"total_cost_micros" json:"total_cost_micros"`
RuntimeMs []int64 `db:"runtime_ms" json:"runtime_ms"`
}
@@ -10745,7 +10262,6 @@ func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessa
pq.Array(arg.CacheReadTokens),
pq.Array(arg.ContextLimit),
pq.Array(arg.Compressed),
pq.Array(arg.TotalCostMicros),
pq.Array(arg.RuntimeMs),
)
if err != nil {
-242
View File
@@ -941,7 +941,6 @@ inserted AS (
cache_read_tokens,
context_limit,
compressed,
total_cost_micros,
runtime_ms
)
SELECT
@@ -962,7 +961,6 @@ inserted AS (
NULLIF((@cache_read_tokens::bigint[])[allocated.ord], 0),
NULLIF((@context_limit::bigint[])[allocated.ord], 0),
(@compressed::boolean[])[allocated.ord],
NULLIF((@total_cost_micros::bigint[])[allocated.ord], 0),
NULLIF((@runtime_ms::bigint[])[allocated.ord], 0)
FROM allocated
RETURNING *
@@ -2211,229 +2209,6 @@ SELECT
COUNT(*) FILTER (WHERE pull_request_state = 'closed')::bigint AS closed
FROM deduped;
-- 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_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,
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
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,
COALESCE(ap.type::text, '')::text AS 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,
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
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
LEFT JOIN
ai_providers ap ON ap.id = cmc.ai_provider_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, ap.type, 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,
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
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,
cc.total_cache_read_tokens,
cc.total_cache_creation_tokens,
cc.total_runtime_ms
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.
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,
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
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 || '%'
OR u.name 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,
total_cache_read_tokens,
total_cache_creation_tokens,
total_runtime_ms,
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;
-- name: GetTotalChatMessageRuntimeMsInRange :one
-- Computes hb_agent_runtime_v1 usage event payloads. Deliberately includes
-- soft-deleted messages and messages from all chats.
@@ -2443,22 +2218,6 @@ WHERE cm.created_at >= @start_time::timestamptz
AND cm.created_at < @end_time::timestamptz
AND cm.runtime_ms IS NOT NULL;
-- name: CountEnabledModelsWithoutPricing :one
-- Counts enabled, non-deleted model configs that lack both input and
-- output pricing in their JSONB options.cost configuration.
SELECT COUNT(*)::bigint AS count
FROM chat_model_configs
WHERE enabled = TRUE
AND deleted = FALSE
AND (
options->'cost' IS NULL
OR options->'cost' = 'null'::jsonb
OR (
(options->'cost'->>'input_price_per_million_tokens' IS NULL)
AND (options->'cost'->>'output_price_per_million_tokens' IS NULL)
)
);
-- name: GetChatsByWorkspaceIDs :many
SELECT *
FROM chats_expanded
@@ -2521,7 +2280,6 @@ SELECT
COALESCE(SUM(cm.reasoning_tokens), 0)::bigint AS total_reasoning_tokens,
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms,
COUNT(DISTINCT cm.model_config_id)::bigint AS distinct_model_count,
COUNT(*) FILTER (WHERE cm.compressed)::bigint AS compressed_message_count
-292
View File
@@ -8,7 +8,6 @@ import (
"errors"
"fmt"
"io"
"math"
"mime"
"net/http"
"net/http/httptest"
@@ -21,7 +20,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
@@ -1598,203 +1596,6 @@ func (api *API) listChatModels(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, response)
}
func (api *API) chatCostSummary(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
// Default date range: last 30 days.
now := time.Now()
defaultStart := now.AddDate(0, 0, -30)
qp := r.URL.Query()
p := httpapi.NewQueryParamParser()
startDate := p.Time(qp, defaultStart, "start_date", time.RFC3339)
endDate := p.Time(qp, now, "end_date", time.RFC3339)
p.ErrorExcessParams(qp)
if len(p.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid query parameters.",
Validations: p.Errors,
})
return
}
targetUser := httpmw.UserParam(r)
if targetUser.ID != apiKey.UserID && !api.Authorize(r, policy.ActionRead, rbac.ResourceChat.WithOwner(targetUser.ID.String())) {
httpapi.Forbidden(rw)
return
}
summary, err := api.Database.GetChatCostSummary(ctx, database.GetChatCostSummaryParams{
OwnerID: targetUser.ID,
StartDate: startDate,
EndDate: endDate,
})
if err != nil {
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
byModel, err := api.Database.GetChatCostPerModel(ctx, database.GetChatCostPerModelParams{
OwnerID: targetUser.ID,
StartDate: startDate,
EndDate: endDate,
})
if err != nil {
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
byChat, err := api.Database.GetChatCostPerChat(ctx, database.GetChatCostPerChatParams{
OwnerID: targetUser.ID,
StartDate: startDate,
EndDate: endDate,
})
if err != nil {
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
modelBreakdowns := make([]codersdk.ChatCostModelBreakdown, 0, len(byModel))
for _, model := range byModel {
modelBreakdowns = append(modelBreakdowns, convertChatCostModelBreakdown(model))
}
chatBreakdowns := make([]codersdk.ChatCostChatBreakdown, 0, len(byChat))
for _, chat := range byChat {
chatBreakdowns = append(chatBreakdowns, convertChatCostChatBreakdown(chat))
}
response := codersdk.ChatCostSummary{
StartDate: startDate,
EndDate: endDate,
TotalCostMicros: summary.TotalCostMicros,
PricedMessageCount: summary.PricedMessageCount,
UnpricedMessagesHavingUsageCount: summary.UnpricedMessagesHavingUsageCount,
TotalInputTokens: summary.TotalInputTokens,
TotalOutputTokens: summary.TotalOutputTokens,
TotalCacheReadTokens: summary.TotalCacheReadTokens,
TotalCacheCreationTokens: summary.TotalCacheCreationTokens,
TotalRuntimeMs: summary.TotalRuntimeMs,
ByModel: modelBreakdowns,
ByChat: chatBreakdowns,
}
httpapi.Write(ctx, rw, http.StatusOK, response)
}
func (api *API) chatCostUsers(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionRead, rbac.ResourceChat) {
httpapi.Forbidden(rw)
return
}
now := time.Now()
defaultStart := now.AddDate(0, 0, -30)
qp := r.URL.Query()
p := httpapi.NewQueryParamParser()
startDate := p.Time(qp, defaultStart, "start_date", time.RFC3339)
endDate := p.Time(qp, now, "end_date", time.RFC3339)
username := strings.TrimSpace(p.String(qp, "", "username"))
limit := p.Int(qp, 10, "limit")
offset := p.Int(qp, 0, "offset")
p.ErrorExcessParams(qp)
if len(p.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid query parameters.",
Validations: p.Errors,
})
return
}
if limit <= 0 {
limit = 10
}
if offset < 0 || offset > math.MaxInt32 || limit > math.MaxInt32 {
validations := make([]codersdk.ValidationError, 0, 2)
if offset < 0 {
validations = append(validations, codersdk.ValidationError{
Field: "offset",
Detail: "Must be greater than or equal to 0.",
})
}
if offset > math.MaxInt32 {
validations = append(validations, codersdk.ValidationError{
Field: "offset",
Detail: fmt.Sprintf("Must be less than or equal to %d.", math.MaxInt32),
})
}
if limit > math.MaxInt32 {
validations = append(validations, codersdk.ValidationError{
Field: "limit",
Detail: fmt.Sprintf("Must be less than or equal to %d.", math.MaxInt32),
})
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid query parameters.",
Validations: validations,
})
return
}
users, err := api.Database.GetChatCostPerUser(ctx, database.GetChatCostPerUserParams{
StartDate: startDate,
EndDate: endDate,
Username: username,
// #nosec G115 - Pagination limits are validated to fit in int32 above.
PageLimit: int32(limit),
// #nosec G115 - Pagination offsets are validated to fit in int32 above.
PageOffset: int32(offset),
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
rollups := make([]codersdk.ChatCostUserRollup, 0, len(users))
count := int64(0)
for _, user := range users {
count = user.TotalCount
rollups = append(rollups, convertChatCostUserRollup(user))
}
if len(users) == 0 && offset > 0 {
countUsers, countErr := api.Database.GetChatCostPerUser(ctx, database.GetChatCostPerUserParams{
StartDate: startDate,
EndDate: endDate,
Username: username,
PageLimit: 1,
PageOffset: 0,
})
if countErr != nil {
httpapi.InternalServerError(rw, countErr)
return
}
if len(countUsers) > 0 {
count = countUsers[0].TotalCount
}
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatCostUsersResponse{
StartDate: startDate,
EndDate: endDate,
Count: count,
Users: rollups,
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Get chat by ID
@@ -6571,57 +6372,6 @@ func (api *API) fetchChatFileMetadata(ctx context.Context, chatID uuid.UUID) []d
return rows
}
func convertChatCostModelBreakdown(model database.GetChatCostPerModelRow) codersdk.ChatCostModelBreakdown {
displayName := strings.TrimSpace(model.DisplayName)
if displayName == "" {
displayName = model.Model
}
return codersdk.ChatCostModelBreakdown{
ModelConfigID: model.ModelConfigID,
DisplayName: displayName,
Provider: model.Provider,
Model: model.Model,
TotalCostMicros: model.TotalCostMicros,
MessageCount: model.MessageCount,
TotalInputTokens: model.TotalInputTokens,
TotalOutputTokens: model.TotalOutputTokens,
TotalCacheReadTokens: model.TotalCacheReadTokens,
TotalCacheCreationTokens: model.TotalCacheCreationTokens,
TotalRuntimeMs: model.TotalRuntimeMs,
}
}
func convertChatCostChatBreakdown(chat database.GetChatCostPerChatRow) codersdk.ChatCostChatBreakdown {
return codersdk.ChatCostChatBreakdown{
RootChatID: chat.RootChatID,
ChatTitle: chat.ChatTitle,
TotalCostMicros: chat.TotalCostMicros,
MessageCount: chat.MessageCount,
TotalInputTokens: chat.TotalInputTokens,
TotalOutputTokens: chat.TotalOutputTokens,
TotalCacheReadTokens: chat.TotalCacheReadTokens,
TotalCacheCreationTokens: chat.TotalCacheCreationTokens,
TotalRuntimeMs: chat.TotalRuntimeMs,
}
}
func convertChatCostUserRollup(user database.GetChatCostPerUserRow) codersdk.ChatCostUserRollup {
return codersdk.ChatCostUserRollup{
UserID: user.UserID,
Username: user.Username,
Name: user.Name,
AvatarURL: user.AvatarURL,
TotalCostMicros: user.TotalCostMicros,
MessageCount: user.MessageCount,
ChatCount: user.ChatCount,
TotalInputTokens: user.TotalInputTokens,
TotalOutputTokens: user.TotalOutputTokens,
TotalCacheReadTokens: user.TotalCacheReadTokens,
TotalCacheCreationTokens: user.TotalCacheCreationTokens,
TotalRuntimeMs: user.TotalRuntimeMs,
}
}
func convertChatQueuedMessage(m database.ChatQueuedMessage) codersdk.ChatQueuedMessage {
return db2sdk.ChatQueuedMessage(m)
}
@@ -7579,26 +7329,6 @@ func validateChatModelCallConfig(modelConfig *codersdk.ChatModelCallConfig) erro
return nil
}
costConfig := codersdk.ModelCostConfig{}
if modelConfig.Cost != nil {
costConfig = *modelConfig.Cost
}
pricingFields := []struct {
name string
value *decimal.Decimal
}{
{name: "cost.input_price_per_million_tokens", value: costConfig.InputPricePerMillionTokens},
{name: "cost.output_price_per_million_tokens", value: costConfig.OutputPricePerMillionTokens},
{name: "cost.cache_read_price_per_million_tokens", value: costConfig.CacheReadPricePerMillionTokens},
{name: "cost.cache_write_price_per_million_tokens", value: costConfig.CacheWritePricePerMillionTokens},
}
for _, field := range pricingFields {
if err := validateNonNegativeDecimalField(field.name, field.value); err != nil {
return err
}
}
if err := validateChatModelReasoningEffortConfig(modelConfig); err != nil {
return err
}
@@ -7641,16 +7371,6 @@ func validateChatModelProviderOptions(options *codersdk.ChatModelProviderOptions
return xerrors.Errorf("provider_options.anthropic.thinking_display must be one of summarized, omitted")
}
func validateNonNegativeDecimalField(name string, value *decimal.Decimal) error {
if value == nil {
return nil
}
if value.IsNegative() {
return xerrors.Errorf("%s must be greater than or equal to zero", name)
}
return nil
}
func unmarshalChatModelCallConfig(
raw json.RawMessage,
) *codersdk.ChatModelCallConfig {
@@ -7681,7 +7401,6 @@ func isZeroChatModelCallConfig(config *codersdk.ChatModelCallConfig) bool {
config.FrequencyPenalty == nil &&
config.ReasoningEffort == nil &&
isZeroChatModelOpenAIConfig(config.OpenAIConfig) &&
isZeroModelCostConfig(config.Cost) &&
isZeroChatModelProviderOptions(config.ProviderOptions)
}
@@ -7689,17 +7408,6 @@ func isZeroChatModelOpenAIConfig(config *codersdk.ChatModelOpenAIConfig) bool {
return config == nil || config.UseResponsesAPI == nil
}
func isZeroModelCostConfig(cost *codersdk.ModelCostConfig) bool {
if cost == nil {
return true
}
return cost.InputPricePerMillionTokens == nil &&
cost.OutputPricePerMillionTokens == nil &&
cost.CacheReadPricePerMillionTokens == nil &&
cost.CacheWritePricePerMillionTokens == nil
}
func isZeroChatModelProviderOptions(options *codersdk.ChatModelProviderOptions) bool {
if options == nil {
return true
+1 -1
View File
@@ -419,7 +419,7 @@ func TestSharedReaderStreamChat(t *testing.T) {
LastModelConfigID: modelConfig.ID,
Title: "shared stream chat",
})
insertAssistantCostMessage(t, db, chat.ID, modelConfig.ID, 0)
insertAssistantMessage(t, db, chat.ID, modelConfig.ID)
err := client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
-5
View File
@@ -10,7 +10,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"
@@ -417,7 +416,6 @@ func TestRewriteChatStartWorkspaceManualUpdateResponse(t *testing.T) {
func TestIsZeroChatModelCallConfigCoversEveryField(t *testing.T) {
t.Parallel()
costSample := decimal.NewFromInt(3)
sampled := codersdk.ChatModelCallConfig{
MaxOutputTokens: ptr.Ref(int64(4096)),
Temperature: ptr.Ref(0.7),
@@ -425,9 +423,6 @@ func TestIsZeroChatModelCallConfigCoversEveryField(t *testing.T) {
TopK: ptr.Ref(int64(40)),
PresencePenalty: ptr.Ref(0.1),
FrequencyPenalty: ptr.Ref(0.2),
Cost: &codersdk.ModelCostConfig{
InputPricePerMillionTokens: &costSample,
},
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref("medium"),
},
+5 -536
View File
@@ -20,7 +20,6 @@ import (
"github.com/google/uuid"
"github.com/mark3labs/mcp-go/mcp"
"github.com/shopspring/decimal"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
@@ -264,12 +263,11 @@ func (s *failNextUpdateChatModelConfigStore) UpdateChatModelConfig(
return s.Store.UpdateChatModelConfig(ctx, arg)
}
func insertAssistantCostMessage(
func insertAssistantMessage(
t *testing.T,
db database.Store,
chatID uuid.UUID,
modelConfigID uuid.UUID,
totalCostMicros int64,
) {
t.Helper()
@@ -279,11 +277,10 @@ func insertAssistantCostMessage(
require.NoError(t, err)
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chatID,
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true},
Role: database.ChatMessageRoleAssistant,
Content: assistantContent,
TotalCostMicros: sql.NullInt64{Int64: totalCostMicros, Valid: true},
ChatID: chatID,
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: true},
Role: database.ChatMessageRoleAssistant,
Content: assistantContent,
})
}
@@ -3878,41 +3875,6 @@ func TestListChatModelConfigs(t *testing.T) {
require.Equal(t, enabledConfig.ID, memberConfigs[0].ID)
})
t.Run("DeserializesLegacyPricingJSON", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
legacyOptions := json.RawMessage(`{"input_price_per_million_tokens":0.15,"output_price_per_million_tokens":0.6,"cache_read_price_per_million_tokens":0.03,"cache_write_price_per_million_tokens":0.3}`)
storedConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
AIProviderID: uuid.NullUUID{UUID: aiProvider.ID, Valid: true},
Model: "gpt-4o-mini-legacy",
DisplayName: "GPT-4o Mini Legacy",
CreatedBy: uuid.NullUUID{UUID: firstUser.UserID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: firstUser.UserID, Valid: true},
ContextLimit: 4096,
CompressionThreshold: 80,
Options: legacyOptions,
})
configs, err := client.ListChatModelConfigs(ctx)
require.NoError(t, err)
require.Len(t, configs, 1)
require.Equal(t, storedConfig.ID, configs[0].ID)
requireChatModelPricing(t, configs[0].ModelConfig, &codersdk.ChatModelCallConfig{
Cost: &codersdk.ModelCostConfig{
InputPricePerMillionTokens: decRef("0.15"),
OutputPricePerMillionTokens: decRef("0.6"),
CacheReadPricePerMillionTokens: decRef("0.03"),
CacheWritePricePerMillionTokens: decRef("0.3"),
},
})
})
t.Run("SuccessForOrganizationMember", func(t *testing.T) {
t.Parallel()
@@ -3954,20 +3916,11 @@ func TestCreateChatModelConfig(t *testing.T) {
contextLimit := int64(4096)
isDefault := true
pricing := &codersdk.ChatModelCallConfig{
Cost: &codersdk.ModelCostConfig{
InputPricePerMillionTokens: decRef("0.15"),
OutputPricePerMillionTokens: decRef("0.6"),
CacheReadPricePerMillionTokens: decRef("0.03"),
CacheWritePricePerMillionTokens: decRef("0.3"),
},
}
modelConfig, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
IsDefault: &isDefault,
ModelConfig: pricing,
})
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, modelConfig.ID)
@@ -3975,12 +3928,10 @@ func TestCreateChatModelConfig(t *testing.T) {
require.Equal(t, "gpt-4o-mini", modelConfig.Model)
require.EqualValues(t, 4096, modelConfig.ContextLimit)
require.True(t, modelConfig.IsDefault)
requireChatModelPricing(t, modelConfig.ModelConfig, pricing)
configs, err := client.ListChatModelConfigs(ctx)
require.NoError(t, err)
require.Len(t, configs, 1)
requireChatModelPricing(t, configs[0].ModelConfig, pricing)
})
t.Run("ConcurrentCreatesElectSingleDefault", func(t *testing.T) {
@@ -4040,35 +3991,6 @@ func TestCreateChatModelConfig(t *testing.T) {
require.Equal(t, []uuid.UUID{claimed.ID}, defaults)
})
t.Run("RejectsNegativePricing", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key")
contextLimit := int64(4096)
_, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{
AIProviderID: &aiProvider.ID,
Model: "gpt-4o-mini",
ContextLimit: &contextLimit,
ModelConfig: &codersdk.ChatModelCallConfig{
Cost: &codersdk.ModelCostConfig{
InputPricePerMillionTokens: decRef("-0.01"),
},
},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid model config.", sdkErr.Message)
require.Equal(
t,
"cost.input_price_per_million_tokens must be greater than or equal to zero",
sdkErr.Detail,
)
})
t.Run("ReasoningEffortStored", func(t *testing.T) {
t.Parallel()
@@ -4358,29 +4280,18 @@ func TestUpdateChatModelConfig(t *testing.T) {
modelConfig := createChatModelConfig(t, client)
contextLimit := int64(8192)
pricing := &codersdk.ChatModelCallConfig{
Cost: &codersdk.ModelCostConfig{
InputPricePerMillionTokens: decRef("0.2"),
OutputPricePerMillionTokens: decRef("0.8"),
CacheReadPricePerMillionTokens: decRef("0.04"),
CacheWritePricePerMillionTokens: decRef("0.4"),
},
}
updated, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{
DisplayName: "GPT-4o Mini Updated",
ContextLimit: &contextLimit,
ModelConfig: pricing,
})
require.NoError(t, err)
require.Equal(t, modelConfig.ID, updated.ID)
require.Equal(t, "GPT-4o Mini Updated", updated.DisplayName)
require.EqualValues(t, 8192, updated.ContextLimit)
requireChatModelPricing(t, updated.ModelConfig, pricing)
configs, err := client.ListChatModelConfigs(ctx)
require.NoError(t, err)
require.Len(t, configs, 1)
requireChatModelPricing(t, configs[0].ModelConfig, pricing)
})
t.Run("UnchangedProviderWithoutAIProviderID", func(t *testing.T) {
@@ -4594,30 +4505,6 @@ func TestUpdateChatModelConfig(t *testing.T) {
require.True(t, foundForMember)
})
t.Run("RejectsNegativePricing", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
_, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{
ModelConfig: &codersdk.ChatModelCallConfig{
Cost: &codersdk.ModelCostConfig{
OutputPricePerMillionTokens: decRef("-1.0"),
},
},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid model config.", sdkErr.Message)
require.Equal(
t,
"cost.output_price_per_million_tokens must be greater than or equal to zero",
sdkErr.Detail,
)
})
t.Run("UpdateAIProviderID", func(t *testing.T) {
t.Parallel()
@@ -5314,7 +5201,6 @@ func TestGetChatUserPrompts(t *testing.T) {
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
@@ -5396,7 +5282,6 @@ func TestGetChatUserPrompts(t *testing.T) {
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
@@ -5423,7 +5308,6 @@ func TestGetChatUserPrompts(t *testing.T) {
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
@@ -5618,7 +5502,6 @@ func TestGetChatUserPrompts(t *testing.T) {
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
@@ -10660,7 +10543,6 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
CacheReadTokens: []int64{0},
ContextLimit: []int64{0},
Compressed: []bool{false},
TotalCostMicros: []int64{0},
RuntimeMs: []int64{0},
})
require.NoError(t, err)
@@ -11264,194 +11146,6 @@ func TestGetChatFile(t *testing.T) {
})
}
type chatCostTestFixture struct {
Client *codersdk.ExperimentalClient
DB database.Store
ModelConfigID uuid.UUID
ChatID uuid.UUID
EarliestCreatedAt time.Time
LatestCreatedAt time.Time
}
// safeOptions returns an explicit time window around the fixture messages to
// avoid app-time/database-time boundary flakes in summary tests.
func (f chatCostTestFixture) safeOptions() codersdk.ChatCostSummaryOptions {
return codersdk.ChatCostSummaryOptions{
StartDate: f.EarliestCreatedAt.Add(-time.Minute),
EndDate: f.LatestCreatedAt.Add(time.Minute),
}
}
func seedChatCostFixture(t *testing.T) chatCostTestFixture {
t.Helper()
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: "test chat",
})
msg1 := 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},
TotalCostMicros: sql.NullInt64{Int64: 500, Valid: true},
RuntimeMs: sql.NullInt64{Int64: 1500, Valid: true},
})
msg2 := 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},
TotalCostMicros: sql.NullInt64{Int64: 500, Valid: true},
RuntimeMs: sql.NullInt64{Int64: 2500, Valid: true},
})
results := []database.ChatMessage{msg1, msg2}
require.Len(t, results, 2)
earliestCreatedAt := results[0].CreatedAt
latestCreatedAt := results[0].CreatedAt
for _, msg := range results {
if msg.CreatedAt.Before(earliestCreatedAt) {
earliestCreatedAt = msg.CreatedAt
}
if msg.CreatedAt.After(latestCreatedAt) {
latestCreatedAt = msg.CreatedAt
}
}
return chatCostTestFixture{
Client: client,
DB: db,
ModelConfigID: modelConfig.ID,
ChatID: chat.ID,
EarliestCreatedAt: earliestCreatedAt,
LatestCreatedAt: latestCreatedAt,
}
}
func assertChatCostSummary(t *testing.T, summary codersdk.ChatCostSummary, modelConfigID, chatID uuid.UUID) {
t.Helper()
require.Equal(t, int64(1000), summary.TotalCostMicros)
require.Equal(t, int64(2), summary.PricedMessageCount)
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)
require.Len(t, summary.ByModel, 1)
require.Equal(t, modelConfigID, summary.ByModel[0].ModelConfigID)
require.Equal(t, int64(1000), summary.ByModel[0].TotalCostMicros)
require.Equal(t, int64(2), summary.ByModel[0].MessageCount)
require.Equal(t, int64(4000), summary.ByModel[0].TotalRuntimeMs)
require.Len(t, summary.ByChat, 1)
require.Equal(t, chatID, summary.ByChat[0].RootChatID)
require.Equal(t, int64(1000), summary.ByChat[0].TotalCostMicros)
require.Equal(t, int64(2), summary.ByChat[0].MessageCount)
require.Equal(t, int64(4000), summary.ByChat[0].TotalRuntimeMs)
}
func TestChatCostSummary(t *testing.T) {
t.Parallel()
t.Run("BasicSummary", func(t *testing.T) {
t.Parallel()
f := seedChatCostFixture(t)
ctx := testutil.Context(t, testutil.WaitLong)
// Use a window derived from DB timestamps to avoid time boundary flakes.
summary, err := f.Client.GetChatCostSummary(ctx, "me", f.safeOptions())
require.NoError(t, err)
assertChatCostSummary(t, summary, f.ModelConfigID, f.ChatID)
})
}
func TestChatCostSummary_AfterModelDeletion(t *testing.T) {
t.Parallel()
f := seedChatCostFixture(t)
ctx := testutil.Context(t, testutil.WaitLong)
options := f.safeOptions()
// Baseline: use DB-derived timestamps to avoid time boundary flakes.
summary, err := f.Client.GetChatCostSummary(ctx, "me", options)
require.NoError(t, err)
assertChatCostSummary(t, summary, f.ModelConfigID, f.ChatID)
// Soft-delete the model config.
err = f.Client.DeleteChatModelConfig(ctx, f.ModelConfigID)
require.NoError(t, err)
// Costs must survive the deletion unchanged within the same safe window.
summary, err = f.Client.GetChatCostSummary(ctx, "me", options)
require.NoError(t, err)
assertChatCostSummary(t, summary, f.ModelConfigID, f.ChatID)
}
func TestChatCostSummary_AdminDrilldown(t *testing.T) {
t.Parallel()
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
memberClientRaw, member := 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: member.ID,
LastModelConfigID: modelConfig.ID,
Title: "member chat",
})
message := dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
InputTokens: sql.NullInt64{Int64: 200, Valid: true},
OutputTokens: sql.NullInt64{Int64: 100, Valid: true},
TotalCostMicros: sql.NullInt64{Int64: 750, Valid: true},
})
options := codersdk.ChatCostSummaryOptions{
// Pad the DB-assigned timestamp so the query window cannot race it.
StartDate: message.CreatedAt.Add(-time.Minute),
EndDate: message.CreatedAt.Add(time.Minute),
}
t.Run("AdminCanDrilldown", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
summary, err := client.GetChatCostSummary(ctx, member.ID.String(), options)
require.NoError(t, err)
require.Equal(t, int64(750), summary.TotalCostMicros)
require.Equal(t, int64(1), summary.PricedMessageCount)
})
t.Run("MemberCannotDrilldownOtherUser", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
_, err := memberClient.GetChatCostSummary(ctx, firstUser.UserID.String(), options)
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
}
// seedChatGatewayRequest records one finished Coder Agents gateway request
// under sessionChatID, mirroring aibridged: the session ID is the spawning
// chat, and each usage is one provider response within that one request.
@@ -11888,231 +11582,6 @@ func TestGetChatCost(t *testing.T) {
})
}
func TestChatCostUsers(t *testing.T) {
t.Parallel()
seedCtx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
firstUserRecord, err := db.GetUserByID(dbauthz.AsSystemRestricted(seedCtx), firstUser.UserID)
require.NoError(t, err)
modelConfig := createChatModelConfig(t, client)
adminChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "admin chat",
})
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: adminChat.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},
TotalCostMicros: sql.NullInt64{Int64: 300, Valid: true},
})
memberChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: member.ID,
LastModelConfigID: modelConfig.ID,
Title: "member chat",
})
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: memberChat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
InputTokens: sql.NullInt64{Int64: 200, Valid: true},
OutputTokens: sql.NullInt64{Int64: 100, Valid: true},
TotalCostMicros: sql.NullInt64{Int64: 800, Valid: true},
})
t.Run("AdminCanListUsers", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
resp, err := client.GetChatCostUsers(ctx, codersdk.ChatCostUsersOptions{})
require.NoError(t, err)
require.Equal(t, int64(2), resp.Count)
require.Len(t, resp.Users, 2)
require.Equal(t, member.ID, resp.Users[0].UserID)
require.Equal(t, member.Username, resp.Users[0].Username)
require.Equal(t, int64(800), resp.Users[0].TotalCostMicros)
require.Equal(t, int64(1), resp.Users[0].MessageCount)
require.Equal(t, int64(1), resp.Users[0].ChatCount)
require.Equal(t, firstUser.UserID, resp.Users[1].UserID)
require.Equal(t, firstUserRecord.Username, resp.Users[1].Username)
require.Equal(t, int64(300), resp.Users[1].TotalCostMicros)
})
t.Run("AdminCanFilterAndPaginateUsers", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
resp, err := client.GetChatCostUsers(ctx, codersdk.ChatCostUsersOptions{
Username: member.Username,
Pagination: codersdk.Pagination{
Limit: 1,
Offset: 0,
},
})
require.NoError(t, err)
require.Equal(t, int64(1), resp.Count)
require.Len(t, resp.Users, 1)
require.Equal(t, member.ID, resp.Users[0].UserID)
require.Equal(t, member.Username, resp.Users[0].Username)
})
t.Run("MemberCannotListUsers", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
_, err := memberClient.GetChatCostUsers(ctx, codersdk.ChatCostUsersOptions{})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
})
}
func TestChatCostSummary_DateRange(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: "date range test",
})
_ = 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},
TotalCostMicros: sql.NullInt64{Int64: 500, Valid: true},
})
now := time.Now()
t.Run("MessageInRange", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
summary, err := client.GetChatCostSummary(ctx, "me", codersdk.ChatCostSummaryOptions{
StartDate: now.Add(-time.Hour),
EndDate: now.Add(time.Hour),
})
require.NoError(t, err)
require.Equal(t, int64(500), summary.TotalCostMicros)
require.Equal(t, int64(1), summary.PricedMessageCount)
})
t.Run("MessageOutOfRange", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
summary, err := client.GetChatCostSummary(ctx, "me", codersdk.ChatCostSummaryOptions{
StartDate: now.Add(time.Hour),
EndDate: now.Add(2 * time.Hour),
})
require.NoError(t, err)
require.Equal(t, int64(0), summary.TotalCostMicros)
require.Equal(t, int64(0), summary.PricedMessageCount)
})
}
func TestChatCostSummary_UnpricedMessages(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
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 test",
})
pricedMessage := 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},
TotalCostMicros: sql.NullInt64{Int64: 500, Valid: true},
})
unpricedMessage := dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelConfig.ID, Valid: true},
Role: database.ChatMessageRoleAssistant,
InputTokens: sql.NullInt64{Int64: 200, Valid: true},
OutputTokens: sql.NullInt64{Int64: 75, Valid: true},
})
earliestCreatedAt := pricedMessage.CreatedAt
latestCreatedAt := pricedMessage.CreatedAt
if unpricedMessage.CreatedAt.Before(earliestCreatedAt) {
earliestCreatedAt = unpricedMessage.CreatedAt
}
if unpricedMessage.CreatedAt.After(latestCreatedAt) {
latestCreatedAt = unpricedMessage.CreatedAt
}
options := codersdk.ChatCostSummaryOptions{
// Pad the DB-assigned timestamps to avoid time boundary flakes.
StartDate: earliestCreatedAt.Add(-time.Minute),
EndDate: latestCreatedAt.Add(time.Minute),
}
summary, err := client.GetChatCostSummary(ctx, "me", options)
require.NoError(t, err)
require.Equal(t, int64(500), summary.TotalCostMicros)
require.Equal(t, int64(1), summary.PricedMessageCount)
require.Equal(t, int64(1), summary.UnpricedMessagesHavingUsageCount)
require.Equal(t, int64(300), summary.TotalInputTokens)
require.Equal(t, int64(125), summary.TotalOutputTokens)
}
func requireChatModelPricing(
t *testing.T,
actual *codersdk.ChatModelCallConfig,
expected *codersdk.ChatModelCallConfig,
) {
t.Helper()
require.NotNil(t, actual)
require.NotNil(t, expected)
require.NotNil(t, actual.Cost)
require.NotNil(t, expected.Cost)
require.NotNil(t, actual.Cost.InputPricePerMillionTokens)
require.NotNil(t, actual.Cost.OutputPricePerMillionTokens)
require.NotNil(t, actual.Cost.CacheReadPricePerMillionTokens)
require.NotNil(t, actual.Cost.CacheWritePricePerMillionTokens)
require.True(t, expected.Cost.InputPricePerMillionTokens.Equal(*actual.Cost.InputPricePerMillionTokens))
require.True(t, expected.Cost.OutputPricePerMillionTokens.Equal(*actual.Cost.OutputPricePerMillionTokens))
require.True(t, expected.Cost.CacheReadPricePerMillionTokens.Equal(*actual.Cost.CacheReadPricePerMillionTokens))
require.True(t, expected.Cost.CacheWritePricePerMillionTokens.Equal(*actual.Cost.CacheWritePricePerMillionTokens))
}
func decRef(value string) *decimal.Decimal {
d := decimal.RequireFromString(value)
return &d
}
func TestWatchChatDesktop(t *testing.T) {
t.Parallel()
-2
View File
@@ -2306,7 +2306,6 @@ func ConvertChatMessageSummary(dbRow database.GetChatMessageSummariesPerChatRow)
TotalReasoningTokens: dbRow.TotalReasoningTokens,
TotalCacheCreationTokens: dbRow.TotalCacheCreationTokens,
TotalCacheReadTokens: dbRow.TotalCacheReadTokens,
TotalCostMicros: dbRow.TotalCostMicros,
TotalRuntimeMs: dbRow.TotalRuntimeMs,
DistinctModelCount: dbRow.DistinctModelCount,
CompressedMessageCount: dbRow.CompressedMessageCount,
@@ -2602,7 +2601,6 @@ type ChatMessageSummary struct {
TotalReasoningTokens int64 `json:"total_reasoning_tokens"`
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
TotalCostMicros int64 `json:"total_cost_micros"`
TotalRuntimeMs int64 `json:"total_runtime_ms"`
DistinctModelCount int64 `json:"distinct_model_count"`
CompressedMessageCount int64 `json:"compressed_message_count"`
-9
View File
@@ -1719,7 +1719,6 @@ func TestChatsTelemetry(t *testing.T) {
TotalTokens: sql.NullInt64{Int64: 100, Valid: true},
CacheCreationTokens: sql.NullInt64{Int64: 50, Valid: true},
ContextLimit: sql.NullInt64{Int64: 200000, Valid: true},
TotalCostMicros: sql.NullInt64{Int64: 1000, Valid: true},
})
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: rootChat.ID,
@@ -1732,7 +1731,6 @@ func TestChatsTelemetry(t *testing.T) {
ReasoningTokens: sql.NullInt64{Int64: 10, Valid: true},
CacheReadTokens: sql.NullInt64{Int64: 25, Valid: true},
ContextLimit: sql.NullInt64{Int64: 200000, Valid: true},
TotalCostMicros: sql.NullInt64{Int64: 2000, Valid: true},
RuntimeMs: sql.NullInt64{Int64: 500, Valid: true},
ProviderResponseID: sql.NullString{String: "resp-1", Valid: true},
})
@@ -1746,7 +1744,6 @@ func TestChatsTelemetry(t *testing.T) {
TotalTokens: sql.NullInt64{Int64: 150, Valid: true},
CacheCreationTokens: sql.NullInt64{Int64: 30, Valid: true},
ContextLimit: sql.NullInt64{Int64: 200000, Valid: true},
TotalCostMicros: sql.NullInt64{Int64: 1500, Valid: true},
})
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: rootChat.ID,
@@ -1759,7 +1756,6 @@ func TestChatsTelemetry(t *testing.T) {
ReasoningTokens: sql.NullInt64{Int64: 20, Valid: true},
CacheReadTokens: sql.NullInt64{Int64: 40, Valid: true},
ContextLimit: sql.NullInt64{Int64: 200000, Valid: true},
TotalCostMicros: sql.NullInt64{Int64: 3000, Valid: true},
RuntimeMs: sql.NullInt64{Int64: 800, Valid: true},
ProviderResponseID: sql.NullString{String: "resp-2", Valid: true},
})
@@ -1783,7 +1779,6 @@ func TestChatsTelemetry(t *testing.T) {
TotalTokens: sql.NullInt64{Int64: 500, Valid: true},
CacheCreationTokens: sql.NullInt64{Int64: 100, Valid: true},
ContextLimit: sql.NullInt64{Int64: 128000, Valid: true},
TotalCostMicros: sql.NullInt64{Int64: 5000, Valid: true},
})
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: childChat.ID,
@@ -1797,7 +1792,6 @@ func TestChatsTelemetry(t *testing.T) {
CacheReadTokens: sql.NullInt64{Int64: 75, Valid: true},
ContextLimit: sql.NullInt64{Int64: 128000, Valid: true},
Compressed: true,
TotalCostMicros: sql.NullInt64{Int64: 8000, Valid: true},
RuntimeMs: sql.NullInt64{Int64: 1200, Valid: true},
ProviderResponseID: sql.NullString{String: "resp-3", Valid: true},
})
@@ -1817,7 +1811,6 @@ func TestChatsTelemetry(t *testing.T) {
CacheCreationTokens: sql.NullInt64{Int64: 999999, Valid: true},
CacheReadTokens: sql.NullInt64{Int64: 999999, Valid: true},
ContextLimit: sql.NullInt64{Int64: 200000, Valid: true},
TotalCostMicros: sql.NullInt64{Int64: 999999, Valid: true},
RuntimeMs: sql.NullInt64{Int64: 999999, Valid: true},
})
err = db.SoftDeleteChatMessageByID(ctx, poisonMsg.ID)
@@ -1893,7 +1886,6 @@ func TestChatsTelemetry(t *testing.T) {
assert.Equal(t, int64(30), rootSummary.TotalReasoningTokens) // 0+10+0+20+0
assert.Equal(t, int64(80), rootSummary.TotalCacheCreationTokens) // 50+0+30+0+0
assert.Equal(t, int64(65), rootSummary.TotalCacheReadTokens) // 0+25+0+40+0
assert.Equal(t, int64(7500), rootSummary.TotalCostMicros) // 1000+2000+1500+3000+0
assert.Equal(t, int64(1400), rootSummary.TotalRuntimeMs) // 0+500+0+800+100
assert.Equal(t, int64(1), rootSummary.DistinctModelCount)
assert.Equal(t, int64(0), rootSummary.CompressedMessageCount)
@@ -1911,7 +1903,6 @@ func TestChatsTelemetry(t *testing.T) {
assert.Equal(t, int64(0), childSummary.SystemMessageCount)
assert.Equal(t, int64(100), childSummary.TotalCacheCreationTokens) // 100+0
assert.Equal(t, int64(75), childSummary.TotalCacheReadTokens) // 0+75
assert.Equal(t, int64(13000), childSummary.TotalCostMicros) // 5000+8000
assert.Equal(t, int64(1200), childSummary.TotalRuntimeMs) // 0+1200
assert.Equal(t, int64(1), childSummary.DistinctModelCount)
assert.Equal(t, int64(1), childSummary.CompressedMessageCount)
-71
View File
@@ -1,71 +0,0 @@
package chatcost
import (
"github.com/shopspring/decimal"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
)
// Returns cost in micros -- millionths of a dollar, rounded up to the next
// whole microdollar.
// Returns nil when pricing is not configured or when all priced usage fields
// are nil, allowing callers to distinguish "zero cost" from "unpriced".
func CalculateTotalCostMicros(
usage codersdk.ChatMessageUsage,
cost *codersdk.ModelCostConfig,
) *int64 {
if cost == nil {
return nil
}
// A cost config with no prices set means pricing is effectively
// unconfigured — return nil (unpriced) rather than zero.
if cost.InputPricePerMillionTokens == nil &&
cost.OutputPricePerMillionTokens == nil &&
cost.CacheReadPricePerMillionTokens == nil &&
cost.CacheWritePricePerMillionTokens == nil {
return nil
}
if usage.InputTokens == nil &&
usage.OutputTokens == nil &&
usage.ReasoningTokens == nil &&
usage.CacheCreationTokens == nil &&
usage.CacheReadTokens == nil {
return nil
}
// OutputTokens already includes reasoning tokens per provider
// semantics (e.g. OpenAI's completion_tokens encompasses
// reasoning_tokens). Adding ReasoningTokens here would
// double-count.
// Preserve nil when usage exists only in categories without configured
// pricing, so callers can distinguish "unpriced" from "priced at zero".
hasMatchingPrice := (usage.InputTokens != nil && cost.InputPricePerMillionTokens != nil) ||
(usage.OutputTokens != nil && cost.OutputPricePerMillionTokens != nil) ||
(usage.CacheReadTokens != nil && cost.CacheReadPricePerMillionTokens != nil) ||
(usage.CacheCreationTokens != nil && cost.CacheWritePricePerMillionTokens != nil)
if !hasMatchingPrice {
return nil
}
inputMicros := calcCost(usage.InputTokens, cost.InputPricePerMillionTokens)
outputMicros := calcCost(usage.OutputTokens, cost.OutputPricePerMillionTokens)
cacheReadMicros := calcCost(usage.CacheReadTokens, cost.CacheReadPricePerMillionTokens)
cacheWriteMicros := calcCost(usage.CacheCreationTokens, cost.CacheWritePricePerMillionTokens)
total := inputMicros.
Add(outputMicros).
Add(cacheReadMicros).
Add(cacheWriteMicros)
rounded := total.Ceil().IntPart()
return &rounded
}
// calcCost returns the cost in fractional microdollars (millionths of a USD)
// for the given token count at the specified per-million-token price.
func calcCost(tokens *int64, pricePerMillion *decimal.Decimal) decimal.Decimal {
return decimal.NewFromInt(ptr.NilToEmpty(tokens)).Mul(ptr.NilToEmpty(pricePerMillion))
}
-163
View File
@@ -1,163 +0,0 @@
package chatcost_test
import (
"testing"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/x/chatd/chatcost"
"github.com/coder/coder/v2/codersdk"
)
func TestCalculateTotalCostMicros(t *testing.T) {
t.Parallel()
tests := []struct {
name string
usage codersdk.ChatMessageUsage
cost *codersdk.ModelCostConfig
want *int64
}{
{
name: "nil cost returns nil",
usage: codersdk.ChatMessageUsage{InputTokens: ptr.Ref[int64](1000)},
cost: nil,
want: nil,
},
{
name: "all priced usage fields nil returns nil",
usage: codersdk.ChatMessageUsage{
TotalTokens: ptr.Ref[int64](1234),
ContextLimit: ptr.Ref[int64](8192),
},
cost: &codersdk.ModelCostConfig{
InputPricePerMillionTokens: ptr.Ref(decimal.RequireFromString("3")),
},
want: nil,
},
{
name: "sub-micro total rounds up to 1",
usage: codersdk.ChatMessageUsage{InputTokens: ptr.Ref[int64](1)},
cost: &codersdk.ModelCostConfig{
InputPricePerMillionTokens: ptr.Ref(decimal.RequireFromString("0.01")),
},
want: ptr.Ref[int64](1),
},
{
name: "simple input only",
usage: codersdk.ChatMessageUsage{InputTokens: ptr.Ref[int64](1000)},
cost: &codersdk.ModelCostConfig{
InputPricePerMillionTokens: ptr.Ref(decimal.RequireFromString("3")),
},
want: ptr.Ref[int64](3000),
},
{
name: "simple output only",
usage: codersdk.ChatMessageUsage{OutputTokens: ptr.Ref[int64](500)},
cost: &codersdk.ModelCostConfig{
OutputPricePerMillionTokens: ptr.Ref(decimal.RequireFromString("15")),
},
want: ptr.Ref[int64](7500),
},
{
name: "reasoning tokens included in output total",
usage: codersdk.ChatMessageUsage{
OutputTokens: ptr.Ref[int64](500),
ReasoningTokens: ptr.Ref[int64](200),
},
cost: &codersdk.ModelCostConfig{
OutputPricePerMillionTokens: ptr.Ref(decimal.RequireFromString("15")),
},
want: ptr.Ref[int64](7500),
},
{
name: "cache read tokens",
usage: codersdk.ChatMessageUsage{CacheReadTokens: ptr.Ref[int64](10000)},
cost: &codersdk.ModelCostConfig{
CacheReadPricePerMillionTokens: ptr.Ref(decimal.RequireFromString("0.3")),
},
want: ptr.Ref[int64](3000),
},
{
name: "cache creation tokens",
usage: codersdk.ChatMessageUsage{CacheCreationTokens: ptr.Ref[int64](5000)},
cost: &codersdk.ModelCostConfig{
CacheWritePricePerMillionTokens: ptr.Ref(decimal.RequireFromString("3.75")),
},
want: ptr.Ref[int64](18750),
},
{
name: "full mixed usage totals all components exactly",
usage: codersdk.ChatMessageUsage{
InputTokens: ptr.Ref[int64](101),
OutputTokens: ptr.Ref[int64](201),
ReasoningTokens: ptr.Ref[int64](52),
CacheReadTokens: ptr.Ref[int64](1005),
CacheCreationTokens: ptr.Ref[int64](33),
TotalTokens: ptr.Ref[int64](1391),
ContextLimit: ptr.Ref[int64](4096),
},
cost: &codersdk.ModelCostConfig{
InputPricePerMillionTokens: ptr.Ref(decimal.RequireFromString("1.23")),
OutputPricePerMillionTokens: ptr.Ref(decimal.RequireFromString("4.56")),
CacheReadPricePerMillionTokens: ptr.Ref(decimal.RequireFromString("0.7")),
CacheWritePricePerMillionTokens: ptr.Ref(decimal.RequireFromString("7.89")),
},
want: ptr.Ref[int64](2005),
},
{
name: "partial pricing only input contributes",
usage: codersdk.ChatMessageUsage{
InputTokens: ptr.Ref[int64](1234),
OutputTokens: ptr.Ref[int64](999),
ReasoningTokens: ptr.Ref[int64](111),
CacheReadTokens: ptr.Ref[int64](500),
CacheCreationTokens: ptr.Ref[int64](250),
},
cost: &codersdk.ModelCostConfig{
InputPricePerMillionTokens: ptr.Ref(decimal.RequireFromString("2.5")),
},
want: ptr.Ref[int64](3085),
},
{
name: "zero tokens with pricing returns zero pointer",
usage: codersdk.ChatMessageUsage{InputTokens: ptr.Ref[int64](0)},
cost: &codersdk.ModelCostConfig{
InputPricePerMillionTokens: ptr.Ref(decimal.RequireFromString("3")),
},
want: ptr.Ref[int64](0),
},
{
name: "usage only in unpriced categories returns nil",
usage: codersdk.ChatMessageUsage{InputTokens: ptr.Ref[int64](1000)},
cost: &codersdk.ModelCostConfig{
OutputPricePerMillionTokens: ptr.Ref(decimal.RequireFromString("15")),
},
want: nil,
},
{
name: "non nil usage with empty cost config returns nil",
usage: codersdk.ChatMessageUsage{InputTokens: ptr.Ref[int64](42)},
cost: &codersdk.ModelCostConfig{},
want: nil,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := chatcost.CalculateTotalCostMicros(tt.usage, tt.cost)
if tt.want == nil {
require.Nil(t, got)
} else {
require.NotNil(t, got)
require.Equal(t, *tt.want, *got)
}
})
}
}
-2
View File
@@ -2929,7 +2929,6 @@ type chatMessage struct {
cacheCreationTokens int64
cacheReadTokens int64
contextLimit int64
totalCostMicros int64
runtimeMs int64
}
@@ -2973,7 +2972,6 @@ func appendMessageFields(
params.CacheReadTokens = append(params.CacheReadTokens, msg.cacheReadTokens)
params.ContextLimit = append(params.ContextLimit, msg.contextLimit)
params.Compressed = append(params.Compressed, msg.compressed)
params.TotalCostMicros = append(params.TotalCostMicros, msg.totalCostMicros)
params.RuntimeMs = append(params.RuntimeMs, msg.runtimeMs)
}
-3
View File
@@ -33,7 +33,6 @@ type Message struct {
CacheCreationTokens sql.NullInt64
CacheReadTokens sql.NullInt64
ContextLimit sql.NullInt64
TotalCostMicros sql.NullInt64
RuntimeMs sql.NullInt64
}
@@ -62,7 +61,6 @@ func toInsertParams(chatID uuid.UUID, messages []Message) database.InsertChatMes
CacheReadTokens: make([]int64, n),
ContextLimit: make([]int64, n),
Compressed: make([]bool, n),
TotalCostMicros: make([]int64, n),
RuntimeMs: make([]int64, n),
}
for i, m := range messages {
@@ -89,7 +87,6 @@ func toInsertParams(chatID uuid.UUID, messages []Message) database.InsertChatMes
params.CacheReadTokens[i] = nullInt64Or(m.CacheReadTokens, 0)
params.ContextLimit[i] = nullInt64Or(m.ContextLimit, 0)
params.Compressed[i] = m.Compressed
params.TotalCostMicros[i] = nullInt64Or(m.TotalCostMicros, 0)
params.RuntimeMs[i] = nullInt64Or(m.RuntimeMs, 0)
}
return params
-2
View File
@@ -751,7 +751,6 @@ func (s *taskStarter) generateAssistant(
outcome.Step.Content = chathooks.ApplyAdmittedToolCalls(outcome.Step.Content, preflight)
messages, err := buildCommitStepMessages(buildCommitStepMessagesInput{
modelConfigID: prepared.ModelConfigID,
modelCallConfig: prepared.ModelConfig,
step: stepDataFromPersisted(outcome.Step),
toolNameToConfigID: prepared.ToolNameToConfigID,
logger: s.opts.Logger,
@@ -858,7 +857,6 @@ func (s *taskStarter) executeLocalTools(
chathooks.RestoreToolCallOrder(outcome.Step.Content, decision.localToolCalls)
messages, err := buildCommitStepMessages(buildCommitStepMessagesInput{
modelConfigID: prepared.ModelConfigID,
modelCallConfig: prepared.ModelConfig,
step: stepDataFromPersisted(outcome.Step),
toolNameToConfigID: prepared.ToolNameToConfigID,
logger: s.opts.Logger,
+1 -21
View File
@@ -16,7 +16,6 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chatcost"
"github.com/coder/coder/v2/coderd/x/chatd/chatloop"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
@@ -29,7 +28,6 @@ const interruptedToolResultErrorMessage = "tool call was interrupted before it p
type buildCommitStepMessagesInput struct {
modelConfigID uuid.UUID
modelCallConfig codersdk.ChatModelCallConfig
step stepData
toolNameToConfigID map[string]uuid.UUID
logger slog.Logger
@@ -60,7 +58,7 @@ func buildCommitStepMessages(input buildCommitStepMessagesInput) (stepMessagesFo
if err != nil {
return stepMessagesForCommit{}, xerrors.Errorf("marshal assistant content: %w", err)
}
messages = append(messages, assistantMessage(input.modelConfigID, contentVersion, assistantContent, input.step, input.modelCallConfig))
messages = append(messages, assistantMessage(input.modelConfigID, contentVersion, assistantContent, input.step))
}
for _, toolResult := range toolResults {
@@ -186,7 +184,6 @@ func assistantMessage(
contentVersion int16,
content pqtype.NullRawMessage,
step stepData,
modelCallConfig codersdk.ChatModelCallConfig,
) chatstate.Message {
msg := baseMessage(database.ChatMessageRoleAssistant, database.ChatMessageVisibilityBoth, modelConfigID, contentVersion, content)
if step.Usage != (fantasy.Usage{}) {
@@ -196,16 +193,6 @@ func assistantMessage(
msg.ReasoningTokens = nullInt64IfNonZero(step.Usage.ReasoningTokens)
msg.CacheCreationTokens = nullInt64IfNonZero(step.Usage.CacheCreationTokens)
msg.CacheReadTokens = nullInt64IfNonZero(step.Usage.CacheReadTokens)
usage := codersdk.ChatMessageUsage{
InputTokens: int64PtrIfNonZero(step.Usage.InputTokens),
OutputTokens: int64PtrIfNonZero(step.Usage.OutputTokens),
ReasoningTokens: int64PtrIfNonZero(step.Usage.ReasoningTokens),
CacheCreationTokens: int64PtrIfNonZero(step.Usage.CacheCreationTokens),
CacheReadTokens: int64PtrIfNonZero(step.Usage.CacheReadTokens),
}
if totalCost := chatcost.CalculateTotalCostMicros(usage, modelCallConfig.Cost); totalCost != nil {
msg.TotalCostMicros = sql.NullInt64{Int64: *totalCost, Valid: true}
}
}
msg.ContextLimit = step.ContextLimit
if step.Runtime > 0 {
@@ -237,13 +224,6 @@ func nullInt64IfNonZero(value int64) sql.NullInt64 {
return sql.NullInt64{Int64: value, Valid: true}
}
func int64PtrIfNonZero(value int64) *int64 {
if value == 0 {
return nil
}
return &value
}
func visibleMessageIndexes(messages []chatstate.Message) []int {
indexes := make([]int, 0, len(messages))
for i, msg := range messages {
+1 -12
View File
@@ -10,7 +10,6 @@ import (
"charm.land/fantasy"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
@@ -127,21 +126,13 @@ func TestBuildCommitStepMessages_ProviderExecutedResultsStayAssistantContent(t *
require.True(t, parts[1].ProviderExecuted)
}
func TestBuildCommitStepMessages_UsageCostRuntime(t *testing.T) {
func TestBuildCommitStepMessages_UsageRuntime(t *testing.T) {
t.Parallel()
inputPrice := decimal.NewFromFloat(2.5)
outputPrice := decimal.NewFromFloat(7.5)
got, err := buildCommitStepMessages(buildCommitStepMessagesInput{
modelConfigID: uuid.New(),
contentVersion: chatprompt.CurrentContentVersion,
logger: slog.Make(),
modelCallConfig: codersdk.ChatModelCallConfig{
Cost: &codersdk.ModelCostConfig{
InputPricePerMillionTokens: &inputPrice,
OutputPricePerMillionTokens: &outputPrice,
},
},
step: stepData{
Content: []fantasy.Content{fantasy.TextContent{Text: "usage"}},
Usage: fantasy.Usage{InputTokens: 100, OutputTokens: 20, TotalTokens: 120, ReasoningTokens: 3, CacheCreationTokens: 4, CacheReadTokens: 5},
@@ -160,8 +151,6 @@ func TestBuildCommitStepMessages_UsageCostRuntime(t *testing.T) {
require.Equal(t, sql.NullInt64{Int64: 5, Valid: true}, msg.CacheReadTokens)
require.Equal(t, sql.NullInt64{Int64: 4096, Valid: true}, msg.ContextLimit)
require.Equal(t, sql.NullInt64{Int64: 1500, Valid: true}, msg.RuntimeMs)
require.True(t, msg.TotalCostMicros.Valid)
require.Greater(t, msg.TotalCostMicros.Int64, int64(0))
}
func TestBuildCommitStepMessages_ToolTimestampsAndMCPConfigIDs(t *testing.T) {
+15 -209
View File
@@ -17,7 +17,6 @@ import (
"github.com/google/uuid"
"github.com/invopop/jsonschema"
"github.com/shopspring/decimal"
"golang.org/x/xerrors"
"github.com/coder/websocket"
@@ -1459,14 +1458,6 @@ type ChatModelVercelProviderOptions struct {
ExtraBody map[string]any `json:"extra_body,omitempty" description:"Additional fields to include in the request body" hidden:"true"`
}
// ModelCostConfig stores pricing metadata for a chat model.
type ModelCostConfig struct {
InputPricePerMillionTokens *decimal.Decimal `json:"input_price_per_million_tokens,omitempty" description:"Input token price in USD per 1M tokens"`
OutputPricePerMillionTokens *decimal.Decimal `json:"output_price_per_million_tokens,omitempty" description:"Output token price in USD per 1M tokens"`
CacheReadPricePerMillionTokens *decimal.Decimal `json:"cache_read_price_per_million_tokens,omitempty" description:"Cache read token price in USD per 1M tokens"`
CacheWritePricePerMillionTokens *decimal.Decimal `json:"cache_write_price_per_million_tokens,omitempty" description:"Cache write or cache creation token price in USD per 1M tokens"`
}
// Reasoning effort levels, ordered low to high for clamping and comparison.
const (
ChatModelReasoningEffortNone = "none"
@@ -1509,7 +1500,6 @@ type ChatModelCallConfig struct {
TopK *int64 `json:"top_k,omitempty" description:"Number of highest-probability tokens to keep for sampling"`
PresencePenalty *float64 `json:"presence_penalty,omitempty" description:"Penalty for tokens that have already appeared in the output"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty" description:"Penalty for tokens based on their frequency in the output"`
Cost *ModelCostConfig `json:"cost,omitempty" description:"Optional pricing metadata for this model"`
ReasoningEffort *ChatModelReasoningEffortConfig `json:"reasoning_effort,omitempty" description:"Default and max reasoning effort for the model"`
OpenAIConfig *ChatModelOpenAIConfig `json:"openai_config,omitempty" description:"OpenAI client construction settings" providers:"openai"`
ProviderOptions *ChatModelProviderOptions `json:"provider_options,omitempty" description:"Provider-specific option overrides"`
@@ -1521,67 +1511,30 @@ type ChatModelOpenAIConfig struct {
UseResponsesAPI *bool `json:"use_responses_api,omitempty" label:"Use Responses API" description:"Override which OpenAI API this model uses. Leave unset to decide from the provider SDK's known-model list, true to force the Responses API, false to force Chat Completions. Azure OpenAI providers ignore this and always follow the known-model list."`
}
// UnmarshalJSON accepts both the current nested cost object and the previous
// top-level pricing keys so legacy stored model_config JSON continues to load.
func (c *ChatModelCallConfig) UnmarshalJSON(data []byte) error {
return c.unmarshal(data, json.Unmarshal)
}
// UnmarshalStrict is UnmarshalJSON except unknown fields are an error instead
// of being silently dropped. Clients that accept free-form model config JSON
// (e.g. the Terraform provider) use it to reject settings this SDK version
// does not recognize before they are lost.
// UnmarshalStrict rejects unknown fields except for removed pricing fields,
// which may still be present in stored model configuration JSON: the nested
// cost object and the four top-level per-million-token price keys that
// predate it (see migration 000435, which read both forms).
func (c *ChatModelCallConfig) UnmarshalStrict(data []byte) error {
return c.unmarshal(data, func(data []byte, v any) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
// Match json.Unmarshal: reject any trailing data after the value.
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
return xerrors.New("unexpected trailing data after JSON value")
}
return nil
})
}
func (c *ChatModelCallConfig) unmarshal(data []byte, decode func(data []byte, v any) error) error {
type chatModelCallConfigAlias ChatModelCallConfig
aux := struct {
*chatModelCallConfigAlias
InputPricePerMillionTokens *decimal.Decimal `json:"input_price_per_million_tokens,omitempty"`
OutputPricePerMillionTokens *decimal.Decimal `json:"output_price_per_million_tokens,omitempty"`
CacheReadPricePerMillionTokens *decimal.Decimal `json:"cache_read_price_per_million_tokens,omitempty"`
CacheWritePricePerMillionTokens *decimal.Decimal `json:"cache_write_price_per_million_tokens,omitempty"`
Cost json.RawMessage `json:"cost"`
InputPrice json.RawMessage `json:"input_price_per_million_tokens"`
OutputPrice json.RawMessage `json:"output_price_per_million_tokens"`
CacheReadPrice json.RawMessage `json:"cache_read_price_per_million_tokens"`
CacheWritePrice json.RawMessage `json:"cache_write_price_per_million_tokens"`
}{
chatModelCallConfigAlias: (*chatModelCallConfigAlias)(c),
}
if err := decode(data, &aux); err != nil {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&aux); err != nil {
return err
}
if aux.InputPricePerMillionTokens == nil &&
aux.OutputPricePerMillionTokens == nil &&
aux.CacheReadPricePerMillionTokens == nil &&
aux.CacheWritePricePerMillionTokens == nil {
return nil
}
if c.Cost == nil {
c.Cost = &ModelCostConfig{}
}
if c.Cost.InputPricePerMillionTokens == nil {
c.Cost.InputPricePerMillionTokens = aux.InputPricePerMillionTokens
}
if c.Cost.OutputPricePerMillionTokens == nil {
c.Cost.OutputPricePerMillionTokens = aux.OutputPricePerMillionTokens
}
if c.Cost.CacheReadPricePerMillionTokens == nil {
c.Cost.CacheReadPricePerMillionTokens = aux.CacheReadPricePerMillionTokens
}
if c.Cost.CacheWritePricePerMillionTokens == nil {
c.Cost.CacheWritePricePerMillionTokens = aux.CacheWritePricePerMillionTokens
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
return xerrors.New("unexpected trailing data after JSON value")
}
return nil
}
@@ -1940,64 +1893,6 @@ type ChatStreamEvent struct {
ActionRequired *ChatStreamActionRequired `json:"action_required,omitempty"`
}
// ChatCostSummaryOptions are optional query parameters for GetChatCostSummary.
type ChatCostSummaryOptions struct {
StartDate time.Time
EndDate time.Time
}
// ChatCostUsersOptions are optional query parameters for GetChatCostUsers.
type ChatCostUsersOptions struct {
StartDate time.Time
EndDate time.Time
Username string
Pagination
}
// ChatCostSummary is the response from the chat cost summary endpoint.
type ChatCostSummary struct {
StartDate time.Time `json:"start_date" format:"date-time"`
EndDate time.Time `json:"end_date" format:"date-time"`
TotalCostMicros int64 `json:"total_cost_micros"`
PricedMessageCount int64 `json:"priced_message_count"`
UnpricedMessagesHavingUsageCount int64 `json:"unpriced_messages_having_usage_count"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
TotalRuntimeMs int64 `json:"total_runtime_ms"`
ByModel []ChatCostModelBreakdown `json:"by_model"`
ByChat []ChatCostChatBreakdown `json:"by_chat"`
}
// ChatCostModelBreakdown contains per-model cost aggregation.
type ChatCostModelBreakdown struct {
ModelConfigID uuid.UUID `json:"model_config_id" format:"uuid"`
DisplayName string `json:"display_name"`
Provider string `json:"provider"`
Model string `json:"model"`
TotalCostMicros int64 `json:"total_cost_micros"`
MessageCount int64 `json:"message_count"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
TotalRuntimeMs int64 `json:"total_runtime_ms"`
}
// ChatCostChatBreakdown contains per-root-chat cost aggregation.
type ChatCostChatBreakdown struct {
RootChatID uuid.UUID `json:"root_chat_id" format:"uuid"`
ChatTitle string `json:"chat_title"`
TotalCostMicros int64 `json:"total_cost_micros"`
MessageCount int64 `json:"message_count"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
TotalRuntimeMs int64 `json:"total_runtime_ms"`
}
// 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
@@ -2012,30 +1907,6 @@ type ChatCost struct {
UnpricedRequestCount int64 `json:"unpriced_request_count"`
}
// ChatCostUserRollup contains per-user cost aggregation for admin views.
type ChatCostUserRollup struct {
UserID uuid.UUID `json:"user_id" format:"uuid"`
Username string `json:"username"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
TotalCostMicros int64 `json:"total_cost_micros"`
MessageCount int64 `json:"message_count"`
ChatCount int64 `json:"chat_count"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
TotalRuntimeMs int64 `json:"total_runtime_ms"`
}
// ChatCostUsersResponse is the response from the admin chat cost users endpoint.
type ChatCostUsersResponse struct {
StartDate time.Time `json:"start_date" format:"date-time"`
EndDate time.Time `json:"end_date" format:"date-time"`
Count int64 `json:"count"`
Users []ChatCostUserRollup `json:"users"`
}
// ChatHookDispatchFailedResponse is the error body returned when a
// lifecycle hook dispatch fails during a synchronous chat operation.
// Kind lets clients classify the failure without parsing message text.
@@ -2357,34 +2228,6 @@ func (c *ExperimentalClient) DeleteChatModelConfig(ctx context.Context, modelCon
return nil
}
// GetChatCostSummary returns an aggregate cost summary for the specified
// user. Zero-valued StartDate or EndDate fields are omitted from the
// request, letting the server apply its own defaults (typically the last
// 30 days).
func (c *ExperimentalClient) GetChatCostSummary(ctx context.Context, user string, opts ChatCostSummaryOptions) (ChatCostSummary, error) {
qp := url.Values{}
if !opts.StartDate.IsZero() {
qp.Set("start_date", opts.StartDate.Format(time.RFC3339))
}
if !opts.EndDate.IsZero() {
qp.Set("end_date", opts.EndDate.Format(time.RFC3339))
}
reqURL := fmt.Sprintf("/api/experimental/chats/cost/%s/summary", user)
if len(qp) > 0 {
reqURL += "?" + qp.Encode()
}
res, err := c.Request(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return ChatCostSummary{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ChatCostSummary{}, ReadBodyAsError(res)
}
var summary ChatCostSummary
return summary, json.NewDecoder(res.Body).Decode(&summary)
}
// GetChatCost returns the AI Gateway cost for the whole chat tree that
// contains chatID.
func (c *ExperimentalClient) GetChatCost(ctx context.Context, chatID uuid.UUID) (ChatCost, error) {
@@ -2400,43 +2243,6 @@ func (c *ExperimentalClient) GetChatCost(ctx context.Context, chatID uuid.UUID)
return cost, json.NewDecoder(res.Body).Decode(&cost)
}
// GetChatCostUsers returns a per-user cost rollup for the deployment
// (admin only). Zero-valued StartDate or EndDate fields are omitted from
// the request, letting the server apply its own defaults (typically the
// last 30 days).
func (c *ExperimentalClient) GetChatCostUsers(ctx context.Context, opts ChatCostUsersOptions) (ChatCostUsersResponse, error) {
qp := url.Values{}
if !opts.StartDate.IsZero() {
qp.Set("start_date", opts.StartDate.Format(time.RFC3339))
}
if !opts.EndDate.IsZero() {
qp.Set("end_date", opts.EndDate.Format(time.RFC3339))
}
if opts.Username != "" {
qp.Set("username", opts.Username)
}
if opts.Limit > 0 {
qp.Set("limit", strconv.Itoa(opts.Limit))
}
if opts.Offset > 0 {
qp.Set("offset", strconv.Itoa(opts.Offset))
}
reqURL := "/api/experimental/chats/cost/users"
if len(qp) > 0 {
reqURL += "?" + qp.Encode()
}
res, err := c.Request(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return ChatCostUsersResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ChatCostUsersResponse{}, ReadBodyAsError(res)
}
var resp ChatCostUsersResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
// GetChatSystemPrompt returns the deployment-wide chat system prompt.
func (c *ExperimentalClient) GetChatSystemPrompt(ctx context.Context) (ChatSystemPromptResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/system-prompt", nil)
+23 -74
View File
@@ -9,7 +9,6 @@ import (
"time"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -434,75 +433,40 @@ func TestChatMessagePart_ReasoningTimestamps_JSON(t *testing.T) {
})
}
func TestModelCostConfig_LegacyNumericJSON(t *testing.T) {
func TestChatModelCallConfig_UnmarshalStoredCost(t *testing.T) {
t.Parallel()
var decoded codersdk.ModelCostConfig
err := json.Unmarshal([]byte("{\"input_price_per_million_tokens\": 1.5}"), &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.InputPricePerMillionTokens)
require.True(t, decoded.InputPricePerMillionTokens.Equal(decimal.RequireFromString("1.5")))
}
func TestModelCostConfig_QuotedDecimalJSON(t *testing.T) {
t.Parallel()
var decoded codersdk.ModelCostConfig
err := json.Unmarshal([]byte("{\"input_price_per_million_tokens\": \"1.5\"}"), &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.InputPricePerMillionTokens)
require.True(t, decoded.InputPricePerMillionTokens.Equal(decimal.RequireFromString("1.5")))
}
func TestModelCostConfig_NilVsZero(t *testing.T) {
t.Parallel()
zero := decimal.Zero
raw, err := json.Marshal(struct {
Nil codersdk.ModelCostConfig `json:"nil"`
Zero codersdk.ModelCostConfig `json:"zero"`
}{
Nil: codersdk.ModelCostConfig{},
Zero: codersdk.ModelCostConfig{InputPricePerMillionTokens: &zero},
})
require.NoError(t, err)
require.Contains(t, string(raw), "\"zero\":{\"input_price_per_million_tokens\":\"0\"}")
require.Contains(t, string(raw), "\"nil\":{}")
}
func TestChatModelCallConfig_UnmarshalLegacyPricing(t *testing.T) {
t.Parallel()
var decoded codersdk.ChatModelCallConfig
err := json.Unmarshal([]byte("{\"input_price_per_million_tokens\": 1.5}"), &decoded)
require.NoError(t, err)
require.NotNil(t, decoded.Cost)
require.NotNil(t, decoded.Cost.InputPricePerMillionTokens)
require.True(t, decoded.Cost.InputPricePerMillionTokens.Equal(decimal.RequireFromString("1.5")))
}
func TestChatModelCallConfig_UnmarshalStrict(t *testing.T) {
t.Parallel()
var decoded codersdk.ChatModelCallConfig
err := decoded.UnmarshalStrict([]byte(`{
raw := []byte(`{
"temperature": 0.5,
"cost": {"input_price_per_million_tokens": "5"},
"input_price_per_million_tokens": 1.5,
"provider_options": {"anthropic": {"thinking": {"budget_tokens": 1024}}}
}`))
require.NoError(t, err)
require.NotNil(t, decoded.Temperature)
require.True(t, decoded.Cost.InputPricePerMillionTokens.Equal(decimal.RequireFromString("5")))
}`)
err = decoded.UnmarshalStrict([]byte(`{"provider_options": {"anthropic": {"bogus_setting": true}}}`))
var decoded codersdk.ChatModelCallConfig
require.NoError(t, json.Unmarshal(raw, &decoded))
require.NotNil(t, decoded.Temperature)
require.NoError(t, decoded.UnmarshalStrict(raw))
require.NotNil(t, decoded.Temperature)
// Configs predating the nested cost object stored the pricing keys at
// the top level (see migration 000435).
legacyTopLevel := []byte(`{
"temperature": 0.5,
"input_price_per_million_tokens": "5",
"output_price_per_million_tokens": "10",
"cache_read_price_per_million_tokens": "1",
"cache_write_price_per_million_tokens": "2"
}`)
require.NoError(t, decoded.UnmarshalStrict(legacyTopLevel))
require.NotNil(t, decoded.Temperature)
err := decoded.UnmarshalStrict([]byte(`{"provider_options": {"anthropic": {"bogus_setting": true}}}`))
require.ErrorContains(t, err, `unknown field "bogus_setting"`)
// Trailing data after the first value is rejected, matching json.Unmarshal.
err = decoded.UnmarshalStrict([]byte(`{"temperature": 0.5} {"bogus_setting": true}`))
require.ErrorContains(t, err, "trailing data")
// UnmarshalJSON stays lenient.
require.NoError(t, json.Unmarshal([]byte(`{"bogus_setting": true}`), &decoded))
}
@@ -528,21 +492,6 @@ func TestChatModelCallConfig_UseResponsesAPIRoundTrip(t *testing.T) {
require.NotContains(t, string(raw), "use_responses_api")
}
func TestChatCostSummary_JSONRoundTrip(t *testing.T) {
t.Parallel()
original := codersdk.ChatCostSummary{
TotalCostMicros: 123,
}
raw, err := json.Marshal(original)
require.NoError(t, err)
var decoded codersdk.ChatCostSummary
err = json.Unmarshal(raw, &decoded)
require.NoError(t, err)
require.Equal(t, original.TotalCostMicros, decoded.TotalCostMicros)
}
// TestChat_JSONRoundTrip verifies that every field of codersdk.Chat
// survives a JSON marshal/unmarshal cycle. This catches omitempty
// silently eating zero-ish values, struct tag typos, and similar
+1 -2
View File
@@ -224,8 +224,7 @@ sub-agent delegation, and complex multi-step work can consume significant
token volume. Consider:
- Starting with a single model to establish a cost baseline.
- Setting per-model token pricing under **Admin settings** > **AI** >
**Models** (Input Price, Output Price) to track spend.
- Capping spend with [AI Gateway budgets](./platform-controls/spend-management.md).
- Monitoring provider dashboards for usage trends during the evaluation.
### Pilot with a small group
-4
View File
@@ -192,10 +192,6 @@ These options apply to all providers:
| Top K | Limits token selection to the top K candidates. |
| Presence Penalty | Penalizes tokens that have already appeared in the conversation. |
| Frequency Penalty | Penalizes tokens proportional to how often they have appeared. |
| Input Price | Optional USD price metadata for input tokens, recorded per 1M tokens. |
| Output Price | Optional USD price metadata for output tokens, recorded per 1M tokens. |
| Cache Read Price | Optional USD price metadata for cache read tokens, recorded per 1M tokens. |
| Cache Write Price | Optional USD price metadata for cache creation/write tokens, recorded per 1M tokens. |
### Provider-specific options
@@ -124,7 +124,7 @@ Budgets are the only spend cap for Coder Agents chats.
Chats no longer enforce a separate limit of their own, and existing native limit values are not migrated to budgets.
Budget controls in the Coder UI, the group budget endpoints, and the AI spend status endpoints all require a license that includes AI Gateway.
Refer to [Spend Management](./usage-insights.md) for details.
Refer to [Spend management](./spend-management.md) for details.
### Git providers
@@ -1,4 +1,4 @@
# Spend Management
# Spend management (Premium)
Coder controls agent spend with AI Gateway budgets, and surfaces the resulting spend to both admins and users.
@@ -23,8 +23,17 @@ $1,000,000 per member per period.
> Budget controls in the Coder UI, the group budget endpoints (`/api/v2/groups/{group}/ai/budget`), and the AI spend status and reporting endpoints all require the AI Gateway entitlement.
> No experiment is needed.
>
> Native chat usage limits are removed from the application.
> Native chat usage limits and native cost tracking are removed from the application.
> Existing native limit values are not migrated to AI Gateway budgets and are no longer enforced.
> Configured per-model prices and historical native cost totals are also not migrated to AI Gateway.
> Before upgrading, record any per-model prices you need from **Admin settings** > **AI** > **Models**.
> The old cost endpoints default `start_date` to 30 days before the request and `end_date` to the request time, so choose explicit RFC 3339 UTC values that cover all history you need.
> Fetch `/api/experimental/chats/cost/users?start_date=<start>&end_date=<end>&limit=100&offset=0` and save the response.
> After each page, stop when `offset + users.length >= count`; otherwise, increase `offset` by 100 and fetch the next page.
> For every `users[].user_id` across those pages, save `/api/experimental/chats/cost/{user_id}/summary?start_date=<start>&end_date=<end>` with the same dates.
> Each summary contains the user's totals plus `by_model` and `by_chat` breakdowns.
> After upgrading, the native **Spend** page, per-model pricing fields, and aggregate cost endpoints are unavailable.
> Historical `chat_messages.total_cost_micros` values remain in the database temporarily for rolling upgrade compatibility, but AI Gateway reports do not include or reconstruct them.
> Configure AI Gateway budgets separately.
The API reference documents how to [get](../../../reference/api/enterprise.md#get-group-ai-budget), [upsert](../../../reference/api/enterprise.md#upsert-group-ai-budget), and [delete](../../../reference/api/enterprise.md#delete-group-ai-budget) a group budget.
@@ -40,16 +49,30 @@ The API reference documents how to [get](../../../reference/api/enterprise.md#ge
The usage indicator on the Agents page and the summary in the user menu both show the signed-in user's current AI spend, their budget, and the period reset date.
Both appear only when the deployment has the AI Gateway entitlement.
## Spend visibility
## Spend details
Coder has no dedicated deployment-wide spend dashboard.
Spend is shown where it is actionable:
- **Agents page and user menu**: the signed-in user's spend against their budget, as described previously.
- **Group settings**: each member's spend against the group's budget, for admins who can manage the group.
- **Chat summary panel**: the cost of one chat tree, on a chat's Summary tab.
A subagent reports the total for its whole tree, including the chat that started it.
- **Agents** > **Settings** > **Manage Agents** > **Spend**: deployment-wide chat cost per user, with per-user drill-down.
> [!NOTE]
> Per-chat cost comes from AI Gateway records, which are pruned according to `--ai-gateway-retention` (60 days by default).
> A chat for which gateway records have been pruned reports no cost.
Organization administrators can export per-user, per-group, per-model, and per-provider spend to CSV:
```sh
curl -X GET "https://coder.example.com/api/v2/organizations/$ORGANIZATION/ai/spend/export" \
-H "Coder-Session-Token: $CODER_SESSION_TOKEN"
```
A successful response has the `Content-Type` header `text/csv; charset=utf-8` and starts with this CSV header:
```csv
user_id,username,group_id,group_name,organization_id,organization_name,model,provider,provider_name,input_tokens,output_tokens,cache_read_tokens,cache_write_tokens,cost_micros,period_start,period_end
```
The AI Gateway [sessions views](../../ai-gateway/audit.md#navigating-the-ui) show per-request token usage, which is the input to those costs rather than the costs themselves.
AI Gateway data is subject to its own [retention period](../../ai-gateway/monitoring.md#data-retention), 60 days by default, which is configured independently of chat retention.
Spend for requests older than that period is no longer reported, so a chat for which gateway records have been pruned reports no cost.
+3 -3
View File
@@ -1084,10 +1084,10 @@
"state": ["beta"]
},
{
"title": "Spend Management",
"title": "Spend management",
"description": "Cap Coder Agents spend with AI Gateway budgets and track the resulting spend.",
"path": "./ai-coder/agents/platform-controls/usage-insights.md",
"state": ["beta"]
"path": "./ai-coder/agents/platform-controls/spend-management.md",
"state": ["premium"]
},
{
"title": "Git Providers",
-34
View File
@@ -361,17 +361,6 @@ const userAIProviderKeysPath = (user = "me") =>
`/api/experimental/users/${encodeURIComponent(user)}/ai-provider-keys`;
const mcpServerConfigsPath = "/api/experimental/mcp/servers";
type ChatCostDateParams = {
start_date?: string;
end_date?: string;
};
type ChatCostUsersParams = ChatCostDateParams & {
username?: string;
limit?: number;
offset?: number;
};
type Claims = {
license_expires: number;
// nbf is a standard JWT claim for "not before" - the license valid from date
@@ -3943,29 +3932,6 @@ class ExperimentalApiMethods {
);
return response.data;
};
getChatCostSummary = async (
user = "me",
params?: ChatCostDateParams,
): Promise<TypesGen.ChatCostSummary> => {
const url = getURLWithSearchParams(
`/api/experimental/chats/cost/${encodeURIComponent(user)}/summary`,
params,
);
const response = await this.axios.get<TypesGen.ChatCostSummary>(url);
return response.data;
};
getChatCostUsers = async (
params?: ChatCostUsersParams,
): Promise<TypesGen.ChatCostUsersResponse> => {
const url = getURLWithSearchParams(
"/api/experimental/chats/cost/users",
params,
);
const response = await this.axios.get<TypesGen.ChatCostUsersResponse>(url);
return response.data;
};
}
// This is a hard coded CSRF token/cookie pair for local development. In prod,
+1 -43
View File
@@ -54,54 +54,12 @@ export interface ModelOptionsSchema {
export const modelOptionsSchema: ModelOptionsSchema =
schema as ModelOptionsSchema;
const syntheticGeneralFields: FieldSchema[] = [
{
json_name: "cost.input_price_per_million_tokens",
go_name: "Cost.InputPricePerMillionTokens",
type: "number",
description: "Input token price in USD per 1M tokens",
required: false,
input_type: "input",
},
{
json_name: "cost.output_price_per_million_tokens",
go_name: "Cost.OutputPricePerMillionTokens",
type: "number",
description: "Output token price in USD per 1M tokens",
required: false,
input_type: "input",
},
{
json_name: "cost.cache_read_price_per_million_tokens",
go_name: "Cost.CacheReadPricePerMillionTokens",
type: "number",
description: "Cache read token price in USD per 1M tokens",
required: false,
input_type: "input",
},
{
json_name: "cost.cache_write_price_per_million_tokens",
go_name: "Cost.CacheWritePricePerMillionTokens",
type: "number",
description:
"Cache write or cache creation token price in USD per 1M tokens",
required: false,
input_type: "input",
},
];
/**
* Get the general (provider-independent) fields such as temperature
* and max_output_tokens.
*/
export function getGeneralFields(): FieldSchema[] {
const fields = [...modelOptionsSchema.general.fields];
for (const field of syntheticGeneralFields) {
if (!fields.some((existing) => existing.json_name === field.json_name)) {
fields.push(field);
}
}
return fields;
return modelOptionsSchema.general.fields;
}
/**
@@ -49,38 +49,6 @@
"required": false,
"input_type": "input"
},
{
"json_name": "cost.input_price_per_million_tokens",
"go_name": "Cost.InputPricePerMillionTokens",
"type": "number",
"description": "Input token price in USD per 1M tokens",
"required": false,
"input_type": "input"
},
{
"json_name": "cost.output_price_per_million_tokens",
"go_name": "Cost.OutputPricePerMillionTokens",
"type": "number",
"description": "Output token price in USD per 1M tokens",
"required": false,
"input_type": "input"
},
{
"json_name": "cost.cache_read_price_per_million_tokens",
"go_name": "Cost.CacheReadPricePerMillionTokens",
"type": "number",
"description": "Cache read token price in USD per 1M tokens",
"required": false,
"input_type": "input"
},
{
"json_name": "cost.cache_write_price_per_million_tokens",
"go_name": "Cost.CacheWritePricePerMillionTokens",
"type": "number",
"description": "Cache write or cache creation token price in USD per 1M tokens",
"required": false,
"input_type": "input"
},
{
"json_name": "reasoning_effort.default",
"go_name": "ReasoningEffort.Default",
-84
View File
@@ -17,8 +17,6 @@ import {
chatAdvisorConfigKey,
chatCost,
chatCostKey,
chatCostSummary,
chatCostSummaryKey,
chatDebugRunsKey,
chatDiffContentsKey,
chatKey,
@@ -35,7 +33,6 @@ import {
invalidateChatListQueries,
mergeWatchedChatIntoCaches,
mergeWatchedChatSummary,
paginatedChatCostUsers,
pinChat,
prependToInfiniteChatsCache,
promoteChatQueuedMessage,
@@ -62,8 +59,6 @@ vi.mock("#/api/api", () => ({
deleteChatQueuedMessage: vi.fn(),
getChats: vi.fn(),
getChatCost: vi.fn(),
getChatCostSummary: vi.fn(),
getChatCostUsers: vi.fn(),
createChatMessage: vi.fn(),
editChatMessage: vi.fn(),
interruptChat: vi.fn(),
@@ -207,10 +202,6 @@ describe("invalidateChatListQueries", () => {
queryClient.setQueryData(chatKey(chatId), makeChat(chatId));
queryClient.setQueryData(chatMessagesKey(chatId), []);
queryClient.setQueryData(chatDiffContentsKey(chatId), {});
queryClient.setQueryData(
chatCostSummaryKey("me", undefined),
{} as TypesGen.ChatCostSummary,
);
await invalidateChatListQueries(queryClient);
@@ -238,11 +229,6 @@ describe("invalidateChatListQueries", () => {
queryClient.getQueryState(chatDiffContentsKey(chatId))?.isInvalidated,
"chatDiffContentsKey should NOT be invalidated",
).not.toBe(true);
expect(
queryClient.getQueryState(chatCostSummaryKey("me", undefined))
?.isInvalidated,
"chatCostSummaryKey should NOT be invalidated",
).not.toBe(true);
});
it("invalidates the infinite query with undefined opts", async () => {
@@ -847,32 +833,6 @@ describe("reorderPinnedChat", () => {
});
describe("chat cost query factories", () => {
it("builds the summary query key and forwards snake_case params", async () => {
const user = "user-1";
const params = {
start_date: "2025-01-01",
end_date: "2025-01-31",
};
vi.mocked(API.experimental.getChatCostSummary).mockResolvedValue(
{} as TypesGen.ChatCostSummary,
);
const query = chatCostSummary(user, params);
expect(chatCostSummaryKey(user, params)).toEqual([
"chats",
"costSummary",
user,
params,
]);
expect(query.queryKey).toEqual(["chats", "costSummary", user, params]);
await query.queryFn();
expect(API.experimental.getChatCostSummary).toHaveBeenCalledWith(
user,
params,
);
});
it("builds the per-chat cost query key and forwards the chat id", async () => {
const chatId = "chat-1";
vi.mocked(API.experimental.getChatCost).mockResolvedValue(
@@ -886,44 +846,6 @@ describe("chat cost query factories", () => {
await query.queryFn();
expect(API.experimental.getChatCost).toHaveBeenCalledWith(chatId);
});
it("builds paginated cost users query with correct key and coerces empty username", async () => {
const payload = {
start_date: "2025-01-01",
end_date: "2025-01-31",
username: "",
};
vi.mocked(API.experimental.getChatCostUsers).mockResolvedValue(
{} as TypesGen.ChatCostUsersResponse,
);
const result = paginatedChatCostUsers(payload);
// queryPayload returns the original payload.
const pageParams = {
pageNumber: 2,
limit: 25,
offset: 25,
searchParams: new URLSearchParams(),
};
expect(result.queryPayload(pageParams)).toEqual(payload);
// queryKey includes the payload and page number.
const key = result.queryKey({ ...pageParams, payload });
expect(key).toEqual(["chats", "costUsers", payload, 2]);
// queryFn coerces empty username to undefined.
// Cast needed because PaginatedQueryFnContext includes
// react-query internal fields that aren't relevant here.
await (
result.queryFn as (params: Record<string, unknown>) => Promise<unknown>
)({
...pageParams,
payload,
});
expect(API.experimental.getChatCostUsers).toHaveBeenCalledWith(
expect.objectContaining({ username: undefined, limit: 25, offset: 25 }),
);
});
});
describe("mutation invalidation scope", () => {
@@ -953,18 +875,12 @@ describe("mutation invalidation scope", () => {
queryClient.setQueryData(chatDebugRunsKey(chatId), []);
// Diff contents: ["chats", chatId, "diff-contents"]
queryClient.setQueryData(chatDiffContentsKey(chatId), { files: [] });
// Cost summary: ["chats", "costSummary", "me", undefined]
queryClient.setQueryData(
chatCostSummaryKey("me", undefined),
{} as TypesGen.ChatCostSummary,
);
};
/** Keys that should NEVER be invalidated by chat message mutations
* because they are completely unrelated to the message flow. */
const unrelatedKeys = (chatId: string) => [
{ label: "diff-contents", key: chatDiffContentsKey(chatId) },
{ label: "cost-summary", key: chatCostSummaryKey("me", undefined) },
];
it("createChatMessage does not invalidate unrelated queries", async () => {
-43
View File
@@ -10,7 +10,6 @@ import {
type CreateChatMessageRequestWithClearablePlanMode,
} from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery";
import {
projectEditedConversationIntoCache,
reconcileEditedMessageInCache,
@@ -1952,20 +1951,6 @@ export const deleteChatModelConfig = (queryClient: QueryClient) => ({
},
});
type ChatCostDateParams = {
start_date?: string;
end_date?: string;
};
export const chatCostSummaryKey = (user = "me", params?: ChatCostDateParams) =>
[...chatsKey, "costSummary", user, params] as const;
export const chatCostSummary = (user = "me", params?: ChatCostDateParams) => ({
queryKey: chatCostSummaryKey(user, params),
queryFn: () => API.experimental.getChatCostSummary(user, params),
staleTime: 60_000,
});
export const chatCostKey = (rootChatId: string) =>
[...chatsKey, rootChatId, "cost"] as const;
@@ -1977,34 +1962,6 @@ export const chatCost = (rootChatId: string) => ({
staleTime: GATEWAY_REQUEST_STALE_MS,
});
interface PaginatedChatCostUsersPayload {
username: string;
start_date: string;
end_date: string;
}
export function paginatedChatCostUsers(
payload: PaginatedChatCostUsersPayload,
): UsePaginatedQueryOptions<
TypesGen.ChatCostUsersResponse,
PaginatedChatCostUsersPayload
> {
return {
queryPayload: () => payload,
queryKey: ({ payload, pageNumber }) =>
[...chatsKey, "costUsers", payload, pageNumber] as const,
queryFn: ({ payload, limit, offset }) =>
API.experimental.getChatCostUsers({
start_date: payload.start_date,
end_date: payload.end_date,
username: payload.username || undefined,
limit,
offset,
}),
staleTime: 60_000,
};
}
// ── MCP Server Configs ───────────────────────────────────────
export const mcpServerConfigsKey = ["mcp-server-configs"] as const;
-114
View File
@@ -2202,108 +2202,6 @@ export interface ChatCost {
readonly unpriced_request_count: number;
}
// From codersdk/chats.go
/**
* ChatCostChatBreakdown contains per-root-chat cost aggregation.
*/
export interface ChatCostChatBreakdown {
readonly root_chat_id: string;
readonly chat_title: string;
readonly total_cost_micros: number;
readonly message_count: number;
readonly total_input_tokens: number;
readonly total_output_tokens: number;
readonly total_cache_read_tokens: number;
readonly total_cache_creation_tokens: number;
readonly total_runtime_ms: number;
}
// From codersdk/chats.go
/**
* ChatCostModelBreakdown contains per-model cost aggregation.
*/
export interface ChatCostModelBreakdown {
readonly model_config_id: string;
readonly display_name: string;
readonly provider: string;
readonly model: string;
readonly total_cost_micros: number;
readonly message_count: number;
readonly total_input_tokens: number;
readonly total_output_tokens: number;
readonly total_cache_read_tokens: number;
readonly total_cache_creation_tokens: number;
readonly total_runtime_ms: number;
}
// From codersdk/chats.go
/**
* ChatCostSummary is the response from the chat cost summary endpoint.
*/
export interface ChatCostSummary {
readonly start_date: string;
readonly end_date: string;
readonly total_cost_micros: number;
readonly priced_message_count: number;
readonly unpriced_messages_having_usage_count: number;
readonly total_input_tokens: number;
readonly total_output_tokens: number;
readonly total_cache_read_tokens: number;
readonly total_cache_creation_tokens: number;
readonly total_runtime_ms: number;
readonly by_model: readonly ChatCostModelBreakdown[];
readonly by_chat: readonly ChatCostChatBreakdown[];
}
// From codersdk/chats.go
/**
* ChatCostSummaryOptions are optional query parameters for GetChatCostSummary.
*/
export interface ChatCostSummaryOptions {
readonly StartDate: string;
readonly EndDate: string;
}
// From codersdk/chats.go
/**
* ChatCostUserRollup contains per-user cost aggregation for admin views.
*/
export interface ChatCostUserRollup {
readonly user_id: string;
readonly username: string;
readonly name: string;
readonly avatar_url: string;
readonly total_cost_micros: number;
readonly message_count: number;
readonly chat_count: number;
readonly total_input_tokens: number;
readonly total_output_tokens: number;
readonly total_cache_read_tokens: number;
readonly total_cache_creation_tokens: number;
readonly total_runtime_ms: number;
}
// From codersdk/chats.go
/**
* ChatCostUsersOptions are optional query parameters for GetChatCostUsers.
*/
export interface ChatCostUsersOptions extends Pagination {
readonly StartDate: string;
readonly EndDate: string;
readonly Username: string;
}
// From codersdk/chats.go
/**
* ChatCostUsersResponse is the response from the admin chat cost users endpoint.
*/
export interface ChatCostUsersResponse {
readonly start_date: string;
readonly end_date: string;
readonly count: number;
readonly users: readonly ChatCostUserRollup[];
}
// From codersdk/chats.go
/**
* ChatDebugLoggingAdminSettings describes the runtime admin setting
@@ -2890,7 +2788,6 @@ export interface ChatModelCallConfig {
readonly top_k?: number;
readonly presence_penalty?: number;
readonly frequency_penalty?: number;
readonly cost?: ModelCostConfig;
readonly reasoning_effort?: ChatModelReasoningEffortConfig;
readonly openai_config?: ChatModelOpenAIConfig;
readonly provider_options?: ChatModelProviderOptions;
@@ -6185,17 +6082,6 @@ export interface MinimalUser {
readonly avatar_url?: string;
}
// From codersdk/chats.go
/**
* ModelCostConfig stores pricing metadata for a chat model.
*/
export interface ModelCostConfig {
readonly input_price_per_million_tokens?: string;
readonly output_price_per_million_tokens?: string;
readonly cache_read_price_per_million_tokens?: string;
readonly cache_write_price_per_million_tokens?: string;
}
// From netcheck/netcheck.go
/**
* Report contains the result of a single netcheck.
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { MockNoPermissions, MockPermissions } from "#/testHelpers/entities";
import AISettingsSidebarView from "./AISettingsSidebarView";
@@ -20,7 +21,6 @@ const meta: Meta<typeof AISettingsSidebarView> = {
{ path: "/ai/settings/models", useStoryElement: true },
{ path: "/ai/settings/mcp-servers", useStoryElement: true },
{ path: "/ai/settings/templates", useStoryElement: true },
{ path: "/ai/settings/spend", useStoryElement: true },
{ path: "/ai/settings/instructions", useStoryElement: true },
{ path: "/ai/settings/lifecycle", useStoryElement: true },
],
@@ -31,7 +31,15 @@ const meta: Meta<typeof AISettingsSidebarView> = {
export default meta;
type Story = StoryObj<typeof AISettingsSidebarView>;
export const CoderAgentsActive: Story = {};
export const CoderAgentsActive: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("link", { name: "Models" })).toBeVisible();
await expect(
canvas.queryByRole("link", { name: "Spend" }),
).not.toBeInTheDocument();
},
};
export const ModelsActive: Story = {
parameters: {
@@ -42,15 +50,6 @@ export const ModelsActive: Story = {
},
};
export const SpendActive: Story = {
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/spend" },
routing: [{ path: "/ai/settings/spend", useStoryElement: true }],
}),
},
};
export const LifecycleActive: Story = {
parameters: {
reactRouter: reactRouterParameters({
@@ -63,7 +63,6 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
MCP servers
</SubNavItem>
<SubNavItem href="/ai/settings/templates">Templates</SubNavItem>
<SubNavItem href="/ai/settings/spend">Spend</SubNavItem>
<SubNavItem href="/ai/settings/instructions">
Instructions
</SubNavItem>
@@ -411,15 +411,19 @@ export const ReasoningEffortValidationError: Story = {
},
};
export const CostTrackingExpanded: Story = {
export const NativeCostTrackingIsUnavailable: Story = {
args: {
editingModel: mockGPT5,
onDeleteModel: fn(async () => undefined),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = canvas.getByRole("button", { name: /cost tracking/i });
await userEvent.click(toggle);
await expect(
canvas.getByRole("button", { name: /provider configuration/i }),
).toBeVisible();
await expect(
canvas.queryByRole("button", { name: /cost tracking/i }),
).not.toBeInTheDocument();
},
};
@@ -88,7 +88,6 @@ export const ModelForm: FC<ModelFormProps> = ({
...(isDuplicating && { isDefault: false }),
};
const [showAdvanced, setShowAdvanced] = useState(false);
const [showPricing, setShowPricing] = useState(false);
const [showProviderConfig, setShowProviderConfig] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [confirmingReplaceDefault, setConfirmingReplaceDefault] =
@@ -331,8 +330,6 @@ export const ModelForm: FC<ModelFormProps> = ({
displayNameField={displayNameField}
setDefaultDisabled={setDefaultDisabled}
modelConfigFormBuildResult={modelConfigFormBuildResult}
showPricing={showPricing}
setShowPricing={setShowPricing}
showProviderConfig={showProviderConfig}
setShowProviderConfig={setShowProviderConfig}
showAdvanced={showAdvanced}
@@ -28,7 +28,6 @@ import type { ProviderState } from "#/modules/aiModels/providerStates";
import {
GeneralModelConfigFields,
ModelConfigFields,
PricingModelConfigFields,
ReasoningEffortConfigFields,
} from "#/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields";
import { ModelIdentifierField } from "#/pages/AgentsPage/components/ChatModelAdminPanel/ModelIdentifierField";
@@ -102,8 +101,6 @@ export const ModelFormFields: FC<{
displayNameField: FormHelpers;
setDefaultDisabled: boolean;
modelConfigFormBuildResult: ModelConfigFormBuildResult;
showPricing: boolean;
setShowPricing: (open: boolean) => void;
showProviderConfig: boolean;
setShowProviderConfig: (open: boolean) => void;
showAdvanced: boolean;
@@ -127,8 +124,6 @@ export const ModelFormFields: FC<{
displayNameField,
setDefaultDisabled,
modelConfigFormBuildResult,
showPricing,
setShowPricing,
showProviderConfig,
setShowProviderConfig,
showAdvanced,
@@ -244,21 +239,6 @@ export const ModelFormFields: FC<{
</div>
<div className="overflow-hidden rounded-lg border border-solid border-border">
<CollapsibleSection
title="Cost tracking"
description="Set per-token pricing so Coder can track costs and enforce spending limits."
open={showPricing}
onOpenChange={setShowPricing}
contentClassName="grid grid-cols-2 gap-3 pt-3 pl-6 sm:grid-cols-4"
>
<PricingModelConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
</CollapsibleSection>
{hasProviderConfigFields && (
<CollapsibleSection
title="Provider configuration"
@@ -1,159 +0,0 @@
import dayjs from "dayjs";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { useSearchParams } from "react-router";
import { chatCostSummary, paginatedChatCostUsers } from "#/api/queries/chats";
import { user } from "#/api/queries/users";
import type { ChatCostUserRollup } from "#/api/typesGenerated";
import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker";
import { useDebouncedValue } from "#/hooks/debounce";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
import { RequirePermission } from "#/modules/permissions/RequirePermission";
import { SpendPageView } from "./SpendPageView";
import { toExclusiveEndOfDayDateRange } from "./utils/dateRange";
const startDateSearchParam = "startDate";
const endDateSearchParam = "endDate";
const DEFAULT_DATE_RANGE_DAYS = 30;
const SEARCH_DEBOUNCE_MS = 300;
const USAGE_USERS_PAGE_SIZE = 10;
const getDefaultDateRange = (now?: dayjs.Dayjs): DateRangeValue => {
const end = now ?? dayjs();
return {
startDate: end.subtract(DEFAULT_DATE_RANGE_DAYS, "day").toDate(),
endDate: end.toDate(),
};
};
interface SpendPageProps {
now?: dayjs.Dayjs;
}
const SpendPage: FC<SpendPageProps> = ({ now }) => {
const { permissions } = useAuthenticated();
const [searchParams, setSearchParams] = useSearchParams();
const searchFilter = searchParams.get("search") ?? "";
const debouncedSearch = useDebouncedValue(searchFilter, SEARCH_DEBOUNCE_MS);
const setSearchFilter = (value: string) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (value) {
next.set("search", value);
} else {
next.delete("search");
}
next.delete("page");
return next;
},
{ replace: true },
);
};
const startDateParam = searchParams.get(startDateSearchParam)?.trim() ?? "";
const endDateParam = searchParams.get(endDateSearchParam)?.trim() ?? "";
const [defaultDateRange] = useState(() => getDefaultDateRange(now));
let dateRange = defaultDateRange;
let endDateIsExclusive = false;
if (startDateParam && endDateParam) {
const parsedStartDate = new Date(startDateParam);
const parsedEndDate = new Date(endDateParam);
if (
!Number.isNaN(parsedStartDate.getTime()) &&
!Number.isNaN(parsedEndDate.getTime()) &&
parsedStartDate.getTime() <= parsedEndDate.getTime()
) {
dateRange = {
startDate: parsedStartDate,
endDate: parsedEndDate,
};
endDateIsExclusive = true;
}
}
const dateRangeParams = {
start_date: dateRange.startDate.toISOString(),
end_date: dateRange.endDate.toISOString(),
};
const onDateRangeChange = (value: DateRangeValue) => {
const nextDateRange = toExclusiveEndOfDayDateRange(value);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set(startDateSearchParam, nextDateRange.startDate.toISOString());
next.set(endDateSearchParam, nextDateRange.endDate.toISOString());
next.delete("page");
return next;
},
{ replace: true },
);
};
const usersQuery = usePaginatedQuery({
...paginatedChatCostUsers({
...dateRangeParams,
username: debouncedSearch,
}),
recordsPerPage: USAGE_USERS_PAGE_SIZE,
preventScrollReset: true,
});
const selectedUserId = searchParams.get("user") || null;
const selectedUserQuery = useQuery({
...user(selectedUserId ?? ""),
enabled: selectedUserId !== null,
});
const summaryQuery = useQuery({
...chatCostSummary(selectedUserId ?? "me", dateRangeParams),
enabled: selectedUserId !== null,
});
return (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
<SpendPageView
dateRange={dateRange}
endDateIsExclusive={endDateIsExclusive}
onDateRangeChange={onDateRangeChange}
searchFilter={searchFilter}
onSearchFilterChange={setSearchFilter}
usersQuery={usersQuery}
drillInUserId={selectedUserId}
drillInUser={selectedUserQuery.data ?? null}
isDrillInUserLoading={selectedUserQuery.isLoading}
isDrillInUserError={selectedUserQuery.isError}
drillInUserError={selectedUserQuery.error}
onDrillInUserRetry={() => void selectedUserQuery.refetch()}
onClearSelectedUser={() => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete("user");
return next;
});
}}
onSelectUser={(u: ChatCostUserRollup) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("user", u.user_id);
return next;
});
}}
summaryData={summaryQuery.data}
isSummaryLoading={summaryQuery.isLoading}
summaryError={summaryQuery.error}
onSummaryRetry={() => void summaryQuery.refetch()}
/>
</RequirePermission>
);
};
export default SpendPage;
@@ -1,347 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import type { PaginationResult } from "#/components/PaginationWidget/PaginationContainer";
import { SpendPageView } from "./SpendPageView";
const mockUsers: TypesGen.ChatCostUserRollup[] = [
{
user_id: "user-1",
username: "alice",
name: "Alice Liddell",
avatar_url: "",
total_cost_micros: 2_500_000,
message_count: 42,
chat_count: 5,
total_input_tokens: 200_000,
total_output_tokens: 300_000,
total_cache_read_tokens: 10_000,
total_cache_creation_tokens: 5_000,
total_runtime_ms: 0,
},
{
user_id: "user-2",
username: "bob",
name: "Bob Builder",
avatar_url: "",
total_cost_micros: 1_000_000,
message_count: 18,
chat_count: 3,
total_input_tokens: 80_000,
total_output_tokens: 120_000,
total_cache_read_tokens: 4_000,
total_cache_creation_tokens: 2_000,
total_runtime_ms: 0,
},
];
const mockUsersResponse: TypesGen.ChatCostUsersResponse = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
count: mockUsers.length,
users: mockUsers,
};
const mockUserProfile = {
id: "user-1",
username: "alice",
name: "Alice Liddell",
email: "alice@example.com",
avatar_url: "",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-06-01T00:00:00Z",
status: "active",
organization_ids: [],
roles: [],
last_seen_at: "2026-03-11T10:00:00Z",
login_type: "password",
has_ai_seat: false,
} as TypesGen.User;
const mockCostSummary = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
total_cost_micros: 2_500_000,
priced_message_count: 40,
unpriced_messages_having_usage_count: 2,
total_input_tokens: 200_000,
total_output_tokens: 300_000,
total_cache_read_tokens: 10_000,
total_cache_creation_tokens: 5_000,
total_runtime_ms: 0,
by_model: [
{
model_config_id: "model-1",
display_name: "GPT-4.1",
provider: "OpenAI",
model: "gpt-4.1",
total_cost_micros: 2_000_000,
message_count: 30,
total_input_tokens: 150_000,
total_output_tokens: 250_000,
total_cache_read_tokens: 8_000,
total_cache_creation_tokens: 4_000,
total_runtime_ms: 0,
},
],
by_chat: [
{
root_chat_id: "chat-1",
chat_title: "Refactor auth module",
total_cost_micros: 1_200_000,
message_count: 15,
total_input_tokens: 80_000,
total_output_tokens: 120_000,
total_cache_read_tokens: 3_000,
total_cache_creation_tokens: 1_500,
total_runtime_ms: 0,
},
],
} as TypesGen.ChatCostSummary;
const defaultDateRange = {
startDate: new Date("2026-02-10T00:00:00Z"),
endDate: new Date("2026-03-12T00:00:00Z"),
};
function mockUsersQuery(
opts: {
data?: TypesGen.ChatCostUsersResponse;
isLoading?: boolean;
isFetching?: boolean;
error?: unknown;
} = {},
): PaginationResult & {
data: TypesGen.ChatCostUsersResponse | undefined;
isLoading: boolean;
isFetching: boolean;
error: unknown;
refetch: () => unknown;
} {
const data = opts.data;
const isSuccess = data !== undefined && !opts.error;
return {
data,
isLoading: opts.isLoading ?? false,
isFetching: opts.isFetching ?? false,
error: opts.error ?? null,
refetch: fn(),
isPlaceholderData: false,
currentPage: 1,
limit: 25,
onPageChange: fn(),
goToPreviousPage: fn(),
goToNextPage: fn(),
goToFirstPage: fn(),
...(isSuccess
? {
isSuccess: true as const,
hasNextPage: false,
hasPreviousPage: false,
totalRecords: data.count,
totalPages: 1,
currentOffsetStart: data.count === 0 ? 0 : 1,
countIsCapped: false,
}
: {
isSuccess: false as const,
hasNextPage: false,
hasPreviousPage: false,
totalRecords: undefined,
totalPages: undefined,
currentOffsetStart: undefined,
countIsCapped: false,
}),
};
}
const baseProps = {
dateRange: defaultDateRange,
endDateIsExclusive: false,
searchFilter: "",
usersQuery: mockUsersQuery(),
drillInUserId: null as string | null,
drillInUser: null as TypesGen.User | null,
isDrillInUserLoading: false,
isDrillInUserError: false,
drillInUserError: undefined as unknown,
summaryData: undefined as TypesGen.ChatCostSummary | undefined,
isSummaryLoading: false,
summaryError: undefined as unknown,
};
const meta = {
title: "pages/AISettingsPage/SpendPage/SpendPageView",
component: SpendPageView,
// TODO: Stories in this file fail when pixel runs their play functions. Fix them and remove the exclude.
parameters: { pixel: { exclude: true } },
args: {
...baseProps,
onDateRangeChange: fn(),
onSearchFilterChange: fn(),
onDrillInUserRetry: fn(),
onClearSelectedUser: fn(),
onSelectUser: fn(),
onSummaryRetry: fn(),
},
} satisfies Meta<typeof SpendPageView>;
export default meta;
type Story = StoryObj<typeof SpendPageView>;
export const SpendUsersEmpty: Story = {
args: {
usersQuery: mockUsersQuery({
data: {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
count: 0,
users: [],
},
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("AI spend usage");
await expect(canvas.getByRole("alert")).toHaveTextContent(
"As of v2.36, AI Governance Cost Control replaces Coder Agents Cost Control.",
);
await expect(
canvas.getByRole("link", { name: /Read more here/ }),
).toHaveAttribute(
"href",
expect.stringContaining("/ai-coder/ai-gateway/cost-controls"),
);
await expect(
await canvas.findByText("No usage data for this period."),
).toBeInTheDocument();
},
};
export const SpendUserDrillIn: Story = {
args: {
drillInUserId: "user-1",
drillInUser: mockUserProfile,
summaryData: mockCostSummary,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText(`User ID: ${mockUserProfile.id}`);
await expect(canvas.getByText("Alice Liddell")).toBeInTheDocument();
await expect(canvas.getByText("@alice")).toBeInTheDocument();
await expect(canvas.getByText("Back")).toBeInTheDocument();
},
};
export const SpendUserDrillInAndBack: Story = {
args: {
drillInUserId: "user-1",
drillInUser: mockUserProfile,
summaryData: mockCostSummary,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await canvas.findByText(`User ID: ${mockUserProfile.id}`);
await userEvent.click(canvas.getByText("Back"));
expect(args.onClearSelectedUser).toHaveBeenCalled();
},
};
export const SpendDrillInLoading: Story = {
args: {
drillInUserId: "user-1",
drillInUser: null,
isDrillInUserLoading: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByRole("status", { name: "Loading user details" }),
).toBeInTheDocument();
},
};
export const SpendDrillInError: Story = {
args: {
drillInUserId: "user-1",
drillInUser: null,
isDrillInUserError: true,
drillInUserError: new Error("User not found"),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("User not found");
await expect(canvas.getByText("Retry")).toBeInTheDocument();
},
};
export const SpendRefetchOverlay: Story = {
args: {
usersQuery: mockUsersQuery({
data: mockUsersResponse,
isFetching: true,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Alice Liddell");
await expect(
await canvas.findByRole("status", { name: "Refreshing usage" }),
).toBeInTheDocument();
},
};
export const SpendUsersLoading: Story = {
args: {
usersQuery: mockUsersQuery({ isLoading: true }),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
await canvas.findByRole("status", { name: "Loading usage" }),
).toBeInTheDocument();
},
};
export const SpendUsersError: Story = {
args: {
usersQuery: mockUsersQuery({
error: new Error("Failed to load usage data"),
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText("Failed to load usage data"),
).toBeInTheDocument();
await expect(canvas.getByText("Retry")).toBeInTheDocument();
},
};
export const SpendUserClickToDrillIn: Story = {
args: {
usersQuery: mockUsersQuery({ data: mockUsersResponse }),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const row = await canvas.findByRole("button", {
name: /^View details for Alice Liddell/,
});
await userEvent.click(row);
expect(args.onSelectUser).toHaveBeenCalledWith(
expect.objectContaining({ user_id: "user-1" }),
);
},
};
@@ -1,128 +0,0 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker";
import { Link } from "#/components/Link/Link";
import type { PaginationResult } from "#/components/PaginationWidget/PaginationContainer";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { docs } from "#/utils/docs";
import { SpendDrillInView } from "./components/SpendDrillInView";
import { UsageTab } from "./components/UsageTab/UsageTab";
import { formatUsageDateRange, toInclusiveDateRange } from "./utils/dateRange";
interface SpendPageViewProps {
dateRange: DateRangeValue;
endDateIsExclusive: boolean;
onDateRangeChange: (value: DateRangeValue) => void;
searchFilter: string;
onSearchFilterChange: (value: string) => void;
usersQuery: PaginationResult & {
data: TypesGen.ChatCostUsersResponse | undefined;
isLoading: boolean;
isFetching: boolean;
error: unknown;
refetch: () => unknown;
};
drillInUserId: string | null;
drillInUser: TypesGen.User | null;
isDrillInUserLoading: boolean;
isDrillInUserError: boolean;
drillInUserError: unknown;
onDrillInUserRetry: () => void;
onClearSelectedUser: () => void;
onSelectUser: (user: TypesGen.ChatCostUserRollup) => void;
summaryData: TypesGen.ChatCostSummary | undefined;
isSummaryLoading: boolean;
summaryError: unknown;
onSummaryRetry: () => void;
}
export const SpendPageView: FC<SpendPageViewProps> = ({
dateRange,
endDateIsExclusive,
onDateRangeChange,
searchFilter,
onSearchFilterChange,
usersQuery,
drillInUserId,
drillInUser,
isDrillInUserLoading,
isDrillInUserError,
drillInUserError,
onDrillInUserRetry,
onClearSelectedUser,
onSelectUser,
summaryData,
isSummaryLoading,
summaryError,
onSummaryRetry,
}) => {
const displayDateRange = toInclusiveDateRange(dateRange, endDateIsExclusive);
const dateRangeLabel = formatUsageDateRange(dateRange, {
endDateIsExclusive,
});
if (drillInUserId) {
return (
<SpendDrillInView
selectedUser={drillInUser}
isLoading={isDrillInUserLoading}
isError={isDrillInUserError}
error={drillInUserError}
onRetry={onDrillInUserRetry}
onBack={onClearSelectedUser}
displayDateRange={displayDateRange}
onDateRangeChange={onDateRangeChange}
dateRangeLabel={dateRangeLabel}
summaryData={summaryData}
isSummaryLoading={isSummaryLoading}
summaryError={summaryError}
onSummaryRetry={onSummaryRetry}
/>
);
}
return (
<div className="flex max-w-[1100px] flex-col gap-8">
<Alert severity="warning" prominent>
<AlertDescription>
As of v2.36, AI Governance Cost Control replaces Coder Agents Cost
Control. The limits on this page are no longer enforced and do not
carry over. Recreate each limit as an AI Governance budget to restore
enforcement.{" "}
<Link
href={docs(
"/ai-coder/ai-gateway/cost-controls#migrate-from-coder-agents-cost-control",
)}
target="_blank"
rel="noreferrer"
>
Read more here
</Link>
</AlertDescription>
</Alert>
<SettingsHeader>
<SettingsHeaderTitle>AI spend usage</SettingsHeaderTitle>
<SettingsHeaderDescription>
Monitor AI usage across your deployment.
</SettingsHeaderDescription>
</SettingsHeader>
<div className="pt-8">
<UsageTab
displayDateRange={displayDateRange}
onDateRangeChange={onDateRangeChange}
searchFilter={searchFilter}
onSearchFilterChange={onSearchFilterChange}
usersQuery={usersQuery}
onSelectUser={onSelectUser}
/>
</div>
</div>
);
};
@@ -1,17 +0,0 @@
import { ChevronLeftIcon } from "lucide-react";
import type { FC } from "react";
interface BackButtonProps {
onClick: () => void;
}
export const BackButton: FC<BackButtonProps> = ({ onClick }) => (
<button
type="button"
onClick={onClick}
className="mb-4 inline-flex cursor-pointer items-center gap-0.5 border-0 bg-transparent p-0 text-sm text-content-secondary transition-colors hover:text-content-primary"
>
<ChevronLeftIcon className="size-4" />
Back
</button>
);
@@ -1,184 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { ChatCostSummaryView } from "./ChatCostSummaryView";
const buildSummary = (
overrides: Partial<TypesGen.ChatCostSummary> = {},
): TypesGen.ChatCostSummary => ({
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
total_cost_micros: 1_500_000,
priced_message_count: 12,
unpriced_messages_having_usage_count: 0,
total_input_tokens: 123_456,
total_output_tokens: 654_321,
total_cache_read_tokens: 9_876,
total_cache_creation_tokens: 5_432,
total_runtime_ms: 0,
by_model: [
{
model_config_id: "model-config-1",
display_name: "GPT-4.1",
provider: "OpenAI",
model: "gpt-4.1",
total_cost_micros: 1_250_000,
message_count: 9,
total_input_tokens: 100_000,
total_output_tokens: 200_000,
total_cache_read_tokens: 7_654,
total_cache_creation_tokens: 3_210,
total_runtime_ms: 0,
},
],
by_chat: [
{
root_chat_id: "chat-1",
chat_title: "Quarterly review",
total_cost_micros: 750_000,
message_count: 5,
total_input_tokens: 60_000,
total_output_tokens: 80_000,
total_cache_read_tokens: 4_321,
total_cache_creation_tokens: 1_234,
total_runtime_ms: 0,
},
],
...overrides,
});
const emptySummary = buildSummary({
total_cost_micros: 0,
priced_message_count: 0,
unpriced_messages_having_usage_count: 0,
total_input_tokens: 0,
total_output_tokens: 0,
by_model: [],
by_chat: [],
});
const meta: Meta<typeof ChatCostSummaryView> = {
title: "pages/AISettingsPage/SpendPage/components/ChatCostSummaryView",
component: ChatCostSummaryView,
args: {
summary: undefined,
isLoading: false,
error: undefined,
onRetry: fn(),
loadingLabel: "Loading usage details",
emptyMessage: "No usage details available.",
},
};
export default meta;
type Story = StoryObj<typeof ChatCostSummaryView>;
export const Loading: Story = {
args: {
isLoading: true,
},
};
const ErrorState: Story = {
args: {
error: new globalThis.Error("Failed to fetch"),
onRetry: fn(),
},
};
export { ErrorState as Error };
export const ErrorNonError: Story = {
args: {
error: "string error",
onRetry: fn(),
},
};
export const Empty: Story = {
args: {
summary: emptySummary,
},
};
export const WithData: Story = {
args: {
summary: buildSummary(),
},
};
export const UnpricedWarning: Story = {
args: {
summary: buildSummary({
unpriced_messages_having_usage_count: 2,
}),
},
};
const manyModels = Array.from({ length: 12 }, (_, i) => ({
model_config_id: `model-${i + 1}`,
display_name: `Model ${i + 1}`,
provider: "TestProvider",
model: `test-model-${i + 1}`,
total_cost_micros: 100_000 * (i + 1),
message_count: i + 1,
total_input_tokens: 10_000 * (i + 1),
total_output_tokens: 20_000 * (i + 1),
total_cache_read_tokens: 1_000,
total_cache_creation_tokens: 500,
total_runtime_ms: 0,
}));
const manyChats = Array.from({ length: 12 }, (_, i) => ({
root_chat_id: `chat-${i + 1}`,
chat_title: `Agent ${i + 1}`,
total_cost_micros: 50_000 * (i + 1),
message_count: i + 1,
total_input_tokens: 5_000 * (i + 1),
total_output_tokens: 10_000 * (i + 1),
total_cache_read_tokens: 500,
total_cache_creation_tokens: 250,
total_runtime_ms: 0,
}));
export const PaginatedChats: Story = {
args: {
summary: buildSummary({ by_chat: manyChats }),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// First page shows agents 1 to 10, agent 11 is on page 2.
await canvas.findByText("Agent 1");
await expect(canvas.queryByText("Agent 11")).not.toBeInTheDocument();
// Navigate to page 2 (second pagination widget on the page).
const nextButtons = canvas.getAllByRole("button", { name: /next/i });
await userEvent.click(nextButtons[nextButtons.length - 1]);
await expect(canvas.getByText("Agent 11")).toBeInTheDocument();
await expect(canvas.getByText("Agent 12")).toBeInTheDocument();
await expect(canvas.queryByText("Agent 1")).not.toBeInTheDocument();
},
};
export const PaginatedModels: Story = {
args: {
summary: buildSummary({ by_model: manyModels }),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// First page shows models 1 to 10, model 11 is on page 2.
await canvas.findByText("Model 1");
await expect(canvas.queryByText("Model 11")).not.toBeInTheDocument();
// Navigate to page 2.
const nextButton = canvas.getByRole("button", { name: /next/i });
await userEvent.click(nextButton);
await expect(canvas.getByText("Model 11")).toBeInTheDocument();
await expect(canvas.getByText("Model 12")).toBeInTheDocument();
await expect(canvas.queryByText("Model 1")).not.toBeInTheDocument();
},
};
@@ -1,295 +0,0 @@
import { TriangleAlertIcon } from "lucide-react";
import { type FC, useState } from "react";
import { getErrorMessage } from "#/api/errors";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "#/components/Table/Table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { formatTokenCount } from "#/utils/analytics";
import { formatCostMicros } from "#/utils/currency";
import { paginateItems } from "#/utils/paginateItems";
interface ChatCostSummaryViewProps {
summary: TypesGen.ChatCostSummary | undefined;
isLoading: boolean;
error: unknown;
onRetry: () => void;
loadingLabel: string;
emptyMessage: string;
}
export const ChatCostSummaryView: FC<ChatCostSummaryViewProps> = ({
summary,
isLoading,
error,
onRetry,
loadingLabel,
emptyMessage,
}) => {
// Page state is intentionally not reset when summary data changes.
// The clamped derivation below guarantees the displayed page is
// always valid, and preserving the raw state lets the user return
// to their previous page if they widen the date range back.
const [modelPage, setModelPage] = useState(1);
const [chatPage, setChatPage] = useState(1);
if (isLoading) {
return (
<div
role="status"
aria-label={loadingLabel}
className="flex min-h-[240px] items-center justify-center"
>
<Spinner size="lg" loading />
</div>
);
}
if (error != null) {
return (
<div className="flex min-h-[240px] flex-col items-center justify-center gap-4 text-center">
<p className="m-0 text-sm text-content-secondary">
{getErrorMessage(error, "Failed to load usage details.")}
</p>
<Button variant="outline" size="sm" type="button" onClick={onRetry}>
Retry
</Button>
</div>
);
}
if (!summary) {
return null;
}
const modelPageSize = 10;
const {
pagedItems: pagedModels,
clampedPage: clampedModelPage,
hasPreviousPage: hasModelPrev,
hasNextPage: hasModelNext,
} = paginateItems(summary.by_model, modelPageSize, modelPage);
const chatPageSize = 10;
const {
pagedItems: pagedChats,
clampedPage: clampedChatPage,
hasPreviousPage: hasChatPrev,
hasNextPage: hasChatNext,
} = paginateItems(summary.by_chat, chatPageSize, chatPage);
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
<div className="rounded-lg border border-border-default bg-surface-secondary p-4">
<p className="text-xs font-medium uppercase tracking-wide text-content-secondary">
Total Cost
</p>
<p className="mt-1 text-2xl font-semibold text-content-primary">
{formatCostMicros(summary.total_cost_micros)}
</p>
</div>
<div className="rounded-lg border border-border-default bg-surface-secondary p-4">
<p className="text-xs font-medium uppercase tracking-wide text-content-secondary">
Input Tokens
</p>
<p className="mt-1 text-2xl font-semibold text-content-primary">
{formatTokenCount(summary.total_input_tokens)}
</p>
</div>
<div className="rounded-lg border border-border-default bg-surface-secondary p-4">
<p className="text-xs font-medium uppercase tracking-wide text-content-secondary">
Output Tokens
</p>
<p className="mt-1 text-2xl font-semibold text-content-primary">
{formatTokenCount(summary.total_output_tokens)}
</p>
</div>
<div className="rounded-lg border border-border-default bg-surface-secondary p-4">
<p className="text-xs font-medium uppercase tracking-wide text-content-secondary">
Cache read
</p>
<p className="mt-1 text-2xl font-semibold text-content-primary">
{formatTokenCount(summary.total_cache_read_tokens)}
</p>
</div>
<div className="rounded-lg border border-border-default bg-surface-secondary p-4">
<p className="text-xs font-medium uppercase tracking-wide text-content-secondary">
Cache write
</p>
<p className="mt-1 text-2xl font-semibold text-content-primary">
{formatTokenCount(summary.total_cache_creation_tokens)}
</p>
</div>
<div className="rounded-lg border border-border-default bg-surface-secondary p-4">
<p className="text-xs font-medium uppercase tracking-wide text-content-secondary">
Messages
</p>
<p className="mt-1 text-2xl font-semibold text-content-primary">
{(
summary.priced_message_count +
summary.unpriced_messages_having_usage_count
).toLocaleString()}
</p>
</div>
</div>
{summary.unpriced_messages_having_usage_count > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-border-warning bg-surface-warning p-4 text-sm text-content-primary">
<TriangleAlertIcon className="size-5 shrink-0 text-content-warning" />
<span>
{summary.unpriced_messages_having_usage_count} message
{summary.unpriced_messages_having_usage_count === 1 ? "" : "s"} with
usage could not be priced because model pricing data was
unavailable.
</span>
</div>
)}
{summary.by_model.length === 0 && summary.by_chat.length === 0 ? (
<p className="py-12 text-center text-content-secondary">
{emptyMessage}
</p>
) : (
<>
<div>
<Table aria-label="Cost breakdown by model">
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead>Provider</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Messages</TableHead>
<TableHead className="text-right">Input</TableHead>
<TableHead className="text-right">Output</TableHead>
<TableHead className="text-right">Cache read</TableHead>
<TableHead className="text-right">Cache write</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedModels.map((model) => (
<TableRow key={model.model_config_id}>
<TableCell>{model.display_name || model.model}</TableCell>
<TableCell className="text-content-secondary">
{model.provider}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCostMicros(model.total_cost_micros)}
</TableCell>
<TableCell className="text-right tabular-nums">
{model.message_count.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(model.total_input_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(model.total_output_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(model.total_cache_read_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(model.total_cache_creation_tokens)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{summary.by_model.length > modelPageSize && (
<div className="pt-4">
<PaginationWidgetBase
totalRecords={summary.by_model.length}
currentPage={clampedModelPage}
pageSize={modelPageSize}
onPageChange={setModelPage}
hasPreviousPage={hasModelPrev}
hasNextPage={hasModelNext}
/>
</div>
)}
</div>
<div>
<Table aria-label="Cost breakdown by agent">
<TableHeader>
<TableRow>
<TableHead>Agent</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Messages</TableHead>
<TableHead className="text-right">Input</TableHead>
<TableHead className="text-right">Output</TableHead>
<TableHead className="text-right">Cache read</TableHead>
<TableHead className="text-right">Cache write</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedChats.map((chat) => (
<TableRow key={chat.root_chat_id}>
<TableCell className="max-w-[200px]">
{chat.chat_title ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block truncate">
{chat.chat_title}
</span>
</TooltipTrigger>
<TooltipContent>{chat.chat_title}</TooltipContent>
</Tooltip>
) : (
<span className="text-content-secondary">
Untitled agent
</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCostMicros(chat.total_cost_micros)}
</TableCell>
<TableCell className="text-right tabular-nums">
{chat.message_count.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(chat.total_input_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(chat.total_output_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(chat.total_cache_read_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(chat.total_cache_creation_tokens)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{summary.by_chat.length > chatPageSize && (
<div className="pt-4">
<PaginationWidgetBase
totalRecords={summary.by_chat.length}
currentPage={clampedChatPage}
pageSize={chatPageSize}
onPageChange={setChatPage}
hasPreviousPage={hasChatPrev}
hasNextPage={hasChatNext}
/>
</div>
)}
</div>
</>
)}
</div>
);
};
@@ -1,128 +0,0 @@
import type { FC } from "react";
import { getErrorMessage } from "#/api/errors";
import type * as TypesGen from "#/api/typesGenerated";
import { AvatarData } from "#/components/Avatar/AvatarData";
import { Button } from "#/components/Button/Button";
import {
DateRangePicker,
type DateRangeValue,
} from "#/components/DateRangePicker/DateRangePicker";
import { Spinner } from "#/components/Spinner/Spinner";
import { BackButton } from "./BackButton";
import { ChatCostSummaryView } from "./ChatCostSummaryView";
import { SpendSectionHeader } from "./SpendSectionHeader";
interface SpendDrillInViewProps {
selectedUser: TypesGen.User | null;
isLoading: boolean;
isError: boolean;
error: unknown;
onRetry: () => void;
onBack: () => void;
displayDateRange: DateRangeValue;
onDateRangeChange: (value: DateRangeValue) => void;
dateRangeLabel: string;
summaryData: TypesGen.ChatCostSummary | undefined;
isSummaryLoading: boolean;
summaryError: unknown;
onSummaryRetry: () => void;
}
export const SpendDrillInView: FC<SpendDrillInViewProps> = ({
selectedUser,
isLoading,
isError,
error,
onRetry,
onBack,
displayDateRange,
onDateRangeChange,
dateRangeLabel,
summaryData,
isSummaryLoading,
summaryError,
onSummaryRetry,
}) => {
const backButton = <BackButton onClick={onBack} />;
const header = (
<SpendSectionHeader
title="Spend details"
description="Review spend details for a specific user."
actions={
<DateRangePicker
value={displayDateRange}
onChange={onDateRangeChange}
/>
}
/>
);
if (isLoading) {
return (
<div className="space-y-6">
<div>
{backButton}
{header}
</div>
<div
role="status"
aria-label="Loading user details"
className="flex min-h-[240px] items-center justify-center"
>
<Spinner size="lg" loading className="text-content-secondary" />
</div>
</div>
);
}
if (isError || !selectedUser) {
return (
<div className="space-y-6">
<div>
{backButton}
{header}
</div>
<div className="flex min-h-[240px] flex-col items-center justify-center gap-4 text-center">
<p className="m-0 text-sm text-content-secondary">
{getErrorMessage(error, "Failed to load user profile.")}
</p>
<Button variant="outline" size="sm" type="button" onClick={onRetry}>
Retry
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
{backButton}
{header}
</div>
<div className="flex items-center justify-between rounded-lg bg-surface-secondary px-4 py-3">
<AvatarData
title={selectedUser.name || selectedUser.username}
subtitle={`@${selectedUser.username}`}
src={selectedUser.avatar_url}
imgFallbackText={selectedUser.username}
/>
<div className="min-w-0 text-xs text-content-secondary">
<div>User ID: {selectedUser.id}</div>
<div>{dateRangeLabel}</div>
</div>
</div>
<ChatCostSummaryView
key={selectedUser.id}
summary={summaryData}
isLoading={isSummaryLoading}
error={summaryError}
onRetry={onSummaryRetry}
loadingLabel="Loading usage details"
emptyMessage="No usage data for this user in the selected period."
/>
</div>
);
};
@@ -1,29 +0,0 @@
import type { FC, ReactNode } from "react";
interface SpendSectionHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
}
export const SpendSectionHeader: FC<SpendSectionHeaderProps> = ({
title,
description,
actions,
}) => {
return (
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="m-0 text-xl font-semibold leading-7 text-content-primary">
{title}
</h2>
{description && (
<p className="m-0 mt-3 text-sm font-medium leading-6 text-content-secondary">
{description}
</p>
)}
</div>
{actions}
</div>
);
};
@@ -1,209 +0,0 @@
import type { FC } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { AvatarData } from "#/components/Avatar/AvatarData";
import { Button } from "#/components/Button/Button";
import {
DateRangePicker,
type DateRangeValue,
} from "#/components/DateRangePicker/DateRangePicker";
import {
PaginationContainer,
type PaginationResult,
} from "#/components/PaginationWidget/PaginationContainer";
import { SearchField } from "#/components/SearchField/SearchField";
import { Spinner } from "#/components/Spinner/Spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "#/components/Table/Table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { useClickableTableRow } from "#/hooks/useClickableTableRow";
import { formatTokenCount } from "#/utils/analytics";
import { formatCostMicros } from "#/utils/currency";
import { SpendSectionHeader } from "../SpendSectionHeader";
interface UsageTabProps {
displayDateRange: DateRangeValue;
onDateRangeChange: (value: DateRangeValue) => void;
searchFilter: string;
onSearchFilterChange: (value: string) => void;
usersQuery: PaginationResult & {
data: TypesGen.ChatCostUsersResponse | undefined;
isLoading: boolean;
isFetching: boolean;
error: unknown;
refetch: () => unknown;
};
onSelectUser: (user: TypesGen.ChatCostUserRollup) => void;
}
export const UsageTab: FC<UsageTabProps> = ({
displayDateRange,
onDateRangeChange,
searchFilter,
onSearchFilterChange,
usersQuery,
onSelectUser,
}) => {
return (
<section className="space-y-6">
<SpendSectionHeader
title="Usage by user"
description="Monitor AI usage and spend for users in the selected date range."
actions={
<DateRangePicker
value={displayDateRange}
onChange={onDateRangeChange}
/>
}
/>
<div>
<div className="w-full md:max-w-sm">
<SearchField
value={searchFilter}
onChange={onSearchFilterChange}
placeholder="Search by name or username"
aria-label="Search usage by name or username"
/>
</div>
</div>
{usersQuery.isLoading && (
<div
role="status"
aria-label="Loading usage"
className="flex min-h-[240px] items-center justify-center"
>
<Spinner size="lg" loading className="text-content-secondary" />
</div>
)}
{usersQuery.error != null && (
<div className="flex min-h-[240px] flex-col items-center justify-center gap-4 text-center">
<ErrorAlert error={usersQuery.error} />
<Button
variant="outline"
size="sm"
type="button"
onClick={() => void usersQuery.refetch()}
>
Retry
</Button>
</div>
)}
{usersQuery.data && (
<div className="relative pt-3">
{usersQuery.isFetching && !usersQuery.isLoading && (
<div
role="status"
aria-label="Refreshing usage"
className="absolute inset-0 z-10 flex items-center justify-center bg-surface-primary/50"
>
<Spinner size="lg" loading className="text-content-secondary" />
</div>
)}
{usersQuery.data.users.length === 0 ? (
<p className="py-12 text-center text-content-secondary">
No usage data for this period.
</p>
) : (
<PaginationContainer query={usersQuery} paginationUnitLabel="users">
<div className="overflow-hidden rounded-lg border border-border-default">
<Table aria-label="User spend details">
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Messages</TableHead>
<TableHead className="text-right">Chats</TableHead>
<TableHead className="text-right">Input</TableHead>
<TableHead className="text-right">Output</TableHead>
<TableHead className="text-right">Cache Read</TableHead>
<TableHead className="text-right">Cache Write</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersQuery.data.users.map((user) => (
<UserRow
key={user.user_id}
user={user}
onSelect={onSelectUser}
/>
))}
</TableBody>
</Table>
</div>
</PaginationContainer>
)}
</div>
)}
</section>
);
};
const UserRow: FC<{
user: TypesGen.ChatCostUserRollup;
onSelect: (user: TypesGen.ChatCostUserRollup) => void;
}> = ({ user, onSelect }) => {
const clickableRowProps = useClickableTableRow({
onClick: () => onSelect(user),
});
return (
<TableRow
{...clickableRowProps}
aria-label={`View details for ${user.name || user.username}`}
className="text-xs"
>
<TableCell className="max-w-[200px] px-3 py-2">
<Tooltip>
<TooltipTrigger asChild>
<div>
<AvatarData
title={
<span className="block truncate">
{user.name || user.username}
</span>
}
subtitle={
<span className="block truncate">@{user.username}</span>
}
src={user.avatar_url}
imgFallbackText={user.username}
/>
</div>
</TooltipTrigger>
<TooltipContent>{user.name || user.username}</TooltipContent>
</Tooltip>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCostMicros(user.total_cost_micros)}
</TableCell>
<TableCell className="text-right tabular-nums">
{user.message_count.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums">
{user.chat_count.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_input_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_output_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_cache_read_tokens)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTokenCount(user.total_cache_creation_tokens)}
</TableCell>
</TableRow>
);
};
@@ -1,159 +0,0 @@
import { describe, expect, it } from "vitest";
import {
formatUsageDateRange,
toExclusiveEndOfDayDateRange,
toInclusiveDateRange,
} from "./dateRange";
describe("toExclusiveEndOfDayDateRange", () => {
it("moves a non-midnight end date to the next midnight", () => {
const startDate = new Date(2025, 5, 1, 0, 0, 0, 0);
const endDate = new Date(2025, 5, 8, 14, 30, 0, 0);
const result = toExclusiveEndOfDayDateRange({ startDate, endDate });
expect(result.endDate).toEqual(new Date(2025, 5, 9, 0, 0, 0, 0));
});
it("returns unchanged when the end date is already midnight", () => {
const startDate = new Date(2025, 5, 1, 0, 0, 0, 0);
const endDate = new Date(2025, 5, 8, 0, 0, 0, 0);
const result = toExclusiveEndOfDayDateRange({ startDate, endDate });
expect(result.endDate).toBe(endDate);
});
it("preserves startDate", () => {
const startDate = new Date(2025, 5, 1, 0, 0, 0, 0);
const endDate = new Date(2025, 5, 8, 14, 30, 0, 0);
const result = toExclusiveEndOfDayDateRange({ startDate, endDate });
expect(result.startDate).toBe(startDate);
});
});
describe("toInclusiveDateRange", () => {
it("subtracts 1ms when endDateIsExclusive is true and end date is midnight", () => {
const startDate = new Date("2025-06-01T00:00:00.000");
const endDate = new Date("2025-06-08T00:00:00.000");
const result = toInclusiveDateRange({ startDate, endDate }, true);
expect(result.endDate.getTime()).toBe(endDate.getTime() - 1);
});
it("returns unchanged when endDateIsExclusive is true and end date is not midnight", () => {
const startDate = new Date("2025-06-01T00:00:00.000");
const endDate = new Date("2025-06-08T14:30:00.000");
const result = toInclusiveDateRange({ startDate, endDate }, true);
expect(result.endDate).toBe(endDate);
});
it("returns unchanged when endDateIsExclusive is false and end date is midnight", () => {
const startDate = new Date("2025-06-01T00:00:00.000");
const endDate = new Date("2025-06-08T00:00:00.000");
const result = toInclusiveDateRange({ startDate, endDate }, false);
expect(result.endDate).toBe(endDate);
});
it("returns unchanged when endDateIsExclusive is false and end date is not midnight", () => {
const startDate = new Date("2025-06-01T00:00:00.000");
const endDate = new Date("2025-06-08T14:30:00.000");
const result = toInclusiveDateRange({ startDate, endDate }, false);
expect(result.endDate).toBe(endDate);
});
it("preserves startDate in all cases", () => {
const startDate = new Date("2025-06-01T00:00:00.000");
const midnightEnd = new Date("2025-06-08T00:00:00.000");
const nonMidnightEnd = new Date("2025-06-08T14:30:00.000");
const explicitMidnight = toInclusiveDateRange(
{ startDate, endDate: midnightEnd },
true,
);
expect(explicitMidnight.startDate).toBe(startDate);
const explicitNonMidnight = toInclusiveDateRange(
{ startDate, endDate: nonMidnightEnd },
true,
);
expect(explicitNonMidnight.startDate).toBe(startDate);
const implicitMidnight = toInclusiveDateRange(
{ startDate, endDate: midnightEnd },
false,
);
expect(implicitMidnight.startDate).toBe(startDate);
const implicitNonMidnight = toInclusiveDateRange(
{ startDate, endDate: nonMidnightEnd },
false,
);
expect(implicitNonMidnight.startDate).toBe(startDate);
});
});
describe("formatUsageDateRange", () => {
it("formats a basic date range without options", () => {
const result = formatUsageDateRange({
startDate: new Date("2025-06-01T00:00:00.000"),
endDate: new Date("2025-06-08T00:00:00.000"),
});
expect(result).toBe("Jun 1 to Jun 8, 2025");
});
it("shows previous day when endDateIsExclusive is true and end date is midnight", () => {
const result = formatUsageDateRange(
{
startDate: new Date("2025-06-01T00:00:00.000"),
endDate: new Date("2025-06-08T00:00:00.000"),
},
{ endDateIsExclusive: true },
);
expect(result).toBe("Jun 1 to Jun 7, 2025");
});
it("shows same day when endDateIsExclusive is true and end date is not midnight", () => {
const result = formatUsageDateRange(
{
startDate: new Date("2025-06-01T00:00:00.000"),
endDate: new Date("2025-06-08T14:30:00.000"),
},
{ endDateIsExclusive: true },
);
expect(result).toBe("Jun 1 to Jun 8, 2025");
});
it("shows same day when endDateIsExclusive is false and end date is midnight", () => {
const result = formatUsageDateRange(
{
startDate: new Date("2025-06-01T00:00:00.000"),
endDate: new Date("2025-06-08T00:00:00.000"),
},
{ endDateIsExclusive: false },
);
expect(result).toBe("Jun 1 to Jun 8, 2025");
});
it("formats a cross-month range", () => {
const result = formatUsageDateRange({
startDate: new Date("2025-05-28T00:00:00.000"),
endDate: new Date("2025-06-04T00:00:00.000"),
});
expect(result).toBe("May 28 to Jun 4, 2025");
});
it("formats a same-month range", () => {
const result = formatUsageDateRange({
startDate: new Date("2025-06-01T00:00:00.000"),
endDate: new Date("2025-06-15T00:00:00.000"),
});
expect(result).toBe("Jun 1 to Jun 15, 2025");
});
it("formats a cross-year range without ambiguity", () => {
const result = formatUsageDateRange({
startDate: new Date("2025-12-28T00:00:00.000"),
endDate: new Date("2026-01-04T00:00:00.000"),
});
// Start date omits year, end date includes it. The label reads
// as "Dec 28 to Jan 4, 2026" which is unambiguous enough for a
// 30-day range label (the start year is implied).
expect(result).toBe("Dec 28 to Jan 4, 2026");
});
});
@@ -1,67 +0,0 @@
import dayjs from "dayjs";
import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker";
/**
* Returns true when the given date falls exactly on local midnight
* (00:00:00.000). DateRangePicker's `toBoundary` produces local
* midnight via `dayjs(to).startOf("day").add(1, "day").toDate()`,
* so we use local-time methods to match that convention.
*/
function isMidnight(date: Date): boolean {
return (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0
);
}
export function toExclusiveEndOfDayDateRange(
dateRange: DateRangeValue,
): DateRangeValue {
if (isMidnight(dateRange.endDate)) {
return dateRange;
}
return {
startDate: dateRange.startDate,
endDate: dayjs(dateRange.endDate).startOf("day").add(1, "day").toDate(),
};
}
/**
* When the user picks an explicit date range whose end boundary is
* midnight of the following day, adjust it by 1 ms so the
* DateRangePicker highlights the inclusive end date.
*/
export function toInclusiveDateRange(
dateRange: DateRangeValue,
endDateIsExclusive: boolean,
): DateRangeValue {
if (endDateIsExclusive && isMidnight(dateRange.endDate)) {
return {
startDate: dateRange.startDate,
endDate: new Date(dateRange.endDate.getTime() - 1),
};
}
return dateRange;
}
/**
* Format a date range for display. When `endDateIsExclusive` is true
* and the end date is midnight, the formatted label shows the
* preceding day.
*/
export function formatUsageDateRange(
value: DateRangeValue,
options?: { endDateIsExclusive?: boolean },
): string {
const adjusted = toInclusiveDateRange(
value,
options?.endDateIsExclusive ?? false,
);
return `${dayjs(adjusted.startDate).format("MMM D")} to ${dayjs(
adjusted.endDate,
).format("MMM D, YYYY")}`;
}
@@ -1,59 +0,0 @@
import dayjs, { type Dayjs } from "dayjs";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { useLocation } from "react-router";
import { chatCostSummary } from "#/api/queries/chats";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import { useAuthContext } from "#/contexts/auth/AuthProvider";
import { AgentAnalyticsPageView } from "./AgentAnalyticsPageView";
import { AgentPageHeader } from "./components/AgentPageHeader";
const createDateRange = (now?: Dayjs) => {
const end = now ?? dayjs();
const start = end.subtract(30, "day");
return {
startDate: start.toISOString(),
endDate: end.toISOString(),
rangeLabel: `${start.format("MMM D")} ${end.format("MMM D, YYYY")}`,
};
};
interface AgentAnalyticsPageProps {
/** Override the current time for deterministic storybook snapshots. */
now?: Dayjs;
}
const AgentAnalyticsPage: FC<AgentAnalyticsPageProps> = ({ now }) => {
const { user } = useAuthContext();
const location = useLocation();
const [anchor] = useState<Dayjs>(() => dayjs());
const dateRange = createDateRange(now ?? anchor);
const summaryQuery = useQuery({
...chatCostSummary(user?.id ?? "me", {
start_date: dateRange.startDate,
end_date: dateRange.endDate,
}),
enabled: Boolean(user?.id),
});
return (
<ScrollArea className="min-h-0 flex-1" viewportClassName="[&>div]:!block">
<AgentPageHeader
mobileBack={{
to: { pathname: "/agents", search: location.search },
label: "Agents",
}}
/>
<AgentAnalyticsPageView
summary={summaryQuery.data}
isLoading={summaryQuery.isLoading}
error={summaryQuery.error}
onRetry={() => void summaryQuery.refetch()}
rangeLabel={dateRange.rangeLabel}
/>
</ScrollArea>
);
};
export default AgentAnalyticsPage;
@@ -1,47 +0,0 @@
import { BarChart3Icon } from "lucide-react";
import type { FC } from "react";
import type { ChatCostSummary } from "#/api/typesGenerated";
import { ChatCostSummaryView } from "#/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView";
import { SectionHeader } from "./components/SectionHeader";
interface AgentAnalyticsPageViewProps {
summary: ChatCostSummary | undefined;
isLoading: boolean;
error: unknown;
onRetry: () => void;
rangeLabel: string;
}
export const AgentAnalyticsPageView: FC<AgentAnalyticsPageViewProps> = ({
summary,
isLoading,
error,
onRetry,
rangeLabel,
}) => {
return (
<div className="flex flex-col p-4 pt-8">
<div className="mx-auto w-full max-w-3xl">
<SectionHeader
label="Analytics"
description="Review your personal Coder Agents usage and cost breakdowns."
action={
<div className="flex items-center gap-2 text-xs text-content-secondary">
<BarChart3Icon className="size-4" />
<span>{rangeLabel}</span>
</div>
}
/>
<ChatCostSummaryView
summary={summary}
isLoading={isLoading}
error={error}
onRetry={onRetry}
loadingLabel="Loading analytics"
emptyMessage="No usage data for you in this period."
/>
</div>
</div>
);
};
@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import dayjs from "dayjs";
import { useState } from "react";
import { Navigate, useOutletContext } from "react-router";
import {
@@ -29,7 +28,6 @@ import {
withWebSocket,
} from "#/testHelpers/storybook";
import { CoderAgentsPageView } from "../AISettingsPage/CoderAgentsPage/CoderAgentsPageView";
import AgentAnalyticsPage from "./AgentAnalyticsPage";
import AgentCreatePage from "./AgentCreatePage";
import AgentSettingsCompactionPage from "./AgentSettingsCompactionPage";
import AgentSettingsGeneralPage from "./AgentSettingsGeneralPage";
@@ -65,69 +63,6 @@ const defaultModelConfigs: TypesGen.ChatModelConfig[] = [
},
];
const mockAnalyticsSummary: TypesGen.ChatCostSummary = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
total_cost_micros: 1_500_000,
priced_message_count: 12,
unpriced_messages_having_usage_count: 1,
total_input_tokens: 123_456,
total_output_tokens: 654_321,
total_cache_read_tokens: 9_876,
total_cache_creation_tokens: 5_432,
total_runtime_ms: 0,
by_model: [
{
model_config_id: defaultModelConfigID,
display_name: "GPT-4.1",
provider: "OpenAI",
model: "gpt-4.1",
total_cost_micros: 1_250_000,
message_count: 9,
total_input_tokens: 100_000,
total_output_tokens: 200_000,
total_cache_read_tokens: 7_654,
total_cache_creation_tokens: 3_210,
total_runtime_ms: 0,
},
],
by_chat: [
{
root_chat_id: "chat-1",
chat_title: "Quarterly review",
total_cost_micros: 750_000,
message_count: 5,
total_input_tokens: 60_000,
total_output_tokens: 80_000,
total_cache_read_tokens: 4_321,
total_cache_creation_tokens: 1_234,
total_runtime_ms: 0,
},
],
};
const mockUsageUsers: TypesGen.ChatCostUsersResponse = {
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
count: 1,
users: [
{
user_id: "user-1",
username: "alice",
name: "Alice Example",
avatar_url: "https://example.com/alice.png",
total_cost_micros: 1_200_000,
message_count: 12,
chat_count: 3,
total_input_tokens: 120_000,
total_output_tokens: 45_000,
total_cache_read_tokens: 6_789,
total_cache_creation_tokens: 2_468,
total_runtime_ms: 0,
},
],
};
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const todayTimestamp = new Date().toISOString();
@@ -143,10 +78,6 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
...overrides,
});
// Use local noon so the rendered range label stays stable
// across timezones.
const fixedNow = dayjs("2026-03-12T12:00:00");
const AgentsRouteElement = () => (
<CoderAgentsPageView
adminOverridesData={{ allow_users: false }}
@@ -226,17 +157,8 @@ const agentsRouting = {
path: "coder-agents",
element: <Navigate to="/ai/settings/coder-agents" replace />,
},
{
path: "spend",
element: <Navigate to="/ai/settings/spend" replace />,
},
{
path: "usage",
element: <Navigate to="/ai/settings/spend" replace />,
},
],
},
{ path: "analytics", element: <AgentAnalyticsPage now={fixedNow} /> },
{ path: ":agentId", element: <div /> },
{ index: true, element: <AgentCreatePage /> },
],
@@ -244,10 +166,7 @@ const agentsRouting = {
const aiSettingsRouting = {
path: "/ai/settings",
children: [
{ path: "coder-agents", element: <AgentsRouteElement /> },
{ path: "spend", element: <div>Spend limits and usage</div> },
],
children: [{ path: "coder-agents", element: <AgentsRouteElement /> }],
};
const setInnerWidthForStory = (width: number) => {
@@ -390,12 +309,6 @@ const meta: Meta<typeof AgentsPageLayout> = {
workspaces: [],
count: 0,
});
spyOn(API.experimental, "getChatCostSummary").mockResolvedValue(
mockAnalyticsSummary,
);
spyOn(API.experimental, "getChatCostUsers").mockResolvedValue(
mockUsageUsers,
);
spyOn(API.experimental, "getChatSystemPrompt").mockResolvedValue({
system_prompt: "",
include_default_system_prompt: true,
@@ -502,12 +415,6 @@ const meta: Meta<typeof AgentsPageLayout> = {
spyOn(API.experimental, "updateChatRetentionDays").mockResolvedValue();
spyOn(API, "getGroups").mockResolvedValue([]);
spyOn(API.experimental, "getChatCostUsers").mockResolvedValue({
start_date: "2026-02-10T00:00:00Z",
end_date: "2026-03-12T00:00:00Z",
count: 0,
users: [],
});
},
};
@@ -1067,43 +974,6 @@ const openSettingsView = async (canvasElement: HTMLElement) => {
await userEvent.click(await canvas.findByRole("link", { name: "Settings" }));
};
export const OpensAnalyticsForAdmins: Story = {
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents/analytics" },
routing: [agentsRouting, aiSettingsRouting],
}),
},
play: async () => {
await waitFor(() => {
expect(
screen.getByText(
"Review your personal Coder Agents usage and cost breakdowns.",
),
).toBeInTheDocument();
});
},
};
export const OpensAnalyticsForNonAdmins: Story = {
parameters: {
permissions: MockNoPermissions,
reactRouter: reactRouterParameters({
location: { path: "/agents/analytics" },
routing: [agentsRouting, aiSettingsRouting],
}),
},
play: async () => {
await waitFor(() => {
expect(
screen.getByText(
"Review your personal Coder Agents usage and cost breakdowns.",
),
).toBeInTheDocument();
});
},
};
export const OpensSettingsForAdmins: Story = {
play: async ({ canvasElement }) => {
await openSettingsView(canvasElement);
@@ -734,7 +734,6 @@ const AgentsPageLayout: FC = () => {
const isSettingsPanel = isSettingsView(sidebarView);
const isSettingsIndex = isSettingsPanel && !sidebarView.section;
const isSettingsDetail = isSettingsPanel && Boolean(sidebarView.section);
const isAnalytics = sidebarView.panel === "analytics";
// The sidebar expects plain string error messages, but the outlet
// context carries structured ChatDetailError objects.
@@ -784,7 +783,7 @@ const AgentsPageLayout: FC = () => {
"sm:h-full sm:min-h-0 sm:border-b-0",
agentId
? "hidden sm:block shrink-0 h-[42dvh] min-h-[240px] border-b border-border-default"
: isSettingsDetail || isAnalytics
: isSettingsDetail
? "hidden sm:block shrink-0"
: "order-2 sm:order-none flex-1 min-h-0 border-b border-border-default sm:flex-none sm:border-t-0 sm:border-b-0",
isSidebarCollapsed && "sm:hidden",
@@ -173,6 +173,52 @@ const meta: Meta<typeof AgentPageHeader> = {
export default meta;
type Story = StoryObj<typeof AgentPageHeader>;
export const MobileActionsExcludeAnalytics: Story = {
beforeEach: () => {
const originalMatchMedia = window.matchMedia;
window.matchMedia = createMatchMediaController(false).matchMedia;
return () => {
window.matchMedia = originalMatchMedia;
};
},
render: () => <HeaderStateHarness />,
parameters: {
viewport: { defaultViewport: "mobile1" },
reactRouter: {
location: {
path: "/agents",
},
routing: [
{
path: "/",
element: (
<Outlet
context={{
isSidebarCollapsed: false,
onExpandSidebar: () => undefined,
}}
/>
),
children: [{ path: "agents", useStoryElement: true }],
},
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
const menu = within(await within(document.body).findByRole("menu"));
await waitFor(() => {
expect(menu.getByRole("menuitem", { name: "Settings" })).toBeVisible();
});
await expect(
menu.queryByRole("menuitem", { name: "Analytics" }),
).not.toBeInTheDocument();
},
};
export const ToggleStateStaysInSyncAcrossBreakpoints: Story = {
render: () => <HeaderStateHarness />,
parameters: {
@@ -1,6 +1,5 @@
import {
ArrowLeftIcon,
BarChart3Icon,
BellIcon,
BellOffIcon,
EllipsisIcon,
@@ -37,7 +36,7 @@ import { getChimeEnabled, setChimeEnabled } from "../utils/chime";
interface AgentPageHeaderProps {
children?: ReactNode;
/** When set, shows a back link on mobile instead of the logo
* and hides the settings/analytics nav buttons. */
* and hides the mobile actions menu. */
mobileBack?: { to: To; label: string };
chimeEnabled?: boolean;
onToggleChime?: () => void;
@@ -177,14 +176,6 @@ export const AgentPageHeader: FC<AgentPageHeaderProps> = ({
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
to={{ pathname: "/agents/analytics", search: location.search }}
>
<BarChart3Icon className="size-icon-sm" />
Analytics
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
@@ -37,10 +37,6 @@ import {
type ModelConfigFormBuildResult,
type ModelFormValues,
} from "./modelConfigFormLogic";
import {
getPricingPlaceholderForField,
pricingFieldNames,
} from "./pricingFields";
const booleanFieldOptions = [
{ label: "Off", value: "false" },
@@ -57,14 +53,6 @@ const isReasoningEffortField = (jsonName: string): boolean =>
// ── Helpers ────────────────────────────────────────────────────
/** Short display labels for pricing fields to avoid overly verbose names. */
const shortLabelOverrides: Record<string, string> = {
"cost.input_price_per_million_tokens": "Input",
"cost.output_price_per_million_tokens": "Output",
"cost.cache_read_price_per_million_tokens": "Cache read",
"cost.cache_write_price_per_million_tokens": "Cache write",
};
/**
* Suffix units displayed inside the input control. When present,
* the field renders as an InputGroup with the suffix appended.
@@ -90,16 +78,6 @@ const placeholderOverrides: Record<string, string> = {
frequency_penalty: "-2.0 to 2.0",
};
/**
* Convert a dot-and-underscore-separated json_name into a
* human-readable label. Uses short overrides for pricing fields
* when available.
*
* @example
* snakeToPrettyLabel("thinking.budget_tokens") // "Thinking Budget Tokens"
* snakeToPrettyLabel("reasoning_effort") // "Reasoning Effort"
*/
/** Capitalize the first letter of a string. */
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
@@ -108,9 +86,6 @@ function snakeToPrettyLabel(field: FieldSchema): string {
if (field.label) {
return field.label;
}
if (shortLabelOverrides[field.json_name]) {
return shortLabelOverrides[field.json_name];
}
const words = field.json_name.split(/[._]/);
return words
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
@@ -121,11 +96,6 @@ function snakeToPrettyLabel(field: FieldSchema): string {
* Derive a sensible placeholder from the field schema type.
*/
function placeholderForField(field: FieldSchema): string {
const pricingPlaceholder = getPricingPlaceholderForField(field.json_name);
if (pricingPlaceholder !== undefined) {
return pricingPlaceholder;
}
switch (field.type) {
case "integer":
case "number":
@@ -618,67 +588,6 @@ export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
);
};
/**
* Pricing fields rendered with $ prefix and /1M suffix using
* InputGroup for a compact, readable layout.
*/
export const PricingModelConfigFields: FC<ModelConfigFieldsProps> = ({
provider,
form,
fieldErrors,
disabled,
}) => {
const fields = getVisibleGeneralFields(provider).filter(({ json_name }) =>
pricingFieldNames.has(json_name),
);
return (
<>
{fields.map((field) => {
const camelName = field.json_name
.split(".")
.map(snakeToCamel)
.join(".");
const fieldKey = `config.${camelName}`;
const label = snakeToPrettyLabel(field);
const errorId = `${fieldKey}-error`;
const fieldError = fieldErrors[camelName];
const fieldProps = form.getFieldProps(fieldKey);
return (
<div key={fieldKey} className="flex min-w-0 flex-col gap-1.5">
<FieldLabel htmlFor={fieldKey} label={label} />
<InputGroup
className={cn(fieldError && "border-border-destructive")}
>
<InputGroupAddon align="inline-start">$</InputGroupAddon>
<InputGroupInput
id={fieldKey}
className="min-w-0 placeholder:text-content-disabled"
placeholder="0"
{...fieldProps}
disabled={disabled}
aria-invalid={Boolean(fieldError)}
aria-describedby={fieldError ? errorId : undefined}
/>
<InputGroupAddon align="inline-end">
<span className="text-xs text-content-disabled">
USD/1M tokens
</span>
</InputGroupAddon>
</InputGroup>
{fieldError && (
<p id={errorId} className="m-0 text-xs text-content-destructive">
{fieldError}
</p>
)}
</div>
);
})}
</>
);
};
/** Reasoning effort selects, outside Advanced. */
export const ReasoningEffortConfigFields: FC<ModelConfigFieldsProps> = ({
provider,
@@ -726,15 +635,12 @@ export const GeneralModelConfigFields: FC<ModelConfigFieldsProps> = ({
}) => {
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
const fields = getVisibleGeneralFields(provider).filter(
({ json_name }) =>
!pricingFieldNames.has(json_name) && !isReasoningEffortField(json_name),
({ json_name }) => !isReasoningEffortField(json_name),
);
return (
<>
{fields.map((field) => {
// General field keys support nested json_name values, such as
// cost.input_price_per_million_tokens.
const camelName = field.json_name
.split(".")
.map(snakeToCamel)
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildInitialModelFormValues } from "../modelConfigFormLogic";
import { pricingFieldNameList } from "../pricingFields";
import {
type ApplyKnownModelDefaultsParameters,
type ApplyKnownModelDefaultsResult,
@@ -184,81 +183,6 @@ describe("applyKnownModelDefaults", () => {
expect(
getPath(result.values, "config.anthropic.maxOutputTokens"),
).toBeUndefined();
expect(result.appliedFields).toContain("config.maxOutputTokens");
});
it("populates flat input and output costs through pricing descriptors", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "openai",
knownModel: customKnownModel({ inputCost: 5, outputCost: 30 }),
});
expect(
getPath(result.values, "config.cost.inputPricePerMillionTokens"),
).toBe("5");
expect(
getPath(result.values, "config.cost.outputPricePerMillionTokens"),
).toBe("30");
expect(pricingFieldNameList.slice(0, 2)).toEqual([
"cost.input_price_per_million_tokens",
"cost.output_price_per_million_tokens",
]);
expect(result.appliedFields).toEqual(
expect.arrayContaining([
"config.cost.inputPricePerMillionTokens",
"config.cost.outputPricePerMillionTokens",
]),
);
});
it("populates cache read and cache write costs when present", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "anthropic",
knownModel: customKnownModel({
provider: "anthropic",
cacheReadCost: 0.5,
cacheWriteCost: 6.25,
}),
});
expect(
getPath(result.values, "config.cost.cacheReadPricePerMillionTokens"),
).toBe("0.5");
expect(
getPath(result.values, "config.cost.cacheWritePricePerMillionTokens"),
).toBe("6.25");
expect(result.appliedFields).toEqual(
expect.arrayContaining([
"config.cost.cacheReadPricePerMillionTokens",
"config.cost.cacheWritePricePerMillionTokens",
]),
);
});
it("leaves missing cache costs unchanged and excludes them from applied fields", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "openai",
knownModel: customKnownModel({ inputCost: 30, outputCost: 180 }),
});
expect(
getPath(result.values, "config.cost.cacheReadPricePerMillionTokens"),
).toBe("");
expect(
getPath(result.values, "config.cost.cacheWritePricePerMillionTokens"),
).toBe("");
expect(result.appliedFields).not.toContain(
"config.cost.cacheReadPricePerMillionTokens",
);
expect(result.appliedFields).not.toContain(
"config.cost.cacheWritePricePerMillionTokens",
);
});
it("does not set compressionThreshold", () => {
@@ -1,10 +1,8 @@
import { toFormFieldKey } from "#/api/chatModelOptions";
import {
deepGet,
deepSet,
type ModelFormValues,
} from "../modelConfigFormLogic";
import { pricingFieldNameList } from "../pricingFields";
import type { KnownModel } from "./types";
export type ApplyKnownModelDefaultsResult = {
@@ -19,22 +17,6 @@ export type ApplyKnownModelDefaultsParameters = {
knownModel: KnownModel;
};
type KnownModelCostField =
| "inputCost"
| "outputCost"
| "cacheReadCost"
| "cacheWriteCost";
const pricingModelFieldByName = {
"cost.input_price_per_million_tokens": "inputCost",
"cost.output_price_per_million_tokens": "outputCost",
"cost.cache_read_price_per_million_tokens": "cacheReadCost",
"cost.cache_write_price_per_million_tokens": "cacheWriteCost",
} as const satisfies Record<
(typeof pricingFieldNameList)[number],
KnownModelCostField
>;
const thinkingBudgetTokensPathByProvider: Record<string, string> = {
anthropic: "config.anthropic.thinking.budgetTokens",
};
@@ -144,22 +126,5 @@ export const applyKnownModelDefaults = ({
}
}
for (const fieldName of pricingFieldNameList) {
const knownModelField = pricingModelFieldByName[fieldName];
const cost = knownModel[knownModelField];
if (cost === undefined) {
continue;
}
const path = toFormFieldKey("config", fieldName);
maybeApplyDefault({
appliedFields,
initialValues,
nextValues,
path,
value: String(cost),
values,
});
}
return { values: nextValues, appliedFields };
};
@@ -236,33 +236,6 @@ describe("extractModelConfigFormState", () => {
expect(result.frequencyPenalty).toBe("0.3");
});
it("extracts pricing fields", () => {
const model: TypesGen.ChatModelConfig = {
...baseChatModelConfig,
model_config: {
cost: {
input_price_per_million_tokens: "0.15",
output_price_per_million_tokens: "0.6",
cache_read_price_per_million_tokens: "0.03",
cache_write_price_per_million_tokens: "0.3",
},
},
};
const result = extractModelConfigFormState(model);
expect(deepGet(result, ["cost", "inputPricePerMillionTokens"])).toBe(
"0.15",
);
expect(deepGet(result, ["cost", "outputPricePerMillionTokens"])).toBe(
"0.6",
);
expect(deepGet(result, ["cost", "cacheReadPricePerMillionTokens"])).toBe(
"0.03",
);
expect(deepGet(result, ["cost", "cacheWritePricePerMillionTokens"])).toBe(
"0.3",
);
});
it("extracts reasoning effort bounds", () => {
const model: TypesGen.ChatModelConfig = {
...baseChatModelConfig,
@@ -669,41 +642,6 @@ describe("buildModelConfigFromForm", () => {
});
});
describe("pricing fields", () => {
it("builds config with valid pricing fields", () => {
const result = buildModelConfigFromForm(
"openai",
formWith({
cost: {
inputPricePerMillionTokens: "0.15",
outputPricePerMillionTokens: "0.6",
cacheReadPricePerMillionTokens: "0.03",
cacheWritePricePerMillionTokens: "0.3",
},
}),
);
expect(result.fieldErrors).toEqual({});
expect(result.modelConfig).toMatchObject({
cost: {
input_price_per_million_tokens: "0.15",
output_price_per_million_tokens: "0.6",
cache_read_price_per_million_tokens: "0.03",
cache_write_price_per_million_tokens: "0.3",
},
});
});
it("reports error for negative pricing fields", () => {
const result = buildModelConfigFromForm(
"openai",
formWith({ cost: { inputPricePerMillionTokens: "-0.5" } }),
);
expect(result.fieldErrors["cost.inputPricePerMillionTokens"]).toContain(
"must be zero or greater",
);
expect(result.modelConfig).toBeUndefined();
});
});
describe("OpenAI / Azure provider", () => {
it("builds OpenAI provider options with text verbosity", () => {
const result = buildModelConfigFromForm(
@@ -9,7 +9,6 @@ import {
snakeToCamel,
} from "#/api/chatModelOptions";
import type * as TypesGen from "#/api/typesGenerated";
import { pricingFieldNames } from "./pricingFields";
// ── Preserved public types ─────────────────────────────────────
@@ -143,7 +142,7 @@ function convertFormValue(value: string, field: FieldSchema): unknown {
case "integer":
return Number.parseInt(trimmed, 10);
case "number":
return isNonNegativePricingField(field) ? trimmed : Number(trimmed);
return Number(trimmed);
case "boolean":
return trimmed === "true";
case "array":
@@ -189,7 +188,6 @@ function buildEmptyProviderState(provider: string): Record<string, unknown> {
export const emptyModelConfigFormState: ModelConfigFormState = (() => {
const state: ModelConfigFormState = {};
// General fields (e.g. maxOutputTokens, cost.inputPricePerMillionTokens).
for (const field of getGeneralFields()) {
const camelSegments = field.json_name.split(".").map(snakeToCamel);
deepSet(state, camelSegments, "");
@@ -215,7 +213,6 @@ export const extractModelConfigFormState = (
const state: ModelConfigFormState = {};
// General fields may be nested (for example, cost.input_price_per_million_tokens).
for (const field of getGeneralFields()) {
const snakeSegments = field.json_name.split(".");
const camelSegments = snakeSegments.map(snakeToCamel);
@@ -268,10 +265,6 @@ export const buildInitialModelFormValues = (
: structuredClone(emptyModelConfigFormState),
});
function isNonNegativePricingField(field: FieldSchema): boolean {
return pricingFieldNames.has(field.json_name);
}
const reasoningEffortEnum =
getGeneralFields().find(
(field) => field.json_name === "reasoning_effort.default",
@@ -280,19 +273,13 @@ const reasoningEffortEnum =
const reasoningEffortRank = (value: string): number =>
reasoningEffortEnum.indexOf(value.trim().toLowerCase());
function isValidOptionalNumber(
value: string | undefined,
minimum?: number,
): boolean {
function isValidOptionalNumber(value: string | undefined): boolean {
const trimmed = value?.trim();
if (!trimmed) {
return true;
}
const parsed = Number(trimmed);
return (
Number.isFinite(parsed) && (minimum === undefined || parsed >= minimum)
);
return Number.isFinite(Number(trimmed));
}
// ── Schema-driven Yup validation ───────────────────────────────
@@ -317,16 +304,12 @@ function yupTestForField(field: FieldSchema): Yup.StringSchema {
},
);
case "number": {
const minimum = isNonNegativePricingField(field) ? 0 : undefined;
const errorMessage =
minimum === 0
? `${label} must be zero or greater.`
: `${label} must be a valid number.`;
return Yup.string().test("optional-number", errorMessage, (value) =>
isValidOptionalNumber(value, minimum),
case "number":
return Yup.string().test(
"optional-number",
`${label} must be a valid number.`,
(value) => isValidOptionalNumber(value),
);
}
case "boolean":
return Yup.string().test(
@@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import type * as TypesGen from "#/api/typesGenerated";
import {
getDefaultPricingForField,
getPricingPlaceholderForField,
hasCustomPricing,
pricingFieldNameList,
} from "./pricingFields";
describe("pricingFields", () => {
it("uses $0 defaults for every pricing field", () => {
for (const fieldName of pricingFieldNameList) {
expect(getDefaultPricingForField(fieldName)).toBe("0");
expect(getPricingPlaceholderForField(fieldName)).toBe("0");
}
});
it("treats missing pricing as undefined pricing", () => {
expect(hasCustomPricing()).toBe(false);
});
it("treats explicit zero pricing as custom pricing", () => {
expect(
hasCustomPricing({
cost: {
input_price_per_million_tokens: "0",
output_price_per_million_tokens: "0",
},
} satisfies TypesGen.ChatModelCallConfig),
).toBe(true);
});
it("detects custom pricing when any pricing field is greater than zero", () => {
expect(
hasCustomPricing({
cost: {
cache_write_price_per_million_tokens: "0.25",
},
} satisfies TypesGen.ChatModelCallConfig),
).toBe(true);
});
});
@@ -1,65 +0,0 @@
import type * as TypesGen from "#/api/typesGenerated";
// Single source of truth for the model config fields that belong in the
// Pricing section and require non-negative validation.
export const pricingFieldNameList = [
"cost.input_price_per_million_tokens",
"cost.output_price_per_million_tokens",
"cost.cache_read_price_per_million_tokens",
"cost.cache_write_price_per_million_tokens",
] as const;
export const pricingFieldNames = new Set<string>(pricingFieldNameList);
type PricingFieldName = (typeof pricingFieldNameList)[number];
export const defaultPricingByFieldName = {
"cost.input_price_per_million_tokens": "0",
"cost.output_price_per_million_tokens": "0",
"cost.cache_read_price_per_million_tokens": "0",
"cost.cache_write_price_per_million_tokens": "0",
} as const satisfies Record<PricingFieldName, string>;
export const pricingPlaceholderByFieldName = {
"cost.input_price_per_million_tokens": "0",
"cost.output_price_per_million_tokens": "0",
"cost.cache_read_price_per_million_tokens": "0",
"cost.cache_write_price_per_million_tokens": "0",
} as const satisfies Record<PricingFieldName, string>;
export const getDefaultPricingForField = (
fieldName: string,
): string | undefined =>
defaultPricingByFieldName[
fieldName as keyof typeof defaultPricingByFieldName
];
export const getPricingPlaceholderForField = (
fieldName: string,
): string | undefined =>
pricingPlaceholderByFieldName[
fieldName as keyof typeof pricingPlaceholderByFieldName
];
const getNestedValue = (value: unknown, path: readonly string[]): unknown => {
let current = value;
for (const segment of path) {
if (
current === undefined ||
current === null ||
typeof current !== "object"
) {
return undefined;
}
current = (current as Record<string, unknown>)[segment];
}
return current;
};
export const hasCustomPricing = (
modelConfig?: TypesGen.ChatModelCallConfig,
): boolean =>
pricingFieldNameList.some(
(fieldName) =>
getNestedValue(modelConfig, fieldName.split(".")) !== undefined,
);
@@ -112,7 +112,6 @@ const meta: Meta<typeof ChatSearchDialog> = {
{ path: "/agents/:agentId", useStoryElement: true },
{ path: "/agents/settings", useStoryElement: true },
{ path: "/agents/settings/personal-skills", useStoryElement: true },
{ path: "/agents/analytics", useStoryElement: true },
],
}),
},
@@ -6,12 +6,6 @@ describe("sidebarViewFromPath", () => {
expect(sidebarViewFromPath("/agents")).toEqual({ panel: "chats" });
});
it("returns analytics for the analytics route", () => {
expect(sidebarViewFromPath("/agents/analytics")).toEqual({
panel: "analytics",
});
});
it("returns chats for non-settings agent routes", () => {
expect(sidebarViewFromPath("/agents/some-uuid")).toEqual({
panel: "chats",
@@ -96,8 +90,4 @@ describe("isSettingsView", () => {
it("returns false for chats", () => {
expect(isSettingsView({ panel: "chats" })).toBe(false);
});
it("returns false for analytics", () => {
expect(isSettingsView({ panel: "analytics" })).toBe(false);
});
});
@@ -1,15 +1,11 @@
type SidebarView =
| { panel: "chats" }
| { panel: "settings"; section: string | undefined }
| { panel: "analytics" };
| { panel: "settings"; section: string | undefined };
/**
* Derive the current sidebar view from the URL pathname.
*/
export function sidebarViewFromPath(pathname: string): SidebarView {
if (pathname.startsWith("/agents/analytics")) {
return { panel: "analytics" };
}
const settingsMatch = pathname.match(/^\/agents\/settings(?:\/([^/]+))?/);
if (settingsMatch) {
return { panel: "settings", section: settingsMatch[1] };
-27
View File
@@ -6,7 +6,6 @@ import {
Outlet,
Route,
ScrollRestoration,
useLocation,
useParams,
} from "react-router";
import { GlobalErrorBoundary } from "./components/ErrorBoundary/GlobalErrorBoundary";
@@ -389,12 +388,6 @@ const AgentSettingsPersonalSkillsPage = lazy(
const AgentSettingsAPIKeysPage = lazy(
() => import("./pages/AgentsPage/AgentSettingsAPIKeysPage"),
);
const AISettingsSpendPage = lazy(
() => import("./pages/AISettingsPage/SpendPage/SpendPage"),
);
const AgentAnalyticsPage = lazy(
() => import("./pages/AgentsPage/AgentAnalyticsPage"),
);
import {
AgentChatPageSkeleton,
@@ -548,12 +541,6 @@ const groupsRouter = () => {
);
};
/** Redirect that preserves the current query string. */
const NavigateWithSearch = ({ to }: { to: string }) => {
const location = useLocation();
return <Navigate to={{ pathname: to, search: location.search }} replace />;
};
/** Redirect /aibridge/sessions/:sessionId to /ai-gateway/sessions/:sessionId. */
const RedirectAIBridgeSession = () => {
const { sessionId } = useParams() as { sessionId: string };
@@ -768,7 +755,6 @@ export const router = createBrowserRouter(
/>
<Route index element={<AISettingsIndexRedirect />} />
<Route path="models" element={<AISettingsModelsPage />} />
<Route path="spend" element={<AISettingsSpendPage />} />
<Route
path="instructions"
element={<AISettingsInstructionsPage />}
@@ -904,24 +890,11 @@ export const router = createBrowserRouter(
path="mcp-servers"
element={<Navigate to="/ai/settings/mcp-servers" replace />}
/>
<Route
path="spend"
element={<NavigateWithSearch to="/ai/settings/spend" />}
/>
<Route
path="limits"
element={<NavigateWithSearch to="/ai/settings/spend" />}
/>
<Route
path="usage"
element={<NavigateWithSearch to="/ai/settings/spend" />}
/>
<Route
path="templates"
element={<Navigate to="/ai/settings/templates" replace />}
/>
</Route>
<Route path="analytics" element={<AgentAnalyticsPage />} />
<Route
path=":agentId"
element={
-24
View File
@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { formatTokenCount } from "./analytics";
describe("formatTokenCount", () => {
it("formats zero values", () => {
expect(formatTokenCount(0)).toBe("0");
});
it("formats normal values with locale separators", () => {
expect(formatTokenCount(999)).toBe("999");
expect(formatTokenCount(1_234)).toBe("1,234");
expect(formatTokenCount(999_999)).toBe("999,999");
});
it("formats large values in millions", () => {
expect(formatTokenCount(1_000_000)).toBe("1M");
expect(formatTokenCount(1_500_000)).toBe("1.5M");
expect(formatTokenCount(2_000_000)).toBe("2M");
});
it("rounds million values to one decimal place when needed", () => {
expect(formatTokenCount(1_250_000)).toBe("1.3M");
});
});
-14
View File
@@ -1,14 +0,0 @@
/**
* Format a token count to a compact human-readable string.
* Examples: 0 "0", 1234 "1,234", 1_500_000 "1.5M"
*/
export function formatTokenCount(tokens: number): string {
if (tokens >= 1_000_000) {
const millions = tokens / 1_000_000;
return `${millions % 1 === 0 ? millions.toFixed(0) : millions.toFixed(1)}M`;
}
if (tokens >= 1_000) {
return tokens.toLocaleString("en-US");
}
return tokens.toString();
}