feat: add ai_user_daily_spend table and queries - #26562
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
This comment has been minimized.
This comment has been minimized.
0e9ce46 to
5375f15
Compare
|
/coder-agents-review |
|
Chat: Review posted | View chat Review history
deep-review v0.9.0 | Round 1 | Last posted: Round 1, 5 findings (1 P2, 1 P4, 3 Nit), COMMENT. Review Finding inventoryFindings
Round logRound 1Netero + Panel. 1 P2, 0 P3, 1 P4, 3 Nit. Reviewed against 335d6bd..5375f15. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
Clean, well-scoped PR. The table design is sound: composite PK covers the enforcement query pattern, the upsert serializes correctly under READ COMMITTED, UTC normalization is tested at timezone boundaries, and the test density at 85.9% (372 test LOC for 61 production LOC) is thorough. Pariston tried to build a case against this change and couldn't.
Severity count: 1 P2, 1 P4, 3 Nit.
The single P2 is a convergent finding from six reviewers: the upsert's ON CONFLICT path accepts negative cost_micros, silently reducing accumulated spend on existing rows. Since this table drives budget enforcement, a caller bug passing a negative delta would corrupt the enforcement data. The fix is a one-line Go validation.
Notes from the panel worth knowing (not posted as findings):
- The secondary index
(effective_group_id, day)has no query consumer yet; it's a sound forward investment for group-level aggregation (AIGOV-428). (Knuckle) GetUserSpendSinceauthz checksActionReadon the user, notActionReadPersonal. Any actor that can read the user can query spend in any group. For budget enforcement this is correct; for user-facing spend visibility, the API layer may need tighter scoping. (Mafuuu, Knov)- No retention/purge mechanism for
ai_user_daily_spend. At moderate scale this is manageable, but the table grows unboundedly. Worth adbpurgehandler when the callers land. (Hisoka) - The authz wrapper for
GetUserSpendSincerequires the user to exist (GetUserByID), but the table preserves rows after user deletion. Post-deletion reporting would need a separate system-level query. (Knov)
"I tried to build a case against this change and couldn't. The problem is correctly understood, the solution is proportional, and the fix is at the right level." - Pariston
🤖 This review was automatically generated with Coder Agents.
| -- The day parameter is normalized to its UTC calendar day before storage. | ||
| -- Returns the resulting row. | ||
| INSERT INTO ai_user_daily_spend (user_id, effective_group_id, day, spend_micros) | ||
| VALUES (@user_id, @effective_group_id, ((@day::timestamptz) AT TIME ZONE 'UTC')::date, @cost_micros) |
There was a problem hiding this comment.
P2 [CRF-2] The upsert's ON CONFLICT path does spend_micros = ai_user_daily_spend.spend_micros + EXCLUDED.spend_micros. CostMicros is int64 (signed). The table CHECK only fires when the result goes negative.
On INSERT (new row): negative cost_micros is rejected because the result itself is negative. Safe.
On UPDATE (existing row): if current spend_micros is 1000 and cost_micros is -500, the result is 500, which passes the CHECK. Spend is silently reduced.
The query comment says "Adds cost_micros to the spend" and the table drives budget enforcement (AIGOV-428). A caller bug passing a negative delta silently corrupts enforcement data: spend goes down, headroom goes up, budget appears unspent. No test covers negative cost_micros; all test values are non-negative.
Fix: validate CostMicros >= 0 in the Go dbauthz wrapper (or a shared helper) before calling the query. This produces a clear application error rather than relying on the schema-level floor. (Hisoka P2, Knov P2, Mafuuu P3, Bisky P3)
🤖
There was a problem hiding this comment.
I think this is relevant, but dbauthz is the database-related authorization layer. This should be done at the application layer instead. The schema CHECK remains the data-integrity backstop.
| spend_micros = ai_user_daily_spend.spend_micros + EXCLUDED.spend_micros | ||
| RETURNING *; | ||
|
|
||
| -- name: GetUserSpendSince :one |
There was a problem hiding this comment.
Nit [CRF-1] The query filters on user_id, effective_group_id, and day >= period_start, but the name conveys only User and Since. A caller reading the name could expect spend across all groups. A name like GetUserSpendByEffectiveGroupSince would be more precise, matching the pattern of other queries in this file (GetGroupAIBudget, GetUserAIBudgetOverride). (Netero)
🤖
There was a problem hiding this comment.
Both UserID and EffectiveGroupID are required in Params, so the filter scope is clear at every call site. I think the current name is enough, but happy to change it if there is agreement
f1439e4 to
1fb725d
Compare
| return database.AIUserDailySpend{}, xerrors.Errorf("cost_micros must be non-negative, got %d", arg.CostMicros) | ||
| } | ||
| // Daily spend writes are made by the aibridged process. | ||
| if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAibridgeInterception); err != nil { |
There was a problem hiding this comment.
This operation is performed by the AI Bridge daemon, so we're checking that it has Update on AIBridgeInterception. The operation isn't actually modifying an interception, though. But seems the best approach with what we currently have.
Other alternatives:
- Introduce a new resource for cost control. Would this make sense?
- Follow the token usage pattern, where we validate that the actor matches the interception's initiator via
authorizeAIBridgeInterceptionAction. This would require passing aninterception_idhere and at the database layer.
Thoughts?
There was a problem hiding this comment.
Since it's performed by the daemon, we'd expect the context to have subjectAibridged, so yeah I think a new resource is the most idiomatic approach here since it is indeed a new resource in the system.
There was a problem hiding this comment.
@dannykopping We can do that, but my only concern is that for group budget and user overrides budget we use operations on Groups/Users resources, not on a specific AI Cost Control resource. Would this mean that for the upcoming changes on the list groups and list members (including the spend) pages, we should include read operations on this new resource? 🤔
There was a problem hiding this comment.
I don't have any strong feelings here; it's a bit murky. Having a separate resource feels like the cleanest approach, but it'd require some refactoring of the existing budget code.
There was a problem hiding this comment.
Yeah, it is not entirely clear for me as well. We have two components for cost control:
- Budget config (group budgets, user overrides): these are properties of User/Group resources, so authorizing on those entities makes sense, we're updating the entity itself.
- Spend records: The write is clean,
aibridgedupdates the user's spend, so a new resource fits. For the read we will have 2 actors:- on the pre-request path aibridged reads the user's spend (resource-level makes sense)
- admin views the groups/members pages: this is the tricky one. These pages will include the user's spend, so this would mean that in order for admins to see this page, they need to have read operation on both Users/Groups and this new resource. Which means we would be coupling users/groups with spend. "Can read User X" would implicitly mean "can read X's spend".
As a result, for simplicity, I'll introduce a new ResourceAICostControl with just the Update action for now. Reads stay on the parent entities (User/Group). We might need to revisit this later. Let me know if this makes sense. @evgeniy-scherbina would also be good to have your opinion, since you have more context on the rbac side of things for cost control 🙂
There was a problem hiding this comment.
I don't mind to keep it as it is.
- For Budget Config (Groups and UserOverrides) we treated it as extension of Groups and Users so we reused corresponding RBAC resources.
- We can treat Spend records as extension of interception (because it's an aggregation of token_usages). I understand it's not perfect, but neither is adding new resource for this specific case.
There was a problem hiding this comment.
After internal discussion, we decided to keep the authorization check as is (Update on AIBridgeInterception) rather than creating a new resource. This check is enough for now, and it guarantees that only subjectAibridged and Owner roles can effectively increment user spend. We can revisit this later if needed.
d174b59 to
bb18cf7
Compare
888ac85 to
dee171f
Compare
6e8896d to
41ad5f2
Compare
41ad5f2 to
ae35c71
Compare
Merge activity
|
## Description Adds post-response spend accumulation to `RecordTokenUsage`. ## Changes - Wrap the token usage insert and daily spend increment in a single transaction. - Skip the spend update when the user is unbudgeted, the model is unpriced, or the computed cost is non-positive. Depends on #26562 Closes https://linear.app/codercom/issue/AIGOV-427/add-post-response-spend-accumulation > [!NOTE] > Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira

Description
Adds the spend tracking table and queries needed by AIGOV-427 (post-response accumulation) and AIGOV-428 (pre-request enforcement).
Changes
ai_user_daily_spendtable to aggregate per-user, per-effective-group AI spend by UTC day.UpsertUserAIDailySpendandGetUserAISpendSincequeries.Closes https://linear.app/codercom/issue/AIGOV-426/add-daily-spend-table-and-queries
Note
Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira