@nothumanwork/pi-usage 0.0.0-stage → 0.1.0
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 +115 -2
- package/index.ts +58 -0
- package/package.json +48 -4
- package/src/format.ts +66 -0
- package/src/providers/index.ts +7 -0
- package/src/providers/openai.ts +181 -0
- package/src/service.ts +38 -0
- package/src/types.ts +48 -0
- package/src/view.ts +121 -0
package/README.md
CHANGED
|
@@ -1,3 +1,116 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pi Usage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Account usage limits in Pi, starting with **OpenAI ChatGPT via the Codex login**.
|
|
4
|
+
|
|
5
|
+
Run `/usage` for a scrollable view of the account, plan, percentage remaining, reset times, and credits reported by OpenAI. Bars fill with **remaining** allowance, as in Codex—not tokens consumed in the current Pi session.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
From this checkout, after installing dependencies at the repository root:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pi install /path/to/pi-extensions/pi-usage
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then restart Pi or run `/reload`. For a one-session preview:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pi -e /path/to/pi-extensions/pi-usage/index.ts
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
After publication:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pi install npm:@nothumanwork/pi-usage
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires Pi 1.4.0's extension/auth APIs and Node.js 22.19+. No background polling, configuration file, or model tool is installed.
|
|
28
|
+
|
|
29
|
+
## Sign in
|
|
30
|
+
|
|
31
|
+
Keep using **`/login openai` → Sign in with ChatGPT** for inference. Add the separate credential used by Codex to read usage:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
/login openai-codex
|
|
35
|
+
/usage
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Choose the **same ChatGPT account/workspace** for both logins. The usage view displays the account belonging to the Codex credential; it does not automatically verify that it matches your inference account. Signing into the Codex provider does not require changing your selected model.
|
|
39
|
+
|
|
40
|
+
Pi owns credential storage, refresh, and refresh-token locking. This extension does not create another OAuth client, copy tokens, read `~/.codex/auth.json`, or use browser cookies. An API key is not a fallback.
|
|
41
|
+
|
|
42
|
+
### Why the second login?
|
|
43
|
+
|
|
44
|
+
The newer Sign in with ChatGPT token-sharing login does not currently expose Codex-style account usage. Its token is not accepted by Codex's usage endpoint. See [OpenAI issue #54219](https://github.com/openai/codex/issues/54219) and [OpenAI's errors and recovery guide](https://developers.openai.com/siwc/token-sharing-open-source/errors-and-recovery).
|
|
45
|
+
|
|
46
|
+
This extension reads **account allowance through Codex**. It deliberately **does not include Pi-specific app caps** on the newer login. An app-specific cap can therefore block Pi even when the account view shows allowance remaining. No allowance is inferred from session token counts or token expiry.
|
|
47
|
+
|
|
48
|
+
## Commands and controls
|
|
49
|
+
|
|
50
|
+
| Command / key | Action |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `/usage` | Fetch all supported providers |
|
|
53
|
+
| `/usage openai` | Fetch only OpenAI |
|
|
54
|
+
| `r` | Fetch a fresh snapshot |
|
|
55
|
+
| Up / Down | Scroll |
|
|
56
|
+
| Page Up / Page Down | Scroll a page |
|
|
57
|
+
| Enter / Escape / `q` | Close; cancel pending work |
|
|
58
|
+
|
|
59
|
+
The terminal view is an overlay. RPC clients receive the same information as a notification; run the command again to refresh. Print/JSON mode has no account-usage UI. Nothing is sent to the model or appended to session history.
|
|
60
|
+
|
|
61
|
+
The view includes:
|
|
62
|
+
|
|
63
|
+
- Codex account identity, plan, source, and fetch time.
|
|
64
|
+
- Usage windows returned by the server, including five-hour, weekly, and additional model limits when present.
|
|
65
|
+
- Local reset date/time and a relative duration.
|
|
66
|
+
- Credit balance or unlimited-credit status when reported. Balances are not assumed to be dollars.
|
|
67
|
+
- A link to [ChatGPT Settings → Usage](https://chatgpt.com/settings/usage).
|
|
68
|
+
|
|
69
|
+
Primary does **not** necessarily mean five-hour: some plans only report a weekly window. Labels follow the server's window duration. Missing/null values are shown as unknown, not zero usage or unlimited allowance. Expired reset timestamps prompt a refresh rather than inventing a replenished quota. Failed refreshes clear the previous snapshot.
|
|
70
|
+
|
|
71
|
+
Requests happen only when opening or refreshing the view, with no automatic retries. A 15-second overall timeout bounds the wait, including auth resolution; closing the view cancels the usage request. Pi may finish its own already-started credential refresh independently. One failed provider does not suppress other providers.
|
|
72
|
+
|
|
73
|
+
## Implementation and extension points
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
index.ts Command registration and mode handling
|
|
77
|
+
src/types.ts Provider-neutral snapshots, windows, groups and errors
|
|
78
|
+
src/service.ts Independent provider collection, cancellation and timeouts
|
|
79
|
+
src/providers/index.ts Adapter registration
|
|
80
|
+
src/providers/openai.ts Pi auth + Codex usage adapter and normalization
|
|
81
|
+
src/format.ts Shared text presentation
|
|
82
|
+
src/view.ts Pi TUI overlay, refresh and scrolling
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
To add a provider:
|
|
86
|
+
|
|
87
|
+
1. Implement `UsageProvider` from `src/types.ts`.
|
|
88
|
+
2. Resolve credentials through Pi and return a normalized `UsageSnapshot`.
|
|
89
|
+
3. Use `UsageError` for safe, actionable error messages. Never include tokens, raw auth errors, or response bodies.
|
|
90
|
+
4. Add the adapter to `createUsageProviders()` in `src/providers/index.ts` and add fixture-based tests.
|
|
91
|
+
|
|
92
|
+
No changes to the command or view should be needed. Other extensions are not dynamically discovered as usage plugins in this first version.
|
|
93
|
+
|
|
94
|
+
### SDKs and endpoint stability
|
|
95
|
+
|
|
96
|
+
- **Pi SDK:** provider authentication and refresh; commands and UI.
|
|
97
|
+
- **OpenAI JavaScript SDK:** HTTP transport, error classification, timeouts and cancellation.
|
|
98
|
+
- **jose:** decode account claims for routing/display (the server validates the bearer token).
|
|
99
|
+
- **Zod:** validate and normalize the usage response.
|
|
100
|
+
|
|
101
|
+
The adapter uses the SDK's generic `get()` method because there is no typed JavaScript SDK quota method. It reads `GET https://chatgpt.com/backend-api/wham/usage`, the internal endpoint used by [Codex's backend client](https://github.com/openai/codex/tree/main/codex-rs/backend-client). This is **not a stable public billing API** and may change. The adapter is isolated for that reason.
|
|
102
|
+
|
|
103
|
+
Credentials are sent only to the fixed ChatGPT endpoint. Redirects and SDK logging are disabled. Requests do not use API billing organization/project settings. Account metadata is escaped for terminal display. No token or usage cache is written to disk.
|
|
104
|
+
|
|
105
|
+
## Develop
|
|
106
|
+
|
|
107
|
+
Run from the repository root:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
npm install --ignore-scripts
|
|
111
|
+
npm run check --workspace @nothumanwork/pi-usage
|
|
112
|
+
npm test --workspace @nothumanwork/pi-usage
|
|
113
|
+
npm pack --workspace @nothumanwork/pi-usage --dry-run
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Tests mock the network and credentials, exercise the real OpenAI SDK transport and Pi extension loader, and cover normalization, errors, cancellation, multiple providers, narrow terminals, refresh, and scrolling. They do not consume a ChatGPT allowance or verify a real account's live quota.
|
package/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { formatUsage } from "./src/format.ts";
|
|
3
|
+
import { createUsageProviders } from "./src/providers/index.ts";
|
|
4
|
+
import { collectUsage } from "./src/service.ts";
|
|
5
|
+
import type { UsageProvider } from "./src/types.ts";
|
|
6
|
+
import { UsageView } from "./src/view.ts";
|
|
7
|
+
|
|
8
|
+
export function registerUsage(pi: ExtensionAPI, providers: readonly UsageProvider[]): void {
|
|
9
|
+
let active: AbortController | undefined;
|
|
10
|
+
pi.on("session_shutdown", () => active?.abort());
|
|
11
|
+
|
|
12
|
+
pi.registerCommand("usage", {
|
|
13
|
+
description: "Show account usage limits and reset times (/usage [provider])",
|
|
14
|
+
getArgumentCompletions: (prefix) => providers
|
|
15
|
+
.filter((provider) => provider.id.startsWith(prefix.toLowerCase()))
|
|
16
|
+
.map((provider) => ({ value: provider.id, label: provider.label })),
|
|
17
|
+
handler: async (args, ctx) => {
|
|
18
|
+
if (!ctx.hasUI) {
|
|
19
|
+
ctx.ui.notify("/usage requires interactive Pi or an RPC client with notifications.", "warning");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const id = args.trim().toLowerCase();
|
|
23
|
+
const selected = id ? providers.filter((provider) => provider.id === id) : providers;
|
|
24
|
+
if (!selected.length) {
|
|
25
|
+
ctx.ui.notify(`Usage: /usage [${providers.map((provider) => provider.id).join("|")}]`, "warning");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (active) {
|
|
29
|
+
ctx.ui.notify("An account usage view is already open.", "info");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
active = controller;
|
|
34
|
+
try {
|
|
35
|
+
if (ctx.mode === "tui") {
|
|
36
|
+
await ctx.ui.custom<void>((tui, theme, keys, done) => {
|
|
37
|
+
const view = new UsageView(tui, theme, keys, () => done(), (signal) =>
|
|
38
|
+
collectUsage(selected, ctx.modelRegistry, AbortSignal.any([signal, controller.signal])));
|
|
39
|
+
controller.signal.addEventListener("abort", () => view.close(), { once: true });
|
|
40
|
+
return view;
|
|
41
|
+
}, { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%" } });
|
|
42
|
+
} else {
|
|
43
|
+
const results = await collectUsage(selected, ctx.modelRegistry, controller.signal);
|
|
44
|
+
if (!controller.signal.aborted) ctx.ui.notify(formatUsage(results), "info");
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
if (!controller.signal.aborted) ctx.ui.notify("Could not display account usage. Try /usage again.", "error");
|
|
48
|
+
} finally {
|
|
49
|
+
controller.abort();
|
|
50
|
+
if (active === controller) active = undefined;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default function usageExtension(pi: ExtensionAPI): void {
|
|
57
|
+
registerUsage(pi, createUsageProviders());
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nothumanwork/pi-usage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Account usage limits and reset times in Pi, starting with OpenAI ChatGPT via Codex login",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22.19.0"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/thehumanworks/pi-extensions.git",
|
|
18
|
+
"directory": "pi-usage"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"index.ts",
|
|
22
|
+
"src",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./index.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"check": "tsc --noEmit",
|
|
32
|
+
"test": "tsx --test test/*.test.ts"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"jose": "^6.2.14",
|
|
36
|
+
"openai": "^7.19.0",
|
|
37
|
+
"zod": "^4.6.7"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
41
|
+
"@earendil-works/pi-tui": "*"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
45
|
+
"@earendil-works/pi-tui": "1.4.0",
|
|
46
|
+
"@types/node": "^22.19.0",
|
|
47
|
+
"tsx": "^4.20.0",
|
|
48
|
+
"typescript": "^5.9.3"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import type { UsageResult, UsageWindow } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** Provider labels and account metadata are data, not terminal control sequences. */
|
|
5
|
+
export function safeText(value: string): string {
|
|
6
|
+
return stripVTControlCharacters(value)
|
|
7
|
+
.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, " ")
|
|
8
|
+
.slice(0, 240);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatReset(resetsAt: number | undefined, now: number): string {
|
|
12
|
+
if (resetsAt === undefined) return "Reset time not reported";
|
|
13
|
+
if (resetsAt <= now) return "Reset time passed — refresh to update";
|
|
14
|
+
const minutes = Math.max(1, Math.ceil((resetsAt - now) / 60_000));
|
|
15
|
+
const days = Math.floor(minutes / 1440);
|
|
16
|
+
const hours = Math.floor((minutes % 1440) / 60);
|
|
17
|
+
const mins = minutes % 60;
|
|
18
|
+
const relative = [days ? `${days}d` : "", hours ? `${hours}h` : "", mins ? `${mins}m` : ""].filter(Boolean).join(" ");
|
|
19
|
+
const date = new Date(resetsAt).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short" });
|
|
20
|
+
return `Resets in ${relative} · ${date}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatWindow(window: UsageWindow, now: number): string[] {
|
|
24
|
+
const label = safeText(window.label);
|
|
25
|
+
if (window.remainingPercent === undefined) return [`${label}: Not reported`, ` ${formatReset(window.resetsAt, now)}`];
|
|
26
|
+
const left = Math.max(0, Math.min(100, window.remainingPercent));
|
|
27
|
+
const filled = Math.round(left / 5);
|
|
28
|
+
const bar = "█".repeat(filled) + "░".repeat(20 - filled);
|
|
29
|
+
return [
|
|
30
|
+
`${label}: [${bar}] ${Math.round(left * 10) / 10}% left`,
|
|
31
|
+
` ${formatReset(window.resetsAt, now)}`,
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatUsage(results: readonly UsageResult[], now = Date.now()): string {
|
|
36
|
+
return results.map((result) => {
|
|
37
|
+
const lines = [safeText(result.provider.label)];
|
|
38
|
+
if (result.status !== "ok") {
|
|
39
|
+
lines.push(`${result.status === "unavailable" ? "Unavailable" : "Error"}: ${safeText(result.message)}`);
|
|
40
|
+
} else {
|
|
41
|
+
const data = result.snapshot;
|
|
42
|
+
lines.push(`Account: ${safeText(data.account)}`);
|
|
43
|
+
if (data.plan) lines.push(`Plan: ${safeText(data.plan)}`);
|
|
44
|
+
lines.push(`Source: ${safeText(data.source)}`);
|
|
45
|
+
lines.push(`Checked: ${new Date(data.fetchedAt).toLocaleString()}`);
|
|
46
|
+
if (!data.groups.length) lines.push("", "No allowance windows reported (not necessarily unlimited).");
|
|
47
|
+
for (const group of data.groups) {
|
|
48
|
+
lines.push("", safeText(group.label));
|
|
49
|
+
if (group.limitReached) lines.push("Limit reached / usage currently restricted");
|
|
50
|
+
if (!group.windows.length) lines.push("No allowance windows reported (not necessarily unlimited).");
|
|
51
|
+
for (const window of group.windows) lines.push(...formatWindow(window, now));
|
|
52
|
+
}
|
|
53
|
+
if (data.credits) {
|
|
54
|
+
const credits = data.credits;
|
|
55
|
+
const balance = credits.unlimited ? "Unlimited"
|
|
56
|
+
: credits.balance != null ? safeText(credits.balance)
|
|
57
|
+
: credits.hasCredits === false ? "None"
|
|
58
|
+
: credits.hasCredits === true ? "Available (balance not reported)" : "Not reported";
|
|
59
|
+
lines.push("", `Credits: ${balance}`);
|
|
60
|
+
}
|
|
61
|
+
lines.push("", ...data.notes.map(safeText));
|
|
62
|
+
}
|
|
63
|
+
lines.push(`Manage usage: ${safeText(result.provider.dashboardUrl)}`);
|
|
64
|
+
return lines.join("\n");
|
|
65
|
+
}).join("\n\n────────────────────────────────────────\n\n");
|
|
66
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { UsageProvider } from "../types.ts";
|
|
2
|
+
import { createOpenAIProvider } from "./openai.ts";
|
|
3
|
+
|
|
4
|
+
// Add an adapter here; commands, collection, and rendering remain provider-neutral.
|
|
5
|
+
export function createUsageProviders(): UsageProvider[] {
|
|
6
|
+
return [createOpenAIProvider()];
|
|
7
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { decodeJwt } from "jose";
|
|
2
|
+
import OpenAI from "openai";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { UsageError, type AuthRegistry, type UsageGroup, type UsageProvider, type UsageSnapshot, type UsageWindow } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
export const CODEX_PROVIDER = "openai-codex";
|
|
7
|
+
export const USAGE_BASE_URL = "https://chatgpt.com/backend-api";
|
|
8
|
+
export const LOGIN_HINT = "Run /login openai-codex and choose the same ChatGPT account as your OpenAI login. Your inference login is unchanged.";
|
|
9
|
+
|
|
10
|
+
const nonnegative = z.number().finite().nonnegative();
|
|
11
|
+
const timestamp = nonnegative.max(8_640_000_000_000);
|
|
12
|
+
const windowSchema = z.object({
|
|
13
|
+
used_percent: nonnegative.nullish(),
|
|
14
|
+
limit_window_seconds: nonnegative.nullish(),
|
|
15
|
+
reset_at: timestamp.nullish(),
|
|
16
|
+
reset_after_seconds: nonnegative.nullish(),
|
|
17
|
+
});
|
|
18
|
+
const limitSchema = z.object({
|
|
19
|
+
allowed: z.boolean().nullish(),
|
|
20
|
+
limit_reached: z.boolean().nullish(),
|
|
21
|
+
primary_window: windowSchema.nullish(),
|
|
22
|
+
secondary_window: windowSchema.nullish(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Codex's backend-client/OpenAPI models are the wire-contract reference. This is
|
|
26
|
+
// an internal endpoint, not the public OpenAI organization/billing usage API.
|
|
27
|
+
// Unknown fields are ignored; missing/null quotas stay unknown, never 0% used.
|
|
28
|
+
const payloadSchema = z.object({
|
|
29
|
+
plan_type: z.string().min(1),
|
|
30
|
+
account_id: z.string().nullish(),
|
|
31
|
+
email: z.string().nullish(),
|
|
32
|
+
rate_limit: limitSchema.nullish(),
|
|
33
|
+
code_review_rate_limit: limitSchema.nullish(),
|
|
34
|
+
additional_rate_limits: z.array(z.object({
|
|
35
|
+
limit_name: z.string(),
|
|
36
|
+
metered_feature: z.string(),
|
|
37
|
+
rate_limit: limitSchema.nullish(),
|
|
38
|
+
})).nullish(),
|
|
39
|
+
credits: z.object({
|
|
40
|
+
has_credits: z.boolean().nullish(),
|
|
41
|
+
unlimited: z.boolean().nullish(),
|
|
42
|
+
balance: z.string().nullish(),
|
|
43
|
+
overage_limit_reached: z.boolean().nullish(),
|
|
44
|
+
}).nullish(),
|
|
45
|
+
spend_control: z.object({ reached: z.boolean().nullish() }).nullish(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export interface CodexAccount { accessToken: string; accountId: string; email?: string }
|
|
49
|
+
|
|
50
|
+
/** Decode only for account routing/display; OpenAI validates the token itself. */
|
|
51
|
+
export function accountFromToken(accessToken: string): CodexAccount | undefined {
|
|
52
|
+
try {
|
|
53
|
+
const claims = decodeJwt(accessToken);
|
|
54
|
+
const auth = z.object({ chatgpt_account_id: z.string().regex(/^[\w-]+$/) })
|
|
55
|
+
.safeParse(claims["https://api.openai.com/auth"]);
|
|
56
|
+
if (!auth.success) return undefined;
|
|
57
|
+
const profile = z.object({ email: z.string().optional() })
|
|
58
|
+
.safeParse(claims["https://api.openai.com/profile"]);
|
|
59
|
+
return {
|
|
60
|
+
accessToken,
|
|
61
|
+
accountId: auth.data.chatgpt_account_id,
|
|
62
|
+
email: profile.success ? profile.data.email : undefined,
|
|
63
|
+
};
|
|
64
|
+
} catch { return undefined; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function getCodexAccount(registry: AuthRegistry): Promise<CodexAccount> {
|
|
68
|
+
if (!registry.getProvider(CODEX_PROVIDER)) {
|
|
69
|
+
throw new UsageError(`This Pi build has no Codex login provider. ${LOGIN_HINT}`, "unavailable");
|
|
70
|
+
}
|
|
71
|
+
let result;
|
|
72
|
+
try { result = await registry.getProviderAuth(CODEX_PROVIDER); }
|
|
73
|
+
catch { throw new UsageError(`Could not refresh the Codex login. ${LOGIN_HINT}`); }
|
|
74
|
+
// Do not use API keys, the new SIWC token, ~/.codex/auth.json, or browser cookies.
|
|
75
|
+
if (result?.source !== "OAuth" || !result.auth.apiKey) {
|
|
76
|
+
throw new UsageError(`A Codex ChatGPT login is needed to read account limits. ${LOGIN_HINT}`, "unavailable");
|
|
77
|
+
}
|
|
78
|
+
const account = accountFromToken(result.auth.apiKey);
|
|
79
|
+
if (!account) throw new UsageError(`The Codex login has no usable ChatGPT account. ${LOGIN_HINT}`);
|
|
80
|
+
return account;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function windowLabel(seconds: number | null | undefined, fallback: string): string {
|
|
84
|
+
if (!seconds) return fallback;
|
|
85
|
+
if (seconds === 604800) return "Weekly limit";
|
|
86
|
+
if (seconds % 86400 === 0) return `${seconds / 86400} day limit`;
|
|
87
|
+
if (seconds % 3600 === 0) return `${seconds / 3600} hour limit`;
|
|
88
|
+
if (seconds % 60 === 0) return `${seconds / 60} minute limit`;
|
|
89
|
+
return `${seconds} second limit`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeWindow(window: z.infer<typeof windowSchema>, fallback: string, now: number): UsageWindow {
|
|
93
|
+
const reset = window.reset_at != null ? window.reset_at * 1000
|
|
94
|
+
: window.reset_after_seconds != null ? now + window.reset_after_seconds * 1000 : undefined;
|
|
95
|
+
return {
|
|
96
|
+
label: windowLabel(window.limit_window_seconds, fallback),
|
|
97
|
+
remainingPercent: window.used_percent == null ? undefined : Math.max(0, 100 - window.used_percent),
|
|
98
|
+
resetsAt: reset !== undefined && Number.isFinite(reset) && reset <= 8_640_000_000_000_000 ? reset : undefined,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeGroup(label: string, limit: z.infer<typeof limitSchema>, now: number): UsageGroup {
|
|
103
|
+
const windows: UsageWindow[] = [];
|
|
104
|
+
if (limit.primary_window) windows.push(normalizeWindow(limit.primary_window, "Primary limit", now));
|
|
105
|
+
if (limit.secondary_window) windows.push(normalizeWindow(limit.secondary_window, "Secondary limit", now));
|
|
106
|
+
return { label, windows, limitReached: limit.limit_reached === true || limit.allowed === false };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function normalizeUsage(payload: unknown, account: CodexAccount, now = Date.now()): UsageSnapshot {
|
|
110
|
+
const parsed = payloadSchema.safeParse(payload);
|
|
111
|
+
if (!parsed.success) throw new UsageError("OpenAI returned an unrecognized usage response. Check the usage dashboard; the Codex endpoint may have changed.");
|
|
112
|
+
const data = parsed.data;
|
|
113
|
+
if (data.account_id && data.account_id !== account.accountId) {
|
|
114
|
+
throw new UsageError("OpenAI returned usage for a different account. No usage is displayed; check your Codex login.");
|
|
115
|
+
}
|
|
116
|
+
const groups: UsageGroup[] = [];
|
|
117
|
+
if (data.rate_limit) groups.push(normalizeGroup("Account allowance", data.rate_limit, now));
|
|
118
|
+
for (const extra of data.additional_rate_limits ?? []) {
|
|
119
|
+
if (extra.rate_limit) groups.push(normalizeGroup(extra.limit_name || extra.metered_feature, extra.rate_limit, now));
|
|
120
|
+
}
|
|
121
|
+
if (data.code_review_rate_limit) groups.push(normalizeGroup("Code review", data.code_review_rate_limit, now));
|
|
122
|
+
const email = data.email || account.email;
|
|
123
|
+
const notes = ["Account allowance via Codex; Pi-specific app caps are not included."];
|
|
124
|
+
if (data.spend_control?.reached) notes.push("An account/workspace spending limit has been reached.");
|
|
125
|
+
if (data.credits?.overage_limit_reached) notes.push("The credit overage limit has been reached.");
|
|
126
|
+
return {
|
|
127
|
+
account: email ? `${email} (${account.accountId})` : account.accountId,
|
|
128
|
+
plan: data.plan_type,
|
|
129
|
+
source: "Codex login (openai-codex)",
|
|
130
|
+
fetchedAt: now,
|
|
131
|
+
groups,
|
|
132
|
+
credits: data.credits ? {
|
|
133
|
+
unlimited: data.credits.unlimited ?? undefined,
|
|
134
|
+
hasCredits: data.credits.has_credits ?? undefined,
|
|
135
|
+
balance: data.credits.balance ?? undefined,
|
|
136
|
+
} : undefined,
|
|
137
|
+
notes,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createOpenAIProvider(options: { fetch?: typeof globalThis.fetch; now?: () => number } = {}): UsageProvider {
|
|
142
|
+
return {
|
|
143
|
+
id: "openai",
|
|
144
|
+
label: "OpenAI · ChatGPT",
|
|
145
|
+
dashboardUrl: "https://chatgpt.com/settings/usage",
|
|
146
|
+
async fetch({ registry, signal }) {
|
|
147
|
+
const account = await getCodexAccount(registry);
|
|
148
|
+
signal.throwIfAborted();
|
|
149
|
+
const client = new OpenAI({
|
|
150
|
+
apiKey: account.accessToken,
|
|
151
|
+
baseURL: USAGE_BASE_URL,
|
|
152
|
+
// Never pick up OPENAI_ORG_ID/PROJECT_ID or log credentials/response bodies.
|
|
153
|
+
organization: null,
|
|
154
|
+
project: null,
|
|
155
|
+
logLevel: "off",
|
|
156
|
+
maxRetries: 0,
|
|
157
|
+
timeout: 12_000,
|
|
158
|
+
fetch: options.fetch,
|
|
159
|
+
fetchOptions: { redirect: "error" },
|
|
160
|
+
defaultHeaders: { "ChatGPT-Account-Id": account.accountId },
|
|
161
|
+
});
|
|
162
|
+
let payload: unknown;
|
|
163
|
+
try {
|
|
164
|
+
// The official SDK's generic request API handles transport and cancellation.
|
|
165
|
+
// There is no generated quota method in the JavaScript SDK.
|
|
166
|
+
payload = await client.get<unknown>("/wham/usage", { signal });
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (signal.aborted) throw signal.reason;
|
|
169
|
+
if (error instanceof OpenAI.APIError) {
|
|
170
|
+
if (error.status === 401) throw new UsageError(`The Codex login was rejected. ${LOGIN_HINT}`);
|
|
171
|
+
if (error.status === 403) throw new UsageError("OpenAI denied access to Codex usage. Check the account and usage dashboard.");
|
|
172
|
+
if (error.status === 429) throw new UsageError("OpenAI throttled the usage check. Try again later; this does not mean your plan allowance is empty.");
|
|
173
|
+
if (error.status && error.status >= 500) throw new UsageError("OpenAI usage is temporarily unavailable. Try again later.");
|
|
174
|
+
}
|
|
175
|
+
if (error instanceof OpenAI.APIConnectionTimeoutError) throw new UsageError("OpenAI usage request timed out. Try again.");
|
|
176
|
+
throw new UsageError("Could not retrieve OpenAI usage. Check your connection or the usage dashboard.");
|
|
177
|
+
}
|
|
178
|
+
return normalizeUsage(payload, account, options.now?.() ?? Date.now());
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { UsageError, type AuthRegistry, type UsageProvider, type UsageResult } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** Pi owns auth refresh, which has no command AbortSignal in 1.4.0. Stop waiting
|
|
4
|
+
* on dismissal/timeout without interfering with its locked token rotation. */
|
|
5
|
+
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const abort = () => reject(signal.reason);
|
|
8
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
9
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
10
|
+
if (signal.aborted) abort();
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function collectUsage(
|
|
15
|
+
providers: readonly UsageProvider[],
|
|
16
|
+
registry: AuthRegistry,
|
|
17
|
+
signal: AbortSignal,
|
|
18
|
+
timeoutMs = 15_000,
|
|
19
|
+
): Promise<UsageResult[]> {
|
|
20
|
+
signal.throwIfAborted();
|
|
21
|
+
const results = await Promise.all(providers.map(async (adapter): Promise<UsageResult> => {
|
|
22
|
+
const provider = { id: adapter.id, label: adapter.label, dashboardUrl: adapter.dashboardUrl };
|
|
23
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
24
|
+
const combined = AbortSignal.any([signal, timeout]);
|
|
25
|
+
try {
|
|
26
|
+
const snapshot = await abortable(Promise.resolve().then(() => adapter.fetch({ registry, signal: combined })), combined);
|
|
27
|
+
return { provider, status: "ok", snapshot };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (signal.aborted) throw signal.reason;
|
|
30
|
+
if (timeout.aborted) return { provider, status: "error", message: "Usage check timed out. Try refreshing." };
|
|
31
|
+
if (error instanceof UsageError) return { provider, status: error.status, message: error.message };
|
|
32
|
+
// A provider must opt into a safe message. Do not render raw request errors.
|
|
33
|
+
return { provider, status: "error", message: "Could not retrieve usage for this provider. Try refreshing or check its dashboard." };
|
|
34
|
+
}
|
|
35
|
+
}));
|
|
36
|
+
signal.throwIfAborted();
|
|
37
|
+
return results;
|
|
38
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type AuthRegistry = Pick<ExtensionContext["modelRegistry"], "getProviderAuth" | "getProvider">;
|
|
4
|
+
|
|
5
|
+
/** Provider adapters own auth and wire formats; the view only knows these types. */
|
|
6
|
+
export interface UsageWindow {
|
|
7
|
+
label: string;
|
|
8
|
+
remainingPercent?: number;
|
|
9
|
+
resetsAt?: number; // Unix milliseconds, not credential expiry.
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UsageGroup {
|
|
13
|
+
label: string;
|
|
14
|
+
windows: UsageWindow[];
|
|
15
|
+
limitReached?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UsageSnapshot {
|
|
19
|
+
account: string;
|
|
20
|
+
plan?: string;
|
|
21
|
+
source: string;
|
|
22
|
+
fetchedAt: number;
|
|
23
|
+
groups: UsageGroup[];
|
|
24
|
+
credits?: { unlimited?: boolean; hasCredits?: boolean; balance?: string };
|
|
25
|
+
notes: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UsageProvider {
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
dashboardUrl: string;
|
|
32
|
+
fetch(context: { registry: AuthRegistry; signal: AbortSignal }): Promise<UsageSnapshot>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type UsageResult = {
|
|
36
|
+
provider: Pick<UsageProvider, "id" | "label" | "dashboardUrl">;
|
|
37
|
+
} & (
|
|
38
|
+
| { status: "ok"; snapshot: UsageSnapshot }
|
|
39
|
+
| { status: "unavailable" | "error"; message: string }
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** Only static, user-safe messages belong here. Never wrap raw SDK/auth errors. */
|
|
43
|
+
export class UsageError extends Error {
|
|
44
|
+
constructor(message: string, public readonly status: "unavailable" | "error" = "error") {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "UsageError";
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/view.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, ScrollView, Text, truncateToWidth, type Component, type KeybindingsManager, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { formatUsage } from "./format.ts";
|
|
4
|
+
import type { UsageResult } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/** Built from Pi's layout, wrapping, scroll and input components, not another TUI. */
|
|
7
|
+
export class UsageView implements Component {
|
|
8
|
+
private readonly heading: Text;
|
|
9
|
+
private readonly body: Text;
|
|
10
|
+
private readonly scroll: ScrollView;
|
|
11
|
+
private request?: AbortController;
|
|
12
|
+
private disposed = false;
|
|
13
|
+
private results?: UsageResult[];
|
|
14
|
+
private loading = true;
|
|
15
|
+
private failed = false;
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly tui: Pick<TUI, "requestRender"> & { terminal?: { rows: number } },
|
|
19
|
+
private readonly theme: Pick<Theme, "fg" | "bold">,
|
|
20
|
+
private readonly keys: Pick<KeybindingsManager, "matches">,
|
|
21
|
+
private readonly done: () => void,
|
|
22
|
+
private readonly load: (signal: AbortSignal) => Promise<UsageResult[]>,
|
|
23
|
+
) {
|
|
24
|
+
const heading = new Text("", 0, 0);
|
|
25
|
+
const body = new Text("", 0, 0);
|
|
26
|
+
const scroll = new ScrollView(body, { scrollbar: "hidden", overscroll: "contain" });
|
|
27
|
+
this.heading = heading;
|
|
28
|
+
this.body = body;
|
|
29
|
+
this.scroll = scroll;
|
|
30
|
+
this.refresh();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
refresh(): void {
|
|
34
|
+
if (this.disposed || this.request) return;
|
|
35
|
+
const request = new AbortController();
|
|
36
|
+
this.request = request;
|
|
37
|
+
this.loading = true;
|
|
38
|
+
this.failed = false;
|
|
39
|
+
this.results = undefined; // Never leave an old account snapshot looking current.
|
|
40
|
+
this.scroll.scrollToStart();
|
|
41
|
+
this.update();
|
|
42
|
+
void Promise.resolve().then(() => this.load(request.signal)).then(
|
|
43
|
+
(results) => { if (!this.disposed && !request.signal.aborted) this.results = results; },
|
|
44
|
+
() => { if (!this.disposed && !request.signal.aborted) this.failed = true; },
|
|
45
|
+
).finally(() => {
|
|
46
|
+
if (this.request === request) this.request = undefined;
|
|
47
|
+
if (!this.disposed) {
|
|
48
|
+
this.loading = false;
|
|
49
|
+
this.update();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private update(): void {
|
|
55
|
+
this.invalidate();
|
|
56
|
+
this.tui.requestRender();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
invalidate(): void {
|
|
60
|
+
// Rebuild themed strings on invalidation, including palette changes.
|
|
61
|
+
this.heading?.setText(this.theme.bold(this.theme.fg("accent", "Account usage")));
|
|
62
|
+
this.body?.setText(this.loading ? this.theme.fg("muted", "Loading account usage…")
|
|
63
|
+
: this.failed ? this.theme.fg("error", "Could not load usage. Press r to retry.")
|
|
64
|
+
: formatUsage(this.results ?? []));
|
|
65
|
+
this.heading?.invalidate();
|
|
66
|
+
this.body?.invalidate();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
render(width: number): string[] {
|
|
70
|
+
// Pi 1.4 overlays call render() directly in BOTH screen modes, bypassing the
|
|
71
|
+
// layout engine. Supply the viewport to ScrollView and clip here so neither
|
|
72
|
+
// the footer nor later providers disappear below the overlay's maxHeight.
|
|
73
|
+
const height = Math.max(1, Math.floor((this.tui.terminal?.rows ?? 24) * 0.85));
|
|
74
|
+
const heading = height >= 4 ? this.heading.render(width).slice(0, 1) : [];
|
|
75
|
+
const footer = height >= 2 ? [truncateToWidth("r refresh · ↑/↓ scroll · PgUp/PgDn · Enter/Esc close", width)] : [];
|
|
76
|
+
const gap = height >= 8 ? [""] : [];
|
|
77
|
+
const viewport = Math.max(1, height - heading.length - footer.length - 2 * gap.length);
|
|
78
|
+
const content = this.scroll.render(width);
|
|
79
|
+
this.scroll.updateLayout(content.length, viewport, () => this.tui.requestRender());
|
|
80
|
+
const visible = content.slice(this.scroll.scrollTop, this.scroll.scrollTop + viewport);
|
|
81
|
+
if (footer.length) {
|
|
82
|
+
const position = content.length > viewport ? `${this.scroll.scrollTop + 1}–${Math.min(content.length, this.scroll.scrollTop + viewport)}/${content.length} · ` : "";
|
|
83
|
+
footer[0] = truncateToWidth(this.theme.fg("dim", position + "r refresh · ↑/↓ scroll · PgUp/PgDn · Enter/Esc close"), width);
|
|
84
|
+
}
|
|
85
|
+
return [...heading, ...gap, ...visible, ...gap, ...footer];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
handleMouse(event: TuiMouseEvent): { handled: boolean } | undefined {
|
|
89
|
+
if (event.type !== "wheel") return;
|
|
90
|
+
this.scroll.scrollBy(event.wheelDelta ?? 0);
|
|
91
|
+
this.tui.requestRender();
|
|
92
|
+
return { handled: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
handleInput(data: string): void {
|
|
96
|
+
if (this.disposed) return;
|
|
97
|
+
if (this.keys.matches(data, "tui.select.cancel") || this.keys.matches(data, "tui.select.confirm") || matchesKey(data, "q")) {
|
|
98
|
+
this.close();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (matchesKey(data, "r")) this.refresh();
|
|
102
|
+
else if (this.keys.matches(data, "tui.select.up")) this.scroll.scrollBy(-1);
|
|
103
|
+
else if (this.keys.matches(data, "tui.select.down")) this.scroll.scrollBy(1);
|
|
104
|
+
else if (matchesKey(data, Key.pageUp)) this.scroll.scrollBy(-Math.max(1, this.scroll.viewportHeight - 1));
|
|
105
|
+
else if (matchesKey(data, Key.pageDown)) this.scroll.scrollBy(Math.max(1, this.scroll.viewportHeight - 1));
|
|
106
|
+
else return;
|
|
107
|
+
this.tui.requestRender();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
close(): void {
|
|
111
|
+
if (this.disposed) return;
|
|
112
|
+
this.dispose();
|
|
113
|
+
this.done();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
dispose(): void {
|
|
117
|
+
if (this.disposed) return;
|
|
118
|
+
this.disposed = true;
|
|
119
|
+
this.request?.abort();
|
|
120
|
+
}
|
|
121
|
+
}
|