feat(billing): classify gateway billing denials and rework the usage-limit modal for usage-based billing#3485
Merged
Conversation
…l for usage-based billing First of the usage-based billing cutover stack (errors/copy -> billing surfaces -> model gating): - classifyGatewayLimitError/extractGatedModel in @posthog/shared; the free-tier model-gate 403 no longer classifies as a fatal session error. - sessionService routes gateway billing denials to the usage-limit modal with a cause; the modal renders cause-aware usage-based copy behind the new posthog-code-usage-billing flag (off = seat-era copy unchanged). - Helper prompts route to the free-tier model for orgs known unbilled via the optional code_usage_billed usage field, with a single gate-403 retry as the cold-start backstop. - Seat handling keys off the API's 410 seat_product_retired: auto-provision stops re-attempting and explicit upgrades surface a clear message. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
|
😎 Merged directly without going through the merge queue, as the queue was empty and the PR was up to date with the target branch - details. |
Merged
2 tasks
|
React Doctor found no issues in the changed files. 🎉 Reviewed by React Doctor for commit |
…410 copy Seats are gone entirely by cutover and the whole seat surface gets deleted after it, so special handling isn't worth carrying: SeatService goes back to main unchanged (auto-provision already fails soft on any create error), and the 410 keeps only a typed error plus readable copy in classifySeatError for the seat-era UI still visible until the flag flips. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
…ll comments Usage-based billing has exactly two denial causes — model_gate and org_limit — so the per-user burst/sustained cause taxonomy and the bucket-to-cause derivation are gone. Per-user valve messages (a pre-cutover mechanism) now land on the generic no-CTA fallback instead of an invented "free daily limit" story, and preflightCloudUsage is back to main's shape plus a single org_limit cause. Also strips narrative comments across the diff down to the constraints the code can't show. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
…ng flag The seat-era copy this branch preserved is already a dead end in production (Pro upgrades return 410 Gone), so there is nothing to keep behind a flag: the usage-limit modal renders usage-based copy unconditionally. Pre-flip this degrades safely — the model gate can't fire until the server-side cutover switch flips, and valve hits land on the generic fallback — so merge/release order relative to the flip doesn't matter. Deletes seatEraLimitContent, the USAGE_BILLING_FLAG, the modal's seat/Pro plumbing (useSeat, the support mailto), and the store's unused bucket/isPro state; threshold and preflight callers pass only what the modal reads. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
… the model The regex existed only to fish the model id back out of our own error prose so the modal could name it — and the session path only ever sees the stringified message, so even a structured gateway field would need the same string-fishing. The user just sent a message on the model they picked, so "This model isn't included in the free tier" is unambiguous. Deletes the regex, the store's model field, and the show-args threading. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
adboio
added a commit
to PostHog/posthog
that referenced
this pull request
Jul 15, 2026
…him (#71343) ## Problem PostHog Code's usage-based billing cutover (#70951) gates unbilled orgs to the free-tier model list, but the gate's 403 goes out as a bare FastAPI `detail` string. Pre-cutover desktop builds classify errors by substring: they treat that 403 as a fatal session error and tear a healthy session down ("Connection lost" + recovery loops), and no client can tell which limit tripped a 429 without parsing prose. **Why:** installed desktop builds can't be updated in time for the cutover — the gateway's error bodies are the only lever that reaches them, and this can deploy ahead of the model-gate flip. ## Changes - The free-tier model-gate 403 now uses the standard error envelope with a machine-readable `code: "model_gate"`, and its message carries a `(rate_limit)` suffix — a compat shim that routes pre-cutover PostHog Code clients to their usage-limit modal instead of session teardown (documented inline; remove once those builds roll forward). - Throttle 429s repeat the reason in `error.message` (SDK error strings often surface only the message) and carry the throttle scope as `error.code` (`billable_credits`, `user_cost_burst`, `user_cost_sustained`, …), so clients can classify the limit cause. Companion client work: [PostHog/code#3485](PostHog/code#3485) classifies these bodies into cause-aware billing UX. ## How did you test this code? Automated only: the enforcement-path gate tests now pin the new envelope (`code`, the `(rate_limit)` suffix), the rate-limiting endpoint test pins the message/reason/code shape, and a TestClient-level test pins the gate 403's wire body end-to-end (top-level error envelope with `code: "model_gate"` and the `(rate_limit)` suffix — never `{"detail": ...}` nesting) through the raise site and the exception-handler unwrap together. All would fail if the shim or codes were dropped. Full llm-gateway suite green locally (1239 passed, 50 skipped, 30 xfailed, 22 xpassed). ## Automatic notifications - [ ] Publish to changelog? - [ ] Alert Sales and Marketing teams? ## Docs update None — internal error-body shape. ## 🤖 Agent context **Autonomy:** Human-driven (agent-assisted) PostHog Code session. Part of the usage-based billing cutover client plan: this PR makes gateway billing denials classifiable by both pre- and post-cutover desktop builds. --- *Created with [PostHog Code](https://posthog.com/code?ref=pr)*
… release The readable-copy branch only served the window where the seat-era plans page was live without PR 2's replacement. PR 1 and PR 2 ship as one release, so that window is zero: the typed 410 error, the classify branch, and their tests go, leaving the PR with no seat-API changes at all (auto-provisioning already fails soft on any create error). Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
ad97d99 to
2e09cf7
Compare
Contributor
Author
…code_usage_billed posthog#71315 shipped the bit as code_usage_subscribed; the schema listened for code_usage_billed, which zod strips, so the client would have read "unknown" forever. Rename the field and align internal naming to subscribed. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
Contributor
|
Reviews (1): Last reviewed commit: "fix(billing): the usage endpoint field i..." | Re-trigger Greptile |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
…e subscription memo to the org Greptile P1s: an org_limit classification only opened the modal when the message also carried generic rate-limit wording, and the singleton's learned code_usage_subscribed bit survived org switches, pinning helpers of a subscribed org to the free-tier model. The modal now keys off the classified cause directly, and the memo clears when the auth org changes. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
Local testing surfaced a coherence gap: hitting the free valve during a session showed the generic "try again shortly" modal, while the cloud pre-flight showed "Free usage used up / Add payment method" for the same state. The valves only exist for unsubscribed orgs, so classify their 429 wording as org_limit — both paths now land on the free-tier upsell. The free-tier copy drops the "billing period" over-claim (a daily valve resets sooner) and carries the reset hint when the caller has one. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
charlesvien
approved these changes
Jul 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

tl;dr
getBillingUrlto nav user to posthog cloud billing settingstesting
full vibe-description:
Problem
Usage-based billing is replacing seats, and the app misroutes the gateway's new denials: the free-tier model-gate 403 (PostHog/posthog#70951) reads as a fatal session error ("Connection lost" + recovery loops), the org credit-bucket 429 isn't recognized, and the usage-limit modal still pitches the retired Pro plan.
Changes
First of a small stack (errors/copy → billing surfaces → model gating):
classifyGatewayLimitErrorin@posthog/shared, matched against the error bodies from PostHog/posthog#71343. Only two causes exist under usage-based billing —model_gateandorg_limit; anything else gets generic no-CTA copy. Model-gate 403s no longer classify as fatal session errors.code_usage_subscribedusage field (PostHog/posthog#71315), with a single gate-403 retry as the cold-start backstop.Why: the gateway's model gate can only flip once the app stops treating its 403 as fatal — this is the must-ship slice of the cutover client work. Ships in the same release as PR 2 (billing surfaces), which replaces the seat-era plans page.
How did you test this?
Automated only: typecheck and Biome clean across shared/api-client/core/ui; suites green (shared 602, core 2291, ui 1659) with new tests for the classifier (envelope, message-only, and legacy body shapes), the fatal-403 exclusion, helper routing, and the modal copy matrix. Not covered: a visual pass over the new modal copy.
Automatic notifications
Created with PostHog Code