@bacnh85/pi-sub 0.1.3 → 0.1.5

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/index.ts +179 -39
  2. package/package.json +1 -1
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,60 @@ async function fetchCodexUsage(signal?: AbortSignal): Promise<SubscriptionUsageS
249
303
  }
250
304
  }
251
305
 
306
+ async function fetchOpenCodeGoUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
307
+ try {
308
+ await readOpenCodeGoAuth();
309
+ const account: SubscriptionAccountSnapshot = {
310
+ isActive: true,
311
+ accountLabel: "OpenCode Go",
312
+ lastActivity: "Now",
313
+ };
314
+ return {
315
+ providerId: OPC_PROVIDER,
316
+ providerDisplayName: "OpenCode Go",
317
+ accounts: [account],
318
+ activeAccount: account,
319
+ fetchedAt: Date.now(),
320
+ };
321
+ } catch (error) {
322
+ return {
323
+ providerId: OPC_PROVIDER,
324
+ providerDisplayName: "OpenCode Go",
325
+ accounts: [],
326
+ fetchedAt: Date.now(),
327
+ error: redactedError(error, "OpenCode Go"),
328
+ };
329
+ }
330
+ }
331
+
252
332
  const codexAdapter: SubscriptionProviderAdapter = {
253
- id: PI_SUB_PROVIDER,
333
+ id: CODEX_PROVIDER,
254
334
  displayName: "Codex",
255
335
  isModelSupported: isCodexModel,
256
336
  fetchUsage: fetchCodexUsage,
257
337
  };
258
338
 
259
- const adapters = [codexAdapter];
339
+ const openCodeGoAdapter: SubscriptionProviderAdapter = {
340
+ id: OPC_PROVIDER,
341
+ displayName: "OpenCode Go",
342
+ isModelSupported: isOpenCodeGoModel,
343
+ fetchUsage: fetchOpenCodeGoUsage,
344
+ };
345
+
346
+ const adapters = [codexAdapter, openCodeGoAdapter];
260
347
 
261
348
  function supportedAdapter(model: ModelLike): SubscriptionProviderAdapter | undefined {
262
349
  return adapters.find((adapter) => adapter.isModelSupported(model));
263
350
  }
264
351
 
352
+ function formatRemaining(window: UsageWindow | undefined): string {
353
+ if (!window) return "?";
354
+ if (window.remaining !== undefined && window.remainingLabel) return `${window.remaining}%/${window.remainingLabel}`;
355
+ if (window.remaining !== undefined) return `${window.remaining}%`;
356
+ if (window.percent !== undefined && window.remainingLabel) return `${Math.max(0, 100 - window.percent)}%/${window.remainingLabel}`;
357
+ return "?";
358
+ }
359
+
265
360
  function formatWindow(window: UsageWindow | undefined, _compact = true): string {
266
361
  if (!window) return "?";
267
362
  if (window.percent !== undefined && window.resetLabel) return `${window.percent}% (${window.resetLabel})`;
@@ -271,8 +366,32 @@ function formatWindow(window: UsageWindow | undefined, _compact = true): string
271
366
  return "?";
272
367
  }
273
368
 
274
- function maxPercent(account: SubscriptionAccountSnapshot | undefined): number {
275
- return Math.max(account?.fiveHour?.percent ?? 0, account?.weekly?.percent ?? 0);
369
+ function minRemaining(account: SubscriptionAccountSnapshot | undefined): number {
370
+ const values: number[] = [];
371
+ if (account?.fiveHour?.remaining !== undefined) values.push(account.fiveHour.remaining);
372
+ if (account?.weekly?.remaining !== undefined) values.push(account.weekly.remaining);
373
+ if (account?.monthly?.remaining !== undefined) values.push(account.monthly.remaining);
374
+ if (values.length === 0) return 100;
375
+ return Math.min(...values);
376
+ }
377
+
378
+ function windowSegments(account: SubscriptionAccountSnapshot | undefined): string[] {
379
+ if (!account) return [];
380
+ const segments: string[] = [];
381
+ if (account.fiveHour) segments.push(`R:${formatRemaining(account.fiveHour)}`);
382
+ if (account.weekly) segments.push(`W:${formatRemaining(account.weekly)}`);
383
+ if (account.monthly) segments.push(`M:${formatRemaining(account.monthly)}`);
384
+ return segments;
385
+ }
386
+
387
+ function aggregateSessionCost(ctx: ExtensionContext): number {
388
+ let total = 0;
389
+ for (const entry of ctx.sessionManager.getBranch()) {
390
+ if (entry.type === "message" && entry.message.role === "assistant") {
391
+ total += (entry.message.usage as any)?.cost?.total ?? 0;
392
+ }
393
+ }
394
+ return total;
276
395
  }
277
396
 
278
397
  function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
@@ -282,24 +401,28 @@ function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
282
401
  }
283
402
  const theme = ctx.ui.theme;
284
403
  const snapshot = state.snapshot;
285
- const modelId = state.model?.id ?? "unknown-model";
286
404
  let line: string;
287
405
  let color: "dim" | "warning" | "error" = "dim";
288
406
  if (!snapshot) {
289
- line = `Sub ${state.adapter.displayName} loading · ${modelId}`;
407
+ line = `Sub ${state.adapter.displayName} loading`;
290
408
  } else if (snapshot.error) {
291
- line = `Sub ${snapshot.error} · ${modelId}`;
409
+ line = `Sub ${snapshot.error}`;
292
410
  color = "warning";
293
411
  } else {
294
412
  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";
413
+ const segments = windowSegments(account);
414
+ const cost = snapshot.cost;
415
+ const hasWindows = segments.length > 0;
416
+ if (cost !== undefined && cost > 0) segments.push(`$${cost.toFixed(2)}`);
417
+ if (segments.length === 0) {
418
+ line = `Sub ${state.adapter.displayName}`;
419
+ } else if (!hasWindows) {
420
+ line = `${state.adapter.displayName} ${segments.join(" ")}`;
421
+ } else {
422
+ line = segments.join(" ");
423
+ }
424
+ const remaining = minRemaining(account);
425
+ color = remaining <= 10 ? "error" : remaining <= 20 ? "warning" : "dim";
303
426
  }
304
427
  ctx.ui.setStatus(STATUS_KEY, theme.fg(color, line));
305
428
  }
@@ -340,6 +463,7 @@ async function refreshUsage(ctx: ExtensionContext, state: State, force: boolean)
340
463
  if (state.inFlight) return state.inFlight;
341
464
  renderSubscriptionLine(ctx, state);
342
465
  state.inFlight = adapter.fetchUsage(ctx.signal).then((snapshot) => {
466
+ snapshot.cost = aggregateSessionCost(ctx);
343
467
  state.snapshot = snapshot;
344
468
  state.lastRefreshAt = Date.now();
345
469
  renderSubscriptionLine(ctx, state);
@@ -368,24 +492,40 @@ function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: St
368
492
  if (!snapshot) return "Subscription usage has not been loaded yet.";
369
493
  if (snapshot.error) return `${snapshot.providerDisplayName}: ${snapshot.error}`;
370
494
  if (snapshot.accounts.length === 0) return `${snapshot.providerDisplayName}: no accounts found.`;
495
+
496
+ const columns: { key: string; label: string; get: (a: SubscriptionAccountSnapshot) => string }[] = [
497
+ { key: "account", label: "ACCOUNT", get: (a) => a.accountLabel ?? "unknown" },
498
+ { key: "plan", label: "PLAN", get: (a) => a.plan ?? "?" },
499
+ ];
500
+
501
+ const hasFiveHour = snapshot.accounts.some((a) => a.fiveHour);
502
+ const hasWeekly = snapshot.accounts.some((a) => a.weekly);
503
+ const hasMonthly = snapshot.accounts.some((a) => a.monthly);
504
+
505
+ if (hasFiveHour) columns.push({ key: "five", label: "ROLLING", get: (a) => formatRemaining(a.fiveHour) });
506
+ if (hasWeekly) columns.push({ key: "weekly", label: "WEEKLY", get: (a) => formatRemaining(a.weekly) });
507
+ if (hasMonthly) columns.push({ key: "monthly", label: "MONTHLY", get: (a) => formatRemaining(a.monthly) });
508
+
371
509
  const rows = snapshot.accounts.map((account) => ({
372
510
  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 ?? "",
511
+ snapshot: account,
378
512
  }));
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`;
513
+
514
+ const widths: Record<string, number> = {};
515
+ for (const col of columns) {
516
+ widths[col.key] = Math.max(col.label.length, ...snapshot.accounts.map((a) => col.get(a).length));
517
+ }
518
+
519
+ const headerCols = columns.map((c) => pad(c.label, widths[c.key]));
520
+ const header = ` ${headerCols.join(" ")} LAST ACTIVITY`;
386
521
  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");
522
+ const body = rows.map((row) => {
523
+ const cols = columns.map((c) => pad(c.get(row.snapshot), widths[c.key]));
524
+ return `${row.active} ${cols.join(" ")} ${row.snapshot.lastActivity ?? ""}`;
525
+ });
526
+
527
+ const costLine = snapshot.cost !== undefined ? `\nSession cost: $${snapshot.cost.toFixed(2)}` : "";
528
+ return [`Provider: ${snapshot.providerDisplayName} · Model: ${state.model?.id ?? "unknown-model"} · Fetched: ${new Date(snapshot.fetchedAt).toLocaleTimeString()}${costLine}`, "", header, sep, ...body].join("\n");
389
529
  }
390
530
 
391
531
  export default function (pi: ExtensionAPI) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",