@bacnh85/pi-sub 0.1.26 → 0.1.27

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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 0.1.27 (2026-08-22)
2
+
3
+ - Router adapter now fetches real usage from OmniRoute instances: GET
4
+ `<origin>/api/usage/om-usage` (Bearer = router API key from auth.json or
5
+ ROUTER_API_KEY env). The plain-text report (Personal quota Daily/Weekly +
6
+ Provider quota Session/Weekly) is parsed into the footer R:/W: windows.
7
+ Non-OmniRoute routers 404 → fall back to the endpoint-only display.
8
+ When the per-key usage command is disabled, the footer shows a hint to
9
+ enable it in the OmniRoute dashboard (API Keys → the key).
10
+ - New pure parser `parseOmniUsageText` with an env-gated self-check
11
+ (PI_SUB_SELF_CHECK=1) — pi-sub stays pack-only for CI.
12
+
1
13
  ## 0.1.26 (2026-08-21)
2
14
 
3
15
  - Router adapter: reads `router.baseUrl` from `~/.pi/agent/settings.json`
package/README.md CHANGED
@@ -53,7 +53,22 @@ The built-in `zai-coding-cn` provider targets the domestic BigModel endpoint (`o
53
53
 
54
54
  ### Router (pi-router — formerly 9router)
55
55
 
56
- The router (9router, omniroute, …) is a proxy with no upstream usage API, so the footer shows the configured endpoint and last response speed. The endpoint URL is read from `~/.pi/agent/settings.json` (`router.baseUrl`) with `ROUTER_BASE_URL` env override:
56
+ For **OmniRoute** instances the footer shows real usage: `GET <origin>/api/usage/om-usage`
57
+ with the router API key (auth.json `router` credential from `/login router`, or
58
+ `ROUTER_API_KEY` env) returns the per-key report — Personal quota (daily/weekly
59
+ USD budgets) and Provider quota (session/weekly connection windows) — rendered
60
+ as `R:`/`W:` remaining-percent windows:
61
+
62
+ ```text
63
+ Router usage R:80% W:28% 145 tok/s
64
+ ```
65
+
66
+ Requirements:
67
+ - The router instance must be OmniRoute (other routers 404 → endpoint-only fallback).
68
+ - The API key must have the **usage command enabled** in the OmniRoute dashboard
69
+ (API Keys → the key → enable "usage command"); the footer shows a hint when it's off.
70
+
71
+ The endpoint URL is read from `~/.pi/agent/settings.json` (`router.baseUrl`), env `ROUTER_BASE_URL` overrides:
57
72
 
58
73
  ```text
59
74
  Router (172.30.55.22:20128) 145 tok/s
@@ -327,6 +327,79 @@ function readRouterConfig(): { baseUrl: string } | null {
327
327
  }
328
328
  }
329
329
 
330
+ /** Router API key: auth.json `router` credential (via /login), then env. */
331
+ function readRouterApiKey(): string | undefined {
332
+ const stored = readStoredCredential(ROUTER_PROVIDER, piAuthPath()) as PiAuthEntry | undefined;
333
+ if (stored?.key) return stored.key;
334
+ return process.env.ROUTER_API_KEY || process.env.NINE_ROUTER_API_KEY || undefined;
335
+ }
336
+
337
+ /** Strip a `/v1` suffix so management routes (under the origin) can be
338
+ * derived from the OpenAI-compatible baseUrl. */
339
+ function routerOrigin(baseUrl: string): string {
340
+ return baseUrl.replace(/\/v1\/?$/, "");
341
+ }
342
+
343
+ /** Parse OmniRoute's `/api/usage/om-usage` plain-text report into windows.
344
+ * Sections: "Personal quota" (per-key USD budgets: Daily/Weekly) and
345
+ * "Provider quota" (connection session/weekly). Lines: `<Label>`,
346
+ * `NN% left`, `⏱ reset in <countdown>`. Robust to missing/unknown blocks. */
347
+ export function parseOmniUsageText(text: string): {
348
+ personalDaily?: UsageWindow;
349
+ personalWeekly?: UsageWindow;
350
+ session?: UsageWindow;
351
+ providerWeekly?: UsageWindow;
352
+ } {
353
+ const out: {
354
+ personalDaily?: UsageWindow;
355
+ personalWeekly?: UsageWindow;
356
+ session?: UsageWindow;
357
+ providerWeekly?: UsageWindow;
358
+ } = {};
359
+ const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
360
+ let inPersonal = false;
361
+ for (let i = 0; i < lines.length; i++) {
362
+ const line = lines[i];
363
+ if (line.toLowerCase() === "personal quota") { inPersonal = true; continue; }
364
+ if (line.toLowerCase() === "provider quota") { inPersonal = false; continue; }
365
+ const usedMatch = line.match(/^(\d+)%\s*left$/);
366
+ if (!usedMatch) continue;
367
+ const label = (lines[i - 1] ?? "").toLowerCase();
368
+ const resetMatch = lines[i + 1]?.match(/reset in (.+)$/);
369
+ const remaining = Number(usedMatch[1]);
370
+ if (remaining < 0 || remaining > 100) continue;
371
+ const window: UsageWindow = { remaining };
372
+ if (resetMatch) window.resetLabel = `⏱ ${resetMatch[1].trim()}`;
373
+ if (inPersonal) {
374
+ if (label.includes("daily")) out.personalDaily = window;
375
+ else if (label.includes("weekly")) out.personalWeekly = window;
376
+ } else {
377
+ if (label.includes("session")) out.session = window;
378
+ else if (label.includes("weekly")) out.providerWeekly = window;
379
+ }
380
+ }
381
+ return out;
382
+ }
383
+
384
+ // ponytail: runnable self-check (pi-sub has no test runner — pack gate only)
385
+ if (process.env.PI_SUB_SELF_CHECK === "1") {
386
+ const sample = [
387
+ "Personal quota", "Daily", "80% left", "⏱ reset in 15h 0m", "",
388
+ "Weekly", "90% left", "⏱ reset in 7d 0h 0m", "",
389
+ "Provider quota", "Session", "47% left", "⏱ reset in 9m", "",
390
+ "Weekly", "28% left", "⏱ reset in 1d 0h 0m",
391
+ ].join("\n");
392
+ const p = parseOmniUsageText(sample);
393
+ const assert = (cond: boolean, msg: string) => { if (!cond) throw new Error("pi-sub self-check: " + msg); };
394
+ assert(p.personalDaily?.remaining === 80, "personal daily 80");
395
+ assert(p.personalWeekly?.remaining === 90, "personal weekly 90");
396
+ assert(p.session?.remaining === 47, "session 47");
397
+ assert(p.providerWeekly?.remaining === 28, "provider weekly 28");
398
+ assert(p.personalDaily?.resetLabel?.includes("15h"), "daily reset label");
399
+ const disabled = parseOmniUsageText("Usage command is disabled for this API key.");
400
+ assert(Object.keys(disabled).length === 0, "disabled text parses empty");
401
+ }
402
+
330
403
  async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): Promise<UsageApiSnapshot | undefined> {
331
404
  const accountId = getCodexAccountId(entry) ?? entry.accountId;
332
405
  if (!accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
@@ -398,7 +471,7 @@ async function fetchOpenCodeGoUsage(_signal?: AbortSignal): Promise<Subscription
398
471
  }
399
472
  }
400
473
 
401
- async function fetchRouterUsage(_signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
474
+ async function fetchRouterUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
402
475
  const cfg = readRouterConfig();
403
476
  const now = Date.now();
404
477
  if (!cfg) {
@@ -409,16 +482,67 @@ async function fetchRouterUsage(_signal?: AbortSignal): Promise<SubscriptionUsag
409
482
  error: "router not configured — set router.baseUrl in ~/.pi/agent/settings.json",
410
483
  };
411
484
  }
412
- const account: SubscriptionAccountSnapshot = {
485
+ const apiKey = readRouterApiKey();
486
+ const baseAccount: SubscriptionAccountSnapshot = {
413
487
  id: cfg.baseUrl,
414
488
  isActive: true,
415
489
  accountLabel: cfg.baseUrl.replace(/^https?:\/\//, ""),
416
490
  lastActivity: "Now",
417
491
  };
492
+
493
+ // OmniRoute exposes per-key usage at GET <origin>/api/usage/om-usage
494
+ // (Bearer = the router API key). The report is plain text: Personal quota
495
+ // (Daily/Weekly USB budgets) + Provider quota (Session/Weekly connections).
496
+ // Non-OmniRoute routers 404 here — fall back to endpoint-only display.
497
+ if (apiKey) {
498
+ try {
499
+ const timeoutSignal = AbortSignal.timeout(7_000);
500
+ const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
501
+ const response = await fetch(`${routerOrigin(cfg.baseUrl)}/api/usage/om-usage`, {
502
+ headers: { Accept: "text/plain", Authorization: `Bearer ${apiKey}` },
503
+ signal: combined,
504
+ });
505
+ if (response.ok) {
506
+ const text = await response.text();
507
+ if (text && !text.includes("disabled")) {
508
+ const w = parseOmniUsageText(text);
509
+ // personalDaily = per-key budget (nearest reset → R slot),
510
+ // provider weekly = connection quota (W slot). Fall back sensibly.
511
+ const account: SubscriptionAccountSnapshot = {
512
+ ...baseAccount,
513
+ plan: "Router usage",
514
+ fiveHour: w.personalDaily ?? w.session,
515
+ weekly: w.providerWeekly ?? w.personalWeekly,
516
+ usageBreakdown: text.length > 120 ? text : undefined,
517
+ };
518
+ return {
519
+ providerDisplayName: "Router",
520
+ accounts: [account],
521
+ activeAccount: account,
522
+ fetchedAt: Date.now(),
523
+ };
524
+ }
525
+ // Usage command exists but is disabled for this key — keep the footer
526
+ // clean (endpoint display) and surface the hint in /sub detail only.
527
+ const hintAccount: SubscriptionAccountSnapshot = {
528
+ ...baseAccount,
529
+ usageBreakdown: "OmniRoute usage command is disabled for this API key — " +
530
+ "enable it in the dashboard (API Keys → this key → usage command).",
531
+ };
532
+ return {
533
+ providerDisplayName: "Router",
534
+ accounts: [hintAccount],
535
+ activeAccount: hintAccount,
536
+ fetchedAt: Date.now(),
537
+ };
538
+ }
539
+ } catch { /* non-OmniRoute or transient — fall through to endpoint display */ }
540
+ }
541
+
418
542
  return {
419
543
  providerDisplayName: "Router",
420
- accounts: [account],
421
- activeAccount: account,
544
+ accounts: [baseAccount],
545
+ activeAccount: baseAccount,
422
546
  fetchedAt: now,
423
547
  };
424
548
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",