@bacnh85/pi-sub 0.1.2 → 0.1.4

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 +3 -3
  2. package/index.ts +194 -42
  3. package/package.json +1 -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 separate subscription line below the editor only while the active Pi model provider is `openai-codex`.
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`.
6
6
 
7
7
  ## Install
8
8
 
@@ -26,7 +26,7 @@ pi -e ./extensions/pi-sub
26
26
 
27
27
  ## What it shows
28
28
 
29
- The footer status includes:
29
+ The footer status appears after Pi's built-in status/token usage line and includes:
30
30
 
31
31
  - active account email;
32
32
  - subscription plan, such as `Plus`;
@@ -54,7 +54,7 @@ When Pi OpenAI Codex auth is available, `/sub` shows the active account usage:
54
54
 
55
55
  ```text
56
56
  ACCOUNT PLAN 5H USAGE WEEKLY USAGE LAST ACTIVITY
57
- * hangdanchi@gmail.com Plus 85% (18:12) 80% (08:00 on 2 Jul) Now
57
+ * user@example.com Plus 85% (18:12) 80% (08:00 on 2 Jul) Now
58
58
  ```
59
59
 
60
60
  ## Refresh behavior
package/index.ts CHANGED
@@ -10,7 +10,8 @@ const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
10
10
  const REFRESH_INTERVAL_MS = 60_000;
11
11
  const REFRESH_TTL_MS = 30_000;
12
12
  const REFRESH_DEBOUNCE_MS = 2_000;
13
- const PI_SUB_PROVIDER = "openai-codex";
13
+ const CODEX_PROVIDER = "openai-codex";
14
+ const OPC_PROVIDER = "opencode-go";
14
15
 
15
16
  type ModelLike = { provider?: string; id?: string } | undefined;
16
17
 
@@ -34,12 +35,15 @@ type PiAuthEntry = {
34
35
  refresh?: string;
35
36
  expires?: number;
36
37
  accountId?: string;
38
+ key?: string;
37
39
  };
38
40
 
39
41
  interface UsageWindow {
40
42
  used?: number;
41
43
  limit?: number;
42
44
  percent?: number;
45
+ remaining?: number;
46
+ remainingLabel?: string;
43
47
  resetLabel?: string;
44
48
  resetsAt?: string | number | Date;
45
49
  label?: string;
@@ -52,6 +56,7 @@ interface SubscriptionAccountSnapshot {
52
56
  plan?: string;
53
57
  fiveHour?: UsageWindow;
54
58
  weekly?: UsageWindow;
59
+ monthly?: UsageWindow;
55
60
  lastActivity?: string;
56
61
  }
57
62
 
@@ -62,6 +67,7 @@ interface SubscriptionUsageSnapshot {
62
67
  activeAccount?: SubscriptionAccountSnapshot;
63
68
  fetchedAt: number;
64
69
  error?: string;
70
+ cost?: number;
65
71
  }
66
72
 
67
73
  interface SubscriptionProviderAdapter {
@@ -83,7 +89,11 @@ interface State {
83
89
 
84
90
  function isCodexModel(model: ModelLike): boolean {
85
91
  const provider = model?.provider?.toLowerCase() ?? "";
86
- return provider === PI_SUB_PROVIDER || provider.includes(PI_SUB_PROVIDER);
92
+ return provider === CODEX_PROVIDER || provider.includes(CODEX_PROVIDER);
93
+ }
94
+
95
+ function isOpenCodeGoModel(model: ModelLike): boolean {
96
+ return (model?.provider?.toLowerCase() ?? "") === OPC_PROVIDER;
87
97
  }
88
98
 
89
99
  function piAuthPath(): string {
@@ -140,6 +150,19 @@ function planLabel(plan: string | undefined): string | undefined {
140
150
  return labels[normalized] ?? plan;
141
151
  }
142
152
 
153
+ function formatRemainingTime(resetAtSec: number | undefined): string | undefined {
154
+ if (!resetAtSec) return undefined;
155
+ const nowSec = Date.now() / 1000;
156
+ const remainingSec = resetAtSec - nowSec;
157
+ if (remainingSec <= 0) return "0M";
158
+ const remainingMin = Math.ceil(remainingSec / 60);
159
+ if (remainingMin < 60) return `${remainingMin}M`;
160
+ const remainingH = Math.ceil(remainingMin / 60);
161
+ if (remainingH < 24) return `${remainingH}H`;
162
+ const remainingD = Math.ceil(remainingH / 24);
163
+ return `${remainingD}D`;
164
+ }
165
+
143
166
  function formatReset(timestampSeconds: number | undefined): string | undefined {
144
167
  if (!timestampSeconds) return undefined;
145
168
  const date = new Date(timestampSeconds * 1000);
@@ -154,12 +177,16 @@ function formatReset(timestampSeconds: number | undefined): string | undefined {
154
177
  function usageWindowFromApi(window: UsageApiWindow | undefined): UsageWindow | undefined {
155
178
  if (!window || typeof window.used_percent !== "number") return undefined;
156
179
  const percent = Math.round(window.used_percent);
180
+ const remaining = Math.max(0, 100 - percent);
157
181
  const resetLabel = formatReset(window.reset_at);
182
+ const remainingLabel = formatRemainingTime(window.reset_at);
158
183
  return {
159
184
  percent,
185
+ remaining,
186
+ remainingLabel,
160
187
  resetLabel,
161
188
  resetsAt: window.reset_at,
162
- label: resetLabel ? `${percent}% (${resetLabel})` : `${percent}%`,
189
+ label: remainingLabel ? `${remaining}% (${remainingLabel})` : `${remaining}%`,
163
190
  };
164
191
  }
165
192
 
@@ -193,13 +220,39 @@ function parseUsageResponse(body: unknown): UsageApiSnapshot | undefined {
193
220
  };
194
221
  }
195
222
 
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
+
196
242
  async function readPiCodexAuth(): Promise<PiAuthEntry> {
197
243
  const auth = await readJsonFile<PiAuthFile>(piAuthPath());
198
- const entry = auth[PI_SUB_PROVIDER];
244
+ const entry = auth[CODEX_PROVIDER];
199
245
  if (!entry?.access || !entry.accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
200
246
  return entry;
201
247
  }
202
248
 
249
+ async function readOpenCodeGoAuth(): Promise<{ key: string }> {
250
+ const auth = await readJsonFile<PiAuthFile>(piAuthPath());
251
+ 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 };
254
+ }
255
+
203
256
  async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): Promise<UsageApiSnapshot | undefined> {
204
257
  const timeoutSignal = AbortSignal.timeout(7_000);
205
258
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
@@ -216,13 +269,14 @@ async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): P
216
269
  return parseUsageResponse(await response.json());
217
270
  }
218
271
 
219
- function redactedError(error: unknown): string {
272
+ function redactedError(error: unknown, provider = "Codex"): string {
220
273
  const message = error instanceof Error ? error.message : String(error || "Unknown error");
221
274
  if (/ENOENT|no such file/i.test(message)) return "Pi auth not found";
222
275
  if (/missing openai-codex/i.test(message)) return "openai-codex auth not found";
223
- if (/timed out|timeout|aborted/i.test(message)) return "Codex usage refresh timed out";
224
- if (/401|403|auth|token|unauthorized|forbidden/i.test(message)) return "Codex auth unavailable";
225
- return "Codex usage unavailable";
276
+ if (/missing opencode-go/i.test(message)) return "opencode-go auth not found";
277
+ if (/timed out|timeout|aborted/i.test(message)) return `${provider} usage refresh timed out`;
278
+ if (/401|403|auth|token|unauthorized|forbidden/i.test(message)) return `${provider} auth unavailable`;
279
+ return `${provider} usage unavailable`;
226
280
  }
227
281
 
228
282
  async function fetchCodexUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
@@ -232,7 +286,7 @@ async function fetchCodexUsage(signal?: AbortSignal): Promise<SubscriptionUsageS
232
286
  const usage = await fetchUsageFromPiAuth(entry, signal);
233
287
  activeAccount = mergeUsageIntoAccount(activeAccount, usage);
234
288
  return {
235
- providerId: PI_SUB_PROVIDER,
289
+ providerId: CODEX_PROVIDER,
236
290
  providerDisplayName: "Codex",
237
291
  accounts: [activeAccount],
238
292
  activeAccount,
@@ -240,7 +294,7 @@ async function fetchCodexUsage(signal?: AbortSignal): Promise<SubscriptionUsageS
240
294
  };
241
295
  } catch (error) {
242
296
  return {
243
- providerId: PI_SUB_PROVIDER,
297
+ providerId: CODEX_PROVIDER,
244
298
  providerDisplayName: "Codex",
245
299
  accounts: [],
246
300
  fetchedAt: Date.now(),
@@ -249,19 +303,75 @@ async function fetchCodexUsage(signal?: AbortSignal): Promise<SubscriptionUsageS
249
303
  }
250
304
  }
251
305
 
306
+ async function fetchOpenCodeGoUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
307
+ try {
308
+ const auth = await readOpenCodeGoAuth();
309
+ const timeoutSignal = AbortSignal.timeout(7_000);
310
+ const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
311
+ const response = await fetch("https://opencode.ai/zen/go/v1/usage", {
312
+ headers: {
313
+ Accept: "application/json",
314
+ Authorization: `Bearer ${auth.key}`,
315
+ "User-Agent": "pi-sub/0.1.4",
316
+ },
317
+ signal: combinedSignal,
318
+ });
319
+ if (!response.ok) throw new Error(`usage request failed with HTTP ${response.status}`);
320
+ const usage = parseOpcodeUsageResponse(await response.json());
321
+ const account: SubscriptionAccountSnapshot = {
322
+ isActive: true,
323
+ accountLabel: "OpenCode Go",
324
+ plan: planLabel(usage?.plan_type),
325
+ fiveHour: usageWindowFromApi(usage?.primary),
326
+ weekly: usageWindowFromApi(usage?.secondary),
327
+ lastActivity: "Now",
328
+ };
329
+ return {
330
+ providerId: OPC_PROVIDER,
331
+ providerDisplayName: "OpenCode Go",
332
+ accounts: [account],
333
+ activeAccount: account,
334
+ fetchedAt: Date.now(),
335
+ };
336
+ } catch (error) {
337
+ return {
338
+ providerId: OPC_PROVIDER,
339
+ providerDisplayName: "OpenCode Go",
340
+ accounts: [],
341
+ fetchedAt: Date.now(),
342
+ error: redactedError(error, "OpenCode Go"),
343
+ };
344
+ }
345
+ }
346
+
252
347
  const codexAdapter: SubscriptionProviderAdapter = {
253
- id: PI_SUB_PROVIDER,
348
+ id: CODEX_PROVIDER,
254
349
  displayName: "Codex",
255
350
  isModelSupported: isCodexModel,
256
351
  fetchUsage: fetchCodexUsage,
257
352
  };
258
353
 
259
- const adapters = [codexAdapter];
354
+ const openCodeGoAdapter: SubscriptionProviderAdapter = {
355
+ id: OPC_PROVIDER,
356
+ displayName: "OpenCode Go",
357
+ isModelSupported: isOpenCodeGoModel,
358
+ fetchUsage: fetchOpenCodeGoUsage,
359
+ };
360
+
361
+ const adapters = [codexAdapter, openCodeGoAdapter];
260
362
 
261
363
  function supportedAdapter(model: ModelLike): SubscriptionProviderAdapter | undefined {
262
364
  return adapters.find((adapter) => adapter.isModelSupported(model));
263
365
  }
264
366
 
367
+ function formatRemaining(window: UsageWindow | undefined): string {
368
+ if (!window) return "?";
369
+ if (window.remaining !== undefined && window.remainingLabel) return `${window.remaining}%/${window.remainingLabel}`;
370
+ if (window.remaining !== undefined) return `${window.remaining}%`;
371
+ if (window.percent !== undefined && window.remainingLabel) return `${Math.max(0, 100 - window.percent)}%/${window.remainingLabel}`;
372
+ return "?";
373
+ }
374
+
265
375
  function formatWindow(window: UsageWindow | undefined, _compact = true): string {
266
376
  if (!window) return "?";
267
377
  if (window.percent !== undefined && window.resetLabel) return `${window.percent}% (${window.resetLabel})`;
@@ -271,37 +381,62 @@ function formatWindow(window: UsageWindow | undefined, _compact = true): string
271
381
  return "?";
272
382
  }
273
383
 
274
- function maxPercent(account: SubscriptionAccountSnapshot | undefined): number {
275
- return Math.max(account?.fiveHour?.percent ?? 0, account?.weekly?.percent ?? 0);
384
+ function minRemaining(account: SubscriptionAccountSnapshot | undefined): number {
385
+ const values: number[] = [];
386
+ if (account?.fiveHour?.remaining !== undefined) values.push(account.fiveHour.remaining);
387
+ if (account?.weekly?.remaining !== undefined) values.push(account.weekly.remaining);
388
+ if (account?.monthly?.remaining !== undefined) values.push(account.monthly.remaining);
389
+ if (values.length === 0) return 100;
390
+ return Math.min(...values);
391
+ }
392
+
393
+ function windowSegments(account: SubscriptionAccountSnapshot | undefined): string[] {
394
+ if (!account) return [];
395
+ const segments: string[] = [];
396
+ if (account.fiveHour) segments.push(`R:${formatRemaining(account.fiveHour)}`);
397
+ if (account.weekly) segments.push(`W:${formatRemaining(account.weekly)}`);
398
+ if (account.monthly) segments.push(`M:${formatRemaining(account.monthly)}`);
399
+ return segments;
400
+ }
401
+
402
+ function aggregateSessionCost(ctx: ExtensionContext): number {
403
+ let total = 0;
404
+ for (const entry of ctx.sessionManager.getBranch()) {
405
+ if (entry.type === "message" && entry.message.role === "assistant") {
406
+ total += (entry.message.usage as any)?.cost?.total ?? 0;
407
+ }
408
+ }
409
+ return total;
276
410
  }
277
411
 
278
412
  function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
279
413
  if (!state.adapter) {
280
- ctx.ui.setWidget(STATUS_KEY, undefined);
414
+ ctx.ui.setStatus(STATUS_KEY, undefined);
281
415
  return;
282
416
  }
283
417
  const theme = ctx.ui.theme;
284
418
  const snapshot = state.snapshot;
285
- const modelId = state.model?.id ?? "unknown-model";
286
419
  let line: string;
287
420
  let color: "dim" | "warning" | "error" = "dim";
288
421
  if (!snapshot) {
289
- line = `Sub ${state.adapter.displayName} loading · ${modelId}`;
422
+ line = `Sub ${state.adapter.displayName} loading`;
290
423
  } else if (snapshot.error) {
291
- line = `Sub ${snapshot.error} · ${modelId}`;
424
+ line = `Sub ${snapshot.error}`;
292
425
  color = "warning";
293
426
  } else {
294
427
  const account = snapshot.activeAccount;
295
- const pieces = ["Sub"];
296
- if (account?.plan) pieces.push(account.plan);
297
- pieces.push(account?.accountLabel ?? "unknown account");
298
- pieces.push(`5H ${formatWindow(account?.fiveHour)}`);
299
- pieces.push(`W ${formatWindow(account?.weekly)}`);
300
- pieces.push(modelId);
301
- line = pieces.join(" · ");
302
- color = maxPercent(account) >= 90 ? "error" : maxPercent(account) >= 80 ? "warning" : "dim";
428
+ const segments = windowSegments(account);
429
+ const cost = snapshot.cost;
430
+ if (cost !== undefined && cost > 0) segments.push(`$${cost.toFixed(2)}`);
431
+ if (segments.length === 0) {
432
+ line = `Sub ${state.adapter.displayName}`;
433
+ } else {
434
+ line = segments.join(" ");
435
+ }
436
+ const remaining = minRemaining(account);
437
+ color = remaining <= 10 ? "error" : remaining <= 20 ? "warning" : "dim";
303
438
  }
304
- ctx.ui.setWidget(STATUS_KEY, [theme.fg(color, line)], { placement: "belowEditor" });
439
+ ctx.ui.setStatus(STATUS_KEY, theme.fg(color, line));
305
440
  }
306
441
 
307
442
  function startTimer(ctx: ExtensionContext, state: State): void {
@@ -340,6 +475,7 @@ async function refreshUsage(ctx: ExtensionContext, state: State, force: boolean)
340
475
  if (state.inFlight) return state.inFlight;
341
476
  renderSubscriptionLine(ctx, state);
342
477
  state.inFlight = adapter.fetchUsage(ctx.signal).then((snapshot) => {
478
+ snapshot.cost = aggregateSessionCost(ctx);
343
479
  state.snapshot = snapshot;
344
480
  state.lastRefreshAt = Date.now();
345
481
  renderSubscriptionLine(ctx, state);
@@ -368,24 +504,40 @@ function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: St
368
504
  if (!snapshot) return "Subscription usage has not been loaded yet.";
369
505
  if (snapshot.error) return `${snapshot.providerDisplayName}: ${snapshot.error}`;
370
506
  if (snapshot.accounts.length === 0) return `${snapshot.providerDisplayName}: no accounts found.`;
507
+
508
+ const columns: { key: string; label: string; get: (a: SubscriptionAccountSnapshot) => string }[] = [
509
+ { key: "account", label: "ACCOUNT", get: (a) => a.accountLabel ?? "unknown" },
510
+ { key: "plan", label: "PLAN", get: (a) => a.plan ?? "?" },
511
+ ];
512
+
513
+ const hasFiveHour = snapshot.accounts.some((a) => a.fiveHour);
514
+ const hasWeekly = snapshot.accounts.some((a) => a.weekly);
515
+ const hasMonthly = snapshot.accounts.some((a) => a.monthly);
516
+
517
+ if (hasFiveHour) columns.push({ key: "five", label: "ROLLING", get: (a) => formatRemaining(a.fiveHour) });
518
+ if (hasWeekly) columns.push({ key: "weekly", label: "WEEKLY", get: (a) => formatRemaining(a.weekly) });
519
+ if (hasMonthly) columns.push({ key: "monthly", label: "MONTHLY", get: (a) => formatRemaining(a.monthly) });
520
+
371
521
  const rows = snapshot.accounts.map((account) => ({
372
522
  active: account.isActive ? "*" : " ",
373
- account: account.accountLabel ?? "unknown",
374
- plan: account.plan ?? "?",
375
- five: formatWindow(account.fiveHour, false),
376
- weekly: formatWindow(account.weekly, false),
377
- activity: account.lastActivity ?? "",
523
+ snapshot: account,
378
524
  }));
379
- const widths = {
380
- account: Math.max("ACCOUNT".length, ...rows.map((row) => row.account.length)),
381
- plan: Math.max("PLAN".length, ...rows.map((row) => row.plan.length)),
382
- five: Math.max("5H USAGE".length, ...rows.map((row) => row.five.length)),
383
- weekly: Math.max("WEEKLY USAGE".length, ...rows.map((row) => row.weekly.length)),
384
- };
385
- const header = ` ${pad("ACCOUNT", widths.account)} ${pad("PLAN", widths.plan)} ${pad("5H USAGE", widths.five)} ${pad("WEEKLY USAGE", widths.weekly)} LAST ACTIVITY`;
525
+
526
+ const widths: Record<string, number> = {};
527
+ for (const col of columns) {
528
+ widths[col.key] = Math.max(col.label.length, ...snapshot.accounts.map((a) => col.get(a).length));
529
+ }
530
+
531
+ const headerCols = columns.map((c) => pad(c.label, widths[c.key]));
532
+ const header = ` ${headerCols.join(" ")} LAST ACTIVITY`;
386
533
  const sep = "-".repeat(header.length);
387
- const body = rows.map((row) => `${row.active} ${pad(row.account, widths.account)} ${pad(row.plan, widths.plan)} ${pad(row.five, widths.five)} ${pad(row.weekly, widths.weekly)} ${row.activity}`);
388
- return [`Provider: ${snapshot.providerDisplayName} · Model: ${state.model?.id ?? "unknown-model"} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}`, "", header, sep, ...body].join("\n");
534
+ const body = rows.map((row) => {
535
+ const cols = columns.map((c) => pad(c.get(row.snapshot), widths[c.key]));
536
+ return `${row.active} ${cols.join(" ")} ${row.snapshot.lastActivity ?? ""}`;
537
+ });
538
+
539
+ const costLine = snapshot.cost !== undefined ? `\nSession cost: $${snapshot.cost.toFixed(2)}` : "";
540
+ return [`Provider: ${snapshot.providerDisplayName} · Model: ${state.model?.id ?? "unknown-model"} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}${costLine}`, "", header, sep, ...body].join("\n");
389
541
  }
390
542
 
391
543
  export default function (pi: ExtensionAPI) {
@@ -413,7 +565,7 @@ export default function (pi: ExtensionAPI) {
413
565
 
414
566
  pi.on("session_shutdown", async (_event, ctx) => {
415
567
  stopTimer(state);
416
- ctx.ui.setWidget(STATUS_KEY, undefined);
568
+ ctx.ui.setStatus(STATUS_KEY, undefined);
417
569
  });
418
570
 
419
571
  pi.registerCommand("sub", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",