🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

feat: add CSV export for AI spend data - #27491

Merged
ssncferreira merged 7 commits into
mainfrom
ssncf/ai-spend-csv-export
Jul 28, 2026
Merged

feat: add CSV export for AI spend data#27491
ssncferreira merged 7 commits into
mainfrom
ssncf/ai-spend-csv-export

Conversation

@ssncferreira

@ssncferreira ssncferreira commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Description

Adds GET /api/v2/organizations/{organization}/ai/spend/export, returning text/csv with per-user, per-group, per-model, per-provider aggregated AI spend. The data is built from the raw AI Gateway token usage tables rather than the ai_user_daily_spend rollup, but stays consistent with it: spend is attributed through the token usage's effective group and bucketed by the token usage created_at, the same values the daily rollup derives from.

The period defaults to the current UTC month, narrowed to the configured AI Gateway retention window when the month begins before retained data does. Explicit period_start/period_end params must be provided together, are interpreted as UTC, and may span at most 31 days. Unlike the default period, an explicit period that begins before the retention window is rejected rather than narrowed. Every row echoes the applied bounds, so a narrowed window is visible in the export.

The endpoint requires organization-level admin permissions.

Changes

  • Add the ExportOrganizationAISpend query aggregating aibridge_token_usages joined to aibridge_interceptions, scoped to the organization via the effective group, resolving the username, group name, and organization name alongside their IDs.
  • Add the exportOrganizationAISpend handler and route, gated by the aigateway-cost-control experiment and the AIBridge feature, returning the CSV in a single response.
  • Add the ExportOrganizationAISpend codersdk client method.
  • Require organization-wide ResourceGroupMember read, since the export aggregates every user in the organization. The per-row filter stays in dbauthz as defence in depth.
  • Escape leading formula characters in the free-text columns, so a model or provider name recorded from an intercepted request cannot be evaluated when the CSV is opened in a spreadsheet.
  • Add an index on aibridge_token_usages (effective_group_id, created_at), which the period and group predicates otherwise cannot use.

Closes https://linear.app/codercom/issue/AIGOV-293/add-csv-export-for-ai-spend-data

Note

Generated by Coder Agents on behalf of @ssncferreira

@linear-code

linear-code Bot commented Jul 24, 2026

Copy link
Copy Markdown

AIGOV-293

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

@ssncferreira ssncferreira changed the title feat(enterprise/coderd): add CSV export for AI spend data feat: add CSV export for AI spend data Jul 24, 2026
@ssncferreira

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-07-27 12:03 UTC by @ssncferreira
Spend: $26.80 / $100.00

Review history
  • R1 (2026-07-27): 20 reviewers, 3 Nit, 4 Note, 1 P1, 4 P2, 5 P3, REQUEST_CHANGES. Review

deep-review v0.9.0 | Round 1 | 0f1eafa..2cb0bba

Last posted: Round 1, 17 findings (1 P1, 4 P2, 5 P3, 3 Nit, 4 Note), REQUEST_CHANGES. Review

Finding inventory

Finding inventory, PR #27491

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Open enterprise/coderd/aibridge.go:1164 CSV formula injection: user-controlled model/provider written raw R1 Hisoka P2, Kurapika P2, Ryosuke P2, Meruem P3, Chopper P3, Razor P3 Yes
CRF-2 P2 Open coderd/database/queries/aicostcontrol.sql:267 Inner JOIN groups silently drops NULL/deleted-group spend; export undercounts money with no signal R1 Mafuuu P2, Knuckle P3, Meruem Note, Knov Note Yes
CRF-3 P1 Open coderd/database/queries/aicostcontrol.sql:265 No index on created_at/effective_group_id; every export full-scans the highest-volume table R1 Killua P1, Knuckle P2 Yes
CRF-4 P2 Open enterprise/coderd/aibridge.go:1151 Unbounded result set fully buffered in memory; post-filter runs full-org query for any member; comment asserts a false bound R1 Knov P2, Meruem P2, Killua P3, Kurapika Note, Ryosuke Note Yes
CRF-5 P3 Open enterprise/coderd/aibridge.go:1096 Retention guard applies to default (no-param) window, so a short retention 400s citing a period_start the caller never sent R1 Netero P3, Hisoka P3, Pariston P3, Ryosuke P3, Razor P3, Chopper P3, Meruem P3, Mafuuu P3, Mafu-san Note, Knov Note Yes
CRF-6 P3 Open enterprise/coderd/aibridge.go:1097 Retention guard keys on token-usage created_at but purge deletes by interception started_at, so passing the guard does not guarantee completeness R1 Pariston P3, Knov P3 Yes
CRF-7 P3 Open enterprise/coderd/aibridge.go:1036 CSV header labels UUID columns user/group/organization; should be *_id; external contract, fix before ship R1 Gon P3 Yes
CRF-8 P3 Open enterprise/coderd/aibridge_test.go:4019 PeriodValidation cases assert only 400, not the message, so six branches are indistinguishable R1 Chopper P3 Yes
CRF-9 P2 Open enterprise/coderd/aibridge_test.go:4065 TestExportOrganizationAISpendRoleAccess seeds at time.Now() with real clock and reads the default month window; month-boundary flake R1 Komugi P2 Yes
CRF-10 Nit Open codersdk/aibridge.go:375 AISpendExportOptions lacks @typescript-ignore, leaks an orphaned PascalCase interface into typesGenerated.ts R1 Melody Nit Yes
CRF-11 P3 Open PR description Description documents start/end params, but the endpoint accepts period_start/period_end; also omits the retention constraint R1 Leorio P3, Netero Note Yes (body)
CRF-12 P3 Open coderd/database/queries/aicostcontrol.sql:268 No test proves the org-scope WHERE excludes another org's spend, the invariant the endpoint is named for R1 Bisky P3 Yes
CRF-13 Nit Open enterprise/coderd/aibridge_test.go:4026 aiSpendExportCSVHeaderForTest hand-copied twin; export_test.go re-export is the repo idiom (Bisky judged the mirror acceptable) R1 Robin Nit Yes
CRF-14 Note Open enterprise/coderd/aibridge.go:1187 Every export downloads as the same filename ai-spend-export.csv R1 Leorio Note Yes
CRF-15 Note Open enterprise/coderd/aibridge.go:1146 period bounds parsed at nanosecond precision but echoed to CSV at second precision R1 Melody Note Yes
CRF-16 Note Open enterprise/coderd/aibridge.go:1162 row.GroupID.UUID.String() ignores .Valid; correctness depends on the INNER JOIN staying inner R1 Melody Note, Knuckle Note Yes
CRF-17 Nit Open enterprise/coderd/aibridge_test.go:3720 Dead FeatureTemplateRBAC entitlement copied from group-endpoint tests; this route does not gate on it R1 Mafu-san Nit Yes
CRF-18 Note Open enterprise/coderd/coderd.go:533 No explicit org-membership authz; non-members get 200 with header-only CSV rather than 403 (by design, matches siblings) R1 Mafuuu Note Yes

Round log

Round 1

Panel (18 reviewers + Netero first pass + 2 wildcards). Netero first pass: P3/Note only, mechanical floor clean, panel proceeded. Findings: 1 P1, 4 P2, 6 P3, 3 Nit, 4 Note. Reviewed against 0f1eafa..2cb0bba. CI green (23 passed, 9 skipped).

CSV formula injection (CRF-1) converged across Hisoka, Kurapika, Ryosuke (P2) and Meruem, Chopper, Razor (P3); higher wins, model/provider verified unconstrained (stored verbatim from intercepted request, never rejected). Missing index (CRF-3) rated P1 by Killua, P2 by Knuckle; kept P1 per the drop/downgrade gate: the consequence (full scan of the highest-volume table, unbounded by anything but multi-month retention, DB-connection pinned, triggerable by any in-org member via the post-filter) is set by consequence not by the experiment gating (probability). CRF-3 and CRF-4 compound into a low-privilege DB resource-exhaustion vector. Null-group drop (CRF-2) kept P2 over the lower Note ratings: consistency-with-rollup is not correctness for a money artifact, and the swagger promises org spend without a caveat.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@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.

Clean, well-scoped feature: one authorized read endpoint, its SDK client, and dense tests (62.9% test density, no drive-by refactors). The per-row authorization is the right call and is genuinely proven: TestExportOrganizationAISpendRoleAccess shows admins see every row and a plain member sees only their own via the fetchWithPostFilter owner-scoped RBAC object, and CustomPeriodHalfOpen pins the [start, end) boundary with rows seeded at both edges. The panel also confirmed the raw-table source is forced (the daily rollup has no model/provider/token columns) and that the spend column stays consistent with the rollup because both read the same effective_group_id/cost_micros snapshot.

Requesting changes on the storage and correctness surface, not the design. Severity count: 1 P1, 4 P2, 6 P3, 3 Nit, 4 Note.

The two that compound and are worth fixing together: this export walks away from the index the rollup path relies on (CRF-3) and drops the per-call cap every sibling AI-spend endpoint enforces (CRF-4). Because authorization is a post-filter, any in-org member's request runs the full-org aggregation, full-scans the highest-volume table, and materializes the whole result in memory before returning their single row. Knuckle put it well: "This is the loan... Interest rate: every export gets slower as the table grows, linearly, with no ceiling short of retention."

The rest: CSV formula injection from user-controlled model/provider (CRF-1), a silent money undercount from the inner group join (CRF-2), a month-boundary test flake (CRF-9), the retention guard misfiring on the default window and keying off the wrong timestamp (CRF-5, CRF-6), and CSV/contract polish (CRF-7, CRF-8, CRF-10).

Process: the PR description still tells consumers to use start/end, but commit d216b5f renamed the params to period_start/period_end in the handler, SDK, swagger, and docs (CRF-11, P3). The description also omits the retention-window constraint the same commit added. The generated docs are correct; only the human-facing description is stale. Please update it, since it is exactly what a reviewer reads to understand the contract.

No agent-accepted permanence: CRF-2 (which spend the export omits) and the CRF-3/CRF-4 scale posture are product/operational decisions. If any is accepted as-is rather than fixed, that needs a human decision (surface the omission or document the caveat in swagger, and/or file a ticket), not a silent defer.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/queries/aicostcontrol.sql
Comment thread enterprise/coderd/aibridge.go Outdated
COALESCE(SUM(tu.cost_micros), 0)::BIGINT AS cost_micros
FROM aibridge_token_usages tu
JOIN aibridge_interceptions ai ON ai.id = tu.interception_id
JOIN groups ON groups.id = tu.effective_group_id

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.

P2 [CRF-2] The inner JOIN groups ON groups.id = tu.effective_group_id silently drops real spend, so this "organization AI spend" CSV can undercount money with no signal. (Mafuuu P2, Knuckle P3)

The raw table is the superset that contains spend the rollup omits... an operator exporting to reconcile or charge back AI cost gets a total that is systematically short by the entire spend of users who resolve to no effective group.

Two classes vanish: token usage with effective_group_id IS NULL (a real, tested state carrying non-zero cost, per ExcludesNullEffectiveGroup), and usage referencing a hard-deleted group (no FK; DeleteGroupByID deletes the row, orphaning historical effective_group_id). "Consistent with the rollup" is the defense and it holds for consistency, but the PR chose the raw table because it is the superset, then filtered it back to the rollup's blind spot. The swagger @description promises org spend with no caveat. Whether ungrouped/orphaned spend belongs here is a product decision: surface it (a sentinel/ungrouped group value) or document the exclusion in swagger. Knov also notes the export keeps cost_micros <= 0 rows the rollup skips, a second divergence from the "consistent" claim. Intentional-but-flagged, proven by test; not a silent accept.

🤖

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.

Ungrouped spend only happens for users with no org membership, and the daily table can't represent it either since effective_group_id is NOT NULL there. Regarding cost_micros, the only advantage I see is that the CSV could distinguish a priced model that resulted in zero cost from an unpriced one. But if the price is updated during the budget period (currently only via a release), usage recorded before and after it would aggregate into the same row, so we would lose that distinction anyway. Let me know what you think.

Comment thread enterprise/coderd/aibridge.go Outdated

// Build the full CSV in memory so the response is sent once with a
// Content-Length, and so a write error surfaces as a 500 before any
// status is written. The row count is bounded by the 31-day period cap.

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.

P2 [CRF-4] The full aggregation is buffered in memory with no row cap, the comment justifies it with a bound that does not exist, and the post-filter runs the full-org query for every caller. (Knov P2, Meruem P2)

The comment claims "The row count is bounded by the 31-day period cap." The 31-day cap bounds the time window, not cardinality. Result rows are one per distinct (initiator_id, effective_group_id, provider, model) tuple... There is no LIMIT, unlike the sibling endpoints in this same file which explicitly cap output (maxOrganizationGroupsAISpendGroupIDs = 100).

Separately, fetchWithPostFilter runs ExportOrganizationAISpend unscoped and materializes every org row into Go (a second copy in rbac.Filter, then the bytes.Buffer), so a plain member exporting "their own" spend still forces the whole-org aggregation and buffer. This compounds with CRF-3 (the full scan) into a low-privilege resource-exhaustion vector. Nearly the whole panel flagged the false comment (Hisoka, Pariston, Leorio, Ryosuke, Razor, Chopper, Kurapika). Fix: bound the row count (cap distinct users like the siblings cap IDs, or paginate/stream), and state the real bound in the comment.

🤖

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.

Members can't hit this endpoint anymore. We still build the whole CSV in memory, which is what the RFC describes (link), and it's also what lets us fail with a clean 500 rather than a truncated file. fetchWithPostFilter already loads every row before the handler runs, so streaming wouldn't avoid that allocation, but we can add it later if this proves problematic. Also worth noting the endpoint is capped at 31 days, which helps bound the result size.

Comment thread enterprise/coderd/aibridge_test.go Outdated
Comment thread enterprise/coderd/aibridge_test.go Outdated
Comment thread enterprise/coderd/aibridge.go Outdated
Comment thread enterprise/coderd/aibridge.go
Comment thread enterprise/coderd/aibridge.go
Comment thread enterprise/coderd/coderd.go
@ssncferreira
ssncferreira force-pushed the ssncf/ai-spend-csv-export branch from cc4d080 to f39e6d9 Compare July 27, 2026 19:42

// The export aggregates the whole organization, so require organization-wide
// read rather than letting the per-row filter narrow it to the caller.
if !api.Authorize(r, policy.ActionRead, rbac.ResourceGroupMember.InOrg(org.ID)) {

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.

Scoping this endpoint only to organization-level admin permissions as per the RFC.
This is slightly inconsistent with the group members spend endpoint, which for regular members, returns just their spend. But this is a different scope, which seems admin-related.

@ssncferreira
ssncferreira marked this pull request as ready for review July 27, 2026 20:20
@ssncferreira
ssncferreira requested review from a team, ibetitsmike and johnstcn as code owners July 27, 2026 20:20
@ssncferreira
ssncferreira force-pushed the ssncf/ai-spend-csv-export branch from f39e6d9 to 4eed9b8 Compare July 28, 2026 09:43
@ssncferreira
ssncferreira merged commit c3895ff into main Jul 28, 2026
28 checks passed
@ssncferreira
ssncferreira deleted the ssncf/ai-spend-csv-export branch July 28, 2026 09:58
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 28, 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.

2 participants