| 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 | */ |
| 15 | import { cursorRateModel } from "./accountUsage.ts"; |
| 16 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; |
| 17 | |
| 18 | export type CursorCredentialSource = string | { readonly kind: "keychain" }; |
| 19 | |
| 20 | /** Matches the clients' stale time, so a page refetching on focus reuses one fetch. */ |
| 21 | export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000; |
| 22 | |
| 23 | /** Dashboard events can be finalized after they were first read. */ |
| 24 | const REFETCH_OVERLAP_MS = 60 * 60 * 1000; |
| 25 | |
| 26 | export 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 | |
| 36 | interface CursorFetchRange { |
| 37 | readonly sinceMs: number; |
| 38 | readonly untilMs: number; |
| 39 | } |
| 40 | |
| 41 | /** Whether the cache answers without a refresh. */ |
| 42 | export 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 | */ |
| 53 | export 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 | */ |
| 71 | export 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 | */ |
| 107 | export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v1.json"; |
| 108 | const CURSOR_ACCOUNT_CACHE_VERSION = 1; |
| 109 | |
| 110 | /** Positional rows with interned strings, as in the scan cache. */ |
| 111 | type 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 | |
| 123 | interface 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. */ |
| 132 | export 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 | |
| 167 | const 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 | */ |
| 175 | export 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 | } |