Skip to content

Commit 092903f

Browse files
authored
feat(billing): classify gateway billing denials and rework the usage-limit modal for usage-based billing (#3485)
1 parent 73b695c commit 092903f

18 files changed

Lines changed: 592 additions & 131 deletions
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, it } from "vitest";
2+
import { usageLimitContent } from "./usageLimitContent";
3+
4+
describe("usageLimitContent", () => {
5+
it("offers a payment method for the model gate", () => {
6+
const content = usageLimitContent({
7+
cause: "model_gate",
8+
resetLabel: null,
9+
subscribed: false,
10+
});
11+
expect(content.title).toBe("Unlock premium models");
12+
expect(content.description).toContain("This model isn't");
13+
expect(content.actionLabel).toBe("Add payment method");
14+
});
15+
16+
it.each([
17+
// Confirmed-free org: allocation used up, the fix is adding a card.
18+
[false, "Free usage used up", "Add payment method"],
19+
// Subscribed org: the fix is raising the spend limit.
20+
[true, "Organization usage limit reached", "Manage billing"],
21+
// Unknown subscription state must not read as free.
22+
[undefined, "Organization usage limit reached", "Manage billing"],
23+
] as const)(
24+
"org_limit with subscribed=%s -> %s / %s",
25+
(subscribed, title, actionLabel) => {
26+
const content = usageLimitContent({
27+
cause: "org_limit",
28+
resetLabel: null,
29+
subscribed,
30+
});
31+
expect(content.title).toBe(title);
32+
expect(content.actionLabel).toBe(actionLabel);
33+
},
34+
);
35+
36+
it("includes the reset hint in the free-tier copy when available", () => {
37+
const content = usageLimitContent({
38+
cause: "org_limit",
39+
resetLabel: "Resets in 3h",
40+
subscribed: false,
41+
});
42+
expect(content.title).toBe("Free usage used up");
43+
expect(content.description).toContain("Resets in 3h");
44+
});
45+
46+
it("renders generic copy without a billing CTA when the cause is unknown", () => {
47+
const content = usageLimitContent({
48+
cause: null,
49+
resetLabel: "Resets in 2h",
50+
subscribed: true,
51+
});
52+
expect(content.title).toBe("Usage limit reached");
53+
expect(content.description).toContain("Resets in 2h");
54+
expect(content.actionLabel).toBeNull();
55+
expect(content.dismissLabel).toBe("Got it");
56+
});
57+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { GatewayLimitCause } from "@posthog/shared";
2+
3+
export interface UsageLimitContent {
4+
title: string;
5+
description: string;
6+
actionLabel: string | null;
7+
dismissLabel: string;
8+
}
9+
10+
export function usageLimitContent(args: {
11+
cause: GatewayLimitCause | null;
12+
resetLabel: string | null;
13+
subscribed: boolean | undefined;
14+
}): UsageLimitContent {
15+
const { cause, resetLabel, subscribed } = args;
16+
17+
if (cause === "model_gate") {
18+
return {
19+
title: "Unlock premium models",
20+
description:
21+
"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.",
22+
actionLabel: "Add payment method",
23+
dismissLabel: "Not now",
24+
};
25+
}
26+
27+
if (cause === "org_limit") {
28+
if (subscribed === false) {
29+
return {
30+
title: "Free usage used up",
31+
description: `Your organization has used up its included PostHog Code usage.${
32+
resetLabel ? ` ${resetLabel}.` : ""
33+
} Add a payment method to keep going — you only pay for what you use.`,
34+
actionLabel: "Add payment method",
35+
dismissLabel: "Not now",
36+
};
37+
}
38+
return {
39+
title: "Organization usage limit reached",
40+
description:
41+
"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.",
42+
actionLabel: "Manage billing",
43+
dismissLabel: "Got it",
44+
};
45+
}
46+
47+
// Not a billing denial (e.g. an upstream provider's own rate limit) —
48+
// don't send the user to billing for something billing can't fix.
49+
return {
50+
title: "Usage limit reached",
51+
description: `PostHog Code hit a usage limit.${
52+
resetLabel ? ` ${resetLabel}.` : ""
53+
} Please try again shortly.`,
54+
actionLabel: null,
55+
dismissLabel: "Got it",
56+
};
57+
}

packages/core/src/llm-gateway/llm-gateway.test.ts

Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { AuthService } from "../auth/auth";
23
import type {
34
LlmGatewayAuth,
45
LlmGatewayEndpoints,
@@ -43,8 +44,25 @@ function createService(
4344
};
4445
const logger = { ...log, scope: () => log };
4546

46-
const service = new LlmGatewayService(host, logger);
47-
return { service, auth, endpoints, log };
47+
const orgListeners: Array<(state: { currentOrgId: string | null }) => void> =
48+
[];
49+
const authService = {
50+
getState: () => ({ currentOrgId: "org-1" }),
51+
on: (
52+
_event: string,
53+
listener: (state: { currentOrgId: string | null }) => void,
54+
) => {
55+
orgListeners.push(listener);
56+
},
57+
} as unknown as AuthService;
58+
const emitAuthState = (currentOrgId: string | null) => {
59+
for (const listener of orgListeners) {
60+
listener({ currentOrgId });
61+
}
62+
};
63+
64+
const service = new LlmGatewayService(host, logger, authService);
65+
return { service, auth, endpoints, log, emitAuthState };
4866
}
4967

5068
const SUCCESS_BODY = {
@@ -157,6 +175,137 @@ describe("LlmGatewayService.prompt", () => {
157175
});
158176
});
159177

178+
it("surfaces a FastAPI bare-string detail as the error message", async () => {
179+
const fetchMock = vi.fn().mockResolvedValue(
180+
createJsonResponse(
181+
{
182+
detail: "OAuth application not authorized for product 'posthog_code'",
183+
},
184+
403,
185+
),
186+
);
187+
const { service } = createService(fetchMock);
188+
189+
await expect(
190+
service.prompt([{ role: "user", content: "hi" }]),
191+
).rejects.toMatchObject({
192+
name: "LlmGatewayError",
193+
message: "OAuth application not authorized for product 'posthog_code'",
194+
type: "unknown_error",
195+
statusCode: 403,
196+
});
197+
});
198+
199+
// The free-tier model gate's 403 body, as the gateway serves it.
200+
const MODEL_GATE_BODY = {
201+
error: {
202+
message:
203+
"Model 'claude-haiku-4-5' needs a paid PostHog plan. Models available on the free tier: @cf/zai-org/glm-5.2. Add a payment method to your organization to unlock all models. (rate_limit)",
204+
type: "permission_error",
205+
code: "model_gate",
206+
},
207+
};
208+
209+
it("retries once on the free-tier model when the model gate 403s", async () => {
210+
const fetchMock = vi
211+
.fn()
212+
.mockResolvedValueOnce(createJsonResponse(MODEL_GATE_BODY, 403))
213+
.mockResolvedValueOnce(createJsonResponse(SUCCESS_BODY));
214+
const { service } = createService(fetchMock);
215+
216+
const result = await service.prompt([{ role: "user", content: "hi" }], {
217+
model: "claude-haiku-4-5",
218+
});
219+
220+
expect(result.content).toBe("hello world");
221+
expect(fetchMock).toHaveBeenCalledTimes(2);
222+
const firstBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
223+
const retryBody = JSON.parse(fetchMock.mock.calls[1][1].body as string);
224+
expect(firstBody.model).toBe("claude-haiku-4-5");
225+
expect(retryBody.model).toBe("@cf/zai-org/glm-5.2");
226+
});
227+
228+
it("routes straight to the free-tier model once the org is known unsubscribed", async () => {
229+
const fetchMock = vi
230+
.fn()
231+
.mockResolvedValueOnce(createJsonResponse(MODEL_GATE_BODY, 403))
232+
.mockImplementation(async () => createJsonResponse(SUCCESS_BODY));
233+
const { service } = createService(fetchMock);
234+
235+
// First call learns "unsubscribed" from the gate's 403.
236+
await service.prompt([{ role: "user", content: "hi" }], {
237+
model: "claude-haiku-4-5",
238+
});
239+
// Second call must not burn a round trip on the gate.
240+
await service.prompt([{ role: "user", content: "hi" }], {
241+
model: "claude-haiku-4-5",
242+
});
243+
244+
expect(fetchMock).toHaveBeenCalledTimes(3);
245+
const thirdBody = JSON.parse(fetchMock.mock.calls[2][1].body as string);
246+
expect(thirdBody.model).toBe("@cf/zai-org/glm-5.2");
247+
});
248+
249+
it("forgets the learned subscription state when the organization changes", async () => {
250+
const fetchMock = vi
251+
.fn()
252+
.mockResolvedValueOnce(createJsonResponse(MODEL_GATE_BODY, 403))
253+
.mockImplementation(async () => createJsonResponse(SUCCESS_BODY));
254+
const { service, emitAuthState } = createService(fetchMock);
255+
256+
await service.prompt([{ role: "user", content: "hi" }], {
257+
model: "claude-haiku-4-5",
258+
});
259+
emitAuthState("org-2");
260+
await service.prompt([{ role: "user", content: "hi" }], {
261+
model: "claude-haiku-4-5",
262+
});
263+
264+
const bodyAfterSwitch = JSON.parse(
265+
fetchMock.mock.calls[2][1].body as string,
266+
);
267+
expect(bodyAfterSwitch.model).toBe("claude-haiku-4-5");
268+
});
269+
270+
it("keeps the learned subscription state across same-org auth changes", async () => {
271+
const fetchMock = vi
272+
.fn()
273+
.mockResolvedValueOnce(createJsonResponse(MODEL_GATE_BODY, 403))
274+
.mockImplementation(async () => createJsonResponse(SUCCESS_BODY));
275+
const { service, emitAuthState } = createService(fetchMock);
276+
277+
await service.prompt([{ role: "user", content: "hi" }], {
278+
model: "claude-haiku-4-5",
279+
});
280+
emitAuthState("org-1");
281+
await service.prompt([{ role: "user", content: "hi" }], {
282+
model: "claude-haiku-4-5",
283+
});
284+
285+
expect(fetchMock).toHaveBeenCalledTimes(3);
286+
const bodyAfterRefresh = JSON.parse(
287+
fetchMock.mock.calls[2][1].body as string,
288+
);
289+
expect(bodyAfterRefresh.model).toBe("@cf/zai-org/glm-5.2");
290+
});
291+
292+
it("does not retry non-gate 403s on the free-tier model", async () => {
293+
const fetchMock = vi
294+
.fn()
295+
.mockResolvedValue(
296+
createJsonResponse(
297+
{ detail: "Product 'posthog_code' requires OAuth authentication" },
298+
403,
299+
),
300+
);
301+
const { service } = createService(fetchMock);
302+
303+
await expect(
304+
service.prompt([{ role: "user", content: "hi" }]),
305+
).rejects.toMatchObject({ statusCode: 403 });
306+
expect(fetchMock).toHaveBeenCalledTimes(1);
307+
});
308+
160309
it("throws a timeout LlmGatewayError when the request aborts via the internal timeout", async () => {
161310
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
162311
return new Promise<Response>((_resolve, reject) => {
@@ -214,6 +363,40 @@ describe("LlmGatewayService.fetchUsage", () => {
214363
statusCode: 503,
215364
});
216365
});
366+
367+
it("parses the usage-based billing fields when present", async () => {
368+
const fetchMock = vi.fn().mockResolvedValue(
369+
createJsonResponse({
370+
...USAGE_BODY,
371+
ai_credits: { exhausted: true },
372+
code_usage_subscribed: true,
373+
}),
374+
);
375+
const { service } = createService(fetchMock);
376+
377+
const usage = await service.fetchUsage();
378+
379+
expect(usage.ai_credits?.exhausted).toBe(true);
380+
expect(usage.code_usage_subscribed).toBe(true);
381+
});
382+
383+
it("feeds code_usage_subscribed into helper model routing", async () => {
384+
const fetchMock = vi
385+
.fn()
386+
.mockResolvedValueOnce(
387+
createJsonResponse({ ...USAGE_BODY, code_usage_subscribed: false }),
388+
)
389+
.mockResolvedValue(createJsonResponse(SUCCESS_BODY));
390+
const { service } = createService(fetchMock);
391+
392+
await service.fetchUsage();
393+
await service.prompt([{ role: "user", content: "hi" }], {
394+
model: "claude-haiku-4-5",
395+
});
396+
397+
const promptBody = JSON.parse(fetchMock.mock.calls[1][1].body as string);
398+
expect(promptBody.model).toBe("@cf/zai-org/glm-5.2");
399+
});
217400
});
218401

219402
describe("LlmGatewayService.invalidatePlanCache", () => {

0 commit comments

Comments
 (0)