@bacnh85/pi-sub 0.1.5 → 0.1.7

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 (3) hide show
  1. package/README.md +23 -7
  2. package/index.ts +44 -39
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Pi extension that shows subscription usage for the currently selected supported model provider.
4
4
 
5
- V1 supports OpenAI Codex models by reading Pi's auth state from `~/.pi/agent/auth.json` (or `$PI_CODING_AGENT_DIR/auth.json`) and displays a subscription footer status after Pi's built-in status/token usage line only while the active Pi model provider is `openai-codex`.
5
+ Supports OpenAI Codex (`openai-codex`) with live usage windows from ChatGPT's usage endpoint, and OpenCode Go (`opencode-go`) with session cost tracking. Displays a subscription footer status after Pi's built-in status/token usage line.
6
6
 
7
7
  ## Install
8
8
 
@@ -26,6 +26,8 @@ pi -e ./extensions/pi-sub
26
26
 
27
27
  ## What it shows
28
28
 
29
+ ### OpenAI Codex
30
+
29
31
  The footer status appears after Pi's built-in status/token usage line and includes:
30
32
 
31
33
  - active account email;
@@ -40,6 +42,14 @@ Example subscription line:
40
42
  Sub · Plus · user@example.com · 5H 85% (18:12) · W 80% (08:00 on 2 Jul) · gpt-5-codex
41
43
  ```
42
44
 
45
+ ### OpenCode Go
46
+
47
+ OpenCode Go does not expose usage windows, so the footer shows only the accumulated session cost:
48
+
49
+ ```text
50
+ OpenCode Go $0.23
51
+ ```
52
+
43
53
  When the current model provider is not supported, `pi-sub` clears its subscription line and does not refresh subscription data.
44
54
 
45
55
  ## Commands
@@ -57,6 +67,14 @@ ACCOUNT PLAN 5H USAGE WEEKLY USAGE LAST ACTIVITY
57
67
  * user@example.com Plus 85% (18:12) 80% (08:00 on 2 Jul) Now
58
68
  ```
59
69
 
70
+ For OpenCode Go, `/sub` shows the provider/model and session cost:
71
+
72
+ ```text
73
+ Provider: OpenCode Go · Model: kimi-k2.6 · Fetched: 14:23
74
+ OpenCode Go does not expose usage windows.
75
+ Session cost: $0.23
76
+ ```
77
+
60
78
  ## Refresh behavior
61
79
 
62
80
  `pi-sub` refreshes usage data:
@@ -73,12 +91,10 @@ Refreshes are cached briefly to avoid excessive usage endpoint calls.
73
91
 
74
92
  ## Requirements and troubleshooting
75
93
 
76
- - Pi auth must contain an `openai-codex` OAuth entry in `~/.pi/agent/auth.json` or `$PI_CODING_AGENT_DIR/auth.json`.
77
- - The entry must include `access` and `accountId` fields.
78
- - `pi-sub` redacts auth/token-related errors and never prints Codex tokens.
79
-
80
- If usage is unavailable, verify that Pi can use the `openai-codex` provider and that the auth file exists.
94
+ - **OpenAI Codex**: Pi auth must contain an `openai-codex` OAuth entry in `~/.pi/agent/auth.json` or `$PI_CODING_AGENT_DIR/auth.json`. The entry must include `access` and `accountId` fields.
95
+ - **OpenCode Go**: Pi auth must contain an `opencode-go` API key entry (via `/login` or env var). The entry must have a `key` field or an `accountId` field.
96
+ - `pi-sub` redacts auth/token-related errors and never prints credentials.
81
97
 
82
98
  ## Design notes
83
99
 
84
- The extension is named `pi-sub` rather than `pi-codex-usage` so future subscription providers can be added as separate adapters. V1 intentionally supports only OpenAI Codex and vendors only the small amount of behavior needed for this extension instead of invoking `codex-auth` directly.
100
+ The extension is named `pi-sub` rather than `pi-codex-usage` so future subscription providers can be added as separate adapters. Supports OpenAI Codex (live usage API) and OpenCode Go (session cost only).
package/index.ts CHANGED
@@ -80,8 +80,10 @@ interface SubscriptionProviderAdapter {
80
80
  interface State {
81
81
  model?: ModelLike;
82
82
  adapter?: SubscriptionProviderAdapter;
83
+ adapterId?: string;
83
84
  snapshot?: SubscriptionUsageSnapshot;
84
85
  lastRefreshAt: number;
86
+ refreshGeneration: number;
85
87
  inFlight?: Promise<SubscriptionUsageSnapshot>;
86
88
  refreshTimer?: NodeJS.Timeout;
87
89
  debounceTimer?: NodeJS.Timeout;
@@ -132,6 +134,14 @@ function accountFromPiAuth(entry: PiAuthEntry): SubscriptionAccountSnapshot {
132
134
  };
133
135
  }
134
136
 
137
+ function getCodexAccountId(entry: PiAuthEntry | undefined): string | undefined {
138
+ if (!entry) return undefined;
139
+ if (typeof entry.accountId === "string" && entry.accountId.length > 0) return entry.accountId;
140
+ const claims = decodeJwtPayload(entry.access);
141
+ const auth = claims?.["https://api.openai.com/auth"];
142
+ return typeof auth?.chatgpt_account_id === "string" ? auth.chatgpt_account_id : undefined;
143
+ }
144
+
135
145
  function planLabel(plan: string | undefined): string | undefined {
136
146
  if (!plan) return undefined;
137
147
  const normalized = plan.toLowerCase().replace(/[_-]+/g, " ");
@@ -220,37 +230,20 @@ function parseUsageResponse(body: unknown): UsageApiSnapshot | undefined {
220
230
  };
221
231
  }
222
232
 
223
- function parseOpcodeUsageResponse(body: unknown): UsageApiSnapshot | undefined {
224
- if (!body || typeof body !== "object") return undefined;
225
- const root = body as any;
226
- const usage = root.usage ?? root;
227
- const parseWindow = (window: any): UsageApiWindow | undefined => {
228
- if (!window || typeof window !== "object" || typeof window.used_percent !== "number") return undefined;
229
- return {
230
- used_percent: window.used_percent,
231
- limit_window_seconds: typeof window.limit_window_seconds === "number" ? window.limit_window_seconds : undefined,
232
- reset_at: typeof window.reset_at === "number" ? window.reset_at : typeof window.reset_ts === "number" ? window.reset_ts : undefined,
233
- };
234
- };
235
- return {
236
- primary: parseWindow(usage.rolling ?? usage.primary_window),
237
- secondary: parseWindow(usage.weekly ?? usage.secondary_window),
238
- plan_type: typeof root.plan_type === "string" ? root.plan_type : typeof usage.plan_type === "string" ? usage.plan_type : undefined,
239
- };
240
- }
241
-
242
- async function readPiCodexAuth(): Promise<PiAuthEntry> {
233
+ async function readPiCodexAuth(): Promise<PiAuthEntry & { accountId: string }> {
243
234
  const auth = await readJsonFile<PiAuthFile>(piAuthPath());
244
235
  const entry = auth[CODEX_PROVIDER];
245
- if (!entry?.access || !entry.accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
246
- return entry;
236
+ const accountId = getCodexAccountId(entry!);
237
+ if (!entry?.access || !accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
238
+ return { ...entry, accountId };
247
239
  }
248
240
 
249
- async function readOpenCodeGoAuth(): Promise<{ key: string }> {
241
+ async function readOpenCodeGoAuth(): Promise<{ key?: string; accountId?: string }> {
250
242
  const auth = await readJsonFile<PiAuthFile>(piAuthPath());
251
243
  const entry = auth[OPC_PROVIDER];
252
- if (!entry?.key || typeof entry.key !== "string") throw new Error("Missing opencode-go API key in Pi auth");
253
- return { key: entry.key };
244
+ if (!entry?.key && !entry?.accountId) throw new Error("Missing opencode-go API key or accountId in Pi auth");
245
+ if (typeof entry.key === "string") return { key: entry.key };
246
+ return { accountId: typeof entry.accountId === "string" ? entry.accountId : undefined };
254
247
  }
255
248
 
256
249
  async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): Promise<UsageApiSnapshot | undefined> {
@@ -260,7 +253,7 @@ async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): P
260
253
  headers: {
261
254
  Accept: "application/json",
262
255
  Authorization: `Bearer ${entry.access}`,
263
- "ChatGPT-Account-Id": entry.accountId!,
256
+ "ChatGPT-Account-Id": getCodexAccountId(entry) ?? entry.accountId,
264
257
  "User-Agent": "pi-sub/0.1.0",
265
258
  },
266
259
  signal: combinedSignal,
@@ -303,19 +296,13 @@ async function fetchCodexUsage(signal?: AbortSignal): Promise<SubscriptionUsageS
303
296
  }
304
297
  }
305
298
 
306
- async function fetchOpenCodeGoUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
299
+ async function fetchOpenCodeGoUsage(_signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
307
300
  try {
308
301
  await readOpenCodeGoAuth();
309
- const account: SubscriptionAccountSnapshot = {
310
- isActive: true,
311
- accountLabel: "OpenCode Go",
312
- lastActivity: "Now",
313
- };
314
302
  return {
315
303
  providerId: OPC_PROVIDER,
316
304
  providerDisplayName: "OpenCode Go",
317
- accounts: [account],
318
- activeAccount: account,
305
+ accounts: [],
319
306
  fetchedAt: Date.now(),
320
307
  };
321
308
  } catch (error) {
@@ -442,11 +429,21 @@ function stopTimer(state: State): void {
442
429
  }
443
430
 
444
431
  function updateActiveAdapter(ctx: ExtensionContext, state: State, model: ModelLike): void {
432
+ const nextAdapter = supportedAdapter(model);
433
+ const adapterChanged = state.adapterId !== nextAdapter?.id;
434
+
445
435
  state.model = model;
446
- state.adapter = supportedAdapter(model);
447
- if (!state.adapter) {
436
+ state.adapter = nextAdapter;
437
+ state.adapterId = nextAdapter?.id;
438
+
439
+ if (adapterChanged) {
448
440
  state.snapshot = undefined;
449
441
  state.lastRefreshAt = 0;
442
+ state.inFlight = undefined;
443
+ state.refreshGeneration++;
444
+ }
445
+
446
+ if (!state.adapter) {
450
447
  stopTimer(state);
451
448
  }
452
449
  renderSubscriptionLine(ctx, state);
@@ -461,15 +458,19 @@ async function refreshUsage(ctx: ExtensionContext, state: State, force: boolean)
461
458
  }
462
459
  if (!force && state.snapshot && Date.now() - state.lastRefreshAt < REFRESH_TTL_MS) return state.snapshot;
463
460
  if (state.inFlight) return state.inFlight;
461
+ const generation = state.refreshGeneration;
464
462
  renderSubscriptionLine(ctx, state);
465
463
  state.inFlight = adapter.fetchUsage(ctx.signal).then((snapshot) => {
464
+ if (state.refreshGeneration !== generation) return snapshot;
466
465
  snapshot.cost = aggregateSessionCost(ctx);
467
466
  state.snapshot = snapshot;
468
467
  state.lastRefreshAt = Date.now();
469
468
  renderSubscriptionLine(ctx, state);
470
469
  return snapshot;
471
470
  }).finally(() => {
472
- state.inFlight = undefined;
471
+ if (state.refreshGeneration === generation) {
472
+ state.inFlight = undefined;
473
+ }
473
474
  });
474
475
  return state.inFlight;
475
476
  }
@@ -491,7 +492,11 @@ function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: St
491
492
  if (!state.adapter) return `Subscription tracking inactive for current model provider (${state.model?.provider ?? "unknown"}).`;
492
493
  if (!snapshot) return "Subscription usage has not been loaded yet.";
493
494
  if (snapshot.error) return `${snapshot.providerDisplayName}: ${snapshot.error}`;
494
- if (snapshot.accounts.length === 0) return `${snapshot.providerDisplayName}: no accounts found.`;
495
+ if (snapshot.accounts.length === 0) {
496
+ const costLine = snapshot.cost !== undefined && snapshot.cost > 0 ? `\nSession cost: $${snapshot.cost.toFixed(2)}` : "";
497
+ const modelInfo = state.model?.id ? ` · Model: ${state.model.id}` : "";
498
+ return `Provider: ${snapshot.providerDisplayName}${modelInfo} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}\n${snapshot.providerDisplayName} does not expose usage windows.${costLine}`;
499
+ }
495
500
 
496
501
  const columns: { key: string; label: string; get: (a: SubscriptionAccountSnapshot) => string }[] = [
497
502
  { key: "account", label: "ACCOUNT", get: (a) => a.accountLabel ?? "unknown" },
@@ -529,7 +534,7 @@ function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: St
529
534
  }
530
535
 
531
536
  export default function (pi: ExtensionAPI) {
532
- const state: State = { lastRefreshAt: 0 };
537
+ const state: State = { lastRefreshAt: 0, refreshGeneration: 0 };
533
538
 
534
539
  pi.registerMessageRenderer(MESSAGE_TYPE, (message, _options, theme) => {
535
540
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,6 +33,7 @@
33
33
  "./index.ts"
34
34
  ]
35
35
  },
36
+ "engines": { "node": ">=20.3.0" },
36
37
  "peerDependencies": {
37
38
  "@earendil-works/pi-coding-agent": "*",
38
39
  "@earendil-works/pi-tui": "*"