Skip to content
Source
Sign inSign up

accountCache.ts

243 lines7.3 KB

1// Occurrence indices are fetch-local; replace entire refetched ranges to prevent key collisions.
2import { cursorRateModel } from "./accountUsage.ts";
3import type { UsageRecord } from "@t3tools/provider-core/server/usage";
4​
5export type CursorCredentialSource = { readonly kind: "apiKey"; readonly apiKey: string };
6​
7export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000;
8​
9/** Dashboard events can be finalized after they were first read. */
10const REFETCH_OVERLAP_MS = 60 * 60 * 1000;
11​
12export interface CursorAccountCache {
13 readonly accountKey: string;
14 readonly sinceMs: number;
15 readonly untilMs: number;
16 readonly fetchedAtMs: number;
17 readonly records: readonly UsageRecord[];
19​
20interface CursorFetchRange {
21 readonly sinceMs: number;
22 readonly untilMs: number;
24​
25export function isCursorCacheFresh(
26 cache: CursorAccountCache | undefined,
27 nowMs: number,
28): cache is CursorAccountCache {
29 return cache !== undefined && nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS;
31​
32export function cursorFetchRange(
33 cache: CursorAccountCache | undefined,
34 retentionStartMs: number,
35 nowMs: number,
36): CursorFetchRange {
37 return cache === undefined ||
38 cache.sinceMs > retentionStartMs ||
39 cache.untilMs - REFETCH_OVERLAP_MS < retentionStartMs
40 ? { sinceMs: retentionStartMs, untilMs: nowMs }
41 : { sinceMs: cache.untilMs - REFETCH_OVERLAP_MS, untilMs: nowMs };
43​
44export function mergeCursorFetch(
45 cache: CursorAccountCache | undefined,
46 accountKey: string,
47 range: CursorFetchRange,
48 fetched: readonly UsageRecord[],
49 nowMs: number,
50 retentionStartMs: number,
51) {
52 const inRange = (record: UsageRecord) =>
53 record.timestampMs >= range.sinceMs && record.timestampMs <= range.untilMs;
54 const previous = cache?.records ?? [];
55 const added = fetched.filter(inRange);
56 const replacedKeys = new Set<string | null>();
57 let pruned = false;
58 for (const record of previous) {
59 if (inRange(record)) replacedKeys.add(record.dedupeKey);
60 else if (record.timestampMs < retentionStartMs) pruned = true;
61 }
62 const changed =
63 cache === undefined ||
64 pruned ||
65 replacedKeys.size !== added.length ||
66 added.some((record) => !replacedKeys.has(record.dedupeKey));
67 const records = !changed
68 ? previous
69 : previous
70 .filter((record) => !inRange(record) && record.timestampMs >= retentionStartMs)
71 .concat(added);
72 return {
73 changed,
74 cache: {
75 accountKey,
76 sinceMs: retentionStartMs,
77 untilMs: range.untilMs,
78 fetchedAtMs: nowMs,
79 records,
80 } satisfies CursorAccountCache,
81 };
83​
84/** Versioned files isolate caches for different server versions sharing a state directory. */
85export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v1.json";
86const CURSOR_ACCOUNT_CACHE_VERSION = 1;
87​
88type SerializedRecord = readonly [
89 timestampMs: number,
90 modelIndex: number,
91 sessionIndex: number,
92 uncachedInputTokens: number,
93 cachedInputTokens: number,
94 cacheCreationTokens: number,
95 outputTokens: number,
96 reportedCostUsd: number | null,
97 dedupeKey: string | null,
98];
99​
100interface SerializedAccount {
101 readonly accountKey: string;
102 readonly sinceMs: number;
103 readonly untilMs: number;
104 readonly fetchedAtMs: number;
105 readonly records: readonly SerializedRecord[];
107​
108export function encodeCursorAccountCaches(caches: ReadonlyMap<string, CursorAccountCache>): string {
109 const strings: string[] = [];
110 const indexes = new Map<string, number>();
111 const intern = (value: string) => {
112 let index = indexes.get(value);
113 if (index === undefined) {
114 index = strings.length;
115 strings.push(value);
116 indexes.set(value, index);
117 }
118 return index;
119 };
120 const accounts: Record<string, SerializedAccount> = {};
121 for (const [credentialKey, cache] of caches) {
122 accounts[credentialKey] = {
123 accountKey: cache.accountKey,
124 sinceMs: cache.sinceMs,
125 untilMs: cache.untilMs,
126 fetchedAtMs: cache.fetchedAtMs,
127 records: cache.records.map((record) => [
128 record.timestampMs,
129 intern(record.model),
130 intern(record.sessionId),
131 record.totals.uncachedInputTokens,
132 record.totals.cachedInputTokens,
133 record.totals.cacheCreationTokens,
134 record.totals.outputTokens,
135 record.reportedCostUsd,
136 record.dedupeKey,
137 ]),
138 };
139 }
140 return JSON.stringify({ version: CURSOR_ACCOUNT_CACHE_VERSION, strings, accounts });
142​
143const isFiniteNumber = (value: unknown): value is number =>
144 typeof value === "number" && Number.isFinite(value);
145​
146/** Drop a malformed account whole; surviving records would leave gaps in a range marked complete. */
147export function decodeCursorAccountCaches(document: unknown): Map<string, CursorAccountCache> {
148 const caches = new Map<string, CursorAccountCache>();
149 if (typeof document !== "object" || document === null) return caches;
150 const root = document as {
151 readonly version?: unknown;
152 readonly strings?: unknown;
153 readonly accounts?: unknown;
154 };
155 const strings = root.strings;
156 if (
157 root.version !== CURSOR_ACCOUNT_CACHE_VERSION ||
158 !Array.isArray(strings) ||
159 !strings.every((value): value is string => typeof value === "string") ||
160 typeof root.accounts !== "object" ||
161 root.accounts === null
162 ) {
163 return caches;
164 }
165 const stringAt = (index: unknown): string | undefined =>
166 typeof index === "number" ? strings[index] : undefined;
167​
168 const decodeRecord = (row: unknown): UsageRecord | null => {
169 if (!Array.isArray(row)) return null;
170 const [
171 timestampMs,
172 modelIndex,
173 sessionIndex,
174 uncached,
175 cached,
176 creation,
177 output,
178 cost,
179 key,
180 ]: readonly unknown[] = row;
181 const model = stringAt(modelIndex);
182 const sessionId = stringAt(sessionIndex);
183 if (
184 !isFiniteNumber(timestampMs) ||
185 model === undefined ||
186 sessionId === undefined ||
187 !isFiniteNumber(uncached) ||
188 !isFiniteNumber(cached) ||
189 !isFiniteNumber(creation) ||
190 !isFiniteNumber(output) ||
191 (cost !== null && !isFiniteNumber(cost)) ||
192 (key !== null && typeof key !== "string")
193 ) {
194 return null;
195 }
196 return {
197 provider: "cursor",
198 timestampMs,
199 model,
200 rateModel: cursorRateModel(model),
201 sessionId,
202 totals: {
203 uncachedInputTokens: uncached,
204 cachedInputTokens: cached,
205 cacheCreationTokens: creation,
206 outputTokens: output,
207 reasoningTokens: 0,
208 },
209 reportedCostUsd: cost,
210 speed: "standard",
211 dedupeKey: key,
212 };
213 };
214​
215 for (const [credentialKey, raw] of Object.entries(root.accounts)) {
216 if (typeof raw !== "object" || raw === null) continue;
217 const account = raw as Partial<Record<keyof SerializedAccount, unknown>>;
218 if (
219 typeof account.accountKey !== "string" ||
220 !isFiniteNumber(account.sinceMs) ||
221 !isFiniteNumber(account.untilMs) ||
222 !isFiniteNumber(account.fetchedAtMs) ||
223 !Array.isArray(account.records)
224 ) {
225 continue;
226 }
227 const records: UsageRecord[] = [];
228 for (const row of account.records) {
229 const record = decodeRecord(row);
230 if (record === null) break;
231 records.push(record);
232 }
233 if (records.length !== account.records.length) continue;
234 caches.set(credentialKey, {
235 accountKey: account.accountKey,
236 sinceMs: account.sinceMs,
237 untilMs: account.untilMs,
238 fetchedAtMs: account.fetchedAtMs,
239 records,
240 });
241 }
242 return caches;