From c5846f298d42b4dbcc6e26c3776346a58cdacf16 Mon Sep 17 00:00:00 2001 From: Philip Date: Wed, 4 Nov 2026 20:19:41 -0500 Subject: [PATCH] fix(cursor): read account usage from dashboard billing items Cursor retired get-filtered-usage-events (it returns {} for every range), so Usage showed empty Cursor history. Session and Keychain logins now read /api/dashboard/v2/query-billing-items in day slices; API-key logins keep the RPC path. Bump the account cache to v2 so empty v1 caches are re-read. Co-authored-by: Cursor --- apps/server/src/usage/UsageService.test.ts | 45 ++ .../src/server/accountCache.test.ts | 6 + .../src/server/accountCache.ts | 25 +- .../src/server/accountUsage.test.ts | 672 +++++++++++------- .../src/server/accountUsage.ts | 544 +++++++++----- 5 files changed, 877 insertions(+), 415 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9cf97cd4c7c4d..994aa68cba51a 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1460,6 +1460,51 @@ describe("UsageService", () => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); + it.live("re-reads full Cursor account history over a cache the empty-usage reader wrote", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + yield* writeCursorLogin(home); + yield* TestClock.setTime(CURSOR_NOW); + const cursor = makeFakeCursor(); + cursor.state.events = [{ timestampMs: CURSOR_NOW - 30 * HOUR_MS, outputTokens: 5 }]; + yield* Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const lastRefreshMs = CURSOR_NOW - 10 * 60 * 1000; + // As stored by releases whose reader took Cursor's `{}` answer for an empty history. + yield* Effect.promise(async () => { + await NodeFSP.mkdir(stateDir, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(stateDir, "usage-cursor-account-cache-v1.json"), + JSON.stringify({ + version: 1, + strings: [], + accounts: { + [NodePath.join(home, "config", "cursor", "auth.json")]: { + accountKey: cursor.state.accountKey, + sinceMs: lastRefreshMs - 92 * 24 * HOUR_MS, + untilMs: lastRefreshMs, + fetchedAtMs: lastRefreshMs, + records: [], + }, + }, + }), + ); + }); + const service = yield* makeWithCursor(cursor.read); + const summary = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.strictEqual( + totalOutputTokens(summary), + 5, + `Cursor history older than the stale cache was not re-read: ${JSON.stringify(cursor.state.calls)}`, + ); + }).pipe( + Effect.provide( + layerService({ prefix: "usage-service-cursor-stale-cache", home, settings }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.live("does not read the macOS Cursor Keychain before account usage is enabled", () => Effect.gen(function* () { const { settings, home } = yield* setup; diff --git a/packages/provider-cursor/src/server/accountCache.test.ts b/packages/provider-cursor/src/server/accountCache.test.ts index 55b6ba5df3c66..63ba963d75c71 100644 --- a/packages/provider-cursor/src/server/accountCache.test.ts +++ b/packages/provider-cursor/src/server/accountCache.test.ts @@ -75,5 +75,11 @@ describe("cursorAccountCache", () => { 0, ).changed, ); + const corrected = { + ...record(10 * DAY_MS), + totals: { ...record(10 * DAY_MS).totals, outputTokens: 2 }, + reportedCostUsd: 0.25, + }; + assert.isTrue(mergeCursorFetch(cache, "a", range, [corrected], 11 * DAY_MS, 0).changed); }); }); diff --git a/packages/provider-cursor/src/server/accountCache.ts b/packages/provider-cursor/src/server/accountCache.ts index b2ff2fbd6b3a2..e28c72a88654e 100644 --- a/packages/provider-cursor/src/server/accountCache.ts +++ b/packages/provider-cursor/src/server/accountCache.ts @@ -1,4 +1,3 @@ -// Occurrence indices are fetch-local; replace entire refetched ranges to prevent key collisions. import { cursorRateModel } from "./accountUsage.ts"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; @@ -44,6 +43,17 @@ export function cursorFetchRange( : { sinceMs: cache.untilMs - REFETCH_OVERLAP_MS, untilMs: nowMs }; } +/** Billing item ids are stable, so a corrected item keeps its key and must be compared by content. */ +const sameSerializedRecord = (a: UsageRecord, b: UsageRecord) => + a.timestampMs === b.timestampMs && + a.model === b.model && + a.sessionId === b.sessionId && + a.totals.uncachedInputTokens === b.totals.uncachedInputTokens && + a.totals.cachedInputTokens === b.totals.cachedInputTokens && + a.totals.cacheCreationTokens === b.totals.cacheCreationTokens && + a.totals.outputTokens === b.totals.outputTokens && + a.reportedCostUsd === b.reportedCostUsd; + export function mergeCursorFetch( cache: CursorAccountCache | undefined, accountKey: string, @@ -56,11 +66,14 @@ export function mergeCursorFetch( record.timestampMs >= range.sinceMs && record.timestampMs <= range.untilMs; const previous = cache?.records ?? []; const added = fetched.filter(inRange); - const replacedKeys = new Set(previous.filter(inRange).map((record) => record.dedupeKey)); + const replaced = new Map(previous.filter(inRange).map((record) => [record.dedupeKey, record])); const changed = cache === undefined || - replacedKeys.size !== added.length || - added.some((record) => !replacedKeys.has(record.dedupeKey)); + replaced.size !== added.length || + added.some((record) => { + const prior = replaced.get(record.dedupeKey); + return prior === undefined || !sameSerializedRecord(prior, record); + }); return { changed, cache: { @@ -77,8 +90,8 @@ export function mergeCursorFetch( } /** Versioned files isolate caches for different server versions sharing a state directory. */ -export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v1.json"; -const CURSOR_ACCOUNT_CACHE_VERSION = 1; +export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v2.json"; +const CURSOR_ACCOUNT_CACHE_VERSION = 2; type SerializedRecord = readonly [ timestampMs: number, diff --git a/packages/provider-cursor/src/server/accountUsage.test.ts b/packages/provider-cursor/src/server/accountUsage.test.ts index a2ccc42cc7502..bb52625977f6d 100644 --- a/packages/provider-cursor/src/server/accountUsage.test.ts +++ b/packages/provider-cursor/src/server/accountUsage.test.ts @@ -27,9 +27,9 @@ const writeAuth = (accessToken: string) => }); interface DashboardRequest { + readonly method: string; readonly url: string; readonly init: RequestInit; - readonly page: number; readonly headers: Headers; } @@ -41,9 +41,9 @@ const dashboard = (answer: (request: DashboardRequest) => Response | Promise) => ({ + billingItemId: `item-${eventTime}`, + eventTime, + product: "cursor", + chargeCategory: "usage", + usageType: "included", + principal: { type: "user", id: "user_demo" }, + usage, + pricing: { currency: "USD", contracted: "0.0000000000" }, + displayExtras: { model: "composer-2.5", userEmail: "user@example.com", displayLabel: "Composer" }, + hasTokenMeters: true, + totalTokens: "0", +}); + +const billingItemsRequest = (request: DashboardRequest) => { + const url = new URL(request.url); + return request.method === "GET" && + url.origin === "https://cursor.com" && + url.pathname === "/api/dashboard/v2/query-billing-items" + ? url.searchParams + : null; +}; + +const JAN_1 = Date.parse("2026-01-01T00:00:00Z"); +const JAN_2 = Date.parse("2026-01-02T00:00:00Z"); + +const demoToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + describe("Cursor account history", () => { - it.effect( - "reads paginated Cursor account history including headless calls with separate cache tokens", - () => - Effect.gen(function* () { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); - const pages: number[] = []; - const signals: AbortSignal[] = []; - const request = ({ url, init, headers, page }: DashboardRequest) => { - assert.strictEqual(url, "https://cursor.com/api/dashboard/get-filtered-usage-events"); - assert.strictEqual(init.redirect, "error"); - assert.strictEqual(headers.get("origin"), "https://cursor.com"); - assert.strictEqual(headers.get("content-type"), "application/json"); - assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A"); - const body = { page }; - pages.push(body.page); - if (init.signal) signals.push(init.signal); + it.effect("sends the Cursor session only to cursor.com, one request per billing page", () => + Effect.gen(function* () { + const { authPath } = yield* writeAuth(demoToken); + const requests: DashboardRequest[] = []; + const result = yield* readAccountUsage( + authPath, + Date.parse("2026-10-31T00:00:00Z"), + Date.parse("2026-11-01T00:00:00Z"), + async (request) => { + requests.push(request); return Response.json({ - totalUsageEventsCount: 1001, - usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({ - timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000), - model: "claude-sonnet-4-5", - conversationId: `conversation-${body.page}`, - isHeadless: body.page === 2, - chargedCents: 0, - tokenUsage: { - inputTokens: 10, - outputTokens: 5, - cacheReadTokens: 30, - cacheWriteTokens: 2, - totalCents: 25, - }, - })), + data: [], + pagination: { nextCursor: requests.length === 1 ? "page-2" : null }, }); - }; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, request); - assert.isNull(result.error); - assert.deepStrictEqual(pages, [1, 2]); - assert.lengthOf(signals, 2); - assert.notStrictEqual(signals[0], signals[1]); - assert.strictEqual(result.records.length, 1001); - assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2"); - assert.deepStrictEqual(result.records[0]?.totals, { - uncachedInputTokens: 10, - cachedInputTokens: 30, - cacheCreationTokens: 2, - outputTokens: 5, - reasoningTokens: 0, - }); - assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25); - assert.isFalse(result.accountKey?.includes("demo") ?? true); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + }, + ); + assert.isNull(result.error); + assert.deepStrictEqual( + requests.map(({ url }) => Object.fromEntries(new URL(url).searchParams)), + [ + { + scope: "user", + startTime: "2026-10-31T00:00:00.000Z", + endTime: "2026-11-01T00:00:00.000Z", + pageSize: "1000", + }, + { + scope: "user", + startTime: "2026-10-31T00:00:00.000Z", + endTime: "2026-11-01T00:00:00.000Z", + pageSize: "1000", + cursor: "page-2", + }, + ], + ); + for (const { method, url, init, headers } of requests) { + assert.strictEqual(method, "GET"); + assert.isNotNull(billingItemsRequest({ method, url, init, headers })); + assert.strictEqual(init.redirect, "error"); + assert.strictEqual(headers.get("origin"), "https://cursor.com"); + assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A"); + } + assert.notStrictEqual(requests[0]?.init.signal, requests[1]?.init.signal); + assert.isFalse(result.accountKey?.includes("demo") ?? true); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); - it.effect("reads Cursor account history beyond 100 pages", () => + it.effect("prices included billing items from the rate table and keeps contracted charges", () => Effect.gen(function* () { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); - const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null })); - let requests = 0; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { - requests += 1; - return Response.json({ - totalUsageEventsCount: 100_001, - usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }], - }); + const { authPath } = yield* writeAuth(demoToken); + const item = (eventTime: string, pricing: { currency: string; contracted: string }) => ({ + ...billingItem(eventTime, { "prompt-text-tokens": "10" }), + pricing, }); + const result = yield* readAccountUsage(authPath, JAN_1, JAN_2, async () => + Response.json({ + data: [ + item("2026-01-01T00:00:00.000Z", { currency: "USD", contracted: "0.0000000000" }), + item("2026-01-01T00:00:01.000Z", { currency: "USD", contracted: "0.0125000000" }), + item("2026-01-01T00:00:02.000Z", { currency: "EUR", contracted: "1.0000000000" }), + ], + }), + ); + assert.isNull(result.error); + assert.deepStrictEqual( + result.records.map((record) => record.reportedCostUsd), + [null, 0.0125, null], + ); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect("keeps identically billed items apart and counts a repeated item once", () => + Effect.gen(function* () { + const { authPath } = yield* writeAuth(demoToken); + const usage = { "completion-text-tokens": "5" }; + const first = { ...billingItem("2026-01-01T00:00:00.000Z", usage), billingItemId: "a" }; + const twin = { ...first, billingItemId: "b" }; + const result = yield* readAccountUsage(authPath, JAN_1, JAN_2, async ({ url }) => + Response.json( + new URL(url).searchParams.get("cursor") === null + ? { data: [first, twin], pagination: { nextCursor: "page-2" } } + : { data: [twin] }, + ), + ); assert.isNull(result.error); - assert.strictEqual(requests, 101); - assert.deepStrictEqual(result.records, []); + assert.deepStrictEqual( + result.records.map((record) => record.dedupeKey), + [`cursor-account:${result.accountKey}:a`, `cursor-account:${result.accountKey}:b`], + ); }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect("accepts confirmed empty Cursor usage but rejects error envelopes", () => Effect.gen(function* () { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const { authPath } = yield* writeAuth(demoToken); for (const body of [ {}, - { totalUsageEventsCount: 0 }, - { totalUsageEventsCount: 0, usageEventsDisplay: [] }, + { data: [] }, + { data: [], pagination: {} }, + { data: [], pagination: { nextCursor: null } }, + { + data: [ + { billingItemId: "fee", chargeCategory: "subscription", hasTokenMeters: false }, + { billingItemId: "seat", chargeCategory: "subscription" }, + ], + }, ]) { - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => + const result = yield* readAccountUsage(authPath, JAN_1, JAN_2, async () => Response.json(body), ); - assert.isNull(result.error); + assert.isNull(result.error, JSON.stringify(body)); assert.deepStrictEqual(result.records, []); assert.isFalse(result.missing); } for (const body of [ { error: "upstream error" }, + { data: [], error: "upstream error" }, + { message: "Unauthorized" }, + { data: {} }, + { data: null }, { detail: "unknown error envelope" }, - { totalUsageEventsCount: 0, error: "upstream error" }, + { data: [], pagination: "next" }, + { data: [], pagination: { nextCursor: 2 } }, + { data: [], pagination: { nextCursor: "" } }, null, [], "invalid", 0, ]) { - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => + const result = yield* readAccountUsage(authPath, JAN_1, JAN_2, async () => Response.json(body), ); - assert.isNotNull(result.error); + assert.isNotNull(result.error, JSON.stringify(body)); assert.deepStrictEqual(result.records, []); } }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); - it.effect("requires a terminal Cursor page after a full page reaches the reported count", () => + it.effect("rejects billing items it cannot account for", () => Effect.gen(function* () { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); + const { authPath } = yield* writeAuth(demoToken); + const valid = billingItem("2026-01-01T00:00:00.000Z", { "prompt-text-tokens": "10" }); + for (const item of [ + null, + { ...valid, eventTime: "yesterday" }, + { ...valid, eventTime: undefined }, + { ...valid, displayExtras: {} }, + { ...valid, billingItemId: undefined }, + { ...valid, usage: "10" }, + { ...valid, usage: { "prompt-text-tokens": "-1" } }, + { ...valid, usage: { "prompt-text-tokens": "1.5" } }, + { ...valid, usage: { "completion-text-tokens": "many" } }, + { ...valid, usage: { "cached-prompt-text-tokens": -1 } }, + ]) { + const result = yield* readAccountUsage(authPath, JAN_1, JAN_2, async () => + Response.json({ data: [valid, item] }), + ); + assert.isNotNull(result.error, JSON.stringify(item)); + assert.deepStrictEqual(result.records, []); + } + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect("stops when Cursor repeats a pagination cursor", () => + Effect.gen(function* () { + const { authPath } = yield* writeAuth(demoToken); let requests = 0; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { + const result = yield* readAccountUsage(authPath, JAN_1, JAN_2, async () => { requests++; - return Response.json( - requests === 1 - ? { - totalUsageEventsCount: 1000, - usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({ - timestamp: String(1780000000000 + index), - model: "gpt-5", - tokenUsage: { inputTokens: 10, outputTokens: 5 }, - })), - } - : { totalUsageEventsCount: 1000 }, - ); + return Response.json({ data: [], pagination: { nextCursor: "same" } }); }); - assert.isNull(result.error); - assert.strictEqual(result.records.length, 1000); + assert.isNotNull(result.error); assert.strictEqual(requests, 2); }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); - it.effect( - "removes only count-proven Cursor boundary copies and preserves identical billed events", - () => - Effect.gen(function* () { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const { authPath } = yield* writeAuth(accessToken); - const event = (index: number) => ({ - timestamp: String(1780000000000 + index), - model: "gpt-5", - tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 }, - }); - for (const total of [2000, 2001]) { - let requests = 0; - const result = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { - requests++; + it.effect("reads day slices six at a time and counts an item on a slice boundary once", () => + Effect.gen(function* () { + const { authPath } = yield* writeAuth(demoToken); + const day = (date: number, hour = 0) => + `2026-11-0${date}T${String(hour).padStart(2, "0")}:00:00.000Z`; + const eventTimes = [1, 2, 3, 4, 5, 6, 7, 8].flatMap((date) => + date === 8 ? [day(date)] : [day(date), day(date, 12)], + ); + const firstPages: string[] = []; + let open = 0; + let mostOpen = 0; + const { promise: sixOpen, resolve: release } = Promise.withResolvers(); + const result = yield* readAccountUsage( + authPath, + Date.parse(day(1)), + Date.parse(day(8)), + async ({ url }) => { + const params = new URL(url).searchParams; + const start = Date.parse(params.get("startTime") ?? ""); + const end = Date.parse(params.get("endTime") ?? ""); + // Each slice ends where the next begins, so Cursor returns midnight items twice. + const inSlice = eventTimes.filter((time) => { + const ms = Date.parse(time); + return ms >= start && ms <= end; + }); + if (params.get("cursor") === "page-2") return Response.json({ - totalUsageEventsCount: total, - usageEventsDisplay: - requests === 1 - ? Array.from({ length: 1000 }, (_, index) => event(index)) - : requests === 2 - ? Array.from({ length: 1000 }, (_, index) => event(999 + index)) - : [event(1999)], + data: inSlice + .slice(1) + .map((time) => billingItem(time, { "prompt-text-tokens": "1" })), }); - }); - assert.isNull(result.error); - assert.strictEqual(result.records.length, total); - assert.strictEqual(requests, 3); - assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999); - assert.strictEqual( - result.records.filter((record) => record.timestampMs === 1780000000999).length, - total === 2000 ? 1 : 2, - ); - assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total); - } - let requests = 0; - const inconsistent = yield* readAccountUsage(authPath, 0, 1781000000000, async () => { - requests++; + firstPages.push(`${params.get("startTime")}/${params.get("endTime")}`); + mostOpen = Math.max(mostOpen, ++open); + if (open === 6) release(); + await sixOpen; + open--; return Response.json({ - totalUsageEventsCount: 1001, - usageEventsDisplay: - requests === 1 - ? Array.from({ length: 1000 }, (_, index) => event(index)) - : [event(500), event(1000)], + data: inSlice + .slice(0, 1) + .map((time) => billingItem(time, { "prompt-text-tokens": "1" })), + pagination: { nextCursor: "page-2" }, }); - }); - assert.isNotNull(inconsistent.error); - assert.deepStrictEqual(inconsistent.records, []); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + }, + ); + assert.isNull(result.error); + assert.strictEqual(mostOpen, 6); + assert.deepStrictEqual( + firstPages.toSorted(), + [1, 2, 3, 4, 5, 6, 7].map((date) => `${day(date)}/${day(date + 1)}`), + ); + assert.deepStrictEqual( + result.records.map((record) => record.timestampMs), + eventTimes.map((time) => Date.parse(time)), + ); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); - it.effect( - "does not present truncated Cursor account pages or authentication failures as complete history", - () => - Effect.gen(function* () { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; - const { dir, authPath } = yield* writeAuth(accessToken); - const truncated = yield* readAccountUsage(authPath, 0, 1781000000000, async () => - Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }), - ); - assert.isNotNull(truncated.error); - assert.deepStrictEqual(truncated.records, []); - const denied = yield* readAccountUsage( - authPath, - 0, - 1781000000000, - async () => new Response(accessToken, { status: 401 }), - ); - assert.isNotNull(denied.error); - assert.isFalse(denied.error?.includes(accessToken) ?? true); - assert.deepStrictEqual(denied.records, []); - let requested = false; - const missing = yield* readAccountUsage( - (yield* Path.Path).join(dir, "missing.json"), - 0, - 1781000000000, - async () => { - requested = true; - return Response.json({}); - }, - ); - assert.isTrue(missing.missing); - assert.isFalse(requested); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + it.effect("asks for 100 items a page when Cursor rejects 1,000", () => + Effect.gen(function* () { + const { authPath } = yield* writeAuth(demoToken); + const pageSizes: (string | null)[] = []; + const result = yield* readAccountUsage(authPath, JAN_1, JAN_2, async ({ url }) => { + const params = new URL(url).searchParams; + pageSizes.push(params.get("pageSize")); + if (params.get("pageSize") === "1000") return new Response(null, { status: 400 }); + return params.get("cursor") === null + ? Response.json({ + data: [billingItem("2026-01-01T00:00:00.000Z", { "prompt-text-tokens": "1" })], + pagination: { nextCursor: "page-2" }, + }) + : Response.json({ + data: [billingItem("2026-01-01T00:00:01.000Z", { "prompt-text-tokens": "1" })], + }); + }); + assert.isNull(result.error); + assert.strictEqual(result.records.length, 2); + assert.deepStrictEqual(pageSizes, ["1000", "100", "100"]); + + pageSizes.length = 0; + const rejected = yield* readAccountUsage(authPath, JAN_1, JAN_2, async ({ url }) => { + pageSizes.push(new URL(url).searchParams.get("pageSize")); + return new Response(null, { status: 400 }); + }); + assert.strictEqual(rejected.error, "Cursor account usage could not be read."); + assert.deepStrictEqual(pageSizes, ["1000", "100"]); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect("does not present authentication failures or missing credentials as history", () => + Effect.gen(function* () { + const { dir, authPath } = yield* writeAuth(demoToken); + const denied = yield* readAccountUsage( + authPath, + 0, + 1781000000000, + async () => new Response(demoToken, { status: 401 }), + ); + assert.strictEqual(denied.error, "Sign in to Cursor again to read account usage."); + assert.deepStrictEqual(denied.records, []); + let requested = false; + const missing = yield* readAccountUsage( + (yield* Path.Path).join(dir, "missing.json"), + 0, + 1781000000000, + async () => { + requested = true; + return Response.json({ data: [] }); + }, + ); + assert.isTrue(missing.missing); + assert.isFalse(requested); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); -describe("readCursorAccountUsage", () => { - it.effect("reads an API key's account history through Cursor's API", () => +describe("Cursor account billing items", () => { + it.effect("reads Cursor account history from the dashboard's billing items", () => Effect.gen(function* () { const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const requests: Array = []; + const { authPath } = yield* writeAuth(accessToken); const result = yield* readAccountUsage( - { kind: "apiKey", apiKey: "api-key" }, - 0, - 1781000000000, - ({ url, headers }) => { - requests.push([url, headers.get("authorization")]); - return url.endsWith("/auth/exchange_user_api_key") - ? Response.json({ accessToken }) + authPath, + Date.parse("2026-11-01T00:00:00Z"), + Date.parse("2026-11-02T00:00:00Z"), + async (request) => { + const params = billingItemsRequest(request); + // Cursor still answers the old usage-events endpoint, with an empty body. + if (params === null) return Response.json({}); + assert.strictEqual(params.get("scope"), "user"); + assert.include( + request.headers.get("cookie") ?? "", + "WorkosCursorSessionToken=demo%3A%3A", + ); + return params.get("cursor") === "page-2" + ? Response.json({ + data: [billingItem("2026-11-01T01:00:00.000Z", { "completion-text-tokens": "7" })], + }) : Response.json({ - totalUsageEventsCount: 1, - usageEventsDisplay: [ - { timestamp: "1780000000000", model: "gpt-5", tokenUsage: { outputTokens: 4 } }, + data: [ + billingItem("2026-11-01T03:43:46.022Z", { + "cache-write-text-tokens": "0", + "cached-prompt-text-tokens": "46400", + "completion-text-tokens": "312", + "prompt-text-tokens": "1263", + }), ], + pagination: { nextCursor: "page-2" }, }); }, ); assert.isNull(result.error); - assert.deepStrictEqual(requests, [ - ["https://api2.cursor.sh/auth/exchange_user_api_key", "Bearer api-key"], + assert.deepStrictEqual( + result.records.map((record) => ({ + timestampMs: record.timestampMs, + model: record.model, + totals: record.totals, + })), [ - "https://api2.cursor.sh/aiserver.v1.DashboardService/GetFilteredUsageEvents", - `Bearer ${accessToken}`, + { + timestampMs: Date.parse("2026-11-01T03:43:46.022Z"), + model: "composer-2.5", + totals: { + uncachedInputTokens: 1263, + cachedInputTokens: 46400, + cacheCreationTokens: 0, + outputTokens: 312, + reasoningTokens: 0, + }, + }, + { + timestampMs: Date.parse("2026-11-01T01:00:00.000Z"), + model: "composer-2.5", + totals: { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 7, + reasoningTokens: 0, + }, + }, ], - ]); - assert.strictEqual(result.records[0]?.totals.outputTokens, 4); + ); }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); - it.effect("stops waiting for an API key exchange that never answers", () => + it.effect("keeps the days around an idle day Cursor answers with an empty body", () => Effect.gen(function* () { - const read = yield* Effect.forkChild( - readAccountUsage( - { kind: "apiKey", apiKey: "api-key" }, - 0, - 1781000000000, - () => new Promise(() => {}), - ), + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + const { authPath } = yield* writeAuth(accessToken); + const result = yield* readAccountUsage( + authPath, + Date.parse("2026-11-01T00:00:00Z"), + Date.parse("2026-11-04T00:00:00Z"), + async (request) => { + const params = billingItemsRequest(request); + // Cursor still answers the old usage-events endpoint, with an empty body. + if (params === null) return Response.json({}); + const day = params.get("startTime")?.slice(0, 10); + return day === "2026-11-02" + ? Response.json({}) + : Response.json({ + data: [billingItem(`${day}T12:00:00.000Z`, { "completion-text-tokens": "5" })], + }); + }, + ); + assert.isNull(result.error); + assert.deepStrictEqual( + result.records.map((record) => record.timestampMs), + [Date.parse("2026-11-01T12:00:00.000Z"), Date.parse("2026-11-03T12:00:00.000Z")], ); - yield* TestClock.adjust("10 seconds"); - const result = yield* Fiber.join(read); - assert.strictEqual(result.error, "Cursor account usage could not be read."); }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); +}); +describe("readCursorAccountUsage", () => { it.effect("asks for Keychain approval when the prompt goes unanswered", () => Effect.gen(function* () { const result = yield* readAccountUsage( @@ -356,64 +506,35 @@ describe("readCursorAccountUsage", () => { }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); - it.effect("reads the pages behind the first together and keeps them in page order", () => + it.effect("ends on a failed or stalled page, leaving no request open", () => Effect.gen(function* () { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - let inFlight = 0; - let mostInFlight = 0; - const result = yield* readAccountUsage( - { kind: "keychain" }, - 0, - 1781000000000, - async ({ page }) => { - inFlight++; - mostInFlight = Math.max(mostInFlight, inFlight); - for (let turn = page; turn < 9; turn++) await Promise.resolve(); - inFlight--; - return Response.json({ - totalUsageEventsCount: 8001, - usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({ - timestamp: String(1780000000000 + (page - 1) * 1000 + index), - model: "gpt-5", - tokenUsage: { inputTokens: 1 }, - })), - }); - }, - Effect.succeed(accessToken), - ); - assert.isNull(result.error); - assert.strictEqual(mostInFlight, 6); - assert.deepStrictEqual( - result.records.map((record) => record.timestampMs), - Array.from({ length: 8001 }, (_, index) => 1780000000000 + index), - ); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), - ); - - it.effect("ends like a page-by-page read when a page fails, leaving no request open", () => - Effect.gen(function* () { - const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; - const fullPage = { - totalUsageEventsCount: 8001, - usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })), + const firstPage = { + data: [billingItem("2026-01-01T00:00:00.000Z", { "prompt-text-tokens": "10" })], + pagination: { nextCursor: "page-2" }, }; for (const [secondPage, error] of [ - [() => Response.json(fullPage), "Cursor account usage could not be read."], [ - () => new Response(null, { status: 401 }), + () => Promise.resolve(new Response(null, { status: 401 })), "Sign in to Cursor again to read account usage.", ], + [ + () => Promise.reject(new Error("connection reset")), + "Cursor account usage could not be read.", + ], + [undefined, "Cursor account usage could not be read."], ] as const) { let open = 0; - const result = yield* readAccountUsage( + const { promise: stalled, resolve: stall } = Promise.withResolvers(); + const read = yield* readAccountUsage( { kind: "keychain" }, 0, 1781000000000, - ({ init, page }) => { - if (page === 1) return Promise.resolve(Response.json(fullPage)); - if (page === 2) return Promise.resolve(secondPage()); - if (page === 3) return Promise.reject(new Error("connection reset")); + ({ url, init }) => { + if (new URL(url).searchParams.get("cursor") === null) + return Promise.resolve(Response.json(firstPage)); + if (secondPage !== undefined) return secondPage(); open++; + stall(); return new Promise((_resolve, reject) => { init.signal?.addEventListener("abort", () => { open--; @@ -421,8 +542,13 @@ describe("readCursorAccountUsage", () => { }); }); }, - Effect.succeed(accessToken), - ); + Effect.succeed(demoToken), + ).pipe(Effect.forkChild); + if (secondPage === undefined) { + yield* Effect.promise(() => stalled); + yield* TestClock.adjust("10 seconds"); + } + const result = yield* Fiber.join(read); assert.strictEqual(result.error, error); assert.deepStrictEqual(result.records, []); assert.strictEqual(open, 0); @@ -430,3 +556,53 @@ describe("readCursorAccountUsage", () => { }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); + +describe("Cursor account API-key history", () => { + it.effect("reads an API key's account history through Cursor's API", () => + Effect.gen(function* () { + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + const requests: Array = []; + const result = yield* readAccountUsage( + { kind: "apiKey", apiKey: "api-key" }, + 0, + 1781000000000, + ({ url, headers }) => { + requests.push([url, headers.get("authorization")]); + return url.endsWith("/auth/exchange_user_api_key") + ? Response.json({ accessToken }) + : Response.json({ + totalUsageEventsCount: 1, + usageEventsDisplay: [ + { timestamp: "1780000000000", model: "gpt-5", tokenUsage: { outputTokens: 4 } }, + ], + }); + }, + ); + assert.isNull(result.error); + assert.deepStrictEqual(requests, [ + ["https://api2.cursor.sh/auth/exchange_user_api_key", "Bearer api-key"], + [ + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetFilteredUsageEvents", + `Bearer ${accessToken}`, + ], + ]); + assert.strictEqual(result.records[0]?.totals.outputTokens, 4); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect("stops waiting for an API key exchange that never answers", () => + Effect.gen(function* () { + const read = yield* Effect.forkChild( + readAccountUsage( + { kind: "apiKey", apiKey: "api-key" }, + 0, + 1781000000000, + () => new Promise(() => {}), + ), + ); + yield* TestClock.adjust("10 seconds"); + const result = yield* Fiber.join(read); + assert.strictEqual(result.error, "Cursor account usage could not be read."); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); +}); diff --git a/packages/provider-cursor/src/server/accountUsage.ts b/packages/provider-cursor/src/server/accountUsage.ts index 2719960eed033..0a7e3bd0b3ee8 100644 --- a/packages/provider-cursor/src/server/accountUsage.ts +++ b/packages/provider-cursor/src/server/accountUsage.ts @@ -1,5 +1,6 @@ import type { UsageRecord } from "@t3tools/provider-core/server/usage"; import * as Digest from "effect/crypto/Digest"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; @@ -9,6 +10,7 @@ import * as FileSystem from "effect/FileSystem"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -27,6 +29,13 @@ function tokens(value: unknown): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; } +/** Cursor reports token counts as decimal strings; a missing meter counts as zero. */ +function tokenCount(value: unknown): number | null { + if (value === undefined) return 0; + const count = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value; + return typeof count === "number" && Number.isSafeInteger(count) && count >= 0 ? count : null; +} + // Grok's xAI first-party rate entry has no bare alias. export function cursorRateModel(model: string): string { const base = model @@ -47,6 +56,11 @@ class CursorAccountUsageInvalidError extends Schema.TaggedError()( + "CursorAccountUsageSignInError", + {}, +) {} + const invalid = (reason: string) => Effect.fail(new CursorAccountUsageInvalidError({ reason })); const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -86,11 +100,26 @@ function boundaryOverlap(previous: readonly string[], current: readonly string[] return lengths.at(-1) ?? 0; } -const DASHBOARD_USAGE_URL = "https://cursor.com/api/dashboard/get-filtered-usage-events"; +const BILLING_ITEMS_URL = "https://cursor.com/api/dashboard/v2/query-billing-items"; +const FILTERED_USAGE_RPC_URL = `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetFilteredUsageEvents`; +const PAGE_SIZE = 1000; +/** Cursor's previous usage endpoint answered 400 above 1,000 items; this one's cap is unpublished. */ +const FALLBACK_PAGE_SIZE = 100; +/** A busy account bills tens of thousands of items a month, read as day slices six at a time. */ +const SLICE_MS = 24 * 60 * 60 * 1000; +const CONCURRENT_SLICES = 6; +const PAGE_TIMEOUT = Duration.seconds(10); /** The session cookie must never follow a redirect off cursor.com. */ const NO_REDIRECT: RequestInit = { redirect: "error" }; +const readTimeoutForSlices = (sinceMs: number, endDate: number) => { + const sliceCount = Math.max(1, Math.ceil((endDate - sinceMs) / SLICE_MS)); + const rounds = Math.ceil(sliceCount / CONCURRENT_SLICES); + // Each round can spend one page timeout per concurrent slice chain; keep a 60s floor. + return Duration.seconds(Math.max(60, rounds * 15)); +}; + export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(function* ( credentialSource: CursorCredentialSource, sinceMs: number, @@ -165,6 +194,8 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi }; } let accountKey: string | null = null; + const useBillingItems = + typeof credentialSource === "string" || credentialSource.kind === "keychain"; const read = Effect.gen(function* () { const payload = Base64Url.decodeString(accessToken.split(".")[1] ?? ""); if (Result.isFailure(payload)) return yield* invalid("Invalid authentication"); @@ -177,43 +208,87 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi const userId = subject.split("|").at(-1); if (!userId) return yield* invalid("Invalid authentication"); accountKey = yield* accountHash(subject); - if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) + if ( + Option.isNone(DateTime.make(sinceMs)) || + Option.isNone(DateTime.make(endDate)) || + sinceMs < 0 || + sinceMs > endDate + ) return yield* invalid("Invalid date window"); - // The web dashboard rejects API-key tokens; Cursor's RPC API accepts them. - const pageRequest = - typeof credentialSource !== "string" && credentialSource.kind === "apiKey" - ? HttpClientRequest.post( - `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetFilteredUsageEvents`, - ).pipe( - HttpClientRequest.bearerToken(accessToken), - HttpClientRequest.setHeader("connect-protocol-version", "1"), - ) - : HttpClientRequest.post(DASHBOARD_USAGE_URL).pipe( - HttpClientRequest.setHeaders({ - Origin: "https://cursor.com", - Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, - }), - ); - const records: UsageRecord[] = []; - const occurrences = new Map(); - const pages: unknown[][] = []; - let completed = false; - const pageSize = 1000; - let total: number | undefined; - const readPage = Effect.fn("CursorAccountUsage.readPage")( - function* (page: number) { - // A count can include overlapping page boundaries. Allow room to - // reconcile them without imposing a fixed account-size limit. - if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { - return yield* invalid("Account usage page limit exceeded"); - } + + if (useBillingItems) { + return yield* readBillingItemsHistory({ + httpClient, + accessToken, + userId, + accountKey, + sinceMs, + endDate, + }); + } + + return yield* readFilteredUsageEventsHistory({ + httpClient, + accessToken, + accountKey, + sinceMs, + endDate, + }); + }); + return yield* read.pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: useBillingItems ? readTimeoutForSlices(sinceMs, endDate) : Duration.seconds(60), + orElse: () => invalid("Account usage read timed out"), + }), + Effect.catchTags({ + CursorAccountUsageSignInError: () => + Effect.succeed({ + accountKey, + records: [], + missing: false, + error: "Sign in to Cursor again to read account usage.", + } satisfies CursorAccountUsageReadResult), + }), + Effect.orElseSucceed( + () => + ({ + accountKey, + records: [], + missing: false, + error: "Cursor account usage could not be read.", + }) satisfies CursorAccountUsageReadResult, + ), + ); +}); + +const readBillingItemsHistory = Effect.fn("CursorAccountUsage.readBillingItemsHistory")( + function* (input: { + readonly httpClient: HttpClient.HttpClient; + readonly accessToken: string; + readonly userId: string; + readonly accountKey: string; + readonly sinceMs: number; + readonly endDate: number; + }) { + const { httpClient, accessToken, userId, accountKey, sinceMs, endDate } = input; + const readPage = Effect.fn("CursorAccountUsage.readBillingPage")( + function* (startMs: number, endMs: number, pageSize: number, cursor: string | undefined) { const response = yield* httpClient .execute( - pageRequest.pipe( - HttpClientRequest.bodyText( - pageRequestBody(page, pageSize, sinceMs, endDate), - "application/json", - ), + HttpClientRequest.get(BILLING_ITEMS_URL, { + urlParams: { + scope: "user", + startTime: DateTime.formatIso(DateTime.makeUnsafe(startMs)), + endTime: DateTime.formatIso(DateTime.makeUnsafe(endMs)), + pageSize, + ...(cursor === undefined ? {} : { cursor }), + }, + }).pipe( + HttpClientRequest.setHeaders({ + Origin: "https://cursor.com", + Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, + }), ), ) .pipe( @@ -222,14 +297,10 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi () => new CursorAccountUsageInvalidError({ reason: "Account usage request failed" }), ), ); - if (response.status === 401 || response.status === 403) { - return { - accountKey, - records: [], - missing: false, - error: "Sign in to Cursor again to read account usage.", - } satisfies CursorAccountUsageReadResult; - } + if (response.status === 401 || response.status === 403) + return yield* new CursorAccountUsageSignInError(); + if (response.status === 400 && cursor === undefined && pageSize !== FALLBACK_PAGE_SIZE) + return null; if (response.status < 200 || response.status >= 300) return yield* invalid("Account usage request failed"); const parsed: unknown = yield* response.json.pipe( @@ -241,150 +312,301 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi return yield* invalid("Invalid account usage page"); } const body = object(parsed); - const keys = Object.keys(body); if ("error" in body || "message" in body || "code" in body) return yield* invalid("Account usage error response"); - const count = keys.length === 0 ? 0 : body.totalUsageEventsCount; - const events = - keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount") - ? [] - : body.usageEventsDisplay; - if ( - (count !== undefined && - (typeof count !== "number" || - !Number.isSafeInteger(count) || - count < 0 || - (total !== undefined && count !== total))) || - !Array.isArray(events) || - events.length > pageSize || - (count === undefined && !Array.isArray(body.usageEventsDisplay)) - ) { - return yield* invalid("Inconsistent account usage page"); - } - if (typeof count === "number") total = count; - return events as unknown[]; + // Cursor's JSON omits empty fields, so an idle slice answers `{}`. + const data = Object.keys(body).length === 0 ? [] : body.data; + const pagination = body.pagination ?? {}; + if (!Array.isArray(data) || typeof pagination !== "object" || Array.isArray(pagination)) + return yield* invalid("Invalid account usage page"); + const nextCursor = object(pagination).nextCursor ?? undefined; + if (nextCursor !== undefined && (typeof nextCursor !== "string" || !nextCursor)) + return yield* invalid("Invalid account usage page"); + return { items: data as unknown[], nextCursor }; }, Effect.timeoutOrElse({ - duration: Duration.seconds(10), + duration: PAGE_TIMEOUT, orElse: () => invalid("Account usage request timed out"), }), ); - // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages - // the first one's count implies are requested up to six ahead and consumed in page order. - const ahead: Fiber.Fiber< - Effect.Success>, - CursorAccountUsageInvalidError - >[] = []; - for (let page = 1; ; page++) { - while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) { - ahead.push(yield* Effect.forkScoped(readPage(page + ahead.length))); - } - const next = ahead.shift(); - const events = yield* next === undefined ? readPage(page) : Fiber.join(next); - if (!Array.isArray(events)) return events; - pages.push(events); - if (events.length < pageSize) { - completed = true; - break; - } - } - if (!completed) return yield* invalid("Account usage page limit exceeded"); - const rawCount = pages.reduce((sum, page) => sum + page.length, 0); - if (total !== undefined && rawCount < total) - return yield* invalid("Incomplete account usage pages"); - let removalsRemaining = total === undefined ? 0 : rawCount - total; - let previousKeys: string[] = []; - for (const events of pages) { - const eventKeys = - removalsRemaining > 0 - ? yield* Effect.forEach(events, (event) => accountHash(canonicalJson(event))) - : []; - const removalCount = Math.min(removalsRemaining, boundaryOverlap(previousKeys, eventKeys)); - removalsRemaining -= removalCount; - previousKeys = eventKeys; - for (const raw of events.slice(removalCount)) { - const event = object(raw); - const usage = object(event.tokenUsage); - if (event.tokenUsage === undefined || event.tokenUsage === null) continue; - for (const key of [ - "inputTokens", - "outputTokens", - "cacheReadTokens", - "cacheWriteTokens", - "totalCents", - ]) { - const value = usage[key]; - if ( - value !== undefined && - (typeof value !== "number" || !Number.isFinite(value) || value < 0) - ) { - return yield* invalid("Invalid account usage totals"); - } + let pageSize = PAGE_SIZE; + const readSlice = Effect.fn("CursorAccountUsage.readSlice")(function* (startMs: number) { + const endMs = Math.min(startMs + SLICE_MS, endDate); + const items: unknown[] = []; + const cursors = new Set(); + let cursor: string | undefined; + for (;;) { + const page = yield* readPage(startMs, endMs, pageSize, cursor); + if (page === null) { + pageSize = FALLBACK_PAGE_SIZE; + continue; } - const timestampMs = - typeof event.timestamp === "string" && event.timestamp.trim() !== "" - ? Number(event.timestamp) - : event.timestamp; + items.push(...page.items); + cursor = page.nextCursor; + if (cursor === undefined) return items; + if (cursors.has(cursor)) return yield* invalid("Account usage pagination loop"); + cursors.add(cursor); + } + }); + const sliceStarts: number[] = []; + for (let startMs = sinceMs; sliceStarts.length === 0 || startMs < endDate; startMs += SLICE_MS) + sliceStarts.push(startMs); + const slices = yield* Effect.forEach(sliceStarts, readSlice, { + concurrency: CONCURRENT_SLICES, + }); + const records: UsageRecord[] = []; + const itemIds = new Set(); + for (const items of slices) { + for (const raw of items) { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) + return yield* invalid("Invalid account usage event"); + const item = object(raw); + if (item.hasTokenMeters === false || item.usage === undefined || item.usage === null) + continue; + const usage = item.usage; + if (typeof usage !== "object" || Array.isArray(usage)) + return yield* invalid("Invalid account usage totals"); + const meters = object(usage); + const uncachedInputTokens = tokenCount(meters["prompt-text-tokens"]); + const cachedInputTokens = tokenCount(meters["cached-prompt-text-tokens"]); + const cacheCreationTokens = tokenCount(meters["cache-write-text-tokens"]); + const outputTokens = tokenCount(meters["completion-text-tokens"]); + if ( + uncachedInputTokens === null || + cachedInputTokens === null || + cacheCreationTokens === null || + outputTokens === null + ) + return yield* invalid("Invalid account usage totals"); + const timestampMs = typeof item.eventTime === "string" ? Date.parse(item.eventTime) : NaN; + const model = object(item.displayExtras).model; + const id = item.billingItemId; if ( - typeof timestampMs !== "number" || !Number.isFinite(timestampMs) || - typeof event.model !== "string" || - !event.model + typeof model !== "string" || + !model || + typeof id !== "string" || + !id ) return yield* invalid("Invalid account usage event"); - if (timestampMs < sinceMs || timestampMs > endDate) continue; - const totals = { - uncachedInputTokens: tokens(usage.inputTokens), - cachedInputTokens: tokens(usage.cacheReadTokens), - cacheCreationTokens: tokens(usage.cacheWriteTokens), - outputTokens: tokens(usage.outputTokens), - reasoningTokens: 0, - }; - const reportedCostUsd = - typeof usage.totalCents === "number" ? usage.totalCents / 100 : null; - const sessionId = typeof event.conversationId === "string" ? event.conversationId : ""; - // No event ID is provided. Preserve identical billed rows with an occurrence index. - const key = yield* accountHash( - billedEventIdentity(timestampMs, event.model, sessionId, totals, reportedCostUsd), - ); - const occurrence = occurrences.get(key) ?? 0; - occurrences.set(key, occurrence + 1); + if (timestampMs < sinceMs || timestampMs > endDate || itemIds.has(id)) continue; + itemIds.add(id); + const pricing = object(item.pricing); + const contracted = + typeof pricing.contracted === "string" ? Number(pricing.contracted) : pricing.contracted; records.push({ provider: "cursor", timestampMs, - model: event.model, - rateModel: cursorRateModel(event.model), - sessionId, - totals, - reportedCostUsd, + model, + rateModel: cursorRateModel(model), + sessionId: "", + totals: { + uncachedInputTokens, + cachedInputTokens, + cacheCreationTokens, + outputTokens, + reasoningTokens: 0, + }, + // Included-plan items are contracted at 0, so price those from the rate table instead. + reportedCostUsd: + pricing.currency === "USD" && + typeof contracted === "number" && + Number.isFinite(contracted) && + contracted > 0 + ? contracted + : null, speed: "standard", - dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`, + dedupeKey: `cursor-account:${accountKey}:${id}`, }); } - yield* Effect.yieldNow; } - if (removalsRemaining !== 0) return yield* invalid("Inconsistent account usage boundaries"); return { accountKey, records, missing: false, error: null, } satisfies CursorAccountUsageReadResult; - }); - return yield* read.pipe( - Effect.scoped, + }, +); + +/** API-key tokens are rejected by the web dashboard; Cursor's RPC API still accepts them. */ +const readFilteredUsageEventsHistory = Effect.fn( + "CursorAccountUsage.readFilteredUsageEventsHistory", +)(function* (input: { + readonly httpClient: HttpClient.HttpClient; + readonly accessToken: string; + readonly accountKey: string; + readonly sinceMs: number; + readonly endDate: number; +}) { + const { httpClient, accessToken, accountKey, sinceMs, endDate } = input; + const pageRequest = HttpClientRequest.post(FILTERED_USAGE_RPC_URL).pipe( + HttpClientRequest.bearerToken(accessToken), + HttpClientRequest.setHeader("connect-protocol-version", "1"), + ); + const records: UsageRecord[] = []; + const occurrences = new Map(); + const pages: unknown[][] = []; + let completed = false; + const pageSize = 1000; + let total: number | undefined; + const readPage = Effect.fn("CursorAccountUsage.readFilteredPage")( + function* (page: number) { + if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { + return yield* invalid("Account usage page limit exceeded"); + } + const response = yield* httpClient + .execute( + pageRequest.pipe( + HttpClientRequest.bodyText( + pageRequestBody(page, pageSize, sinceMs, endDate), + "application/json", + ), + ), + ) + .pipe( + Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT), + Effect.mapError( + () => new CursorAccountUsageInvalidError({ reason: "Account usage request failed" }), + ), + ); + if (response.status === 401 || response.status === 403) { + return yield* new CursorAccountUsageSignInError(); + } + if (response.status < 200 || response.status >= 300) + return yield* invalid("Account usage request failed"); + const parsed: unknown = yield* response.json.pipe( + Effect.mapError( + () => new CursorAccountUsageInvalidError({ reason: "Invalid account usage page" }), + ), + ); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return yield* invalid("Invalid account usage page"); + } + const body = object(parsed); + const keys = Object.keys(body); + if ("error" in body || "message" in body || "code" in body) + return yield* invalid("Account usage error response"); + const count = keys.length === 0 ? 0 : body.totalUsageEventsCount; + const events = + keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount") + ? [] + : body.usageEventsDisplay; + if ( + (count !== undefined && + (typeof count !== "number" || + !Number.isSafeInteger(count) || + count < 0 || + (total !== undefined && count !== total))) || + !Array.isArray(events) || + events.length > pageSize || + (count === undefined && !Array.isArray(body.usageEventsDisplay)) + ) { + return yield* invalid("Inconsistent account usage page"); + } + if (typeof count === "number") total = count; + return events as unknown[]; + }, Effect.timeoutOrElse({ - duration: Duration.seconds(60), - orElse: () => invalid("Account usage read timed out"), + duration: PAGE_TIMEOUT, + orElse: () => invalid("Account usage request timed out"), }), - Effect.orElseSucceed( - () => - ({ - accountKey, - records: [], - missing: false, - error: "Cursor account usage could not be read.", - }) satisfies CursorAccountUsageReadResult, - ), ); + const ahead: Fiber.Fiber< + Effect.Success>, + CursorAccountUsageInvalidError | CursorAccountUsageSignInError + >[] = []; + for (let page = 1; ; page++) { + while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) { + ahead.push(yield* Effect.forkScoped(readPage(page + ahead.length))); + } + const next = ahead.shift(); + const events = yield* next === undefined ? readPage(page) : Fiber.join(next); + if (!Array.isArray(events)) return events; + pages.push(events); + if (events.length < pageSize) { + completed = true; + break; + } + } + if (!completed) return yield* invalid("Account usage page limit exceeded"); + const rawCount = pages.reduce((sum, page) => sum + page.length, 0); + if (total !== undefined && rawCount < total) + return yield* invalid("Incomplete account usage pages"); + let removalsRemaining = total === undefined ? 0 : rawCount - total; + let previousKeys: string[] = []; + for (const events of pages) { + const eventKeys = + removalsRemaining > 0 + ? yield* Effect.forEach(events, (event) => accountHash(canonicalJson(event))) + : []; + const removalCount = Math.min(removalsRemaining, boundaryOverlap(previousKeys, eventKeys)); + removalsRemaining -= removalCount; + previousKeys = eventKeys; + for (const raw of events.slice(removalCount)) { + const event = object(raw); + const usage = object(event.tokenUsage); + if (event.tokenUsage === undefined || event.tokenUsage === null) continue; + for (const key of [ + "inputTokens", + "outputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "totalCents", + ]) { + const value = usage[key]; + if ( + value !== undefined && + (typeof value !== "number" || !Number.isFinite(value) || value < 0) + ) { + return yield* invalid("Invalid account usage totals"); + } + } + const timestampMs = + typeof event.timestamp === "string" && event.timestamp.trim() !== "" + ? Number(event.timestamp) + : event.timestamp; + if ( + typeof timestampMs !== "number" || + !Number.isFinite(timestampMs) || + typeof event.model !== "string" || + !event.model + ) + return yield* invalid("Invalid account usage event"); + if (timestampMs < sinceMs || timestampMs > endDate) continue; + const totals = { + uncachedInputTokens: tokens(usage.inputTokens), + cachedInputTokens: tokens(usage.cacheReadTokens), + cacheCreationTokens: tokens(usage.cacheWriteTokens), + outputTokens: tokens(usage.outputTokens), + reasoningTokens: 0, + }; + const reportedCostUsd = typeof usage.totalCents === "number" ? usage.totalCents / 100 : null; + const sessionId = typeof event.conversationId === "string" ? event.conversationId : ""; + const key = yield* accountHash( + billedEventIdentity(timestampMs, event.model, sessionId, totals, reportedCostUsd), + ); + const occurrence = occurrences.get(key) ?? 0; + occurrences.set(key, occurrence + 1); + records.push({ + provider: "cursor", + timestampMs, + model: event.model, + rateModel: cursorRateModel(event.model), + sessionId, + totals, + reportedCostUsd, + speed: "standard", + dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`, + }); + } + yield* Effect.yieldNow; + } + if (removalsRemaining !== 0) return yield* invalid("Inconsistent account usage boundaries"); + return { + accountKey, + records, + missing: false, + error: null, + } satisfies CursorAccountUsageReadResult; });