Skip to content

Commit 0649175

Browse files
authored
fix(billing): surface the upgrade gate on codex billing denials
The codex adapter resolved every non-retried fatal error as a silent refusal, so a free-tier org's gateway 403/429 died with a warning badge and no explanation. A fatal error that classifies as a gateway billing denial now rejects the prompt with the gateway's message, which the host already classifies into the upgrade modal. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
1 parent 9233471 commit 0649175

2 files changed

Lines changed: 46 additions & 2 deletions

File tree

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,6 +1596,32 @@ describe("CodexAppServerAgent", () => {
15961596
expect((await done).stopReason).toBe("refusal");
15971597
});
15981598

1599+
it("rejects the prompt when the fatal error is a gateway billing denial", async () => {
1600+
// A silent refusal would hide the free-tier gate: the host only shows the
1601+
// upgrade modal when the prompt rejects with the gateway's message.
1602+
const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } });
1603+
const { client } = makeFakeClient();
1604+
const agent = new CodexAppServerAgent(client, {
1605+
processOptions: { binaryPath: "/x/codex" },
1606+
rpcFactory: stub.factory,
1607+
});
1608+
1609+
await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest);
1610+
const done = agent.prompt({
1611+
sessionId: "t",
1612+
prompt: [{ type: "text", text: "go" }],
1613+
} as unknown as PromptRequest);
1614+
stub.emit("error", {
1615+
willRetry: false,
1616+
error: {
1617+
message:
1618+
"unexpected status 403 Forbidden: Model 'gpt-5.5' needs a paid PostHog plan. Models available on the free tier: @cf/zai-org/glm-5.2. (rate_limit)",
1619+
},
1620+
});
1621+
1622+
await expect(done).rejects.toThrow("needs a paid PostHog plan");
1623+
});
1624+
15991625
it("ends the turn without turn/start when no prompt block is usable", async () => {
16001626
const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } });
16011627
const { client } = makeFakeClient();

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ import type {
1919
SetSessionConfigOptionResponse,
2020
StopReason,
2121
} from "@agentclientprotocol/sdk";
22-
import { mcpToolKey, posthogToolMeta } from "@posthog/shared";
22+
import {
23+
classifyGatewayLimitError,
24+
mcpToolKey,
25+
posthogToolMeta,
26+
} from "@posthog/shared";
2327
import {
2428
type NativeGoalState,
2529
POSTHOG_NOTIFICATIONS,
@@ -1245,11 +1249,25 @@ export class CodexAppServerAgent extends BaseAcpAgent {
12451249

12461250
if (method === APP_SERVER_NOTIFICATIONS.ERROR) {
12471251
// A non-retried fatal error: resolve the turn so prompt() returns rather than hangs.
1248-
const willRetry = (params as { willRetry?: boolean })?.willRetry;
1252+
const { willRetry, error } = (params ?? {}) as {
1253+
willRetry?: boolean;
1254+
error?: { message?: string };
1255+
};
12491256
if (willRetry === false) {
12501257
this.logger.warn("codex app-server fatal error notification", {
12511258
params,
12521259
});
1260+
const message = error?.message ?? "";
1261+
// A gateway billing denial rejects the prompt so the host classifies
1262+
// it and shows the upgrade gate; a silent refusal would hide it.
1263+
if (classifyGatewayLimitError(message) !== null) {
1264+
if (this.compactionActive) {
1265+
this.compactionActive = false;
1266+
this.emitCompactionBoundary();
1267+
}
1268+
this.turns.fail(new Error(message));
1269+
return;
1270+
}
12531271
void this.finalizeTurn("refusal");
12541272
}
12551273
}

0 commit comments

Comments
 (0)