pi-usage-limits 0.1.1
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 +68 -0
- package/claude_limits.ts +54 -0
- package/codex_limits.ts +58 -0
- package/index.ts +22 -0
- package/lock.ts +57 -0
- package/package.json +30 -0
- package/state.ts +27 -0
- package/usage.ts +96 -0
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# pi-usage-limits
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/pi-usage-limits)
|
|
4
|
+
|
|
5
|
+
A [pi](https://github.com/earendil-works/pi) extension that shows your **Claude** and **Codex** subscription limits in pi's status line and updates them after every prompt.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
5h: 12%, 7d: 25%
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Why use it
|
|
12
|
+
|
|
13
|
+
- **Works with Claude through the Claude Agent SDK.** It reads limits with your existing Claude Code login, so it works when pi runs Claude models through the SDK, including `pi-claude-bridge`. You don't need a separate Anthropic login or API key in pi.
|
|
14
|
+
- **Codex support, no CLI needed.** Reads 5-hour and weekly Codex limits over HTTPS with pi's own `openai-codex` login. pi refreshes the token; `~/.codex/auth.json` is never read or touched.
|
|
15
|
+
- **Never slows pi down.** Usage is fetched in the background, and pi doesn't wait for it.
|
|
16
|
+
- **Updates after each prompt.** It refreshes when a session starts, when you switch models, and once each prompt finishes, instead of on every turn or tool call.
|
|
17
|
+
- **Fewer calls with several sessions.** Results are cached on disk for 60 seconds, and the cache is shared between all running pi sessions. A cached value is marked with ``.
|
|
18
|
+
- **Doesn't replace your footer.** It adds one status entry, so pi's built-in footer and other extensions keep working.
|
|
19
|
+
- **No tokens used.** Reading limits sends no prompt and doesn't touch your quota.
|
|
20
|
+
- **Clear errors.** If a lookup fails, the status line shows the reason in red, such as the Claude CLI's own error.
|
|
21
|
+
- **Small and readable.** A few hundred lines of plain TypeScript, with no build step.
|
|
22
|
+
|
|
23
|
+
## Supported models
|
|
24
|
+
|
|
25
|
+
| Active model | Source | Shows |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| Model id contains `claude` | Claude Agent SDK (Claude Code login) | 5-hour and 7-day utilization |
|
|
28
|
+
| `openai-codex` provider, or model id contains `codex` | ChatGPT usage endpoint (pi's `openai-codex` login) | 5-hour and weekly usage |
|
|
29
|
+
|
|
30
|
+
Other models show `<model> not supported yet`.
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
- pi
|
|
35
|
+
- **For Claude:** `@anthropic-ai/claude-agent-sdk` installed alongside pi (for example as a `pi-claude-bridge` dependency), signed in to a Claude subscription. API-key logins have no subscription limits to show.
|
|
36
|
+
- **For Codex:** signed in to pi's `openai-codex` provider (`/login` in pi) with a ChatGPT subscription.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pi install npm:pi-usage-limits
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Try it for a single session without installing:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pi -e npm:pi-usage-limits
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## How it works
|
|
51
|
+
|
|
52
|
+
| Event | Action |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `session_start` | Show current usage |
|
|
55
|
+
| `model_select` | Show usage for the new model |
|
|
56
|
+
| `agent_settled` | Refresh after a prompt completes |
|
|
57
|
+
|
|
58
|
+
The cache lives at `~/.pi/agent/cache/pi-usage-limits.json`. Writes are atomic and protected by a lockfile, so parallel sessions don't corrupt it.
|
|
59
|
+
|
|
60
|
+
## Caveats
|
|
61
|
+
|
|
62
|
+
- **Claude** limits come from an experimental Claude Agent SDK API, which may change in a future SDK release.
|
|
63
|
+
- **Codex** limits come from an undocumented ChatGPT endpoint (`chatgpt.com/backend-api/wham/usage`, the same one the Codex CLI uses), which may change without notice.
|
|
64
|
+
- Each uncached Claude lookup starts a short-lived `claude` process, which takes about a second. It runs in the background.
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
package/claude_limits.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
|
|
3
|
+
export type ClaudeLimits = {
|
|
4
|
+
subscription: string;
|
|
5
|
+
available: boolean;
|
|
6
|
+
rate_limits: {
|
|
7
|
+
five_hour: {
|
|
8
|
+
utilization: number;
|
|
9
|
+
};
|
|
10
|
+
seven_day: {
|
|
11
|
+
utilization: number;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const getLimits = async function (): Promise<ClaudeLimits> {
|
|
17
|
+
const log: string[] = [];
|
|
18
|
+
const q = query({
|
|
19
|
+
prompt: (async function* () {
|
|
20
|
+
await new Promise(() => {});
|
|
21
|
+
})(),
|
|
22
|
+
options: {
|
|
23
|
+
persistSession: false,
|
|
24
|
+
settingSources: [],
|
|
25
|
+
strictMcpConfig: true,
|
|
26
|
+
// CLI only explains a null usage result in its debug log
|
|
27
|
+
extraArgs: { "debug-to-stderr": null },
|
|
28
|
+
stderr: (s) => log.push(s),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
let u;
|
|
33
|
+
try {
|
|
34
|
+
u = await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({
|
|
35
|
+
skipBehaviors: true,
|
|
36
|
+
});
|
|
37
|
+
} finally {
|
|
38
|
+
q.close();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!u.rate_limits) {
|
|
42
|
+
const why = log
|
|
43
|
+
.join("")
|
|
44
|
+
.split("\n")
|
|
45
|
+
.findLast((l) => l.includes("[ERROR]"));
|
|
46
|
+
throw new Error(why?.replace(/^\S+ \[\w+\] /, "") ?? "no rate_limits");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
subscription: u.subscription_type,
|
|
51
|
+
available: u.rate_limits_available,
|
|
52
|
+
rate_limits: u.rate_limits,
|
|
53
|
+
} as ClaudeLimits;
|
|
54
|
+
};
|
package/codex_limits.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type CodexWindow = {
|
|
2
|
+
usedPercent: number;
|
|
3
|
+
windowDurationMins: number | null;
|
|
4
|
+
resetsAt: number | null;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type CodexLimits = {
|
|
8
|
+
primary: CodexWindow | null;
|
|
9
|
+
secondary: CodexWindow | null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type WhamWindow = {
|
|
13
|
+
used_percent: number;
|
|
14
|
+
limit_window_seconds?: number | null;
|
|
15
|
+
reset_at?: number | null;
|
|
16
|
+
} | null;
|
|
17
|
+
|
|
18
|
+
// undocumented ChatGPT backend endpoint (same one the Codex CLI calls); may change without notice
|
|
19
|
+
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
20
|
+
|
|
21
|
+
// Account id lives in the OAuth access token, as pi-ai reads it.
|
|
22
|
+
export function accountId(token: string): string | undefined {
|
|
23
|
+
try {
|
|
24
|
+
const payload = JSON.parse(
|
|
25
|
+
Buffer.from(token.split(".")[1], "base64url").toString(),
|
|
26
|
+
);
|
|
27
|
+
return payload["https://api.openai.com/auth"]?.chatgpt_account_id;
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const toWindow = (w: WhamWindow | undefined): CodexWindow | null =>
|
|
34
|
+
w
|
|
35
|
+
? {
|
|
36
|
+
usedPercent: w.used_percent,
|
|
37
|
+
windowDurationMins: w.limit_window_seconds
|
|
38
|
+
? w.limit_window_seconds / 60
|
|
39
|
+
: null,
|
|
40
|
+
resetsAt: w.reset_at ?? null,
|
|
41
|
+
}
|
|
42
|
+
: null;
|
|
43
|
+
|
|
44
|
+
export async function getCodexLimits(token: string): Promise<CodexLimits> {
|
|
45
|
+
const id = accountId(token);
|
|
46
|
+
if (!id) throw new Error("Codex token has no ChatGPT account id");
|
|
47
|
+
const res = await fetch(USAGE_URL, {
|
|
48
|
+
headers: { Authorization: `Bearer ${token}`, "ChatGPT-Account-Id": id },
|
|
49
|
+
signal: AbortSignal.timeout(10_000),
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok)
|
|
52
|
+
throw new Error(`Codex usage request failed: HTTP ${res.status}`);
|
|
53
|
+
const body = await res.json();
|
|
54
|
+
return {
|
|
55
|
+
primary: toWindow(body?.rate_limit?.primary_window),
|
|
56
|
+
secondary: toWindow(body?.rate_limit?.secondary_window),
|
|
57
|
+
};
|
|
58
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
ExtensionEvent,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { getUsage } from "./usage";
|
|
7
|
+
|
|
8
|
+
// pi awaits handler promises, so awaiting here blocks the redraw.
|
|
9
|
+
function refresh(_event: ExtensionEvent, ctx: ExtensionContext) {
|
|
10
|
+
if (!ctx.model) return;
|
|
11
|
+
getUsage(ctx.model, ctx.modelRegistry)
|
|
12
|
+
.then((usage) =>
|
|
13
|
+
ctx.ui.setStatus("model-usage", ctx.ui.theme.fg("dim", usage)),
|
|
14
|
+
)
|
|
15
|
+
.catch((e) => ctx.ui.setStatus("model-usage", ctx.ui.theme.fg("dim", e)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function (pi: ExtensionAPI) {
|
|
19
|
+
pi.on("session_start", refresh);
|
|
20
|
+
pi.on("model_select", refresh);
|
|
21
|
+
pi.on("agent_settled", refresh);
|
|
22
|
+
}
|
package/lock.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
4
|
+
|
|
5
|
+
const alive = (pid: number) => {
|
|
6
|
+
try {
|
|
7
|
+
process.kill(pid, 0);
|
|
8
|
+
return true;
|
|
9
|
+
} catch (e: any) {
|
|
10
|
+
return e.code === "EPERM"; // exists, owned by another user
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// O_EXCL lockfile + dead-PID stale detection. Ceilings: two waiters breaking the same
|
|
15
|
+
// stale lock can race (one may delete the other's fresh lock); PID reuse can keep a stale lock alive
|
|
16
|
+
// until timeout; a crash between create and PID write leaves an empty lock that never goes stale.
|
|
17
|
+
export async function withLock<T>(
|
|
18
|
+
file: string,
|
|
19
|
+
fn: () => Promise<T>,
|
|
20
|
+
timeoutMs = 5000,
|
|
21
|
+
): Promise<T> {
|
|
22
|
+
const lock = `${file}.lock`;
|
|
23
|
+
const deadline = Date.now() + timeoutMs;
|
|
24
|
+
for (;;) {
|
|
25
|
+
try {
|
|
26
|
+
const fh = await open(lock, "wx"); // atomic create-if-absent
|
|
27
|
+
await fh.writeFile(String(process.pid));
|
|
28
|
+
await fh.close();
|
|
29
|
+
break;
|
|
30
|
+
} catch (e: any) {
|
|
31
|
+
if (e.code !== "EEXIST") throw e;
|
|
32
|
+
const pid = Number(await readFile(lock, "utf8").catch(() => ""));
|
|
33
|
+
if (pid && !alive(pid)) {
|
|
34
|
+
await unlink(lock).catch(() => {});
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (Date.now() > deadline)
|
|
38
|
+
throw new Error(`Timed out waiting for ${lock}`);
|
|
39
|
+
await sleep(10 + Math.random() * 20);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return await fn();
|
|
44
|
+
} finally {
|
|
45
|
+
await unlink(lock).catch(() => {});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const readJson = (file: string) =>
|
|
50
|
+
readFile(file, "utf8").then(JSON.parse, () => undefined);
|
|
51
|
+
|
|
52
|
+
// Atomic replace: lock-free readers see the old or new file, never a torn one
|
|
53
|
+
export async function writeJson(file: string, data: unknown) {
|
|
54
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
55
|
+
await writeFile(tmp, JSON.stringify(data, null, 2));
|
|
56
|
+
await rename(tmp, file);
|
|
57
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-usage-limits",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension"
|
|
8
|
+
],
|
|
9
|
+
"author": "Anthony Ilinykh <anthonyilinykh@gmail.com>",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"pi": {
|
|
13
|
+
"extensions": [
|
|
14
|
+
"./index.ts"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"*.ts",
|
|
19
|
+
"!*.test.ts",
|
|
20
|
+
"README.md",
|
|
21
|
+
"VERSION"
|
|
22
|
+
],
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
25
|
+
"@anthropic-ai/claude-agent-sdk": "*"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^26.6.3"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/state.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { readJson, withLock, writeJson } from "./lock";
|
|
5
|
+
import { ClaudeLimits } from "./claude_limits";
|
|
6
|
+
import type { CodexLimits } from "./codex_limits";
|
|
7
|
+
|
|
8
|
+
const dir = join(getAgentDir(), "cache");
|
|
9
|
+
const file = join(dir, "pi-usage-limits.json");
|
|
10
|
+
|
|
11
|
+
export type State = {
|
|
12
|
+
anthropic_last_api_call?: number;
|
|
13
|
+
anthropic_last_limits?: ClaudeLimits;
|
|
14
|
+
codex_last_api_call?: number;
|
|
15
|
+
codex_last_limits?: CodexLimits;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function saveState(state: State) {
|
|
19
|
+
await mkdir(dir, { recursive: true });
|
|
20
|
+
await withLock(file, async () =>
|
|
21
|
+
writeJson(file, { ...(await readJson(file)), ...state }),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function loadState(): Promise<State | undefined> {
|
|
26
|
+
return readJson(file);
|
|
27
|
+
}
|
package/usage.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { getLimits, ClaudeLimits } from "./claude_limits";
|
|
2
|
+
import { loadState, saveState } from "./state";
|
|
3
|
+
import {
|
|
4
|
+
getCodexLimits,
|
|
5
|
+
type CodexLimits,
|
|
6
|
+
type CodexWindow,
|
|
7
|
+
} from "./codex_limits";
|
|
8
|
+
|
|
9
|
+
type TokenSource = {
|
|
10
|
+
getApiKeyForProvider(provider: string): Promise<string | undefined>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function getUsage(
|
|
14
|
+
model: { id: string; provider?: string },
|
|
15
|
+
auth: TokenSource,
|
|
16
|
+
): Promise<string> {
|
|
17
|
+
if (model.provider === "openai-codex" || model.id.includes("codex")) {
|
|
18
|
+
return getCodexUsage(auth);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (model.id.includes("claude")) {
|
|
22
|
+
return getClaudeUsage();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return `${model.id} not supported yet`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function getClaudeUsage() {
|
|
29
|
+
const state = await loadState();
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
state?.anthropic_last_limits &&
|
|
33
|
+
Date.now() - (state.anthropic_last_api_call ?? 0) < 60_000
|
|
34
|
+
) {
|
|
35
|
+
return formatClaudeUsage(state.anthropic_last_limits) + " ";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const limits = await getLimits();
|
|
39
|
+
|
|
40
|
+
await saveState({
|
|
41
|
+
anthropic_last_api_call: Date.now(),
|
|
42
|
+
anthropic_last_limits: limits,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return formatClaudeUsage(limits);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function getCodexUsage(auth: TokenSource): Promise<string> {
|
|
49
|
+
const state = await loadState();
|
|
50
|
+
|
|
51
|
+
if (
|
|
52
|
+
state?.codex_last_limits &&
|
|
53
|
+
Date.now() - (state.codex_last_api_call ?? 0) < 60_000
|
|
54
|
+
) {
|
|
55
|
+
return formatCodexUsage(state.codex_last_limits) + " ";
|
|
56
|
+
}
|
|
57
|
+
// pi's own openai-codex login; pi refreshes the token, we never touch ~/.codex/auth.json
|
|
58
|
+
const token = await auth.getApiKeyForProvider("openai-codex");
|
|
59
|
+
if (!token) throw new Error("Codex: run /login openai-codex");
|
|
60
|
+
const limits = await getCodexLimits(token);
|
|
61
|
+
|
|
62
|
+
await saveState({
|
|
63
|
+
codex_last_api_call: Date.now(),
|
|
64
|
+
codex_last_limits: limits,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return formatCodexUsage(limits);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatCodexUsage(limits: CodexLimits): string {
|
|
71
|
+
const window = (value: CodexWindow | null, fallback: string) => {
|
|
72
|
+
if (!value) return undefined;
|
|
73
|
+
const minutes = value.windowDurationMins;
|
|
74
|
+
const label = !minutes
|
|
75
|
+
? fallback
|
|
76
|
+
: minutes % 1440 === 0
|
|
77
|
+
? `${minutes / 1440}d`
|
|
78
|
+
: minutes % 60 === 0
|
|
79
|
+
? `${minutes / 60}h`
|
|
80
|
+
: `${minutes}m`;
|
|
81
|
+
return `${label}: ${value.usedPercent}%`;
|
|
82
|
+
};
|
|
83
|
+
return (
|
|
84
|
+
[window(limits.primary, "primary"), window(limits.secondary, "secondary")]
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.join(", ") || "usage unavailable"
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatClaudeUsage(limits: ClaudeLimits) {
|
|
91
|
+
// SDK returns null when its usage fetch fails (throttled/unavailable)
|
|
92
|
+
if (!limits.rate_limits) return "usage unavailable";
|
|
93
|
+
const h = limits.rate_limits.five_hour.utilization;
|
|
94
|
+
const w = limits.rate_limits.seven_day.utilization;
|
|
95
|
+
return `5h: ${h}%, 7d: ${w}%`;
|
|
96
|
+
}
|