@bacnh85/pi-sub 0.1.12 → 0.1.14

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.
Files changed (2) hide show
  1. package/extensions/index.ts +35 -49
  2. package/package.json +2 -2
@@ -1,6 +1,5 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { readStoredCredential, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { createHash } from "node:crypto";
3
- import fs from "node:fs/promises";
4
3
  import os from "node:os";
5
4
  import path from "node:path";
6
5
 
@@ -28,8 +27,6 @@ type UsageApiSnapshot = {
28
27
  plan_type?: string;
29
28
  };
30
29
 
31
- type PiAuthFile = Record<string, PiAuthEntry | undefined>;
32
-
33
30
  type PiAuthEntry = {
34
31
  type?: string;
35
32
  access?: string;
@@ -66,7 +63,6 @@ interface SubscriptionUsageSnapshot {
66
63
  activeAccount?: SubscriptionAccountSnapshot;
67
64
  fetchedAt: number;
68
65
  error?: string;
69
- cost?: number;
70
66
  }
71
67
 
72
68
  type SubscriptionProviderAdapter = {
@@ -89,6 +85,7 @@ interface State {
89
85
  lastTokPerSec?: number;
90
86
  cumulativeOutput: number;
91
87
  cumulativeDurationMs: number;
88
+ cumulativeCost: number;
92
89
  }
93
90
 
94
91
  function isCodexModel(model: ModelLike): boolean {
@@ -109,10 +106,6 @@ function piAuthPath(): string {
109
106
  return path.join(configDir, "auth.json");
110
107
  }
111
108
 
112
- async function readJsonFile<T>(file: string): Promise<T> {
113
- return JSON.parse(await fs.readFile(file, "utf8")) as T;
114
- }
115
-
116
109
  function decodeJwtPayload(token: string | undefined): Record<string, any> | undefined {
117
110
  if (!token) return undefined;
118
111
  const parts = token.split(".");
@@ -213,9 +206,9 @@ function formatRemainingTime(resetAtSec: number | undefined): string | undefined
213
206
  if (remainingSec <= 0) return "0M";
214
207
  const remainingMin = Math.ceil(remainingSec / 60);
215
208
  if (remainingMin < 60) return `${remainingMin}M`;
216
- const remainingH = Math.ceil(remainingMin / 60);
209
+ const remainingH = Math.ceil(remainingSec / 3600);
217
210
  if (remainingH < 24) return `${remainingH}H`;
218
- const remainingD = Math.ceil(remainingH / 24);
211
+ const remainingD = Math.ceil(remainingSec / 86400);
219
212
  return `${remainingD}D`;
220
213
  }
221
214
 
@@ -274,23 +267,20 @@ function parseUsageResponse(body: unknown): UsageApiSnapshot | undefined {
274
267
  }
275
268
 
276
269
  async function readPiCodexAuth(): Promise<PiAuthEntry & { accountId: string }> {
277
- const auth = await readJsonFile<PiAuthFile>(piAuthPath());
278
- const entry = auth[CODEX_PROVIDER];
279
- const accountId = getCodexAccountId(entry!);
270
+ const entry = readStoredCredential(CODEX_PROVIDER, piAuthPath()) as PiAuthEntry | undefined;
271
+ const accountId = getCodexAccountId(entry);
280
272
  if (!entry?.access || !accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
281
273
  return { ...entry, accountId };
282
274
  }
283
275
 
284
276
  async function readOpenCodeGoAuth(): Promise<SubscriptionAccountSnapshot> {
285
- const auth = await readJsonFile<PiAuthFile>(piAuthPath());
286
- const entry = auth[OPC_PROVIDER];
277
+ const entry = readStoredCredential(OPC_PROVIDER, piAuthPath()) as PiAuthEntry | undefined;
287
278
  if (!entry?.key && !entry?.accountId) throw new Error("Missing opencode-go API key or accountId in Pi auth");
288
279
  return authAccountSnapshot("OpenCode Go", entry, { plan: "Go" });
289
280
  }
290
281
 
291
282
  async function readZaiAuth(): Promise<{ key: string; account: SubscriptionAccountSnapshot }> {
292
- const auth = await readJsonFile<PiAuthFile>(piAuthPath());
293
- const entry = auth[ZAI_PROVIDER];
283
+ const entry = readStoredCredential(ZAI_PROVIDER, piAuthPath()) as PiAuthEntry | undefined;
294
284
  if (!entry?.key) throw new Error("Missing zai API key in Pi auth");
295
285
  return { key: entry.key, account: authAccountSnapshot("Z.ai", entry) };
296
286
  }
@@ -388,7 +378,7 @@ interface ZaiUsageApiResponse {
388
378
  interface ZaiUsageApiError {
389
379
  code: number;
390
380
  msg: string;
391
- success: boolean;
381
+ success?: boolean;
392
382
  }
393
383
 
394
384
  function zaiLimitToUsageWindow(limit: ZaiLimitEntry): UsageWindow | undefined {
@@ -430,9 +420,11 @@ async function fetchZaiUsage(signal?: AbortSignal): Promise<SubscriptionUsageSna
430
420
  const body = await response.json();
431
421
 
432
422
  // Z.ai returns HTTP 200 even on auth errors: {"code":401,"msg":"...","success":false}
423
+ // Also handle missing success field, empty msg, or presence of code.
433
424
  const apiError = body as ZaiUsageApiError;
434
- if (typeof apiError.success === "boolean" && !apiError.success && apiError.msg) {
435
- throw new Error(`Z.ai API error: ${apiError.msg}`);
425
+ if (apiError.code >= 400 || (typeof apiError.success === "boolean" && !apiError.success) || (apiError.msg && apiError.msg.length > 0 && apiError.success === undefined)) {
426
+ const message = apiError.msg || `HTTP status ${apiError.code}`;
427
+ throw new Error(`Z.ai API error: ${message}`);
436
428
  }
437
429
 
438
430
  const parsed = body as ZaiUsageApiResponse;
@@ -481,9 +473,8 @@ function supportedAdapter(model: ModelLike): SubscriptionProviderAdapter | undef
481
473
 
482
474
  function formatRemaining(window: UsageWindow | undefined): string {
483
475
  if (!window) return "?";
484
- if (window.remaining !== undefined && window.remainingLabel) return `${window.remaining}%/${window.remainingLabel}`;
476
+ if (window.remainingLabel) return `${window.remaining}%/${window.remainingLabel}`;
485
477
  if (window.remaining !== undefined) return `${window.remaining}%`;
486
- if (window.percent !== undefined && window.remainingLabel) return `${Math.max(0, 100 - window.percent)}%/${window.remainingLabel}`;
487
478
  return "?";
488
479
  }
489
480
 
@@ -503,16 +494,6 @@ function windowSegments(account: SubscriptionAccountSnapshot | undefined): strin
503
494
  return segments;
504
495
  }
505
496
 
506
- function aggregateSessionCost(ctx: ExtensionContext): number {
507
- let total = 0;
508
- for (const entry of ctx.sessionManager.getBranch()) {
509
- if (entry.type === "message" && entry.message.role === "assistant") {
510
- total += (entry.message.usage as any)?.cost?.total ?? 0;
511
- }
512
- }
513
- return total;
514
- }
515
-
516
497
  function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
517
498
  if (!state.adapter) {
518
499
  ctx.ui.setStatus(STATUS_KEY, undefined);
@@ -532,9 +513,9 @@ function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
532
513
  const windowParts = windowSegments(account);
533
514
  const accountPart = formatFooterAccount(account);
534
515
  const segments = accountPart ? [accountPart, ...windowParts] : [...windowParts];
535
- const cost = snapshot.cost;
516
+ const cost = state.cumulativeCost;
536
517
  const hasWindows = windowParts.length > 0;
537
- if (cost !== undefined && cost > 0) segments.push(`$${cost.toFixed(2)}`);
518
+ if (cost > 0) segments.push(`$${cost.toFixed(2)}`);
538
519
  if (state.lastTokPerSec !== undefined) segments.push(`${state.lastTokPerSec} tok/s`);
539
520
  if (segments.length === 0) {
540
521
  line = `Sub ${state.adapter.displayName}`;
@@ -597,7 +578,6 @@ async function refreshUsage(ctx: ExtensionContext, state: State, force: boolean)
597
578
  renderSubscriptionLine(ctx, state);
598
579
  state.inFlight = adapter.fetchUsage(ctx.signal).then((snapshot) => {
599
580
  if (state.refreshGeneration !== generation) return snapshot;
600
- snapshot.cost = aggregateSessionCost(ctx);
601
581
  state.snapshot = snapshot;
602
582
  state.lastRefreshAt = Date.now();
603
583
  renderSubscriptionLine(ctx, state);
@@ -628,7 +608,7 @@ function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: St
628
608
  if (!snapshot) return "Subscription usage has not been loaded yet.";
629
609
  if (snapshot.error) return `${snapshot.providerDisplayName}: ${snapshot.error}`;
630
610
  if (snapshot.accounts.length === 0) {
631
- const costLine = snapshot.cost !== undefined && snapshot.cost > 0 ? `\nSession cost: $${snapshot.cost.toFixed(2)}` : "";
611
+ const costLine = state.cumulativeCost > 0 ? `\nSession cost: $${state.cumulativeCost.toFixed(2)}` : "";
632
612
  const modelInfo = state.model?.id ? ` · Model: ${state.model.id}` : "";
633
613
  return `Provider: ${snapshot.providerDisplayName}${modelInfo} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}\n${snapshot.providerDisplayName} does not expose usage windows.${costLine}`;
634
614
  }
@@ -660,7 +640,7 @@ function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: St
660
640
  return `${row.active} ${cols.join(" ")} ${row.snapshot.lastActivity ?? ""}`;
661
641
  });
662
642
 
663
- const costLine = snapshot.cost !== undefined ? `\nSession cost: $${snapshot.cost.toFixed(2)}` : "";
643
+ const costLine = state.cumulativeCost > 0 ? `\nSession cost: $${state.cumulativeCost.toFixed(2)}` : "";
664
644
  const tokPerSecLine = state.lastTokPerSec !== undefined
665
645
  ? `\nLast response: ${state.lastTokPerSec} tok/s` +
666
646
  (state.cumulativeDurationMs > 0
@@ -675,7 +655,7 @@ function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: St
675
655
  }
676
656
 
677
657
  export default function (pi: ExtensionAPI) {
678
- const state: State = { lastRefreshAt: 0, refreshGeneration: 0, cumulativeOutput: 0, cumulativeDurationMs: 0 };
658
+ const state: State = { lastRefreshAt: 0, refreshGeneration: 0, cumulativeOutput: 0, cumulativeDurationMs: 0, cumulativeCost: 0 };
679
659
 
680
660
  pi.on("session_start", async (_event, ctx) => {
681
661
  updateActiveAdapter(ctx, state, ctx.model);
@@ -692,20 +672,26 @@ export default function (pi: ExtensionAPI) {
692
672
  });
693
673
 
694
674
  pi.on("message_end", async (event, ctx) => {
695
- if (event.message.role === "assistant" && state.responseStartTime) {
696
- const output = (event.message.usage as any)?.output ?? 0;
697
- const elapsed = Date.now() - state.responseStartTime;
698
- state.responseStartTime = undefined;
699
- if (elapsed > 0 && output > 0) {
700
- state.lastTokPerSec = Math.round(output / (elapsed / 1000));
701
- state.cumulativeOutput += output;
702
- state.cumulativeDurationMs += elapsed;
703
- if (state.adapter) renderSubscriptionLine(ctx, state);
675
+ if (event.message.role === "assistant") {
676
+ state.cumulativeCost += (event.message.usage as any)?.cost?.total ?? 0;
677
+ if (state.responseStartTime) {
678
+ const output = (event.message.usage as any)?.output ?? 0;
679
+ const elapsed = Date.now() - state.responseStartTime;
680
+ state.responseStartTime = undefined;
681
+ if (elapsed > 0 && output > 0) {
682
+ state.lastTokPerSec = Math.round(output / (elapsed / 1000));
683
+ state.cumulativeOutput += output;
684
+ state.cumulativeDurationMs += elapsed;
685
+ }
704
686
  }
687
+ if (state.adapter) renderSubscriptionLine(ctx, state);
705
688
  }
706
689
  });
707
690
 
708
- pi.on("after_provider_response", async (_event, ctx) => {
691
+ pi.on("after_provider_response", async (event, ctx) => {
692
+ if (event.status >= 400) {
693
+ state.responseStartTime = undefined;
694
+ }
709
695
  if (state.adapter) scheduleRefresh(ctx, state);
710
696
  });
711
697
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,6 +38,6 @@
38
38
  "node": ">=20.3.0"
39
39
  },
40
40
  "peerDependencies": {
41
- "@earendil-works/pi-coding-agent": "*"
41
+ "@earendil-works/pi-coding-agent": ">=0.80.8 <0.81.0"
42
42
  }
43
43
  }