@latentminds/pi-quotas 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,399 @@
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ /** Aggregated token counts */
6
+ export interface TokenBuckets {
7
+ input: number;
8
+ output: number;
9
+ cacheRead: number;
10
+ cacheWrite: number;
11
+ totalTokens: number;
12
+ costTotal: number;
13
+ }
14
+
15
+ export function emptyBuckets(): TokenBuckets {
16
+ return {
17
+ input: 0,
18
+ output: 0,
19
+ cacheRead: 0,
20
+ cacheWrite: 0,
21
+ totalTokens: 0,
22
+ costTotal: 0,
23
+ };
24
+ }
25
+
26
+ export function addBuckets(a: TokenBuckets, b: TokenBuckets): TokenBuckets {
27
+ return {
28
+ input: a.input + b.input,
29
+ output: a.output + b.output,
30
+ cacheRead: a.cacheRead + b.cacheRead,
31
+ cacheWrite: a.cacheWrite + b.cacheWrite,
32
+ totalTokens: a.totalTokens + b.totalTokens,
33
+ costTotal: a.costTotal + b.costTotal,
34
+ };
35
+ }
36
+
37
+ /** Per-model usage row */
38
+ export interface ModelUsage {
39
+ provider: string;
40
+ model: string;
41
+ tokens: TokenBuckets;
42
+ messageCount: number;
43
+ }
44
+
45
+ /** Per-session usage row */
46
+ export interface SessionUsage {
47
+ sessionId: string;
48
+ sessionPath: string;
49
+ cwd: string;
50
+ name?: string;
51
+ created: Date;
52
+ tokens: TokenBuckets;
53
+ messageCount: number;
54
+ }
55
+
56
+ /** Full aggregation result */
57
+ export interface AggregateResult {
58
+ totals: TokenBuckets;
59
+ byModel: ModelUsage[];
60
+ byProvider: Array<{
61
+ provider: string;
62
+ tokens: TokenBuckets;
63
+ messageCount: number;
64
+ }>;
65
+ bySession: SessionUsage[];
66
+ sessionCount: number;
67
+ messageCount: number;
68
+ }
69
+
70
+ /** Minimal assistant message fields for extraction */
71
+ interface AssistantMessageData {
72
+ role: string;
73
+ provider?: string;
74
+ model?: string;
75
+ usage?: {
76
+ input?: number;
77
+ output?: number;
78
+ cacheRead?: number;
79
+ cacheWrite?: number;
80
+ totalTokens?: number;
81
+ cost?: {
82
+ input?: number;
83
+ output?: number;
84
+ cacheRead?: number;
85
+ cacheWrite?: number;
86
+ total?: number;
87
+ };
88
+ };
89
+ }
90
+
91
+ interface SessionEntry {
92
+ type: string;
93
+ message?: AssistantMessageData;
94
+ name?: string;
95
+ }
96
+
97
+ /**
98
+ * Find the pi sessions directory. Scans ~/.pi/agent/sessions/ for subdirectories.
99
+ */
100
+ function getSessionsBaseDir(): string {
101
+ const agentDir =
102
+ process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
103
+ return join(agentDir, "sessions");
104
+ }
105
+
106
+ /**
107
+ * Discover all JSONL session files across all project directories.
108
+ */
109
+ async function discoverSessionFiles(): Promise<string[]> {
110
+ const baseDir = getSessionsBaseDir();
111
+ const files: string[] = [];
112
+
113
+ let projectDirs: string[];
114
+ try {
115
+ const entries = await readdir(baseDir, { withFileTypes: true });
116
+ projectDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
117
+ } catch {
118
+ return [];
119
+ }
120
+
121
+ for (const projectDir of projectDirs) {
122
+ const projectPath = join(baseDir, projectDir);
123
+ try {
124
+ const sessionEntries = await readdir(projectPath, {
125
+ withFileTypes: true,
126
+ });
127
+ for (const entry of sessionEntries) {
128
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
129
+ files.push(join(projectPath, entry.name));
130
+ }
131
+ }
132
+ } catch {
133
+ // skip inaccessible project dirs
134
+ }
135
+ }
136
+
137
+ return files;
138
+ }
139
+
140
+ /**
141
+ * Parse a single JSONL session file and extract token usage from assistant messages.
142
+ * Returns session metadata + per-model token aggregates.
143
+ */
144
+ async function parseSessionFile(
145
+ filePath: string,
146
+ ): Promise<{ session: SessionUsage; models: Map<string, ModelUsage> } | null> {
147
+ let content: string;
148
+ try {
149
+ content = await readFile(filePath, "utf8");
150
+ } catch {
151
+ return null;
152
+ }
153
+
154
+ const lines = content.trim().split("\n");
155
+ if (lines.length === 0) return null;
156
+
157
+ const sessionTokens = emptyBuckets();
158
+ const models = new Map<string, ModelUsage>();
159
+ let sessionId = "";
160
+ let cwd = "";
161
+ let name: string | undefined;
162
+ let created: Date | undefined;
163
+ let messageCount = 0;
164
+
165
+ for (const line of lines) {
166
+ let entry: SessionEntry;
167
+ try {
168
+ entry = JSON.parse(line) as SessionEntry;
169
+ } catch {
170
+ continue;
171
+ }
172
+
173
+ // Extract session header
174
+ if (entry.type === "session") {
175
+ const header = entry as unknown as {
176
+ id?: string;
177
+ cwd?: string;
178
+ timestamp?: string;
179
+ };
180
+ sessionId = header.id ?? "";
181
+ cwd = header.cwd ?? "";
182
+ if (header.timestamp) {
183
+ created = new Date(header.timestamp);
184
+ }
185
+ continue;
186
+ }
187
+
188
+ // Extract session name
189
+ if (entry.type === "session_info" && entry.name) {
190
+ name = entry.name;
191
+ continue;
192
+ }
193
+
194
+ // Process assistant messages
195
+ if (entry.type === "message" && entry.message?.role === "assistant") {
196
+ const msg = entry.message;
197
+ const usage = msg.usage;
198
+ if (!usage) continue;
199
+
200
+ const buckets: TokenBuckets = {
201
+ input: usage.input ?? 0,
202
+ output: usage.output ?? 0,
203
+ cacheRead: usage.cacheRead ?? 0,
204
+ cacheWrite: usage.cacheWrite ?? 0,
205
+ totalTokens: usage.totalTokens ?? 0,
206
+ costTotal: usage.cost?.total ?? 0,
207
+ };
208
+
209
+ // Accumulate into session totals
210
+ sessionTokens.input += buckets.input;
211
+ sessionTokens.output += buckets.output;
212
+ sessionTokens.cacheRead += buckets.cacheRead;
213
+ sessionTokens.cacheWrite += buckets.cacheWrite;
214
+ sessionTokens.totalTokens += buckets.totalTokens;
215
+ sessionTokens.costTotal += buckets.costTotal;
216
+ messageCount++;
217
+
218
+ // Accumulate per-model
219
+ const provider = msg.provider ?? "unknown";
220
+ const model = msg.model ?? "unknown";
221
+ const modelKey = `${provider}/${model}`;
222
+ const existing = models.get(modelKey);
223
+ if (existing) {
224
+ existing.tokens = addBuckets(existing.tokens, buckets);
225
+ existing.messageCount++;
226
+ } else {
227
+ models.set(modelKey, {
228
+ provider,
229
+ model,
230
+ tokens: { ...buckets },
231
+ messageCount: 1,
232
+ });
233
+ }
234
+ }
235
+ }
236
+
237
+ if (messageCount === 0) return null;
238
+
239
+ // Get file creation time as fallback
240
+ let fileCreated = created ?? new Date(0);
241
+ if (!created) {
242
+ try {
243
+ const fileStat = await stat(filePath);
244
+ fileCreated = fileStat.birthtime;
245
+ } catch {
246
+ // keep default
247
+ }
248
+ }
249
+
250
+ return {
251
+ session: {
252
+ sessionId,
253
+ sessionPath: filePath,
254
+ cwd,
255
+ name,
256
+ created: fileCreated,
257
+ tokens: sessionTokens,
258
+ messageCount,
259
+ },
260
+ models,
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Aggregate token usage across all sessions.
266
+ * Reads JSONL files directly for performance (avoids building session tree structures).
267
+ */
268
+ export async function aggregateAllSessions(options?: {
269
+ cwd?: string;
270
+ since?: Date;
271
+ until?: Date;
272
+ limit?: number;
273
+ }): Promise<AggregateResult> {
274
+ const files = await discoverSessionFiles();
275
+
276
+ const totals = emptyBuckets();
277
+ const byModelMap = new Map<string, ModelUsage>();
278
+ const byProviderMap = new Map<
279
+ string,
280
+ { tokens: TokenBuckets; messageCount: number }
281
+ >();
282
+ const sessions: SessionUsage[] = [];
283
+ let totalMessages = 0;
284
+
285
+ // Parse files in parallel (bounded concurrency)
286
+ const CONCURRENCY = 16;
287
+ for (let i = 0; i < files.length; i += CONCURRENCY) {
288
+ const batch = files.slice(i, i + CONCURRENCY);
289
+ const results = await Promise.all(batch.map((f) => parseSessionFile(f)));
290
+
291
+ for (const result of results) {
292
+ if (!result) continue;
293
+ const { session, models } = result;
294
+
295
+ // Filter by cwd if specified
296
+ if (options?.cwd && session.cwd !== options.cwd) continue;
297
+
298
+ // Filter by time range
299
+ if (options?.since && session.created < options.since) continue;
300
+ if (options?.until && session.created > options.until) continue;
301
+
302
+ sessions.push(session);
303
+
304
+ // Accumulate totals
305
+ totals.input += session.tokens.input;
306
+ totals.output += session.tokens.output;
307
+ totals.cacheRead += session.tokens.cacheRead;
308
+ totals.cacheWrite += session.tokens.cacheWrite;
309
+ totals.totalTokens += session.tokens.totalTokens;
310
+ totals.costTotal += session.tokens.costTotal;
311
+ totalMessages += session.messageCount;
312
+
313
+ // Accumulate per-model
314
+ for (const [key, modelUsage] of models) {
315
+ const existing = byModelMap.get(key);
316
+ if (existing) {
317
+ existing.tokens = addBuckets(existing.tokens, modelUsage.tokens);
318
+ existing.messageCount += modelUsage.messageCount;
319
+ } else {
320
+ byModelMap.set(key, { ...modelUsage });
321
+ }
322
+
323
+ // Accumulate per-provider
324
+ const providerEntry = byProviderMap.get(modelUsage.provider);
325
+ if (providerEntry) {
326
+ providerEntry.tokens = addBuckets(
327
+ providerEntry.tokens,
328
+ modelUsage.tokens,
329
+ );
330
+ providerEntry.messageCount += modelUsage.messageCount;
331
+ } else {
332
+ byProviderMap.set(modelUsage.provider, {
333
+ tokens: { ...modelUsage.tokens },
334
+ messageCount: modelUsage.messageCount,
335
+ });
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ // Sort sessions by cost descending
342
+ sessions.sort((a, b) => b.tokens.costTotal - a.tokens.costTotal);
343
+
344
+ // Sort models by cost descending
345
+ const byModel = Array.from(byModelMap.values()).sort(
346
+ (a, b) => b.tokens.costTotal - a.tokens.costTotal,
347
+ );
348
+
349
+ // Sort providers by cost descending
350
+ const byProvider = Array.from(byProviderMap.entries())
351
+ .map(([provider, data]) => ({ provider, ...data }))
352
+ .sort((a, b) => b.tokens.costTotal - a.tokens.costTotal);
353
+
354
+ // Apply limit
355
+ const limit = options?.limit;
356
+ const limitedSessions = limit ? sessions.slice(0, limit) : sessions;
357
+
358
+ return {
359
+ totals,
360
+ byModel,
361
+ byProvider,
362
+ bySession: limitedSessions,
363
+ sessionCount: sessions.length,
364
+ messageCount: totalMessages,
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Format a number with commas for readability.
370
+ */
371
+ export function formatNumber(n: number): string {
372
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
373
+ if (n >= 10_000) return `${(n / 1_000).toFixed(1)}K`;
374
+ if (n >= 1_000) return n.toLocaleString("en-US");
375
+ return String(n);
376
+ }
377
+
378
+ /**
379
+ * Format cost as USD string.
380
+ */
381
+ export function formatCost(cost: number): string {
382
+ if (cost === 0) return "$0.00";
383
+ if (cost < 0.01) return "<$0.01";
384
+ if (cost >= 1000) return `$${(cost / 1000).toFixed(1)}K`;
385
+ return `$${cost.toFixed(2)}`;
386
+ }
387
+
388
+ /**
389
+ * Format token buckets as a compact summary line.
390
+ */
391
+ export function formatTokenSummary(tokens: TokenBuckets): string {
392
+ const parts: string[] = [];
393
+ if (tokens.input > 0) parts.push(`${formatNumber(tokens.input)} in`);
394
+ if (tokens.output > 0) parts.push(`${formatNumber(tokens.output)} out`);
395
+ if (tokens.cacheRead > 0)
396
+ parts.push(`${formatNumber(tokens.cacheRead)} cached`);
397
+ if (tokens.costTotal > 0) parts.push(formatCost(tokens.costTotal));
398
+ return parts.join(" · ");
399
+ }
@@ -24,6 +24,19 @@ describe("fetchAnthropicQuotasWithToken", () => {
24
24
  });
25
25
  });
26
26
 
27
+ it("skips the OAuth usage call for a direct API key and returns not_applicable", async () => {
28
+ const fetchSpy = vi.fn();
29
+ globalThis.fetch = fetchSpy as any;
30
+
31
+ const result = await fetchAnthropicQuotasWithToken("sk-ant-api03-direct-key");
32
+
33
+ expect(result).toMatchObject({
34
+ success: false,
35
+ error: { kind: "not_applicable" },
36
+ });
37
+ expect(fetchSpy).not.toHaveBeenCalled();
38
+ });
39
+
27
40
  it("fetches and parses quota windows", async () => {
28
41
  globalThis.fetch = vi.fn().mockResolvedValue(
29
42
  new Response(
@@ -234,4 +247,22 @@ describe("fetchOpenRouterQuotasWithToken", () => {
234
247
  error: { kind: "http" },
235
248
  });
236
249
  });
250
+
251
+ it("extracts a clean message from a JSON error body instead of raw JSON", async () => {
252
+ globalThis.fetch = vi.fn().mockResolvedValue(
253
+ new Response(
254
+ JSON.stringify({
255
+ error: { type: "authentication_error", message: "invalid x-api-key" },
256
+ }),
257
+ { status: 401 },
258
+ ),
259
+ ) as any;
260
+
261
+ const result = await fetchOpenRouterQuotasWithToken("bad-key");
262
+ expect(result).toMatchObject({ success: false, error: { kind: "http" } });
263
+ if (!result.success) {
264
+ expect(result.error.message).toBe("invalid x-api-key");
265
+ expect(result.error.message).not.toContain("{");
266
+ }
267
+ });
237
268
  });