Skip to content

feat(llm-gateway): report org credit-bucket spend on the usage endpoint#71404

Merged
adboio merged 5 commits into
masterfrom
posthog-code/usage-endpoint-org-spend
Jul 16, 2026
Merged

feat(llm-gateway): report org credit-bucket spend on the usage endpoint#71404
adboio merged 5 commits into
masterfrom
posthog-code/usage-endpoint-org-spend

Conversation

@adboio

@adboio adboio commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Problem

PostHog Code is cutting over to usage-based billing, and the desktop app wants to show real org spend ("used $12.40 of your $50 limit") in the titlebar and on the Plan & usage page. Today GET /v1/usage/{product} carries only per-user percentages against internal rate valves plus an ai_credits.exhausted boolean, so clients have nothing to render a dollar amount from. The numbers already exist in organization.usage, synced from billing.

Changes

  • GET /api/projects/{id}/quota_limits/ now returns usage and limit per resource, read from the org's synced billing numbers. usage is the same usage + todays_usage sum the quota limiter compares against the limit. The limited boolean stays authoritative for gating (grace periods and refund offsets live only in the limiting decision). Both fields are null when billing hasn't synced the resource.
  • The gateway's quota resolver parses those numbers, converts credit buckets to USD (1 credit = $0.01) and carries them through its Redis cache. Fail-open paths keep them as null, meaning unknown, never $0.
  • GET /v1/usage/{product} exposes them as ai_credits.used_usd and ai_credits.limit_usd. Additive and optional, so old clients are unaffected.

The per-user burst/sustained buckets still expose only percentages (test_response_has_no_usd_fields continues to guard that). The valve limits stay internal; the org's own bucket spend is the user's billing data.

Note

The wire change is additive on both hops. Old gateway against new Django ignores the new keys; new gateway against old Django reads null and reports unknown.

How did you test this code?

  • Gateway: full unit suite passes locally (1240 tests). New regressions covered that no existing test did: Django responses without numbers read as unknown rather than $0, USD numbers survive the Redis cache round trip (a one-sided cache would flash the numbers in and out between hit and miss), fail-open cache entries carry null numbers, and the usage endpoint forwards the resolver's numbers onto ai_credits.
  • Django: extended test_quota_limits.py with a synced-org case (1500 + 200 credits of 2000 reports usage 1700, a synced-but-unlimited bucket reports a null limit, a never-synced resource reports nulls, not zeros). I could not run the Django suite in this sandbox; CI covers it. Ruff (pinned version) is clean.
  • mypy on the gateway files adds no new errors (two pre-existing ones in quota_resolver.py are untouched).

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Authored by Claude (PostHog Code cloud session) at Adam's request, as the backend half of the usage-based billing client work in PostHog/code#3487, which wants to render org spend for both free-tier and subscribed orgs. Main decision: Django reports raw native units generically for every resource, and the credits-to-USD conversion lives in the gateway where the client contract is defined, rather than special-casing credit buckets in Django. Absent-means-unknown is preserved end to end so a resolver blip or unsynced org never renders as $0 spent.


Created with PostHog Code

quota_limits now returns each resource's usage and limit from the org's
synced billing numbers (the same usage + todays_usage sum the limiter
compares; the boolean stays authoritative for gating). The gateway converts
credit buckets to USD and exposes ai_credits.used_usd / limit_usd, cached
alongside the existing bits. Absent numbers mean unknown, never zero —
fail-open paths carry None.

Generated-By: PostHog Code
Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
@adboio adboio self-assigned this Jul 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Hey @adboio! 👋

It looks like your git author email on this PR isn't your @posthog.com address (adambowker98@gmail.com). Since you're on the PostHog team, it's worth pointing your local git author email at your @posthog.com address. Why it matters:

  • Consistent work identity in git history — internal tooling that attributes commits to team members keys off your @posthog.com address.
  • Keeps team contributions easy to tell apart from external community ones when scanning history.

You can fix it for this repo with:

git config user.email "you@posthog.com"

Or set it globally with git config --global user.email "you@posthog.com". No need to redo this PR — just a nudge for next time. 🙂

@trunk-io

trunk-io Bot commented Jul 16, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

Move the dense inline `usage + todays_usage` ternary (and its rationale)
out of the response loop into a small `_resource_usage` helper, so the
call site reads `"usage": _resource_usage(summary)`. Pure refactor —
behavior is identical for synced, synced-unlimited, and never-synced
resources.

Generated-By: PostHog Code
Task-Id: 5e82b22a-9b3c-4809-9fa2-b89a616d373f
@adboio
adboio marked this pull request as ready for review July 16, 2026 03:33
@adboio adboio added the stamphog Request AI approval (no full review) label Jul 16, 2026
@assign-reviewers-posthog
assign-reviewers-posthog Bot requested a review from a team July 16, 2026 03:33
Comment thread ee/api/quota_limits.py
resource,
QuotaLimitingCaches.QUOTA_LIMITER_CACHE_KEY,
),
"usage": _resource_usage(summary),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Restrict organization spend to billing-authorized users

This project-read endpoint now returns organization-wide usage and limits for every quota resource. TeamAndOrgViewSetMixin admits any effective project member with project:read, whereas the billing usage/spend endpoints require IsOrganizationAdmin; thus a non-admin restricted to one project can call this endpoint (or use a project:read personal key) to obtain the entire organization's current billing consumption and credit limits, including activity attributable to other projects.

Prompt To Fix With AI
Do not include organization usage/limit amounts in the project-read quota response unless the caller has the same organization-level billing authorization as the billing spend/usage APIs (admin/owner and any owner-only-billing policy). If PostHog Code must expose the values to other users, introduce a narrowly scoped, explicitly authorized service/endpoint that returns only the necessary bucket and enforces the intended billing-data access policy. Add tests showing a project member and a project:read key without billing access cannot retrieve these amounts.

Severity: medium | Confidence: 97% | React with 👍 if useful or 👎 if not

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.

intentional design choice; spend is relevant to users in posthog desktop app; can revisit later if needed

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "refactor(quota-limits): extract per-reso..." | Re-trigger Greptile

Comment thread ee/api/quota_limits.py Outdated
@stamphog

stamphog Bot commented Jul 16, 2026

Copy link
Copy Markdown

Note

🤖 stamphog reviewed deca08992e55f53fe642ffedcd1ec19e56e070a0 — verdict: REFUSED

This adds org-wide billing usage/limit numbers to an endpoint scoped only to project:read, so any project member (or a project-scoped personal API key) can now pull the organization's entire billing consumption — a real access-control gap on sensitive billing data, correctly flagged by hex-security-app and left unaddressed. A second unresolved bot finding (greptile) shows the null-usage case collapses to $0 instead of the "unknown" the PR explicitly claims to preserve.

  • Author wrote 31% of the modified lines and has 10 merged PRs in these paths (familiarity MODERATE).
  • greptile-apps[bot], hex-security-app[bot] reviewed the current head.
  • ee/api/quota_limits.py: QuotaLimitsViewSet requires only project:read, but now returns organization-wide usage and limit for every quota resource — a project member restricted to one project (or a project-scoped personal API key) can read the whole org's billing spend/limits, unlike the org-admin-gated billing usage endpoints (hex-security-app finding, unresolved).
  • ee/api/quota_limits.py _resource_usage: when a synced resource has usage and todays_usage both explicitly null, (None or 0) + (None or 0) returns 0 instead of None, contradicting the PR's stated 'never renders as $0' guarantee (greptile finding, unresolved, no test covers this case).
Gate mechanics and policy version
Gate Result
prerequisites all clear
deny-list no deny categories matched
size 86L, 4F substantive, 202L/7F incl. docs/generated/snapshots — within ceiling
tier T1-agent / T1c-medium (202L, 7F, two-areas, feat)
stamphog 2.0.0b3 .stamphog/policy.yml @ a9bbb06 · reviewed head deca089

@stamphog stamphog Bot removed the stamphog Request AI approval (no full review) label Jul 16, 2026
@hex-security-app

Copy link
Copy Markdown

Thanks for clarifying. The concern is specifically the authorization boundary: this route is available to any project:read member/key for one project, while the new values describe organization-wide spend across projects. If the intended product policy is that all project members may view that organization-level billing data, then this is an explicit policy exception rather than an accidental exposure; otherwise the response needs billing-level authorization or a narrowly scoped endpoint. I’ll leave the finding open for the owning team to confirm that policy decision.

Comment thread ee/api/quota_limits.py
resource,
QuotaLimitingCaches.QUOTA_LIMITER_CACHE_KEY,
),
"usage": _resource_usage(summary),

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.

Medium: Organization usage exceeds the project scope

This project-scoped endpoint now returns organization-wide totals for every quota resource. An attacker holding a key restricted to one project can use it to read aggregate event, recording, AI, and other usage and limits across the organization, including sibling projects. Restrict the response to data authorized at the project level, or require an organization-level billing permission before returning these fields.

# Entries written before this field existed read as False (capped)
# until the TTL turns them over.
code_usage_billing_active=bool(payload.get("code_usage_billing_active")),
used_usd=_optional_number(payload.get("used_usd")),

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.

Medium: Cached spend bypasses token-scope authorization

The cache is keyed only by resource and team_id, and get_resource_status returns a hit before Django validates the forwarded credential. An attacker holding a token scoped to a different team or organization can therefore receive these newly cached spend values for the user's current team after an authorized request primes the cache. Bind cached spend to the credential's authorization context, or validate team and organization access before returning these fields from a shared cache.

@veria-ai

veria-ai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

PR overview

This PR adds reporting of organization credit-bucket spend through the LLM gateway usage/quota endpoint. The touched quota-limit and quota-resolver code expands usage responses and caching around quota resource spend.

Two security issues remain open, both involving authorization boundaries for newly exposed usage and spend data. A project-scoped or otherwise mismatched token can potentially read organization-wide or cross-team cached spend and quota information that should require stronger authorization. No issues have been addressed yet, so the PR still needs access-control tightening before it is safe to merge.

Open issues (2)

Fixed/addressed: 0 · PR risk: 7/10

adboio added 2 commits July 15, 2026 23:43
Guards the fix in the previous commit: a resource billing synced with
null usage/todays_usage must report usage as null (unknown), not 0, so
clients never render "$0 spent" while billing figures are incomplete.
Adds a present-but-null case to the synced-resources endpoint test —
the existing cases only cover real numbers and never-synced resources.

Generated-By: PostHog Code
Task-Id: 5e82b22a-9b3c-4809-9fa2-b89a616d373f
@github-actions

Copy link
Copy Markdown
Contributor

🤖 CI report

Playwright — all passed

All tests passed.

View test results →

@adboio
adboio merged commit d518218 into master Jul 16, 2026
248 checks passed

adboio commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Merge activity

@adboio
adboio deleted the posthog-code/usage-endpoint-org-spend branch July 16, 2026 04:19
@deployment-status-posthog

deployment-status-posthog Bot commented Jul 16, 2026

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-07-16 04:43 UTC Run
prod-us ✅ Deployed 2026-07-16 04:58 UTC Run
prod-eu ✅ Deployed 2026-07-16 04:58 UTC Run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants