From fd1ee432e29d96b17e180e3af421b2ae5dab307e Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 15 Jul 2026 16:58:33 -0400 Subject: [PATCH 1/9] feat(billing): classify gateway billing denials and rework limit modal 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 --- packages/api-client/src/posthog-client.ts | 15 ++ packages/core/src/billing/seatErrors.ts | 16 +++ packages/core/src/billing/seatService.test.ts | 61 ++++++++ packages/core/src/billing/seatService.ts | 23 ++- .../src/billing/usageLimitContent.test.ts | 116 +++++++++++++++ .../core/src/billing/usageLimitContent.ts | 123 ++++++++++++++++ .../core/src/llm-gateway/llm-gateway.test.ts | 122 ++++++++++++++++ packages/core/src/llm-gateway/llm-gateway.ts | 67 ++++++++- packages/core/src/llm-gateway/schemas.ts | 5 +- packages/core/src/sessions/sessionService.ts | 28 +++- packages/core/src/usage/schemas.ts | 8 ++ packages/shared/src/analytics-events.ts | 9 ++ packages/shared/src/errors.test.ts | 86 +++++++++++ packages/shared/src/errors.ts | 69 +++++++++ packages/shared/src/flags.ts | 7 + packages/shared/src/index.ts | 3 + .../src/features/billing/UsageLimitModal.tsx | 133 ++++++++++-------- .../features/billing/preflightCloudUsage.ts | 29 ++-- .../src/features/billing/usageLimitStore.ts | 26 +++- 19 files changed, 862 insertions(+), 84 deletions(-) create mode 100644 packages/core/src/billing/usageLimitContent.test.ts create mode 100644 packages/core/src/billing/usageLimitContent.ts diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 88bacc8312..fd98581216 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -133,6 +133,18 @@ export class SeatPaymentFailedError extends Error { } } +/** + * Seat creation, upgrades, and reactivation return 410 Gone once PostHog Code + * seats are retired in favor of usage-based billing (reads and cancellation + * keep working for existing seats). + */ +export class SeatProductRetiredError extends Error { + constructor() { + super("PostHog Code seats have been retired"); + this.name = "SeatProductRetiredError"; + } +} + export class SandboxCustomImagesDisabledError extends Error { constructor(message?: string) { super(message ?? "Custom sandbox images are not enabled"); @@ -4589,6 +4601,9 @@ export class PostHogAPIClient { const parsed = this.parseFetcherError(error); if (parsed) { + if (parsed.status === 410) { + throw new SeatProductRetiredError(); + } if ( parsed.status === 400 && typeof parsed.body.redirect_url === "string" diff --git a/packages/core/src/billing/seatErrors.ts b/packages/core/src/billing/seatErrors.ts index ca7ddabe12..38b903d5cc 100644 --- a/packages/core/src/billing/seatErrors.ts +++ b/packages/core/src/billing/seatErrors.ts @@ -3,11 +3,27 @@ export interface ClassifiedSeatError { redirectUrl: string | null; } +/** + * The seat API's 410 Gone: PostHog Code seats are retired in favor of + * usage-based billing. Name-based so it survives the SeatClient seam. + */ +export function isSeatProductRetiredError(error: unknown): boolean { + return error instanceof Error && error.name === "SeatProductRetiredError"; +} + export function classifySeatError(error: unknown): ClassifiedSeatError { if (!(error instanceof Error)) { return { error: "An unexpected error occurred", redirectUrl: null }; } + if (isSeatProductRetiredError(error)) { + return { + error: + "PostHog Code seat plans have been retired — usage is now billed to your organization.", + redirectUrl: null, + }; + } + if (error.name === "SeatSubscriptionRequiredError") { const redirectUrl = "redirectUrl" in error && typeof error.redirectUrl === "string" diff --git a/packages/core/src/billing/seatService.test.ts b/packages/core/src/billing/seatService.test.ts index 9896257b0f..fc37ad2499 100644 --- a/packages/core/src/billing/seatService.test.ts +++ b/packages/core/src/billing/seatService.test.ts @@ -62,6 +62,13 @@ class SeatPaymentFailedError extends Error { } } +class SeatProductRetiredError extends Error { + constructor() { + super("PostHog Code seats have been retired"); + this.name = "SeatProductRetiredError"; + } +} + beforeEach(() => { vi.clearAllMocks(); }); @@ -278,3 +285,57 @@ describe("error classification", () => { expect(result.error).toBe("Card declined"); }); }); + +// The seat API 410s creation/upgrades/reactivation once Code seats are +// retired in favor of usage-based billing (reads keep working). +describe("seat product retired (410 Gone)", () => { + it("treats a retired auto-provision as seatless instead of erroring", async () => { + const client = makeClient({ + createSeat: vi.fn().mockRejectedValue(new SeatProductRetiredError()), + }); + const result = await new SeatService(client, logger).fetchSeat({ + autoProvision: true, + }); + expect(result.seat).toBeNull(); + expect(result.error).toBeNull(); + }); + + it("stops re-attempting provisioning once the product is known retired", async () => { + const createSeat = vi.fn().mockRejectedValue(new SeatProductRetiredError()); + const client = makeClient({ createSeat }); + const service = new SeatService(client, logger); + + await service.fetchSeat({ autoProvision: true }); + createSeat.mockClear(); + await service.fetchSeat({ autoProvision: true }); + + expect(createSeat).not.toHaveBeenCalled(); + }); + + it("still re-fetches the seat after a non-retirement provisioning failure", async () => { + const seat = makeSeat(); + const getMySeat = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValue(seat); + const client = makeClient({ + getMySeat, + createSeat: vi.fn().mockRejectedValue(new Error("conflict")), + }); + const result = await new SeatService(client, logger).fetchSeat({ + autoProvision: true, + }); + expect(result.error).toBeNull(); + expect(result.seat).toEqual(seat); + }); + + it("surfaces retirement as a clear error on an explicit upgrade", async () => { + const client = makeClient({ + createSeat: vi.fn().mockRejectedValue(new SeatProductRetiredError()), + }); + const result = await new SeatService(client, logger).upgradeToPro(); + expect(result.error).toContain("retired"); + expect(result.redirectUrl).toBeNull(); + }); +}); diff --git a/packages/core/src/billing/seatService.ts b/packages/core/src/billing/seatService.ts index 24f25cbc25..3f94e7078e 100644 --- a/packages/core/src/billing/seatService.ts +++ b/packages/core/src/billing/seatService.ts @@ -2,7 +2,11 @@ import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; import { PLAN_FREE, PLAN_PRO, type SeatData } from "@posthog/shared"; import { inject, injectable } from "inversify"; import { SEAT_CLIENT, type SeatClient, type SeatLogger } from "./identifiers"; -import { type ClassifiedSeatError, classifySeatError } from "./seatErrors"; +import { + type ClassifiedSeatError, + classifySeatError, + isSeatProductRetiredError, +} from "./seatErrors"; export interface SeatOperationResult { seat: SeatData | null; @@ -43,6 +47,11 @@ function fail(classified: ClassifiedSeatError): SeatOperationResult { export class SeatService { private readonly logger: SeatLogger; + // The seat API 410s creation/upgrades/reactivation once seats are retired + // (usage-based billing). Remembered so auth/onboarding fetches stop + // re-attempting a provision that can never succeed. + private seatProductRetired = false; + constructor( @inject(SEAT_CLIENT) private readonly client: SeatClient, @inject(ROOT_LOGGER) logger: RootLogger, @@ -55,13 +64,18 @@ export class SeatService { autoProvision: boolean; }): Promise { let seat = await this.client.getMySeat({ best: options.best }); - if (!seat && options.autoProvision) { + if (!seat && options.autoProvision && !this.seatProductRetired) { this.logger.info("No seat found, auto-provisioning free plan", { best: options.best, }); try { seat = await this.client.createSeat(PLAN_FREE); - } catch { + } catch (error) { + if (isSeatProductRetiredError(error)) { + this.seatProductRetired = true; + this.logger.info("Seat product retired; skipping auto-provision"); + return null; + } this.logger.info("Auto-provision failed, re-fetching seat"); seat = await this.client.getMySeat({ best: options.best }); } @@ -118,6 +132,7 @@ export class SeatService { this.client.invalidatePlanCache(); return ok(seat, null, true); } catch (error) { + if (isSeatProductRetiredError(error)) this.seatProductRetired = true; this.logger.error("provisionFreeSeat failed", error); return fail(classifySeatError(error)); } @@ -143,6 +158,7 @@ export class SeatService { this.client.invalidatePlanCache(); return ok(seat, seat); } catch (error) { + if (isSeatProductRetiredError(error)) this.seatProductRetired = true; return fail(classifySeatError(error)); } } @@ -168,6 +184,7 @@ export class SeatService { this.client.invalidatePlanCache(); return ok(seat, seat); } catch (error) { + if (isSeatProductRetiredError(error)) this.seatProductRetired = true; return fail(classifySeatError(error)); } } diff --git a/packages/core/src/billing/usageLimitContent.test.ts b/packages/core/src/billing/usageLimitContent.test.ts new file mode 100644 index 0000000000..aaf54ea01a --- /dev/null +++ b/packages/core/src/billing/usageLimitContent.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + deriveUsageLimitCause, + seatEraLimitContent, + usageBasedLimitContent, +} from "./usageLimitContent"; + +describe("deriveUsageLimitCause", () => { + it.each([ + ["model_gate", "burst", "model_gate"], + [null, "burst", "user_daily_limit"], + [null, "sustained", "user_monthly_limit"], + // No cause and no bucket (e.g. an upstream provider's own rate limit): + // stay generic instead of blaming the org's billing. + [null, null, null], + ] as const)("cause %s + bucket %s -> %s", (cause, bucket, expected) => { + expect(deriveUsageLimitCause(cause, bucket)).toBe(expected); + }); +}); + +describe("usageBasedLimitContent", () => { + it("names the gated model and offers a payment method", () => { + const content = usageBasedLimitContent({ + cause: "model_gate", + model: "Claude Opus 4.8", + resetLabel: null, + billed: false, + }); + expect(content.title).toBe("Unlock premium models"); + expect(content.description).toContain("Claude Opus 4.8 isn't"); + expect(content.actionLabel).toBe("Add payment method"); + }); + + it("falls back to generic wording when the gated model is unknown", () => { + const content = usageBasedLimitContent({ + cause: "model_gate", + model: null, + resetLabel: null, + billed: undefined, + }); + expect(content.description).toContain("This model isn't"); + }); + + it.each([ + // Confirmed-free org: allocation used up, the fix is adding a card. + [false, "Free usage used up", "Add payment method"], + // Billed org: the fix is raising the spend limit. + [true, "Organization usage limit reached", "Manage billing"], + // Unknown billed state must not read as free. + [undefined, "Organization usage limit reached", "Manage billing"], + ] as const)( + "org_limit with billed=%s -> %s / %s", + (billed, title, actionLabel) => { + const content = usageBasedLimitContent({ + cause: "org_limit", + model: null, + resetLabel: null, + billed, + }); + expect(content.title).toBe(title); + expect(content.actionLabel).toBe(actionLabel); + }, + ); + + it.each([ + ["user_daily_limit", "Free daily limit reached"], + ["user_monthly_limit", "Free monthly limit reached"], + ] as const)("%s carries the reset hint", (cause, title) => { + const content = usageBasedLimitContent({ + cause, + model: null, + resetLabel: "Resets in 2h", + billed: false, + }); + expect(content.title).toBe(title); + expect(content.description).toContain("Resets in 2h"); + expect(content.actionLabel).toBe("Add payment method"); + }); + + it("renders generic copy without a billing CTA when the cause is unknown", () => { + const content = usageBasedLimitContent({ + cause: null, + model: null, + resetLabel: null, + billed: true, + }); + expect(content.title).toBe("Usage limit reached"); + expect(content.actionLabel).toBeNull(); + expect(content.dismissLabel).toBe("Got it"); + }); +}); + +describe("seatEraLimitContent", () => { + it("keeps the Pro cap copy with no upgrade action", () => { + const content = seatEraLimitContent({ + bucket: "sustained", + isPro: true, + resetLabel: "Resets in 3d", + }); + expect(content.title).toBe("Monthly limit reached"); + expect(content.description).toContain("monthly usage cap"); + expect(content.actionLabel).toBeNull(); + expect(content.dismissLabel).toBe("Got it"); + }); + + it("keeps the free-plan upgrade pitch", () => { + const content = seatEraLimitContent({ + bucket: "burst", + isPro: false, + resetLabel: null, + }); + expect(content.title).toBe("Daily limit reached"); + expect(content.description).toContain("Upgrade to Pro for 40×"); + expect(content.actionLabel).toBe("See Pro"); + }); +}); diff --git a/packages/core/src/billing/usageLimitContent.ts b/packages/core/src/billing/usageLimitContent.ts new file mode 100644 index 0000000000..c595326dc1 --- /dev/null +++ b/packages/core/src/billing/usageLimitContent.ts @@ -0,0 +1,123 @@ +import type { GatewayLimitCause } from "@posthog/shared"; +import { PRO_USAGE_MULTIPLIER } from "./usageDisplay"; + +export interface UsageLimitContent { + title: string; + description: string; + /** Primary action label; null renders only the dismiss button. */ + actionLabel: string | null; + dismissLabel: string; +} + +/** + * Maps a legacy bucket-only caller onto a usage-based cause. Null when + * neither a cause nor a bucket is known — e.g. an upstream provider's own + * rate limit — so the modal falls back to generic copy instead of blaming + * the org's billing. + */ +export function deriveUsageLimitCause( + cause: GatewayLimitCause | null, + bucket: "burst" | "sustained" | null, +): GatewayLimitCause | null { + if (cause) return cause; + if (bucket === "burst") return "user_daily_limit"; + if (bucket === "sustained") return "user_monthly_limit"; + return null; +} + +export function usageBasedLimitContent(args: { + cause: GatewayLimitCause | null; + model: string | null; + resetLabel: string | null; + /** usage.code_usage_billed — absent means unknown, never free. */ + billed: boolean | undefined; +}): UsageLimitContent { + const { cause, model, resetLabel, billed } = args; + + if (cause === "model_gate") { + return { + title: "Unlock premium models", + description: `${model ? `${model} isn't` : "This model isn't"} included in the free tier. Add a payment method to your organization to unlock all models — you only pay for what you use. You can keep working now by switching to an included model.`, + actionLabel: "Add payment method", + dismissLabel: "Not now", + }; + } + + if (cause === "org_limit") { + if (billed === false) { + return { + title: "Free usage used up", + description: + "Your organization has used its included PostHog Code usage for this billing period. Add a payment method to keep going — you only pay for what you use.", + actionLabel: "Add payment method", + dismissLabel: "Not now", + }; + } + return { + title: "Organization usage limit reached", + description: + "Your organization has reached its PostHog Code spend limit for this billing period. Raise or remove the limit in your PostHog billing settings to keep going.", + actionLabel: "Manage billing", + dismissLabel: "Got it", + }; + } + + if (cause === "user_daily_limit" || cause === "user_monthly_limit") { + const period = cause === "user_daily_limit" ? "daily" : "monthly"; + return { + title: `Free ${period} limit reached`, + description: `You've hit the free tier's ${period} usage limit.${ + resetLabel ? ` ${resetLabel}.` : "" + } Add a payment method to your organization for uncapped usage-based access.`, + actionLabel: "Add payment method", + dismissLabel: "Not now", + }; + } + + // No recognizable billing cause (e.g. an upstream provider rate limit): + // don't send the user to billing for something billing can't fix. + return { + title: "Usage limit reached", + description: `PostHog Code hit a usage limit.${ + resetLabel ? ` ${resetLabel}.` : "" + } Please try again shortly.`, + actionLabel: null, + dismissLabel: "Got it", + }; +} + +export function seatEraLimitContent(args: { + bucket: "burst" | "sustained" | null; + isPro: boolean; + resetLabel: string | null; +}): UsageLimitContent { + const { bucket, isPro, resetLabel } = args; + const isDaily = bucket === "burst"; + const isMonthly = bucket === "sustained"; + + const title = isDaily + ? "Daily limit reached" + : isMonthly && !isPro + ? "You're out of usage for this month" + : isMonthly + ? "Monthly limit reached" + : "Usage limit reached"; + + const proCapLabel = isDaily + ? "a daily usage cap" + : isMonthly + ? "a monthly usage cap" + : "usage caps"; + const description = isPro + ? `Your Pro plan has ${proCapLabel}.${resetLabel ? ` ${resetLabel}.` : ""}` + : `You've hit your Free ${ + isDaily ? "daily" : isMonthly ? "monthly" : "usage" + } limit. Upgrade to Pro for ${PRO_USAGE_MULTIPLIER}× more usage.`; + + return { + title, + description, + actionLabel: isPro ? null : "See Pro", + dismissLabel: isPro ? "Got it" : "Not now", + }; +} diff --git a/packages/core/src/llm-gateway/llm-gateway.test.ts b/packages/core/src/llm-gateway/llm-gateway.test.ts index 75cd555392..75777b19dc 100644 --- a/packages/core/src/llm-gateway/llm-gateway.test.ts +++ b/packages/core/src/llm-gateway/llm-gateway.test.ts @@ -157,6 +157,94 @@ describe("LlmGatewayService.prompt", () => { }); }); + it("surfaces a FastAPI bare-string detail as the error message", async () => { + const fetchMock = vi.fn().mockResolvedValue( + createJsonResponse( + { + detail: "OAuth application not authorized for product 'posthog_code'", + }, + 403, + ), + ); + const { service } = createService(fetchMock); + + await expect( + service.prompt([{ role: "user", content: "hi" }]), + ).rejects.toMatchObject({ + name: "LlmGatewayError", + message: "OAuth application not authorized for product 'posthog_code'", + type: "unknown_error", + statusCode: 403, + }); + }); + + // The free-tier model gate's 403 body, as the gateway serves it. + const MODEL_GATE_BODY = { + error: { + message: + "Model 'claude-haiku-4-5' needs a paid PostHog plan. Models available on the free tier: @cf/zai-org/glm-5.2. Add a payment method to your organization to unlock all models. (rate_limit)", + type: "permission_error", + code: "model_gate", + }, + }; + + it("retries once on the free-tier model when the model gate 403s", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(createJsonResponse(MODEL_GATE_BODY, 403)) + .mockResolvedValueOnce(createJsonResponse(SUCCESS_BODY)); + const { service } = createService(fetchMock); + + const result = await service.prompt([{ role: "user", content: "hi" }], { + model: "claude-haiku-4-5", + }); + + expect(result.content).toBe("hello world"); + expect(fetchMock).toHaveBeenCalledTimes(2); + const firstBody = JSON.parse(fetchMock.mock.calls[0][1].body as string); + const retryBody = JSON.parse(fetchMock.mock.calls[1][1].body as string); + expect(firstBody.model).toBe("claude-haiku-4-5"); + expect(retryBody.model).toBe("@cf/zai-org/glm-5.2"); + }); + + it("routes straight to the free-tier model once the org is known unbilled", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(createJsonResponse(MODEL_GATE_BODY, 403)) + .mockImplementation(async () => createJsonResponse(SUCCESS_BODY)); + const { service } = createService(fetchMock); + + // First call learns "unbilled" from the gate's 403. + await service.prompt([{ role: "user", content: "hi" }], { + model: "claude-haiku-4-5", + }); + // Second call must not burn a round trip on the gate. + await service.prompt([{ role: "user", content: "hi" }], { + model: "claude-haiku-4-5", + }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + const thirdBody = JSON.parse(fetchMock.mock.calls[2][1].body as string); + expect(thirdBody.model).toBe("@cf/zai-org/glm-5.2"); + }); + + it("does not retry non-gate 403s on the free-tier model", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + createJsonResponse( + { detail: "Product 'posthog_code' requires OAuth authentication" }, + 403, + ), + ); + const { service } = createService(fetchMock); + + await expect( + service.prompt([{ role: "user", content: "hi" }]), + ).rejects.toMatchObject({ statusCode: 403 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("throws a timeout LlmGatewayError when the request aborts via the internal timeout", async () => { const fetchMock = vi.fn((_url: string, init?: RequestInit) => { return new Promise((_resolve, reject) => { @@ -214,6 +302,40 @@ describe("LlmGatewayService.fetchUsage", () => { statusCode: 503, }); }); + + it("parses the usage-based billing fields when present", async () => { + const fetchMock = vi.fn().mockResolvedValue( + createJsonResponse({ + ...USAGE_BODY, + ai_credits: { exhausted: true }, + code_usage_billed: true, + }), + ); + const { service } = createService(fetchMock); + + const usage = await service.fetchUsage(); + + expect(usage.ai_credits?.exhausted).toBe(true); + expect(usage.code_usage_billed).toBe(true); + }); + + it("feeds code_usage_billed into helper model routing", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + createJsonResponse({ ...USAGE_BODY, code_usage_billed: false }), + ) + .mockResolvedValue(createJsonResponse(SUCCESS_BODY)); + const { service } = createService(fetchMock); + + await service.fetchUsage(); + await service.prompt([{ role: "user", content: "hi" }], { + model: "claude-haiku-4-5", + }); + + const promptBody = JSON.parse(fetchMock.mock.calls[1][1].body as string); + expect(promptBody.model).toBe("@cf/zai-org/glm-5.2"); + }); }); describe("LlmGatewayService.invalidatePlanCache", () => { diff --git a/packages/core/src/llm-gateway/llm-gateway.ts b/packages/core/src/llm-gateway/llm-gateway.ts index 52b3d15121..07ca92d9d6 100644 --- a/packages/core/src/llm-gateway/llm-gateway.ts +++ b/packages/core/src/llm-gateway/llm-gateway.ts @@ -1,4 +1,5 @@ import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; +import { classifyGatewayLimitError } from "@posthog/shared"; import { buildPosthogPropertyHeaderRecord, type PosthogProperties, @@ -25,6 +26,11 @@ import { // the cheapest model rather than the gateway default. export const HELPER_GATEWAY_MODEL = "claude-haiku-4-5"; +// When the org isn't billed for Code usage, the gateway 403s models outside +// the free tier (free-tier model gate). Helper calls are invisible plumbing, +// so they run on the free-tier model instead of surfacing the gate. +export const FREE_TIER_GATEWAY_MODEL = "@cf/zai-org/glm-5.2"; + export class LlmGatewayError extends Error { constructor( message: string, @@ -54,6 +60,12 @@ export class LlmGatewayService { private readonly endpoints: LlmGatewayEndpoints; private readonly log: LlmGatewayLogger; + // Last code_usage_billed seen from the gateway. Usage fetches ride through + // this service and the usage monitor refreshes on LLM activity, so this + // stays warm. null until the gateway has said either way — old gateways + // omit the field entirely. + private lastKnownCodeUsageBilled: boolean | null = null; + async prompt( messages: LlmMessage[], options: { @@ -70,11 +82,55 @@ export class LlmGatewayService { */ posthogProperties?: PosthogProperties; } = {}, + ): Promise { + const requested = options.model ?? this.endpoints.defaultModel; + // This service only carries helper workloads — the main agent session + // goes through the SDK, not here. When the org is known to be on the + // free tier, route straight to the free-tier model instead of burning a + // round trip on the model gate's 403. + const model = + this.lastKnownCodeUsageBilled === false + ? FREE_TIER_GATEWAY_MODEL + : requested; + try { + return await this.sendPrompt(messages, { ...options, model }); + } catch (error) { + const isModelGate = + error instanceof LlmGatewayError && + error.statusCode === 403 && + classifyGatewayLimitError(error.message) === "model_gate"; + if (!isModelGate || model === FREE_TIER_GATEWAY_MODEL) throw error; + // Backstop for a cold start (no usage fetch yet) or a stale billed bit + // (org just lost billing): the gate itself is authoritative that the + // org isn't billed, so remember that and degrade this call instead of + // failing it. + this.lastKnownCodeUsageBilled = false; + this.log.warn("Model gated for free tier, retrying on free-tier model", { + model, + fallbackModel: FREE_TIER_GATEWAY_MODEL, + }); + return await this.sendPrompt(messages, { + ...options, + model: FREE_TIER_GATEWAY_MODEL, + }); + } + } + + private async sendPrompt( + messages: LlmMessage[], + options: { + system?: string; + maxTokens?: number; + model: string; + signal?: AbortSignal; + timeoutMs?: number; + posthogProperties?: PosthogProperties; + }, ): Promise { const { system, maxTokens, - model = this.endpoints.defaultModel, + model, signal, timeoutMs = 60_000, posthogProperties, @@ -154,8 +210,11 @@ export class LlmGatewayService { }); } + const detail = + typeof errorData?.detail === "string" ? errorData.detail : undefined; const errorMessage = errorData?.error?.message || + detail || `HTTP ${response.status}: ${response.statusText}`; const errorType = errorData?.error?.type || "unknown_error"; const errorCode = errorData?.error?.code; @@ -223,7 +282,11 @@ export class LlmGatewayService { ); } - return usageOutput.parse(await response.json()); + const usage = usageOutput.parse(await response.json()); + if (usage.code_usage_billed !== undefined) { + this.lastKnownCodeUsageBilled = usage.code_usage_billed; + } + return usage; } async invalidatePlanCache(): Promise { diff --git a/packages/core/src/llm-gateway/schemas.ts b/packages/core/src/llm-gateway/schemas.ts index 9b985139b9..64e717974d 100644 --- a/packages/core/src/llm-gateway/schemas.ts +++ b/packages/core/src/llm-gateway/schemas.ts @@ -50,11 +50,14 @@ export interface AnthropicMessagesResponse { } export interface AnthropicErrorResponse { - error: { + error?: { message: string; type: string; code?: string; }; + // FastAPI access-denial shape: some gateway 403s (e.g. OAuth/product + // checks) carry a bare string `detail` instead of the error envelope. + detail?: unknown; } export type { UsageBucket, UsageOutput } from "../usage/schemas"; diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 8c29049924..7c61cb6b16 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -14,7 +14,9 @@ import { type Adapter, type AgentSession, type CloudRegion, + classifyGatewayLimitError, type ExecutionMode, + extractGatedModel, flattenSelectOptions, getBackoffDelay, getCloudUrlFromRegion, @@ -2712,15 +2714,35 @@ export class SessionService { this.d.store.clearOptimisticItems(session.taskRunId); - if (isRateLimitError(errorMessage, errorDetails)) { - this.d.log.warn("Rate limit exceeded, showing usage limit modal", { + const limitCause = classifyGatewayLimitError(errorMessage, errorDetails); + + // Gateway billing denials — the free-tier model gate (403) or a usage + // limit (429): the session is healthy, and the fix is switching model, + // adding a payment method, or raising a limit. Surface the upgrade + // gate, not an error state. + if ( + limitCause === "model_gate" || + isRateLimitError(errorMessage, errorDetails) + ) { + this.d.log.warn("Gateway limit reached, showing usage limit modal", { taskRunId: session.taskRunId, + cause: limitCause, }); this.d.store.updateSession(session.taskRunId, { isPromptPending: false, promptStartedAt: null, }); - this.d.usageLimit.show(); + this.d.usageLimit.show( + limitCause === "model_gate" + ? { + cause: limitCause, + model: + extractGatedModel(errorMessage, errorDetails) ?? undefined, + } + : limitCause + ? { cause: limitCause } + : undefined, + ); return { stopReason: "rate_limited" }; } diff --git a/packages/core/src/usage/schemas.ts b/packages/core/src/usage/schemas.ts index 7ad2c1db8e..2e1c36103b 100644 --- a/packages/core/src/usage/schemas.ts +++ b/packages/core/src/usage/schemas.ts @@ -11,8 +11,16 @@ export const usageOutput = z.object({ user_id: z.number(), sustained: usageBucketSchema, burst: usageBucketSchema, + // Org credit-bucket state (posthog_code_credits: free allocation used up, + // or the org's billing limit reached). Named ai_credits on the wire; the + // gateway reports it for every credit bucket. + ai_credits: z.object({ exhausted: z.boolean() }).optional(), is_rate_limited: z.boolean(), + // Seat-era plan bit; false for everyone once seats are retired. is_pro: z.boolean(), + // True when the org pays for Code usage (usage-based billing). Absent on + // gateways that predate the field — treat absence as unknown, never as free. + code_usage_billed: z.boolean().optional(), billing_period_end: z.string().datetime().nullable().optional(), }); diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index c1dffa2051..70c8a664dd 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -983,12 +983,21 @@ export type UpgradePromptClickedSurface = | "plan_page_card" | "upgrade_dialog"; +/** Which gateway limit/gate put the prompt on screen (usage-based billing). */ +export type UpgradePromptCause = + | "model_gate" + | "org_limit" + | "user_daily_limit" + | "user_monthly_limit"; + export interface UpgradePromptShownProperties { surface: UpgradePromptShownSurface; + cause?: UpgradePromptCause; } export interface UpgradePromptClickedProperties { surface: UpgradePromptClickedSurface; + cause?: UpgradePromptCause; } export interface CloudTaskUsageBlockedProperties { diff --git a/packages/shared/src/errors.test.ts b/packages/shared/src/errors.test.ts index 0501363126..5285deaf16 100644 --- a/packages/shared/src/errors.test.ts +++ b/packages/shared/src/errors.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + classifyGatewayLimitError, + extractGatedModel, getErrorMessage, isAuthError, isFatalSessionError, @@ -84,6 +86,80 @@ describe("isRateLimitError", () => { }); }); +describe("classifyGatewayLimitError", () => { + it.each([ + [ + // The gate 403 as the ACP layer surfaces it (full body embedded). + `Internal error: API Error: 403 {"error":{"message":"Model 'claude-opus-4-8' needs a paid PostHog plan. Models available on the free tier: @cf/zai-org/glm-5.2. Add a payment method to your organization to unlock all models. (rate_limit)","type":"permission_error","code":"model_gate"}}`, + "model_gate", + ], + [ + // SDK surfaces that reduce the body to its message string. + "API Error: 403 Model 'gpt-5.5' needs a paid PostHog plan. (rate_limit)", + "model_gate", + ], + [ + // Bare FastAPI detail from gateways predating the error envelope. + `Internal error: API Error: 403 {"detail":"Model 'claude-opus-4-8' needs a paid PostHog plan."}`, + "model_gate", + ], + [ + "Rate limit exceeded: Your team has reached its PostHog Code usage limit for this billing period. See https://app.posthog.com/organization/billing for your usage and limits.", + "org_limit", + ], + [ + // Gateway fallback wording for a credit bucket without a mapped message. + "Your team has reached its usage limit for this billing period.", + "org_limit", + ], + ["Rate limit exceeded: User burst rate limit exceeded", "user_daily_limit"], + [ + "Rate limit exceeded: User sustained rate limit exceeded", + "user_monthly_limit", + ], + ])("classifies %j as %s", (message, expected) => { + expect(classifyGatewayLimitError(message)).toBe(expected); + }); + + it("matches against the details when the message is generic", () => { + expect( + classifyGatewayLimitError( + "Internal error", + "API Error: 403 Model 'gpt-5.5' needs a paid PostHog plan.", + ), + ).toBe("model_gate"); + }); + + it.each([ + "Rate limit exceeded", + "Rate limit exceeded: Product rate limit exceeded", + "Your team has used its monthly PostHog AI credits.", + "network down", + ])("returns null for %j", (message) => { + expect(classifyGatewayLimitError(message)).toBeNull(); + }); +}); + +describe("extractGatedModel", () => { + it("pulls the model id out of the gate message", () => { + expect( + extractGatedModel( + "Model 'claude-opus-4-8' needs a paid PostHog plan. Models available on the free tier: @cf/zai-org/glm-5.2.", + ), + ).toBe("claude-opus-4-8"); + }); + + it("falls back to the details and returns null when absent", () => { + expect( + extractGatedModel( + "Internal error", + "Model 'gpt-5.5' needs a paid PostHog plan.", + ), + ).toBe("gpt-5.5"); + expect(extractGatedModel("no model here")).toBeNull(); + }); +}); + describe("isFatalSessionError", () => { it.each([ "internal error", @@ -123,6 +199,16 @@ describe("isFatalSessionError", () => { ), ).toBe(false); }); + + it("does not treat a free-tier model-gate 403 as fatal despite the Internal error wrapper", () => { + // Shim-less body (no "(rate_limit)" suffix), so this exercises the + // model-gate exclusion rather than the rate-limit one. + expect( + isFatalSessionError( + `Internal error: API Error: 403 {"detail":"Model 'claude-opus-4-8' needs a paid PostHog plan."}`, + ), + ).toBe(false); + }); }); describe("isTransientUpstreamError", () => { diff --git a/packages/shared/src/errors.ts b/packages/shared/src/errors.ts index a910c95b45..9d31ca484b 100644 --- a/packages/shared/src/errors.ts +++ b/packages/shared/src/errors.ts @@ -74,6 +74,40 @@ const RATE_LIMIT_PATTERNS = [ "[429]", ] as const; +/** + * Billing/limit denials from the PostHog LLM gateway (posthog_code product), + * classified from the server's error text. The gateway also stamps + * machine-readable `code`s on the body ("model_gate", "billable_credits", + * "user_cost_burst", "user_cost_sustained") for direct HTTP consumers, but + * agent-SDK surfaces often reduce the body to its message string, so + * classification matches the prose that is always present in that message. + * Kept in sync with services/llm-gateway in posthog/posthog: + * - "model_gate" (403): the org isn't billed for Code usage and requested a + * model outside the free tier — "Model 'X' needs a paid PostHog plan. …". + * - "org_limit" (429): the org's posthog_code_credits bucket is exhausted + * (free allocation used up, or the org's billing limit reached). + * - "user_daily_limit" / "user_monthly_limit" (429): the per-user cost valves. + */ +export type GatewayLimitCause = + | "model_gate" + | "org_limit" + | "user_daily_limit" + | "user_monthly_limit"; + +const MODEL_GATE_PATTERNS = ["needs a paid posthog plan"] as const; + +const ORG_LIMIT_PATTERNS = [ + "reached its posthog code usage limit", + // Gateway fallback wording for a credit bucket without a mapped message. + "reached its usage limit for this billing period", +] as const; + +const USER_DAILY_LIMIT_PATTERNS = ["user burst rate limit exceeded"] as const; + +const USER_MONTHLY_LIMIT_PATTERNS = [ + "user sustained rate limit exceeded", +] as const; + const FATAL_SESSION_ERROR_PATTERNS = [ "internal error", "process exited", @@ -121,6 +155,33 @@ export function isRateLimitError( ); } +const GATED_MODEL_REGEX = /Model '([^']+)' needs a paid PostHog plan/i; + +/** The model id a free-tier model-gate 403 names, when present. */ +export function extractGatedModel( + errorMessage: string, + errorDetails?: string, +): string | null { + return ( + errorMessage.match(GATED_MODEL_REGEX)?.[1] ?? + errorDetails?.match(GATED_MODEL_REGEX)?.[1] ?? + null + ); +} + +export function classifyGatewayLimitError( + errorMessage: string, + errorDetails?: string, +): GatewayLimitCause | null { + const matches = (patterns: readonly string[]) => + includesAny(errorMessage, patterns) || includesAny(errorDetails, patterns); + if (matches(MODEL_GATE_PATTERNS)) return "model_gate"; + if (matches(ORG_LIMIT_PATTERNS)) return "org_limit"; + if (matches(USER_DAILY_LIMIT_PATTERNS)) return "user_daily_limit"; + if (matches(USER_MONTHLY_LIMIT_PATTERNS)) return "user_monthly_limit"; + return null; +} + export function isTransientUpstreamError( errorMessage: string, errorDetails?: string, @@ -137,6 +198,14 @@ export function isFatalSessionError( ): boolean { if (isRateLimitError(errorMessage, errorDetails)) return false; if (isTransientUpstreamError(errorMessage, errorDetails)) return false; + // A free-tier model-gate 403 arrives wrapped as "Internal error: API + // Error: 403 …" — the session is healthy, the fix is switching model or + // adding a payment method, so it must never trigger session teardown. + // (Current gateways also suffix the message "(rate_limit)", caught by the + // rate-limit exclusion above; this covers bodies without that shim.) + if (classifyGatewayLimitError(errorMessage, errorDetails) === "model_gate") { + return false; + } return ( includesAny(errorMessage, FATAL_SESSION_ERROR_PATTERNS) || includesAny(errorDetails, FATAL_SESSION_ERROR_PATTERNS) diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index 7f1587d962..9a60304a63 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -1,5 +1,12 @@ export const BILLING_FLAG = "posthog-code-billing"; export const SPEND_ANALYSIS_FLAG = "posthog-code-spend-analysis"; +/** + * Usage-based billing (the 2026-07 seat retirement). Swaps the usage-limit + * modal from seat-era Free/Pro copy to cause-aware usage-based copy. Flip + * together with the gateway's LLM_GATEWAY_POSTHOG_CODE_MODEL_GATE_ENABLED + * cutover switch. + */ +export const USAGE_BILLING_FLAG = "posthog-code-usage-billing"; export const EXPERIMENT_SUGGESTIONS_FLAG = "posthog-code-experiment-suggestions"; export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 88873fd0b8..98f7262624 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -72,6 +72,9 @@ export { export type { SignalReportPriority, Task } from "./domain-types"; export * from "./enrichment"; export { + classifyGatewayLimitError, + extractGatedModel, + type GatewayLimitCause, getErrorMessage, isAuthError, isFatalSessionError, diff --git a/packages/ui/src/features/billing/UsageLimitModal.tsx b/packages/ui/src/features/billing/UsageLimitModal.tsx index d637a6f3dc..f1ddfa2370 100644 --- a/packages/ui/src/features/billing/UsageLimitModal.tsx +++ b/packages/ui/src/features/billing/UsageLimitModal.tsx @@ -1,16 +1,26 @@ import { WarningCircle } from "@phosphor-icons/react"; +import { formatResetTime } from "@posthog/core/billing/usageDisplay"; import { - formatResetTime, - PRO_USAGE_MULTIPLIER, -} from "@posthog/core/billing/usageDisplay"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; + deriveUsageLimitCause, + seatEraLimitContent, + usageBasedLimitContent, +} from "@posthog/core/billing/usageLimitContent"; +import { USAGE_BILLING_FLAG } from "@posthog/shared"; +import { + ANALYTICS_EVENTS, + type UpgradePromptCause, +} from "@posthog/shared/analytics-events"; import { Button, Dialog, Flex, Text } from "@radix-ui/themes"; import { useEffect } from "react"; import { track } from "../../shell/analytics"; import { openExternalUrl } from "../../shell/openExternal"; +import { getBillingUrl } from "../../utils/urls"; +import { useAuthStateValue } from "../auth/store"; +import { useFeatureFlag } from "../feature-flags/useFeatureFlag"; import { openSettings } from "../settings/hooks/useOpenSettings"; import { useUsageLimitStore } from "./usageLimitStore"; import { useSeat } from "./useSeat"; +import { useUsage } from "./useUsage"; const SUPPORT_MAILTO = "mailto:charles@posthog.com?subject=PostHog%20Code%20%E2%80%94%20Pro%20usage%20limit"; @@ -20,23 +30,52 @@ export function UsageLimitModal() { const bucket = useUsageLimitStore((s) => s.bucket); const resetAt = useUsageLimitStore((s) => s.resetAt); const eventIsPro = useUsageLimitStore((s) => s.isPro); + const cause = useUsageLimitStore((s) => s.cause); + const model = useUsageLimitStore((s) => s.model); const hide = useUsageLimitStore((s) => s.hide); const { isPro: seatIsPro } = useSeat(); + const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + // Whether the org pays for Code usage — picks the org_limit copy variant. + const { usage } = useUsage({ enabled: usageBillingEnabled && isOpen }); const isPro = eventIsPro ?? seatIsPro; + const derivedCause = deriveUsageLimitCause(cause, bucket); + const trackedCause: UpgradePromptCause | undefined = + usageBillingEnabled && derivedCause ? derivedCause : undefined; + useEffect(() => { if (isOpen) { track(ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN, { surface: "usage_limit_modal", + ...(trackedCause ? { cause: trackedCause } : {}), }); } - }, [isOpen]); + }, [isOpen, trackedCause]); + + const resetLabel = resetAt ? formatResetTime(resetAt) : null; + + const content = usageBillingEnabled + ? usageBasedLimitContent({ + cause: derivedCause, + model, + resetLabel, + billed: usage?.code_usage_billed, + }) + : seatEraLimitContent({ bucket, isPro, resetLabel }); - const handleUpgrade = () => { + const handleAction = () => { track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface: "usage_limit_modal", + ...(trackedCause ? { cause: trackedCause } : {}), }); hide(); + if (usageBillingEnabled) { + // Payment methods and billing limits live on the PostHog billing page. + const billingUrl = getBillingUrl(cloudRegion); + if (billingUrl) openExternalUrl(billingUrl); + return; + } openSettings("plan-usage"); }; @@ -44,28 +83,9 @@ export function UsageLimitModal() { openExternalUrl(SUPPORT_MAILTO); }; - const isDaily = bucket === "burst"; - const isMonthly = bucket === "sustained"; - const resetLabel = resetAt ? formatResetTime(resetAt) : null; - - const title = isDaily - ? "Daily limit reached" - : isMonthly && !isPro - ? "You're out of usage for this month" - : isMonthly - ? "Monthly limit reached" - : "Usage limit reached"; - - const proCapLabel = isDaily - ? "a daily usage cap" - : isMonthly - ? "a monthly usage cap" - : "usage caps"; - const description = isPro - ? `Your Pro plan has ${proCapLabel}.${resetLabel ? ` ${resetLabel}.` : ""}` - : `You've hit your Free ${ - isDaily ? "daily" : isMonthly ? "monthly" : "usage" - } limit. Upgrade to Pro for ${PRO_USAGE_MULTIPLIER}× more usage.`; + // Seat-era Pro keeps its support escape hatch; usage-based billing has no + // Pro plan (support routes through the billing page instead). + const showSupport = !usageBillingEnabled && isPro; return ( @@ -77,43 +97,38 @@ export function UsageLimitModal() { - {title} + {content.title} - {description} + {content.description} - {isPro ? ( - <> - - - - ) : ( - <> - - - + {showSupport && ( + + )} + + {content.actionLabel && ( + )} diff --git a/packages/ui/src/features/billing/preflightCloudUsage.ts b/packages/ui/src/features/billing/preflightCloudUsage.ts index a12258ee92..c4326a3092 100644 --- a/packages/ui/src/features/billing/preflightCloudUsage.ts +++ b/packages/ui/src/features/billing/preflightCloudUsage.ts @@ -8,20 +8,27 @@ import { import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { track } from "@posthog/ui/shell/analytics"; import { logger } from "@posthog/ui/shell/logger"; -import { type UsageLimitBucket, useUsageLimitStore } from "./usageLimitStore"; +import { + type UsageLimitBucket, + type UsageLimitShowArgs, + useUsageLimitStore, +} from "./usageLimitStore"; const log = logger.scope("preflight-cloud-usage"); -function usageLimitArgs(usage: UsageOutput): { - bucket: UsageLimitBucket; - resetAt: string; - isPro: boolean; -} { - // Prefer the bucket that's actually exceeded (burst/daily takes priority); if neither - // is flagged (is_rate_limited via a server-side valve), fall back to the monthly bucket - // so the modal still shows a title and reset time rather than a bare prompt. +function usageLimitArgs(usage: UsageOutput): UsageLimitShowArgs { + // Prefer the bucket that's actually exceeded (burst/daily takes priority); + // otherwise fall back to the monthly bucket for the reset hint. If neither + // is flagged, is_rate_limited came from the org's credit bucket (free + // allocation or billing limit exhausted) — name that cause. const bucket: UsageLimitBucket = usage.burst.exceeded ? "burst" : "sustained"; - return { bucket, resetAt: usage[bucket].reset_at, isPro: usage.is_pro }; + const orgLimited = !usage.burst.exceeded && !usage.sustained.exceeded; + return { + bucket, + resetAt: usage[bucket].reset_at, + isPro: usage.is_pro, + ...(orgLimited ? { cause: "org_limit" as const } : {}), + }; } async function fetchUsageSnapshot(): Promise { @@ -52,7 +59,7 @@ export async function assertCloudUsageAvailable(): Promise { if (usage && isUsageExceeded(usage)) { const args = usageLimitArgs(usage); track(ANALYTICS_EVENTS.CLOUD_TASK_USAGE_BLOCKED, { - bucket: args.bucket, + bucket: args.bucket ?? null, is_pro: usage.is_pro, }); useUsageLimitStore.getState().show(args); diff --git a/packages/ui/src/features/billing/usageLimitStore.ts b/packages/ui/src/features/billing/usageLimitStore.ts index f1c2ca114c..f627c84e1e 100644 --- a/packages/ui/src/features/billing/usageLimitStore.ts +++ b/packages/ui/src/features/billing/usageLimitStore.ts @@ -1,20 +1,32 @@ +import type { GatewayLimitCause } from "@posthog/shared"; import { create } from "zustand"; export type UsageLimitBucket = "burst" | "sustained"; +export interface UsageLimitShowArgs { + bucket?: UsageLimitBucket; + resetAt?: string; + isPro?: boolean; + /** + * Which gateway limit/gate tripped (usage-based billing). Drives the + * cause-specific copy; legacy callers that only know the bucket omit it. + */ + cause?: GatewayLimitCause; + /** The model the free-tier gate blocked, when known (cause "model_gate"). */ + model?: string; +} + interface UsageLimitState { isOpen: boolean; bucket: UsageLimitBucket | null; resetAt: string | null; isPro: boolean | null; + cause: GatewayLimitCause | null; + model: string | null; } interface UsageLimitActions { - show: (args?: { - bucket: UsageLimitBucket; - resetAt: string; - isPro?: boolean; - }) => void; + show: (args?: UsageLimitShowArgs) => void; hide: () => void; } @@ -25,6 +37,8 @@ export const useUsageLimitStore = create()((set) => ({ bucket: null, resetAt: null, isPro: null, + cause: null, + model: null, show: (args) => set({ @@ -32,6 +46,8 @@ export const useUsageLimitStore = create()((set) => ({ bucket: args?.bucket ?? null, resetAt: args?.resetAt ?? null, isPro: args?.isPro ?? null, + cause: args?.cause ?? null, + model: args?.model ?? null, }), hide: () => set({ isOpen: false }), })); From a93db4e07a0c048b0c9e2752b0acd5279a7b7e2b Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 15 Jul 2026 17:12:30 -0400 Subject: [PATCH 2/9] refactor(billing): drop the seat-retirement memo; keep only readable 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 --- packages/core/src/billing/seatErrors.ts | 13 +++----- packages/core/src/billing/seatService.test.ts | 30 ------------------- packages/core/src/billing/seatService.ts | 23 ++------------ 3 files changed, 7 insertions(+), 59 deletions(-) diff --git a/packages/core/src/billing/seatErrors.ts b/packages/core/src/billing/seatErrors.ts index 38b903d5cc..1ec69e214c 100644 --- a/packages/core/src/billing/seatErrors.ts +++ b/packages/core/src/billing/seatErrors.ts @@ -3,20 +3,15 @@ export interface ClassifiedSeatError { redirectUrl: string | null; } -/** - * The seat API's 410 Gone: PostHog Code seats are retired in favor of - * usage-based billing. Name-based so it survives the SeatClient seam. - */ -export function isSeatProductRetiredError(error: unknown): boolean { - return error instanceof Error && error.name === "SeatProductRetiredError"; -} - export function classifySeatError(error: unknown): ClassifiedSeatError { if (!(error instanceof Error)) { return { error: "An unexpected error occurred", redirectUrl: null }; } - if (isSeatProductRetiredError(error)) { + // The seat API's 410 Gone: seats are retired in favor of usage-based + // billing. Only reachable from the seat-era UI, which goes away with the + // cutover — no handling beyond readable copy. + if (error.name === "SeatProductRetiredError") { return { error: "PostHog Code seat plans have been retired — usage is now billed to your organization.", diff --git a/packages/core/src/billing/seatService.test.ts b/packages/core/src/billing/seatService.test.ts index fc37ad2499..2229567bda 100644 --- a/packages/core/src/billing/seatService.test.ts +++ b/packages/core/src/billing/seatService.test.ts @@ -300,36 +300,6 @@ describe("seat product retired (410 Gone)", () => { expect(result.error).toBeNull(); }); - it("stops re-attempting provisioning once the product is known retired", async () => { - const createSeat = vi.fn().mockRejectedValue(new SeatProductRetiredError()); - const client = makeClient({ createSeat }); - const service = new SeatService(client, logger); - - await service.fetchSeat({ autoProvision: true }); - createSeat.mockClear(); - await service.fetchSeat({ autoProvision: true }); - - expect(createSeat).not.toHaveBeenCalled(); - }); - - it("still re-fetches the seat after a non-retirement provisioning failure", async () => { - const seat = makeSeat(); - const getMySeat = vi - .fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - .mockResolvedValue(seat); - const client = makeClient({ - getMySeat, - createSeat: vi.fn().mockRejectedValue(new Error("conflict")), - }); - const result = await new SeatService(client, logger).fetchSeat({ - autoProvision: true, - }); - expect(result.error).toBeNull(); - expect(result.seat).toEqual(seat); - }); - it("surfaces retirement as a clear error on an explicit upgrade", async () => { const client = makeClient({ createSeat: vi.fn().mockRejectedValue(new SeatProductRetiredError()), diff --git a/packages/core/src/billing/seatService.ts b/packages/core/src/billing/seatService.ts index 3f94e7078e..24f25cbc25 100644 --- a/packages/core/src/billing/seatService.ts +++ b/packages/core/src/billing/seatService.ts @@ -2,11 +2,7 @@ import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; import { PLAN_FREE, PLAN_PRO, type SeatData } from "@posthog/shared"; import { inject, injectable } from "inversify"; import { SEAT_CLIENT, type SeatClient, type SeatLogger } from "./identifiers"; -import { - type ClassifiedSeatError, - classifySeatError, - isSeatProductRetiredError, -} from "./seatErrors"; +import { type ClassifiedSeatError, classifySeatError } from "./seatErrors"; export interface SeatOperationResult { seat: SeatData | null; @@ -47,11 +43,6 @@ function fail(classified: ClassifiedSeatError): SeatOperationResult { export class SeatService { private readonly logger: SeatLogger; - // The seat API 410s creation/upgrades/reactivation once seats are retired - // (usage-based billing). Remembered so auth/onboarding fetches stop - // re-attempting a provision that can never succeed. - private seatProductRetired = false; - constructor( @inject(SEAT_CLIENT) private readonly client: SeatClient, @inject(ROOT_LOGGER) logger: RootLogger, @@ -64,18 +55,13 @@ export class SeatService { autoProvision: boolean; }): Promise { let seat = await this.client.getMySeat({ best: options.best }); - if (!seat && options.autoProvision && !this.seatProductRetired) { + if (!seat && options.autoProvision) { this.logger.info("No seat found, auto-provisioning free plan", { best: options.best, }); try { seat = await this.client.createSeat(PLAN_FREE); - } catch (error) { - if (isSeatProductRetiredError(error)) { - this.seatProductRetired = true; - this.logger.info("Seat product retired; skipping auto-provision"); - return null; - } + } catch { this.logger.info("Auto-provision failed, re-fetching seat"); seat = await this.client.getMySeat({ best: options.best }); } @@ -132,7 +118,6 @@ export class SeatService { this.client.invalidatePlanCache(); return ok(seat, null, true); } catch (error) { - if (isSeatProductRetiredError(error)) this.seatProductRetired = true; this.logger.error("provisionFreeSeat failed", error); return fail(classifySeatError(error)); } @@ -158,7 +143,6 @@ export class SeatService { this.client.invalidatePlanCache(); return ok(seat, seat); } catch (error) { - if (isSeatProductRetiredError(error)) this.seatProductRetired = true; return fail(classifySeatError(error)); } } @@ -184,7 +168,6 @@ export class SeatService { this.client.invalidatePlanCache(); return ok(seat, seat); } catch (error) { - if (isSeatProductRetiredError(error)) this.seatProductRetired = true; return fail(classifySeatError(error)); } } From 6af223baf70a5ae80ffb4257bac02d641d4d9982 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 15 Jul 2026 17:27:17 -0400 Subject: [PATCH 3/9] refactor(billing): collapse limit causes to the usage-based world; cull comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/api-client/src/posthog-client.ts | 6 +-- packages/core/src/billing/seatErrors.ts | 5 +-- .../src/billing/usageLimitContent.test.ts | 32 +-------------- .../core/src/billing/usageLimitContent.ts | 32 +-------------- packages/core/src/llm-gateway/llm-gateway.ts | 22 ++++------ packages/core/src/llm-gateway/schemas.ts | 3 +- packages/core/src/sessions/sessionService.ts | 6 +-- packages/core/src/usage/schemas.ts | 9 ++-- packages/shared/src/analytics-events.ts | 8 +--- packages/shared/src/errors.test.ts | 7 +--- packages/shared/src/errors.ts | 41 +++++-------------- packages/shared/src/flags.ts | 6 +-- .../src/features/billing/UsageLimitModal.tsx | 18 +++----- .../features/billing/preflightCloudUsage.ts | 12 +++--- .../src/features/billing/usageLimitStore.ts | 7 +--- 15 files changed, 50 insertions(+), 164 deletions(-) diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index fd98581216..545f7effbb 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -133,11 +133,7 @@ export class SeatPaymentFailedError extends Error { } } -/** - * Seat creation, upgrades, and reactivation return 410 Gone once PostHog Code - * seats are retired in favor of usage-based billing (reads and cancellation - * keep working for existing seats). - */ +/** Seat creation/upgrades/reactivation return 410 Gone: seats are retired. */ export class SeatProductRetiredError extends Error { constructor() { super("PostHog Code seats have been retired"); diff --git a/packages/core/src/billing/seatErrors.ts b/packages/core/src/billing/seatErrors.ts index 1ec69e214c..2bd9e9290e 100644 --- a/packages/core/src/billing/seatErrors.ts +++ b/packages/core/src/billing/seatErrors.ts @@ -8,9 +8,8 @@ export function classifySeatError(error: unknown): ClassifiedSeatError { return { error: "An unexpected error occurred", redirectUrl: null }; } - // The seat API's 410 Gone: seats are retired in favor of usage-based - // billing. Only reachable from the seat-era UI, which goes away with the - // cutover — no handling beyond readable copy. + // Seats are retired (410 Gone); the seat-era UI that can hit this goes + // away at cutover, so readable copy is the only handling. if (error.name === "SeatProductRetiredError") { return { error: diff --git a/packages/core/src/billing/usageLimitContent.test.ts b/packages/core/src/billing/usageLimitContent.test.ts index aaf54ea01a..ed0a154e87 100644 --- a/packages/core/src/billing/usageLimitContent.test.ts +++ b/packages/core/src/billing/usageLimitContent.test.ts @@ -1,23 +1,9 @@ import { describe, expect, it } from "vitest"; import { - deriveUsageLimitCause, seatEraLimitContent, usageBasedLimitContent, } from "./usageLimitContent"; -describe("deriveUsageLimitCause", () => { - it.each([ - ["model_gate", "burst", "model_gate"], - [null, "burst", "user_daily_limit"], - [null, "sustained", "user_monthly_limit"], - // No cause and no bucket (e.g. an upstream provider's own rate limit): - // stay generic instead of blaming the org's billing. - [null, null, null], - ] as const)("cause %s + bucket %s -> %s", (cause, bucket, expected) => { - expect(deriveUsageLimitCause(cause, bucket)).toBe(expected); - }); -}); - describe("usageBasedLimitContent", () => { it("names the gated model and offers a payment method", () => { const content = usageBasedLimitContent({ @@ -62,29 +48,15 @@ describe("usageBasedLimitContent", () => { }, ); - it.each([ - ["user_daily_limit", "Free daily limit reached"], - ["user_monthly_limit", "Free monthly limit reached"], - ] as const)("%s carries the reset hint", (cause, title) => { - const content = usageBasedLimitContent({ - cause, - model: null, - resetLabel: "Resets in 2h", - billed: false, - }); - expect(content.title).toBe(title); - expect(content.description).toContain("Resets in 2h"); - expect(content.actionLabel).toBe("Add payment method"); - }); - it("renders generic copy without a billing CTA when the cause is unknown", () => { const content = usageBasedLimitContent({ cause: null, model: null, - resetLabel: null, + resetLabel: "Resets in 2h", billed: true, }); expect(content.title).toBe("Usage limit reached"); + expect(content.description).toContain("Resets in 2h"); expect(content.actionLabel).toBeNull(); expect(content.dismissLabel).toBe("Got it"); }); diff --git a/packages/core/src/billing/usageLimitContent.ts b/packages/core/src/billing/usageLimitContent.ts index c595326dc1..cede164409 100644 --- a/packages/core/src/billing/usageLimitContent.ts +++ b/packages/core/src/billing/usageLimitContent.ts @@ -9,27 +9,11 @@ export interface UsageLimitContent { dismissLabel: string; } -/** - * Maps a legacy bucket-only caller onto a usage-based cause. Null when - * neither a cause nor a bucket is known — e.g. an upstream provider's own - * rate limit — so the modal falls back to generic copy instead of blaming - * the org's billing. - */ -export function deriveUsageLimitCause( - cause: GatewayLimitCause | null, - bucket: "burst" | "sustained" | null, -): GatewayLimitCause | null { - if (cause) return cause; - if (bucket === "burst") return "user_daily_limit"; - if (bucket === "sustained") return "user_monthly_limit"; - return null; -} - export function usageBasedLimitContent(args: { cause: GatewayLimitCause | null; model: string | null; resetLabel: string | null; - /** usage.code_usage_billed — absent means unknown, never free. */ + /** usage.code_usage_billed — absent means unknown, not free. */ billed: boolean | undefined; }): UsageLimitContent { const { cause, model, resetLabel, billed } = args; @@ -62,19 +46,7 @@ export function usageBasedLimitContent(args: { }; } - if (cause === "user_daily_limit" || cause === "user_monthly_limit") { - const period = cause === "user_daily_limit" ? "daily" : "monthly"; - return { - title: `Free ${period} limit reached`, - description: `You've hit the free tier's ${period} usage limit.${ - resetLabel ? ` ${resetLabel}.` : "" - } Add a payment method to your organization for uncapped usage-based access.`, - actionLabel: "Add payment method", - dismissLabel: "Not now", - }; - } - - // No recognizable billing cause (e.g. an upstream provider rate limit): + // Not a billing denial (e.g. an upstream provider's own rate limit) — // don't send the user to billing for something billing can't fix. return { title: "Usage limit reached", diff --git a/packages/core/src/llm-gateway/llm-gateway.ts b/packages/core/src/llm-gateway/llm-gateway.ts index 07ca92d9d6..21acf5569f 100644 --- a/packages/core/src/llm-gateway/llm-gateway.ts +++ b/packages/core/src/llm-gateway/llm-gateway.ts @@ -26,9 +26,7 @@ import { // the cheapest model rather than the gateway default. export const HELPER_GATEWAY_MODEL = "claude-haiku-4-5"; -// When the org isn't billed for Code usage, the gateway 403s models outside -// the free tier (free-tier model gate). Helper calls are invisible plumbing, -// so they run on the free-tier model instead of surfacing the gate. +// Unbilled orgs can only use the free tier; helpers degrade to it silently. export const FREE_TIER_GATEWAY_MODEL = "@cf/zai-org/glm-5.2"; export class LlmGatewayError extends Error { @@ -60,10 +58,8 @@ export class LlmGatewayService { private readonly endpoints: LlmGatewayEndpoints; private readonly log: LlmGatewayLogger; - // Last code_usage_billed seen from the gateway. Usage fetches ride through - // this service and the usage monitor refreshes on LLM activity, so this - // stays warm. null until the gateway has said either way — old gateways - // omit the field entirely. + // Learned from this service's own usage fetches; null until the gateway + // has said either way. private lastKnownCodeUsageBilled: boolean | null = null; async prompt( @@ -84,10 +80,8 @@ export class LlmGatewayService { } = {}, ): Promise { const requested = options.model ?? this.endpoints.defaultModel; - // This service only carries helper workloads — the main agent session - // goes through the SDK, not here. When the org is known to be on the - // free tier, route straight to the free-tier model instead of burning a - // round trip on the model gate's 403. + // Helper-only traffic (the agent session rides the SDK, not this + // service): known-unbilled orgs go straight to the free-tier model. const model = this.lastKnownCodeUsageBilled === false ? FREE_TIER_GATEWAY_MODEL @@ -100,10 +94,8 @@ export class LlmGatewayService { error.statusCode === 403 && classifyGatewayLimitError(error.message) === "model_gate"; if (!isModelGate || model === FREE_TIER_GATEWAY_MODEL) throw error; - // Backstop for a cold start (no usage fetch yet) or a stale billed bit - // (org just lost billing): the gate itself is authoritative that the - // org isn't billed, so remember that and degrade this call instead of - // failing it. + // Cold start / stale bit: the gate is authoritative that the org is + // unbilled — remember it and degrade this call instead of failing. this.lastKnownCodeUsageBilled = false; this.log.warn("Model gated for free tier, retrying on free-tier model", { model, diff --git a/packages/core/src/llm-gateway/schemas.ts b/packages/core/src/llm-gateway/schemas.ts index 64e717974d..51b288fedd 100644 --- a/packages/core/src/llm-gateway/schemas.ts +++ b/packages/core/src/llm-gateway/schemas.ts @@ -55,8 +55,7 @@ export interface AnthropicErrorResponse { type: string; code?: string; }; - // FastAPI access-denial shape: some gateway 403s (e.g. OAuth/product - // checks) carry a bare string `detail` instead of the error envelope. + // Some gateway 403s carry a bare FastAPI `detail` instead of the envelope. detail?: unknown; } diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 7c61cb6b16..578b71bdda 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -2716,10 +2716,8 @@ export class SessionService { const limitCause = classifyGatewayLimitError(errorMessage, errorDetails); - // Gateway billing denials — the free-tier model gate (403) or a usage - // limit (429): the session is healthy, and the fix is switching model, - // adding a payment method, or raising a limit. Surface the upgrade - // gate, not an error state. + // Billing denials from the gateway leave the session healthy — surface + // the upgrade gate, not an error state. if ( limitCause === "model_gate" || isRateLimitError(errorMessage, errorDetails) diff --git a/packages/core/src/usage/schemas.ts b/packages/core/src/usage/schemas.ts index 2e1c36103b..b4136e8fff 100644 --- a/packages/core/src/usage/schemas.ts +++ b/packages/core/src/usage/schemas.ts @@ -11,15 +11,12 @@ export const usageOutput = z.object({ user_id: z.number(), sustained: usageBucketSchema, burst: usageBucketSchema, - // Org credit-bucket state (posthog_code_credits: free allocation used up, - // or the org's billing limit reached). Named ai_credits on the wire; the - // gateway reports it for every credit bucket. + // Org credit bucket (named ai_credits on the wire): free allocation or + // billing limit exhausted. ai_credits: z.object({ exhausted: z.boolean() }).optional(), is_rate_limited: z.boolean(), - // Seat-era plan bit; false for everyone once seats are retired. is_pro: z.boolean(), - // True when the org pays for Code usage (usage-based billing). Absent on - // gateways that predate the field — treat absence as unknown, never as free. + // Absent on older gateways — absence means unknown, never "free". code_usage_billed: z.boolean().optional(), billing_period_end: z.string().datetime().nullable().optional(), }); diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 70c8a664dd..c04200fc1d 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -983,12 +983,8 @@ export type UpgradePromptClickedSurface = | "plan_page_card" | "upgrade_dialog"; -/** Which gateway limit/gate put the prompt on screen (usage-based billing). */ -export type UpgradePromptCause = - | "model_gate" - | "org_limit" - | "user_daily_limit" - | "user_monthly_limit"; +/** Which gateway denial put the prompt on screen (usage-based billing). */ +export type UpgradePromptCause = "model_gate" | "org_limit"; export interface UpgradePromptShownProperties { surface: UpgradePromptShownSurface; diff --git a/packages/shared/src/errors.test.ts b/packages/shared/src/errors.test.ts index 5285deaf16..75cf0522aa 100644 --- a/packages/shared/src/errors.test.ts +++ b/packages/shared/src/errors.test.ts @@ -112,11 +112,6 @@ describe("classifyGatewayLimitError", () => { "Your team has reached its usage limit for this billing period.", "org_limit", ], - ["Rate limit exceeded: User burst rate limit exceeded", "user_daily_limit"], - [ - "Rate limit exceeded: User sustained rate limit exceeded", - "user_monthly_limit", - ], ])("classifies %j as %s", (message, expected) => { expect(classifyGatewayLimitError(message)).toBe(expected); }); @@ -133,6 +128,8 @@ describe("classifyGatewayLimitError", () => { it.each([ "Rate limit exceeded", "Rate limit exceeded: Product rate limit exceeded", + // Per-user valves are a pre-cutover mechanism; generic copy handles them. + "Rate limit exceeded: User burst rate limit exceeded", "Your team has used its monthly PostHog AI credits.", "network down", ])("returns null for %j", (message) => { diff --git a/packages/shared/src/errors.ts b/packages/shared/src/errors.ts index 9d31ca484b..2e4e728425 100644 --- a/packages/shared/src/errors.ts +++ b/packages/shared/src/errors.ts @@ -75,24 +75,16 @@ const RATE_LIMIT_PATTERNS = [ ] as const; /** - * Billing/limit denials from the PostHog LLM gateway (posthog_code product), - * classified from the server's error text. The gateway also stamps - * machine-readable `code`s on the body ("model_gate", "billable_credits", - * "user_cost_burst", "user_cost_sustained") for direct HTTP consumers, but - * agent-SDK surfaces often reduce the body to its message string, so - * classification matches the prose that is always present in that message. - * Kept in sync with services/llm-gateway in posthog/posthog: - * - "model_gate" (403): the org isn't billed for Code usage and requested a - * model outside the free tier — "Model 'X' needs a paid PostHog plan. …". - * - "org_limit" (429): the org's posthog_code_credits bucket is exhausted - * (free allocation used up, or the org's billing limit reached). - * - "user_daily_limit" / "user_monthly_limit" (429): the per-user cost valves. + * Billing denials from the PostHog LLM gateway, matched on the prose in + * `error.message` (agent-SDK surfaces often reduce the body to that string, + * so the gateway's structured `code` can't be relied on here). Kept in sync + * with services/llm-gateway in posthog/posthog: + * - "model_gate" (403): org isn't billed for Code usage and requested a + * model outside the free tier. + * - "org_limit" (429): the org's credit bucket is exhausted — free + * allocation used up, or the org's billing limit reached. */ -export type GatewayLimitCause = - | "model_gate" - | "org_limit" - | "user_daily_limit" - | "user_monthly_limit"; +export type GatewayLimitCause = "model_gate" | "org_limit"; const MODEL_GATE_PATTERNS = ["needs a paid posthog plan"] as const; @@ -102,12 +94,6 @@ const ORG_LIMIT_PATTERNS = [ "reached its usage limit for this billing period", ] as const; -const USER_DAILY_LIMIT_PATTERNS = ["user burst rate limit exceeded"] as const; - -const USER_MONTHLY_LIMIT_PATTERNS = [ - "user sustained rate limit exceeded", -] as const; - const FATAL_SESSION_ERROR_PATTERNS = [ "internal error", "process exited", @@ -177,8 +163,6 @@ export function classifyGatewayLimitError( includesAny(errorMessage, patterns) || includesAny(errorDetails, patterns); if (matches(MODEL_GATE_PATTERNS)) return "model_gate"; if (matches(ORG_LIMIT_PATTERNS)) return "org_limit"; - if (matches(USER_DAILY_LIMIT_PATTERNS)) return "user_daily_limit"; - if (matches(USER_MONTHLY_LIMIT_PATTERNS)) return "user_monthly_limit"; return null; } @@ -198,11 +182,8 @@ export function isFatalSessionError( ): boolean { if (isRateLimitError(errorMessage, errorDetails)) return false; if (isTransientUpstreamError(errorMessage, errorDetails)) return false; - // A free-tier model-gate 403 arrives wrapped as "Internal error: API - // Error: 403 …" — the session is healthy, the fix is switching model or - // adding a payment method, so it must never trigger session teardown. - // (Current gateways also suffix the message "(rate_limit)", caught by the - // rate-limit exclusion above; this covers bodies without that shim.) + // A model-gate 403 arrives wrapped as "Internal error: API Error: 403 …" + // but the session is healthy — it must never trigger teardown. if (classifyGatewayLimitError(errorMessage, errorDetails) === "model_gate") { return false; } diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index 9a60304a63..3411adeaa3 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -1,10 +1,8 @@ export const BILLING_FLAG = "posthog-code-billing"; export const SPEND_ANALYSIS_FLAG = "posthog-code-spend-analysis"; /** - * Usage-based billing (the 2026-07 seat retirement). Swaps the usage-limit - * modal from seat-era Free/Pro copy to cause-aware usage-based copy. Flip - * together with the gateway's LLM_GATEWAY_POSTHOG_CODE_MODEL_GATE_ENABLED - * cutover switch. + * Usage-based billing (the 2026-07 seat retirement). Flip together with the + * gateway's LLM_GATEWAY_POSTHOG_CODE_MODEL_GATE_ENABLED cutover switch. */ export const USAGE_BILLING_FLAG = "posthog-code-usage-billing"; export const EXPERIMENT_SUGGESTIONS_FLAG = diff --git a/packages/ui/src/features/billing/UsageLimitModal.tsx b/packages/ui/src/features/billing/UsageLimitModal.tsx index f1ddfa2370..16ddaff9ba 100644 --- a/packages/ui/src/features/billing/UsageLimitModal.tsx +++ b/packages/ui/src/features/billing/UsageLimitModal.tsx @@ -1,15 +1,11 @@ import { WarningCircle } from "@phosphor-icons/react"; import { formatResetTime } from "@posthog/core/billing/usageDisplay"; import { - deriveUsageLimitCause, seatEraLimitContent, usageBasedLimitContent, } from "@posthog/core/billing/usageLimitContent"; import { USAGE_BILLING_FLAG } from "@posthog/shared"; -import { - ANALYTICS_EVENTS, - type UpgradePromptCause, -} from "@posthog/shared/analytics-events"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { Button, Dialog, Flex, Text } from "@radix-ui/themes"; import { useEffect } from "react"; import { track } from "../../shell/analytics"; @@ -36,13 +32,11 @@ export function UsageLimitModal() { const { isPro: seatIsPro } = useSeat(); const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); - // Whether the org pays for Code usage — picks the org_limit copy variant. + // code_usage_billed picks the org_limit copy variant. const { usage } = useUsage({ enabled: usageBillingEnabled && isOpen }); const isPro = eventIsPro ?? seatIsPro; - const derivedCause = deriveUsageLimitCause(cause, bucket); - const trackedCause: UpgradePromptCause | undefined = - usageBillingEnabled && derivedCause ? derivedCause : undefined; + const trackedCause = usageBillingEnabled ? (cause ?? undefined) : undefined; useEffect(() => { if (isOpen) { @@ -57,7 +51,7 @@ export function UsageLimitModal() { const content = usageBillingEnabled ? usageBasedLimitContent({ - cause: derivedCause, + cause, model, resetLabel, billed: usage?.code_usage_billed, @@ -71,7 +65,6 @@ export function UsageLimitModal() { }); hide(); if (usageBillingEnabled) { - // Payment methods and billing limits live on the PostHog billing page. const billingUrl = getBillingUrl(cloudRegion); if (billingUrl) openExternalUrl(billingUrl); return; @@ -83,8 +76,7 @@ export function UsageLimitModal() { openExternalUrl(SUPPORT_MAILTO); }; - // Seat-era Pro keeps its support escape hatch; usage-based billing has no - // Pro plan (support routes through the billing page instead). + // Seat-era Pro keeps its support escape hatch. const showSupport = !usageBillingEnabled && isPro; return ( diff --git a/packages/ui/src/features/billing/preflightCloudUsage.ts b/packages/ui/src/features/billing/preflightCloudUsage.ts index c4326a3092..4d3e73cf07 100644 --- a/packages/ui/src/features/billing/preflightCloudUsage.ts +++ b/packages/ui/src/features/billing/preflightCloudUsage.ts @@ -17,17 +17,17 @@ import { const log = logger.scope("preflight-cloud-usage"); function usageLimitArgs(usage: UsageOutput): UsageLimitShowArgs { - // Prefer the bucket that's actually exceeded (burst/daily takes priority); - // otherwise fall back to the monthly bucket for the reset hint. If neither - // is flagged, is_rate_limited came from the org's credit bucket (free - // allocation or billing limit exhausted) — name that cause. + // Prefer the bucket that's actually exceeded (burst/daily takes priority); if neither + // is flagged (is_rate_limited via a server-side valve), fall back to the monthly bucket + // so the modal still shows a title and reset time rather than a bare prompt. const bucket: UsageLimitBucket = usage.burst.exceeded ? "burst" : "sustained"; - const orgLimited = !usage.burst.exceeded && !usage.sustained.exceeded; + // Under usage-based billing a cloud-task block is the org's credit bucket; + // the seat-era modal ignores the cause. return { bucket, resetAt: usage[bucket].reset_at, isPro: usage.is_pro, - ...(orgLimited ? { cause: "org_limit" as const } : {}), + cause: "org_limit", }; } diff --git a/packages/ui/src/features/billing/usageLimitStore.ts b/packages/ui/src/features/billing/usageLimitStore.ts index f627c84e1e..477917753a 100644 --- a/packages/ui/src/features/billing/usageLimitStore.ts +++ b/packages/ui/src/features/billing/usageLimitStore.ts @@ -7,12 +7,9 @@ export interface UsageLimitShowArgs { bucket?: UsageLimitBucket; resetAt?: string; isPro?: boolean; - /** - * Which gateway limit/gate tripped (usage-based billing). Drives the - * cause-specific copy; legacy callers that only know the bucket omit it. - */ + /** Which gateway denial tripped (drives the usage-based copy). */ cause?: GatewayLimitCause; - /** The model the free-tier gate blocked, when known (cause "model_gate"). */ + /** The model the gate blocked, when known. */ model?: string; } From f34f84fd932c36a9f00dad47cecd919ea412c484 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 15 Jul 2026 17:39:24 -0400 Subject: [PATCH 4/9] refactor(billing): drop the seat-era modal branch and the usage-billing flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/billing/usageLimitContent.test.ts | 40 ++--------- .../core/src/billing/usageLimitContent.ts | 39 +---------- packages/shared/src/flags.ts | 5 -- .../src/features/billing/UsageLimitModal.tsx | 69 ++++--------------- .../features/billing/billing.contribution.ts | 6 +- .../billing/preflightCloudUsage.test.ts | 19 +++-- .../features/billing/preflightCloudUsage.ts | 31 +++------ .../features/billing/usageLimitStore.test.ts | 14 ++-- .../src/features/billing/usageLimitStore.ts | 12 +--- 9 files changed, 50 insertions(+), 185 deletions(-) diff --git a/packages/core/src/billing/usageLimitContent.test.ts b/packages/core/src/billing/usageLimitContent.test.ts index ed0a154e87..2cd8a6fd65 100644 --- a/packages/core/src/billing/usageLimitContent.test.ts +++ b/packages/core/src/billing/usageLimitContent.test.ts @@ -1,12 +1,9 @@ import { describe, expect, it } from "vitest"; -import { - seatEraLimitContent, - usageBasedLimitContent, -} from "./usageLimitContent"; +import { usageLimitContent } from "./usageLimitContent"; -describe("usageBasedLimitContent", () => { +describe("usageLimitContent", () => { it("names the gated model and offers a payment method", () => { - const content = usageBasedLimitContent({ + const content = usageLimitContent({ cause: "model_gate", model: "Claude Opus 4.8", resetLabel: null, @@ -18,7 +15,7 @@ describe("usageBasedLimitContent", () => { }); it("falls back to generic wording when the gated model is unknown", () => { - const content = usageBasedLimitContent({ + const content = usageLimitContent({ cause: "model_gate", model: null, resetLabel: null, @@ -37,7 +34,7 @@ describe("usageBasedLimitContent", () => { ] as const)( "org_limit with billed=%s -> %s / %s", (billed, title, actionLabel) => { - const content = usageBasedLimitContent({ + const content = usageLimitContent({ cause: "org_limit", model: null, resetLabel: null, @@ -49,7 +46,7 @@ describe("usageBasedLimitContent", () => { ); it("renders generic copy without a billing CTA when the cause is unknown", () => { - const content = usageBasedLimitContent({ + const content = usageLimitContent({ cause: null, model: null, resetLabel: "Resets in 2h", @@ -61,28 +58,3 @@ describe("usageBasedLimitContent", () => { expect(content.dismissLabel).toBe("Got it"); }); }); - -describe("seatEraLimitContent", () => { - it("keeps the Pro cap copy with no upgrade action", () => { - const content = seatEraLimitContent({ - bucket: "sustained", - isPro: true, - resetLabel: "Resets in 3d", - }); - expect(content.title).toBe("Monthly limit reached"); - expect(content.description).toContain("monthly usage cap"); - expect(content.actionLabel).toBeNull(); - expect(content.dismissLabel).toBe("Got it"); - }); - - it("keeps the free-plan upgrade pitch", () => { - const content = seatEraLimitContent({ - bucket: "burst", - isPro: false, - resetLabel: null, - }); - expect(content.title).toBe("Daily limit reached"); - expect(content.description).toContain("Upgrade to Pro for 40×"); - expect(content.actionLabel).toBe("See Pro"); - }); -}); diff --git a/packages/core/src/billing/usageLimitContent.ts b/packages/core/src/billing/usageLimitContent.ts index cede164409..e32e6005e4 100644 --- a/packages/core/src/billing/usageLimitContent.ts +++ b/packages/core/src/billing/usageLimitContent.ts @@ -1,5 +1,4 @@ import type { GatewayLimitCause } from "@posthog/shared"; -import { PRO_USAGE_MULTIPLIER } from "./usageDisplay"; export interface UsageLimitContent { title: string; @@ -9,7 +8,7 @@ export interface UsageLimitContent { dismissLabel: string; } -export function usageBasedLimitContent(args: { +export function usageLimitContent(args: { cause: GatewayLimitCause | null; model: string | null; resetLabel: string | null; @@ -57,39 +56,3 @@ export function usageBasedLimitContent(args: { dismissLabel: "Got it", }; } - -export function seatEraLimitContent(args: { - bucket: "burst" | "sustained" | null; - isPro: boolean; - resetLabel: string | null; -}): UsageLimitContent { - const { bucket, isPro, resetLabel } = args; - const isDaily = bucket === "burst"; - const isMonthly = bucket === "sustained"; - - const title = isDaily - ? "Daily limit reached" - : isMonthly && !isPro - ? "You're out of usage for this month" - : isMonthly - ? "Monthly limit reached" - : "Usage limit reached"; - - const proCapLabel = isDaily - ? "a daily usage cap" - : isMonthly - ? "a monthly usage cap" - : "usage caps"; - const description = isPro - ? `Your Pro plan has ${proCapLabel}.${resetLabel ? ` ${resetLabel}.` : ""}` - : `You've hit your Free ${ - isDaily ? "daily" : isMonthly ? "monthly" : "usage" - } limit. Upgrade to Pro for ${PRO_USAGE_MULTIPLIER}× more usage.`; - - return { - title, - description, - actionLabel: isPro ? null : "See Pro", - dismissLabel: isPro ? "Got it" : "Not now", - }; -} diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index 3411adeaa3..7f1587d962 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -1,10 +1,5 @@ export const BILLING_FLAG = "posthog-code-billing"; export const SPEND_ANALYSIS_FLAG = "posthog-code-spend-analysis"; -/** - * Usage-based billing (the 2026-07 seat retirement). Flip together with the - * gateway's LLM_GATEWAY_POSTHOG_CODE_MODEL_GATE_ENABLED cutover switch. - */ -export const USAGE_BILLING_FLAG = "posthog-code-usage-billing"; export const EXPERIMENT_SUGGESTIONS_FLAG = "posthog-code-experiment-suggestions"; export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; diff --git a/packages/ui/src/features/billing/UsageLimitModal.tsx b/packages/ui/src/features/billing/UsageLimitModal.tsx index 16ddaff9ba..319acca7db 100644 --- a/packages/ui/src/features/billing/UsageLimitModal.tsx +++ b/packages/ui/src/features/billing/UsageLimitModal.tsx @@ -1,10 +1,6 @@ import { WarningCircle } from "@phosphor-icons/react"; import { formatResetTime } from "@posthog/core/billing/usageDisplay"; -import { - seatEraLimitContent, - usageBasedLimitContent, -} from "@posthog/core/billing/usageLimitContent"; -import { USAGE_BILLING_FLAG } from "@posthog/shared"; +import { usageLimitContent } from "@posthog/core/billing/usageLimitContent"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { Button, Dialog, Flex, Text } from "@radix-ui/themes"; import { useEffect } from "react"; @@ -12,73 +8,45 @@ import { track } from "../../shell/analytics"; import { openExternalUrl } from "../../shell/openExternal"; import { getBillingUrl } from "../../utils/urls"; import { useAuthStateValue } from "../auth/store"; -import { useFeatureFlag } from "../feature-flags/useFeatureFlag"; -import { openSettings } from "../settings/hooks/useOpenSettings"; import { useUsageLimitStore } from "./usageLimitStore"; -import { useSeat } from "./useSeat"; import { useUsage } from "./useUsage"; -const SUPPORT_MAILTO = - "mailto:charles@posthog.com?subject=PostHog%20Code%20%E2%80%94%20Pro%20usage%20limit"; - export function UsageLimitModal() { const isOpen = useUsageLimitStore((s) => s.isOpen); - const bucket = useUsageLimitStore((s) => s.bucket); const resetAt = useUsageLimitStore((s) => s.resetAt); - const eventIsPro = useUsageLimitStore((s) => s.isPro); const cause = useUsageLimitStore((s) => s.cause); const model = useUsageLimitStore((s) => s.model); const hide = useUsageLimitStore((s) => s.hide); - const { isPro: seatIsPro } = useSeat(); - const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); // code_usage_billed picks the org_limit copy variant. - const { usage } = useUsage({ enabled: usageBillingEnabled && isOpen }); - const isPro = eventIsPro ?? seatIsPro; - - const trackedCause = usageBillingEnabled ? (cause ?? undefined) : undefined; + const { usage } = useUsage({ enabled: isOpen }); useEffect(() => { if (isOpen) { track(ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN, { surface: "usage_limit_modal", - ...(trackedCause ? { cause: trackedCause } : {}), + ...(cause ? { cause } : {}), }); } - }, [isOpen, trackedCause]); - - const resetLabel = resetAt ? formatResetTime(resetAt) : null; + }, [isOpen, cause]); - const content = usageBillingEnabled - ? usageBasedLimitContent({ - cause, - model, - resetLabel, - billed: usage?.code_usage_billed, - }) - : seatEraLimitContent({ bucket, isPro, resetLabel }); + const content = usageLimitContent({ + cause, + model, + resetLabel: resetAt ? formatResetTime(resetAt) : null, + billed: usage?.code_usage_billed, + }); const handleAction = () => { track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface: "usage_limit_modal", - ...(trackedCause ? { cause: trackedCause } : {}), + ...(cause ? { cause } : {}), }); hide(); - if (usageBillingEnabled) { - const billingUrl = getBillingUrl(cloudRegion); - if (billingUrl) openExternalUrl(billingUrl); - return; - } - openSettings("plan-usage"); + const billingUrl = getBillingUrl(cloudRegion); + if (billingUrl) openExternalUrl(billingUrl); }; - const handleSupport = () => { - openExternalUrl(SUPPORT_MAILTO); - }; - - // Seat-era Pro keeps its support escape hatch. - const showSupport = !usageBillingEnabled && isPro; - return ( - {showSupport && ( - - )}