1/**
2 * Cursor account usage from its dashboard API, read with the saved CLI or
3 * Keychain login. The dashboard covers CLI, desktop and headless usage from
4 * every machine on the account.
5 *
6 * @module provider-cursor/server/accountUsage
7 */
8import type { UsageRecord } from "@t3tools/provider-core/server/usage";
9import * as Crypto from "effect/Crypto";
10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";
12import * as Base64Url from "effect/encoding/Base64Url";
13import * as Hex from "effect/encoding/Hex";
14import * as Fiber from "effect/Fiber";
15import * as FileSystem from "effect/FileSystem";
16import * as FetchHttpClient from "effect/http/FetchHttpClient";
17import * as HttpClient from "effect/http/HttpClient";
18import * as HttpClientRequest from "effect/http/HttpClientRequest";
19import * as PlatformError from "effect/PlatformError";
20import * as Result from "effect/Result";
21import * as Schema from "effect/Schema";
22
23import * as CursorKeychain from "./CursorKeychain.ts";
24
25function object(value: unknown): Record<string, unknown> {
26 return typeof value === "object" && value !== null && !Array.isArray(value)
27 ? (value as Record<string, unknown>)
28 : {};
29}
30
31function tokens(value: unknown): number {
32 return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
33}
34
35/**
36 * Maps Cursor's tiered names (`cursor-grok-4.6-high-fast`,
37 * `claude-fable-5-1-thinking-high`) to the base model's rate-table key.
38 * Grok resolves through xAI's first-party entry, which has no bare alias.
39 */
40export function cursorRateModel(model: string): string {
41 const base = model
42 .replace(/^cursor-/, "")
43 .replace(/(?:-thinking)?(?:-(?:none|minimal|low|medium|high|xhigh|max))?(?:-fast)?$/, "");
44 return base.startsWith("grok-") ? `xai/${base}` : base;
45}
46
47export interface CursorAccountUsageReadResult {
48 readonly accountKey: string | null;
49 readonly records: readonly UsageRecord[];
50 readonly missing: boolean;
51 readonly error: string | null;
52}
53
54/** Why a dashboard read was abandoned; the caller only sees the generic message. */
55class CursorAccountUsageInvalidError extends Schema.TaggedError<CursorAccountUsageInvalidError>()(
56 "CursorAccountUsageInvalidError",
57 { reason: Schema.String },
58) {}
59
60const invalid = (reason: string) => Effect.fail(new CursorAccountUsageInvalidError({ reason }));
61
62const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown));
63
64const pageRequestBody = (page: number, pageSize: number, sinceMs: number, endDate: number) =>
65 JSON.stringify({ page, pageSize, startDate: String(sinceMs), endDate: String(endDate) });
66
67const billedEventIdentity = (...fields: readonly unknown[]) => JSON.stringify(fields);
68
69const textEncoder = new TextEncoder();
70
71const accountHash = Effect.fn("accountHash")(function* (value: string) {
72 const crypto = yield* Crypto.Crypto;
73 return Hex.encode(yield* crypto.digest("SHA-256", textEncoder.encode(value)));
74});
75
76function canonicalJson(value: unknown): string {
77 if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
78 if (value !== null && typeof value === "object") {
79 return `{${Object.entries(value)
80 .sort(([a], [b]) => a.localeCompare(b))
81 .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
82 .join(",")}}`;
83 }
84 return JSON.stringify(value) ?? "null";
85}
86
87/** Find the longest exact suffix/prefix overlap in linear time. */
88function boundaryOverlap(previous: readonly string[], current: readonly string[]): number {
89 const sequence = [...current, "", ...previous];
90 const lengths = Array.from({ length: sequence.length }, () => 0);
91 for (let index = 1; index < sequence.length; index++) {
92 let length = lengths[index - 1]!;
93 while (length > 0 && sequence[index] !== sequence[length]) length = lengths[length - 1]!;
94 if (sequence[index] === sequence[length]) length++;
95 lengths[index] = length;
96 }
97 return lengths.at(-1) ?? 0;
98}
99
100const DASHBOARD_USAGE_URL = "https://cursor.com/api/dashboard/get-filtered-usage-events";
101
102/** The session cookie must never follow a redirect off cursor.com. */
103const NO_REDIRECT: RequestInit = { redirect: "error" };
104
105/**
106 * Dashboard usage includes headless agents and reports fresh input separately
107 * from cache reads. `keychainToken` is only run for a Keychain login.
108 */
109export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(function* (
110 credentialSource: string | { readonly kind: "keychain" },
111 sinceMs: number,
112 endDate: number,
113 keychainToken: Effect.Effect<
114 string | null,
115 | CursorKeychain.CursorKeychainTimeoutError
116 | CursorKeychain.CursorKeychainReadError
117 | CursorAccountUsageInvalidError
118 > = invalid("No Keychain reader"),
119): Effect.fn.Return<
120 CursorAccountUsageReadResult,
121 never,
122 FileSystem.FileSystem | Crypto.Crypto | HttpClient.HttpClient
123> {
124 const fileSystem = yield* FileSystem.FileSystem;
125 const httpClient = yield* HttpClient.HttpClient;
126 const credential = yield* Effect.result<
127 unknown,
128 | PlatformError.PlatformError
129 | Schema.SchemaError
130 | CursorKeychain.CursorKeychainTimeoutError
131 | CursorKeychain.CursorKeychainReadError
132 | CursorAccountUsageInvalidError,
133 never
134 >(
135 typeof credentialSource === "string"
136 ? fileSystem.readFileString(credentialSource).pipe(
137 Effect.flatMap(decodeJson),
138 Effect.map((credentials) => object(credentials).accessToken),
139 )
140 : keychainToken,
141 );
142 if (Result.isFailure(credential)) {
143 const cause = credential.failure;
144 const missing =
145 typeof credentialSource === "string" &&
146 PlatformError.isPlatformError(cause) &&
147 cause.reason._tag === "NotFound";
148 return {
149 accountKey: null,
150 records: [],
151 missing,
152 error: missing
153 ? null
154 : typeof credentialSource === "string"
155 ? "Cursor credentials could not be read."
156 : CursorKeychain.isCursorKeychainTimeoutError(cause)
157 ? "Allow Keychain access on the Mac running T3 Code, then refresh."
158 : "Cursor Keychain credentials could not be read.",
159 };
160 }
161 const accessToken = credential.success;
162 if (typeof accessToken !== "string" || !accessToken) {
163 return {
164 accountKey: null,
165 records: [],
166 missing: true,
167 error:
168 typeof credentialSource === "string"
169 ? null
170 : "Cursor account history needs a macOS Keychain CLI login on this server.",
171 };
172 }
173 let accountKey: string | null = null;
174 const read = Effect.gen(function* () {
175 const payload = Base64Url.decodeString(accessToken.split(".")[1] ?? "");
176 if (Result.isFailure(payload)) return yield* invalid("Invalid authentication");
177 const subject = object(
178 yield* decodeJson(payload.success).pipe(
179 Effect.catch(() => invalid("Invalid authentication")),
180 ),
181 ).sub;
182 if (typeof subject !== "string" || !subject) return yield* invalid("Invalid authentication");
183 const userId = subject.split("|").at(-1);
184 if (!userId) return yield* invalid("Invalid authentication");
185 accountKey = yield* accountHash(subject);
186 if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate)
187 return yield* invalid("Invalid date window");
188 const records: UsageRecord[] = [];
189 const occurrences = new Map<string, number>();
190 const pages: unknown[][] = [];
191 let completed = false;
192 const pageSize = 1000;
193 let total: number | undefined;
194 const readPage = (page: number) =>
195 Effect.gen(function* () {
196 // A count can include overlapping page boundaries. Allow room to
197 // reconcile them without imposing a fixed account-size limit.
198 if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) {
199 return yield* invalid("Account usage page limit exceeded");
200 }
201 const response = yield* httpClient
202 .execute(
203 HttpClientRequest.post(DASHBOARD_USAGE_URL).pipe(
204 HttpClientRequest.setHeaders({
205 Origin: "https://cursor.com",
206 Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`,
207 }),
208 HttpClientRequest.bodyText(
209 pageRequestBody(page, pageSize, sinceMs, endDate),
210 "application/json",
211 ),
212 ),
213 )
214 .pipe(
215 Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT),
216 Effect.mapError(
217 () => new CursorAccountUsageInvalidError({ reason: "Account usage request failed" }),
218 ),
219 );
220 if (response.status === 401 || response.status === 403) {
221 return {
222 accountKey,
223 records: [],
224 missing: false,
225 error: "Sign in to Cursor again to read account usage.",
226 } satisfies CursorAccountUsageReadResult;
227 }
228 if (response.status < 200 || response.status >= 300)
229 return yield* invalid("Account usage request failed");
230 const parsed: unknown = yield* response.json.pipe(
231 Effect.mapError(
232 () => new CursorAccountUsageInvalidError({ reason: "Invalid account usage page" }),
233 ),
234 );
235 if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
236 return yield* invalid("Invalid account usage page");
237 }
238 const body = object(parsed);
239 const keys = Object.keys(body);
240 if ("error" in body || "message" in body || "code" in body)
241 return yield* invalid("Account usage error response");
242 const count = keys.length === 0 ? 0 : body.totalUsageEventsCount;
243 const events =
244 keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount")
245 ? []
246 : body.usageEventsDisplay;
247 if (
248 (count !== undefined &&
249 (typeof count !== "number" ||
250 !Number.isSafeInteger(count) ||
251 count < 0 ||
252 (total !== undefined && count !== total))) ||
253 !Array.isArray(events) ||
254 events.length > pageSize ||
255 (count === undefined && !Array.isArray(body.usageEventsDisplay))
256 ) {
257 return yield* invalid("Inconsistent account usage page");
258 }
259 if (typeof count === "number") total = count;
260 return events as unknown[];
261 }).pipe(
262 Effect.timeoutOrElse({
263 duration: Duration.seconds(10),
264 orElse: () => invalid("Account usage request timed out"),
265 }),
266 );
267 // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages
268 // the first one's count implies are requested up to six ahead and consumed in page order.
269 // Pages still in flight when the read ends are interrupted with its scope.
270 const ahead: Fiber.Fiber<
271 Effect.Success<ReturnType<typeof readPage>>,
272 CursorAccountUsageInvalidError
273 >[] = [];
274 for (let page = 1; ; page++) {
275 while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) {
276 ahead.push(yield* Effect.forkScoped(readPage(page + ahead.length)));
277 }
278 const next = ahead.shift();
279 const events = yield* next === undefined ? readPage(page) : Fiber.join(next);
280 // A rejected login comes back as the finished result instead of a page.
281 if (!Array.isArray(events)) return events;
282 pages.push(events);
283 if (events.length < pageSize) {
284 completed = true;
285 break;
286 }
287 }
288 if (!completed) return yield* invalid("Account usage page limit exceeded");
289 const rawCount = pages.reduce((sum, page) => sum + page.length, 0);
290 if (total !== undefined && rawCount < total)
291 return yield* invalid("Incomplete account usage pages");
292 let removalsRemaining = total === undefined ? 0 : rawCount - total;
293 let previousKeys: string[] = [];
294 for (const events of pages) {
295 const eventKeys =
296 removalsRemaining > 0
297 ? yield* Effect.forEach(events, (event) => accountHash(canonicalJson(event)))
298 : [];
299 const removalCount = Math.min(removalsRemaining, boundaryOverlap(previousKeys, eventKeys));
300 removalsRemaining -= removalCount;
301 previousKeys = eventKeys;
302 for (const raw of events.slice(removalCount)) {
303 const event = object(raw);
304 const usage = object(event.tokenUsage);
305 if (event.tokenUsage === undefined || event.tokenUsage === null) continue;
306 for (const key of [
307 "inputTokens",
308 "outputTokens",
309 "cacheReadTokens",
310 "cacheWriteTokens",
311 "totalCents",
312 ]) {
313 const value = usage[key];
314 if (
315 value !== undefined &&
316 (typeof value !== "number" || !Number.isFinite(value) || value < 0)
317 ) {
318 return yield* invalid("Invalid account usage totals");
319 }
320 }
321 const timestampMs =
322 typeof event.timestamp === "string" && event.timestamp.trim() !== ""
323 ? Number(event.timestamp)
324 : event.timestamp;
325 if (
326 typeof timestampMs !== "number" ||
327 !Number.isFinite(timestampMs) ||
328 typeof event.model !== "string" ||
329 !event.model
330 )
331 return yield* invalid("Invalid account usage event");
332 if (timestampMs < sinceMs || timestampMs > endDate) continue;
333 const totals = {
334 uncachedInputTokens: tokens(usage.inputTokens),
335 cachedInputTokens: tokens(usage.cacheReadTokens),
336 cacheCreationTokens: tokens(usage.cacheWriteTokens),
337 outputTokens: tokens(usage.outputTokens),
338 reasoningTokens: 0,
339 };
340 const reportedCostUsd =
341 typeof usage.totalCents === "number" ? usage.totalCents / 100 : null;
342 const sessionId = typeof event.conversationId === "string" ? event.conversationId : "";
343 // No event ID is provided. Preserve identical billed rows with an occurrence index.
344 const key = yield* accountHash(
345 billedEventIdentity(timestampMs, event.model, sessionId, totals, reportedCostUsd),
346 );
347 const occurrence = occurrences.get(key) ?? 0;
348 occurrences.set(key, occurrence + 1);
349 records.push({
350 provider: "cursor",
351 timestampMs,
352 model: event.model,
353 rateModel: cursorRateModel(event.model),
354 sessionId,
355 totals,
356 reportedCostUsd,
357 speed: "standard",
358 dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`,
359 });
360 }
361 yield* Effect.yieldNow;
362 }
363 if (removalsRemaining !== 0) return yield* invalid("Inconsistent account usage boundaries");
364 return {
365 accountKey,
366 records,
367 missing: false,
368 error: null,
369 } satisfies CursorAccountUsageReadResult;
370 });
371 return yield* read.pipe(
372 Effect.scoped,
373 Effect.timeoutOrElse({
374 duration: Duration.seconds(60),
375 orElse: () => invalid("Account usage read timed out"),
376 }),
377 Effect.catch(() =>
378 Effect.succeed({
379 accountKey,
380 records: [],
381 missing: false,
382 error: "Cursor account usage could not be read.",
383 } satisfies CursorAccountUsageReadResult),
384 ),
385 );
386});