| 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 | */ |
| 8 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; |
| 9 | import * as Crypto from "effect/Crypto"; |
| 10 | import * as Duration from "effect/Duration"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Base64Url from "effect/encoding/Base64Url"; |
| 13 | import * as Hex from "effect/encoding/Hex"; |
| 14 | import * as Fiber from "effect/Fiber"; |
| 15 | import * as FileSystem from "effect/FileSystem"; |
| 16 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 17 | import * as HttpClient from "effect/http/HttpClient"; |
| 18 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 19 | import * as PlatformError from "effect/PlatformError"; |
| 20 | import * as Result from "effect/Result"; |
| 21 | import * as Schema from "effect/Schema"; |
| 22 | |
| 23 | import * as CursorKeychain from "./CursorKeychain.ts"; |
| 24 | |
| 25 | function 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 | |
| 31 | function 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 | */ |
| 40 | export 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 | |
| 47 | export 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. */ |
| 55 | class CursorAccountUsageInvalidError extends Schema.TaggedError<CursorAccountUsageInvalidError>()( |
| 56 | "CursorAccountUsageInvalidError", |
| 57 | { reason: Schema.String }, |
| 58 | ) {} |
| 59 | |
| 60 | const invalid = (reason: string) => Effect.fail(new CursorAccountUsageInvalidError({ reason })); |
| 61 | |
| 62 | const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 63 | |
| 64 | const pageRequestBody = (page: number, pageSize: number, sinceMs: number, endDate: number) => |
| 65 | JSON.stringify({ page, pageSize, startDate: String(sinceMs), endDate: String(endDate) }); |
| 66 | |
| 67 | const billedEventIdentity = (...fields: readonly unknown[]) => JSON.stringify(fields); |
| 68 | |
| 69 | const textEncoder = new TextEncoder(); |
| 70 | |
| 71 | const 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 | |
| 76 | function 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. */ |
| 88 | function 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 | |
| 100 | const 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. */ |
| 103 | const 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 | */ |
| 109 | export 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 | }); |