Skip to content

Commit f0e6ac6

Browse files
authored
feat: remove native chat usage limits in favor of AI Gateway budgets (#27329)
## 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. **This PR:** remove native chat usage limits. 3. Remove native chat cost tracking and its dedicated admin UI (#27330). ## Summary Removes the native usage-limit API, SDK types, SQL, and chat enforcement for deployment, user, and group chat limits. Compact AI Gateway budget indicators remain in the Agents sidebar, user menu, and group settings. Gateway budget rejections and provider quota failures continue to classify as usage-limit errors, including a 409 response for synchronous title generation. Budget-period labels now use the API's UTC boundaries, so users see the same dates in every browser timezone. The documentation explains the AI Gateway replacement, its licensing requirements, and the differences from native limits. No schema is dropped in this release. The usage-limit table, index, user and group columns, constraints, audit mappings, and generated scan fields remain for mixed-version rolling upgrades. #27600 tracks their removal after the compatibility window. ## Breaking change Native day, week, and month chat spend limits are removed and are not migrated. AI Gateway budgets are month-based, group-scoped with per-user overrides, and require the AI Gateway entitlement. Deployments without that entitlement no longer have chat spend enforcement. > Mux prepared this PR on Mike's behalf.
1 parent a2287d6 commit f0e6ac6

78 files changed

Lines changed: 607 additions & 6846 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎coderd/coderd.go‎

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1420,19 +1420,6 @@ func New(options *Options) *API {
14201420
r.Delete("/", api.deleteChatModelConfig)
14211421
})
14221422
})
1423-
r.Route("/usage-limits", func(r chi.Router) {
1424-
r.Get("/", api.getChatUsageLimitConfig)
1425-
r.Put("/", api.updateChatUsageLimitConfig)
1426-
r.Get("/status", api.getMyChatUsageLimitStatus)
1427-
r.Route("/overrides/{user}", func(r chi.Router) {
1428-
r.Put("/", api.upsertChatUsageLimitOverride)
1429-
r.Delete("/", api.deleteChatUsageLimitOverride)
1430-
})
1431-
r.Route("/group-overrides/{group}", func(r chi.Router) {
1432-
r.Put("/", api.upsertChatUsageLimitGroupOverride)
1433-
r.Delete("/", api.deleteChatUsageLimitGroupOverride)
1434-
})
1435-
})
14361423
r.Route("/user-provider-configs", func(r chi.Router) {
14371424
r.Get("/", api.listUserChatProviderConfigs)
14381425
r.Route("/{providerConfig}", func(r chi.Router) {

‎coderd/database/dbauthz/dbauthz.go‎

Lines changed: 0 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -2213,20 +2213,6 @@ func (q *querier) DeleteChatQueuedMessageReturningCount(ctx context.Context, arg
22132213
return q.db.DeleteChatQueuedMessageReturningCount(ctx, arg)
22142214
}
22152215

2216-
func (q *querier) DeleteChatUsageLimitGroupOverride(ctx context.Context, groupID uuid.UUID) error {
2217-
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
2218-
return err
2219-
}
2220-
return q.db.DeleteChatUsageLimitGroupOverride(ctx, groupID)
2221-
}
2222-
2223-
func (q *querier) DeleteChatUsageLimitUserOverride(ctx context.Context, userID uuid.UUID) error {
2224-
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
2225-
return err
2226-
}
2227-
return q.db.DeleteChatUsageLimitUserOverride(ctx, userID)
2228-
}
2229-
22302216
func (q *querier) DeleteCryptoKey(ctx context.Context, arg database.DeleteCryptoKeyParams) (database.CryptoKey, error) {
22312217
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceCryptoKey); err != nil {
22322218
return database.CryptoKey{}, err
@@ -3636,27 +3622,6 @@ func (q *querier) GetChatTitleGenerationModelOverride(ctx context.Context) (stri
36363622
return q.db.GetChatTitleGenerationModelOverride(ctx)
36373623
}
36383624

3639-
func (q *querier) GetChatUsageLimitConfig(ctx context.Context) (database.ChatUsageLimitConfig, error) {
3640-
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
3641-
return database.ChatUsageLimitConfig{}, err
3642-
}
3643-
return q.db.GetChatUsageLimitConfig(ctx)
3644-
}
3645-
3646-
func (q *querier) GetChatUsageLimitGroupOverride(ctx context.Context, groupID uuid.UUID) (database.GetChatUsageLimitGroupOverrideRow, error) {
3647-
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
3648-
return database.GetChatUsageLimitGroupOverrideRow{}, err
3649-
}
3650-
return q.db.GetChatUsageLimitGroupOverride(ctx, groupID)
3651-
}
3652-
3653-
func (q *querier) GetChatUsageLimitUserOverride(ctx context.Context, userID uuid.UUID) (database.GetChatUsageLimitUserOverrideRow, error) {
3654-
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
3655-
return database.GetChatUsageLimitUserOverrideRow{}, err
3656-
}
3657-
return q.db.GetChatUsageLimitUserOverride(ctx, userID)
3658-
}
3659-
36603625
func (q *querier) GetChatUserPromptsByChatID(ctx context.Context, arg database.GetChatUserPromptsByChatIDParams) ([]database.GetChatUserPromptsByChatIDRow, error) {
36613626
// Authorize read on the parent chat.
36623627
_, err := q.GetChatByID(ctx, arg.ChatID)
@@ -5179,13 +5144,6 @@ func (q *querier) GetUserChatPersonalModelOverride(ctx context.Context, arg data
51795144
return q.db.GetUserChatPersonalModelOverride(ctx, arg)
51805145
}
51815146

5182-
func (q *querier) GetUserChatSpendInPeriod(ctx context.Context, arg database.GetUserChatSpendInPeriodParams) (int64, error) {
5183-
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithOwner(arg.UserID.String())); err != nil {
5184-
return 0, err
5185-
}
5186-
return q.db.GetUserChatSpendInPeriod(ctx, arg)
5187-
}
5188-
51895147
func (q *querier) GetUserCodeDiffDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) {
51905148
user, err := q.db.GetUserByID(ctx, userID)
51915149
if err != nil {
@@ -5216,13 +5174,6 @@ func (q *querier) GetUserForChatSyntheticAPIKeyByID(ctx context.Context, id uuid
52165174
return fetchWithAction(q.log, q.auth, policy.ActionReadPersonal, q.db.GetUserForChatSyntheticAPIKeyByID)(ctx, id)
52175175
}
52185176

5219-
func (q *querier) GetUserGroupSpendLimit(ctx context.Context, arg database.GetUserGroupSpendLimitParams) (int64, error) {
5220-
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithOwner(arg.UserID.String())); err != nil {
5221-
return 0, err
5222-
}
5223-
return q.db.GetUserGroupSpendLimit(ctx, arg)
5224-
}
5225-
52265177
func (q *querier) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) {
52275178
// Used by insights endpoints. Need to check both for auditors and for regular users with template acl perms.
52285179
if err := q.authorizeContext(ctx, policy.ActionViewInsights, rbac.ResourceTemplate); err != nil {
@@ -6908,20 +6859,6 @@ func (q *querier) ListChatContextResourcesByChatID(ctx context.Context, chatID u
69086859
return q.db.ListChatContextResourcesByChatID(ctx, chatID)
69096860
}
69106861

6911-
func (q *querier) ListChatUsageLimitGroupOverrides(ctx context.Context) ([]database.ListChatUsageLimitGroupOverridesRow, error) {
6912-
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
6913-
return nil, err
6914-
}
6915-
return q.db.ListChatUsageLimitGroupOverrides(ctx)
6916-
}
6917-
6918-
func (q *querier) ListChatUsageLimitOverrides(ctx context.Context) ([]database.ListChatUsageLimitOverridesRow, error) {
6919-
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceDeploymentConfig); err != nil {
6920-
return nil, err
6921-
}
6922-
return q.db.ListChatUsageLimitOverrides(ctx)
6923-
}
6924-
69256862
func (q *querier) ListProvisionerKeysByOrganization(ctx context.Context, organizationID uuid.UUID) ([]database.ProvisionerKey, error) {
69266863
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.ListProvisionerKeysByOrganization)(ctx, organizationID)
69276864
}
@@ -7157,13 +7094,6 @@ func (q *querier) ReorderChatQueuedMessageToHead(ctx context.Context, arg databa
71577094
return q.db.ReorderChatQueuedMessageToHead(ctx, arg)
71587095
}
71597096

7160-
func (q *querier) ResolveUserChatSpendLimit(ctx context.Context, arg database.ResolveUserChatSpendLimitParams) (database.ResolveUserChatSpendLimitRow, error) {
7161-
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat.WithOwner(arg.UserID.String())); err != nil {
7162-
return database.ResolveUserChatSpendLimitRow{}, err
7163-
}
7164-
return q.db.ResolveUserChatSpendLimit(ctx, arg)
7165-
}
7166-
71677097
func (q *querier) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string) error {
71687098
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
71697099
return err
@@ -9047,27 +8977,6 @@ func (q *querier) UpsertChatTitleGenerationModelOverride(ctx context.Context, va
90478977
return q.db.UpsertChatTitleGenerationModelOverride(ctx, value)
90488978
}
90498979

9050-
func (q *querier) UpsertChatUsageLimitConfig(ctx context.Context, arg database.UpsertChatUsageLimitConfigParams) (database.ChatUsageLimitConfig, error) {
9051-
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
9052-
return database.ChatUsageLimitConfig{}, err
9053-
}
9054-
return q.db.UpsertChatUsageLimitConfig(ctx, arg)
9055-
}
9056-
9057-
func (q *querier) UpsertChatUsageLimitGroupOverride(ctx context.Context, arg database.UpsertChatUsageLimitGroupOverrideParams) (database.UpsertChatUsageLimitGroupOverrideRow, error) {
9058-
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
9059-
return database.UpsertChatUsageLimitGroupOverrideRow{}, err
9060-
}
9061-
return q.db.UpsertChatUsageLimitGroupOverride(ctx, arg)
9062-
}
9063-
9064-
func (q *querier) UpsertChatUsageLimitUserOverride(ctx context.Context, arg database.UpsertChatUsageLimitUserOverrideParams) (database.UpsertChatUsageLimitUserOverrideRow, error) {
9065-
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
9066-
return database.UpsertChatUsageLimitUserOverrideRow{}, err
9067-
}
9068-
return q.db.UpsertChatUsageLimitUserOverride(ctx, arg)
9069-
}
9070-
90718980
//nolint:revive // Parameter name matches the generated querier interface.
90728981
func (q *querier) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error {
90738982
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {

‎coderd/database/dbauthz/dbauthz_test.go‎

Lines changed: 0 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -1688,152 +1688,6 @@ func (s *MethodTestSuite) TestChats() {
16881688
dbm.EXPECT().UpsertChatWorkspaceTTL(gomock.Any(), "1h").Return(nil).AnyTimes()
16891689
check.Args("1h").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
16901690
}))
1691-
s.Run("GetUserChatSpendInPeriod", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
1692-
arg := database.GetUserChatSpendInPeriodParams{
1693-
UserID: uuid.New(),
1694-
OrganizationID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
1695-
1696-
StartTime: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
1697-
EndTime: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC),
1698-
}
1699-
spend := int64(123)
1700-
dbm.EXPECT().GetUserChatSpendInPeriod(gomock.Any(), arg).Return(spend, nil).AnyTimes()
1701-
check.Args(arg).Asserts(rbac.ResourceChat.WithOwner(arg.UserID.String()), policy.ActionRead).Returns(spend)
1702-
}))
1703-
s.Run("GetUserGroupSpendLimit", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
1704-
arg := database.GetUserGroupSpendLimitParams{
1705-
UserID: uuid.New(),
1706-
OrganizationID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
1707-
}
1708-
limit := int64(456)
1709-
dbm.EXPECT().GetUserGroupSpendLimit(gomock.Any(), arg).Return(limit, nil).AnyTimes()
1710-
check.Args(arg).Asserts(rbac.ResourceChat.WithOwner(arg.UserID.String()), policy.ActionRead).Returns(limit)
1711-
}))
1712-
1713-
s.Run("ResolveUserChatSpendLimit", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
1714-
arg := database.ResolveUserChatSpendLimitParams{
1715-
UserID: uuid.New(),
1716-
OrganizationID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
1717-
}
1718-
row := database.ResolveUserChatSpendLimitRow{EffectiveLimitMicros: 789, LimitSource: "group"}
1719-
dbm.EXPECT().ResolveUserChatSpendLimit(gomock.Any(), arg).Return(row, nil).AnyTimes()
1720-
check.Args(arg).Asserts(rbac.ResourceChat.WithOwner(arg.UserID.String()), policy.ActionRead).Returns(row)
1721-
}))
1722-
1723-
s.Run("GetChatUsageLimitConfig", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
1724-
now := dbtime.Now()
1725-
config := database.ChatUsageLimitConfig{
1726-
ID: 1,
1727-
Singleton: true,
1728-
Enabled: true,
1729-
DefaultLimitMicros: 1_000_000,
1730-
Period: "monthly",
1731-
CreatedAt: now,
1732-
UpdatedAt: now,
1733-
}
1734-
dbm.EXPECT().GetChatUsageLimitConfig(gomock.Any()).Return(config, nil).AnyTimes()
1735-
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(config)
1736-
}))
1737-
s.Run("GetChatUsageLimitGroupOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
1738-
groupID := uuid.New()
1739-
override := database.GetChatUsageLimitGroupOverrideRow{
1740-
GroupID: groupID,
1741-
SpendLimitMicros: sql.NullInt64{Int64: 2_000_000, Valid: true},
1742-
}
1743-
dbm.EXPECT().GetChatUsageLimitGroupOverride(gomock.Any(), groupID).Return(override, nil).AnyTimes()
1744-
check.Args(groupID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(override)
1745-
}))
1746-
s.Run("GetChatUsageLimitUserOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
1747-
userID := uuid.New()
1748-
override := database.GetChatUsageLimitUserOverrideRow{
1749-
UserID: userID,
1750-
SpendLimitMicros: sql.NullInt64{Int64: 3_000_000, Valid: true},
1751-
}
1752-
dbm.EXPECT().GetChatUsageLimitUserOverride(gomock.Any(), userID).Return(override, nil).AnyTimes()
1753-
check.Args(userID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(override)
1754-
}))
1755-
s.Run("ListChatUsageLimitGroupOverrides", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
1756-
overrides := []database.ListChatUsageLimitGroupOverridesRow{{
1757-
GroupID: uuid.New(),
1758-
GroupName: "group-name",
1759-
GroupDisplayName: "Group Name",
1760-
GroupAvatarUrl: "https://example.com/group.png",
1761-
SpendLimitMicros: sql.NullInt64{Int64: 4_000_000, Valid: true},
1762-
MemberCount: 5,
1763-
}}
1764-
dbm.EXPECT().ListChatUsageLimitGroupOverrides(gomock.Any()).Return(overrides, nil).AnyTimes()
1765-
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(overrides)
1766-
}))
1767-
s.Run("ListChatUsageLimitOverrides", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
1768-
overrides := []database.ListChatUsageLimitOverridesRow{{
1769-
UserID: uuid.New(),
1770-
Username: "usage-limit-user",
1771-
Name: "Usage Limit User",
1772-
AvatarURL: "https://example.com/avatar.png",
1773-
SpendLimitMicros: sql.NullInt64{Int64: 5_000_000, Valid: true},
1774-
}}
1775-
dbm.EXPECT().ListChatUsageLimitOverrides(gomock.Any()).Return(overrides, nil).AnyTimes()
1776-
check.Args().Asserts(rbac.ResourceDeploymentConfig, policy.ActionRead).Returns(overrides)
1777-
}))
1778-
s.Run("UpsertChatUsageLimitConfig", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
1779-
now := dbtime.Now()
1780-
arg := database.UpsertChatUsageLimitConfigParams{
1781-
Enabled: true,
1782-
DefaultLimitMicros: 6_000_000,
1783-
Period: "monthly",
1784-
}
1785-
config := database.ChatUsageLimitConfig{
1786-
ID: 1,
1787-
Singleton: true,
1788-
Enabled: arg.Enabled,
1789-
DefaultLimitMicros: arg.DefaultLimitMicros,
1790-
Period: arg.Period,
1791-
CreatedAt: now,
1792-
UpdatedAt: now,
1793-
}
1794-
dbm.EXPECT().UpsertChatUsageLimitConfig(gomock.Any(), arg).Return(config, nil).AnyTimes()
1795-
check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(config)
1796-
}))
1797-
s.Run("UpsertChatUsageLimitGroupOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
1798-
arg := database.UpsertChatUsageLimitGroupOverrideParams{
1799-
SpendLimitMicros: 7_000_000,
1800-
GroupID: uuid.New(),
1801-
}
1802-
override := database.UpsertChatUsageLimitGroupOverrideRow{
1803-
GroupID: arg.GroupID,
1804-
Name: "group",
1805-
DisplayName: "Group",
1806-
AvatarURL: "",
1807-
SpendLimitMicros: sql.NullInt64{Int64: arg.SpendLimitMicros, Valid: true},
1808-
}
1809-
dbm.EXPECT().UpsertChatUsageLimitGroupOverride(gomock.Any(), arg).Return(override, nil).AnyTimes()
1810-
check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(override)
1811-
}))
1812-
s.Run("UpsertChatUsageLimitUserOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
1813-
arg := database.UpsertChatUsageLimitUserOverrideParams{
1814-
SpendLimitMicros: 8_000_000,
1815-
UserID: uuid.New(),
1816-
}
1817-
override := database.UpsertChatUsageLimitUserOverrideRow{
1818-
UserID: arg.UserID,
1819-
Username: "user",
1820-
Name: "User",
1821-
AvatarURL: "",
1822-
SpendLimitMicros: sql.NullInt64{Int64: arg.SpendLimitMicros, Valid: true},
1823-
}
1824-
dbm.EXPECT().UpsertChatUsageLimitUserOverride(gomock.Any(), arg).Return(override, nil).AnyTimes()
1825-
check.Args(arg).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(override)
1826-
}))
1827-
s.Run("DeleteChatUsageLimitGroupOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
1828-
groupID := uuid.New()
1829-
dbm.EXPECT().DeleteChatUsageLimitGroupOverride(gomock.Any(), groupID).Return(nil).AnyTimes()
1830-
check.Args(groupID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
1831-
}))
1832-
s.Run("DeleteChatUsageLimitUserOverride", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
1833-
userID := uuid.New()
1834-
dbm.EXPECT().DeleteChatUsageLimitUserOverride(gomock.Any(), userID).Return(nil).AnyTimes()
1835-
check.Args(userID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
1836-
}))
18371691
s.Run("CleanupDeletedMCPServerIDsFromChats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
18381692
dbm.EXPECT().CleanupDeletedMCPServerIDsFromChats(gomock.Any()).Return(nil).AnyTimes()
18391693
check.Args().Asserts(rbac.ResourceChat, policy.ActionUpdate)

0 commit comments

Comments
 (0)