pi-usage-limits 0.1.4 → 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/README.md CHANGED
@@ -24,10 +24,11 @@ A [pi](https://github.com/earendil-works/pi) extension that shows your **Claude*
24
24
 
25
25
  | Active model | Source | Shows |
26
26
  |---|---|---|
27
+ | `openai` provider, including `gpt-6.1-sol` | No quota lookup | `OpenAI usage unavailable; check usage dashboard` |
28
+ | `openai-codex` provider, or a non-`openai` model id containing `codex` | ChatGPT usage endpoint (pi's `openai-codex` login) | 5-hour and weekly usage |
27
29
  | Model id contains `claude` | Claude Agent SDK (Claude Code login) | 5-hour and 7-day utilization |
28
- | `openai` or `openai-codex` provider, or model id contains `codex` | ChatGPT usage endpoint (pi's `openai-codex` login) | 5-hour and weekly usage |
29
30
 
30
- OpenAI models such as `(openai) gpt-6.1-sol` show the same ChatGPT subscription limits using your `openai-codex` login, not OpenAI API limits.
31
+ The `openai` provider's Sign in with ChatGPT flow has no documented quota endpoint. Check **ChatGPT → Settings → Usage**, as [OpenAI recommends](https://developers.openai.com/siwc/token-sharing-open-source/profiles-and-sessions#tracking-usage). For API-key authentication, check the [OpenAI API usage dashboard](https://platform.openai.com/usage) instead. This extension never substitutes `openai-codex` credentials or cached limits for `openai`, even when the model id contains `codex`.
31
32
 
32
33
  Other models show `<model> not supported yet`.
33
34
 
@@ -57,14 +58,20 @@ pi -e npm:pi-usage-limits
57
58
  | `model_select` | Show usage for the new model |
58
59
  | `agent_settled` | Refresh after a prompt completes |
59
60
 
60
- 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.
61
+ The cache lives at `~/.pi/agent/cache/pi-usage-limits.json`. Entries pair each source's limits with a fetch timestamp and expire after 60 seconds. Codex entries also record the account ID and are used only after checking the current login. Limits are shared across models using the same usage source, never between `openai` and `openai-codex`.
62
+
63
+ Writes use atomic replacement and a lockfile for read-modify-write updates. The lock does not deduplicate concurrent API refreshes. Malformed or incompatible entries are treated as cache misses; the old cache format is discarded on the next save. Cache I/O failures produce console diagnostics but do not prevent displaying freshly fetched limits. Refresh failures still surface as errors rather than silently serving stale limits.
61
64
 
62
65
  ## Caveats
63
66
 
64
- - **Claude** limits come from an experimental Claude Agent SDK API, which may change in a future SDK release.
67
+ - **Claude** limits come from an experimental Claude Agent SDK API, which may change in a future SDK release. The cache assumes one Claude Code login per agent directory; switching Claude accounts may show the previous account's limits for up to 60 seconds.
65
68
  - **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.
66
69
  - Each uncached Claude lookup starts a short-lived `claude` process, which takes about a second. It runs in the background.
67
70
 
71
+ ## Development
72
+
73
+ Run the cache regression tests with `npm test`. Tests use a temporary agent directory and mocked HTTP responses; they do not access your real cache or subscription endpoints.
74
+
68
75
  ## License
69
76
 
70
77
  MIT
package/codex_limits.ts CHANGED
@@ -44,12 +44,20 @@ const toWindow = (w: WhamWindow | undefined): CodexWindow | null =>
44
44
  export async function getCodexLimits(token: string): Promise<CodexLimits> {
45
45
  const id = accountId(token);
46
46
  if (!id) throw new Error("Codex token has no ChatGPT account id");
47
+
47
48
  const res = await fetch(USAGE_URL, {
48
49
  headers: { Authorization: `Bearer ${token}`, "ChatGPT-Account-Id": id },
49
50
  signal: AbortSignal.timeout(10_000),
50
51
  });
52
+
53
+ if (res.status === 401)
54
+ throw new Error(
55
+ "Codex usage request failed: HTTP 401; run /login openai-codex to sign in again",
56
+ );
57
+
51
58
  if (!res.ok)
52
59
  throw new Error(`Codex usage request failed: HTTP ${res.status}`);
60
+
53
61
  const body = await res.json();
54
62
  return {
55
63
  primary: toWindow(body?.rate_limit?.primary_window),
package/lock.ts CHANGED
@@ -46,8 +46,14 @@ export async function withLock<T>(
46
46
  }
47
47
  }
48
48
 
49
- export const readJson = (file: string) =>
50
- readFile(file, "utf8").then(JSON.parse, () => undefined);
49
+ export async function readJson(file: string) {
50
+ try {
51
+ return JSON.parse(await readFile(file, "utf8"));
52
+ } catch (error: any) {
53
+ if (error.code === "ENOENT" || error instanceof SyntaxError) return undefined;
54
+ throw error;
55
+ }
56
+ }
51
57
 
52
58
  // Atomic replace: lock-free readers see the old or new file, never a torn one
53
59
  export async function writeJson(file: string, data: unknown) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-usage-limits",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -9,6 +9,9 @@
9
9
  "author": "Anthony Ilinykh <anthonyilinykh@gmail.com>",
10
10
  "license": "MIT",
11
11
  "type": "module",
12
+ "scripts": {
13
+ "test": "tsx --test tests/*.test.ts"
14
+ },
12
15
  "pi": {
13
16
  "extensions": [
14
17
  "./index.ts"
@@ -16,7 +19,6 @@
16
19
  },
17
20
  "files": [
18
21
  "*.ts",
19
- "!*.test.ts",
20
22
  "README.md",
21
23
  "VERSION"
22
24
  ],
@@ -25,6 +27,7 @@
25
27
  "@anthropic-ai/claude-agent-sdk": "*"
26
28
  },
27
29
  "devDependencies": {
28
- "@types/node": "^26.6.3"
30
+ "@types/node": "^26.6.3",
31
+ "tsx": "^4.23.15"
29
32
  }
30
33
  }
package/state.ts CHANGED
@@ -2,26 +2,80 @@ import { mkdir } from "node:fs/promises";
2
2
  import { join } from "path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
  import { readJson, withLock, writeJson } from "./lock";
5
- import { ClaudeLimits } from "./claude_limits";
6
- import type { CodexLimits } from "./codex_limits";
5
+ import type { ClaudeLimits } from "./claude_limits";
6
+ import type { CodexLimits, CodexWindow } from "./codex_limits";
7
7
 
8
8
  const dir = join(getAgentDir(), "cache");
9
9
  const file = join(dir, "pi-usage-limits.json");
10
+ const CACHE_TTL_MS = 60_000;
11
+
12
+ type CacheEntry<T> = {
13
+ fetchedAt: number;
14
+ value: T;
15
+ };
10
16
 
11
17
  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;
18
+ anthropic?: CacheEntry<ClaudeLimits>;
19
+ codex?: CacheEntry<CodexLimits> & { accountId: string };
16
20
  };
17
21
 
22
+ export function isFresh(entry: CacheEntry<unknown> | undefined, now = Date.now()) {
23
+ if (!entry || !Number.isFinite(entry.fetchedAt)) return false;
24
+ const age = now - entry.fetchedAt;
25
+ return age >= 0 && age < CACHE_TTL_MS;
26
+ }
27
+
28
+ const isNumber = (value: unknown): value is number =>
29
+ typeof value === "number" && Number.isFinite(value);
30
+
31
+ const isClaudeLimits = (value: any): value is ClaudeLimits =>
32
+ typeof value?.subscription === "string" &&
33
+ typeof value?.available === "boolean" &&
34
+ isNumber(value?.rate_limits?.five_hour?.utilization) &&
35
+ isNumber(value?.rate_limits?.seven_day?.utilization);
36
+
37
+ const isCodexWindow = (value: any): value is CodexWindow | null =>
38
+ value === null ||
39
+ (isNumber(value?.usedPercent) &&
40
+ (value?.windowDurationMins === null || isNumber(value?.windowDurationMins)) &&
41
+ (value?.resetsAt === null || isNumber(value?.resetsAt)));
42
+
43
+ const isCodexLimits = (value: any): value is CodexLimits =>
44
+ isCodexWindow(value?.primary) && isCodexWindow(value?.secondary);
45
+
18
46
  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
- );
47
+ try {
48
+ await mkdir(dir, { recursive: true });
49
+ await withLock(file, async () =>
50
+ writeJson(file, { ...(await loadState()), ...state }),
51
+ );
52
+ } catch (error) {
53
+ console.warn("pi-usage-limits: could not save cache", error);
54
+ }
23
55
  }
24
56
 
25
57
  export async function loadState(): Promise<State | undefined> {
26
- return readJson(file);
58
+ try {
59
+ const data = await readJson(file);
60
+ if (!data || typeof data !== "object") return undefined;
61
+ const state: State = {};
62
+ if (
63
+ isNumber(data.anthropic?.fetchedAt) &&
64
+ isClaudeLimits(data.anthropic?.value)
65
+ ) {
66
+ state.anthropic = data.anthropic;
67
+ }
68
+ if (
69
+ isNumber(data.codex?.fetchedAt) &&
70
+ typeof data.codex?.accountId === "string" &&
71
+ data.codex.accountId.length > 0 &&
72
+ isCodexLimits(data.codex?.value)
73
+ ) {
74
+ state.codex = data.codex;
75
+ }
76
+ return state;
77
+ } catch (error) {
78
+ console.warn("pi-usage-limits: could not read cache", error);
79
+ return undefined;
80
+ }
27
81
  }
package/usage.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { getLimits, ClaudeLimits } from "./claude_limits";
2
- import { loadState, saveState } from "./state";
1
+ import { getLimits, type ClaudeLimits } from "./claude_limits";
2
+ import { isFresh, loadState, saveState } from "./state";
3
3
  import {
4
+ accountId,
4
5
  getCodexLimits,
5
6
  type CodexLimits,
6
7
  type CodexWindow,
@@ -14,11 +15,12 @@ export async function getUsage(
14
15
  model: { id: string; provider?: string },
15
16
  auth: TokenSource,
16
17
  ): Promise<string> {
17
- if (
18
- model.provider === "openai" ||
19
- model.provider === "openai-codex" ||
20
- model.id.includes("codex")
21
- ) {
18
+ // Sign in with ChatGPT has no documented quota endpoint; never substitute Codex usage.
19
+ if (model.provider === "openai") {
20
+ return "OpenAI usage unavailable; check usage dashboard";
21
+ }
22
+
23
+ if (model.provider === "openai-codex" || model.id.includes("codex")) {
22
24
  return getCodexUsage(auth);
23
25
  }
24
26
 
@@ -32,40 +34,36 @@ export async function getUsage(
32
34
  async function getClaudeUsage() {
33
35
  const state = await loadState();
34
36
 
35
- if (
36
- state?.anthropic_last_limits &&
37
- Date.now() - (state.anthropic_last_api_call ?? 0) < 60_000
38
- ) {
39
- return formatClaudeUsage(state.anthropic_last_limits) + " 󰃨";
37
+ if (state?.anthropic && isFresh(state.anthropic)) {
38
+ return formatClaudeUsage(state.anthropic.value) + " 󰃨";
40
39
  }
41
40
 
42
41
  const limits = await getLimits();
43
42
 
44
43
  await saveState({
45
- anthropic_last_api_call: Date.now(),
46
- anthropic_last_limits: limits,
44
+ anthropic: { fetchedAt: Date.now(), value: limits },
47
45
  });
48
46
 
49
47
  return formatClaudeUsage(limits);
50
48
  }
51
49
 
52
50
  async function getCodexUsage(auth: TokenSource): Promise<string> {
53
- const state = await loadState();
54
-
55
- if (
56
- state?.codex_last_limits &&
57
- Date.now() - (state.codex_last_api_call ?? 0) < 60_000
58
- ) {
59
- return formatCodexUsage(state.codex_last_limits) + " 󰃨";
60
- }
61
51
  // pi's own openai-codex login; pi refreshes the token, we never touch ~/.codex/auth.json
62
52
  const token = await auth.getApiKeyForProvider("openai-codex");
63
53
  if (!token) throw new Error("Codex: run /login openai-codex");
54
+
55
+ const id = accountId(token);
56
+ if (!id) throw new Error("Codex token has no ChatGPT account id");
57
+
58
+ const state = await loadState();
59
+ if (state?.codex && state.codex.accountId === id && isFresh(state.codex)) {
60
+ return formatCodexUsage(state.codex.value) + " 󰃨";
61
+ }
62
+
64
63
  const limits = await getCodexLimits(token);
65
64
 
66
65
  await saveState({
67
- codex_last_api_call: Date.now(),
68
- codex_last_limits: limits,
66
+ codex: { fetchedAt: Date.now(), value: limits, accountId: id },
69
67
  });
70
68
 
71
69
  return formatCodexUsage(limits);