@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.
- package/index.ts +179 -39
- 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
|
|
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 ===
|
|
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:
|
|
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[
|
|
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 (/
|
|
224
|
-
if (/
|
|
225
|
-
return
|
|
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:
|
|
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:
|
|
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:
|
|
333
|
+
id: CODEX_PROVIDER,
|
|
254
334
|
displayName: "Codex",
|
|
255
335
|
isModelSupported: isCodexModel,
|
|
256
336
|
fetchUsage: fetchCodexUsage,
|
|
257
337
|
};
|
|
258
338
|
|
|
259
|
-
const
|
|
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
|
|
275
|
-
|
|
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
|
|
407
|
+
line = `Sub ${state.adapter.displayName} loading`;
|
|
290
408
|
} else if (snapshot.error) {
|
|
291
|
-
line = `Sub ${snapshot.error}
|
|
409
|
+
line = `Sub ${snapshot.error}`;
|
|
292
410
|
color = "warning";
|
|
293
411
|
} else {
|
|
294
412
|
const account = snapshot.activeAccount;
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
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) =>
|
|
388
|
-
|
|
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) {
|