Skip to content

Commit 87f0eb3

Browse files
authored
fix(billing): key the gateway models cache on the auth token
Restriction marks are identity-scoped, but the cache keyed on token presence — an org switch in the same process served the previous org's marks for up to the 10-minute TTL, mislocking pickers and letting pickAllowedModel swap session defaults off stale data. Entries are now keyed on the exact token; a rotation costs one refetch. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
1 parent 2843258 commit 87f0eb3

2 files changed

Lines changed: 67 additions & 11 deletions

File tree

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,60 @@ describe("gateway model fetch timeout", () => {
173173
);
174174
});
175175

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+
176230
describe("isCloudflareModel", () => {
177231
it.each([
178232
{ id: "@cf/zai-org/glm-5.2", owned_by: "cloudflare", expected: true },

packages/agent/src/gateway-models.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,21 +71,23 @@ const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
7171
// the callers fall through to `return []`.
7272
const GATEWAY_FETCH_TIMEOUT_MS = 10_000;
7373

74-
// Authed and anonymous responses differ (free-tier marks are authed-only),
75-
// so cache entries are keyed on auth presence.
74+
// Restriction marks are identity-scoped (free-tier marks are authed-only and
75+
// differ per org), so cache entries are keyed on the exact token — an org
76+
// switch in the same process must never be served the old org's marks. A
77+
// token rotation just costs one refetch.
7678
interface ModelsCache<T> {
7779
models: T[];
7880
expiry: number;
7981
url: string;
80-
authed: boolean;
82+
token: string | null;
8183
}
8284

8385
function readModelsCache<T>(
8486
cache: ModelsCache<T> | null,
8587
url: string,
86-
authed: boolean,
88+
token: string | null,
8789
): T[] | null {
88-
if (!cache || cache.url !== url || cache.authed !== authed) return null;
90+
if (!cache || cache.url !== url || cache.token !== token) return null;
8991
return Date.now() < cache.expiry ? cache.models : null;
9092
}
9193

@@ -103,8 +105,8 @@ export async function fetchGatewayModels(
103105
return [];
104106
}
105107

106-
const authed = Boolean(options?.authToken);
107-
const cached = readModelsCache(gatewayModelsCache, gatewayUrl, authed);
108+
const token = options?.authToken ?? null;
109+
const cached = readModelsCache(gatewayModelsCache, gatewayUrl, token);
108110
if (cached) return cached;
109111

110112
const modelsUrl = `${gatewayUrl}/v1/models`;
@@ -127,7 +129,7 @@ export async function fetchGatewayModels(
127129
models,
128130
expiry: Date.now() + CACHE_TTL,
129131
url: gatewayUrl,
130-
authed,
132+
token,
131133
};
132134
return models;
133135
} catch {
@@ -182,8 +184,8 @@ export async function fetchModelsList(
182184
return [];
183185
}
184186

185-
const authed = Boolean(options?.authToken);
186-
const cached = readModelsCache(modelsListCache, gatewayUrl, authed);
187+
const token = options?.authToken ?? null;
188+
const cached = readModelsCache(modelsListCache, gatewayUrl, token);
187189
if (cached) return cached;
188190

189191
try {
@@ -215,7 +217,7 @@ export async function fetchModelsList(
215217
models: results,
216218
expiry: Date.now() + CACHE_TTL,
217219
url: gatewayUrl,
218-
authed,
220+
token,
219221
};
220222
return results;
221223
} catch {

0 commit comments

Comments
 (0)