Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

feat: add GET /groups/{group}/members/ai/spend - #27130

#27130
Merged
ssncferreira merged 8 commits into
maincoder/coder:mainfrom
ssncf/aigov-group-members-spendcoder/coder:ssncf/aigov-group-members-spendCopy head branch name to clipboard
Jul 20, 2026
Merged

feat: add GET /groups/{group}/members/ai/spend#27130
ssncferreira merged 8 commits into
maincoder/coder:mainfrom
ssncf/aigov-group-members-spendcoder/coder:ssncf/aigov-group-members-spendCopy head branch name to clipboard

Conversation

@ssncferreira

@ssncferreira ssncferreira commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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}/members endpoint. AI spend data is kept separate from that endpoint so that:

  • Different concepts stay on different endpoints: identity (group members) vs. cost control (spend). Cost control is an additional feature layered on top of groups/orgs.
  • Callers that don't need spend information don't pay for its computation.

UI flow:

  1. Request /api/v2/groups/{group}/members → returns the group's members.
  2. Request /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_micros and limit_source are populated only when the queried group is the member's effective budget source (its own limit or a user override). effective_group_id is null when the member's budget resolves to a group in another organization, since an organization is treated as a tenant boundary.

image

Changes

  • Add codersdk.GroupMembersAISpend and GroupMemberAISpend types, reusing the shared AISpendPeriodWindow.
  • Add GetGroupMembersAISpend SQL query with a dbauthz per-row filter that mirrors GET /api/v2/groups/{group}/members.
  • Add handler and routes under /groups/{group}/members/ai/spend (and the org-scoped alias) with a required user_ids query param (cap 100). Callers with more than 100 members are expected to batch across multiple requests.
  • Add codersdk client method.
  • Tests: dbauthz, raw SQL, endpoint, and role-access.

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

ssncferreira commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

@github-actions

Copy link
Copy Markdown

Docs preview

📖 View docs preview for docs/reference/api/enterprise.md

@ssncferreira
ssncferreira force-pushed the ssncf/aigov-org-groups-spend branch from ca91968 to 802751b Compare July 13, 2026 12:05
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-group-members-spend branch 2 times, most recently from 7c6e8f9 to 4450200 Compare July 13, 2026 12:21
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-org-groups-spend branch from 802751b to 2b21c5f Compare July 13, 2026 12:21
@linear-code

linear-code Bot commented Jul 13, 2026

Copy link
Copy Markdown

AIGOV-471

@ssncferreira
ssncferreira force-pushed the ssncf/aigov-org-groups-spend branch 2 times, most recently from b6c2225 to fa3ffda Compare July 13, 2026 16:53
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-group-members-spend branch 2 times, most recently from d7bcaae to ddd5212 Compare July 14, 2026 08:46
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-org-groups-spend branch 2 times, most recently from c27116f to 98e74d3 Compare July 14, 2026 16:32
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-group-members-spend branch 2 times, most recently from 7cd402b to 946a8e7 Compare July 14, 2026 17:41
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-org-groups-spend branch from 98e74d3 to eb5f19c Compare July 14, 2026 17:41
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-group-members-spend branch from 946a8e7 to 8da7768 Compare July 14, 2026 17:41
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-org-groups-spend branch 2 times, most recently from 0830e8f to 4b4aa4d Compare July 15, 2026 09:35
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-group-members-spend branch from 8da7768 to 64580db Compare July 15, 2026 09:35
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-org-groups-spend branch from 4b4aa4d to 8ef0651 Compare July 15, 2026 09:54
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-group-members-spend branch 5 times, most recently from e934d28 to cac8ab5 Compare July 15, 2026 15:30

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/budget write routes (enterprise/coderd/coderd.go, not touched here) gate only on FeatureAIBridge. 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_micros is 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 (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. get the user(s) overrides
  2. for user(s) without an override, resolve the effective group by policy (highest/lowest, ...)
  3. 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think that would be a good call here.

@evgeniy-scherbina evgeniy-scherbina Jul 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

-- name: GetHighestGroupAIBudgetByUser :one
-- Returns the highest group AI budget across the groups the user belongs to,
-- breaking ties by group name ascending. Implements the "highest" budget policy.
-- group_members_expanded is a UNION of group_members and organization_members,
-- so the implicit "Everyone" group (group_id == organization_id) is included.
-- Returns no rows when the user has no budgeted groups; callers should treat
-- sql.ErrNoRows as "no group budget".
SELECT
gaib.group_id,
gaib.spend_limit_micros
FROM group_ai_budgets gaib
JOIN group_members_expanded gme ON gme.group_id = gaib.group_id
WHERE gme.user_id = @user_id
ORDER BY
gaib.spend_limit_micros DESC, -- highest wins
gme.group_name ASC, -- alphabetical tiebreak
-- Final tiebreak on the group id makes the result deterministic when two
-- groups share both name and limit, which is possible across organizations
-- (groups are unique on (organization_id, name), not name alone).
gaib.group_id ASC
LIMIT 1;


A possible approach would be to convert ResolveUserAIBudget into a ResolveEffectiveGroup that receives a set of users, with the flow:

  1. get the user(s) overrides
  2. for user(s) without an override, resolve the effective group by policy (highest/lowest, ...)
  3. 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)?

@ssncferreira ssncferreira Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread codersdk/aibridge.go Outdated
Comment thread coderd/database/queries/aicostcontrol.sql
Comment thread coderd/database/queries/aicostcontrol.sql
Comment thread codersdk/aibridge.go
}

// GroupMemberAISpend is a single member's AI spend attributed to the queried
// group in the current budget period.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

🤖

@ssncferreira ssncferreira Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@ssncferreira ssncferreira Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in d7c03aa

Comment thread codersdk/aibridge.go
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"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread coderd/database/queries/aicostcontrol.sql
Comment thread coderd/database/queries/aicostcontrol.sql Outdated
Comment thread codersdk/aibridge.go Outdated
Comment thread enterprise/coderd/aibridge.go
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-group-members-spend branch from 2b33020 to f1af274 Compare July 16, 2026 09:52
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-org-groups-spend branch from f402a88 to 312fa43 Compare July 16, 2026 09:52
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-group-members-spend branch from f1af274 to deeaf89 Compare July 16, 2026 10:36
Comment thread codersdk/aibridge.go
// 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"`

@ssncferreira ssncferreira Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that makes sense.

// @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]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, the spend remains attributed to the group correct? It doesn't just vanish?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@ssncferreira
ssncferreira marked this pull request as ready for review July 16, 2026 12:38
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-group-members-spend branch from 85f5132 to 010205f Compare July 16, 2026 13:02
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-group-members-spend branch from 010205f to 3756807 Compare July 16, 2026 13:17
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-org-groups-spend branch from ca72acc to 50a654d Compare July 20, 2026 11:41
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-group-members-spend branch from d7c03aa to 2278463 Compare July 20, 2026 11:41

ssncferreira commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Merge activity

  • Jul 20, 11:54 AM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Jul 20, 11:56 AM UTC: Graphite rebased this pull request as part of a merge.
  • Jul 20, 12:04 PM UTC: @ssncferreira merged this pull request with Graphite.

@ssncferreira
ssncferreira changed the base branch from ssncf/aigov-org-groups-spend to graphite-base/27130 July 20, 2026 11:54
@ssncferreira
ssncferreira changed the base branch from graphite-base/27130 to main July 20, 2026 11:55
@ssncferreira
ssncferreira force-pushed the ssncf/aigov-group-members-spend branch from 2278463 to eca5f76 Compare July 20, 2026 11:55
@ssncferreira
ssncferreira merged commit a9fdf87 into main Jul 20, 2026
33 of 34 checks passed
@ssncferreira
ssncferreira deleted the ssncf/aigov-group-members-spend branch July 20, 2026 12:04
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 20, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.