@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.
- package/README.md +9 -2
- package/package.json +25 -5
- package/src/config.ts +43 -13
- package/src/extensions/command-quotas/command.ts +23 -1
- package/src/extensions/command-quotas/components/quotas-display.test.ts +29 -0
- package/src/extensions/command-quotas/components/quotas-display.ts +8 -2
- package/src/extensions/command-quotas/provider-commands.test.ts +9 -0
- package/src/extensions/command-quotas/provider-commands.ts +12 -0
- package/src/extensions/command-tokens/command.ts +102 -0
- package/src/extensions/command-tokens/index.ts +1 -0
- package/src/extensions/command-tokens/tokens-display.ts +357 -0
- package/src/extensions/quota-warnings/index.ts +36 -14
- package/src/extensions/token-status/index.ts +241 -0
- package/src/extensions/token-status/provider-detection.test.ts +30 -0
- package/src/extensions/usage-status/index.test.ts +182 -0
- package/src/extensions/usage-status/index.ts +104 -40
- package/src/lib/quotas.ts +12 -1
- package/src/lib/session-tokens.test.ts +137 -0
- package/src/lib/session-tokens.ts +399 -0
- package/src/providers/fetch.test.ts +31 -0
- package/src/providers/fetch.ts +167 -22
- package/src/providers/opencode-go-config.ts +127 -0
- package/src/providers/opencode-go.ts +183 -0
- package/src/providers/parse.test.ts +185 -7
- package/src/providers/providers.ts +229 -21
- package/src/types/quotas.ts +12 -3
- package/src/utils/quotas-severity.ts +14 -2
|
@@ -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
|
});
|