@cad0p/pi-usage 0.11.4-20261103.0 → 0.11.4
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 +2 -2
- package/bin/piquota.js +0 -1
- package/package.json +1 -1
- package/src/engine.js +23 -81
- package/src/http.js +1 -34
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ One source of truth for quota: **the credentials Pi already owns**.
|
|
|
6
6
|
> `@cad0p/pi-usage` is a fork of [J3pf/piQuota](https://github.com/J3pf/piQuota) (`@j3pf/piquota`). It adds:
|
|
7
7
|
>
|
|
8
8
|
> * **OpenCode Go from the API key**: `GET https://opencode.ai/zen/go/v1/usage` returns the rolling (5h), weekly and monthly windows for the key Pi already stores, so no browser session is needed.
|
|
9
|
-
> * **Numbered accounts**: `auth.json` entries such as `opencode-go-2` … `opencode-go-14` (the [pi-fallback-provider](https://github.com/cad0p/pi-fallback-provider) sibling convention) are read as extra accounts of their base provider.
|
|
9
|
+
> * **Numbered accounts**: `auth.json` entries such as `opencode-go-2` … `opencode-go-14` (the [pi-fallback-provider](https://github.com/cad0p/pi-fallback-provider) sibling convention) are read as extra accounts of their base provider.
|
|
10
10
|
> * **One Omarchy tab per provider**: a multi-account provider shows each account's binding window (`#2 · Monthly`) in a single tab, and summarises failed accounts.
|
|
11
11
|
> * **Tokens by day and by model**: the tokens Pi's own session files recorded are folded per provider and shown in `/usage`, in `--json` (`localStats`) and in the Omarchy records, at a bounded scan cost.
|
|
12
12
|
> * **`/usage` on demand**: the Pi command is `/usage` only, and nothing is pinned above the editor or in the footer unless you ask for it. A multi-account provider gets one line per account in the panel.
|
|
@@ -755,7 +755,7 @@ extensions/quota-panel.ts Pi TUI line and /usage
|
|
|
755
755
|
## Tests
|
|
756
756
|
|
|
757
757
|
```bash
|
|
758
|
-
node --test tests/*.test.mjs #
|
|
758
|
+
node --test tests/*.test.mjs # 354 tests, fake tokens only, no network
|
|
759
759
|
```
|
|
760
760
|
|
|
761
761
|
Modules covered: `auth.json` parsing and de-duplication, the Claude Code store
|
package/bin/piquota.js
CHANGED
|
@@ -96,7 +96,6 @@ Omarchy:
|
|
|
96
96
|
Environment:
|
|
97
97
|
PI_QUOTA_LOCAL_STATS=0 skip the token scan of Pi's session files
|
|
98
98
|
PI_QUOTA_LOCAL_STATS_BUDGET_MS wall-clock budget for that scan (default 12000)
|
|
99
|
-
PI_QUOTA_ACCOUNT_CONCURRENCY accounts of one provider queried at once (1-32, default 8)
|
|
100
99
|
|
|
101
100
|
Guarantees:
|
|
102
101
|
* ~/.pi/agent/auth.json is opened read-only. Never written, synced or refreshed.
|
package/package.json
CHANGED
package/src/engine.js
CHANGED
|
@@ -149,47 +149,6 @@ export function resolveClaudeCredentials(input) {
|
|
|
149
149
|
};
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
/** Default number of accounts of one family queried at the same time. */
|
|
153
|
-
export const DEFAULT_ACCOUNT_CONCURRENCY = 8;
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* `PI_QUOTA_ACCOUNT_CONCURRENCY` (1-32) bounds the parallel account requests of
|
|
157
|
-
* one family; 1 restores the sequential behaviour.
|
|
158
|
-
*
|
|
159
|
-
* @param {Record<string, string | undefined>} env
|
|
160
|
-
* @returns {number}
|
|
161
|
-
*/
|
|
162
|
-
export function accountConcurrency(env) {
|
|
163
|
-
const value = Number.parseInt(env.PI_QUOTA_ACCOUNT_CONCURRENCY ?? "", 10);
|
|
164
|
-
if (!Number.isFinite(value) || value < 1) return DEFAULT_ACCOUNT_CONCURRENCY;
|
|
165
|
-
return Math.min(32, value);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Map with at most `limit` calls in flight, results in input order.
|
|
170
|
-
*
|
|
171
|
-
* @template T, R
|
|
172
|
-
* @param {T[]} items
|
|
173
|
-
* @param {number} limit
|
|
174
|
-
* @param {(item: T, index: number) => Promise<R>} fn
|
|
175
|
-
* @returns {Promise<R[]>}
|
|
176
|
-
*/
|
|
177
|
-
export async function mapBounded(items, limit, fn) {
|
|
178
|
-
/** @type {R[]} */
|
|
179
|
-
const results = new Array(items.length);
|
|
180
|
-
let next = 0;
|
|
181
|
-
const worker = async () => {
|
|
182
|
-
while (next < items.length) {
|
|
183
|
-
const index = next++;
|
|
184
|
-
results[index] = await fn(items[index], index);
|
|
185
|
-
}
|
|
186
|
-
};
|
|
187
|
-
// A non-numeric limit would otherwise start no worker and return holes.
|
|
188
|
-
const bounded = Number.isFinite(limit) ? Math.max(1, Math.floor(limit)) : 1;
|
|
189
|
-
await Promise.all(Array.from({ length: Math.min(bounded, items.length) }, worker));
|
|
190
|
-
return results;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
152
|
/** @type {Record<string, (credential: any, options?: any) => Promise<import("./model.js").QuotaResult>>} */
|
|
194
153
|
const PROVIDERS = {
|
|
195
154
|
claude: fetchClaude,
|
|
@@ -355,18 +314,17 @@ export async function collectQuota(options = {}) {
|
|
|
355
314
|
return;
|
|
356
315
|
}
|
|
357
316
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
// order afterwards, so the final backoff state is exactly what the
|
|
363
|
-
// sequential loop produced.
|
|
364
|
-
const fetchQuota = PROVIDERS[family];
|
|
365
|
-
const outcomes = await mapBounded(credentials, accountConcurrency(env), async (credential) => {
|
|
317
|
+
/** @type {import("./model.js").QuotaResult[]} */
|
|
318
|
+
const results = [];
|
|
319
|
+
const familyWarnList = [];
|
|
320
|
+
for (const credential of credentials) {
|
|
366
321
|
const freshness = checkFreshness(credential, now);
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
322
|
+
if (!freshness.fresh) {
|
|
323
|
+
familyWarnList.push(
|
|
324
|
+
`${family}: the ${expiredTokenName(credential)} expired ${Math.abs(freshness.expiresInMin ?? 0)}m ago; ${refreshHint(credential)}`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
const fetchQuota = PROVIDERS[family];
|
|
370
328
|
try {
|
|
371
329
|
const result = await fetchQuota(credential, {
|
|
372
330
|
now,
|
|
@@ -383,7 +341,18 @@ export async function collectQuota(options = {}) {
|
|
|
383
341
|
// The renderers group siblings by family; the record builder needs the
|
|
384
342
|
// slot to label each account without re-parsing its display string.
|
|
385
343
|
result.slot = credential.slot ?? null;
|
|
386
|
-
|
|
344
|
+
if (isThrottled(result.error)) {
|
|
345
|
+
const seconds = recordBackoff(family, {
|
|
346
|
+
retryAfterSec: retryAfterFromError(result.error),
|
|
347
|
+
now,
|
|
348
|
+
env,
|
|
349
|
+
home: options.home,
|
|
350
|
+
}).seconds;
|
|
351
|
+
familyWarnList.push(`${family}: throttled upstream; pausing that family for ${seconds}s`);
|
|
352
|
+
} else if (result.ok || isAuthFailure(result.error)) {
|
|
353
|
+
clearBackoff(family, { env, home: options.home });
|
|
354
|
+
}
|
|
355
|
+
results.push(result);
|
|
387
356
|
} catch (error) {
|
|
388
357
|
// A provider must never take the whole report down.
|
|
389
358
|
const crashed = degradedResult({
|
|
@@ -396,35 +365,8 @@ export async function collectQuota(options = {}) {
|
|
|
396
365
|
expiresInMin: freshness.expiresInMin,
|
|
397
366
|
});
|
|
398
367
|
crashed.slot = credential.slot ?? null;
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
/** @type {import("./model.js").QuotaResult[]} */
|
|
404
|
-
const results = [];
|
|
405
|
-
const familyWarnList = [];
|
|
406
|
-
for (const { result, warning, crashed } of outcomes) {
|
|
407
|
-
if (warning) familyWarnList.push(warning);
|
|
408
|
-
if (!crashed) {
|
|
409
|
-
// Bookkeeping must degrade like the provider call it follows: a
|
|
410
|
-
// failing backoff write is a warning, never a rejected family.
|
|
411
|
-
try {
|
|
412
|
-
if (isThrottled(result.error)) {
|
|
413
|
-
const seconds = recordBackoff(family, {
|
|
414
|
-
retryAfterSec: retryAfterFromError(result.error),
|
|
415
|
-
now,
|
|
416
|
-
env,
|
|
417
|
-
home: options.home,
|
|
418
|
-
}).seconds;
|
|
419
|
-
familyWarnList.push(`${family}: throttled upstream; pausing that family for ${seconds}s`);
|
|
420
|
-
} else if (result.ok || isAuthFailure(result.error)) {
|
|
421
|
-
clearBackoff(family, { env, home: options.home });
|
|
422
|
-
}
|
|
423
|
-
} catch (error) {
|
|
424
|
-
familyWarnList.push(`${family}: could not update the backoff state: ${/** @type {{ message?: string }} */ (error)?.message ?? String(error)}`);
|
|
425
|
-
}
|
|
368
|
+
results.push(crashed);
|
|
426
369
|
}
|
|
427
|
-
results.push(result);
|
|
428
370
|
}
|
|
429
371
|
byFamily[family] = results;
|
|
430
372
|
familyWarnings[family] = familyWarnList;
|
package/src/http.js
CHANGED
|
@@ -5,41 +5,8 @@
|
|
|
5
5
|
* redaction of secrets and error normalization stay in one place.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFileSync } from "node:fs";
|
|
9
|
-
|
|
10
8
|
const DEFAULT_TIMEOUT_MS = 15000;
|
|
11
9
|
|
|
12
|
-
/**
|
|
13
|
-
* The package's own User-Agent, sent on every request that does not set one.
|
|
14
|
-
*
|
|
15
|
-
* Without it Node's fetch announces itself as `node`, a generic HTTP-library
|
|
16
|
-
* name. OpenCode Go's terms ask clients for their own User-Agent and have warned
|
|
17
|
-
* and banned accounts over generic ones, and a usage poller that runs every few
|
|
18
|
-
* minutes against every key of a user is exactly the traffic that gets looked
|
|
19
|
-
* at. Providers that need a specific client string (Antigravity's CLI agent,
|
|
20
|
-
* the dashboard's browser agent) keep setting their own.
|
|
21
|
-
*/
|
|
22
|
-
export const PACKAGE_USER_AGENT = (() => {
|
|
23
|
-
try {
|
|
24
|
-
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
25
|
-
return `pi-usage/${pkg.version} (+https://github.com/cad0p/pi-usage)`;
|
|
26
|
-
} catch {
|
|
27
|
-
return "pi-usage (+https://github.com/cad0p/pi-usage)";
|
|
28
|
-
}
|
|
29
|
-
})();
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Copy `headers` and add `User-Agent` unless one is already set (any case).
|
|
33
|
-
*
|
|
34
|
-
* @param {Record<string, string> | undefined} headers
|
|
35
|
-
* @returns {Record<string, string>}
|
|
36
|
-
*/
|
|
37
|
-
export function withUserAgent(headers) {
|
|
38
|
-
const out = { ...(headers ?? {}) };
|
|
39
|
-
if (!Object.keys(out).some((name) => name.toLowerCase() === "user-agent")) out["User-Agent"] = PACKAGE_USER_AGENT;
|
|
40
|
-
return out;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
10
|
/**
|
|
44
11
|
* Redact credential-looking substrings from arbitrary text before it can reach
|
|
45
12
|
* a log, a terminal or an error message.
|
|
@@ -129,7 +96,7 @@ export async function requestJson(url, options = {}) {
|
|
|
129
96
|
try {
|
|
130
97
|
const response = await fetchFn(url, {
|
|
131
98
|
method: options.method ?? "GET",
|
|
132
|
-
headers:
|
|
99
|
+
headers: options.headers,
|
|
133
100
|
body: options.body,
|
|
134
101
|
signal: controller.signal,
|
|
135
102
|
});
|