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.
This commit is contained in:
Michael Suchacz
2026-08-04 11:36:49 +02:00
committed by GitHub
parent a2287d6739
commit f0e6ac64b3
78 changed files with 611 additions and 6850 deletions
-390
View File
@@ -7097,24 +7097,6 @@ func (q *sqlQuerier) DeleteChatQueuedMessageReturningCount(ctx context.Context,
return result.RowsAffected()
}
const deleteChatUsageLimitGroupOverride = `-- name: DeleteChatUsageLimitGroupOverride :exec
UPDATE groups SET chat_spend_limit_micros = NULL WHERE id = $1::uuid
`
func (q *sqlQuerier) DeleteChatUsageLimitGroupOverride(ctx context.Context, groupID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteChatUsageLimitGroupOverride, groupID)
return err
}
const deleteChatUsageLimitUserOverride = `-- name: DeleteChatUsageLimitUserOverride :exec
UPDATE users SET chat_spend_limit_micros = NULL WHERE id = $1::uuid
`
func (q *sqlQuerier) DeleteChatUsageLimitUserOverride(ctx context.Context, userID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteChatUsageLimitUserOverride, userID)
return err
}
const deleteOldChats = `-- name: DeleteOldChats :execrows
WITH deletable AS (
SELECT id
@@ -9119,61 +9101,6 @@ func (q *sqlQuerier) GetChatStreamSyncRows(ctx context.Context, ids []uuid.UUID)
return items, nil
}
const getChatUsageLimitConfig = `-- name: GetChatUsageLimitConfig :one
SELECT id, singleton, enabled, default_limit_micros, period, created_at, updated_at FROM chat_usage_limit_config WHERE singleton = TRUE LIMIT 1
`
func (q *sqlQuerier) GetChatUsageLimitConfig(ctx context.Context) (ChatUsageLimitConfig, error) {
row := q.db.QueryRowContext(ctx, getChatUsageLimitConfig)
var i ChatUsageLimitConfig
err := row.Scan(
&i.ID,
&i.Singleton,
&i.Enabled,
&i.DefaultLimitMicros,
&i.Period,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getChatUsageLimitGroupOverride = `-- name: GetChatUsageLimitGroupOverride :one
SELECT id AS group_id, chat_spend_limit_micros AS spend_limit_micros
FROM groups
WHERE id = $1::uuid AND chat_spend_limit_micros IS NOT NULL
`
type GetChatUsageLimitGroupOverrideRow struct {
GroupID uuid.UUID `db:"group_id" json:"group_id"`
SpendLimitMicros sql.NullInt64 `db:"spend_limit_micros" json:"spend_limit_micros"`
}
func (q *sqlQuerier) GetChatUsageLimitGroupOverride(ctx context.Context, groupID uuid.UUID) (GetChatUsageLimitGroupOverrideRow, error) {
row := q.db.QueryRowContext(ctx, getChatUsageLimitGroupOverride, groupID)
var i GetChatUsageLimitGroupOverrideRow
err := row.Scan(&i.GroupID, &i.SpendLimitMicros)
return i, err
}
const getChatUsageLimitUserOverride = `-- name: GetChatUsageLimitUserOverride :one
SELECT id AS user_id, chat_spend_limit_micros AS spend_limit_micros
FROM users
WHERE id = $1::uuid AND chat_spend_limit_micros IS NOT NULL
`
type GetChatUsageLimitUserOverrideRow struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
SpendLimitMicros sql.NullInt64 `db:"spend_limit_micros" json:"spend_limit_micros"`
}
func (q *sqlQuerier) GetChatUsageLimitUserOverride(ctx context.Context, userID uuid.UUID) (GetChatUsageLimitUserOverrideRow, error) {
row := q.db.QueryRowContext(ctx, getChatUsageLimitUserOverride, userID)
var i GetChatUsageLimitUserOverrideRow
err := row.Scan(&i.UserID, &i.SpendLimitMicros)
return i, err
}
const getChatUserPromptsByChatID = `-- name: GetChatUserPromptsByChatID :many
SELECT
cm.id,
@@ -10346,68 +10273,6 @@ func (q *sqlQuerier) GetTotalChatMessageRuntimeMsInRange(ctx context.Context, ar
return total_runtime_ms, err
}
const getUserChatSpendInPeriod = `-- name: GetUserChatSpendInPeriod :one
SELECT COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_spend_micros
FROM chat_messages cm
JOIN chats c ON c.id = cm.chat_id
WHERE c.owner_id = $1::uuid
AND ($2::uuid IS NULL
OR c.organization_id = $2::uuid)
AND cm.created_at >= $3::timestamptz
AND cm.created_at < $4::timestamptz
AND cm.total_cost_micros IS NOT NULL
`
type GetUserChatSpendInPeriodParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
StartTime time.Time `db:"start_time" json:"start_time"`
EndTime time.Time `db:"end_time" json:"end_time"`
}
// Returns the total spend for a user in the given period.
// When organization_id is NULL, spend across all organizations is
// returned (global behavior). Otherwise only spend within the
// specified organization is included.
func (q *sqlQuerier) GetUserChatSpendInPeriod(ctx context.Context, arg GetUserChatSpendInPeriodParams) (int64, error) {
row := q.db.QueryRowContext(ctx, getUserChatSpendInPeriod,
arg.UserID,
arg.OrganizationID,
arg.StartTime,
arg.EndTime,
)
var total_spend_micros int64
err := row.Scan(&total_spend_micros)
return total_spend_micros, err
}
const getUserGroupSpendLimit = `-- name: GetUserGroupSpendLimit :one
SELECT COALESCE(MIN(g.chat_spend_limit_micros), -1)::bigint AS limit_micros
FROM groups g
JOIN group_members_expanded gme ON gme.group_id = g.id
WHERE gme.user_id = $1::uuid
AND ($2::uuid IS NULL
OR g.organization_id = $2::uuid)
AND g.chat_spend_limit_micros IS NOT NULL
`
type GetUserGroupSpendLimitParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
}
// Returns the minimum (most restrictive) group limit for a user.
// Returns -1 if no group limits match the specified scope.
// When organization_id is NULL, groups across all organizations are
// considered (global behavior). Otherwise only groups within the
// specified organization are considered.
func (q *sqlQuerier) GetUserGroupSpendLimit(ctx context.Context, arg GetUserGroupSpendLimitParams) (int64, error) {
row := q.db.QueryRowContext(ctx, getUserGroupSpendLimit, arg.UserID, arg.OrganizationID)
var limit_micros int64
err := row.Scan(&limit_micros)
return limit_micros, err
}
const hydrateAgentChatsContext = `-- name: HydrateAgentChatsContext :many
WITH hydrated AS (
UPDATE chats
@@ -11138,106 +11003,6 @@ func (q *sqlQuerier) ListChatContextResourcesByChatID(ctx context.Context, chatI
return items, nil
}
const listChatUsageLimitGroupOverrides = `-- name: ListChatUsageLimitGroupOverrides :many
SELECT
g.id AS group_id,
g.name AS group_name,
g.display_name AS group_display_name,
g.avatar_url AS group_avatar_url,
g.chat_spend_limit_micros AS spend_limit_micros,
(SELECT COUNT(*)
FROM group_members_expanded gme
WHERE gme.group_id = g.id
AND gme.user_is_system = FALSE) AS member_count
FROM groups g
WHERE g.chat_spend_limit_micros IS NOT NULL
ORDER BY g.name ASC
`
type ListChatUsageLimitGroupOverridesRow struct {
GroupID uuid.UUID `db:"group_id" json:"group_id"`
GroupName string `db:"group_name" json:"group_name"`
GroupDisplayName string `db:"group_display_name" json:"group_display_name"`
GroupAvatarUrl string `db:"group_avatar_url" json:"group_avatar_url"`
SpendLimitMicros sql.NullInt64 `db:"spend_limit_micros" json:"spend_limit_micros"`
MemberCount int64 `db:"member_count" json:"member_count"`
}
func (q *sqlQuerier) ListChatUsageLimitGroupOverrides(ctx context.Context) ([]ListChatUsageLimitGroupOverridesRow, error) {
rows, err := q.db.QueryContext(ctx, listChatUsageLimitGroupOverrides)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListChatUsageLimitGroupOverridesRow
for rows.Next() {
var i ListChatUsageLimitGroupOverridesRow
if err := rows.Scan(
&i.GroupID,
&i.GroupName,
&i.GroupDisplayName,
&i.GroupAvatarUrl,
&i.SpendLimitMicros,
&i.MemberCount,
); 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 listChatUsageLimitOverrides = `-- name: ListChatUsageLimitOverrides :many
SELECT u.id AS user_id, u.username, u.name, u.avatar_url,
u.chat_spend_limit_micros AS spend_limit_micros
FROM users u
WHERE u.chat_spend_limit_micros IS NOT NULL
ORDER BY u.username ASC
`
type ListChatUsageLimitOverridesRow 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"`
SpendLimitMicros sql.NullInt64 `db:"spend_limit_micros" json:"spend_limit_micros"`
}
func (q *sqlQuerier) ListChatUsageLimitOverrides(ctx context.Context) ([]ListChatUsageLimitOverridesRow, error) {
rows, err := q.db.QueryContext(ctx, listChatUsageLimitOverrides)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListChatUsageLimitOverridesRow
for rows.Next() {
var i ListChatUsageLimitOverridesRow
if err := rows.Scan(
&i.UserID,
&i.Username,
&i.Name,
&i.AvatarURL,
&i.SpendLimitMicros,
); 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 lockChatAndBumpSnapshotVersion = `-- name: LockChatAndBumpSnapshotVersion :one
WITH bumped_chat AS (
UPDATE chats
@@ -11558,63 +11323,6 @@ func (q *sqlQuerier) ReorderChatQueuedMessageToHead(ctx context.Context, arg Reo
return result.RowsAffected()
}
const resolveUserChatSpendLimit = `-- name: ResolveUserChatSpendLimit :one
SELECT CASE
WHEN NOT cfg.enabled THEN -1
WHEN u.chat_spend_limit_micros IS NOT NULL THEN u.chat_spend_limit_micros
WHEN gl.limit_micros IS NOT NULL THEN gl.limit_micros
ELSE cfg.default_limit_micros
END::bigint AS effective_limit_micros,
CASE
WHEN NOT cfg.enabled THEN 'disabled'
WHEN u.chat_spend_limit_micros IS NOT NULL THEN 'user'
WHEN gl.limit_micros IS NOT NULL THEN 'group'
ELSE 'default'
END AS limit_source
FROM chat_usage_limit_config cfg
CROSS JOIN users u
LEFT JOIN LATERAL (
SELECT MIN(g.chat_spend_limit_micros) AS limit_micros
FROM groups g
JOIN group_members_expanded gme ON gme.group_id = g.id
WHERE gme.user_id = $1::uuid
AND ($2::uuid IS NULL
OR g.organization_id = $2::uuid)
AND g.chat_spend_limit_micros IS NOT NULL
) gl ON TRUE
WHERE u.id = $1::uuid
LIMIT 1
`
type ResolveUserChatSpendLimitParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
}
type ResolveUserChatSpendLimitRow struct {
EffectiveLimitMicros int64 `db:"effective_limit_micros" json:"effective_limit_micros"`
LimitSource string `db:"limit_source" json:"limit_source"`
}
// Resolves the effective spend limit for a user using the hierarchy:
// 1. Individual user override (highest priority, applies globally across
// all organizations since it lives on the users table)
// 2. Minimum group limit across the user's groups
// 3. Global default from config
//
// Returns -1 if limits are not enabled.
// When organization_id is NULL, groups across all organizations are
// considered (global behavior). Otherwise only groups within the
// specified organization are considered.
// limit_source indicates which tier won: 'user', 'group', 'default',
// or 'disabled'.
func (q *sqlQuerier) ResolveUserChatSpendLimit(ctx context.Context, arg ResolveUserChatSpendLimitParams) (ResolveUserChatSpendLimitRow, error) {
row := q.db.QueryRowContext(ctx, resolveUserChatSpendLimit, arg.UserID, arg.OrganizationID)
var i ResolveUserChatSpendLimitRow
err := row.Scan(&i.EffectiveLimitMicros, &i.LimitSource)
return i, err
}
const setChatContextSnapshot = `-- name: SetChatContextSnapshot :exec
UPDATE chats
SET
@@ -13823,104 +13531,6 @@ func (q *sqlQuerier) UpsertChatHeartbeat(ctx context.Context, arg UpsertChatHear
return err
}
const upsertChatUsageLimitConfig = `-- name: UpsertChatUsageLimitConfig :one
INSERT INTO chat_usage_limit_config (singleton, enabled, default_limit_micros, period, updated_at)
VALUES (TRUE, $1::boolean, $2::bigint, $3::text, NOW())
ON CONFLICT (singleton) DO UPDATE SET
enabled = EXCLUDED.enabled,
default_limit_micros = EXCLUDED.default_limit_micros,
period = EXCLUDED.period,
updated_at = NOW()
RETURNING id, singleton, enabled, default_limit_micros, period, created_at, updated_at
`
type UpsertChatUsageLimitConfigParams struct {
Enabled bool `db:"enabled" json:"enabled"`
DefaultLimitMicros int64 `db:"default_limit_micros" json:"default_limit_micros"`
Period string `db:"period" json:"period"`
}
func (q *sqlQuerier) UpsertChatUsageLimitConfig(ctx context.Context, arg UpsertChatUsageLimitConfigParams) (ChatUsageLimitConfig, error) {
row := q.db.QueryRowContext(ctx, upsertChatUsageLimitConfig, arg.Enabled, arg.DefaultLimitMicros, arg.Period)
var i ChatUsageLimitConfig
err := row.Scan(
&i.ID,
&i.Singleton,
&i.Enabled,
&i.DefaultLimitMicros,
&i.Period,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const upsertChatUsageLimitGroupOverride = `-- name: UpsertChatUsageLimitGroupOverride :one
UPDATE groups
SET chat_spend_limit_micros = $1::bigint
WHERE id = $2::uuid
RETURNING id AS group_id, name, display_name, avatar_url, chat_spend_limit_micros AS spend_limit_micros
`
type UpsertChatUsageLimitGroupOverrideParams struct {
SpendLimitMicros int64 `db:"spend_limit_micros" json:"spend_limit_micros"`
GroupID uuid.UUID `db:"group_id" json:"group_id"`
}
type UpsertChatUsageLimitGroupOverrideRow struct {
GroupID uuid.UUID `db:"group_id" json:"group_id"`
Name string `db:"name" json:"name"`
DisplayName string `db:"display_name" json:"display_name"`
AvatarURL string `db:"avatar_url" json:"avatar_url"`
SpendLimitMicros sql.NullInt64 `db:"spend_limit_micros" json:"spend_limit_micros"`
}
func (q *sqlQuerier) UpsertChatUsageLimitGroupOverride(ctx context.Context, arg UpsertChatUsageLimitGroupOverrideParams) (UpsertChatUsageLimitGroupOverrideRow, error) {
row := q.db.QueryRowContext(ctx, upsertChatUsageLimitGroupOverride, arg.SpendLimitMicros, arg.GroupID)
var i UpsertChatUsageLimitGroupOverrideRow
err := row.Scan(
&i.GroupID,
&i.Name,
&i.DisplayName,
&i.AvatarURL,
&i.SpendLimitMicros,
)
return i, err
}
const upsertChatUsageLimitUserOverride = `-- name: UpsertChatUsageLimitUserOverride :one
UPDATE users
SET chat_spend_limit_micros = $1::bigint
WHERE id = $2::uuid
RETURNING id AS user_id, username, name, avatar_url, chat_spend_limit_micros AS spend_limit_micros
`
type UpsertChatUsageLimitUserOverrideParams struct {
SpendLimitMicros int64 `db:"spend_limit_micros" json:"spend_limit_micros"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
}
type UpsertChatUsageLimitUserOverrideRow 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"`
SpendLimitMicros sql.NullInt64 `db:"spend_limit_micros" json:"spend_limit_micros"`
}
func (q *sqlQuerier) UpsertChatUsageLimitUserOverride(ctx context.Context, arg UpsertChatUsageLimitUserOverrideParams) (UpsertChatUsageLimitUserOverrideRow, error) {
row := q.db.QueryRowContext(ctx, upsertChatUsageLimitUserOverride, arg.SpendLimitMicros, arg.UserID)
var i UpsertChatUsageLimitUserOverrideRow
err := row.Scan(
&i.UserID,
&i.Username,
&i.Name,
&i.AvatarURL,
&i.SpendLimitMicros,
)
return i, err
}
const batchUpsertConnectionLogs = `-- name: BatchUpsertConnectionLogs :exec
INSERT INTO connection_logs (
id, connect_time, organization_id, workspace_owner_id, workspace_id,