1/**
2 * Cursor account usage, cached per credential source.
3 *
4 * Cursor's dashboard API takes about 20 seconds for a 30-day window, so fetched
5 * records are kept for the whole retention period, whatever window was asked
6 * for. After one full fetch, a refresh fetches only the newest edge.
7 *
8 * A record's dedupe key carries its timestamp and an occurrence index counted
9 * within one fetch. A refetched range therefore replaces every cached record in
10 * that range rather than adding to it, so each timestamp's records always come
11 * from a single fetch and their occurrence indexes cannot collide.
12 *
13 * @module provider-cursor/server/accountCache
14 */
15import { cursorRateModel } from "./accountUsage.ts";
16import type { UsageRecord } from "@t3tools/provider-core/server/usage";
17
18export type CursorCredentialSource = string | { readonly kind: "keychain" };
19
20/** Matches the clients' stale time, so a page refetching on focus reuses one fetch. */
21export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000;
22
23/** Dashboard events can be finalized after they were first read. */
24const REFETCH_OVERLAP_MS = 60 * 60 * 1000;
25
26export interface CursorAccountCache {
27 readonly accountKey: string;
28 /** Inclusive range of event timestamps the records are complete for. */
29 readonly sinceMs: number;
30 readonly untilMs: number;
31 /** When the newest edge was fetched. */
32 readonly fetchedAtMs: number;
33 readonly records: readonly UsageRecord[];
34}
35
36interface CursorFetchRange {
37 readonly sinceMs: number;
38 readonly untilMs: number;
39}
40
41/** Whether the cache answers without a refresh. */
42export function isCursorCacheFresh(
43 cache: CursorAccountCache | undefined,
44 nowMs: number,
45): cache is CursorAccountCache {
46 return cache !== undefined && nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS;
47}
48
49/**
50 * The range to fetch so the cache covers `[retentionStartMs, nowMs]`: the
51 * newest edge, or everything when the cache does not reach back that far.
52 */
53export function cursorFetchRange(
54 cache: CursorAccountCache | undefined,
55 retentionStartMs: number,
56 nowMs: number,
57): CursorFetchRange {
58 return cache === undefined ||
59 cache.sinceMs > retentionStartMs ||
60 cache.untilMs - REFETCH_OVERLAP_MS < retentionStartMs
61 ? { sinceMs: retentionStartMs, untilMs: nowMs }
62 : { sinceMs: cache.untilMs - REFETCH_OVERLAP_MS, untilMs: nowMs };
63}
64
65/**
66 * Applies a fetched range to the cache, replacing the cached records it covers
67 * and dropping those before the retention start. Pass `undefined` for a cache
68 * that belongs to another account. `changed` is false when the fetch only
69 * confirmed the cached records, so there is nothing new to persist.
70 */
71export function mergeCursorFetch(
72 cache: CursorAccountCache | undefined,
73 accountKey: string,
74 range: CursorFetchRange,
75 fetched: readonly UsageRecord[],
76 nowMs: number,
77 retentionStartMs: number,
78) {
79 const inRange = (record: UsageRecord) =>
80 record.timestampMs >= range.sinceMs && record.timestampMs <= range.untilMs;
81 const previous = cache?.records ?? [];
82 const added = fetched.filter(inRange);
83 const replacedKeys = new Set(previous.filter(inRange).map((record) => record.dedupeKey));
84 const changed =
85 cache === undefined ||
86 replacedKeys.size !== added.length ||
87 added.some((record) => !replacedKeys.has(record.dedupeKey));
88 return {
89 changed,
90 cache: {
91 accountKey,
92 sinceMs: retentionStartMs,
93 untilMs: range.untilMs,
94 fetchedAtMs: nowMs,
95 records: [
96 ...previous.filter((record) => !inRange(record) && record.timestampMs >= retentionStartMs),
97 ...added,
98 ],
99 } satisfies CursorAccountCache,
100 };
101}
102
103/**
104 * Each version writes its own file, as the scan cache does, so servers of
105 * different versions sharing a state directory keep their own caches.
106 */
107export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v1.json";
108const CURSOR_ACCOUNT_CACHE_VERSION = 1;
109
110/** Positional rows with interned strings, as in the scan cache. */
111type SerializedRecord = readonly [
112 timestampMs: number,
113 modelIndex: number,
114 sessionIndex: number,
115 uncachedInputTokens: number,
116 cachedInputTokens: number,
117 cacheCreationTokens: number,
118 outputTokens: number,
119 reportedCostUsd: number | null,
120 dedupeKey: string | null,
121];
122
123interface SerializedAccount {
124 readonly accountKey: string;
125 readonly sinceMs: number;
126 readonly untilMs: number;
127 readonly fetchedAtMs: number;
128 readonly records: readonly SerializedRecord[];
129}
130
131/** Serialises the caches by credential source. */
132export function encodeCursorAccountCaches(caches: ReadonlyMap<string, CursorAccountCache>): string {
133 const strings: string[] = [];
134 const indexes = new Map<string, number>();
135 const intern = (value: string) => {
136 let index = indexes.get(value);
137 if (index === undefined) {
138 index = strings.length;
139 strings.push(value);
140 indexes.set(value, index);
141 }
142 return index;
143 };
144 const accounts: Record<string, SerializedAccount> = {};
145 for (const [credentialKey, cache] of caches) {
146 accounts[credentialKey] = {
147 accountKey: cache.accountKey,
148 sinceMs: cache.sinceMs,
149 untilMs: cache.untilMs,
150 fetchedAtMs: cache.fetchedAtMs,
151 records: cache.records.map((record) => [
152 record.timestampMs,
153 intern(record.model),
154 intern(record.sessionId),
155 record.totals.uncachedInputTokens,
156 record.totals.cachedInputTokens,
157 record.totals.cacheCreationTokens,
158 record.totals.outputTokens,
159 record.reportedCostUsd,
160 record.dedupeKey,
161 ]),
162 };
163 }
164 return JSON.stringify({ version: CURSOR_ACCOUNT_CACHE_VERSION, strings, accounts });
165}
166
167const isFiniteNumber = (value: unknown): value is number =>
168 typeof value === "number" && Number.isFinite(value);
169
170/**
171 * Rebuilds the caches from a parsed document. A malformed account is dropped
172 * whole, costing one refetch; keeping its survivors would leave a gap the
173 * covered range claims is complete.
174 */
175export function decodeCursorAccountCaches(document: unknown): Map<string, CursorAccountCache> {
176 const caches = new Map<string, CursorAccountCache>();
177 if (typeof document !== "object" || document === null) return caches;
178 const root = document as {
179 readonly version?: unknown;
180 readonly strings?: unknown;
181 readonly accounts?: unknown;
182 };
183 const strings = root.strings;
184 if (
185 root.version !== CURSOR_ACCOUNT_CACHE_VERSION ||
186 !Array.isArray(strings) ||
187 !strings.every((value): value is string => typeof value === "string") ||
188 typeof root.accounts !== "object" ||
189 root.accounts === null
190 ) {
191 return caches;
192 }
193 const stringAt = (index: unknown): string | undefined =>
194 typeof index === "number" ? strings[index] : undefined;
195
196 const decodeRecord = (row: unknown): UsageRecord | null => {
197 if (!Array.isArray(row)) return null;
198 const [
199 timestampMs,
200 modelIndex,
201 sessionIndex,
202 uncached,
203 cached,
204 creation,
205 output,
206 cost,
207 key,
208 ]: readonly unknown[] = row;
209 const model = stringAt(modelIndex);
210 const sessionId = stringAt(sessionIndex);
211 if (
212 !isFiniteNumber(timestampMs) ||
213 model === undefined ||
214 sessionId === undefined ||
215 !isFiniteNumber(uncached) ||
216 !isFiniteNumber(cached) ||
217 !isFiniteNumber(creation) ||
218 !isFiniteNumber(output) ||
219 (cost !== null && !isFiniteNumber(cost)) ||
220 (key !== null && typeof key !== "string")
221 ) {
222 return null;
223 }
224 return {
225 provider: "cursor",
226 timestampMs,
227 model,
228 rateModel: cursorRateModel(model),
229 sessionId,
230 totals: {
231 uncachedInputTokens: uncached,
232 cachedInputTokens: cached,
233 cacheCreationTokens: creation,
234 outputTokens: output,
235 reasoningTokens: 0,
236 },
237 reportedCostUsd: cost,
238 speed: "standard",
239 dedupeKey: key,
240 };
241 };
242
243 for (const [credentialKey, raw] of Object.entries(root.accounts)) {
244 if (typeof raw !== "object" || raw === null) continue;
245 const account = raw as Partial<Record<keyof SerializedAccount, unknown>>;
246 if (
247 typeof account.accountKey !== "string" ||
248 !isFiniteNumber(account.sinceMs) ||
249 !isFiniteNumber(account.untilMs) ||
250 !isFiniteNumber(account.fetchedAtMs) ||
251 !Array.isArray(account.records)
252 ) {
253 continue;
254 }
255 const records: UsageRecord[] = [];
256 for (const row of account.records) {
257 const record = decodeRecord(row);
258 if (record === null) break;
259 records.push(record);
260 }
261 if (records.length !== account.records.length) continue;
262 caches.set(credentialKey, {
263 accountKey: account.accountKey,
264 sinceMs: account.sinceMs,
265 untilMs: account.untilMs,
266 fetchedAtMs: account.fetchedAtMs,
267 records,
268 });
269 }
270 return caches;
271}