1import type { UsageRecord } from "@t3tools/provider-core/server/usage";
2import * as Digest from "effect/crypto/Digest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Base64Url from "effect/encoding/Base64Url";
6import * as Fiber from "effect/Fiber";
7import * as FetchHttpClient from "effect/http/FetchHttpClient";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import * as Result from "effect/Result";
11import * as Schema from "effect/Schema";
12
13import type { CursorCredentialSource } from "./accountCache.ts";
14import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts";
15
16function object(value: unknown): Record<string, unknown> {
17 return typeof value === "object" && value !== null && !Array.isArray(value)
18 ? (value as Record<string, unknown>)
19 : {};
20}
21
22function tokens(value: unknown): number {
23 return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
24}
25
26// Grok's xAI first-party rate entry has no bare alias.
27export function cursorRateModel(model: string): string {
28 const base = model
29 .replace(/^cursor-/, "")
30 .replace(/(?:-thinking)?(?:-(?:none|minimal|low|medium|high|xhigh|max))?(?:-fast)?$/, "");
31 return base.startsWith("grok-") ? `xai/${base}` : base;
32}
33
34export interface CursorAccountUsageReadResult {
35 readonly accountKey: string | null;
36 readonly records: readonly UsageRecord[];
37 readonly missing: boolean;
38 readonly error: string | null;
39}
40
41class CursorAccountUsageInvalidError extends Schema.TaggedError<CursorAccountUsageInvalidError>()(
42 "CursorAccountUsageInvalidError",
43 { reason: Schema.String },
44) {}
45
46const invalid = (reason: string) => Effect.fail(new CursorAccountUsageInvalidError({ reason }));
47
48const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown));
49
50const pageRequestBody = (page: number, pageSize: number, sinceMs: number, endDate: number) =>
51 JSON.stringify({ page, pageSize, startDate: String(sinceMs), endDate: String(endDate) });
52
53const billedEventIdentity = (...fields: readonly unknown[]) => JSON.stringify(fields);
54
55const textEncoder = new TextEncoder();
56
57function canonicalJson(value: unknown): string {
58 if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
59 if (value !== null && typeof value === "object") {
60 return `{${Object.entries(value)
61 .sort(([a], [b]) => a.localeCompare(b))
62 .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`)
63 .join(",")}}`;
64 }
65 return JSON.stringify(value) ?? "null";
66}
67
68function boundaryOverlap(previous: readonly string[], current: readonly string[]): number {
69 const sequence = [...current, "", ...previous];
70 const lengths = Array.from({ length: sequence.length }, () => 0);
71 for (let index = 1; index < sequence.length; index++) {
72 let length = lengths[index - 1]!;
73 while (length > 0 && sequence[index] !== sequence[length]) length = lengths[length - 1]!;
74 if (sequence[index] === sequence[length]) length++;
75 lengths[index] = length;
76 }
77 return lengths.at(-1) ?? 0;
78}
79
80/** Never forward the access token through a redirect. */
81const NO_REDIRECT: RequestInit = { redirect: "error" };
82
83export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(function* (
84 credentialSource: CursorCredentialSource,
85 sinceMs: number,
86 endDate: number,
87): Effect.fn.Return<CursorAccountUsageReadResult, never, Digest.Digest | HttpClient.HttpClient> {
88 const httpClient = yield* HttpClient.HttpClient;
89 const digest = yield* Digest.Digest;
90 // Per-event hashes share the scan span and use the native hex encoder.
91 const accountHash = (value: string) => digest.digestHex(Digest.sha256, textEncoder.encode(value));
92 const credential = yield* Effect.result(exchangeCursorApiKey(credentialSource.apiKey));
93 if (Result.isFailure(credential)) {
94 return {
95 accountKey: null,
96 records: [],
97 missing: false,
98 error: "Cursor account usage could not be read.",
99 };
100 }
101 const accessToken = credential.success;
102 let accountKey: string | null = null;
103 const read = Effect.gen(function* () {
104 const payload = Base64Url.decodeString(accessToken.split(".")[1] ?? "");
105 if (Result.isFailure(payload)) return yield* invalid("Invalid authentication");
106 const subject = object(
107 yield* decodeJson(payload.success).pipe(
108 Effect.catch(() => invalid("Invalid authentication")),
109 ),
110 ).sub;
111 if (typeof subject !== "string" || !subject) return yield* invalid("Invalid authentication");
112 accountKey = yield* accountHash(subject);
113 if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate)
114 return yield* invalid("Invalid date window");
115 const pageRequest = HttpClientRequest.post(
116 `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetFilteredUsageEvents`,
117 ).pipe(
118 HttpClientRequest.bearerToken(accessToken),
119 HttpClientRequest.setHeader("connect-protocol-version", "1"),
120 );
121 const records: UsageRecord[] = [];
122 const occurrences = new Map<string, number>();
123 const normalized: Array<(Omit<UsageRecord, "dedupeKey"> & { dedupeKey: string }) | null> = [];
124 const pages: Array<{ start: number; end: number; overlap: number }> = [];
125 let previousEvents: unknown[] = [];
126 const pageSize = 1000;
127 let total: number | undefined;
128 const readPage = Effect.fn("CursorAccountUsage.readPage")(
129 function* (page: number) {
130 // A count can include overlapping page boundaries. Allow room to
131 // reconcile them without imposing a fixed account-size limit.
132 if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) {
133 return yield* invalid("Account usage page limit exceeded");
134 }
135 const response = yield* httpClient
136 .execute(
137 pageRequest.pipe(
138 HttpClientRequest.bodyText(
139 pageRequestBody(page, pageSize, sinceMs, endDate),
140 "application/json",
141 ),
142 ),
143 )
144 .pipe(
145 Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT),
146 Effect.mapError(
147 () => new CursorAccountUsageInvalidError({ reason: "Account usage request failed" }),
148 ),
149 );
150 if (response.status === 401 || response.status === 403) {
151 return {
152 accountKey,
153 records: [],
154 missing: false,
155 error: "Sign in to Cursor again to read account usage.",
156 } satisfies CursorAccountUsageReadResult;
157 }
158 if (response.status < 200 || response.status >= 300)
159 return yield* invalid("Account usage request failed");
160 const parsed: unknown = yield* response.json.pipe(
161 Effect.mapError(
162 () => new CursorAccountUsageInvalidError({ reason: "Invalid account usage page" }),
163 ),
164 );
165 if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
166 return yield* invalid("Invalid account usage page");
167 }
168 const body = object(parsed);
169 const keys = Object.keys(body);
170 if ("error" in body || "message" in body || "code" in body)
171 return yield* invalid("Account usage error response");
172 const count = keys.length === 0 ? 0 : body.totalUsageEventsCount;
173 const events =
174 keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount")
175 ? []
176 : body.usageEventsDisplay;
177 if (
178 (count !== undefined &&
179 (typeof count !== "number" ||
180 !Number.isSafeInteger(count) ||
181 count < 0 ||
182 (total !== undefined && count !== total))) ||
183 !Array.isArray(events) ||
184 events.length > pageSize ||
185 (count === undefined && !Array.isArray(body.usageEventsDisplay))
186 ) {
187 return yield* invalid("Inconsistent account usage page");
188 }
189 if (typeof count === "number") total = count;
190 return events as unknown[];
191 },
192 Effect.timeoutOrElse({
193 duration: Duration.seconds(10),
194 orElse: () => invalid("Account usage request timed out"),
195 }),
196 );
197 // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages
198 // the first one's count implies are requested up to six ahead and consumed in page order.
199 const ahead: Fiber.Fiber<
200 Effect.Success<ReturnType<typeof readPage>>,
201 CursorAccountUsageInvalidError
202 >[] = [];
203 for (let page = 1; ; page++) {
204 while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) {
205 ahead.push(yield* Effect.forkScoped(readPage(page + ahead.length)));
206 }
207 const next = ahead.shift();
208 const events = yield* next === undefined ? readPage(page) : Fiber.join(next);
209 if (!Array.isArray(events)) return events;
210 const start = normalized.length;
211 const firstTimestamp = object(events[0]).timestamp;
212 // Equal raw events must have equal timestamps; avoid canonicalizing disjoint pages.
213 const mayOverlap =
214 (typeof firstTimestamp !== "string" && typeof firstTimestamp !== "number") ||
215 previousEvents.some((event) => object(event).timestamp === firstTimestamp);
216 const overlap = mayOverlap
217 ? boundaryOverlap(previousEvents.map(canonicalJson), events.map(canonicalJson))
218 : 0;
219 previousEvents = events;
220 for (const raw of events) {
221 const event = object(raw);
222 const usage = object(event.tokenUsage);
223 if (event.tokenUsage === undefined || event.tokenUsage === null) {
224 normalized.push(null);
225 continue;
226 }
227 for (const key of [
228 "inputTokens",
229 "outputTokens",
230 "cacheReadTokens",
231 "cacheWriteTokens",
232 "totalCents",
233 ]) {
234 const value = usage[key];
235 if (
236 value !== undefined &&
237 (typeof value !== "number" || !Number.isFinite(value) || value < 0)
238 ) {
239 return yield* invalid("Invalid account usage totals");
240 }
241 }
242 const timestampMs =
243 typeof event.timestamp === "string" && event.timestamp.trim() !== ""
244 ? Number(event.timestamp)
245 : event.timestamp;
246 if (
247 typeof timestampMs !== "number" ||
248 !Number.isFinite(timestampMs) ||
249 typeof event.model !== "string" ||
250 !event.model
251 )
252 return yield* invalid("Invalid account usage event");
253 if (timestampMs < sinceMs || timestampMs > endDate) {
254 normalized.push(null);
255 continue;
256 }
257 const totals = {
258 uncachedInputTokens: tokens(usage.inputTokens),
259 cachedInputTokens: tokens(usage.cacheReadTokens),
260 cacheCreationTokens: tokens(usage.cacheWriteTokens),
261 outputTokens: tokens(usage.outputTokens),
262 reasoningTokens: 0,
263 };
264 const reportedCostUsd =
265 typeof usage.totalCents === "number" ? usage.totalCents / 100 : null;
266 const sessionId = typeof event.conversationId === "string" ? event.conversationId : "";
267 // No event ID is provided. Preserve identical billed rows with an occurrence index.
268 const key = yield* accountHash(
269 billedEventIdentity(timestampMs, event.model, sessionId, totals, reportedCostUsd),
270 );
271 normalized.push({
272 provider: "cursor",
273 timestampMs,
274 model: event.model,
275 rateModel: cursorRateModel(event.model),
276 sessionId,
277 totals,
278 reportedCostUsd,
279 speed: "standard",
280 dedupeKey: key,
281 });
282 }
283 pages.push({ start, end: normalized.length, overlap });
284 if (events.length < pageSize) break;
285 yield* Effect.yieldNow;
286 }
287 const rawCount = normalized.length;
288 if (total !== undefined && rawCount < total)
289 return yield* invalid("Incomplete account usage pages");
290 let removalsRemaining = total === undefined ? 0 : rawCount - total;
291 // The final count proves which boundary copies to remove, including non-token events.
292 for (const page of pages) {
293 const removalCount = Math.min(removalsRemaining, page.overlap);
294 removalsRemaining -= removalCount;
295 for (let index = page.start; index < page.end; index++) {
296 const record = normalized[index];
297 normalized[index] = null;
298 if (index < page.start + removalCount || record == null) continue;
299 const key = record.dedupeKey;
300 const occurrence = occurrences.get(key) ?? 0;
301 occurrences.set(key, occurrence + 1);
302 record.dedupeKey = `cursor-account:${accountKey}:${key}:${occurrence}`;
303 records.push(record);
304 }
305 yield* Effect.yieldNow;
306 }
307 if (removalsRemaining !== 0) return yield* invalid("Inconsistent account usage boundaries");
308 yield* Effect.annotateCurrentSpan({
309 "cursor.usage.pages": pages.length,
310 "cursor.usage.events": rawCount,
311 "cursor.usage.records": records.length,
312 });
313 return {
314 accountKey,
315 records,
316 missing: false,
317 error: null,
318 } satisfies CursorAccountUsageReadResult;
319 });
320 return yield* read.pipe(
321 Effect.scoped,
322 Effect.timeoutOrElse({
323 duration: Duration.seconds(60),
324 orElse: () => invalid("Account usage read timed out"),
325 }),
326 Effect.orElseSucceed(
327 () =>
328 ({
329 accountKey,
330 records: [],
331 missing: false,
332 error: "Cursor account usage could not be read.",
333 }) satisfies CursorAccountUsageReadResult,
334 ),
335 );
336});