feat: add GET /groups/{group}/members/ai/spend - #27130
#27130feat: add GET /groups/{group}/members/ai/spend#27130ssncferreira merged 8 commits intomaincoder/coder:mainfrom ssncf/aigov-group-members-spendcoder/coder:ssncf/aigov-group-members-spendCopy head branch name to clipboard
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
e5b6754 to
96d3063
Compare
Docs preview📖 View docs preview for |
ca91968 to
802751b
Compare
7c6e8f9 to
4450200
Compare
802751b to
2b21c5f
Compare
b6c2225 to
fa3ffda
Compare
d7bcaae to
ddd5212
Compare
c27116f to
98e74d3
Compare
7cd402b to
946a8e7
Compare
98e74d3 to
eb5f19c
Compare
946a8e7 to
8da7768
Compare
0830e8f to
4b4aa4d
Compare
8da7768 to
64580db
Compare
4b4aa4d to
8ef0651
Compare
e934d28 to
cac8ab5
Compare
There was a problem hiding this comment.
This is a tight, well-scoped addition: one read-only endpoint (GET /groups/{group}/members/ai/spend plus the org-scoped alias), its SQL query, dbauthz per-row filter, SDK types, and tests, all additive (-0 deletions) at 78.9% test density. The security core is right by construction: the per-row RBACObject() uses ResourceGroupMember with WithOwner(user_id) (not Group.RBACObject), so a plain member reads only their own spend row, and MemberCanOnlyReadOwnRow proves it. Cross-org is closed on both axes (404 before the handler, plus the org-scoped effective-group mask). The paid-feature gate tests avoid the parent-gate trap that the sibling PR #27123 shipped, entitling FeatureTemplateRBAC and withholding only the gate under test while asserting on the message. The SUM aggregation is fan-out-safe: every non-spend join is 1:1 per user. Hisoka summed it up: "I came to fight this query. It fought back well."
Severity count: 0 P0, 0 P1, 0 P2, 3 P3, 1 Nit, 6 Note. No blocking findings.
The one finding worth real attention is CRF-1: effective-budget resolution is now hardcoded to the highest policy inline in the reporting query, independently flagged at P3 by six reviewers. It is correct today (one policy exists) but has no coupling to budget.ResolveUserAIBudget, so the reported effective group/limit/source will silently diverge from what enforcement charges the moment a second policy is added, and unlike the Go resolver this SQL cannot fail closed on an unknown policy. The batch SQL earns its place (a per-user Go call would be an N+1); the fix is to make the drift mechanical, a test that fails when codersdk.AIBudgetPolicies grows past one entry, or threading the policy in, rather than a comment that will rot.
Three non-blocking observations that live outside the changed lines, so they are here rather than inline:
- Experiment-gating asymmetry (Meruem): the new read routes gate on
RequireExperiment(ExperimentAIGatewayCostControl)+FeatureAIBridge, but the adjacent/groups/{group}/ai/budgetwrite routes (enterprise/coderd/coderd.go, not touched here) gate only onFeatureAIBridge. An operator without the experiment can write group budgets but cannot read the spend they govern. Worth reconciling so the flag means one thing across the feature. - Cross-batch period rollover (Hisoka): the 100-ID cap tells callers to batch, but each request recomputes
currentAIBudgetWindow()independently, so a batch set straddling a period rollover is stitched from two windows. Rare (rollover instant) and each response carries its own window, but there is no server-side period pinning across a batch. group_spend_microsis 0 for members whose effective budget resolves to another group (Pariston). Correct under the enforcement model, but a per-group table showing 0 for active users governed elsewhere is a predictable UX confusion worth a deliberate display decision.
🤖 This review was automatically generated with Coder Agents.
| WHERE group_id = @group_id | ||
| AND user_id = ANY(@user_ids::uuid[]) | ||
| ), | ||
| user_highest_group AS ( |
There was a problem hiding this comment.
P3 [CRF-1] Effective-budget resolution is re-implemented inline here and hardcodes the highest policy, with nothing coupling it to the canonical budget.ResolveUserAIBudget. (Meruem P3, Zoro P3, Knuckle P3, Razor P3, Pariston P3, Luffy P3; Hisoka Note, Mafuuu Note)
Enforcement resolves the effective budget through budget.ResolveUserAIBudget (coderd/aibridge/budget/budget.go:40), which takes AIBudgetPolicy as a parameter, switches on it, and returns an error for any unsupported policy. This user_highest_group CTE bakes ORDER BY spend_limit_micros DESC, group_name ASC, group_id ASC (a copy of GetHighestGroupAIBudgetByUser) and never reads AI.BridgeConfig.BudgetPolicy.
Today this is correct because AIBudgetPolicyHighest is the only policy, so I am rating latent divergence, not a live bug. Meruem named the unstated assumption: "the effective group / limit_source shown by this endpoint equals the budget that actually enforces spend. Nothing enforces that." When a second policy is added (or the tie-break in GetHighestGroupAIBudgetByUser changes), enforcement follows the switch while this endpoint keeps reporting highest, so the admin cost-control UI shows an effective_group_id/spend_limit_micros/limit_source the system does not enforce, and this SQL cannot fail closed the way the Go resolver does.
Keep the batch SQL (resolving 100 users through Go would be an N+1). Close the drift mechanically: thread the policy in, or add a test that pins this endpoint's per-user resolution against ResolveUserAIBudget for the same fixtures (Zoro: "a test that fails when codersdk.AIBudgetPolicies grows beyond one entry"). A comment alone will rot; the enum has no compiler tie to this string.
🤖
There was a problem hiding this comment.
This is a good point, and I thought about it too, not just for new policies (phase 1 ships only the Highest policy) but also in the context of AIGOV-509, which adds a fallback to the Everyone group when a user has no budgeted group. As a result, I think we should aim for a single piece of logic that handles user effective group resolution, unified for both single-user and batch.
A possible approach would be to convert ResolveUserAIBudget into a ResolveEffectiveGroup that receives a set of users, with the flow:
- get the user(s) overrides
- for user(s) without an override, resolve the effective group by policy (highest/lowest, ...)
- for user(s) with no budgeted group, fall back to the Everyone group (AIGOV-509)
This keeps user effective group resolution in a single function. It probably makes sense to address as part of AIGOV-509.
There was a problem hiding this comment.
Yeah I think that would be a good call here.
There was a problem hiding this comment.
I think we should aim for a single piece of logic that handles user effective group resolution, unified for both single-user and batch.
Does it imply removing this query in the future?
coder/coderd/database/queries/aicostcontrol.sql
Lines 61 to 81 in 1453b4b
A possible approach would be to convert ResolveUserAIBudget into a ResolveEffectiveGroup that receives a set of users, with the flow:
- get the user(s) overrides
- for user(s) without an override, resolve the effective group by policy (highest/lowest, ...)
- for user(s) with no budgeted group, fall back to the Everyone group (AIGOV-509)
Will this flow be implemented in Go (similar to ResolveUserAIBudget function) or at the SQL level (similar to the GetGroupMembersAISpend query)?
There was a problem hiding this comment.
Will this flow be implemented in Go (similar to ResolveUserAIBudget function) or at the SQL level (similar to the GetGroupMembersAISpend query)?
The initial idea was in Go similar to ResolveUserAIBudget, more precisely: a set of SQL queries that narrow the users at each step (users with an override, then users with an effective group, and so on). I started drafting this as part of AIGOV-509, but it turned out to be less trivial than I expected, mainly because GetGroupMembersAISpend also does filtering of its own (restricting to users that belong to the group, plus org scoping). Unifying resolution would mean moving that logic to an upper layer, above resolution, which makes it a bigger change.
Other alternatives could be a view that applies the highest policy (though that needs one view per policy), or a SQL function that takes the policy as a parameter. I created AIGOV-527 to track this as a follow-up and added TODO comments linking it in the relevant code.
| } | ||
|
|
||
| // GroupMemberAISpend is a single member's AI spend attributed to the queried | ||
| // group in the current budget period. |
There was a problem hiding this comment.
Note [CRF-5] GroupMemberAISpend re-declares the four fields that already live embeddable in UserAIBudgetSummary. (Robin Note)
UserAIBudgetSummary (codersdk/aibridge.go:29) already carries UserID, EffectiveGroupID, SpendLimitMicros, and LimitSource with identical json tags and was built to be embedded. This struct copies all four inline and adds only GroupSpendMicros, so it could be struct { UserAIBudgetSummary; GroupSpendMicros int64 }. The one honest reason to keep them apart is that the null semantics diverge (see CRF-2). If that divergence is intentional the copy is defensible; if not, embed. Worth a decision rather than a reflexive copy.
🤖
There was a problem hiding this comment.
The fields are kept inline on purpose since the null semantics differ from UserAIBudgetSummary. Here effective_group_id can be set while spend_limit_micros and limit_source are null (the effective group is a different same-org group), whereas in UserAIBudgetSummary they are all set or all null. I considered having the same logic, but since this endpoint is for a specific group, it does not make sense to give user's budget information about a different group. cc @EhabY
There was a problem hiding this comment.
After discussing this with @EhabY we came to the conclusion that a cleaner approach would be to have a new group_budget object (including spend_limit_micros and limit_source), that is only returned when effective_group_id == the queried group.
| LimitSource *AIBudgetLimitSource `json:"limit_source"` | ||
| // GroupSpendMicros is the user's spend attributed to the queried group | ||
| // over the current budget period. | ||
| GroupSpendMicros int64 `json:"group_spend_micros"` |
There was a problem hiding this comment.
Note [CRF-6] GroupSpendMicros/group_spend_micros diverges from the two sibling types that name the same concept CurrentSpendMicros/current_spend_micros. (Gon Note)
UserAISpendStatus.CurrentSpendMicros and OrganizationGroupAISpend.CurrentSpendMicros both use CurrentSpendMicros for period spend. There is a real semantic reason to differ here (this is spend attributed to the queried group, which may not be the member's effective group) and the doc comment states it, so this is not clearly wrong. But the Group prefix on a per-member field reads like an aggregate group total, and group_spend_micros is now permanent public API surface. Worth a maintainer confirming the JSON key is the one to ship.
🤖
There was a problem hiding this comment.
group_spend_micros is the spend attributed to the queried group, which can differ from the user's current effective group. The user spend endpoints use current_spend_micros for the user's current effective group. Not sure what would be a better name. Happy to hear other suggestions.
2b33020 to
f1af274
Compare
f402a88 to
312fa43
Compare
f1af274 to
deeaf89
Compare
| // group's organization. Null when no effective budget group is visible in | ||
| // this organization, including when the user's budget resolves to a group | ||
| // in another organization. | ||
| EffectiveGroupID *uuid.UUID `json:"effective_group_id" format:"uuid"` |
There was a problem hiding this comment.
Just a note regarding RBAC: effective_group_id is returned only when the member's effective group is in the same org as the queried group. Since orgs should be seen as tenant-specific, an effective group in a different org is treated the same as no effective group (null).
When plain members call this endpoint, they only receive their own row, and since a user is always a member of their own effective group, it is fine to return it to them.
As for admin roles, IIUC every admin role that can read members' rows also has read access to all groups in the org, so if an admin can read group A it can also read group B in the same org. If we ever introduce group-scoped access roles, we will need to reevaluate this.
Let me know if you see any issues.
| // @Param groupName path string true "Group name" | ||
| // @Param user_ids query string true "Comma-separated list of user IDs (maximum 100)" | ||
| // @Success 200 {object} codersdk.GroupMembersAISpend | ||
| // @Router /api/v2/organizations/{organization}/groups/{groupName}/members/ai/spend [get] |
There was a problem hiding this comment.
Another alternative considered was an endpoint that returns a user's spend in a group even if they are not currently a member of that group: /api/v2/organizations/{organization}/groups/{groupName}/ai/spend.
The only difference would be the case where the queried group was previously the user's effective group and they have spend attributed to it, but they were later removed from the group. That would be a more generic endpoint, but since this is currently only used for the group members page in the UI, I thought it was better to keep it consistent with the existing group members endpoint that feeds it.
There was a problem hiding this comment.
In this case, the spend remains attributed to the group correct? It doesn't just vanish?
There was a problem hiding this comment.
Correct, the spend is always attributed to a group, and it is always immutable. So spend that is attributed to group A will always be attributed to group A. It always depends on the user's effective group at the interception time.
In this specific example, even if the user is no longer a member of the group, the spend is still attributed to it. There is an edge case, though: if the user is again added to the group, it will keep the spend (if still within the budget period), but I think that is ok...otherwise, users could use this hack to get unlimited spend 😅 (even though they already have this unlimited possibility from the Everyone group)
85f5132 to
010205f
Compare
010205f to
3756807
Compare
ca72acc to
50a654d
Compare
d7c03aa to
2278463
Compare
Merge activity
|
2278463 to
eca5f76
Compare
Description
Adds
GET /api/v2/groups/{group}/members/ai/spend?user_ids=...(also available org-scoped at/api/v2/organizations/{org}/groups/{groupName}/members/ai/spend) to return per-member AI spend attributed to a group, along with each member's effective budget group and the applied spend limit when the queried group is their effective budget source.In the UI, this endpoint is used alongside the existing
/api/v2/groups/{group}/membersendpoint. AI spend data is kept separate from that endpoint so that:UI flow:
/api/v2/groups/{group}/members→ returns the group's members./api/v2/groups/{group}/members/ai/spend?user_ids=...with the IDs from step 1.Note: Only current members of the queried group are returned.
spend_limit_microsandlimit_sourceare populated only when the queried group is the member's effective budget source (its own limit or a user override).effective_group_idis null when the member's budget resolves to a group in another organization, since an organization is treated as a tenant boundary.Changes
codersdk.GroupMembersAISpendandGroupMemberAISpendtypes, reusing the sharedAISpendPeriodWindow.GetGroupMembersAISpendSQL query with a dbauthz per-row filter that mirrorsGET /api/v2/groups/{group}/members./groups/{group}/members/ai/spend(and the org-scoped alias) with a requireduser_idsquery param (cap 100). Callers with more than 100 members are expected to batch across multiple requests.Closes https://linear.app/codercom/issue/AIGOV-471/backend-group-members-endpoint-with-members-spend
Note
Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira