diff --git a/packages/core/src/billing/usageLimitContent.test.ts b/packages/core/src/billing/usageLimitContent.test.ts new file mode 100644 index 0000000000..ba884c0d82 --- /dev/null +++ b/packages/core/src/billing/usageLimitContent.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { usageLimitContent } from "./usageLimitContent"; + +describe("usageLimitContent", () => { + it("offers a payment method for the model gate", () => { + const content = usageLimitContent({ + cause: "model_gate", + resetLabel: null, + subscribed: false, + }); + expect(content.title).toBe("Unlock premium models"); + expect(content.description).toContain("This model isn't"); + expect(content.actionLabel).toBe("Add payment method"); + }); + + it.each([ + // Confirmed-free org: allocation used up, the fix is adding a card. + [false, "Free usage used up", "Add payment method"], + // Subscribed org: the fix is raising the spend limit. + [true, "Organization usage limit reached", "Manage billing"], + // Unknown subscription state must not read as free. + [undefined, "Organization usage limit reached", "Manage billing"], + ] as const)( + "org_limit with subscribed=%s -> %s / %s", + (subscribed, title, actionLabel) => { + const content = usageLimitContent({ + cause: "org_limit", + resetLabel: null, + subscribed, + }); + expect(content.title).toBe(title); + expect(content.actionLabel).toBe(actionLabel); + }, + ); + + it("includes the reset hint in the free-tier copy when available", () => { + const content = usageLimitContent({ + cause: "org_limit", + resetLabel: "Resets in 3h", + subscribed: false, + }); + expect(content.title).toBe("Free usage used up"); + expect(content.description).toContain("Resets in 3h"); + }); + + it("renders generic copy without a billing CTA when the cause is unknown", () => { + const content = usageLimitContent({ + cause: null, + resetLabel: "Resets in 2h", + subscribed: 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 new file mode 100644 index 0000000000..433808d6fd --- /dev/null +++ b/packages/core/src/billing/usageLimitContent.ts @@ -0,0 +1,57 @@ +import type { GatewayLimitCause } from "@posthog/shared"; + +export interface UsageLimitContent { + title: string; + description: string; + actionLabel: string | null; + dismissLabel: string; +} + +export function usageLimitContent(args: { + cause: GatewayLimitCause | null; + resetLabel: string | null; + subscribed: boolean | undefined; +}): UsageLimitContent { + const { cause, resetLabel, subscribed } = args; + + if (cause === "model_gate") { + return { + title: "Unlock premium models", + description: + "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 (subscribed === false) { + return { + title: "Free usage used up", + description: `Your organization has used up its included PostHog Code usage.${ + resetLabel ? ` ${resetLabel}.` : "" + } 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", + }; + } + + // 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", + description: `PostHog Code hit a usage limit.${ + resetLabel ? ` ${resetLabel}.` : "" + } Please try again shortly.`, + actionLabel: null, + dismissLabel: "Got it", + }; +} diff --git a/packages/core/src/llm-gateway/llm-gateway.test.ts b/packages/core/src/llm-gateway/llm-gateway.test.ts index 75cd555392..b87f281b5e 100644 --- a/packages/core/src/llm-gateway/llm-gateway.test.ts +++ b/packages/core/src/llm-gateway/llm-gateway.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthService } from "../auth/auth"; import type { LlmGatewayAuth, LlmGatewayEndpoints, @@ -43,8 +44,25 @@ function createService( }; const logger = { ...log, scope: () => log }; - const service = new LlmGatewayService(host, logger); - return { service, auth, endpoints, log }; + const orgListeners: Array<(state: { currentOrgId: string | null }) => void> = + []; + const authService = { + getState: () => ({ currentOrgId: "org-1" }), + on: ( + _event: string, + listener: (state: { currentOrgId: string | null }) => void, + ) => { + orgListeners.push(listener); + }, + } as unknown as AuthService; + const emitAuthState = (currentOrgId: string | null) => { + for (const listener of orgListeners) { + listener({ currentOrgId }); + } + }; + + const service = new LlmGatewayService(host, logger, authService); + return { service, auth, endpoints, log, emitAuthState }; } const SUCCESS_BODY = { @@ -157,6 +175,137 @@ 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 unsubscribed", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(createJsonResponse(MODEL_GATE_BODY, 403)) + .mockImplementation(async () => createJsonResponse(SUCCESS_BODY)); + const { service } = createService(fetchMock); + + // First call learns "unsubscribed" 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("forgets the learned subscription state when the organization changes", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(createJsonResponse(MODEL_GATE_BODY, 403)) + .mockImplementation(async () => createJsonResponse(SUCCESS_BODY)); + const { service, emitAuthState } = createService(fetchMock); + + await service.prompt([{ role: "user", content: "hi" }], { + model: "claude-haiku-4-5", + }); + emitAuthState("org-2"); + await service.prompt([{ role: "user", content: "hi" }], { + model: "claude-haiku-4-5", + }); + + const bodyAfterSwitch = JSON.parse( + fetchMock.mock.calls[2][1].body as string, + ); + expect(bodyAfterSwitch.model).toBe("claude-haiku-4-5"); + }); + + it("keeps the learned subscription state across same-org auth changes", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(createJsonResponse(MODEL_GATE_BODY, 403)) + .mockImplementation(async () => createJsonResponse(SUCCESS_BODY)); + const { service, emitAuthState } = createService(fetchMock); + + await service.prompt([{ role: "user", content: "hi" }], { + model: "claude-haiku-4-5", + }); + emitAuthState("org-1"); + await service.prompt([{ role: "user", content: "hi" }], { + model: "claude-haiku-4-5", + }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + const bodyAfterRefresh = JSON.parse( + fetchMock.mock.calls[2][1].body as string, + ); + expect(bodyAfterRefresh.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 +363,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_subscribed: true, + }), + ); + const { service } = createService(fetchMock); + + const usage = await service.fetchUsage(); + + expect(usage.ai_credits?.exhausted).toBe(true); + expect(usage.code_usage_subscribed).toBe(true); + }); + + it("feeds code_usage_subscribed into helper model routing", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + createJsonResponse({ ...USAGE_BODY, code_usage_subscribed: 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..b3f011c6f2 100644 --- a/packages/core/src/llm-gateway/llm-gateway.ts +++ b/packages/core/src/llm-gateway/llm-gateway.ts @@ -1,9 +1,13 @@ import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; +import { classifyGatewayLimitError } from "@posthog/shared"; import { buildPosthogPropertyHeaderRecord, type PosthogProperties, } from "@posthog/shared/posthog-property-headers"; import { inject, injectable } from "inversify"; +import type { AuthService } from "../auth/auth"; +import { AUTH_SERVICE } from "../auth/auth.module"; +import { AuthServiceEvent } from "../auth/schemas"; import { LLM_GATEWAY_HOST, type LlmGatewayAuth, @@ -25,6 +29,8 @@ import { // the cheapest model rather than the gateway default. export const HELPER_GATEWAY_MODEL = "claude-haiku-4-5"; +export const FREE_TIER_GATEWAY_MODEL = "@cf/zai-org/glm-5.2"; + export class LlmGatewayError extends Error { constructor( message: string, @@ -44,16 +50,26 @@ export class LlmGatewayService { host: LlmGatewayHost, @inject(ROOT_LOGGER) logger: RootLogger, + @inject(AUTH_SERVICE) + authService: AuthService, ) { this.auth = host; this.endpoints = host; this.log = logger.scope("llm-gateway"); + let orgId = authService.getState().currentOrgId; + authService.on(AuthServiceEvent.StateChanged, (state) => { + if (state.currentOrgId === orgId) return; + orgId = state.currentOrgId; + this.lastKnownCodeUsageSubscribed = null; + }); } private readonly auth: LlmGatewayAuth; private readonly endpoints: LlmGatewayEndpoints; private readonly log: LlmGatewayLogger; + private lastKnownCodeUsageSubscribed: boolean | null = null; + async prompt( messages: LlmMessage[], options: { @@ -70,11 +86,47 @@ export class LlmGatewayService { */ posthogProperties?: PosthogProperties; } = {}, + ): Promise { + const requested = options.model ?? this.endpoints.defaultModel; + const model = + this.lastKnownCodeUsageSubscribed === 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; + this.lastKnownCodeUsageSubscribed = 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 +206,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 +278,11 @@ export class LlmGatewayService { ); } - return usageOutput.parse(await response.json()); + const usage = usageOutput.parse(await response.json()); + if (usage.code_usage_subscribed !== undefined) { + this.lastKnownCodeUsageSubscribed = usage.code_usage_subscribed; + } + 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..b22947bd19 100644 --- a/packages/core/src/llm-gateway/schemas.ts +++ b/packages/core/src/llm-gateway/schemas.ts @@ -50,11 +50,12 @@ export interface AnthropicMessagesResponse { } export interface AnthropicErrorResponse { - error: { + error?: { message: string; type: string; code?: string; }; + 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..5536bb99ad 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -14,6 +14,7 @@ import { type Adapter, type AgentSession, type CloudRegion, + classifyGatewayLimitError, type ExecutionMode, flattenSelectOptions, getBackoffDelay, @@ -2712,15 +2713,18 @@ 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); + + if (limitCause !== null || 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 ? { cause: limitCause } : undefined); return { stopReason: "rate_limited" }; } diff --git a/packages/core/src/sessions/sessionServicePromptRecovery.test.ts b/packages/core/src/sessions/sessionServicePromptRecovery.test.ts index 1549401663..927dbe61df 100644 --- a/packages/core/src/sessions/sessionServicePromptRecovery.test.ts +++ b/packages/core/src/sessions/sessionServicePromptRecovery.test.ts @@ -43,6 +43,7 @@ function createHarness() { clearOptimisticItems: vi.fn(), }; const promptMutate = vi.fn(); + const usageLimitShow = vi.fn(); const deps = { store, h: { extractSkillButtonId: () => undefined }, @@ -51,7 +52,7 @@ function createHarness() { track: vi.fn(), getIsOnline: () => true, addDirectoryDialog: { open: false }, - usageLimit: { show: vi.fn() }, + usageLimit: { show: usageLimitShow }, trpc: { agent: { prompt: { mutate: promptMutate }, @@ -74,7 +75,7 @@ function createHarness() { }, "tryAutoRecoverLocalSession", ); - return { service, sessions, store, promptMutate, recoverSpy }; + return { service, sessions, store, promptMutate, recoverSpy, usageLimitShow }; } type RecoverSpy = ReturnType["recoverSpy"]; @@ -131,3 +132,51 @@ describe("SessionService prompt recovery on fatal session errors", () => { }, ); }); + +describe("SessionService gateway billing denials", () => { + it.each([ + { + case: "a model-gate 403", + message: + 'Internal error: API Error: 403 {"error":{"message":"Model \'claude-fable-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"}}', + expectedShow: { cause: "model_gate" }, + }, + { + case: "an org-limit 429", + message: + "Rate limit exceeded: Your team has reached its PostHog Code usage limit for this billing period.", + expectedShow: { cause: "org_limit" }, + }, + // The classified cause alone must open the modal — the org-limit prose + // is not guaranteed to carry generic rate-limit wording. + { + case: "an org-limit message without rate-limit wording", + message: + "Your team has reached its PostHog Code usage limit for this billing period.", + expectedShow: { cause: "org_limit" }, + }, + { + case: "a free-tier valve 429", + message: "Rate limit exceeded: User burst rate limit exceeded", + expectedShow: { cause: "org_limit" }, + }, + { + case: "an unclassified rate limit", + message: "[429] Too many requests", + expectedShow: undefined, + }, + ])( + "shows the usage-limit modal and stops the prompt for $case", + async ({ message, expectedShow }) => { + const { service, promptMutate, usageLimitShow, recoverSpy } = + createHarness(); + promptMutate.mockRejectedValue(new Error(message)); + + const result = await service.sendPrompt(TASK_ID, "hello"); + + expect(result).toEqual({ stopReason: "rate_limited" }); + expect(usageLimitShow).toHaveBeenCalledWith(expectedShow); + expect(recoverSpy).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/core/src/usage/schemas.ts b/packages/core/src/usage/schemas.ts index 7ad2c1db8e..905511bfaf 100644 --- a/packages/core/src/usage/schemas.ts +++ b/packages/core/src/usage/schemas.ts @@ -11,8 +11,10 @@ export const usageOutput = z.object({ user_id: z.number(), sustained: usageBucketSchema, burst: usageBucketSchema, + ai_credits: z.object({ exhausted: z.boolean() }).optional(), is_rate_limited: z.boolean(), is_pro: z.boolean(), + code_usage_subscribed: 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..52725b417b 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -983,12 +983,16 @@ export type UpgradePromptClickedSurface = | "plan_page_card" | "upgrade_dialog"; +export type UpgradePromptCause = "model_gate" | "org_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..05b39ca57f 100644 --- a/packages/shared/src/errors.test.ts +++ b/packages/shared/src/errors.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + classifyGatewayLimitError, getErrorMessage, isAuthError, isFatalSessionError, @@ -84,6 +85,62 @@ 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", + ], + [ + // Per-user free valves fire only for unsubscribed orgs; the modal's + // subscribed bit picks the free-tier copy. + "Rate limit exceeded: User burst rate limit exceeded", + "org_limit", + ], + ["Rate limit exceeded: User sustained rate limit exceeded", "org_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("isFatalSessionError", () => { it.each([ "internal error", @@ -123,6 +180,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..8a0508a832 100644 --- a/packages/shared/src/errors.ts +++ b/packages/shared/src/errors.ts @@ -74,6 +74,19 @@ const RATE_LIMIT_PATTERNS = [ "[429]", ] as const; +export type GatewayLimitCause = "model_gate" | "org_limit"; + +const MODEL_GATE_PATTERNS = ["needs a paid posthog plan"] as const; + +const ORG_LIMIT_PATTERNS = [ + "reached its posthog code usage limit", + "reached its usage limit for this billing period", + // Per-user free valves — billed orgs have none, so these always mean the + // free tier is used up. + "user burst rate limit exceeded", + "user sustained rate limit exceeded", +] as const; + const FATAL_SESSION_ERROR_PATTERNS = [ "internal error", "process exited", @@ -121,6 +134,17 @@ export function isRateLimitError( ); } +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"; + return null; +} + export function isTransientUpstreamError( errorMessage: string, errorDetails?: string, @@ -137,6 +161,9 @@ export function isFatalSessionError( ): boolean { if (isRateLimitError(errorMessage, errorDetails)) return false; if (isTransientUpstreamError(errorMessage, errorDetails)) return false; + 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/index.ts b/packages/shared/src/index.ts index 88873fd0b8..203c46790c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -72,6 +72,8 @@ export { export type { SignalReportPriority, Task } from "./domain-types"; export * from "./enrichment"; export { + classifyGatewayLimitError, + 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..2f25a5451a 100644 --- a/packages/ui/src/features/billing/UsageLimitModal.tsx +++ b/packages/ui/src/features/billing/UsageLimitModal.tsx @@ -1,72 +1,49 @@ import { WarningCircle } from "@phosphor-icons/react"; -import { - formatResetTime, - PRO_USAGE_MULTIPLIER, -} from "@posthog/core/billing/usageDisplay"; +import { formatResetTime } from "@posthog/core/billing/usageDisplay"; +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"; import { track } from "../../shell/analytics"; import { openExternalUrl } from "../../shell/openExternal"; -import { openSettings } from "../settings/hooks/useOpenSettings"; +import { getBillingUrl } from "../../utils/urls"; +import { useAuthStateValue } from "../auth/store"; import { useUsageLimitStore } from "./usageLimitStore"; -import { useSeat } from "./useSeat"; - -const SUPPORT_MAILTO = - "mailto:charles@posthog.com?subject=PostHog%20Code%20%E2%80%94%20Pro%20usage%20limit"; +import { useUsage } from "./useUsage"; 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 hide = useUsageLimitStore((s) => s.hide); - const { isPro: seatIsPro } = useSeat(); - const isPro = eventIsPro ?? seatIsPro; + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const { usage } = useUsage({ enabled: isOpen }); useEffect(() => { if (isOpen) { track(ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN, { surface: "usage_limit_modal", + ...(cause ? { cause } : {}), }); } - }, [isOpen]); + }, [isOpen, cause]); + + const content = usageLimitContent({ + cause, + resetLabel: resetAt ? formatResetTime(resetAt) : null, + subscribed: usage?.code_usage_subscribed, + }); - const handleUpgrade = () => { + const handleAction = () => { track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface: "usage_limit_modal", + ...(cause ? { cause } : {}), }); hide(); - openSettings("plan-usage"); - }; - - const handleSupport = () => { - openExternalUrl(SUPPORT_MAILTO); + const billingUrl = getBillingUrl(cloudRegion); + if (billingUrl) openExternalUrl(billingUrl); }; - 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.`; - return ( - {title} + {content.title} - {description} + {content.description} - {isPro ? ( - <> - - - - ) : ( - <> - - - + + {content.actionLabel && ( + )} diff --git a/packages/ui/src/features/billing/billing.contribution.ts b/packages/ui/src/features/billing/billing.contribution.ts index 4bda49d8bd..1ab8fb3bbf 100644 --- a/packages/ui/src/features/billing/billing.contribution.ts +++ b/packages/ui/src/features/billing/billing.contribution.ts @@ -30,11 +30,7 @@ export class BillingContribution implements Contribution { if (event.threshold === 100) { if (event.userIsActive) { - useUsageLimitStore.getState().show({ - bucket: event.bucket, - resetAt: event.resetAt, - isPro: event.isPro, - }); + useUsageLimitStore.getState().show({ resetAt: event.resetAt }); return; } toast.error("Usage limit reached", { diff --git a/packages/ui/src/features/billing/preflightCloudUsage.test.ts b/packages/ui/src/features/billing/preflightCloudUsage.test.ts index 37859843ab..402a1779b1 100644 --- a/packages/ui/src/features/billing/preflightCloudUsage.test.ts +++ b/packages/ui/src/features/billing/preflightCloudUsage.test.ts @@ -65,9 +65,8 @@ interface Case { available: boolean; modal: { isOpen: boolean; - bucket?: "burst" | "sustained" | null; + cause?: "model_gate" | "org_limit" | null; resetAt?: string | null; - isPro?: boolean | null; }; trackPayload?: { bucket: "burst" | "sustained" | null; is_pro: boolean }; } @@ -80,7 +79,7 @@ const cases: Case[] = [ modal: { isOpen: false }, }, { - name: "blocks and shows the burst modal when the daily limit is exceeded", + name: "blocks with the daily reset hint when the burst bucket is exceeded", arrange: () => refresh.mockResolvedValue( makeUsage({ burst: true, isRateLimited: true, isPro: true }), @@ -88,9 +87,8 @@ const cases: Case[] = [ available: false, modal: { isOpen: true, - bucket: "burst", + cause: "org_limit", resetAt: "2026-05-01T12:10:00.000Z", - isPro: true, }, trackPayload: { bucket: "burst", is_pro: true }, }, @@ -101,20 +99,20 @@ const cases: Case[] = [ getLatest.mockResolvedValue(makeUsage({ sustained: true })); }, available: false, - modal: { isOpen: true, bucket: "sustained" }, + modal: { isOpen: true, cause: "org_limit" }, trackPayload: { bucket: "sustained", is_pro: false }, }, { - name: "falls back to the monthly bucket when only is_rate_limited is set", + name: "falls back to the monthly reset hint when only is_rate_limited is set", arrange: () => refresh.mockResolvedValue(makeUsage({ isRateLimited: true })), available: false, modal: { isOpen: true, - bucket: "sustained", + cause: "org_limit", resetAt: "2026-05-01T13:00:00.000Z", }, - trackPayload: { bucket: "sustained", is_pro: false }, + trackPayload: { bucket: null, is_pro: false }, }, { name: "fails open (allows creation) when usage cannot be fetched", @@ -144,10 +142,9 @@ describe("assertCloudUsageAvailable", () => { const state = useUsageLimitStore.getState(); expect(state.isOpen).toBe(modal.isOpen); - if (modal.bucket !== undefined) expect(state.bucket).toBe(modal.bucket); + if (modal.cause !== undefined) expect(state.cause).toBe(modal.cause); if (modal.resetAt !== undefined) expect(state.resetAt).toBe(modal.resetAt); - if (modal.isPro !== undefined) expect(state.isPro).toBe(modal.isPro); if (trackPayload) { expect(track).toHaveBeenCalledWith( diff --git a/packages/ui/src/features/billing/preflightCloudUsage.ts b/packages/ui/src/features/billing/preflightCloudUsage.ts index a12258ee92..1fe3b1f7c9 100644 --- a/packages/ui/src/features/billing/preflightCloudUsage.ts +++ b/packages/ui/src/features/billing/preflightCloudUsage.ts @@ -8,20 +8,15 @@ 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 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. - const bucket: UsageLimitBucket = usage.burst.exceeded ? "burst" : "sustained"; - return { bucket, resetAt: usage[bucket].reset_at, isPro: usage.is_pro }; +function usageLimitArgs(usage: UsageOutput): UsageLimitShowArgs { + // Reset hint from whichever bucket is exceeded (daily takes priority); + // the monthly bucket covers an org-bucket block, whose period it matches. + const bucket = usage.burst.exceeded ? "burst" : "sustained"; + return { resetAt: usage[bucket].reset_at, cause: "org_limit" }; } async function fetchUsageSnapshot(): Promise { @@ -50,12 +45,15 @@ async function fetchUsageSnapshot(): Promise { export async function assertCloudUsageAvailable(): Promise { const usage = await fetchUsageSnapshot(); if (usage && isUsageExceeded(usage)) { - const args = usageLimitArgs(usage); track(ANALYTICS_EVENTS.CLOUD_TASK_USAGE_BLOCKED, { - bucket: args.bucket, + bucket: usage.burst.exceeded + ? "burst" + : usage.sustained.exceeded + ? "sustained" + : null, is_pro: usage.is_pro, }); - useUsageLimitStore.getState().show(args); + useUsageLimitStore.getState().show(usageLimitArgs(usage)); return false; } return true; diff --git a/packages/ui/src/features/billing/usageLimitStore.test.ts b/packages/ui/src/features/billing/usageLimitStore.test.ts index f09d8821f3..bd6b440b84 100644 --- a/packages/ui/src/features/billing/usageLimitStore.test.ts +++ b/packages/ui/src/features/billing/usageLimitStore.test.ts @@ -5,8 +5,8 @@ describe("usageLimitStore", () => { beforeEach(() => { useUsageLimitStore.setState({ isOpen: false, - bucket: null, resetAt: null, + cause: null, }); }); @@ -19,19 +19,19 @@ describe("usageLimitStore", () => { useUsageLimitStore.getState().show(); const state = useUsageLimitStore.getState(); expect(state.isOpen).toBe(true); - expect(state.bucket).toBeNull(); expect(state.resetAt).toBeNull(); + expect(state.cause).toBeNull(); }); - it("show stores bucket and resetAt when provided", () => { + it("show stores the denial context when provided", () => { useUsageLimitStore.getState().show({ - bucket: "burst", resetAt: "2026-01-02T03:04:05Z", + cause: "model_gate", }); const state = useUsageLimitStore.getState(); expect(state.isOpen).toBe(true); - expect(state.bucket).toBe("burst"); expect(state.resetAt).toBe("2026-01-02T03:04:05Z"); + expect(state.cause).toBe("model_gate"); }); it("hide closes the modal", () => { diff --git a/packages/ui/src/features/billing/usageLimitStore.ts b/packages/ui/src/features/billing/usageLimitStore.ts index f1c2ca114c..b4756762f6 100644 --- a/packages/ui/src/features/billing/usageLimitStore.ts +++ b/packages/ui/src/features/billing/usageLimitStore.ts @@ -1,20 +1,19 @@ +import type { GatewayLimitCause } from "@posthog/shared"; import { create } from "zustand"; -export type UsageLimitBucket = "burst" | "sustained"; +export interface UsageLimitShowArgs { + resetAt?: string; + cause?: GatewayLimitCause; +} interface UsageLimitState { isOpen: boolean; - bucket: UsageLimitBucket | null; resetAt: string | null; - isPro: boolean | null; + cause: GatewayLimitCause | null; } interface UsageLimitActions { - show: (args?: { - bucket: UsageLimitBucket; - resetAt: string; - isPro?: boolean; - }) => void; + show: (args?: UsageLimitShowArgs) => void; hide: () => void; } @@ -22,16 +21,14 @@ type UsageLimitStore = UsageLimitState & UsageLimitActions; export const useUsageLimitStore = create()((set) => ({ isOpen: false, - bucket: null, resetAt: null, - isPro: null, + cause: null, show: (args) => set({ isOpen: true, - bucket: args?.bucket ?? null, resetAt: args?.resetAt ?? null, - isPro: args?.isPro ?? null, + cause: args?.cause ?? null, }), hide: () => set({ isOpen: false }), }));