@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.
- package/README.md +23 -7
- package/index.ts +44 -39
- 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
|
-
|
|
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
|
|
78
|
-
- `pi-sub` redacts auth/token-related errors and never prints
|
|
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.
|
|
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
|
|
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
|
-
|
|
246
|
-
|
|
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
|
|
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
|
|
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(
|
|
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: [
|
|
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 =
|
|
447
|
-
|
|
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.
|
|
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)
|
|
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.
|
|
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": "*"
|