Skip to content

Commit 4f27d54

Browse files
authored
feat(billing): honor the gateway's free-tier model marks — authed fetches, safe defaults, locked pickers (#3488)
1 parent e6485ac commit 4f27d54

19 files changed

Lines changed: 444 additions & 76 deletions

File tree

packages/agent/src/adapters/base-acp-agent.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
WriteTextFileRequest,
1717
WriteTextFileResponse,
1818
} from "@agentclientprotocol/sdk";
19+
import { restrictedModelMeta } from "@posthog/shared";
1920
import {
2021
DEFAULT_GATEWAY_MODEL,
2122
fetchGatewayModels,
@@ -25,6 +26,7 @@ import {
2526
isAnthropicModel,
2627
isCloudflareModel,
2728
isCloudflareModelId,
29+
pickAllowedModel,
2830
} from "../gateway-models";
2931
import { Logger } from "../utils/logger";
3032
/**
@@ -136,22 +138,30 @@ export abstract class BaseAcpAgent implements Agent {
136138
async getModelConfigOptions(
137139
currentModelOverride?: string,
138140
gatewayUrl?: string,
141+
gatewayAuthToken?: string,
139142
): Promise<{
140143
currentModelId: string;
141144
options: SessionConfigSelectOption[];
142145
}> {
146+
// Authenticated so the gateway can mark plan-restricted models —
147+
// anonymous fetches see everything allowed.
143148
this.gatewayModels = await fetchGatewayModels(
144-
gatewayUrl ? { gatewayUrl } : undefined,
149+
gatewayUrl ? { gatewayUrl, authToken: gatewayAuthToken } : undefined,
145150
);
146151

147-
const options = this.gatewayModels
152+
const adapterModels = this.gatewayModels
148153
// Cloudflare models are servable on the Claude adapter too — the gateway translates the
149154
// `@cf/` path onto its Anthropic-Messages surface — so include them alongside Anthropic models.
150-
.filter((model) => isAnthropicModel(model) || isCloudflareModel(model))
155+
.filter((model) => isAnthropicModel(model) || isCloudflareModel(model));
156+
157+
const options = adapterModels
151158
.map((model) => ({
152159
value: model.id,
153160
name: formatGatewayModelName(model),
154161
description: `Context: ${model.context_window.toLocaleString()} tokens`,
162+
// Locked models stay listed so the picker can gate them instead of
163+
// silently dropping them.
164+
...(model.allowed ? {} : { _meta: restrictedModelMeta() }),
155165
}))
156166
// Sort oldest-to-newest so the picker is deterministic and the newest
157167
// model lands at the end of the list, closest to the trigger.
@@ -186,6 +196,11 @@ export abstract class BaseAcpAgent implements Agent {
186196
}
187197
}
188198

199+
// Never auto-select a model the org's plan can't use — it would 403 on
200+
// the first message. An explicit user pick still goes through the
201+
// picker's upgrade gate.
202+
currentModelId = pickAllowedModel(adapterModels, currentModelId);
203+
189204
if (!options.some((opt) => opt.value === currentModelId)) {
190205
options.unshift({
191206
value: currentModelId,

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2097,6 +2097,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
20972097
this.getModelConfigOptions(
20982098
settingsManager.getSettings().model || meta?.model || undefined,
20992099
this.options?.gatewayEnv?.anthropicBaseUrl,
2100+
this.options?.gatewayEnv?.anthropicAuthToken,
21002101
),
21012102
...(meta?.taskRunId
21022103
? [

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
NewSessionRequest,
55
PromptRequest,
66
} from "@agentclientprotocol/sdk";
7+
import { RequestError } from "@agentclientprotocol/sdk";
78
import { describe, expect, it } from "vitest";
89
import type {
910
AppServerClientHandlers,
@@ -1596,6 +1597,45 @@ describe("CodexAppServerAgent", () => {
15961597
expect((await done).stopReason).toBe("refusal");
15971598
});
15981599

1600+
it("rejects the prompt when the fatal error is a gateway billing denial", async () => {
1601+
// A silent refusal would hide the free-tier gate: the host only shows the
1602+
// upgrade modal when the prompt rejects with the gateway's message.
1603+
const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } });
1604+
const { client } = makeFakeClient();
1605+
const agent = new CodexAppServerAgent(client, {
1606+
processOptions: { binaryPath: "/x/codex" },
1607+
rpcFactory: stub.factory,
1608+
});
1609+
1610+
await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest);
1611+
const done = agent.prompt({
1612+
sessionId: "t",
1613+
prompt: [{ type: "text", text: "go" }],
1614+
} as unknown as PromptRequest);
1615+
stub.emit("error", {
1616+
willRetry: false,
1617+
error: {
1618+
message:
1619+
"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)",
1620+
},
1621+
});
1622+
1623+
const err = await done.then(
1624+
() => {
1625+
throw new Error("prompt resolved instead of rejecting");
1626+
},
1627+
(e: unknown) => e,
1628+
);
1629+
// Must be a RequestError with the gateway text in its message — a plain
1630+
// Error crosses the ACP boundary as a bare "Internal error", which the
1631+
// host classifies as fatal and answers with a kill/respawn loop.
1632+
expect(err).toBeInstanceOf(RequestError);
1633+
expect((err as RequestError).message).toContain("Internal error: ");
1634+
expect((err as RequestError).message).toContain(
1635+
"needs a paid PostHog plan",
1636+
);
1637+
});
1638+
15991639
it("ends the turn without turn/start when no prompt block is usable", async () => {
16001640
const stub = makeStubRpc({ "thread/start": { thread: { id: "t" } } });
16011641
const { client } = makeFakeClient();

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ import type {
1919
SetSessionConfigOptionResponse,
2020
StopReason,
2121
} from "@agentclientprotocol/sdk";
22-
import { mcpToolKey, posthogToolMeta } from "@posthog/shared";
22+
import { RequestError } from "@agentclientprotocol/sdk";
23+
import {
24+
classifyGatewayLimitError,
25+
mcpToolKey,
26+
posthogToolMeta,
27+
} from "@posthog/shared";
2328
import {
2429
type NativeGoalState,
2530
POSTHOG_NOTIFICATIONS,
@@ -1245,11 +1250,27 @@ export class CodexAppServerAgent extends BaseAcpAgent {
12451250

12461251
if (method === APP_SERVER_NOTIFICATIONS.ERROR) {
12471252
// A non-retried fatal error: resolve the turn so prompt() returns rather than hangs.
1248-
const willRetry = (params as { willRetry?: boolean })?.willRetry;
1253+
const { willRetry, error } = (params ?? {}) as {
1254+
willRetry?: boolean;
1255+
error?: { message?: string };
1256+
};
12491257
if (willRetry === false) {
12501258
this.logger.warn("codex app-server fatal error notification", {
12511259
params,
12521260
});
1261+
const message = error?.message ?? "";
1262+
// A gateway billing denial rejects the prompt so the host classifies
1263+
// it and shows the upgrade gate. It must be a RequestError: a plain
1264+
// Error serializes to a bare "Internal error" at the ACP boundary,
1265+
// which the host reads as fatal and answers with a respawn loop.
1266+
if (classifyGatewayLimitError(message) !== null) {
1267+
if (this.compactionActive) {
1268+
this.compactionActive = false;
1269+
this.emitCompactionBoundary();
1270+
}
1271+
this.turns.fail(RequestError.internalError(undefined, message));
1272+
return;
1273+
}
12531274
void this.finalizeTurn("refusal");
12541275
}
12551276
}

packages/agent/src/gateway-models.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isAnthropicModel,
99
isBlockedModelId,
1010
isCloudflareModel,
11+
pickAllowedModel,
1112
} from "./gateway-models";
1213

1314
const model = (id: string, owned_by = ""): GatewayModel => ({
@@ -16,6 +17,7 @@ const model = (id: string, owned_by = ""): GatewayModel => ({
1617
context_window: 128000,
1718
supports_streaming: true,
1819
supports_vision: false,
20+
allowed: true,
1921
});
2022

2123
describe("formatGatewayModelName", () => {
@@ -27,6 +29,7 @@ describe("formatGatewayModelName", () => {
2729
context_window: 200000,
2830
supports_streaming: true,
2931
supports_vision: true,
32+
allowed: true,
3033
}),
3134
).toBe("Claude Opus 4.8");
3235
});
@@ -39,6 +42,7 @@ describe("formatGatewayModelName", () => {
3942
context_window: 200000,
4043
supports_streaming: true,
4144
supports_vision: true,
45+
allowed: true,
4246
}),
4347
).toBe("gpt-5.5");
4448
});
@@ -51,6 +55,7 @@ describe("formatGatewayModelName", () => {
5155
context_window: 200000,
5256
supports_streaming: true,
5357
supports_vision: true,
58+
allowed: true,
5459
}),
5560
).toBe("gpt-5.5");
5661
});
@@ -63,6 +68,7 @@ describe("formatGatewayModelName", () => {
6368
context_window: 128000,
6469
supports_streaming: true,
6570
supports_vision: false,
71+
allowed: true,
6672
}),
6773
).toBe("glm-5.2");
6874
});
@@ -167,6 +173,60 @@ describe("gateway model fetch timeout", () => {
167173
);
168174
});
169175

176+
describe("gateway models cache", () => {
177+
afterEach(() => {
178+
vi.restoreAllMocks();
179+
});
180+
181+
const modelsResponse = (allowed: boolean) =>
182+
new Response(
183+
JSON.stringify({
184+
object: "list",
185+
data: [
186+
{
187+
id: "claude-opus-4-8",
188+
owned_by: "anthropic",
189+
context_window: 200000,
190+
supports_streaming: true,
191+
supports_vision: true,
192+
allowed,
193+
},
194+
],
195+
}),
196+
{ status: 200, headers: { "Content-Type": "application/json" } },
197+
);
198+
199+
// Restriction marks are org-scoped: an org switch swaps the token in the
200+
// same process, and the old org's marks must not be served to the new one.
201+
it("does not serve one token's marks to another token", async () => {
202+
const fetchMock = vi
203+
.spyOn(globalThis, "fetch")
204+
.mockResolvedValueOnce(modelsResponse(false))
205+
.mockResolvedValueOnce(modelsResponse(true));
206+
const gatewayUrl = "https://gateway.token-key-test";
207+
208+
const first = await fetchGatewayModels({ gatewayUrl, authToken: "tok-a" });
209+
const second = await fetchGatewayModels({ gatewayUrl, authToken: "tok-b" });
210+
211+
expect(fetchMock).toHaveBeenCalledTimes(2);
212+
expect(first[0]?.allowed).toBe(false);
213+
expect(second[0]?.allowed).toBe(true);
214+
});
215+
216+
it("serves the cached list to the same token without refetching", async () => {
217+
const fetchMock = vi
218+
.spyOn(globalThis, "fetch")
219+
.mockResolvedValue(modelsResponse(false));
220+
const gatewayUrl = "https://gateway.token-cache-hit-test";
221+
222+
await fetchGatewayModels({ gatewayUrl, authToken: "tok-a" });
223+
const cached = await fetchGatewayModels({ gatewayUrl, authToken: "tok-a" });
224+
225+
expect(fetchMock).toHaveBeenCalledTimes(1);
226+
expect(cached[0]?.allowed).toBe(false);
227+
});
228+
});
229+
170230
describe("isCloudflareModel", () => {
171231
it.each([
172232
{ id: "@cf/zai-org/glm-5.2", owned_by: "cloudflare", expected: true },
@@ -189,3 +249,46 @@ describe("isCloudflareModel", () => {
189249
expect(isAnthropicModel(glm)).toBe(false);
190250
});
191251
});
252+
253+
describe("pickAllowedModel", () => {
254+
const entry = (id: string, allowed: boolean) => ({ id, allowed });
255+
256+
it.each([
257+
[
258+
"keeps an allowed preferred model",
259+
[entry("claude-opus-4-8", true)],
260+
"claude-opus-4-8",
261+
"claude-opus-4-8",
262+
],
263+
[
264+
"keeps a preferred model absent from the list",
265+
[entry("claude-opus-4-8", true)],
266+
"claude-sonnet-5",
267+
"claude-sonnet-5",
268+
],
269+
[
270+
"moves a restricted preferred model to the newest allowed one",
271+
[
272+
entry("claude-opus-4-8", false),
273+
entry("claude-sonnet-4-6", true),
274+
entry("@cf/zai-org/glm-5.2", true),
275+
],
276+
"claude-opus-4-8",
277+
"@cf/zai-org/glm-5.2",
278+
],
279+
[
280+
"keeps the preferred model when everything is restricted",
281+
[entry("claude-opus-4-8", false)],
282+
"claude-opus-4-8",
283+
"claude-opus-4-8",
284+
],
285+
[
286+
"keeps the preferred model when the list is empty",
287+
[],
288+
"claude-opus-4-8",
289+
"claude-opus-4-8",
290+
],
291+
] as const)("%s", (_name, models, preferred, expected) => {
292+
expect(pickAllowedModel(models, preferred)).toBe(expected);
293+
});
294+
});

0 commit comments

Comments
 (0)