feat(llm-gateway): lift posthog_code per-user caps for orgs billed for Code usage - #70946
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
…r Code usage After the seat cutover every posthog_code user is seatless, so #70810's free cap would apply to all of them - including users whose org pays for Code usage at cost. This threads "does this org pay for Code" from billing to the gateway throttle context and lifts the per-user cap when it's true. The signal rides the platform's product-feature pipeline rather than a new billing call: billing grants the posthog_code_usage feature only on the Code usage product's paid plan, and syncs it into organization.available_product_features on every customer save (SQS billing_customer_update -> update_org_details). The quota_limits endpoint the gateway already polls per team serves that bit as code_usage_billing_active; an org unknown to billing or not on the paid plan reads False (fail closed), while quota state keeps failing open. Gateway: QuotaResourceStatus carries the flag through the existing per-team Redis cache (entries written before the field existed read False until TTL turnover). ThrottleContext.code_usage_billed selects an infinite UserCostLimit ahead of the free-plan check, so enforcement, status reporting, and spend recording all flow through the one limit-selection path (the staff pattern). Seat holders keep their caps until seat deletion: pre-cutover seat-covered usage is unbilled even in paying orgs. Upgrade freshness: subscribe -> billing customer save -> SQS push (seconds) -> org features updated -> gateway quota cache (<=300s). Must deploy to both gateway regions before the seat-deletion migration runs. Generated-By: PostHog Code Task-Id: e6d23925-2a64-452b-aeea-fd349077f904
A transient Django failure (5xx/timeout past retries) or a single caller's 4xx used to cache code_usage_billing_active=False team-wide for the fail-open window, re-capping a paying org's seatless users at the free limit - which their uncapped recorded spend already exceeds. Failure paths now fall back to the team's last known billing bit from a successful fetch (24h TTL), failing closed only for teams never seen as billed. 4xx raises through the same fail-open path as transient errors, so both compose the fallback at one point and get logged. Also: correct the QuotaResourceStatus field comment (the bit is Code-usage-specific, not any-subscription), note in resolve_plan_and_quota that the billing bit rides the credit-bucket branch, and convert a test docstring to a comment per house style. Generated-By: PostHog Code Task-Id: 9bb9e093-ae2f-4f38-a7db-2c42462b36f9
441e610 to
37af628
Compare
|
Reviews (1): Last reviewed commit: "fix(llm-gateway): keep last-known code u..." | Re-trigger Greptile |
🤖 CI reportℹ️ ClickHouse migration SQL — 1 migration(s)ClickHouse migration SQL per cloud environment
|
The posthog_code_credits block was scoped to usage-based plan keys, but the cutover is clean: usage-based seats will never exist, and post- cutover every caller is seatless with plan_key=None - so nothing would have enforced the org's billing limit (or a free org's monthly allocation) at the gateway once seats are deleted. Scope the bucket block to seatless callers instead: seat holders stay exempt (their usage is excluded from the org's counter), and every seatless caller is blocked when the bucket is exhausted - a paying org's billing limit and a free org's allocation both surface as limited. bucket_block_applies now takes the ThrottleContext so the request path and the usage endpoint keep sharing one decision. The usage-based plan vocabulary (is_usage_based_plan, usage_based_plan_prefixes, posthog-code-usage-* keys) is deleted as dead. Generated-By: PostHog Code Task-Id: 9bb9e093-ae2f-4f38-a7db-2c42462b36f9
| # Precedence matters: an org-billed seatless user is uncapped even though | ||
| # seat_missing would otherwise select the free-plan cap below. | ||
| if _is_org_billed_seatless(context): | ||
| return ORG_BILLED_USER_COST_LIMIT |
There was a problem hiding this comment.
High: Uncapped requests can be attributed to another team
This grants unlimited per-user usage based on context.user.team_id, but the captured billable event still accepts a caller-controlled x-posthog-property-team_id, which the usage reporter uses as the team to charge. A seatless user in a billed organization can therefore send uncapped requests while assigning the costs to an arbitrary or nonexistent team. Bind the billing team used by quota enforcement to the event attribution, or reject external team overrides on posthog_code.
There was a problem hiding this comment.
the per-user cap lift keys on the token's real current_team_id, not the header, so a caller can't spoof their way into being uncapped — only misattribute the cost of usage their org is already entitled to.
correcting my earlier take here: i'd said posthog_code shouldn't honor the x-posthog-property-team_id override at all, but that's wrong — it's load-bearing. the code client sets it deliberately (buildGatewayPropertyHeaders({ team_id: projectId })) because the gateway can authenticate with a shared key whose owner team isn't the project the work runs in; the header is what attributes the $ai_generation to the working project rather than the key owner's team. dropping it would misattribute legitimate spend, not fix anything.
so this is the flip side of a real feature: the same header that correctly redirects attribution to your working project can also be pointed at a project you aren't working in. the right fix isn't removing it — it's validating that the header team is one the caller is authorized for (membership / scoped_teams), which needs authorization context the gateway doesn't have cheaply at capture time. that's separate, bigger work, and since this only misattributes cost (no escalation) and needs a raw request bypassing the first-party client, we're not folding it into this stack.
There was a problem hiding this comment.
Thanks for the detail. I can't automatically confirm this is safe to dismiss, so I'm leaving the thread open for a maintainer to make the call.
| status, ttl = ( | ||
| QuotaResourceStatus( | ||
| limited=False, | ||
| code_usage_billing_active=await self._get_last_known_billing(team_id), |
There was a problem hiding this comment.
High: Authorization failures bypass both usage limits
A valid gateway token that lacks project:read can deliberately make the quota endpoint return 403, including through the usage endpoint, which does not enforce posthog_code product access. This path caches limited=False together with the team's last-known paid flag, so subsequent seatless Code requests bypass both the organization credit ceiling and the per-user cap; the caller can repeat the request whenever the one-minute entry expires. Treat caller-specific 4xx responses as a denied or non-cacheable result rather than the same fail-open state used for transient upstream failures.
There was a problem hiding this comment.
real mechanism, but there's no reachable exploit for a real posthog_code token: they're minted via the read_only/full presets, both of which include project:read, so the 403 can't be self-induced without a custom-scoped token the product never issues.
and there isn't a clean small fix worth adding:
- serving the last-known billing bit on 4xx is deliberate — it's what stops one caller's bad token from re-capping every other user on the shared per-team cache (the earlier finding fixed in this stack). the cache is per-team but a 4xx is per-caller, so from one entry you can't keep the innocent teammate uncapped while capping the attacker. failing closed reopens that.
- gating the usage endpoint on product access looks like a fix but isn't one: the enforcement path already resolves quota itself, so it poisons the same entry directly — the usage endpoint isn't needed for the attack.
the limited=False-on-4xx fail-open itself came in with the quota enforcement in #69324 that this stack sits on top of (same rollout), not the cap lift here. so we're consciously not hardening this one: no reachable exploit, and the only real lever is Django-side (minter bundling project:read with llm_gateway:read), separable from this PR.
There was a problem hiding this comment.
This remains reachable outside the presets: Django allows creating a personal API key with only llm_gateway:read when the gateway flag is enabled, and the gateway accepts that as sufficient auth. /v1/usage/{product} only requires get_authenticated_user, while quota_limits requires project:read; that 403 still populates the shared team cache with limited=false plus the last-known billing bit.
There was a problem hiding this comment.
fixed in 87329f6 — but not by gating the endpoint. that would've been whack-a-mole (the models listing route resolves quota too, and it serves anonymous callers on purpose), so the fix is one level down in the resolver.
a permanent 4xx now fails open for that one request without writing the shared per-team cache; only transient 5xx/timeout still caches the fallback. so a caller who can force a 403 can no longer pin limited=false (or the last-known billing bit) team-wide — there's nothing left to persist. that covers all three callers (chat, usage, models) at once, and keeps the transient-outage behavior that stops one bad token from re-capping a team.
good catch on the personal-key-via-usage-endpoint path — that's what made it reachable past the OAuth presets, and it's what tipped this from 'not worth hardening' to a real fix.
PR overviewThis PR changes llm-gateway Code usage throttling so organizations billed for Code usage are not subject to the normal per-user caps. The touched rate-limiting and quota resolution paths determine which team or organization is billed and whether Code requests should be limited. There are still open security issues in the quota and billing attribution logic. A caller can potentially obtain uncapped Code usage while shifting or avoiding the intended team attribution, and certain authorization failures can be cached in a way that bypasses both organization and per-user limits. These are attacker-triggerable quota and billing bypasses with a clear financial and abuse impact, so the PR remains high risk until the enforcement and attribution paths are bound together correctly. Open issues (2)
Fixed/addressed: 0 · PR risk: 7/10 |
The seatless carve-out assumed seat-covered usage would be excluded from the org's billed usage counter, but the PR doing that split (#69855) was closed for the hard cutover: seats will never coexist with usage-based billing, and the usage reporter permanently counts every posthog_code generation into the bucket. Blocking a narrower population than gets counted lets exempted callers burn past the limit their own spend fills. Exhaustion now blocks every caller of a bucket-billed product, matching the counted population exactly. Post-cutover this is identical for real traffic (everyone is seatless) but no longer depends on the plan resolver's seat state, so a seats-endpoint blip can't punch a hole in the org ceiling. The credit_bucket_scope field is deleted - posthog_code was its only non-default user. Generated-By: PostHog Code Task-Id: 9bb9e093-ae2f-4f38-a7db-2c42462b36f9
richardsolomou
left a comment
There was a problem hiding this comment.
looks good! some veria comments might be worth looking into tho
A 4xx from quota_limits is about the caller's own credentials, not a team fact, but it was cached team-wide alongside the last-known billing bit. A caller who can force a 403 - a key with llm_gateway:read but not project:read, reaching the usage/models routes that skip product-access checks - could pin limited=False for every user on the team and lift the org ceiling for the fail-open window. Permanent 4xx now fails open for the one request without writing the shared cache. Transient 5xx/timeout still caches the fallback (with last-known billing) so a Django blip doesn't re-cap a paying org. Generated-By: PostHog Code Task-Id: 9bb9e093-ae2f-4f38-a7db-2c42462b36f9
…ode usage (#70951) ## Problem Orgs not billed for Code usage should get open models only. The gateway currently serves the same posthog_code model allowlist to everyone. ## Changes Behind `LLM_GATEWAY_POSTHOG_CODE_MODEL_GATE_ENABLED` (default off, flipped at cutover): - posthog_code requests (including the array/twig aliases) from users whose org isn't billed for Code usage only pass models in `posthog_code_free_tier_models` (default `@cf/zai-org/glm-5.2`). Anything else 403s with the free list in the message. Staff exempt. - Keys on the same fail-closed `code_usage_billing_active` bit as #70946. - Matching goes through `_model_matches_product_allowlist`, so date-suffixed and Bedrock variants of a blocked model are blocked too. - Model extraction reads form-encoded bodies as well as JSON, and `model` is now a required form field on the audio transcription routes, as in OpenAI's API. It was optional with a server-side default, so the access checks ran with `model=None` while the handler routed a model upstream unchecked. Every route now requires a model at validation, making `model=None` safe to pass everywhere. This also makes per-product model allowlists apply to transcription for the first time; no caller is affected (the OpenAI SDKs require `model`, and no client of an allowlisted product uses these routes — checked this repo and posthog/code). - `/{product}/v1/models` always returns the full catalog. With the gate on, callers positively identified as free-tier (authenticated, non-staff, org not billed) get models outside the free tier marked `allowed: false` with `restriction_reason: "paid_plan_required"` instead of omitted, so clients can render them disabled with an upgrade prompt. The fields are additive with `allowed: true` defaults; all consumers parse leniently (posthog/code TS casts, codex 0.140.0 serde without `deny_unknown_fields`, OpenAI SDK). Anonymous callers and auth failures are never marked — an unidentifiable caller may be a billed org. Quota-fetch failures mark restricted off the same last-known billing bit enforcement reads, so marks match the 403s requests would get. Enforcement stays the actual gate. Cutover dependency in posthog/code: the gateway models fetches (`packages/agent/src/gateway-models.ts`, `packages/harness/src/extensions/posthog-provider/models.ts`) should send the user's token so free-tier users get accurate `allowed` marks (anonymous fetches see everything allowed), render `allowed: false` entries disabled with the upgrade prompt, and pick the best allowed model as the session default. The internal products on the same OAuth app (background_agents, conversations, signals, slack_app) are only ever driven by server-minted sandbox tokens, so they now require a server-minted credential: sandbox tokens carry an internal `internal_run:read` scope (added to `INTERNAL_SCOPES`), and those products reject OAuth callers that lack it (`requires_server_credential`, behind the same flag). This stops a user's own Code OAuth token from routing around the free-tier gate to premium models through them — those products bill elsewhere or not at all, so the escape would otherwise spend up to the sibling per-user caps on PostHog. Internal scopes can't be obtained via the consent flow or a personal API key, so the marker can't be forged; personal API keys are unaffected (they need an explicit, feature-gated `llm_gateway:read` scope, so the shared server-side gateway key still works). Supersedes #69856. ## How did you test this code? Full gateway unit suite green (1233 tests). Plus end-to-end against a live local stack — gateway from this branch (`debug=false`, so app-ID checks ran) wired to local Django/Postgres/Redis, real Anthropic calls, seeded free/billed/staff orgs with consent-style, server-minted, and personal-API-key credentials: - flag off: behavior unchanged — full unmarked listing, free-org token completes premium-model calls, consent token reaches `background_agents` - flag on: free org 403s with the paid-plan message on `/chat/completions`, `/messages`, and form-encoded transcription; `@cf/zai-org/glm-5.2` passes the gate; free-org server tokens gated too; billed org and staff complete real calls; blocks log `free_tier_model_blocked` - listing: free-tier callers (consent, server token, PAK) see premium models `allowed: false` / `paid_plan_required` with the free model still allowed; anonymous, invalid-token, billed, and staff callers get the full unmarked list; other products' listings untouched - server credential: consent tokens (free and billed) 403 on all four internal products; `internal_run:read` token passes; PAK on `signals` unaffected - transcription without `model` 422s on both routes; malformed JSON 422s, never 500s - simulated Django outage (quota cache cleared): billed org keeps model access via the last-known billing bit through the full 35s retry backoff, free org stays gated, `quota_fetch_failed` logged - found one deploy coupling: without Cloudflare credentials the registry omits the free model, so free-tier callers see everything restricted and free-model requests 503 — don't flip the flag on a deployment missing CF creds ## Automatic notifications - [ ] Publish to changelog? - [ ] Alert Sales and Marketing teams? ## Docs update None yet; free-tier docs land with the cutover comms. ## 🤖 Agent context **Autonomy:** Human-driven (agent-assisted) PostHog Code session (Claude). Skill: /writing-tests. Scoping to the product surface instead of the OAuth credential was deliberate: credential-wide gating would break the PostHog-paid sibling products above for free orgs. Review round 1: fixed the multipart transcription bypass hex-security found; reworked the listing after checking posthog/code (its models fetches are unauthenticated, so fail-closed anonymous filtering would have downgraded billed orgs' pickers at cutover). Round 2 (k11kirky): closed the omitted-model variant by deleting the transcription routes' server-side default — OpenAI's API requires the field and nothing depended on the default — and corrected the listing docs on auth fall-open vs deliberate quota fail-closed. Round 3 (adboio): listing marks restricted models (`allowed` + `restriction_reason`) instead of omitting them; additive-safety verified against all consumers including codex's pinned serde structs. Round 4 (veria-ai): closed the cross-product escape — the internal products share the Code OAuth app, so a user's own Code token could reach premium models through them and skip the gate. First tried confining the credential by scope *shape* (`*` present); reverted after finding the Code OAuth app can be provisioned with an explicit ceiling that grants `llm_gateway:read` (the demo generator does exactly this), which lets a user mint a non-wildcard gateway token and defeat the shape check. The shipped fix instead keys on an *internal* scope marker minted only server-side — unforgeable via any user path and independent of the app's ceiling. Verified against posthog/code that the desktop client only calls posthog_code and the internal products are driven solely by server-minted tokens, so real traffic is untouched. Round 5 (adboio): the free-tier gate's model extraction re-parsed the JSON body the product-access check had already parsed (and end-user extraction parsed it a third time) — the parsed dict is now cached per request; the server-credential opt-in is pinned by a PRODUCTS-derived invariant test so the next internal product on the Code app can't silently omit it. --- *Created with [PostHog Code](https://posthog.com/code?ref=pr)*
…#71315) ## Problem PostHog Code's billing UI needs to know whether the caller's org is subscribed to Code usage billing (payment method on file): it picks the usage-based billing copy ("add a payment method" vs "raise your billing limit") and decides whether to show the free-tier usage meter — subscribed orgs have no per-user caps to meter. The usage endpoint the app already polls doesn't carry that bit. **Why:** cutover dependency for the PostHog Code usage-based billing client work; the desktop app reads this field (optional in its schema, so either side can ship first). ## Changes `GET /v1/usage/{product}` now returns `code_usage_subscribed`, carrying the same `code_usage_billing_active` bit the endpoint already resolves for enforcement (#70946) — the org's subscription status (a payment method puts the org on the paid `posthog_code_usage` plan, whose product feature this reflects) — so the client's billing copy can never disagree with what a request would do. Additive with a `False` default; all existing consumers parse leniently. `is_pro` keeps its seat semantics untouched. ## How did you test this code? Automated only: `tests/test_usage.py` (37 passed) including a parametrized case pinning `code_usage_subscribed` to the resolver's billing bit for both values — catches the field being dropped or wired to anything other than the enforcement bit. ## Automatic notifications - [ ] Publish to changelog? - [ ] Alert Sales and Marketing teams? ## Docs update None. --- *Created with [PostHog Code](https://posthog.com/code?ref=pr)*

Problem
PostHog Code is moving from seats to usage-based billing. Once seats are deleted every user is seatless, and the free cap from #70810 would apply to all of them, including users whose org is billed for their usage. The gateway needs to know, per request, whether the org pays for Code usage.
Changes
Billing grants a
posthog_code_usageproduct feature on the paid Code usage plan, and product features already sync toorganization.available_product_features. Thequota_limitsendpoint the gateway polls per team now returns that check ascode_usage_billing_active, and the gateway carries it into the throttle context ascode_usage_billed.When it's true and the user has no seat, the per-user cost limit is infinite: usage bills to the org, so the org's own billing limit is the ceiling. Spend is still recorded. The bit fails closed (an org billing doesn't know about reads as not billed), but fetch failures serve the team's last known value from the most recent successful fetch (24h TTL) rather than stamping false into the fail-open cache — a Django blip or one caller's bad token must not re-cap a paying org's users for the window. Quota state keeps failing open. Seat holders keep their caps until seat deletion, since seat-covered usage is unbilled.
That org ceiling now actually reaches the callers it bills. The
posthog_code_creditsblock was scoped to usage-based plan keys, which will never exist (the cutover is clean) — so post-cutover nothing would have blocked anyone once the org's billing limit, or a free org's monthly allocation, was exhausted. And with the seat-split (#69855) closed for the hard cutover, the usage reporter permanently counts every posthog_code generation into the bucket — so exhaustion now blocks every caller, exactly the population being counted. Blocking narrower would let exempted callers burn past the limit their own spend fills; it also means the org ceiling no longer depends on the plan resolver's seat state at all. Thecredit_bucket_scopemachinery and the usage-based-plan vocabulary (is_usage_based_plan,usage_based_plan_prefixes,posthog-code-usage-*) are deleted.Reads false for everyone until billing ships the
posthog_code_usageproduct, so this deploys with no behavior change. Must be on both gateway regions before the seat deletion runs.How did you test this code?
Automated only. Gateway suite green (1199): the resolver round-trips the new field through the per-team cache and serves the last-known value on fetch failure (4xx and exhausted retries — without the fallback, a blip re-caps paying users whose recorded spend exceeds the free cap), the cost-throttle matrix pins the bypass to seatless + billed (seat holders and unbilled orgs stay capped), and the bucket-block matrix pins that exhaustion blocks every caller regardless of seat or billing state — a carve-out would let exempted callers burn past the limit they're filling. Django
quota_limits/billing_managertests green (88): the quota_limits test sets real org features and asserts the response field, since dropping it silently re-caps paying users.hogli ci:preflight --fixclean.Automatic notifications
Docs update
None.
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
PostHog Code session (Claude). Skills: /improving-drf-endpoints, /writing-tests. An earlier revision read this from a new billing endpoint through a cached
BillingManagerhelper; Adam redirected it to the product-features sync, which is how every other product gates paid behavior. A review pass on the open PR caught the fail-open cache overwriting the billing bit with the fail-closed default during upstream failures; fixed by serving the per-team last-known value on the failure paths. A reviewer comment then caught that the org credit ceiling never applied to seatless callers (the bucket block was scoped to plan keys that will never exist); first fixed by scoping the bucket to seatless users, then — after Richard flagged that the usage reporter counts seated generations too and the seat-split (#69855) was closed for the hard cutover — simplified to block every caller, matching the counted population.Created with PostHog Code