Skip to content

Commit 05bab7d

Browse files
authored
fix(billing): show the limit modal for any classified cause; scope the subscription memo to the org
Greptile P1s: an org_limit classification only opened the modal when the message also carried generic rate-limit wording, and the singleton's learned code_usage_subscribed bit survived org switches, pinning helpers of a subscribed org to the free-tier model. The modal now keys off the classified cause directly, and the memo clears when the auth org changes. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
1 parent e607772 commit 05bab7d

4 files changed

Lines changed: 121 additions & 8 deletions

File tree

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

Lines changed: 63 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 = {
@@ -228,6 +246,49 @@ describe("LlmGatewayService.prompt", () => {
228246
expect(thirdBody.model).toBe("@cf/zai-org/glm-5.2");
229247
});
230248

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+
231292
it("does not retry non-gate 403s on the free-tier model", async () => {
232293
const fetchMock = vi
233294
.fn()

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import {
55
type PosthogProperties,
66
} from "@posthog/shared/posthog-property-headers";
77
import { inject, injectable } from "inversify";
8+
import type { AuthService } from "../auth/auth";
9+
import { AUTH_SERVICE } from "../auth/auth.module";
10+
import { AuthServiceEvent } from "../auth/schemas";
811
import {
912
LLM_GATEWAY_HOST,
1013
type LlmGatewayAuth,
@@ -47,10 +50,18 @@ export class LlmGatewayService {
4750
host: LlmGatewayHost,
4851
@inject(ROOT_LOGGER)
4952
logger: RootLogger,
53+
@inject(AUTH_SERVICE)
54+
authService: AuthService,
5055
) {
5156
this.auth = host;
5257
this.endpoints = host;
5358
this.log = logger.scope("llm-gateway");
59+
let orgId = authService.getState().currentOrgId;
60+
authService.on(AuthServiceEvent.StateChanged, (state) => {
61+
if (state.currentOrgId === orgId) return;
62+
orgId = state.currentOrgId;
63+
this.lastKnownCodeUsageSubscribed = null;
64+
});
5465
}
5566

5667
private readonly auth: LlmGatewayAuth;

packages/core/src/sessions/sessionService.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2715,10 +2715,7 @@ export class SessionService {
27152715

27162716
const limitCause = classifyGatewayLimitError(errorMessage, errorDetails);
27172717

2718-
if (
2719-
limitCause === "model_gate" ||
2720-
isRateLimitError(errorMessage, errorDetails)
2721-
) {
2718+
if (limitCause !== null || isRateLimitError(errorMessage, errorDetails)) {
27222719
this.d.log.warn("Gateway limit reached, showing usage limit modal", {
27232720
taskRunId: session.taskRunId,
27242721
cause: limitCause,

packages/core/src/sessions/sessionServicePromptRecovery.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function createHarness() {
4343
clearOptimisticItems: vi.fn(),
4444
};
4545
const promptMutate = vi.fn();
46+
const usageLimitShow = vi.fn();
4647
const deps = {
4748
store,
4849
h: { extractSkillButtonId: () => undefined },
@@ -51,7 +52,7 @@ function createHarness() {
5152
track: vi.fn(),
5253
getIsOnline: () => true,
5354
addDirectoryDialog: { open: false },
54-
usageLimit: { show: vi.fn() },
55+
usageLimit: { show: usageLimitShow },
5556
trpc: {
5657
agent: {
5758
prompt: { mutate: promptMutate },
@@ -74,7 +75,7 @@ function createHarness() {
7475
},
7576
"tryAutoRecoverLocalSession",
7677
);
77-
return { service, sessions, store, promptMutate, recoverSpy };
78+
return { service, sessions, store, promptMutate, recoverSpy, usageLimitShow };
7879
}
7980

8081
type RecoverSpy = ReturnType<typeof createHarness>["recoverSpy"];
@@ -131,3 +132,46 @@ describe("SessionService prompt recovery on fatal session errors", () => {
131132
},
132133
);
133134
});
135+
136+
describe("SessionService gateway billing denials", () => {
137+
it.each([
138+
{
139+
case: "a model-gate 403",
140+
message:
141+
'Internal error: API Error: 403 {"error":{"message":"Model \'claude-fable-5\' needs a paid PostHog plan. Models available on the free tier: @cf/zai-org/glm-5.2. Add a payment method to your organization to unlock all models. (rate_limit)","type":"permission_error","code":"model_gate"}}',
142+
expectedShow: { cause: "model_gate" },
143+
},
144+
{
145+
case: "an org-limit 429",
146+
message:
147+
"Rate limit exceeded: Your team has reached its PostHog Code usage limit for this billing period.",
148+
expectedShow: { cause: "org_limit" },
149+
},
150+
// The classified cause alone must open the modal — the org-limit prose
151+
// is not guaranteed to carry generic rate-limit wording.
152+
{
153+
case: "an org-limit message without rate-limit wording",
154+
message:
155+
"Your team has reached its PostHog Code usage limit for this billing period.",
156+
expectedShow: { cause: "org_limit" },
157+
},
158+
{
159+
case: "an unclassified rate limit",
160+
message: "[429] Too many requests",
161+
expectedShow: undefined,
162+
},
163+
])(
164+
"shows the usage-limit modal and stops the prompt for $case",
165+
async ({ message, expectedShow }) => {
166+
const { service, promptMutate, usageLimitShow, recoverSpy } =
167+
createHarness();
168+
promptMutate.mockRejectedValue(new Error(message));
169+
170+
const result = await service.sendPrompt(TASK_ID, "hello");
171+
172+
expect(result).toEqual({ stopReason: "rate_limited" });
173+
expect(usageLimitShow).toHaveBeenCalledWith(expectedShow);
174+
expect(recoverSpy).not.toHaveBeenCalled();
175+
},
176+
);
177+
});

0 commit comments

Comments
 (0)