@bacnh85/pi-sub 0.1.31 → 0.1.32

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. Needs `ROUTER_MGMT_TOKEN` (an `oma_` CLI token or manage-scope key) in
8
+ env / `~/.pi/agent/.env.local`; without it, the footer degrades to the clean
9
+ 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
@@ -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,13 @@ function routerOrigin(baseUrl: string): string {
340
362
  return baseUrl.replace(/\/v1\/?$/, "");
341
363
  }
342
364
 
365
+ /** OmniRoute management token (oma_ CLI token or manage-scope key) from env —
366
+ * unlocks /api/usage/<connectionId> which carries the raw USD balance for
367
+ * credit-based upstreams (deepseek) that the key-authable endpoints strip. */
368
+ function readRouterMgmtToken(): string | undefined {
369
+ return process.env.ROUTER_MGMT_TOKEN || process.env.OMNIROUTE_MGMT_TOKEN || undefined;
370
+ }
371
+
343
372
  /** Parse OmniRoute's `/api/usage/om-usage` plain-text report into windows.
344
373
  * Sections: "Personal quota" (per-key USD budgets: Daily/Weekly) and
345
374
  * "Provider quota" (connection session/weekly). Lines: `<Label>`,
@@ -420,7 +449,7 @@ if (process.env.PI_SUB_SELF_CHECK === "1") {
420
449
  assert(p.personalWeekly?.remaining === 90, "personal weekly 90");
421
450
  assert(p.session?.remaining === 47, "session 47");
422
451
  assert(p.providerWeekly?.remaining === 28, "provider weekly 28");
423
- assert(p.personalDaily?.resetLabel?.includes("15h"), "daily reset label");
452
+ assert(p.personalDaily?.resetLabel?.includes("15h") === true, "daily reset label");
424
453
  const disabled = parseOmniUsageText("Usage command is disabled for this API key.");
425
454
  assert(Object.keys(disabled).length === 0, "disabled text parses empty");
426
455
  // Live-verified: provider without cached data → no windows (endpoint fallback).
@@ -432,7 +461,7 @@ if (process.env.PI_SUB_SELF_CHECK === "1") {
432
461
  );
433
462
  assert(live.session?.remaining === 90, "session 90");
434
463
  assert(live.providerWeekly?.remaining === 0, "weekly 0");
435
- assert(live.session?.resetLabel?.includes("1h 59m"), "session reset");
464
+ assert(live.session?.resetLabel?.includes("1h 59m") === true, "session reset");
436
465
  // Live-verified 2026-08-22: glm-cn scoped quota — Session 99%, Weekly
437
466
  // "Unavailable" (skipped, so W stays absent like the direct Z.ai footer).
438
467
  const glmCn = parseOmniUsageText(
@@ -553,9 +582,11 @@ async function fetchRouterUsage(signal?: AbortSignal, provider?: string): Promis
553
582
  let text = await response.text();
554
583
  if (text && !text.includes("disabled")) {
555
584
  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) {
585
+ // "No cached usage data" = unknown/wrong slug — retry without
586
+ // ?provider= for the best/all snapshot. "Unavailable" windows mean a
587
+ // known credit-based upstream (deepseek) keep them empty so the
588
+ // USD-balance path below takes over instead of showing the aggregate.
589
+ if (provider && !w.session && !w.providerWeekly && text.includes("No cached usage data")) {
559
590
  const plain = await fetch(url.replace(/\?provider=.*$/, ""), {
560
591
  headers: { Accept: "text/plain", Authorization: `Bearer ${apiKey}` },
561
592
  signal: combined,
@@ -568,6 +599,23 @@ async function fetchRouterUsage(signal?: AbortSignal, provider?: string): Promis
568
599
  }
569
600
  }
570
601
  }
602
+ // Credit-based upstreams (deepseek): the usage text prints "Unavailable"
603
+ // windows — pull the real USD balance from the management API instead.
604
+ const credits = await fetchRouterCredits(provider, w);
605
+ if (credits) {
606
+ const account: SubscriptionAccountSnapshot = {
607
+ ...baseAccount,
608
+ plan: `Router · ${provider}`,
609
+ monthlyCredits: credits.balanceUsd,
610
+ usageBreakdown: credits.breakdown,
611
+ };
612
+ return {
613
+ providerDisplayName: "Router",
614
+ accounts: [account],
615
+ activeAccount: account,
616
+ fetchedAt: Date.now(),
617
+ };
618
+ }
571
619
  // personalDaily = per-key budget (nearest reset → R slot),
572
620
  // provider weekly/session = upstream quota (W slot). Fall back sensibly.
573
621
  const account: SubscriptionAccountSnapshot = {
@@ -612,6 +660,56 @@ async function fetchRouterUsage(signal?: AbortSignal, provider?: string): Promis
612
660
  };
613
661
  }
614
662
 
663
+ interface RouterCredits {
664
+ balanceUsd: number;
665
+ breakdown: string;
666
+ }
667
+
668
+ /** Fetch the raw USD balance for credit-based upstreams (deepseek: `credits_usd`)
669
+ * via OmniRoute's management usage API. Only called when the key-authable
670
+ * om-usage text reports no usable windows — the API-key surface normalizes
671
+ * credits to meaningless percentages, so this needs ROUTER_MGMT_TOKEN.
672
+ * Uses connection discovery from /api/v1/me/status (key-authable) +
673
+ * /api/usage/<id> (management token). */
674
+ async function fetchRouterCredits(provider: string | undefined, w: ReturnType<typeof parseOmniUsageText>): Promise<RouterCredits | undefined> {
675
+ if (!provider || w.session || w.providerWeekly) return undefined;
676
+ const cfg = readRouterConfig();
677
+ const apiKey = readRouterApiKey();
678
+ const mgmtToken = readRouterMgmtToken();
679
+ if (!cfg || !apiKey || !mgmtToken) return undefined;
680
+ const origin = routerOrigin(cfg.baseUrl);
681
+ try {
682
+ const combined = AbortSignal.timeout(7_000);
683
+ // 1. Connection id for this upstream via the key-authable status endpoint.
684
+ const statusRes = await fetch(`${origin}/api/v1/me/status`, {
685
+ headers: { Authorization: `Bearer ${apiKey}` },
686
+ signal: combined,
687
+ });
688
+ if (!statusRes.ok) return undefined;
689
+ const status = (await statusRes.json()) as { accountQuotas?: Array<{ provider?: string; connectionId?: string }> };
690
+ const connectionId = status.accountQuotas?.find((q) => q.provider === provider)?.connectionId;
691
+ if (!connectionId) return undefined;
692
+ // 2. Raw usage (management token) — quotas.credits_usd.remaining is the USD balance.
693
+ const usageRes = await fetch(`${origin}/api/usage/${connectionId}`, {
694
+ headers: { Authorization: `Bearer ${mgmtToken}` },
695
+ signal: combined,
696
+ });
697
+ if (!usageRes.ok) return undefined;
698
+ const usage = (await usageRes.json()) as { quotas?: Record<string, { remaining?: number }> };
699
+ const credits = usage.quotas?.credits_usd ?? usage.quotas?.credits;
700
+ const remaining = credits?.remaining;
701
+ if (typeof remaining !== "number" || !Number.isFinite(remaining)) return undefined;
702
+ const cny = usage.quotas?.credits_cny?.remaining;
703
+ return {
704
+ balanceUsd: remaining,
705
+ breakdown: `🪙 Balance (USD) $${remaining.toFixed(2)}` +
706
+ (typeof cny === "number" ? ` · ¥${cny.toFixed(2)} CNY` : ""),
707
+ };
708
+ } catch {
709
+ return undefined; // no mgmt token / upstream down — fall back to endpoint display
710
+ }
711
+ }
712
+
615
713
  // Command Code's /alpha/billing/credits endpoint (auth: same Provider API key
616
714
  // as /provider/v1 models) returns live 5-hour and weekly rolling windows plus
617
715
  // the monthly credit balance — the same data as the cmd /usage CLI.
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.32",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",