mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+85
@@ -9652,6 +9652,40 @@ const docTemplate = `{
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/users/{user}/ai/spend": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Get user AI spend",
|
||||
"operationId": "get-user-ai-spend",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, username, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UserAISpendStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/users/{user}/appearance": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -15251,6 +15285,17 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.AIBudgetLimitSource": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"user_override",
|
||||
"group"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"AIBudgetLimitSourceUserOverride",
|
||||
"AIBudgetLimitSourceGroup"
|
||||
]
|
||||
},
|
||||
"codersdk.AIConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -25657,6 +25702,46 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserAISpendStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current_spend_micros": {
|
||||
"description": "CurrentSpendMicros is the user's spend on their effective group over\nthe current budget period.",
|
||||
"type": "integer"
|
||||
},
|
||||
"effective_group_id": {
|
||||
"description": "EffectiveGroupID is the group the spend is attributed to. Null when\nno budget applies.",
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"limit_source": {
|
||||
"description": "LimitSource identifies which tier produced the limit. Null when no\nbudget applies.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.AIBudgetLimitSource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"period_end": {
|
||||
"description": "PeriodEnd is the exclusive upper bound of the current budget\nperiod.",
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"period_start": {
|
||||
"description": "PeriodStart is the inclusive lower bound of the current budget\nperiod.",
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"spend_limit_micros": {
|
||||
"description": "SpendLimitMicros is the effective spend limit in micro-units.\nNull when no budget applies to the user (unlimited).",
|
||||
"type": "integer"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserActivity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+78
@@ -8555,6 +8555,36 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/users/{user}/ai/spend": {
|
||||
"get": {
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Get user AI spend",
|
||||
"operationId": "get-user-ai-spend",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID, username, or me",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UserAISpendStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v2/users/{user}/appearance": {
|
||||
"get": {
|
||||
"produces": ["application/json"],
|
||||
@@ -13597,6 +13627,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.AIBudgetLimitSource": {
|
||||
"type": "string",
|
||||
"enum": ["user_override", "group"],
|
||||
"x-enum-varnames": [
|
||||
"AIBudgetLimitSourceUserOverride",
|
||||
"AIBudgetLimitSourceGroup"
|
||||
]
|
||||
},
|
||||
"codersdk.AIConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23585,6 +23623,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserAISpendStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current_spend_micros": {
|
||||
"description": "CurrentSpendMicros is the user's spend on their effective group over\nthe current budget period.",
|
||||
"type": "integer"
|
||||
},
|
||||
"effective_group_id": {
|
||||
"description": "EffectiveGroupID is the group the spend is attributed to. Null when\nno budget applies.",
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"limit_source": {
|
||||
"description": "LimitSource identifies which tier produced the limit. Null when no\nbudget applies.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.AIBudgetLimitSource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"period_end": {
|
||||
"description": "PeriodEnd is the exclusive upper bound of the current budget\nperiod.",
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"period_start": {
|
||||
"description": "PeriodStart is the inclusive lower bound of the current budget\nperiod.",
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"spend_limit_micros": {
|
||||
"description": "SpendLimitMicros is the effective spend limit in micro-units.\nNull when no budget applies to the user (unlimited).",
|
||||
"type": "integer"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserActivity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user