@bacnh85/pi-sub 0.1.30 → 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,29 @@
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
+
15
+ ## 0.1.31 (2026-08-22)
16
+
17
+ - **Fixed glm-cn quota via OmniRoute**: OmniRoute's connection slug for the
18
+ Z.ai (CN) upstream is `glm-cn`, not `zai-coding-cn` (a Pi provider id).
19
+ Wrong slug returned "No cached usage data", so the plain-retry fallback
20
+ showed the aggregate snapshot (e.g. R:100% W:0%) instead of the Z.ai windows.
21
+ Alias `glmcn` now maps to the correct slug; footer shows `R:99%/3H`-style
22
+ windows matching the direct Z.ai display.
23
+ - OmniRoute "reset in 2h 55m" countdowns are now converted into compact
24
+ footer labels (`/3H`), and "Unavailable" windows are skipped (previously
25
+ `0%`-style aggregates leaked in via the fallback).
26
+
1
27
  ## 0.1.30 (2026-08-22)
2
28
 
3
29
  - Router usage is now **provider-scoped**: the active router model's upstream
@@ -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,10 +362,26 @@ 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>`,
346
375
  * `NN% left`, `⏱ reset in <countdown>`. Robust to missing/unknown blocks. */
376
+ /** Convert OmniRoute's "reset in 2h 55m" countdown into the compact footer
377
+ * label (e.g. 3H), matching the direct Z.ai display (R:99%/4H). */
378
+ function countdownToLabel(text: string): string | undefined {
379
+ const m = text.match(/(?:(\d+)\s*d)?\s*(?:(\d+)\s*h)?\s*(?:(\d+)\s*m)?/);
380
+ if (!m || (!m[1] && !m[2] && !m[3])) return undefined;
381
+ const secs = Number(m[1] || 0) * 86400 + Number(m[2] || 0) * 3600 + Number(m[3] || 0) * 60;
382
+ return secs ? formatRemainingTime(Date.now() / 1000 + secs) : undefined;
383
+ }
384
+
347
385
  export function parseOmniUsageText(text: string): {
348
386
  personalDaily?: UsageWindow;
349
387
  personalWeekly?: UsageWindow;
@@ -369,7 +407,10 @@ export function parseOmniUsageText(text: string): {
369
407
  const remaining = Number(usedMatch[1]);
370
408
  if (remaining < 0 || remaining > 100) continue;
371
409
  const window: UsageWindow = { remaining };
372
- if (resetMatch) window.resetLabel = `⏱ ${resetMatch[1].trim()}`;
410
+ if (resetMatch) {
411
+ window.resetLabel = `⏱ ${resetMatch[1].trim()}`;
412
+ window.remainingLabel = countdownToLabel(resetMatch[1]);
413
+ }
373
414
  if (inPersonal) {
374
415
  if (label.includes("daily")) out.personalDaily = window;
375
416
  else if (label.includes("weekly")) out.personalWeekly = window;
@@ -397,6 +438,10 @@ if (process.env.PI_SUB_SELF_CHECK === "1") {
397
438
  assert(rp("command-code/deepseek/deepseek-v4-flash") === "command-code", "prefix command-code");
398
439
  assert(rp("cmd/deepseek/deepseek-v4-flash") === "command-code", "alias cmd → command-code");
399
440
  assert(rp("oc/gpt-5") === "opencode-go", "alias oc → opencode-go");
441
+ // OmniRoute's connection slug is `glm-cn` (live-verified; zai-coding-cn is a
442
+ // Pi provider id, NOT an OmniRoute slug — wrong slug = no cached data).
443
+ assert(rp("glm-cn/glm-5.2") === "glm-cn", "glm-cn passes through");
444
+ assert(rp("glmcn/glm-5.2") === "glm-cn", "alias glmcn → glm-cn");
400
445
  assert(rp("zai-coding/glm-5.2") === "zai-coding", "prefix zai-coding");
401
446
  assert(rp("auto/best") === undefined, "generic auto filtered");
402
447
  assert(rp("openrouter/gpt-5") === undefined, "generic openrouter filtered");
@@ -404,7 +449,7 @@ if (process.env.PI_SUB_SELF_CHECK === "1") {
404
449
  assert(p.personalWeekly?.remaining === 90, "personal weekly 90");
405
450
  assert(p.session?.remaining === 47, "session 47");
406
451
  assert(p.providerWeekly?.remaining === 28, "provider weekly 28");
407
- assert(p.personalDaily?.resetLabel?.includes("15h"), "daily reset label");
452
+ assert(p.personalDaily?.resetLabel?.includes("15h") === true, "daily reset label");
408
453
  const disabled = parseOmniUsageText("Usage command is disabled for this API key.");
409
454
  assert(Object.keys(disabled).length === 0, "disabled text parses empty");
410
455
  // Live-verified: provider without cached data → no windows (endpoint fallback).
@@ -416,7 +461,15 @@ if (process.env.PI_SUB_SELF_CHECK === "1") {
416
461
  );
417
462
  assert(live.session?.remaining === 90, "session 90");
418
463
  assert(live.providerWeekly?.remaining === 0, "weekly 0");
419
- assert(live.session?.resetLabel?.includes("1h 59m"), "session reset");
464
+ assert(live.session?.resetLabel?.includes("1h 59m") === true, "session reset");
465
+ // Live-verified 2026-08-22: glm-cn scoped quota — Session 99%, Weekly
466
+ // "Unavailable" (skipped, so W stays absent like the direct Z.ai footer).
467
+ const glmCn = parseOmniUsageText(
468
+ "Provider quota\nSession\n99% left\n⏱ reset in 2h 55m\n\nWeekly\nUnavailable\n⏱ reset in unknown"
469
+ );
470
+ assert(glmCn.session?.remaining === 99, "glm-cn session 99");
471
+ assert(glmCn.session?.remainingLabel === "3H", "glm-cn reset label 3H");
472
+ assert(glmCn.providerWeekly === undefined, "glm-cn weekly unavailable skipped");
420
473
  }
421
474
 
422
475
  async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): Promise<UsageApiSnapshot | undefined> {
@@ -529,9 +582,11 @@ async function fetchRouterUsage(signal?: AbortSignal, provider?: string): Promis
529
582
  let text = await response.text();
530
583
  if (text && !text.includes("disabled")) {
531
584
  let w = parseOmniUsageText(text);
532
- // Provider-scoped call but no cached quota for that upstream — retry
533
- // without ?provider= so the report shows the best/all snapshot.
534
- 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")) {
535
590
  const plain = await fetch(url.replace(/\?provider=.*$/, ""), {
536
591
  headers: { Accept: "text/plain", Authorization: `Bearer ${apiKey}` },
537
592
  signal: combined,
@@ -544,6 +599,23 @@ async function fetchRouterUsage(signal?: AbortSignal, provider?: string): Promis
544
599
  }
545
600
  }
546
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
+ }
547
619
  // personalDaily = per-key budget (nearest reset → R slot),
548
620
  // provider weekly/session = upstream quota (W slot). Fall back sensibly.
549
621
  const account: SubscriptionAccountSnapshot = {
@@ -588,6 +660,56 @@ async function fetchRouterUsage(signal?: AbortSignal, provider?: string): Promis
588
660
  };
589
661
  }
590
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
+
591
713
  // Command Code's /alpha/billing/credits endpoint (auth: same Provider API key
592
714
  // as /provider/v1 models) returns live 5-hour and weekly rolling windows plus
593
715
  // the monthly credit balance — the same data as the cmd /usage CLI.
@@ -913,7 +1035,7 @@ function routerUpstreamPrefix(model: ModelLike): string | undefined {
913
1035
  if (first === "cmd") return "command-code";
914
1036
  if (first === "oc") return "opencode-go";
915
1037
  if (first === "ds") return "deepseek";
916
- if (first === "glmcn" || first === "glm-cn") return "zai-coding-cn";
1038
+ if (first === "glmcn") return "glm-cn"; // OmniRoute connection slug (not the Pi provider id zai-coding-cn)
917
1039
  // Generic router aliases / upstreams without cached quota data — no provider
918
1040
  // selection; the usage API returns the best snapshot instead.
919
1041
  const generic = new Set(["auto", "aug", "no-think", "tllm", "combo", "openrouter", "nvidia", "felo", "pepper", "mcode", "ddgw", "veoaifree-web", "veo-free"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.30",
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",