@bacnh85/pi-sub 0.1.11 → 0.1.13

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/README.md CHANGED
@@ -34,25 +34,29 @@ The footer status appears after Pi's built-in status/token usage line and includ
34
34
  Example subscription line:
35
35
 
36
36
  ```text
37
- (user@example.com) R:15%/2H W:20%/3D
37
+ (user@example.com) R:15%/2H W:20%/3D 42 tok/s
38
38
  ```
39
39
 
40
40
  ### OpenCode Go
41
41
 
42
- OpenCode Go does not expose a public usage-window API, so the footer shows the active account/key label and accumulated session cost:
42
+ OpenCode Go does not expose a public usage-window API, so the footer shows the active account/key label, accumulated session cost, and last response speed:
43
43
 
44
44
  ```text
45
- OpenCode Go (OpenCode Go key#1a2b3c4d) $0.23
45
+ OpenCode Go (OpenCode Go key#1a2b3c4d) $0.23 42 tok/s
46
46
  ```
47
47
 
48
48
  ### Z.ai
49
49
 
50
- Z.ai (GLM Coding Plan) shows the active account/key label plus 5-hour rolling and weekly remaining quota with reset countdowns:
50
+ Z.ai (GLM Coding Plan) shows the active account/key label, 5-hour rolling and weekly remaining quota with reset countdowns, and last response speed:
51
51
 
52
52
  ```text
53
- (Z.ai key#1a2b3c4d) R:55%/2H W:80%/3D
53
+ (Z.ai key#1a2b3c4d) R:55%/2H W:80%/3D 42 tok/s
54
54
  ```
55
55
 
56
+ ### Tokens per second
57
+
58
+ `pi-sub` tracks each response's tokens-per-second (tok/s) speed by measuring the time from provider request to message completion against the response's output token count. The last response's speed is shown in the footer next to usage data. The `/sub` detail view shows both the last response speed and the session-wide average.
59
+
56
60
  When the current model provider is not supported, `pi-sub` clears its subscription line and does not refresh subscription data.
57
61
 
58
62
  ## Commands
@@ -63,18 +67,23 @@ When the current model provider is not supported, `pi-sub` clears its subscripti
63
67
  | `/sub status` | Same as `/sub`. |
64
68
  | `/sub refresh` | Force a usage refresh, then show details. |
65
69
 
66
- When Pi OpenAI Codex auth is available, `/sub` shows the active account usage:
70
+ When Pi OpenAI Codex auth is available, `/sub` shows the active account usage and speed:
67
71
 
68
72
  ```text
73
+ Provider: Codex · Model: o4-mini · Fetched: 14:23
74
+ Session cost: $0.12
75
+ Last response: 42 tok/s · Session avg: 39 tok/s
76
+
69
77
  ACCOUNT PLAN ROLLING WEEKLY LAST ACTIVITY
70
78
  * user@example.com Plus 15%/2H 20%/3D Now
71
79
  ```
72
80
 
73
- For OpenCode Go, `/sub` shows the provider/model, active account/key label, and session cost:
81
+ For OpenCode Go, `/sub` shows the provider/model, active account/key label, session cost, and speed:
74
82
 
75
83
  ```text
76
84
  Provider: OpenCode Go · Model: kimi-k2.6 · Fetched: 14:23
77
85
  Session cost: $0.23
86
+ Last response: 42 tok/s · Session avg: 39 tok/s
78
87
 
79
88
  ACCOUNT PLAN LAST ACTIVITY
80
89
  ------------------------------------------------------
@@ -83,10 +92,12 @@ Session cost: $0.23
83
92
  OpenCode Go does not expose usage windows.
84
93
  ```
85
94
 
86
- For Z.ai, `/sub` shows the rolling and weekly quota windows:
95
+ For Z.ai, `/sub` shows the rolling and weekly quota windows and speed:
87
96
 
88
97
  ```text
89
98
  Provider: Z.ai · Model: glm-5.2 · Fetched: 14:23
99
+ Session cost: $0.05
100
+ Last response: 42 tok/s · Session avg: 39 tok/s
90
101
 
91
102
  ACCOUNT PLAN ROLLING WEEKLY LAST ACTIVITY
92
103
  ------------------------------------------------------------------------
@@ -66,7 +66,6 @@ interface SubscriptionUsageSnapshot {
66
66
  activeAccount?: SubscriptionAccountSnapshot;
67
67
  fetchedAt: number;
68
68
  error?: string;
69
- cost?: number;
70
69
  }
71
70
 
72
71
  type SubscriptionProviderAdapter = {
@@ -85,6 +84,11 @@ interface State {
85
84
  inFlight?: Promise<SubscriptionUsageSnapshot>;
86
85
  refreshTimer?: NodeJS.Timeout;
87
86
  debounceTimer?: NodeJS.Timeout;
87
+ responseStartTime?: number;
88
+ lastTokPerSec?: number;
89
+ cumulativeOutput: number;
90
+ cumulativeDurationMs: number;
91
+ cumulativeCost: number;
88
92
  }
89
93
 
90
94
  function isCodexModel(model: ModelLike): boolean {
@@ -209,9 +213,9 @@ function formatRemainingTime(resetAtSec: number | undefined): string | undefined
209
213
  if (remainingSec <= 0) return "0M";
210
214
  const remainingMin = Math.ceil(remainingSec / 60);
211
215
  if (remainingMin < 60) return `${remainingMin}M`;
212
- const remainingH = Math.ceil(remainingMin / 60);
216
+ const remainingH = Math.ceil(remainingSec / 3600);
213
217
  if (remainingH < 24) return `${remainingH}H`;
214
- const remainingD = Math.ceil(remainingH / 24);
218
+ const remainingD = Math.ceil(remainingSec / 86400);
215
219
  return `${remainingD}D`;
216
220
  }
217
221
 
@@ -272,7 +276,7 @@ function parseUsageResponse(body: unknown): UsageApiSnapshot | undefined {
272
276
  async function readPiCodexAuth(): Promise<PiAuthEntry & { accountId: string }> {
273
277
  const auth = await readJsonFile<PiAuthFile>(piAuthPath());
274
278
  const entry = auth[CODEX_PROVIDER];
275
- const accountId = getCodexAccountId(entry!);
279
+ const accountId = getCodexAccountId(entry);
276
280
  if (!entry?.access || !accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
277
281
  return { ...entry, accountId };
278
282
  }
@@ -384,7 +388,7 @@ interface ZaiUsageApiResponse {
384
388
  interface ZaiUsageApiError {
385
389
  code: number;
386
390
  msg: string;
387
- success: boolean;
391
+ success?: boolean;
388
392
  }
389
393
 
390
394
  function zaiLimitToUsageWindow(limit: ZaiLimitEntry): UsageWindow | undefined {
@@ -426,9 +430,11 @@ async function fetchZaiUsage(signal?: AbortSignal): Promise<SubscriptionUsageSna
426
430
  const body = await response.json();
427
431
 
428
432
  // Z.ai returns HTTP 200 even on auth errors: {"code":401,"msg":"...","success":false}
433
+ // Also handle missing success field, empty msg, or presence of code.
429
434
  const apiError = body as ZaiUsageApiError;
430
- if (typeof apiError.success === "boolean" && !apiError.success && apiError.msg) {
431
- throw new Error(`Z.ai API error: ${apiError.msg}`);
435
+ if (apiError.code >= 400 || (typeof apiError.success === "boolean" && !apiError.success) || (apiError.msg && apiError.msg.length > 0 && apiError.success === undefined)) {
436
+ const message = apiError.msg || `HTTP status ${apiError.code}`;
437
+ throw new Error(`Z.ai API error: ${message}`);
432
438
  }
433
439
 
434
440
  const parsed = body as ZaiUsageApiResponse;
@@ -477,9 +483,8 @@ function supportedAdapter(model: ModelLike): SubscriptionProviderAdapter | undef
477
483
 
478
484
  function formatRemaining(window: UsageWindow | undefined): string {
479
485
  if (!window) return "?";
480
- if (window.remaining !== undefined && window.remainingLabel) return `${window.remaining}%/${window.remainingLabel}`;
486
+ if (window.remainingLabel) return `${window.remaining}%/${window.remainingLabel}`;
481
487
  if (window.remaining !== undefined) return `${window.remaining}%`;
482
- if (window.percent !== undefined && window.remainingLabel) return `${Math.max(0, 100 - window.percent)}%/${window.remainingLabel}`;
483
488
  return "?";
484
489
  }
485
490
 
@@ -499,16 +504,6 @@ function windowSegments(account: SubscriptionAccountSnapshot | undefined): strin
499
504
  return segments;
500
505
  }
501
506
 
502
- function aggregateSessionCost(ctx: ExtensionContext): number {
503
- let total = 0;
504
- for (const entry of ctx.sessionManager.getBranch()) {
505
- if (entry.type === "message" && entry.message.role === "assistant") {
506
- total += (entry.message.usage as any)?.cost?.total ?? 0;
507
- }
508
- }
509
- return total;
510
- }
511
-
512
507
  function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
513
508
  if (!state.adapter) {
514
509
  ctx.ui.setStatus(STATUS_KEY, undefined);
@@ -528,9 +523,10 @@ function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
528
523
  const windowParts = windowSegments(account);
529
524
  const accountPart = formatFooterAccount(account);
530
525
  const segments = accountPart ? [accountPart, ...windowParts] : [...windowParts];
531
- const cost = snapshot.cost;
526
+ const cost = state.cumulativeCost;
532
527
  const hasWindows = windowParts.length > 0;
533
- if (cost !== undefined && cost > 0) segments.push(`$${cost.toFixed(2)}`);
528
+ if (cost > 0) segments.push(`$${cost.toFixed(2)}`);
529
+ if (state.lastTokPerSec !== undefined) segments.push(`${state.lastTokPerSec} tok/s`);
534
530
  if (segments.length === 0) {
535
531
  line = `Sub ${state.adapter.displayName}`;
536
532
  } else if (!hasWindows) {
@@ -592,7 +588,6 @@ async function refreshUsage(ctx: ExtensionContext, state: State, force: boolean)
592
588
  renderSubscriptionLine(ctx, state);
593
589
  state.inFlight = adapter.fetchUsage(ctx.signal).then((snapshot) => {
594
590
  if (state.refreshGeneration !== generation) return snapshot;
595
- snapshot.cost = aggregateSessionCost(ctx);
596
591
  state.snapshot = snapshot;
597
592
  state.lastRefreshAt = Date.now();
598
593
  renderSubscriptionLine(ctx, state);
@@ -623,7 +618,7 @@ function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: St
623
618
  if (!snapshot) return "Subscription usage has not been loaded yet.";
624
619
  if (snapshot.error) return `${snapshot.providerDisplayName}: ${snapshot.error}`;
625
620
  if (snapshot.accounts.length === 0) {
626
- const costLine = snapshot.cost !== undefined && snapshot.cost > 0 ? `\nSession cost: $${snapshot.cost.toFixed(2)}` : "";
621
+ const costLine = state.cumulativeCost > 0 ? `\nSession cost: $${state.cumulativeCost.toFixed(2)}` : "";
627
622
  const modelInfo = state.model?.id ? ` · Model: ${state.model.id}` : "";
628
623
  return `Provider: ${snapshot.providerDisplayName}${modelInfo} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}\n${snapshot.providerDisplayName} does not expose usage windows.${costLine}`;
629
624
  }
@@ -655,8 +650,14 @@ function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: St
655
650
  return `${row.active} ${cols.join(" ")} ${row.snapshot.lastActivity ?? ""}`;
656
651
  });
657
652
 
658
- const costLine = snapshot.cost !== undefined ? `\nSession cost: $${snapshot.cost.toFixed(2)}` : "";
659
- const lines = [`Provider: ${snapshot.providerDisplayName} · Model: ${state.model?.id ?? "unknown-model"} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}${costLine}`, "", header, sep, ...body];
653
+ const costLine = state.cumulativeCost > 0 ? `\nSession cost: $${state.cumulativeCost.toFixed(2)}` : "";
654
+ const tokPerSecLine = state.lastTokPerSec !== undefined
655
+ ? `\nLast response: ${state.lastTokPerSec} tok/s` +
656
+ (state.cumulativeDurationMs > 0
657
+ ? ` · Session avg: ${Math.round(state.cumulativeOutput / (state.cumulativeDurationMs / 1000))} tok/s`
658
+ : "")
659
+ : "";
660
+ const lines = [`Provider: ${snapshot.providerDisplayName} · Model: ${state.model?.id ?? "unknown-model"} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}${costLine}${tokPerSecLine}`, "", header, sep, ...body];
660
661
  if (!hasFiveHour && !hasWeekly) {
661
662
  lines.push("", `${snapshot.providerDisplayName} does not expose usage windows.`);
662
663
  }
@@ -664,7 +665,7 @@ function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: St
664
665
  }
665
666
 
666
667
  export default function (pi: ExtensionAPI) {
667
- const state: State = { lastRefreshAt: 0, refreshGeneration: 0 };
668
+ const state: State = { lastRefreshAt: 0, refreshGeneration: 0, cumulativeOutput: 0, cumulativeDurationMs: 0, cumulativeCost: 0 };
668
669
 
669
670
  pi.on("session_start", async (_event, ctx) => {
670
671
  updateActiveAdapter(ctx, state, ctx.model);
@@ -676,7 +677,31 @@ export default function (pi: ExtensionAPI) {
676
677
  if (state.adapter) void refreshUsage(ctx, state, true);
677
678
  });
678
679
 
679
- pi.on("after_provider_response", async (_event, ctx) => {
680
+ pi.on("before_provider_request", async (_event, _ctx) => {
681
+ state.responseStartTime = Date.now();
682
+ });
683
+
684
+ pi.on("message_end", async (event, ctx) => {
685
+ if (event.message.role === "assistant") {
686
+ state.cumulativeCost += (event.message.usage as any)?.cost?.total ?? 0;
687
+ if (state.responseStartTime) {
688
+ const output = (event.message.usage as any)?.output ?? 0;
689
+ const elapsed = Date.now() - state.responseStartTime;
690
+ state.responseStartTime = undefined;
691
+ if (elapsed > 0 && output > 0) {
692
+ state.lastTokPerSec = Math.round(output / (elapsed / 1000));
693
+ state.cumulativeOutput += output;
694
+ state.cumulativeDurationMs += elapsed;
695
+ }
696
+ }
697
+ if (state.adapter) renderSubscriptionLine(ctx, state);
698
+ }
699
+ });
700
+
701
+ pi.on("after_provider_response", async (event, ctx) => {
702
+ if (event.status >= 400) {
703
+ state.responseStartTime = undefined;
704
+ }
680
705
  if (state.adapter) scheduleRefresh(ctx, state);
681
706
  });
682
707
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",