Skip to content
Source
Sign inSign up

accountUsage.test.ts

415 lines17.2 KB

1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Layer from "effect/Layer";
4import { assert, describe, it } from "@effect/vitest";
5import * as Context from "effect/Context";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as FetchHttpClient from "effect/http/FetchHttpClient";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientError from "effect/http/HttpClientError";
11import * as HttpClientResponse from "effect/http/HttpClientResponse";
12import * as TestClock from "effect/testing/TestClock";
13​
14import { readCursorAccountUsage } from "./accountUsage.ts";
15​
16interface DashboardRequest {
17 readonly url: string;
18 readonly init: RequestInit;
19 readonly page: number;
20 readonly headers: Headers;
22​
23const dashboard = (answer: (request: DashboardRequest) => Response | Promise<Response>) =>
24 HttpClient.make((request, url, signal, fiber) =>
25 Effect.tryPromise({
26 try: async () => {
27 const init = Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit);
28 const body =
29 request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
30 if (
31 url.pathname === "/auth/exchange_user_api_key" &&
32 request.headers.authorization?.startsWith("Bearer header.")
33 ) {
34 return HttpClientResponse.fromWeb(
35 request,
36 Response.json({ accessToken: request.headers.authorization.slice(7) }),
37 );
38 }
39 const response = await answer({
40 url: url.toString(),
41 init: { ...init, signal, body },
42 page: JSON.parse(body).page,
43 headers: new Headers(request.headers),
44 });
45 return HttpClientResponse.fromWeb(request, response);
46 },
47 catch: (cause) =>
48 new HttpClientError.HttpClientError({
49 reason: new HttpClientError.TransportError({ request, cause }),
50 }),
51 }),
52 );
53​
54const readAccountUsage = (
55 credentialSource: Parameters<typeof readCursorAccountUsage>[0],
56 sinceMs: number,
57 endDate: number,
58 answer: (request: DashboardRequest) => Response | Promise<Response>,
59) =>
60 readCursorAccountUsage(credentialSource, sinceMs, endDate).pipe(
61 Effect.provideService(HttpClient.HttpClient, dashboard(answer)),
62 );
63​
64describe("Cursor account history", () => {
65 it.effect(
66 "reads paginated Cursor account history including headless calls with separate cache tokens",
67 () =>
68 Effect.gen(function* () {
69 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`;
70 const credential = { kind: "apiKey", apiKey: accessToken } as const;
71 const pages: number[] = [];
72 const signals: AbortSignal[] = [];
73 const request = ({ url, init, headers, page }: DashboardRequest) => {
74 assert.strictEqual(
75 url,
76 "https://api2.cursor.sh/aiserver.v1.DashboardService/GetFilteredUsageEvents",
77 );
78 assert.strictEqual(init.redirect, "error");
79 assert.strictEqual(headers.get("connect-protocol-version"), "1");
80 assert.strictEqual(headers.get("content-type"), "application/json");
81 assert.strictEqual(headers.get("authorization"), `Bearer ${accessToken}`);
82 const body = { page };
83 pages.push(body.page);
84 if (init.signal) signals.push(init.signal);
85 return Response.json({
86 totalUsageEventsCount: 1001,
87 usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({
88 timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000),
89 model: "claude-sonnet-4-5",
90 conversationId: `conversation-${body.page}`,
91 isHeadless: body.page === 2,
92 chargedCents: 0,
93 tokenUsage: {
94 inputTokens: 10,
95 outputTokens: 5,
96 cacheReadTokens: 30,
97 cacheWriteTokens: 2,
98 totalCents: 25,
99 },
100 })),
101 });
102 };
103 const result = yield* readAccountUsage(credential, 0, 1781000000000, request);
104 assert.isNull(result.error);
105 assert.deepStrictEqual(pages, [1, 2]);
106 assert.lengthOf(signals, 2);
107 assert.notStrictEqual(signals[0], signals[1]);
108 assert.strictEqual(result.records.length, 1001);
109 assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2");
110 assert.deepStrictEqual(result.records[0]?.totals, {
111 uncachedInputTokens: 10,
112 cachedInputTokens: 30,
113 cacheCreationTokens: 2,
114 outputTokens: 5,
115 reasoningTokens: 0,
116 });
117 assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25);
118 assert.strictEqual(
119 result.records[0]?.dedupeKey,
120 "cursor-account:2188c26da759c4fae3aa0252fc6d725eff0922ef0b022551e6f0987d02bfab33:3149aaf8419ad6b66ef7330853d8fe85e6f2e9876e2ad5e85f88bee8c8d6d133:0",
121 );
122 assert.isFalse(result.accountKey?.includes("demo") ?? true);
123 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
124 );
125​
126 it.effect("reads Cursor account history beyond 100 pages", () =>
127 Effect.gen(function* () {
128 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
129 const credential = { kind: "apiKey", apiKey: accessToken } as const;
130 const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null }));
131 let requests = 0;
132 const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => {
133 requests += 1;
134 return Response.json({
135 totalUsageEventsCount: 100_001,
136 usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }],
137 });
138 });
139 assert.isNull(result.error);
140 assert.strictEqual(requests, 101);
141 assert.deepStrictEqual(result.records, []);
142 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
143 );
144​
145 it.effect("accepts confirmed empty Cursor usage but rejects error envelopes", () =>
146 Effect.gen(function* () {
147 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
148 const credential = { kind: "apiKey", apiKey: accessToken } as const;
149 for (const body of [
150 {},
151 { totalUsageEventsCount: 0 },
152 { totalUsageEventsCount: 0, usageEventsDisplay: [] },
153 ]) {
154 const result = yield* readAccountUsage(credential, 0, 1781000000000, async () =>
155 Response.json(body),
156 );
157 assert.isNull(result.error);
158 assert.deepStrictEqual(result.records, []);
159 assert.isFalse(result.missing);
160 }
161 for (const body of [
162 { error: "upstream error" },
163 { detail: "unknown error envelope" },
164 { totalUsageEventsCount: 0, error: "upstream error" },
165 null,
166 [],
167 "invalid",
168 0,
169 ]) {
170 const result = yield* readAccountUsage(credential, 0, 1781000000000, async () =>
171 Response.json(body),
172 );
173 assert.isNotNull(result.error);
174 assert.deepStrictEqual(result.records, []);
175 }
176 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
177 );
178​
179 it.effect("requires a terminal Cursor page after a full page reaches the reported count", () =>
180 Effect.gen(function* () {
181 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
182 const credential = { kind: "apiKey", apiKey: accessToken } as const;
183 let requests = 0;
184 const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => {
185 requests++;
186 return Response.json(
187 requests === 1
188 ? {
189 totalUsageEventsCount: 1000,
190 usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({
191 timestamp: String(1780000000000 + index),
192 model: "gpt-5",
193 tokenUsage: { inputTokens: 10, outputTokens: 5 },
194 })),
195 }
196 : { totalUsageEventsCount: 1000 },
197 );
198 });
199 assert.isNull(result.error);
200 assert.strictEqual(result.records.length, 1000);
201 assert.strictEqual(requests, 2);
202 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
203 );
204​
205 it.effect(
206 "removes only count-proven Cursor boundary copies and preserves identical billed events",
207 () =>
208 Effect.gen(function* () {
209 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
210 const credential = { kind: "apiKey", apiKey: accessToken } as const;
211 const event = (index: number) => ({
212 timestamp: String(1780000000000 + index),
213 model: "gpt-5",
214 tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 },
215 });
216 for (const total of [2000, 2001, undefined]) {
217 const expectedCount = total ?? 2001;
218 let requests = 0;
219 const result = yield* readAccountUsage(credential, 0, 1781000000000, async () => {
220 requests++;
221 return Response.json({
222 totalUsageEventsCount: total,
223 usageEventsDisplay:
224 requests === 1
225 ? Array.from({ length: 1000 }, (_, index) => event(index))
226 : requests === 2
227 ? Array.from({ length: 1000 }, (_, index) => {
228 const row = event(999 + index);
229 return {
230 tokenUsage: {
231 totalCents: row.tokenUsage.totalCents,
232 outputTokens: row.tokenUsage.outputTokens,
233 inputTokens: row.tokenUsage.inputTokens,
234 },
235 model: row.model,
236 timestamp: row.timestamp,
237 };
238 })
239 : [event(1999)],
240 });
241 });
242 assert.isNull(result.error);
243 assert.strictEqual(result.records.length, expectedCount);
244 assert.strictEqual(requests, 3);
245 assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999);
246 assert.strictEqual(
247 result.records.filter((record) => record.timestampMs === 1780000000999).length,
248 total === 2000 ? 1 : 2,
249 );
250 assert.strictEqual(
251 new Set(result.records.map((record) => record.dedupeKey)).size,
252 expectedCount,
253 );
254 }
255 let requests = 0;
256 const inconsistent = yield* readAccountUsage(credential, 0, 1781000000000, async () => {
257 requests++;
258 return Response.json({
259 totalUsageEventsCount: 1001,
260 usageEventsDisplay:
261 requests === 1
262 ? Array.from({ length: 1000 }, (_, index) => event(index))
263 : [event(500), event(1000)],
264 });
265 });
266 assert.isNotNull(inconsistent.error);
267 assert.deepStrictEqual(inconsistent.records, []);
268 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
269 );
270​
271 it.effect(
272 "does not present truncated Cursor account pages or authentication failures as complete history",
273 () =>
274 Effect.gen(function* () {
275 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`;
276 const credential = { kind: "apiKey", apiKey: accessToken } as const;
277 const truncated = yield* readAccountUsage(credential, 0, 1781000000000, async () =>
278 Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }),
279 );
280 assert.isNotNull(truncated.error);
281 assert.deepStrictEqual(truncated.records, []);
282 const denied = yield* readAccountUsage(
283 credential,
284 0,
285 1781000000000,
286 async () => new Response(accessToken, { status: 401 }),
287 );
288 assert.isNotNull(denied.error);
289 assert.isFalse(denied.error?.includes(accessToken) ?? true);
290 assert.deepStrictEqual(denied.records, []);
291 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
292 );
293});
294​
295describe("readCursorAccountUsage", () => {
296 it.effect("reads an API key's account history through Cursor's API", () =>
297 Effect.gen(function* () {
298 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
299 const requests: Array<readonly [string, string | null]> = [];
300 const result = yield* readAccountUsage(
301 { kind: "apiKey", apiKey: "api-key" },
302 0,
303 1781000000000,
304 ({ url, headers }) => {
305 requests.push([url, headers.get("authorization")]);
306 return url.endsWith("/auth/exchange_user_api_key")
307 ? Response.json({ accessToken })
308 : Response.json({
309 totalUsageEventsCount: 1,
310 usageEventsDisplay: [
311 { timestamp: "1780000000000", model: "gpt-5", tokenUsage: { outputTokens: 4 } },
312 ],
313 });
314 },
315 );
316 assert.isNull(result.error);
317 assert.deepStrictEqual(requests, [
318 ["https://api2.cursor.sh/auth/exchange_user_api_key", "Bearer api-key"],
319 [
320 "https://api2.cursor.sh/aiserver.v1.DashboardService/GetFilteredUsageEvents",
321 `Bearer ${accessToken}`,
322 ],
323 ]);
324 assert.strictEqual(result.records[0]?.totals.outputTokens, 4);
325 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
326 );
327​
328 it.effect("stops waiting for an API key exchange that never answers", () =>
329 Effect.gen(function* () {
330 const read = yield* Effect.forkChild(
331 readAccountUsage(
332 { kind: "apiKey", apiKey: "api-key" },
333 0,
334 1781000000000,
335 () => new Promise<Response>(() => {}),
336 ),
337 );
338 yield* TestClock.adjust("10 seconds");
339 const result = yield* Fiber.join(read);
340 assert.strictEqual(result.error, "Cursor account usage could not be read.");
341 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
342 );
343​
344 it.effect("reads the pages behind the first together and keeps them in page order", () =>
345 Effect.gen(function* () {
346 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
347 let inFlight = 0;
348 let mostInFlight = 0;
349 const result = yield* readAccountUsage(
350 { kind: "apiKey", apiKey: accessToken },
351 0,
352 1781000000000,
353 async ({ page }) => {
354 inFlight++;
355 mostInFlight = Math.max(mostInFlight, inFlight);
356 for (let turn = page; turn < 9; turn++) await Promise.resolve();
357 inFlight--;
358 return Response.json({
359 totalUsageEventsCount: 8001,
360 usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({
361 timestamp: String(1780000000000 + (page - 1) * 1000 + index),
362 model: "gpt-5",
363 tokenUsage: { inputTokens: 1 },
364 })),
365 });
366 },
367 );
368 assert.isNull(result.error);
369 assert.strictEqual(mostInFlight, 6);
370 assert.deepStrictEqual(
371 result.records.map((record) => record.timestampMs),
372 Array.from({ length: 8001 }, (_, index) => 1780000000000 + index),
373 );
374 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
375 );
376​
377 it.effect("ends like a page-by-page read when a page fails, leaving no request open", () =>
378 Effect.gen(function* () {
379 const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`;
380 const fullPage = {
381 totalUsageEventsCount: 8001,
382 usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })),
383 };
384 for (const [secondPage, error] of [
385 [() => Response.json(fullPage), "Cursor account usage could not be read."],
386 [
387 () => new Response(null, { status: 401 }),
388 "Sign in to Cursor again to read account usage.",
389 ],
390 ] as const) {
391 let open = 0;
392 const result = yield* readAccountUsage(
393 { kind: "apiKey", apiKey: accessToken },
394 0,
395 1781000000000,
396 ({ init, page }) => {
397 if (page === 1) return Promise.resolve(Response.json(fullPage));
398 if (page === 2) return Promise.resolve(secondPage());
399 if (page === 3) return Promise.reject(new Error("connection reset"));
400 open++;
401 return new Promise((_resolve, reject) => {
402 init.signal?.addEventListener("abort", () => {
403 open--;
404 reject(init.signal?.reason);
405 });
406 });
407 },
408 );
409 assert.strictEqual(result.error, error);
410 assert.deepStrictEqual(result.records, []);
411 assert.strictEqual(open, 0);
412 }
413 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
414 );
415});