@bacnh85/pi-sub 0.1.31 → 0.1.33

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,17 @@
1
+ ## 0.1.32 (2026-08-22)
2
+
3
+ - **DeepSeek via OmniRoute now shows the real USD balance** (e.g. `M:$18.25`)
4
+ instead of the misleading aggregate windows (R:100%/5H W:0%/2D). Credit-based
5
+ upstreams report "Unavailable" session/weekly in the usage text; pi-sub now
6
+ recognizes that shape and fetches `credits_usd` from the management usage
7
+ API — grant the router key the `manage` scope in the OmniRoute dashboard
8
+ (done), or set `ROUTER_MGMT_TOKEN`. Without it, the footer degrades to the
9
+ clean endpoint display (no aggregate leak).
10
+ - The plain-retry fallback (drop `?provider=`) now fires only on "No cached
11
+ usage data" (wrong/unknown slug), not on "Unavailable" windows.
12
+ - pi-sub now reads `.env.local`/`.env` (cwd then `~/.pi/agent`) for its own
13
+ env vars — same discovery chain as pi-munin, stdlib parser, no deps.
14
+
1
15
  ## 0.1.31 (2026-08-22)
2
16
 
3
17
  - **Fixed glm-cn quota via OmniRoute**: OmniRoute's connection slug for the
@@ -52,6 +66,12 @@
52
66
 
53
67
  # Changelog
54
68
 
69
+ ## 0.1.33 (2026-08-29)
70
+
71
+ ### Added
72
+
73
+ - `/sub` argument completion offers `refresh`.
74
+
55
75
  ## 0.1.29 (2026-08-15)
56
76
 
57
77
  ### Features
@@ -4,6 +4,28 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
 
7
+ /** Pi config dirs + .env.local/.env discovery (pi-munin convention, stdlib parse). */
8
+ function loadEnvFiles(): void {
9
+ const dirs = process.env.PI_CODING_AGENT_DIR
10
+ ? [process.env.PI_CODING_AGENT_DIR]
11
+ : [path.join(os.homedir(), ".pi", "agent"), path.join(os.homedir(), ".pi", "agents")];
12
+ const candidates = [path.resolve(process.cwd(), ".env.local"), path.resolve(process.cwd(), ".env")]
13
+ .concat(dirs.flatMap((d) => [path.join(d, ".env.local"), path.join(d, ".env")]));
14
+ for (const file of candidates) {
15
+ try {
16
+ const text = fs.readFileSync(file, "utf8");
17
+ for (const line of text.split(/\r?\n/)) {
18
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
19
+ if (!m) continue;
20
+ let v = m[2].trim();
21
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
22
+ if (process.env[m[1]] === undefined) process.env[m[1]] = v;
23
+ }
24
+ } catch { /* optional file */ }
25
+ }
26
+ }
27
+ loadEnvFiles();
28
+
7
29
  const STATUS_KEY = "pi-sub";
8
30
  const MESSAGE_TYPE = "pi-sub-status";
9
31
  const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
@@ -340,6 +362,15 @@ function routerOrigin(baseUrl: string): string {
340
362
  return baseUrl.replace(/\/v1\/?$/, "");
341
363
  }
342
364
 
365
+ /** OmniRoute management credential (manage-scope key or oma_ CLI token) from
366
+ * env — optional override. The router key itself works when it holds the
367
+ * `manage` scope (API Keys dashboard), which unlocks
368
+ * /api/usage/<connectionId> carrying the raw USD balance for credit-based
369
+ * upstreams (deepseek) that the key-authable endpoints normalize away. */
370
+ function readRouterMgmtToken(apiKey: string | undefined): string | undefined {
371
+ return process.env.ROUTER_MGMT_TOKEN || process.env.OMNIROUTE_MGMT_TOKEN || apiKey;
372
+ }
373
+
343
374
  /** Parse OmniRoute's `/api/usage/om-usage` plain-text report into windows.
344
375
  * Sections: "Personal quota" (per-key USD budgets: Daily/Weekly) and
345
376
  * "Provider quota" (connection session/weekly). Lines: `<Label>`,
@@ -420,7 +451,7 @@ if (process.env.PI_SUB_SELF_CHECK === "1") {
420
451
  assert(p.personalWeekly?.remaining === 90, "personal weekly 90");
421
452
  assert(p.session?.remaining === 47, "session 47");
422
453
  assert(p.providerWeekly?.remaining === 28, "provider weekly 28");
423
- assert(p.personalDaily?.resetLabel?.includes("15h"), "daily reset label");
454
+ assert(p.personalDaily?.resetLabel?.includes("15h") === true, "daily reset label");
424
455
  const disabled = parseOmniUsageText("Usage command is disabled for this API key.");
425
456
  assert(Object.keys(disabled).length === 0, "disabled text parses empty");
426
457
  // Live-verified: provider without cached data → no windows (endpoint fallback).
@@ -432,7 +463,7 @@ if (process.env.PI_SUB_SELF_CHECK === "1") {
432
463
  );
433
464
  assert(live.session?.remaining === 90, "session 90");
434
465
  assert(live.providerWeekly?.remaining === 0, "weekly 0");
435
- assert(live.session?.resetLabel?.includes("1h 59m"), "session reset");
466
+ assert(live.session?.resetLabel?.includes("1h 59m") === true, "session reset");
436
467
  // Live-verified 2026-08-22: glm-cn scoped quota — Session 99%, Weekly
437
468
  // "Unavailable" (skipped, so W stays absent like the direct Z.ai footer).
438
469
  const glmCn = parseOmniUsageText(
@@ -553,9 +584,11 @@ async function fetchRouterUsage(signal?: AbortSignal, provider?: string): Promis
553
584
  let text = await response.text();
554
585
  if (text && !text.includes("disabled")) {
555
586
  let w = parseOmniUsageText(text);
556
- // Provider-scoped call but no cached quota for that upstream — retry
557
- // without ?provider= so the report shows the best/all snapshot.
558
- if (provider && !w.session && !w.providerWeekly) {
587
+ // "No cached usage data" = unknown/wrong slug — retry without
588
+ // ?provider= for the best/all snapshot. "Unavailable" windows mean a
589
+ // known credit-based upstream (deepseek) keep them empty so the
590
+ // USD-balance path below takes over instead of showing the aggregate.
591
+ if (provider && !w.session && !w.providerWeekly && text.includes("No cached usage data")) {
559
592
  const plain = await fetch(url.replace(/\?provider=.*$/, ""), {
560
593
  headers: { Accept: "text/plain", Authorization: `Bearer ${apiKey}` },
561
594
  signal: combined,
@@ -568,6 +601,23 @@ async function fetchRouterUsage(signal?: AbortSignal, provider?: string): Promis
568
601
  }
569
602
  }
570
603
  }
604
+ // Credit-based upstreams (deepseek): the usage text prints "Unavailable"
605
+ // windows — pull the real USD balance from the management API instead.
606
+ const credits = await fetchRouterCredits(provider, w);
607
+ if (credits) {
608
+ const account: SubscriptionAccountSnapshot = {
609
+ ...baseAccount,
610
+ plan: `Router · ${provider}`,
611
+ monthlyCredits: credits.balanceUsd,
612
+ usageBreakdown: credits.breakdown,
613
+ };
614
+ return {
615
+ providerDisplayName: "Router",
616
+ accounts: [account],
617
+ activeAccount: account,
618
+ fetchedAt: Date.now(),
619
+ };
620
+ }
571
621
  // personalDaily = per-key budget (nearest reset → R slot),
572
622
  // provider weekly/session = upstream quota (W slot). Fall back sensibly.
573
623
  const account: SubscriptionAccountSnapshot = {
@@ -612,6 +662,56 @@ async function fetchRouterUsage(signal?: AbortSignal, provider?: string): Promis
612
662
  };
613
663
  }
614
664
 
665
+ interface RouterCredits {
666
+ balanceUsd: number;
667
+ breakdown: string;
668
+ }
669
+
670
+ /** Fetch the raw USD balance for credit-based upstreams (deepseek: `credits_usd`)
671
+ * via OmniRoute's management usage API. Only called when the key-authable
672
+ * om-usage text reports no usable windows — that surface normalizes credits
673
+ * to meaningless percentages. Needs the router key to hold the `manage`
674
+ * scope (or ROUTER_MGMT_TOKEN as override). Connection discovery comes from
675
+ * /api/v1/me/status (key-authable); the balance from /api/usage/<id>. */
676
+ async function fetchRouterCredits(provider: string | undefined, w: ReturnType<typeof parseOmniUsageText>): Promise<RouterCredits | undefined> {
677
+ if (!provider || w.session || w.providerWeekly) return undefined;
678
+ const cfg = readRouterConfig();
679
+ const apiKey = readRouterApiKey();
680
+ const mgmtToken = readRouterMgmtToken(apiKey);
681
+ if (!cfg || !apiKey || !mgmtToken) return undefined;
682
+ const origin = routerOrigin(cfg.baseUrl);
683
+ try {
684
+ const combined = AbortSignal.timeout(7_000);
685
+ // 1. Connection id for this upstream via the key-authable status endpoint.
686
+ const statusRes = await fetch(`${origin}/api/v1/me/status`, {
687
+ headers: { Authorization: `Bearer ${apiKey}` },
688
+ signal: combined,
689
+ });
690
+ if (!statusRes.ok) return undefined;
691
+ const status = (await statusRes.json()) as { accountQuotas?: Array<{ provider?: string; connectionId?: string }> };
692
+ const connectionId = status.accountQuotas?.find((q) => q.provider === provider)?.connectionId;
693
+ if (!connectionId) return undefined;
694
+ // 2. Raw usage (management token) — quotas.credits_usd.remaining is the USD balance.
695
+ const usageRes = await fetch(`${origin}/api/usage/${connectionId}`, {
696
+ headers: { Authorization: `Bearer ${mgmtToken}` },
697
+ signal: combined,
698
+ });
699
+ if (!usageRes.ok) return undefined;
700
+ const usage = (await usageRes.json()) as { quotas?: Record<string, { remaining?: number }> };
701
+ const credits = usage.quotas?.credits_usd ?? usage.quotas?.credits;
702
+ const remaining = credits?.remaining;
703
+ if (typeof remaining !== "number" || !Number.isFinite(remaining)) return undefined;
704
+ const cny = usage.quotas?.credits_cny?.remaining;
705
+ return {
706
+ balanceUsd: remaining,
707
+ breakdown: `🪙 Balance (USD) $${remaining.toFixed(2)}` +
708
+ (typeof cny === "number" ? ` · ¥${cny.toFixed(2)} CNY` : ""),
709
+ };
710
+ } catch {
711
+ return undefined; // no mgmt token / upstream down — fall back to endpoint display
712
+ }
713
+ }
714
+
615
715
  // Command Code's /alpha/billing/credits endpoint (auth: same Provider API key
616
716
  // as /provider/v1 models) returns live 5-hour and weekly rolling windows plus
617
717
  // the monthly credit balance — the same data as the cmd /usage CLI.
@@ -1195,6 +1295,12 @@ export default function (pi: ExtensionAPI) {
1195
1295
 
1196
1296
  pi.registerCommand("sub", {
1197
1297
  description: "Show subscription usage for the current supported model provider (use /sub refresh to force refresh).",
1298
+ getArgumentCompletions: (prefix) => {
1299
+ const items = ["refresh"]
1300
+ .filter((k) => k.startsWith(String(prefix || "").trim().toLowerCase()))
1301
+ .map((k) => ({ value: k, label: k, description: "force refresh" }));
1302
+ return items.length > 0 ? items : null;
1303
+ },
1198
1304
  handler: async (args, ctx) => {
1199
1305
  updateActiveAdapter(ctx, state, ctx.model);
1200
1306
  const command = args.trim().toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",