feat: add user AI spend endpoint (#26978)

## Description

Adds the `GET /api/v2/users/{user}/ai/spend` endpoint returning the
user's current AI spend, effective budget, and period bounds.

## Changes

- Add `userAISpendStatus` handler under the same feature/experiment gate
as `/api/v2/users/{user}/ai/budget`.
- Add `codersdk.UserAIBudgetSummary` (embedded into `UserAISpendStatus`)
and a `UserAISpendStatus` client method.
- Move `LimitSource` from `coderd/aibridge/budget` to `codersdk` so the
type is shared across endpoints.

Closes https://linear.app/codercom/issue/AIGOV-472

> [!NOTE]
> Initially generated by Claude Opus 4.7, modified and reviewed by
@ssncferreira
This commit is contained in:
Susana Ferreira
2026-07-08 12:46:47 +01:00
committed by GitHub
parent 60d0859bde
commit 48f07e6e13
11 changed files with 719 additions and 45 deletions
+3 -14
View File
@@ -15,17 +15,6 @@ import (
"github.com/coder/coder/v2/codersdk"
)
// LimitSource identifies which tier produced an EffectiveBudget.
type LimitSource string
const (
// SourceUserOverride indicates the budget came from a per-user override.
SourceUserOverride LimitSource = "user_override"
// SourceGroup indicates the budget came from a group budget selected by the
// deployment policy.
SourceGroup LimitSource = "group"
)
// Store is the subset of database.Store needed to resolve a user's effective
// AI budget.
type Store interface {
@@ -41,7 +30,7 @@ type EffectiveBudget struct {
// SpendLimitMicros is the effective spend limit in micro-units
// (1 unit = 1,000,000).
SpendLimitMicros int64
Source LimitSource
Source codersdk.AIBudgetLimitSource
}
// ResolveUserAIBudget returns the effective AI budget for userID. The second
@@ -55,7 +44,7 @@ func ResolveUserAIBudget(ctx context.Context, db Store, userID uuid.UUID, policy
return EffectiveBudget{
GroupID: override.GroupID,
SpendLimitMicros: override.SpendLimitMicros,
Source: SourceUserOverride,
Source: codersdk.AIBudgetLimitSourceUserOverride,
}, true, nil
}
if !errors.Is(err, sql.ErrNoRows) {
@@ -75,7 +64,7 @@ func ResolveUserAIBudget(ctx context.Context, db Store, userID uuid.UUID, policy
return EffectiveBudget{
GroupID: row.GroupID,
SpendLimitMicros: row.SpendLimitMicros,
Source: SourceGroup,
Source: codersdk.AIBudgetLimitSourceGroup,
}, true, nil
default:
return EffectiveBudget{}, false, xerrors.Errorf("unsupported AI budget policy: %q", policy)
+7 -7
View File
@@ -72,7 +72,7 @@ func TestResolveUserAIBudget(t *testing.T) {
SpendLimitMicros: 1_000_000,
})
require.NoError(t, err)
return user.ID, budget.EffectiveBudget{GroupID: og.ID, SpendLimitMicros: 1_000_000, Source: budget.SourceUserOverride}, true
return user.ID, budget.EffectiveBudget{GroupID: og.ID, SpendLimitMicros: 1_000_000, Source: codersdk.AIBudgetLimitSourceUserOverride}, true
},
},
{
@@ -82,7 +82,7 @@ func TestResolveUserAIBudget(t *testing.T) {
org := dbgen.Organization(t, db, database.Organization{})
user := dbgen.User(t, db, database.User{})
gid := budgetedGroup(t, ctx, db, org.ID, user.ID, "only", 8_000_000)
return user.ID, budget.EffectiveBudget{GroupID: gid, SpendLimitMicros: 8_000_000, Source: budget.SourceGroup}, true
return user.ID, budget.EffectiveBudget{GroupID: gid, SpendLimitMicros: 8_000_000, Source: codersdk.AIBudgetLimitSourceGroup}, true
},
},
{
@@ -94,7 +94,7 @@ func TestResolveUserAIBudget(t *testing.T) {
budgetedGroup(t, ctx, db, org.ID, user.ID, "low", 5_000_000)
budgetedGroup(t, ctx, db, org.ID, user.ID, "mid", 20_000_000)
high := budgetedGroup(t, ctx, db, org.ID, user.ID, "high", 50_000_000)
return user.ID, budget.EffectiveBudget{GroupID: high, SpendLimitMicros: 50_000_000, Source: budget.SourceGroup}, true
return user.ID, budget.EffectiveBudget{GroupID: high, SpendLimitMicros: 50_000_000, Source: codersdk.AIBudgetLimitSourceGroup}, true
},
},
{
@@ -106,7 +106,7 @@ func TestResolveUserAIBudget(t *testing.T) {
// Equal limits; "alpha" must win over "beta" by name ascending.
alpha := budgetedGroup(t, ctx, db, org.ID, user.ID, "alpha", 10_000_000)
budgetedGroup(t, ctx, db, org.ID, user.ID, "beta", 10_000_000)
return user.ID, budget.EffectiveBudget{GroupID: alpha, SpendLimitMicros: 10_000_000, Source: budget.SourceGroup}, true
return user.ID, budget.EffectiveBudget{GroupID: alpha, SpendLimitMicros: 10_000_000, Source: codersdk.AIBudgetLimitSourceGroup}, true
},
},
{
@@ -124,7 +124,7 @@ func TestResolveUserAIBudget(t *testing.T) {
if bytes.Compare(g2[:], g1[:]) < 0 {
winner = g2
}
return user.ID, budget.EffectiveBudget{GroupID: winner, SpendLimitMicros: 10_000_000, Source: budget.SourceGroup}, true
return user.ID, budget.EffectiveBudget{GroupID: winner, SpendLimitMicros: 10_000_000, Source: codersdk.AIBudgetLimitSourceGroup}, true
},
},
{
@@ -147,7 +147,7 @@ func TestResolveUserAIBudget(t *testing.T) {
// Membership is via organization_members only (no group_members row),
// exercising the org-members half of group_members_expanded.
everyoneID := budgetedEveryoneGroup(t, ctx, db, org.ID, user.ID, 7_000_000)
return user.ID, budget.EffectiveBudget{GroupID: everyoneID, SpendLimitMicros: 7_000_000, Source: budget.SourceGroup}, true
return user.ID, budget.EffectiveBudget{GroupID: everyoneID, SpendLimitMicros: 7_000_000, Source: codersdk.AIBudgetLimitSourceGroup}, true
},
},
{
@@ -165,7 +165,7 @@ func TestResolveUserAIBudget(t *testing.T) {
SpendLimitMicros: 2_000_000,
})
require.NoError(t, err)
return user.ID, budget.EffectiveBudget{GroupID: everyoneID, SpendLimitMicros: 2_000_000, Source: budget.SourceUserOverride}, true
return user.ID, budget.EffectiveBudget{GroupID: everyoneID, SpendLimitMicros: 2_000_000, Source: codersdk.AIBudgetLimitSourceUserOverride}, true
},
},
{