@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 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. The accounts of one provider are queried in parallel (8 at a time, `PI_QUOTA_ACCOUNT_CONCURRENCY` from 1 to 32), so 14 OpenCode Go keys cost about one round trip instead of fourteen. Every request carries `User-Agent: pi-usage/<version> (+https://github.com/cad0p/pi-usage)` unless a provider needs its own client string: OpenCode Go asks clients not to use a generic HTTP-library name, and Node's default is `node`.
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 # 359 tests, fake tokens only, no network
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cad0p/pi-usage",
3
- "version": "0.11.4-20261103.0",
3
+ "version": "0.11.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
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
- // Accounts of one family are independent requests, so they run in
359
- // parallel (bounded): with 14 OpenCode Go keys a sequential loop cost
360
- // ~3.8 s, a parallel one costs about one round trip. Results keep the
361
- // credential order, and the backoff bookkeeping is replayed in that same
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
- const warning = freshness.fresh
368
- ? null
369
- : `${family}: the ${expiredTokenName(credential)} expired ${Math.abs(freshness.expiresInMin ?? 0)}m ago; ${refreshHint(credential)}`;
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
- return { result, warning, crashed: false };
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
- return { result: crashed, warning, crashed: true };
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: withUserAgent(options.headers),
99
+ headers: options.headers,
133
100
  body: options.body,
134
101
  signal: controller.signal,
135
102
  });