From e6a20620630390cc2e070314ba63095e2448afe6 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 15 Jul 2026 15:24:55 -0400 Subject: [PATCH 1/4] feat(billing): wire usage-based billing into the gateway/model layer Classify the gateway's usage-based billing denials (free-tier model gate 403, org credit-bucket 429, per-user valve 429) in shared error helpers and route sessions to the cause-aware upgrade gate instead of error states. Authenticate every gateway models fetch and honor the new allowed/restriction_reason marks: adapters annotate restricted models on session config options, never auto-select a restricted model as session default, and the cloud harness drops restricted models and falls back to an allowed default. Helper prompts route to the free-tier model proactively off the usage endpoint's code_usage_billed bit (403 retry as staleness backstop). Generated-By: PostHog Code Task-Id: bfbcd747-a365-429f-a5dc-622e2c2f7edf --- packages/agent/src/adapters/acp-connection.ts | 8 ++ packages/agent/src/adapters/base-acp-agent.ts | 28 ++++- .../agent/src/adapters/claude/claude-agent.ts | 1 + .../codex-app-server-agent.ts | 3 + .../codex-app-server/session-config.ts | 24 +++- packages/agent/src/agent.ts | 32 +++-- packages/agent/src/gateway-models.test.ts | 5 + packages/agent/src/gateway-models.ts | 83 ++++++++++++- packages/agent/src/server/agent-server.ts | 3 + .../core/src/llm-gateway/llm-gateway.test.ts | 117 ++++++++++++++++++ packages/core/src/llm-gateway/llm-gateway.ts | 66 +++++++++- packages/core/src/llm-gateway/schemas.ts | 5 +- packages/core/src/sessions/sessionService.ts | 24 +++- packages/core/src/usage/schemas.ts | 8 ++ .../src/extensions/posthog-provider/models.ts | 24 +++- .../extensions/posthog-provider/provider.ts | 2 +- packages/harness/src/session.ts | 19 +-- packages/shared/src/analytics-events.ts | 25 +++- packages/shared/src/errors.test.ts | 53 ++++++++ packages/shared/src/errors.ts | 65 ++++++++++ packages/shared/src/flags.ts | 7 ++ packages/shared/src/index.ts | 10 +- packages/shared/src/models.ts | 18 +++ .../src/services/agent/agent.ts | 35 ++++-- .../src/services/agent/auth-adapter.ts | 13 ++ 25 files changed, 628 insertions(+), 50 deletions(-) diff --git a/packages/agent/src/adapters/acp-connection.ts b/packages/agent/src/adapters/acp-connection.ts index 3f76a774fb..03987f5bae 100644 --- a/packages/agent/src/adapters/acp-connection.ts +++ b/packages/agent/src/adapters/acp-connection.ts @@ -25,6 +25,13 @@ export type AcpConnectionConfig = { processCallbacks?: ProcessSpawnedCallback; codexOptions?: CodexOptions; allowedModelIds?: Set; + /** + * Models outside the org's plan (the gateway's `allowed: false` marks). + * Pickers render these locked behind an upgrade gate instead of omitting + * them. Codex-only: the Claude adapter reads marks off its own authed + * models fetch, while codex's model/list round-trip drops unknown fields. + */ + restrictedModelIds?: Set; /** Callback invoked when the agent calls the create_output tool for structured output */ onStructuredOutput?: (output: Record) => Promise; /** PostHog API config; when set, enables file-read enrichment unless disabled. */ @@ -230,6 +237,7 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection { }, model: codexOptions.model, reasoningEffort: codexOptions.reasoningEffort, + restrictedModelIds: config.restrictedModelIds, processCallbacks: config.processCallbacks, onStructuredOutput: config.onStructuredOutput, logger: config.logger?.child("CodexAppServerAgent"), diff --git a/packages/agent/src/adapters/base-acp-agent.ts b/packages/agent/src/adapters/base-acp-agent.ts index 874c68de77..5f0121307f 100644 --- a/packages/agent/src/adapters/base-acp-agent.ts +++ b/packages/agent/src/adapters/base-acp-agent.ts @@ -16,6 +16,7 @@ import type { WriteTextFileRequest, WriteTextFileResponse, } from "@agentclientprotocol/sdk"; +import { restrictedModelMeta } from "@posthog/shared"; import { DEFAULT_GATEWAY_MODEL, fetchGatewayModels, @@ -25,6 +26,7 @@ import { isAnthropicModel, isCloudflareModel, isCloudflareModelId, + pickAllowedModel, } from "../gateway-models"; import { Logger } from "../utils/logger"; /** @@ -136,22 +138,30 @@ export abstract class BaseAcpAgent implements Agent { async getModelConfigOptions( currentModelOverride?: string, gatewayUrl?: string, + gatewayAuthToken?: string, ): Promise<{ currentModelId: string; options: SessionConfigSelectOption[]; }> { + // Authenticated so the gateway can mark models outside the org's plan + // (`allowed: false`) — anonymous fetches see everything allowed. this.gatewayModels = await fetchGatewayModels( - gatewayUrl ? { gatewayUrl } : undefined, + gatewayUrl ? { gatewayUrl, authToken: gatewayAuthToken } : undefined, ); - const options = this.gatewayModels + const adapterModels = this.gatewayModels // Cloudflare models are servable on the Claude adapter too — the gateway translates the // `@cf/` path onto its Anthropic-Messages surface — so include them alongside Anthropic models. - .filter((model) => isAnthropicModel(model) || isCloudflareModel(model)) + .filter((model) => isAnthropicModel(model) || isCloudflareModel(model)); + + const options = adapterModels .map((model) => ({ value: model.id, name: formatGatewayModelName(model), description: `Context: ${model.context_window.toLocaleString()} tokens`, + // Free-tier gate: locked models stay listed so the picker can render + // them behind an upgrade gate instead of silently vanishing. + ...(model.allowed ? {} : { _meta: restrictedModelMeta() }), })) // Sort oldest-to-newest so the picker is deterministic and the newest // model lands at the end of the list, closest to the trigger. @@ -186,6 +196,18 @@ export abstract class BaseAcpAgent implements Agent { } } + // Never auto-select a model the org's plan can't use — a free-tier org + // would 403 on its first message. An explicit user pick still goes + // through the picker, which gates locked models behind the upgrade flow. + const startedOn = currentModelId; + currentModelId = pickAllowedModel(adapterModels, currentModelId); + if (currentModelId !== startedOn) { + this.logger.info( + "Requested model is outside the org's plan; starting on an allowed model", + { requestedModel: startedOn, selectedModel: currentModelId }, + ); + } + if (!options.some((opt) => opt.value === currentModelId)) { options.unshift({ value: currentModelId, diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 6e3b1bae86..402d8dbdd4 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -2097,6 +2097,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.getModelConfigOptions( settingsManager.getSettings().model || meta?.model || undefined, this.options?.gatewayEnv?.anthropicBaseUrl, + this.options?.gatewayEnv?.anthropicAuthToken, ), ...(meta?.taskRunId ? [ diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index 7ed57f3339..d0c71d5d58 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -174,6 +174,8 @@ export interface CodexAppServerAgentOptions { processOptions: CodexAppServerProcessOptions; model?: string; reasoningEffort?: string; + /** Models outside the org's plan; the picker renders them locked. */ + restrictedModelIds?: ReadonlySet; processCallbacks?: ProcessSpawnedCallback; logger?: Logger; onStructuredOutput?: (output: Record) => Promise; @@ -236,6 +238,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.config = new SessionConfigState( options.model ?? DEFAULT_CODEX_MODEL, options.reasoningEffort, + options.restrictedModelIds, ); this.onStructuredOutput = options.onStructuredOutput; this.developerInstructions = options.processOptions.developerInstructions; diff --git a/packages/agent/src/adapters/codex-app-server/session-config.ts b/packages/agent/src/adapters/codex-app-server/session-config.ts index 6070ca7e27..785d1aab78 100644 --- a/packages/agent/src/adapters/codex-app-server/session-config.ts +++ b/packages/agent/src/adapters/codex-app-server/session-config.ts @@ -4,6 +4,7 @@ import { type CodexModePreset, type ExecutionMode, resolveCloudInitialPermissionMode, + restrictedModelMeta, } from "@posthog/shared"; import { type GatewayModel, isOpenAIModel } from "../../gateway-models"; import { getReasoningEffortOptions } from "./models"; @@ -160,6 +161,12 @@ export interface ConfigSelectors { /** From model/list; falls back to the single current model when empty. */ models: Array<{ id: string; name: string }>; efforts: string[]; + /** + * Models outside the org's plan (free-tier model gate). Rendered locked + * behind an upgrade gate by the picker. Sourced from the agent's own authed + * gateway models fetch — codex's model/list round-trip drops the marks. + */ + restrictedModelIds?: ReadonlySet; } /** Builds the ACP configOptions (mode + model + thought_level) the host renders. */ @@ -195,7 +202,13 @@ export function buildConfigOptions(s: ConfigSelectors): SessionConfigOption[] { name: "Model", category: "model", currentValue: s.model, - options: models.map((m) => ({ name: m.name, value: m.id })), + options: models.map((m) => ({ + name: m.name, + value: m.id, + ...(s.restrictedModelIds?.has(m.id) + ? { _meta: restrictedModelMeta() } + : {}), + })), } as unknown as SessionConfigOption, { type: "select", @@ -229,10 +242,16 @@ export class SessionConfigState { private models: Array<{ id: string; name: string }> = []; private efforts: string[] = []; private _options: SessionConfigOption[] = []; + private readonly restrictedModelIds?: ReadonlySet; - constructor(model: string, effort?: string) { + constructor( + model: string, + effort?: string, + restrictedModelIds?: ReadonlySet, + ) { this._model = model; this._effort = effort; + this.restrictedModelIds = restrictedModelIds; this.rebuild(); } @@ -331,6 +350,7 @@ export class SessionConfigState { effort: this._effort, models: this.models, efforts: this.efforts, + restrictedModelIds: this.restrictedModelIds, }); } } diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 69ae25c34c..f4ecd1d46a 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -9,6 +9,7 @@ import { DEFAULT_GATEWAY_MODEL, fetchModelsList, isBlockedModelId, + pickAllowedModel, } from "./gateway-models"; import { PostHogAPIClient, type TaskRunUpdate } from "./posthog-api"; import { SessionLogWriter } from "./session-log-writer"; @@ -79,26 +80,32 @@ export class Agent { this.taskRunId = taskRunId; let allowedModelIds: Set | undefined; + let restrictedModelIds: Set | undefined; let sanitizedModel = options.model && !isBlockedModelId(options.model) ? options.model : undefined; if (options.adapter === "codex" && gatewayConfig) { + // Authenticated so the gateway can mark models outside the org's plan + // (`allowed: false`) — anonymous fetches see everything allowed. const models = await fetchModelsList({ gatewayUrl: gatewayConfig.gatewayUrl, + authToken: gatewayConfig.apiKey, }); - const codexModelIds = models - .filter((model) => { - if (isBlockedModelId(model.id)) return false; - if (model.owned_by) { - return model.owned_by === "openai"; - } - return model.id.startsWith("gpt-") || model.id.startsWith("openai/"); - }) - .map((model) => model.id); + const codexModels = models.filter((model) => { + if (isBlockedModelId(model.id)) return false; + if (model.owned_by) { + return model.owned_by === "openai"; + } + return model.id.startsWith("gpt-") || model.id.startsWith("openai/"); + }); + const codexModelIds = codexModels.map((model) => model.id); if (codexModelIds.length > 0) { allowedModelIds = new Set(codexModelIds); + restrictedModelIds = new Set( + codexModels.filter((model) => !model.allowed).map((m) => m.id), + ); } if (!sanitizedModel || !allowedModelIds?.has(sanitizedModel)) { @@ -106,6 +113,12 @@ export class Agent { ? DEFAULT_CODEX_MODEL : codexModelIds[0]; } + // Never start a session on a model the org's plan can't use — it would + // 403 on the first message. Explicit user picks still flow through the + // picker's upgrade gate. + if (sanitizedModel) { + sanitizedModel = pickAllowedModel(codexModels, sanitizedModel); + } } if (!sanitizedModel && options.adapter !== "codex") { sanitizedModel = DEFAULT_GATEWAY_MODEL; @@ -131,6 +144,7 @@ export class Agent { processCallbacks: options.processCallbacks, onStructuredOutput: options.onStructuredOutput, allowedModelIds, + restrictedModelIds, posthogApiConfig: this.posthogApiConfig, enricherEnabled: this.enricherEnabled, claudeGatewayEnv, diff --git a/packages/agent/src/gateway-models.test.ts b/packages/agent/src/gateway-models.test.ts index c804652b67..3359a66258 100644 --- a/packages/agent/src/gateway-models.test.ts +++ b/packages/agent/src/gateway-models.test.ts @@ -16,6 +16,7 @@ const model = (id: string, owned_by = ""): GatewayModel => ({ context_window: 128000, supports_streaming: true, supports_vision: false, + allowed: true, }); describe("formatGatewayModelName", () => { @@ -27,6 +28,7 @@ describe("formatGatewayModelName", () => { context_window: 200000, supports_streaming: true, supports_vision: true, + allowed: true, }), ).toBe("Claude Opus 4.8"); }); @@ -39,6 +41,7 @@ describe("formatGatewayModelName", () => { context_window: 200000, supports_streaming: true, supports_vision: true, + allowed: true, }), ).toBe("gpt-5.5"); }); @@ -51,6 +54,7 @@ describe("formatGatewayModelName", () => { context_window: 200000, supports_streaming: true, supports_vision: true, + allowed: true, }), ).toBe("gpt-5.5"); }); @@ -63,6 +67,7 @@ describe("formatGatewayModelName", () => { context_window: 128000, supports_streaming: true, supports_vision: false, + allowed: true, }), ).toBe("glm-5.2"); }); diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts index 81db1d8b0a..8fccda9a77 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -4,15 +4,29 @@ export interface GatewayModel { context_window: number; supports_streaming: boolean; supports_vision: boolean; + /** + * Free-tier model gate (posthog_code): the gateway marks models the caller's + * org can't use as `allowed: false` instead of omitting them, so pickers can + * render them locked with an upgrade prompt. Only authenticated fetches get + * marks — anonymous callers see everything allowed — and gateways predating + * the gate omit the field, so absence means allowed. + */ + allowed: boolean; + restriction_reason?: string | null; } interface GatewayModelsResponse { object: "list"; - data: GatewayModel[]; + data: Array & { allowed?: boolean }>; } export interface FetchGatewayModelsOptions { gatewayUrl: string; + /** + * Bearer token for the models fetch. Required for accurate free-tier marks: + * the gateway only annotates `allowed: false` for authenticated callers. + */ + authToken?: string; } export const DEFAULT_GATEWAY_MODEL = "claude-opus-4-8"; @@ -42,12 +56,19 @@ export function isBlockedModelId(modelId: string): boolean { return BLOCKED_MODELS.has(modelId.toLowerCase()); } +interface ModelsListEntry { + id?: string; + owned_by?: string; + allowed?: boolean; + restriction_reason?: string | null; +} + type ModelsListResponse = | { - data?: Array<{ id?: string; owned_by?: string }>; - models?: Array<{ id?: string; owned_by?: string }>; + data?: ModelsListEntry[]; + models?: ModelsListEntry[]; } - | Array<{ id?: string; owned_by?: string }>; + | ModelsListEntry[]; const CACHE_TTL = 10 * 60 * 1000; // 10 minutes @@ -61,8 +82,13 @@ let gatewayModelsCache: { models: GatewayModel[]; expiry: number; url: string; + authed: boolean; } | null = null; +function authHeaders(authToken?: string): Record | undefined { + return authToken ? { Authorization: `Bearer ${authToken}` } : undefined; +} + export async function fetchGatewayModels( options?: FetchGatewayModelsOptions, ): Promise { @@ -71,9 +97,14 @@ export async function fetchGatewayModels( return []; } + // Authed and anonymous responses differ (free-tier marks are only present + // on authed fetches), so a cached anonymous list must not serve an authed + // caller or vice versa. + const authed = Boolean(options?.authToken); if ( gatewayModelsCache && gatewayModelsCache.url === gatewayUrl && + gatewayModelsCache.authed === authed && Date.now() < gatewayModelsCache.expiry ) { return gatewayModelsCache.models; @@ -83,6 +114,7 @@ export async function fetchGatewayModels( try { const response = await fetch(modelsUrl, { + headers: authHeaders(options?.authToken), signal: AbortSignal.timeout(GATEWAY_FETCH_TIMEOUT_MS), }); @@ -91,11 +123,14 @@ export async function fetchGatewayModels( } const data = (await response.json()) as GatewayModelsResponse; - const models = (data.data ?? []).filter((m) => !isBlockedModelId(m.id)); + const models = (data.data ?? []) + .filter((m) => !isBlockedModelId(m.id)) + .map((m) => ({ ...m, allowed: m.allowed !== false })); gatewayModelsCache = { models, expiry: Date.now() + CACHE_TTL, url: gatewayUrl, + authed, }; return models; } catch { @@ -136,12 +171,15 @@ export function isCloudflareModel(model: GatewayModel): boolean { export interface ModelInfo { id: string; owned_by?: string; + allowed: boolean; + restriction_reason?: string | null; } let modelsListCache: { models: ModelInfo[]; expiry: number; url: string; + authed: boolean; } | null = null; export async function fetchModelsList( @@ -152,9 +190,11 @@ export async function fetchModelsList( return []; } + const authed = Boolean(options?.authToken); if ( modelsListCache && modelsListCache.url === gatewayUrl && + modelsListCache.authed === authed && Date.now() < modelsListCache.expiry ) { return modelsListCache.models; @@ -163,6 +203,7 @@ export async function fetchModelsList( try { const modelsUrl = `${gatewayUrl}/v1/models`; const response = await fetch(modelsUrl, { + headers: authHeaders(options?.authToken), signal: AbortSignal.timeout(GATEWAY_FETCH_TIMEOUT_MS), }); if (!response.ok) { @@ -177,12 +218,18 @@ export async function fetchModelsList( const id = model?.id ? String(model.id) : ""; if (!id) continue; if (isBlockedModelId(id)) continue; - results.push({ id, owned_by: model?.owned_by }); + results.push({ + id, + owned_by: model?.owned_by, + allowed: model?.allowed !== false, + restriction_reason: model?.restriction_reason ?? null, + }); } modelsListCache = { models: results, expiry: Date.now() + CACHE_TTL, url: gatewayUrl, + authed, }; return results; } catch { @@ -190,6 +237,30 @@ export async function fetchModelsList( } } +/** + * The model a session should start on: the preferred id when it's present and + * allowed, else the newest allowed model (so a free-tier org lands on the + * free model instead of a premium default that would 403 on first message). + * Returns the preferred id unchanged when the list is empty (fetch failed — + * no marks to honor) or nothing is allowed (all locked; the picker and the + * upgrade gate communicate that state better than a silent second-guess). + */ +export function pickAllowedModel( + models: ReadonlyArray>, + preferred: string, +): string { + if (models.length === 0) return preferred; + const preferredEntry = models.find((m) => m.id === preferred); + if (!preferredEntry || preferredEntry.allowed) return preferred; + const allowed = models.filter((m) => m.allowed); + if (allowed.length === 0) return preferred; + return allowed.reduce((best, candidate) => + getClaudeModelRecency(candidate.id) >= getClaudeModelRecency(best.id) + ? candidate + : best, + ).id; +} + const PROVIDER_NAMES: Record = { anthropic: "Anthropic", openai: "OpenAI", diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 01e69aa342..7de8e1eb78 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1279,8 +1279,11 @@ export class AgentServer { }); if (this.config.repoReadyFile && gatewayEnv.anthropicBaseUrl) { + // Authed so this cache-warm matches the session's own authed fetch + // (the models cache is keyed on auth presence). void fetchGatewayModels({ gatewayUrl: gatewayEnv.anthropicBaseUrl, + authToken: gatewayEnv.anthropicAuthToken, }).catch(() => {}); } diff --git a/packages/core/src/llm-gateway/llm-gateway.test.ts b/packages/core/src/llm-gateway/llm-gateway.test.ts index 75cd555392..c292c997c0 100644 --- a/packages/core/src/llm-gateway/llm-gateway.test.ts +++ b/packages/core/src/llm-gateway/llm-gateway.test.ts @@ -157,6 +157,89 @@ 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, + }); + }); + + const MODEL_GATE_BODY = { + detail: + "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.", + }; + + 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 403 round-trip. + 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 +297,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..e47332d8cb 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 premium models +// (free-tier model gate). Helper calls are invisible plumbing, so they retry +// once on the free-tier model instead of surfacing the gate to the user. +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 told us either way — old gateways + // omit the field entirely. + private lastKnownCodeUsageBilled: boolean | null = null; + async prompt( messages: LlmMessage[], options: { @@ -70,11 +82,54 @@ 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 helpers to the free-tier model upfront instead of + // burning a request 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 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 +209,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 +281,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..8d845799eb 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: the gateway's 403s (e.g. the free-tier + // model gate) 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..6e8b7ef01f 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,6 +2714,26 @@ export class SessionService { this.d.store.clearOptimisticItems(session.taskRunId); + const limitCause = classifyGatewayLimitError(errorMessage, errorDetails); + + // Free-tier model gate (403): the session is healthy — the user needs + // to switch model or add a payment method. Surface the upgrade gate, + // not an error state. + if (limitCause === "model_gate") { + this.d.log.warn("Model gated for free tier, showing upgrade gate", { + taskRunId: session.taskRunId, + }); + this.d.store.updateSession(session.taskRunId, { + isPromptPending: false, + promptStartedAt: null, + }); + this.d.usageLimit.show({ + cause: limitCause, + model: extractGatedModel(errorMessage, errorDetails) ?? undefined, + }); + return { stopReason: "rate_limited" }; + } + if (isRateLimitError(errorMessage, errorDetails)) { this.d.log.warn("Rate limit exceeded, showing usage limit modal", { taskRunId: session.taskRunId, @@ -2720,7 +2742,7 @@ export class SessionService { 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/usage/schemas.ts b/packages/core/src/usage/schemas.ts index 7ad2c1db8e..cfaafa3370 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 posthog_code_credits bucket state (free allocation used up, or the + // org's billing limit reached). Named ai_credits on the wire for legacy + // client compatibility; reported 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, not false. + code_usage_billed: z.boolean().optional(), billing_period_end: z.string().datetime().nullable().optional(), }); diff --git a/packages/harness/src/extensions/posthog-provider/models.ts b/packages/harness/src/extensions/posthog-provider/models.ts index 3ff27d7096..6c0a55e800 100644 --- a/packages/harness/src/extensions/posthog-provider/models.ts +++ b/packages/harness/src/extensions/posthog-provider/models.ts @@ -12,6 +12,12 @@ export interface GatewayModel { display_name?: string; context_window?: number; supports_vision?: boolean; + /** + * Free-tier model gate: the gateway marks models outside the caller's plan + * `allowed: false` on authenticated fetches. Absent (anonymous fetch or an + * older gateway) means allowed. + */ + allowed?: boolean; } type ModelFamily = "anthropic" | "openai" | "cloudflare"; @@ -181,12 +187,16 @@ export function fallbackModelConfigs( async function fetchGatewayModels( region: CloudRegion, + apiKey?: string, ): Promise { if (process.env.PI_OFFLINE || process.env.HARNESS_STATIC_MODELS) { return []; } try { + // Authenticated when credentials exist so the gateway can mark models + // outside the org's plan — anonymous fetches see everything allowed. const response = await fetch(`${getLlmGatewayUrl(region)}/v1/models`, { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS), }); if (!response.ok) { @@ -201,12 +211,18 @@ async function fetchGatewayModels( export async function resolveModelConfigs( region: CloudRegion, + apiKey?: string, ): Promise { - const live = await fetchGatewayModels(region); + const live = await fetchGatewayModels(region, apiKey); if (live.length === 0) { return fallbackModelConfigs(region); } - return live - .filter((model) => Boolean(model.id)) - .map((model) => toModelConfig(model, region)); + const withIds = live.filter((model) => Boolean(model.id)); + // pi has no locked-model rendering, so models outside the org's plan are + // dropped instead of marked. The free tier always includes at least one + // servable model, but guard against an empty result anyway. + const usable = withIds.filter((model) => model.allowed !== false); + return (usable.length > 0 ? usable : withIds).map((model) => + toModelConfig(model, region), + ); } diff --git a/packages/harness/src/extensions/posthog-provider/provider.ts b/packages/harness/src/extensions/posthog-provider/provider.ts index 856b8834bc..1620511118 100644 --- a/packages/harness/src/extensions/posthog-provider/provider.ts +++ b/packages/harness/src/extensions/posthog-provider/provider.ts @@ -70,6 +70,6 @@ export async function resolvePosthogProvider( options: PosthogProviderOptions = {}, ): Promise { const region = resolveRegion(options.region); - const models = await resolveModelConfigs(region); + const models = await resolveModelConfigs(region, options.apiKey); return buildPosthogProvider(models, options); } diff --git a/packages/harness/src/session.ts b/packages/harness/src/session.ts index c2d6cc0700..7220b662f4 100644 --- a/packages/harness/src/session.ts +++ b/packages/harness/src/session.ts @@ -33,15 +33,18 @@ export async function createHarnessSession( const authStorage = AuthStorage.create(); const modelRegistry = ModelRegistry.create(authStorage); - modelRegistry.registerProvider( - POSTHOG_PROVIDER_NAME, - await resolvePosthogProvider(options), - ); + const provider = await resolvePosthogProvider(options); + modelRegistry.registerProvider(POSTHOG_PROVIDER_NAME, provider); - const model = modelRegistry.find( - POSTHOG_PROVIDER_NAME, - options.model ?? DEFAULT_MODEL, - ); + // The preferred model can be absent from the resolved list (e.g. premium + // models are dropped for orgs on the free tier) — fall back to the first + // model the provider actually serves rather than letting pi error. + const fallbackModelId = provider.models?.[0]?.id; + const model = + modelRegistry.find(POSTHOG_PROVIDER_NAME, options.model ?? DEFAULT_MODEL) ?? + (fallbackModelId + ? modelRegistry.find(POSTHOG_PROVIDER_NAME, fallbackModelId) + : undefined); const resourceLoader = new DefaultResourceLoader({ cwd, diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index c1dffa2051..acfec76194 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -973,7 +973,9 @@ export interface ChannelsSpaceViewedProperties { export type UpgradePromptShownSurface = | "usage_limit_modal" | "upgrade_dialog" - | "titlebar_card"; + | "titlebar_card" + | "model_picker" + | "billing_announcement"; export type UpgradePromptClickedSurface = | "usage_limit_modal" @@ -981,14 +983,25 @@ export type UpgradePromptClickedSurface = | "titlebar" | "titlebar_card" | "plan_page_card" - | "upgrade_dialog"; + | "upgrade_dialog" + | "model_picker" + | "billing_announcement"; + +/** 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 { @@ -996,6 +1009,11 @@ export interface CloudTaskUsageBlockedProperties { is_pro: boolean; } +export interface UsageBillingAnnouncementAcknowledgedProperties { + /** Stamps the acknowledgment on the person for support auditability. */ + $set: { code_usage_billing_acknowledged_at: string }; +} + export interface SubscriptionStartedProperties { plan_key: string; previous_plan_key?: string; @@ -1209,6 +1227,8 @@ export const ANALYTICS_EVENTS = { CLOUD_TASK_USAGE_BLOCKED: "Cloud task usage blocked", SUBSCRIPTION_STARTED: "Subscription started", SUBSCRIPTION_CANCELLED: "Subscription cancelled", + USAGE_BILLING_ANNOUNCEMENT_ACKNOWLEDGED: + "Usage billing announcement acknowledged", // Project Bluebird (Channels) events CHANNELS_SPACE_VIEWED: "Channels space viewed", @@ -1364,6 +1384,7 @@ export type EventPropertyMap = { // Subscription events [ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN]: UpgradePromptShownProperties; [ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED]: UpgradePromptClickedProperties; + [ANALYTICS_EVENTS.USAGE_BILLING_ANNOUNCEMENT_ACKNOWLEDGED]: UsageBillingAnnouncementAcknowledgedProperties; [ANALYTICS_EVENTS.CLOUD_TASK_USAGE_BLOCKED]: CloudTaskUsageBlockedProperties; [ANALYTICS_EVENTS.SUBSCRIPTION_STARTED]: SubscriptionStartedProperties; [ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED]: SubscriptionCancelledProperties; diff --git a/packages/shared/src/errors.test.ts b/packages/shared/src/errors.test.ts index 0501363126..63b4fc8e52 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,50 @@ describe("isRateLimitError", () => { }); }); +describe("classifyGatewayLimitError", () => { + it.each([ + [ + // Wrapped the way the ACP layer surfaces a gateway 403. + `Internal error: API Error: 403 {"detail":"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."}`, + "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 an unmapped credit bucket. + "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", + "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 +168,14 @@ describe("isFatalSessionError", () => { ), ).toBe(false); }); + + it("does not treat a free-tier model-gate 403 as fatal despite the Internal error wrapper", () => { + 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..0872c1ffee 100644 --- a/packages/shared/src/errors.ts +++ b/packages/shared/src/errors.ts @@ -74,6 +74,38 @@ const RATE_LIMIT_PATTERNS = [ "[429]", ] as const; +/** + * Billing/limit denials from the PostHog LLM gateway (posthog_code product), + * classified by their server-written detail strings. 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 — "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) — "Your + * team has reached its PostHog Code usage limit for this billing period." + * - "user_daily_limit" / "user_monthly_limit" (429): the per-user cost valve — + * "User burst rate limit exceeded" / "User sustained rate limit exceeded". + */ +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 an unmapped credit bucket. + "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 +153,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 +196,12 @@ 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. + 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..98ef510aa4 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). Flips billing UI from + * the seat-era Free/Pro copy to usage-based copy, and arms the one-time + * billing-change announcement. Flip together with the server-side + * LLM_GATEWAY_POSTHOG_CODE_MODEL_GATE_ENABLED cutover. + */ +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..d193374ae2 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, @@ -131,7 +134,12 @@ export { mentionsToPlainText, splitMentionSegments, } from "./mentions"; -export { defaultEligibleModel } from "./models"; +export { + defaultEligibleModel, + isRestrictedModelOption, + RESTRICTED_MODEL_META_KEY, + restrictedModelMeta, +} from "./models"; export { getOauthClientIdFromRegion, OAUTH_SCOPE_VERSION, diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index f2dd69e951..940757cd17 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -9,3 +9,21 @@ export function defaultEligibleModel( const family = modelId.toLowerCase().split("/").pop() ?? ""; return family.startsWith("claude-fable") ? undefined : modelId; } + +/** + * ACP SessionConfigSelectOption `_meta` key for the posthog_code free-tier + * model gate. Adapters mark models the caller's org can't use (the gateway's + * `allowed: false` annotation) so pickers render them locked behind an + * upgrade gate instead of omitting them. + */ +export const RESTRICTED_MODEL_META_KEY = "posthog.code/restrictedModel"; + +export function restrictedModelMeta(): Record { + return { [RESTRICTED_MODEL_META_KEY]: true }; +} + +export function isRestrictedModelOption( + meta: Record | null | undefined, +): boolean { + return meta?.[RESTRICTED_MODEL_META_KEY] === true; +} diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index dfb440287c..d3c2cef555 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -37,6 +37,7 @@ import { isAnthropicModel, isCloudflareModel, isOpenAIModel, + pickAllowedModel, } from "@posthog/agent/gateway-models"; import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; import { @@ -70,6 +71,7 @@ import { type ExecutionMode, isAuthError, resolveCloudInitialPermissionMode, + restrictedModelMeta, serializeError, TypedEventEmitter, } from "@posthog/shared"; @@ -2241,7 +2243,10 @@ For git operations while detached: async getGatewayModels(apiHost: string) { const gatewayUrl = getLlmGatewayUrl(apiHost); - const models = await fetchGatewayModels({ gatewayUrl }); + const models = await fetchGatewayModels({ + gatewayUrl, + authToken: (await this.agentAuthAdapter.gatewayAuthToken()) ?? undefined, + }); const mapped = models.map((model) => ({ modelId: model.id, @@ -2270,7 +2275,12 @@ For git operations while detached: adapter: Adapter = "claude", ): Promise { const gatewayUrl = getLlmGatewayUrl(apiHost); - const gatewayModels = await fetchGatewayModels({ gatewayUrl }); + // Authed so the gateway can mark plan-restricted models; falls back to an + // anonymous fetch (everything allowed) when auth isn't available. + const gatewayModels = await fetchGatewayModels({ + gatewayUrl, + authToken: (await this.agentAuthAdapter.gatewayAuthToken()) ?? undefined, + }); // The Claude adapter can also drive Cloudflare `@cf/` models the gateway serves over its // Anthropic-Messages surface, so the preview/default-model path must offer them too — otherwise an @@ -2281,13 +2291,15 @@ For git operations while detached: : (model: GatewayModel) => isAnthropicModel(model) || isCloudflareModel(model); - const modelOptions = gatewayModels - .filter((model) => modelFilter(model)) - .map((model) => ({ - value: model.id, - name: formatGatewayModelName(model), - description: `Context: ${model.context_window.toLocaleString()} tokens`, - })); + const adapterModels = gatewayModels.filter((model) => modelFilter(model)); + const modelOptions = adapterModels.map((model) => ({ + value: model.id, + name: formatGatewayModelName(model), + description: `Context: ${model.context_window.toLocaleString()} tokens`, + // Free-tier gate: locked models stay listed so the picker can render + // them behind an upgrade gate instead of silently vanishing. + ...(model.allowed ? {} : { _meta: restrictedModelMeta() }), + })); // The gateway returns models in an arbitrary order. Sort Claude models // oldest-to-newest so the picker is deterministic and the newest model @@ -2306,9 +2318,12 @@ For git operations while detached: "") : DEFAULT_GATEWAY_MODEL; - const resolvedModelId = modelOptions.some((o) => o.value === defaultModel) + const preferredModelId = modelOptions.some((o) => o.value === defaultModel) ? defaultModel : (modelOptions[0]?.value ?? defaultModel); + // Never preselect a model the org's plan can't use — a free-tier org + // would 403 on its first message. + const resolvedModelId = pickAllowedModel(adapterModels, preferredModelId); if (!modelOptions.some((o) => o.value === resolvedModelId)) { modelOptions.unshift({ diff --git a/packages/workspace-server/src/services/agent/auth-adapter.ts b/packages/workspace-server/src/services/agent/auth-adapter.ts index bd139edeca..2f05f1c137 100644 --- a/packages/workspace-server/src/services/agent/auth-adapter.ts +++ b/packages/workspace-server/src/services/agent/auth-adapter.ts @@ -79,6 +79,19 @@ export class AgentAuthAdapter { }; } + /** + * Bearer token for direct gateway REST calls (e.g. the models fetch), so + * the gateway can annotate plan-restricted models. Null when auth isn't + * available — callers fall back to an anonymous fetch. + */ + async gatewayAuthToken(): Promise { + try { + return await this.getValidToken(); + } catch { + return null; + } + } + async buildMcpServers(credentials: Credentials): Promise<{ servers: AcpMcpServer[]; toolApprovals: McpToolApprovals; From e11ad1a444d6c9ec56539c62ff8a246bb7830a5a Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 15 Jul 2026 15:25:17 -0400 Subject: [PATCH 2/4] feat(billing): usage-based billing UI behind posthog-code-usage-billing flag Behind the new flag: cause-aware usage limit modal (model gate / org limit / free-tier valve) with add-payment-method CTAs replacing the Pro-upgrade copy, a one-time blocking billing-change announcement recorded on the person profile, locked rendering + upgrade gate for plan-restricted models in every picker (GLM stays pickable as the free-tier model), a usage-based Plan & usage page (free-tier vs billed states, org-limit callout), free-tier gating of the titlebar usage card off code_usage_billed, and seat fetch/auto-provision skipped everywhere since seats are retired. Flag off keeps the seat-era UI byte-for-byte. Generated-By: PostHog Code Task-Id: bfbcd747-a365-429f-a5dc-622e2c2f7edf --- .../ui/src/features/auth/useAuthSession.ts | 9 +- .../billing/UsageBillingAnnouncementModal.tsx | 106 +++++++++ .../ui/src/features/billing/UsageButton.tsx | 7 +- .../src/features/billing/UsageLimitModal.tsx | 225 +++++++++++++----- .../billing/billingAnnouncementStore.ts | 37 +++ packages/ui/src/features/billing/modelGate.ts | 34 +++ .../features/billing/preflightCloudUsage.ts | 29 ++- .../src/features/billing/usageLimitStore.ts | 26 +- .../ui/src/features/billing/useFreeUsage.ts | 15 +- .../components/ProjectSelectStep.tsx | 7 +- .../sessions/components/ModelRadioItem.tsx | 31 +++ .../sessions/components/ModelSelector.tsx | 25 +- .../components/UnifiedModelSelector.tsx | 21 +- .../settings/sections/PlanUsageSettings.tsx | 171 ++++++++++++- .../task-detail/hooks/usePreviewConfig.ts | 7 +- packages/ui/src/router/routes/__root.tsx | 3 + 16 files changed, 648 insertions(+), 105 deletions(-) create mode 100644 packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx create mode 100644 packages/ui/src/features/billing/billingAnnouncementStore.ts create mode 100644 packages/ui/src/features/billing/modelGate.ts create mode 100644 packages/ui/src/features/sessions/components/ModelRadioItem.tsx diff --git a/packages/ui/src/features/auth/useAuthSession.ts b/packages/ui/src/features/auth/useAuthSession.ts index ab9f2019f4..54dfb97215 100644 --- a/packages/ui/src/features/auth/useAuthSession.ts +++ b/packages/ui/src/features/auth/useAuthSession.ts @@ -1,5 +1,5 @@ import { useHostTRPCClient } from "@posthog/host-router/react"; -import { BILLING_FLAG } from "@posthog/shared"; +import { BILLING_FLAG, USAGE_BILLING_FLAG } from "@posthog/shared"; import { useSeatStore } from "@posthog/ui/features/billing/seatStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { @@ -112,15 +112,18 @@ function useSeatSync( billingEnabled: boolean, ): void { const queryClient = useQueryClient(); + // Seats are retired under usage-based billing — provisioning one would + // error against the retired seat API. + const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); useEffect(() => { - if (!authIdentity || !billingEnabled) { + if (!authIdentity || !billingEnabled || usageBillingEnabled) { useSeatStore.getState().reset(); return; } void useSeatStore.getState().fetchSeat({ autoProvision: true }); void queryClient.invalidateQueries({ queryKey: [["llmGateway"]] }); - }, [authIdentity, billingEnabled, queryClient]); + }, [authIdentity, billingEnabled, usageBillingEnabled, queryClient]); } export function useAuthSession() { diff --git a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx new file mode 100644 index 0000000000..cde4f9984d --- /dev/null +++ b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx @@ -0,0 +1,106 @@ +import { ArrowSquareOut, CreditCard } from "@phosphor-icons/react"; +import { USAGE_BILLING_FLAG } from "@posthog/shared"; +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 { getBillingUrl } from "../../utils/urls"; +import { useAuthStateValue } from "../auth/store"; +import { useFeatureFlag } from "../feature-flags/useFeatureFlag"; +import { useBillingAnnouncementStore } from "./billingAnnouncementStore"; + +/** + * One-time blocking announcement for the usage-based billing cutover: shown + * on first launch after the flag flips, until the user acknowledges. The + * acknowledgment is stamped on the person profile so support can audit it. + */ +export function UsageBillingAnnouncementModal() { + const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); + const acknowledged = useBillingAnnouncementStore((s) => s.acknowledged); + const acknowledge = useBillingAnnouncementStore((s) => s.acknowledge); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const isLoggedIn = useAuthStateValue((state) => state.currentOrgId !== null); + + const isOpen = usageBillingEnabled && isLoggedIn && !acknowledged; + + useEffect(() => { + if (isOpen) { + track(ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN, { + surface: "billing_announcement", + }); + } + }, [isOpen]); + + const handleAcknowledge = () => { + track(ANALYTICS_EVENTS.USAGE_BILLING_ANNOUNCEMENT_ACKNOWLEDGED, { + $set: { + code_usage_billing_acknowledged_at: new Date().toISOString(), + }, + }); + acknowledge(); + }; + + const handleManageBilling = () => { + track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { + surface: "billing_announcement", + }); + const billingUrl = getBillingUrl(cloudRegion); + if (billingUrl) openExternalUrl(billingUrl); + }; + + return ( + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + + + + PostHog Code billing has changed + + + + + Seat-based plans are gone. PostHog Code is now usage-based — your + organization pays for AI usage at cost, and you only pay for what + you use. + + + + + • Your organization's first $20 of + usage each month is included. + + + • Premium models (Claude, GPT) need a payment method on your + organization; an open model stays available on the free tier. + + + • Every organization starts with a{" "} + $50/month spend limit you can raise, + lower, or remove in PostHog billing settings. + + + + + + + + + + ); +} diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index 95b42d3001..5ebc9cb5f8 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -10,7 +10,7 @@ import { PopoverTrigger, Progress, } from "@posthog/quill"; -import { BILLING_FLAG } from "@posthog/shared"; +import { BILLING_FLAG, USAGE_BILLING_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS, type UpgradePromptClickedSurface, @@ -35,6 +35,7 @@ import { useFreeUsage } from "./useFreeUsage"; // popover portal. export function UsageButton() { const billingEnabled = useFeatureFlag(BILLING_FLAG); + const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); const { usage, isLoading } = useFreeUsage(billingEnabled); // Controlled so the trigger click can close the card before navigating to // settings — uncontrolled, the same click would also toggle the popover open @@ -121,7 +122,7 @@ export function UsageButton() { >
- Free plan + {usageBillingEnabled ? "Free tier" : "Free plan"} handleOpenPlan("titlebar_card")} > - Upgrade + {usageBillingEnabled ? "Unlock more" : "Upgrade"}
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); + // Whether the org pays for Code usage — picks the org_limit copy variant. + const { usage } = useUsage({ enabled: usageBillingEnabled && isOpen }); const isPro = eventIsPro ?? seatIsPro; + // Legacy callers only know the bucket; map it onto the usage-based cause. + // No bucket exceeded means the org's credit bucket tripped the limit. + const effectiveCause: GatewayLimitCause = + cause ?? + (bucket === "burst" + ? "user_daily_limit" + : bucket === "sustained" + ? "user_monthly_limit" + : "org_limit"); + const trackedCause: UpgradePromptCause | undefined = usageBillingEnabled + ? effectiveCause + : undefined; + useEffect(() => { if (isOpen) { track(ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN, { surface: "usage_limit_modal", + ...(trackedCause ? { cause: trackedCause } : {}), }); } - }, [isOpen]); + }, [isOpen, trackedCause]); - const handleUpgrade = () => { + const resetLabel = resetAt ? formatResetTime(resetAt) : null; + + const content = usageBillingEnabled + ? usageBasedContent({ + cause: effectiveCause, + model, + resetLabel, + billed: usage?.code_usage_billed, + }) + : seatEraContent({ bucket, isPro, resetLabel }); + + 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 +181,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 +195,38 @@ export function UsageLimitModal() { - {title} + {content.title} - {description} + {content.description} - {isPro ? ( - <> - - - - ) : ( - <> - - - + {showSupport && ( + + )} + + {content.actionLabel && ( + )} diff --git a/packages/ui/src/features/billing/billingAnnouncementStore.ts b/packages/ui/src/features/billing/billingAnnouncementStore.ts new file mode 100644 index 0000000000..c223491165 --- /dev/null +++ b/packages/ui/src/features/billing/billingAnnouncementStore.ts @@ -0,0 +1,37 @@ +import { create } from "zustand"; + +const STORAGE_KEY = "posthog-code-usage-billing-acknowledged"; + +function readAcknowledged(): boolean { + try { + return window.localStorage.getItem(STORAGE_KEY) === "true"; + } catch { + return false; + } +} + +interface BillingAnnouncementState { + acknowledged: boolean; +} + +interface BillingAnnouncementActions { + acknowledge: () => void; +} + +type BillingAnnouncementStore = BillingAnnouncementState & + BillingAnnouncementActions; + +export const useBillingAnnouncementStore = create()( + (set) => ({ + acknowledged: readAcknowledged(), + acknowledge: () => { + try { + window.localStorage.setItem(STORAGE_KEY, "true"); + } catch { + // Persistence failing only means the announcement shows again next + // launch; the acknowledgment event still records on the person. + } + set({ acknowledged: true }); + }, + }), +); diff --git a/packages/ui/src/features/billing/modelGate.ts b/packages/ui/src/features/billing/modelGate.ts new file mode 100644 index 0000000000..47fa39d894 --- /dev/null +++ b/packages/ui/src/features/billing/modelGate.ts @@ -0,0 +1,34 @@ +import type { SessionConfigSelectOption } from "@agentclientprotocol/sdk"; +import { isRestrictedModelOption } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { track } from "../../shell/analytics"; +import { useUsageLimitStore } from "./usageLimitStore"; + +/** Whether a picker option is outside the org's plan (free-tier model gate). */ +export function isRestrictedModel( + option: Pick, +): boolean { + return isRestrictedModelOption(option._meta ?? undefined); +} + +/** + * Intercepts a pick of a plan-restricted model: opens the upgrade gate and + * returns true, so the caller skips the selection. Non-restricted picks + * return false and proceed normally. + */ +export function gateRestrictedModelPick( + options: SessionConfigSelectOption[], + value: string, +): boolean { + const picked = options.find((opt) => opt.value === value); + if (!picked || !isRestrictedModel(picked)) return false; + track(ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN, { + surface: "model_picker", + cause: "model_gate", + }); + useUsageLimitStore.getState().show({ + cause: "model_gate", + model: picked.name || value, + }); + return true; +} diff --git a/packages/ui/src/features/billing/preflightCloudUsage.ts b/packages/ui/src/features/billing/preflightCloudUsage.ts index a12258ee92..ca5447b4e8 100644 --- a/packages/ui/src/features/billing/preflightCloudUsage.ts +++ b/packages/ui/src/features/billing/preflightCloudUsage.ts @@ -8,18 +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). + // If neither is flagged, is_rate_limited came from the org's credit bucket + // (allocation or billing limit exhausted) — say so, with the monthly reset + // as the best available timing hint. + if (!usage.burst.exceeded && !usage.sustained.exceeded) { + return { + bucket: "sustained", + resetAt: usage.sustained.reset_at, + isPro: usage.is_pro, + cause: "org_limit", + }; + } const bucket: UsageLimitBucket = usage.burst.exceeded ? "burst" : "sustained"; return { bucket, resetAt: usage[bucket].reset_at, isPro: usage.is_pro }; } @@ -52,7 +61,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 }), })); diff --git a/packages/ui/src/features/billing/useFreeUsage.ts b/packages/ui/src/features/billing/useFreeUsage.ts index edbe80516f..d2137d7909 100644 --- a/packages/ui/src/features/billing/useFreeUsage.ts +++ b/packages/ui/src/features/billing/useFreeUsage.ts @@ -1,4 +1,6 @@ import type { UsageOutput } from "@posthog/core/usage/schemas"; +import { USAGE_BILLING_FLAG } from "@posthog/shared"; +import { useFeatureFlag } from "../feature-flags/useFeatureFlag"; import { useSeat } from "./useSeat"; import { useUsage } from "./useUsage"; @@ -10,11 +12,22 @@ export interface FreeUsageResult { } export function useFreeUsage(billingEnabled: boolean): FreeUsageResult { + const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); const { seat, isPro } = useSeat(); const seatLoaded = seat !== null; - const eligible = billingEnabled && seatLoaded && !isPro; + // Seat era: free-seat holders only. Usage era: seats are gone — fetch for + // everyone, then show only orgs the gateway confirms as unbilled (the free + // tier's per-user allowance is the meaningful meter). + const eligible = usageBillingEnabled + ? billingEnabled + : billingEnabled && seatLoaded && !isPro; const { usage, isLoading } = useUsage({ enabled: eligible }); if (!eligible) return { usage: null, isLoading: false }; + if (usageBillingEnabled && usage?.code_usage_billed !== false) { + // Billed org (no per-user caps to meter) or billed state unknown: the + // free-tier bar would be noise or wrong — render nothing. + return { usage: null, isLoading: usage ? false : isLoading }; + } return { usage: usage ?? null, isLoading }; } diff --git a/packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx b/packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx index 4f3899aefa..bc3d28a5f5 100644 --- a/packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx +++ b/packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx @@ -13,7 +13,7 @@ import { ComboboxList, ComboboxTrigger, } from "@posthog/quill"; -import { BILLING_FLAG } from "@posthog/shared"; +import { BILLING_FLAG, USAGE_BILLING_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { happyHog } from "@posthog/ui/assets/hedgehogs"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; @@ -77,6 +77,9 @@ export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { client, }); const billingEnabled = useFeatureFlag(BILLING_FLAG); + // Seats are retired under usage-based billing — provisioning one on org + // switch would error against the retired seat API. + const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); const switchOrgTrpcMutation = useSwitchOrgMutation(); const organizations = useMemo(() => { @@ -110,7 +113,7 @@ export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { const switchOrgMutation = useMutation({ mutationFn: async (orgId: string) => { await switchOrgTrpcMutation.mutateAsync(orgId); - if (billingEnabled) { + if (billingEnabled && !usageBillingEnabled) { void useSeatStore.getState().fetchSeat({ autoProvision: true }); } }, diff --git a/packages/ui/src/features/sessions/components/ModelRadioItem.tsx b/packages/ui/src/features/sessions/components/ModelRadioItem.tsx new file mode 100644 index 0000000000..5c6b3cfe02 --- /dev/null +++ b/packages/ui/src/features/sessions/components/ModelRadioItem.tsx @@ -0,0 +1,31 @@ +import { Lock } from "@phosphor-icons/react"; +import { DropdownMenuRadioItem } from "@posthog/quill"; +import { isRestrictedModel } from "@posthog/ui/features/billing/modelGate"; + +/** + * Model picker entry. Plan-restricted models (free-tier model gate) render + * dimmed with a lock; picking one is intercepted by the selector's change + * handler, which opens the upgrade gate instead of selecting. + */ +export function ModelRadioItem({ + model, +}: { + model: { + value: string; + name: string; + _meta?: Record | null; + }; +}) { + const restricted = isRestrictedModel(model); + return ( + + {model.name} + {restricted && ( + + )} + + ); +} diff --git a/packages/ui/src/features/sessions/components/ModelSelector.tsx b/packages/ui/src/features/sessions/components/ModelSelector.tsx index 6cce5064cd..0eb02d1026 100644 --- a/packages/ui/src/features/sessions/components/ModelSelector.tsx +++ b/packages/ui/src/features/sessions/components/ModelSelector.tsx @@ -8,13 +8,18 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, - DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, MenuLabel, } from "@posthog/quill"; -import { type Adapter, GLM_MODEL_FLAG } from "@posthog/shared"; +import { + type Adapter, + GLM_MODEL_FLAG, + USAGE_BILLING_FLAG, +} from "@posthog/shared"; +import { gateRestrictedModelPick } from "@posthog/ui/features/billing/modelGate"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { ModelRadioItem } from "@posthog/ui/features/sessions/components/ModelRadioItem"; import { stripGlmModelOption } from "@posthog/ui/features/sessions/modelOptionFilters"; import { flattenSelectOptions, @@ -43,8 +48,11 @@ export function ModelSelector({ const sessionIsCloud = useSessionIsCloud(taskId); const rawModelOption = useModelConfigOptionForTask(taskId); const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); + // Under usage-based billing GLM is the free tier's included model — it must + // stay pickable regardless of the staged GLM rollout flag. + const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); const modelOption = - glmEnabled || !rawModelOption + glmEnabled || usageBillingEnabled || !rawModelOption ? rawModelOption : stripGlmModelOption(rawModelOption); @@ -63,6 +71,9 @@ export function ModelSelector({ if (!selectOption || options.length === 0) return null; const handleChange = (value: string) => { + // A plan-restricted model opens the upgrade gate instead of becoming the + // selection. + if (gateRestrictedModelPick(options, value)) return; onModelChange?.(value); if (!taskId) return; @@ -110,9 +121,7 @@ export function ModelSelector({ {index > 0 && } {group.name} {group.options.map((model) => ( - - {model.name} - + ))} ))} @@ -123,9 +132,7 @@ export function ModelSelector({ onValueChange={handleChange} > {options.map((model) => ( - - {model.name} - + ))} )} diff --git a/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx b/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx index 3b71f14d4b..06b3956f41 100644 --- a/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx +++ b/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx @@ -15,11 +15,12 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, - DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, MenuLabel, } from "@posthog/quill"; +import { gateRestrictedModelPick } from "@posthog/ui/features/billing/modelGate"; +import { ModelRadioItem } from "@posthog/ui/features/sessions/components/ModelRadioItem"; import { flattenSelectOptions } from "@posthog/ui/features/sessions/sessionStore"; import { useRetainedConfigOption } from "@posthog/ui/features/sessions/useRetainedConfigOption"; import type { AgentAdapter } from "@posthog/ui/features/settings/settingsStore"; @@ -144,6 +145,13 @@ export function UnifiedModelSelector({ { + // A plan-restricted model opens the upgrade gate instead of + // becoming the selection. + if (gateRestrictedModelPick(options, value)) { + pendingValueRef.current = null; + setOpen(false); + return; + } pendingValueRef.current = value; setOpen(false); }} @@ -154,19 +162,12 @@ export function UnifiedModelSelector({ {index > 0 && } {group.name} {group.options.map((model) => ( - - {model.name} - + ))} )) : options.map((model) => ( - - {model.name} - + ))} )} diff --git a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx index 0ee77a180f..b7f8514fab 100644 --- a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx +++ b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx @@ -5,6 +5,7 @@ import { WarningCircle, } from "@phosphor-icons/react"; import { PRO_USAGE_MULTIPLIER } from "@posthog/core/billing/usageDisplay"; +import type { UsageOutput } from "@posthog/core/usage/schemas"; import { Empty, EmptyDescription, @@ -12,7 +13,11 @@ import { EmptyMedia, EmptyTitle, } from "@posthog/quill"; -import { BILLING_FLAG, PLAN_PRO_ALPHA } from "@posthog/shared"; +import { + BILLING_FLAG, + PLAN_PRO_ALPHA, + USAGE_BILLING_FLAG, +} from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { useSwitchOrgMutation } from "@posthog/ui/features/auth/useAuthMutations"; @@ -55,6 +60,7 @@ export function PlanUsageSettings() { hasBetterPlanElsewhere, } = useSeat(); const billingEnabled = useFeatureFlag(BILLING_FLAG); + const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); const { fetchSeat, upgradeToPro, cancelSeat, reactivateSeat, clearError } = useSeatStore(); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); @@ -98,15 +104,17 @@ export function PlanUsageSettings() { isLoading: usageLoading, refetch: refetchUsage, } = useUsage({ - enabled: billingEnabled && seat !== null, + enabled: billingEnabled && (usageBillingEnabled || seat !== null), }); useEffect(() => { - void fetchSeat({ autoProvision: true }); + // Seats are retired under usage-based billing — provisioning one would + // error against the retired seat API. + if (!usageBillingEnabled) void fetchSeat({ autoProvision: true }); // refetchUsage is a refresh mutation, so it bypasses useUsage's `enabled` // gate — skip it for spend-only users. if (billingEnabled) void refetchUsage(); - }, [fetchSeat, refetchUsage, billingEnabled]); + }, [fetchSeat, refetchUsage, billingEnabled, usageBillingEnabled]); useTrackUsageViewed({ isLoading: billingEnabled && (isLoading || usageLoading), @@ -155,7 +163,17 @@ export function PlanUsageSettings() { return ( - {billingEnabled && ( + {billingEnabled && usageBillingEnabled && ( + { + void openBillingPage(billingOrgId); + }} + /> + )} + {billingEnabled && !usageBillingEnabled && ( <> {error && !redirectUrl && ( @@ -489,6 +507,149 @@ export function PlanUsageSettings() { ); } +interface UsageBasedPlanUsageProps { + usage: UsageOutput | null; + usageLoading: boolean; + billingUrl: string | null; + onOpenBilling: () => void; +} + +/** + * Plan & usage under usage-based billing: no seats or plans to manage in-app — + * the org pays for usage at cost, and payment methods / spend limits / org + * usage live on the PostHog billing page. The free-tier meters show the + * per-user allowance; billed orgs have no per-user caps to meter. + */ +function UsageBasedPlanUsage({ + usage, + usageLoading, + billingUrl, + onOpenBilling, +}: UsageBasedPlanUsageProps) { + // Absent on gateways predating the field — treat as unknown, not free. + const billed = usage?.code_usage_billed; + const orgLimitReached = usage?.ai_credits?.exhausted === true; + + return ( + <> + {orgLimitReached && ( + + + + + + + + Your organization has reached its PostHog Code usage limit for + this billing period. + + + + + + )} + + + + + + {billed === false ? "Free tier" : "Usage-based billing"} + + + {billed === false + ? "Your organization's first $20 of usage each month is included, with access to open models. Add a payment method to unlock premium models — you only pay for what you use." + : "Your organization pays for PostHog Code usage at cost — no seats, no subscriptions. The first $20 each month is included."} + + + {billed === true && ( + + Active + + )} + + + + + + Usage + {usageLoading ? ( + + + + ) : billed === false && usage ? ( + + + + + ) : ( + + + {usage + ? "Usage is billed to your organization. View detailed usage and spend in PostHog." + : "Unable to load usage data"} + + {usage && ( + + )} + + )} + + + ); +} + interface PlanCardProps { name: string; price: string; diff --git a/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts b/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts index 2cc286788b..a4118dbb76 100644 --- a/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts +++ b/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts @@ -11,6 +11,7 @@ import { defaultEligibleModel, GLM_MODEL_FLAG, getCloudUrlFromRegion, + USAGE_BILLING_FLAG, } from "@posthog/shared"; import { stripGlmModelOption } from "@posthog/ui/features/sessions/modelOptionFilters"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -47,7 +48,11 @@ function getOptionByCategory( */ export function usePreviewConfig(adapter: Adapter): PreviewConfigResult { const hostClient = useHostTRPCClient(); - const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); + const glmFlagEnabled = useFeatureFlag(GLM_MODEL_FLAG); + // Under usage-based billing GLM is the free tier's included model — it must + // stay pickable regardless of the staged GLM rollout flag. + const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); + const glmEnabled = glmFlagEnabled || usageBillingEnabled; const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const apiHost = useMemo( () => (cloudRegion ? getCloudUrlFromRegion(cloudRegion) : null), diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index f2483c7cf9..d5544ddc46 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -16,6 +16,7 @@ import { isContentlessTask } from "@posthog/shared/domain-types"; import { DeepLinkApprovalModal } from "@posthog/ui/features/agent-applications/components/DeepLinkApprovalModal"; import { useApprovalDeepLink } from "@posthog/ui/features/agent-applications/hooks/useApprovalDeepLink"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { UsageBillingAnnouncementModal } from "@posthog/ui/features/billing/UsageBillingAnnouncementModal"; import { UsageButton } from "@posthog/ui/features/billing/UsageButton"; import { UsageLimitModal } from "@posthog/ui/features/billing/UsageLimitModal"; import { BlankTabView } from "@posthog/ui/features/browser-tabs/BlankTabView"; @@ -326,6 +327,7 @@ function RootLayout() { onToggleShortcutsSheet={toggleShortcutsSheet} /> {billingEnabled && } + @@ -501,6 +503,7 @@ function RootLayout() { /> {billingEnabled && } + From c41f182aa600c946cff8d7bc0c06aeb7d9b6df40 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 15 Jul 2026 15:41:05 -0400 Subject: [PATCH 3/4] refactor(billing): consolidate cutover logic per review pass Move usage-limit modal copy derivation into core (pure, unit-tested) and thin the component; persist the billing announcement via the shared electronStorage-backed persist middleware with a hydration guard; gate seat fetch/provision once in the seat store instead of at three call sites; share a GLM-visibility hook and a models-cache validity helper; name the code_usage_billed tri-state with isCodeUsageUnbilled; merge the session service's duplicated limit branches. Generated-By: PostHog Code Task-Id: bfbcd747-a365-429f-a5dc-622e2c2f7edf --- packages/agent/src/gateway-models.ts | 50 ++++---- packages/core/src/billing/usageDisplay.ts | 11 ++ .../src/billing/usageLimitContent.test.ts | 102 +++++++++++++++ .../core/src/billing/usageLimitContent.ts | 108 ++++++++++++++++ packages/core/src/sessions/sessionService.ts | 40 +++--- .../ui/src/features/auth/useAuthSession.ts | 10 +- .../billing/UsageBillingAnnouncementModal.tsx | 4 +- .../src/features/billing/UsageLimitModal.tsx | 118 ++---------------- .../billing/billingAnnouncementStore.ts | 50 ++++---- .../features/billing/preflightCloudUsage.ts | 24 ++-- packages/ui/src/features/billing/seatStore.ts | 18 ++- .../ui/src/features/billing/useFreeUsage.ts | 3 +- .../components/ProjectSelectStep.tsx | 8 +- .../sessions/components/ModelSelector.tsx | 15 +-- .../features/sessions/useGlmModelVisible.ts | 13 ++ .../settings/sections/PlanUsageSettings.tsx | 31 ++--- .../task-detail/hooks/usePreviewConfig.ts | 10 +- 17 files changed, 370 insertions(+), 245 deletions(-) create mode 100644 packages/core/src/billing/usageLimitContent.test.ts create mode 100644 packages/core/src/billing/usageLimitContent.ts create mode 100644 packages/ui/src/features/sessions/useGlmModelVisible.ts diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts index 8fccda9a77..cfd204b4ea 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -78,12 +78,26 @@ const CACHE_TTL = 10 * 60 * 1000; // 10 minutes // the callers fall through to `return []`. const GATEWAY_FETCH_TIMEOUT_MS = 10_000; -let gatewayModelsCache: { - models: GatewayModel[]; +// Authed and anonymous responses differ (free-tier marks are only present on +// authed fetches), so cached entries are keyed on auth presence: a cached +// anonymous list must not serve an authed caller or vice versa. +interface ModelsCache { + models: T[]; expiry: number; url: string; authed: boolean; -} | null = null; +} + +function readModelsCache( + cache: ModelsCache | null, + url: string, + authed: boolean, +): T[] | null { + if (!cache || cache.url !== url || cache.authed !== authed) return null; + return Date.now() < cache.expiry ? cache.models : null; +} + +let gatewayModelsCache: ModelsCache | null = null; function authHeaders(authToken?: string): Record | undefined { return authToken ? { Authorization: `Bearer ${authToken}` } : undefined; @@ -97,18 +111,9 @@ export async function fetchGatewayModels( return []; } - // Authed and anonymous responses differ (free-tier marks are only present - // on authed fetches), so a cached anonymous list must not serve an authed - // caller or vice versa. const authed = Boolean(options?.authToken); - if ( - gatewayModelsCache && - gatewayModelsCache.url === gatewayUrl && - gatewayModelsCache.authed === authed && - Date.now() < gatewayModelsCache.expiry - ) { - return gatewayModelsCache.models; - } + const cached = readModelsCache(gatewayModelsCache, gatewayUrl, authed); + if (cached) return cached; const modelsUrl = `${gatewayUrl}/v1/models`; @@ -175,12 +180,7 @@ export interface ModelInfo { restriction_reason?: string | null; } -let modelsListCache: { - models: ModelInfo[]; - expiry: number; - url: string; - authed: boolean; -} | null = null; +let modelsListCache: ModelsCache | null = null; export async function fetchModelsList( options?: FetchGatewayModelsOptions, @@ -191,14 +191,8 @@ export async function fetchModelsList( } const authed = Boolean(options?.authToken); - if ( - modelsListCache && - modelsListCache.url === gatewayUrl && - modelsListCache.authed === authed && - Date.now() < modelsListCache.expiry - ) { - return modelsListCache.models; - } + const cached = readModelsCache(modelsListCache, gatewayUrl, authed); + if (cached) return cached; try { const modelsUrl = `${gatewayUrl}/v1/models`; diff --git a/packages/core/src/billing/usageDisplay.ts b/packages/core/src/billing/usageDisplay.ts index 21ef3458d7..20b57e45a4 100644 --- a/packages/core/src/billing/usageDisplay.ts +++ b/packages/core/src/billing/usageDisplay.ts @@ -9,6 +9,17 @@ export function isUsageExceeded(usage: UsageOutput): boolean { ); } +/** + * The org is confirmed on the free tier (not billed for Code usage). False + * when billed OR when the state is unknown — `code_usage_billed` is absent on + * gateways predating the field, and absence must never read as free. + */ +export function isCodeUsageUnbilled( + usage: Pick | null | undefined, +): boolean { + return usage?.code_usage_billed === false; +} + export function formatResetTime( resetAtIso: string, now: number = Date.now(), diff --git a/packages/core/src/billing/usageLimitContent.test.ts b/packages/core/src/billing/usageLimitContent.test.ts new file mode 100644 index 0000000000..f51782c987 --- /dev/null +++ b/packages/core/src/billing/usageLimitContent.test.ts @@ -0,0 +1,102 @@ +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"], + [null, null, "org_limit"], + ] 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"); + }); +}); + +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..7205927a9d --- /dev/null +++ b/packages/core/src/billing/usageLimitContent.ts @@ -0,0 +1,108 @@ +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: no exceeded + * bucket means the org's credit bucket tripped the limit. + */ +export function deriveUsageLimitCause( + cause: GatewayLimitCause | null, + bucket: "burst" | "sustained" | null, +): GatewayLimitCause { + if (cause) return cause; + if (bucket === "burst") return "user_daily_limit"; + if (bucket === "sustained") return "user_monthly_limit"; + return "org_limit"; +} + +export function usageBasedLimitContent(args: { + cause: GatewayLimitCause; + model: string | null; + resetLabel: string | null; + /** usage.code_usage_billed — absent means unknown, not 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", + }; + } + + 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", + }; +} + +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/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 6e8b7ef01f..77c097a4d3 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -2716,33 +2716,33 @@ export class SessionService { const limitCause = classifyGatewayLimitError(errorMessage, errorDetails); - // Free-tier model gate (403): the session is healthy — the user needs - // to switch model or add a payment method. Surface the upgrade gate, - // not an error state. - if (limitCause === "model_gate") { - this.d.log.warn("Model gated for free tier, showing upgrade gate", { + // Gateway billing denials — free-tier model gate (403) or a usage + // limit (429): the session is healthy, 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, - }); - this.d.store.updateSession(session.taskRunId, { - isPromptPending: false, - promptStartedAt: null, - }); - this.d.usageLimit.show({ cause: limitCause, - model: extractGatedModel(errorMessage, errorDetails) ?? undefined, - }); - return { stopReason: "rate_limited" }; - } - - if (isRateLimitError(errorMessage, errorDetails)) { - this.d.log.warn("Rate limit exceeded, showing usage limit modal", { - taskRunId: session.taskRunId, }); this.d.store.updateSession(session.taskRunId, { isPromptPending: false, promptStartedAt: null, }); - this.d.usageLimit.show(limitCause ? { cause: limitCause } : undefined); + 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/ui/src/features/auth/useAuthSession.ts b/packages/ui/src/features/auth/useAuthSession.ts index 54dfb97215..aba6eaf4c2 100644 --- a/packages/ui/src/features/auth/useAuthSession.ts +++ b/packages/ui/src/features/auth/useAuthSession.ts @@ -1,5 +1,5 @@ import { useHostTRPCClient } from "@posthog/host-router/react"; -import { BILLING_FLAG, USAGE_BILLING_FLAG } from "@posthog/shared"; +import { BILLING_FLAG } from "@posthog/shared"; import { useSeatStore } from "@posthog/ui/features/billing/seatStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { @@ -112,18 +112,16 @@ function useSeatSync( billingEnabled: boolean, ): void { const queryClient = useQueryClient(); - // Seats are retired under usage-based billing — provisioning one would - // error against the retired seat API. - const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); useEffect(() => { - if (!authIdentity || !billingEnabled || usageBillingEnabled) { + if (!authIdentity || !billingEnabled) { useSeatStore.getState().reset(); return; } + // No-ops (and resets) once seats are retired — the store owns that gate. void useSeatStore.getState().fetchSeat({ autoProvision: true }); void queryClient.invalidateQueries({ queryKey: [["llmGateway"]] }); - }, [authIdentity, billingEnabled, usageBillingEnabled, queryClient]); + }, [authIdentity, billingEnabled, queryClient]); } export function useAuthSession() { diff --git a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx index cde4f9984d..8a86338cc2 100644 --- a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx +++ b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx @@ -18,11 +18,13 @@ import { useBillingAnnouncementStore } from "./billingAnnouncementStore"; export function UsageBillingAnnouncementModal() { const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); const acknowledged = useBillingAnnouncementStore((s) => s.acknowledged); + const hasHydrated = useBillingAnnouncementStore((s) => s._hasHydrated); const acknowledge = useBillingAnnouncementStore((s) => s.acknowledge); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const isLoggedIn = useAuthStateValue((state) => state.currentOrgId !== null); - const isOpen = usageBillingEnabled && isLoggedIn && !acknowledged; + const isOpen = + usageBillingEnabled && isLoggedIn && hasHydrated && !acknowledged; useEffect(() => { if (isOpen) { diff --git a/packages/ui/src/features/billing/UsageLimitModal.tsx b/packages/ui/src/features/billing/UsageLimitModal.tsx index f037247efb..19c7fb88a4 100644 --- a/packages/ui/src/features/billing/UsageLimitModal.tsx +++ b/packages/ui/src/features/billing/UsageLimitModal.tsx @@ -1,9 +1,11 @@ import { WarningCircle } from "@phosphor-icons/react"; +import { formatResetTime } from "@posthog/core/billing/usageDisplay"; import { - formatResetTime, - PRO_USAGE_MULTIPLIER, -} from "@posthog/core/billing/usageDisplay"; -import { type GatewayLimitCause, USAGE_BILLING_FLAG } from "@posthog/shared"; + deriveUsageLimitCause, + seatEraLimitContent, + usageBasedLimitContent, +} from "@posthog/core/billing/usageLimitContent"; +import { USAGE_BILLING_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS, type UpgradePromptCause, @@ -23,97 +25,6 @@ import { useUsage } from "./useUsage"; const SUPPORT_MAILTO = "mailto:charles@posthog.com?subject=PostHog%20Code%20%E2%80%94%20Pro%20usage%20limit"; -interface ModalContent { - title: string; - description: string; - /** Primary action label; null renders only the dismiss button. */ - actionLabel: string | null; - dismissLabel: string; -} - -function usageBasedContent(args: { - cause: GatewayLimitCause; - model: string | null; - resetLabel: string | null; - billed: boolean | undefined; -}): ModalContent { - 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", - }; - } - - 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", - }; -} - -function seatEraContent(args: { - bucket: "burst" | "sustained" | null; - isPro: boolean; - resetLabel: string | null; -}): ModalContent { - 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", - }; -} - export function UsageLimitModal() { const isOpen = useUsageLimitStore((s) => s.isOpen); const bucket = useUsageLimitStore((s) => s.bucket); @@ -129,17 +40,8 @@ export function UsageLimitModal() { const { usage } = useUsage({ enabled: usageBillingEnabled && isOpen }); const isPro = eventIsPro ?? seatIsPro; - // Legacy callers only know the bucket; map it onto the usage-based cause. - // No bucket exceeded means the org's credit bucket tripped the limit. - const effectiveCause: GatewayLimitCause = - cause ?? - (bucket === "burst" - ? "user_daily_limit" - : bucket === "sustained" - ? "user_monthly_limit" - : "org_limit"); const trackedCause: UpgradePromptCause | undefined = usageBillingEnabled - ? effectiveCause + ? deriveUsageLimitCause(cause, bucket) : undefined; useEffect(() => { @@ -154,13 +56,13 @@ export function UsageLimitModal() { const resetLabel = resetAt ? formatResetTime(resetAt) : null; const content = usageBillingEnabled - ? usageBasedContent({ - cause: effectiveCause, + ? usageBasedLimitContent({ + cause: deriveUsageLimitCause(cause, bucket), model, resetLabel, billed: usage?.code_usage_billed, }) - : seatEraContent({ bucket, isPro, resetLabel }); + : seatEraLimitContent({ bucket, isPro, resetLabel }); const handleAction = () => { track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { diff --git a/packages/ui/src/features/billing/billingAnnouncementStore.ts b/packages/ui/src/features/billing/billingAnnouncementStore.ts index c223491165..87a799a7ac 100644 --- a/packages/ui/src/features/billing/billingAnnouncementStore.ts +++ b/packages/ui/src/features/billing/billingAnnouncementStore.ts @@ -1,37 +1,31 @@ +import { electronStorage } from "@posthog/ui/shell/rendererStorage"; import { create } from "zustand"; - -const STORAGE_KEY = "posthog-code-usage-billing-acknowledged"; - -function readAcknowledged(): boolean { - try { - return window.localStorage.getItem(STORAGE_KEY) === "true"; - } catch { - return false; - } -} +import { persist } from "zustand/middleware"; interface BillingAnnouncementState { acknowledged: boolean; -} - -interface BillingAnnouncementActions { + // Hydration is async (Electron storage over IPC); the announcement must not + // flash open before a persisted acknowledgment has been read back. + _hasHydrated: boolean; acknowledge: () => void; + setHasHydrated: (hydrated: boolean) => void; } -type BillingAnnouncementStore = BillingAnnouncementState & - BillingAnnouncementActions; - -export const useBillingAnnouncementStore = create()( - (set) => ({ - acknowledged: readAcknowledged(), - acknowledge: () => { - try { - window.localStorage.setItem(STORAGE_KEY, "true"); - } catch { - // Persistence failing only means the announcement shows again next - // launch; the acknowledgment event still records on the person. - } - set({ acknowledged: true }); +export const useBillingAnnouncementStore = create()( + persist( + (set) => ({ + acknowledged: false, + _hasHydrated: false, + acknowledge: () => set({ acknowledged: true }), + setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }), + }), + { + name: "posthog-code-usage-billing-acknowledged", + storage: electronStorage, + partialize: (state) => ({ acknowledged: state.acknowledged }), + onRehydrateStorage: () => (state) => { + state?.setHasHydrated(true); + }, }, - }), + ), ); diff --git a/packages/ui/src/features/billing/preflightCloudUsage.ts b/packages/ui/src/features/billing/preflightCloudUsage.ts index ca5447b4e8..5ab17f7c70 100644 --- a/packages/ui/src/features/billing/preflightCloudUsage.ts +++ b/packages/ui/src/features/billing/preflightCloudUsage.ts @@ -17,20 +17,18 @@ import { const log = logger.scope("preflight-cloud-usage"); function usageLimitArgs(usage: UsageOutput): UsageLimitShowArgs { - // Prefer the bucket that's actually exceeded (burst/daily takes priority). - // If neither is flagged, is_rate_limited came from the org's credit bucket - // (allocation or billing limit exhausted) — say so, with the monthly reset - // as the best available timing hint. - if (!usage.burst.exceeded && !usage.sustained.exceeded) { - return { - bucket: "sustained", - resetAt: usage.sustained.reset_at, - isPro: usage.is_pro, - cause: "org_limit", - }; - } + // 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 (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 { diff --git a/packages/ui/src/features/billing/seatStore.ts b/packages/ui/src/features/billing/seatStore.ts index dde6133a84..e186c1963d 100644 --- a/packages/ui/src/features/billing/seatStore.ts +++ b/packages/ui/src/features/billing/seatStore.ts @@ -4,8 +4,9 @@ import type { SeatService, } from "@posthog/core/billing/seatService"; import { resolveService } from "@posthog/di/container"; -import type { SeatData } from "@posthog/shared"; +import { type SeatData, USAGE_BILLING_FLAG } from "@posthog/shared"; import { create } from "zustand"; +import { isFeatureFlagEnabled } from "../../shell/posthogAnalyticsImpl"; interface SeatStoreState { seat: SeatData | null; @@ -55,10 +56,21 @@ function applyResult( }); } +// Seats are retired under usage-based billing — fetching or provisioning one +// would error against the retired seat API. Gated here, at the single entry +// point, so the auth/onboarding/settings callers can stay unconditional. +function seatsRetired(): boolean { + return isFeatureFlagEnabled(USAGE_BILLING_FLAG); +} + export const useSeatStore = create()((set, get) => ({ ...initialState, fetchSeat: async (options?: { autoProvision?: boolean }) => { + if (seatsRetired()) { + set(initialState); + return; + } set({ isLoading: true, error: null, redirectUrl: null }); const service = resolveService(SEAT_SERVICE); const result = await service.fetchSeat({ @@ -69,6 +81,10 @@ export const useSeatStore = create()((set, get) => ({ }, provisionFreeSeat: async () => { + if (seatsRetired()) { + set(initialState); + return; + } set({ isLoading: true, error: null, redirectUrl: null }); const result = await resolveService(SEAT_SERVICE).provisionFreeSeat(); diff --git a/packages/ui/src/features/billing/useFreeUsage.ts b/packages/ui/src/features/billing/useFreeUsage.ts index d2137d7909..edb48a8e54 100644 --- a/packages/ui/src/features/billing/useFreeUsage.ts +++ b/packages/ui/src/features/billing/useFreeUsage.ts @@ -1,3 +1,4 @@ +import { isCodeUsageUnbilled } from "@posthog/core/billing/usageDisplay"; import type { UsageOutput } from "@posthog/core/usage/schemas"; import { USAGE_BILLING_FLAG } from "@posthog/shared"; import { useFeatureFlag } from "../feature-flags/useFeatureFlag"; @@ -24,7 +25,7 @@ export function useFreeUsage(billingEnabled: boolean): FreeUsageResult { const { usage, isLoading } = useUsage({ enabled: eligible }); if (!eligible) return { usage: null, isLoading: false }; - if (usageBillingEnabled && usage?.code_usage_billed !== false) { + if (usageBillingEnabled && !isCodeUsageUnbilled(usage)) { // Billed org (no per-user caps to meter) or billed state unknown: the // free-tier bar would be noise or wrong — render nothing. return { usage: null, isLoading: usage ? false : isLoading }; diff --git a/packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx b/packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx index bc3d28a5f5..13f83e9a44 100644 --- a/packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx +++ b/packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx @@ -13,7 +13,7 @@ import { ComboboxList, ComboboxTrigger, } from "@posthog/quill"; -import { BILLING_FLAG, USAGE_BILLING_FLAG } from "@posthog/shared"; +import { BILLING_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { happyHog } from "@posthog/ui/assets/hedgehogs"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; @@ -77,9 +77,6 @@ export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { client, }); const billingEnabled = useFeatureFlag(BILLING_FLAG); - // Seats are retired under usage-based billing — provisioning one on org - // switch would error against the retired seat API. - const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); const switchOrgTrpcMutation = useSwitchOrgMutation(); const organizations = useMemo(() => { @@ -113,7 +110,8 @@ export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { const switchOrgMutation = useMutation({ mutationFn: async (orgId: string) => { await switchOrgTrpcMutation.mutateAsync(orgId); - if (billingEnabled && !usageBillingEnabled) { + if (billingEnabled) { + // No-ops once seats are retired — the seat store owns that gate. void useSeatStore.getState().fetchSeat({ autoProvision: true }); } }, diff --git a/packages/ui/src/features/sessions/components/ModelSelector.tsx b/packages/ui/src/features/sessions/components/ModelSelector.tsx index 0eb02d1026..13edaf5be8 100644 --- a/packages/ui/src/features/sessions/components/ModelSelector.tsx +++ b/packages/ui/src/features/sessions/components/ModelSelector.tsx @@ -12,13 +12,8 @@ import { DropdownMenuTrigger, MenuLabel, } from "@posthog/quill"; -import { - type Adapter, - GLM_MODEL_FLAG, - USAGE_BILLING_FLAG, -} from "@posthog/shared"; +import type { Adapter } from "@posthog/shared"; import { gateRestrictedModelPick } from "@posthog/ui/features/billing/modelGate"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { ModelRadioItem } from "@posthog/ui/features/sessions/components/ModelRadioItem"; import { stripGlmModelOption } from "@posthog/ui/features/sessions/modelOptionFilters"; import { @@ -27,6 +22,7 @@ import { useSessionIsCloud, useSessionSelector, } from "@posthog/ui/features/sessions/sessionStore"; +import { useGlmModelVisible } from "@posthog/ui/features/sessions/useGlmModelVisible"; import { Fragment, useMemo } from "react"; interface ModelSelectorProps { @@ -47,12 +43,9 @@ export function ModelSelector({ const sessionStatus = useSessionSelector(taskId, (s) => s?.status); const sessionIsCloud = useSessionIsCloud(taskId); const rawModelOption = useModelConfigOptionForTask(taskId); - const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); - // Under usage-based billing GLM is the free tier's included model — it must - // stay pickable regardless of the staged GLM rollout flag. - const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); + const glmVisible = useGlmModelVisible(); const modelOption = - glmEnabled || usageBillingEnabled || !rawModelOption + glmVisible || !rawModelOption ? rawModelOption : stripGlmModelOption(rawModelOption); diff --git a/packages/ui/src/features/sessions/useGlmModelVisible.ts b/packages/ui/src/features/sessions/useGlmModelVisible.ts new file mode 100644 index 0000000000..c7b8d4d88f --- /dev/null +++ b/packages/ui/src/features/sessions/useGlmModelVisible.ts @@ -0,0 +1,13 @@ +import { GLM_MODEL_FLAG, USAGE_BILLING_FLAG } from "@posthog/shared"; +import { useFeatureFlag } from "../feature-flags/useFeatureFlag"; + +/** + * Whether GLM models are pickable: the staged GLM rollout flag, or usage-based + * billing — GLM is the free tier's included model, so it must stay pickable + * regardless of the rollout flag once the cutover flips. + */ +export function useGlmModelVisible(): boolean { + const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); + const usageBillingEnabled = useFeatureFlag(USAGE_BILLING_FLAG); + return glmEnabled || usageBillingEnabled; +} diff --git a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx index b7f8514fab..c1b1d2f2fc 100644 --- a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx +++ b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx @@ -4,7 +4,10 @@ import { Info, WarningCircle, } from "@phosphor-icons/react"; -import { PRO_USAGE_MULTIPLIER } from "@posthog/core/billing/usageDisplay"; +import { + isCodeUsageUnbilled, + PRO_USAGE_MULTIPLIER, +} from "@posthog/core/billing/usageDisplay"; import type { UsageOutput } from "@posthog/core/usage/schemas"; import { Empty, @@ -108,13 +111,12 @@ export function PlanUsageSettings() { }); useEffect(() => { - // Seats are retired under usage-based billing — provisioning one would - // error against the retired seat API. - if (!usageBillingEnabled) void fetchSeat({ autoProvision: true }); + // No-ops once seats are retired — the seat store owns that gate. + void fetchSeat({ autoProvision: true }); // refetchUsage is a refresh mutation, so it bypasses useUsage's `enabled` // gate — skip it for spend-only users. if (billingEnabled) void refetchUsage(); - }, [fetchSeat, refetchUsage, billingEnabled, usageBillingEnabled]); + }, [fetchSeat, refetchUsage, billingEnabled]); useTrackUsageViewed({ isLoading: billingEnabled && (isLoading || usageLoading), @@ -526,8 +528,9 @@ function UsageBasedPlanUsage({ billingUrl, onOpenBilling, }: UsageBasedPlanUsageProps) { - // Absent on gateways predating the field — treat as unknown, not free. - const billed = usage?.code_usage_billed; + // Tri-state: unknown (absent field) must render as billed, never as free. + const unbilled = isCodeUsageUnbilled(usage); + const billed = usage?.code_usage_billed === true; const orgLimitReached = usage?.ai_credits?.exhausted === true; return ( @@ -568,15 +571,15 @@ function UsageBasedPlanUsage({ - {billed === false ? "Free tier" : "Usage-based billing"} + {unbilled ? "Free tier" : "Usage-based billing"} - {billed === false + {unbilled ? "Your organization's first $20 of usage each month is included, with access to open models. Add a payment method to unlock premium models — you only pay for what you use." : "Your organization pays for PostHog Code usage at cost — no seats, no subscriptions. The first $20 each month is included."} - {billed === true && ( + {billed && ( Active @@ -584,14 +587,12 @@ function UsageBasedPlanUsage({ @@ -607,7 +608,7 @@ function UsageBasedPlanUsage({ > - ) : billed === false && usage ? ( + ) : unbilled && usage ? ( state.cloudRegion); const apiHost = useMemo( () => (cloudRegion ? getCloudUrlFromRegion(cloudRegion) : null), From ed5567dafd65c0526d18e69fc3eba37602fe3542 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 15 Jul 2026 16:54:38 -0400 Subject: [PATCH 4/4] refactor(billing): rename subscription bit to code_usage_subscribed Matches the gateway field rename: the bit is the org's subscription status (payment method on file), and "billed" next to is_pro read ambiguously. Mechanical rename across the schema, gateway service, limit-content derivation, and billing UI; is_pro semantics untouched. Generated-By: PostHog Code Task-Id: bfbcd747-a365-429f-a5dc-622e2c2f7edf --- packages/core/src/billing/usageDisplay.ts | 13 +++++----- .../src/billing/usageLimitContent.test.ts | 18 +++++++------- .../core/src/billing/usageLimitContent.ts | 8 +++---- .../core/src/llm-gateway/llm-gateway.test.ts | 12 +++++----- packages/core/src/llm-gateway/llm-gateway.ts | 22 ++++++++--------- packages/core/src/usage/schemas.ts | 7 +++--- .../src/features/billing/UsageLimitModal.tsx | 4 ++-- .../ui/src/features/billing/useFreeUsage.ts | 8 +++---- .../settings/sections/PlanUsageSettings.tsx | 24 ++++++++++--------- 9 files changed, 60 insertions(+), 56 deletions(-) diff --git a/packages/core/src/billing/usageDisplay.ts b/packages/core/src/billing/usageDisplay.ts index 20b57e45a4..4e5e4f7103 100644 --- a/packages/core/src/billing/usageDisplay.ts +++ b/packages/core/src/billing/usageDisplay.ts @@ -10,14 +10,15 @@ export function isUsageExceeded(usage: UsageOutput): boolean { } /** - * The org is confirmed on the free tier (not billed for Code usage). False - * when billed OR when the state is unknown — `code_usage_billed` is absent on - * gateways predating the field, and absence must never read as free. + * The org is confirmed on the free tier (not subscribed to Code usage + * billing). False when subscribed OR when the state is unknown — + * `code_usage_subscribed` is absent on gateways predating the field, and + * absence must never read as free. */ -export function isCodeUsageUnbilled( - usage: Pick | null | undefined, +export function isCodeUsageUnsubscribed( + usage: Pick | null | undefined, ): boolean { - return usage?.code_usage_billed === false; + return usage?.code_usage_subscribed === false; } export function formatResetTime( diff --git a/packages/core/src/billing/usageLimitContent.test.ts b/packages/core/src/billing/usageLimitContent.test.ts index f51782c987..6ca4471764 100644 --- a/packages/core/src/billing/usageLimitContent.test.ts +++ b/packages/core/src/billing/usageLimitContent.test.ts @@ -22,7 +22,7 @@ describe("usageBasedLimitContent", () => { cause: "model_gate", model: "Claude Opus 4.8", resetLabel: null, - billed: false, + subscribed: false, }); expect(content.title).toBe("Unlock premium models"); expect(content.description).toContain("Claude Opus 4.8 isn't"); @@ -34,26 +34,26 @@ describe("usageBasedLimitContent", () => { cause: "model_gate", model: null, resetLabel: null, - billed: undefined, + subscribed: undefined, }); expect(content.description).toContain("This model isn't"); }); it.each([ - // Confirmed-free org: allocation used up, the fix is adding a card. + // Confirmed-unsubscribed 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. + // Subscribed org: the fix is raising the spend limit. [true, "Organization usage limit reached", "Manage billing"], - // Unknown billed state must not read as free. + // Unknown subscription 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) => { + "org_limit with subscribed=%s -> %s / %s", + (subscribed, title, actionLabel) => { const content = usageBasedLimitContent({ cause: "org_limit", model: null, resetLabel: null, - billed, + subscribed, }); expect(content.title).toBe(title); expect(content.actionLabel).toBe(actionLabel); @@ -68,7 +68,7 @@ describe("usageBasedLimitContent", () => { cause, model: null, resetLabel: "Resets in 2h", - billed: false, + subscribed: false, }); expect(content.title).toBe(title); expect(content.description).toContain("Resets in 2h"); diff --git a/packages/core/src/billing/usageLimitContent.ts b/packages/core/src/billing/usageLimitContent.ts index 7205927a9d..b321aac8c9 100644 --- a/packages/core/src/billing/usageLimitContent.ts +++ b/packages/core/src/billing/usageLimitContent.ts @@ -27,10 +27,10 @@ export function usageBasedLimitContent(args: { cause: GatewayLimitCause; model: string | null; resetLabel: string | null; - /** usage.code_usage_billed — absent means unknown, not free. */ - billed: boolean | undefined; + /** usage.code_usage_subscribed — absent means unknown, not free. */ + subscribed: boolean | undefined; }): UsageLimitContent { - const { cause, model, resetLabel, billed } = args; + const { cause, model, resetLabel, subscribed } = args; if (cause === "model_gate") { return { @@ -42,7 +42,7 @@ export function usageBasedLimitContent(args: { } if (cause === "org_limit") { - if (billed === false) { + if (subscribed === false) { return { title: "Free usage used up", description: diff --git a/packages/core/src/llm-gateway/llm-gateway.test.ts b/packages/core/src/llm-gateway/llm-gateway.test.ts index c292c997c0..84bbe341f3 100644 --- a/packages/core/src/llm-gateway/llm-gateway.test.ts +++ b/packages/core/src/llm-gateway/llm-gateway.test.ts @@ -202,14 +202,14 @@ describe("LlmGatewayService.prompt", () => { 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 () => { + 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 "unbilled" from the gate's 403. + // First call learns "unsubscribed" from the gate's 403. await service.prompt([{ role: "user", content: "hi" }], { model: "claude-haiku-4-5", }); @@ -303,7 +303,7 @@ describe("LlmGatewayService.fetchUsage", () => { createJsonResponse({ ...USAGE_BODY, ai_credits: { exhausted: true }, - code_usage_billed: true, + code_usage_subscribed: true, }), ); const { service } = createService(fetchMock); @@ -311,14 +311,14 @@ describe("LlmGatewayService.fetchUsage", () => { const usage = await service.fetchUsage(); expect(usage.ai_credits?.exhausted).toBe(true); - expect(usage.code_usage_billed).toBe(true); + expect(usage.code_usage_subscribed).toBe(true); }); - it("feeds code_usage_billed into helper model routing", async () => { + it("feeds code_usage_subscribed into helper model routing", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( - createJsonResponse({ ...USAGE_BODY, code_usage_billed: false }), + createJsonResponse({ ...USAGE_BODY, code_usage_subscribed: false }), ) .mockResolvedValue(createJsonResponse(SUCCESS_BODY)); const { service } = createService(fetchMock); diff --git a/packages/core/src/llm-gateway/llm-gateway.ts b/packages/core/src/llm-gateway/llm-gateway.ts index e47332d8cb..fe81d26701 100644 --- a/packages/core/src/llm-gateway/llm-gateway.ts +++ b/packages/core/src/llm-gateway/llm-gateway.ts @@ -60,11 +60,11 @@ 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 told us either way — old gateways - // omit the field entirely. - private lastKnownCodeUsageBilled: boolean | null = null; + // Last code_usage_subscribed 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 told us either way — old + // gateways omit the field entirely. + private lastKnownCodeUsageSubscribed: boolean | null = null; async prompt( messages: LlmMessage[], @@ -89,7 +89,7 @@ export class LlmGatewayService { // free tier, route helpers to the free-tier model upfront instead of // burning a request on the model gate's 403. const model = - this.lastKnownCodeUsageBilled === false + this.lastKnownCodeUsageSubscribed === false ? FREE_TIER_GATEWAY_MODEL : requested; try { @@ -100,10 +100,10 @@ export class LlmGatewayService { error.statusCode === 403 && classifyGatewayLimitError(error.message) === "model_gate"; if (!isModelGate || model === FREE_TIER_GATEWAY_MODEL) throw error; - // Backstop for a stale billed bit (org just lost billing): the gate - // itself is authoritative that the org isn't billed, so remember that + // Backstop for a stale subscription bit (org just unsubscribed): the gate + // itself is authoritative that the org isn't subscribed, so remember that // and degrade this call instead of failing it. - this.lastKnownCodeUsageBilled = false; + this.lastKnownCodeUsageSubscribed = false; this.log.warn("Model gated for free tier, retrying on free-tier model", { model, fallbackModel: FREE_TIER_GATEWAY_MODEL, @@ -282,8 +282,8 @@ export class LlmGatewayService { } const usage = usageOutput.parse(await response.json()); - if (usage.code_usage_billed !== undefined) { - this.lastKnownCodeUsageBilled = usage.code_usage_billed; + if (usage.code_usage_subscribed !== undefined) { + this.lastKnownCodeUsageSubscribed = usage.code_usage_subscribed; } return usage; } diff --git a/packages/core/src/usage/schemas.ts b/packages/core/src/usage/schemas.ts index cfaafa3370..35146d5e11 100644 --- a/packages/core/src/usage/schemas.ts +++ b/packages/core/src/usage/schemas.ts @@ -18,9 +18,10 @@ export const usageOutput = z.object({ 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, not false. - code_usage_billed: z.boolean().optional(), + // True when the org is subscribed to Code usage billing (payment method + // on file). Absent on gateways that predate the field — treat absence as + // unknown, not false. + code_usage_subscribed: z.boolean().optional(), billing_period_end: z.string().datetime().nullable().optional(), }); diff --git a/packages/ui/src/features/billing/UsageLimitModal.tsx b/packages/ui/src/features/billing/UsageLimitModal.tsx index 19c7fb88a4..9ec76acf92 100644 --- a/packages/ui/src/features/billing/UsageLimitModal.tsx +++ b/packages/ui/src/features/billing/UsageLimitModal.tsx @@ -36,7 +36,7 @@ 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. + // The org's Code usage subscription — picks the org_limit copy variant. const { usage } = useUsage({ enabled: usageBillingEnabled && isOpen }); const isPro = eventIsPro ?? seatIsPro; @@ -60,7 +60,7 @@ export function UsageLimitModal() { cause: deriveUsageLimitCause(cause, bucket), model, resetLabel, - billed: usage?.code_usage_billed, + subscribed: usage?.code_usage_subscribed, }) : seatEraLimitContent({ bucket, isPro, resetLabel }); diff --git a/packages/ui/src/features/billing/useFreeUsage.ts b/packages/ui/src/features/billing/useFreeUsage.ts index edb48a8e54..1518f759cc 100644 --- a/packages/ui/src/features/billing/useFreeUsage.ts +++ b/packages/ui/src/features/billing/useFreeUsage.ts @@ -1,4 +1,4 @@ -import { isCodeUsageUnbilled } from "@posthog/core/billing/usageDisplay"; +import { isCodeUsageUnsubscribed } from "@posthog/core/billing/usageDisplay"; import type { UsageOutput } from "@posthog/core/usage/schemas"; import { USAGE_BILLING_FLAG } from "@posthog/shared"; import { useFeatureFlag } from "../feature-flags/useFeatureFlag"; @@ -17,7 +17,7 @@ export function useFreeUsage(billingEnabled: boolean): FreeUsageResult { const { seat, isPro } = useSeat(); const seatLoaded = seat !== null; // Seat era: free-seat holders only. Usage era: seats are gone — fetch for - // everyone, then show only orgs the gateway confirms as unbilled (the free + // everyone, then show only orgs the gateway confirms unsubscribed (the free // tier's per-user allowance is the meaningful meter). const eligible = usageBillingEnabled ? billingEnabled @@ -25,8 +25,8 @@ export function useFreeUsage(billingEnabled: boolean): FreeUsageResult { const { usage, isLoading } = useUsage({ enabled: eligible }); if (!eligible) return { usage: null, isLoading: false }; - if (usageBillingEnabled && !isCodeUsageUnbilled(usage)) { - // Billed org (no per-user caps to meter) or billed state unknown: the + if (usageBillingEnabled && !isCodeUsageUnsubscribed(usage)) { + // Subscribed org (no per-user caps to meter) or unknown state: the // free-tier bar would be noise or wrong — render nothing. return { usage: null, isLoading: usage ? false : isLoading }; } diff --git a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx index c1b1d2f2fc..3046d4ac9e 100644 --- a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx +++ b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx @@ -5,7 +5,7 @@ import { WarningCircle, } from "@phosphor-icons/react"; import { - isCodeUsageUnbilled, + isCodeUsageUnsubscribed, PRO_USAGE_MULTIPLIER, } from "@posthog/core/billing/usageDisplay"; import type { UsageOutput } from "@posthog/core/usage/schemas"; @@ -528,9 +528,9 @@ function UsageBasedPlanUsage({ billingUrl, onOpenBilling, }: UsageBasedPlanUsageProps) { - // Tri-state: unknown (absent field) must render as billed, never as free. - const unbilled = isCodeUsageUnbilled(usage); - const billed = usage?.code_usage_billed === true; + // Tri-state: unknown (absent field) must render as subscribed, never free. + const unsubscribed = isCodeUsageUnsubscribed(usage); + const subscribed = usage?.code_usage_subscribed === true; const orgLimitReached = usage?.ai_credits?.exhausted === true; return ( @@ -571,15 +571,15 @@ function UsageBasedPlanUsage({ - {unbilled ? "Free tier" : "Usage-based billing"} + {unsubscribed ? "Free tier" : "Usage-based billing"} - {unbilled + {unsubscribed ? "Your organization's first $20 of usage each month is included, with access to open models. Add a payment method to unlock premium models — you only pay for what you use." - : "Your organization pays for PostHog Code usage at cost — no seats, no subscriptions. The first $20 each month is included."} + : "Your organization pays for PostHog Code usage at cost — no seats, no per-user plans. The first $20 each month is included."} - {billed && ( + {subscribed && ( Active @@ -587,12 +587,14 @@ function UsageBasedPlanUsage({ @@ -608,7 +610,7 @@ function UsageBasedPlanUsage({ > - ) : unbilled && usage ? ( + ) : unsubscribed && usage ? (