@nothumanwork/pi-usage 0.1.0 → 0.2.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 +62 -13
- package/index.ts +3 -3
- package/package.json +4 -2
- package/src/format.ts +3 -2
- package/src/providers/claude-code.ts +96 -0
- package/src/providers/claude-normalize.ts +74 -0
- package/src/providers/claude.ts +82 -0
- package/src/providers/index.ts +2 -1
- package/src/service.ts +3 -3
- package/src/types.ts +4 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Pi Usage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**OpenAI ChatGPT and Claude subscription usage limits in Pi.**
|
|
4
4
|
|
|
5
|
-
Run `/usage` for a scrollable view of
|
|
5
|
+
Run `/usage` for a scrollable view of each account's allowance and reset times—not tokens consumed in the current Pi session. OpenAI bars show **percent left**, like Codex. Claude bars show **percent used**, like Claude Code, including the five-hour session, all-models weekly, and Fable weekly limits.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -18,7 +18,7 @@ Then restart Pi or run `/reload`. For a one-session preview:
|
|
|
18
18
|
pi -e /path/to/pi-extensions/pi-usage/index.ts
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
Published releases:
|
|
22
22
|
|
|
23
23
|
```sh
|
|
24
24
|
pi install npm:@nothumanwork/pi-usage
|
|
@@ -28,6 +28,8 @@ Requires Pi 1.4.0's extension/auth APIs and Node.js 22.19+. No background pollin
|
|
|
28
28
|
|
|
29
29
|
## Sign in
|
|
30
30
|
|
|
31
|
+
### OpenAI
|
|
32
|
+
|
|
31
33
|
Keep using **`/login openai` → Sign in with ChatGPT** for inference. Add the separate credential used by Codex to read usage:
|
|
32
34
|
|
|
33
35
|
```text
|
|
@@ -39,18 +41,58 @@ Choose the **same ChatGPT account/workspace** for both logins. The usage view di
|
|
|
39
41
|
|
|
40
42
|
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
43
|
|
|
42
|
-
|
|
44
|
+
#### Why the second OpenAI login?
|
|
43
45
|
|
|
44
46
|
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
47
|
|
|
46
48
|
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
49
|
|
|
50
|
+
### Claude: native Pi or Claude Code / pi-claude-bridge
|
|
51
|
+
|
|
52
|
+
If already signed into Claude Code (including through `pi-claude-bridge`), just run:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
/usage claude
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**No separate `/login anthropic` is needed for bridge users.** If Claude Code needs a login, run `claude auth login` in your terminal. Inference-only `claude setup-token` tokens may lack the profile permission needed for usage.
|
|
59
|
+
|
|
60
|
+
Alternatively, use Pi's native subscription login:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
/login anthropic
|
|
64
|
+
/usage claude
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Select the Claude subscription/OAuth option, not an API key. An Anthropic API key is not a subscription-usage credential.
|
|
68
|
+
|
|
69
|
+
#### Where authentication lives
|
|
70
|
+
|
|
71
|
+
| Login | Credential store | Who resolves and refreshes it |
|
|
72
|
+
| --- | --- | --- |
|
|
73
|
+
| Pi native Anthropic | `anthropic` entry in `~/.pi/agent/auth.json`, or `$PI_CODING_AGENT_DIR/auth.json` | Pi's `modelRegistry.getProviderAuth("anthropic")` |
|
|
74
|
+
| Claude Code / bridge on macOS | macOS Keychain, normally service `Claude Code-credentials`; fallback `~/.claude/.credentials.json` if Keychain storage is unavailable | Claude Code, through the Agent SDK |
|
|
75
|
+
| Claude Code / bridge on Linux | `~/.claude/.credentials.json` | Claude Code, through the Agent SDK |
|
|
76
|
+
| Claude Code / bridge on Windows | `%USERPROFILE%\.claude\.credentials.json` | Claude Code, through the Agent SDK |
|
|
77
|
+
|
|
78
|
+
`CLAUDE_CONFIG_DIR` changes Claude Code's credentials-file directory **and** the macOS Keychain entry it selects. The SDK handles this, including config-specific Keychain naming; the extension does not duplicate that lookup or rotate Claude Code's tokens itself. The bridge passes the process environment to Claude Code and does not maintain a separate subscription login in Pi. See [Claude Code authentication](https://code.claude.com/docs/en/authentication) and the [bridge source](https://github.com/elidickinson/pi-claude-bridge).
|
|
79
|
+
|
|
80
|
+
**Account selection:** if the selected Pi model uses `claude-bridge`, use Claude Code's login. Otherwise prefer native Pi Anthropic OAuth when present, falling back to Claude Code only when no native OAuth credential is configured (an API key also falls through). A failed selected login does **not** silently switch to another account. The view labels the source and shows identity when available.
|
|
81
|
+
|
|
82
|
+
The Claude Code path inspects the stored **subscription**, not API billing: API-key, API-endpoint, profile, and third-party backend environment overrides are removed from the usage-only child. `CLAUDE_CONFIG_DIR` and an explicit `CLAUDE_CODE_OAUTH_TOKEN` remain respected; an inference-only token results in an unavailable state rather than a hidden account switch.
|
|
83
|
+
|
|
84
|
+
The Agent SDK opens a short-lived Claude Code process in an empty temporary directory. It sends **no prompt**, enables no tools or MCP servers, disables ordinary hooks/settings, does not persist a conversation, and requests `skipBehaviors: true` to avoid scanning local transcripts. Claude Code's managed policies still apply. The process is closed and the temporary directory removed on success, failure, cancellation, or timeout. Credential refresh remains owned by Claude Code.
|
|
85
|
+
|
|
86
|
+
Keep npm optional dependencies enabled: the Agent SDK supplies a platform-specific Claude Code binary. No separately installed CLI executable is required to read an existing login. The SDK is loaded lazily, only for this auth path.
|
|
87
|
+
|
|
48
88
|
## Commands and controls
|
|
49
89
|
|
|
50
90
|
| Command / key | Action |
|
|
51
91
|
| --- | --- |
|
|
52
92
|
| `/usage` | Fetch all supported providers |
|
|
53
93
|
| `/usage openai` | Fetch only OpenAI |
|
|
94
|
+
| `/usage claude` | Fetch only Claude subscription usage |
|
|
95
|
+
| `/usage anthropic` | Alias for `/usage claude` |
|
|
54
96
|
| `r` | Fetch a fresh snapshot |
|
|
55
97
|
| Up / Down | Scroll |
|
|
56
98
|
| Page Up / Page Down | Scroll a page |
|
|
@@ -60,15 +102,18 @@ The terminal view is an overlay. RPC clients receive the same information as a n
|
|
|
60
102
|
|
|
61
103
|
The view includes:
|
|
62
104
|
|
|
63
|
-
-
|
|
64
|
-
-
|
|
105
|
+
- Account identity when available, plan when reported, credential source, and fetch time.
|
|
106
|
+
- OpenAI usage windows returned by the server, including five-hour, weekly, and additional model limits when present.
|
|
107
|
+
- Claude's **Current session (5h)**, **Current week (all models)**, and **Current week (Fable)**, plus other model-specific weekly limits when reported.
|
|
65
108
|
- Local reset date/time and a relative duration.
|
|
66
109
|
- Credit balance or unlimited-credit status when reported. Balances are not assumed to be dollars.
|
|
67
|
-
-
|
|
110
|
+
- Links to [ChatGPT Settings → Usage](https://chatgpt.com/settings/usage) and [Claude Settings → Usage](https://claude.ai/settings/usage).
|
|
68
111
|
|
|
69
112
|
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
113
|
|
|
71
|
-
Requests happen only when opening or refreshing the view, with no
|
|
114
|
+
Requests happen only when opening or refreshing the view, with no extension background polling or retry loop. Claude Code manages its own authentication/network behavior. A 15-second overall timeout bounds the wait, including auth resolution and SDK startup; closing the view cancels the usage request or SDK process. Pi may finish its own already-started credential refresh independently. One failed provider does not suppress other providers.
|
|
115
|
+
|
|
116
|
+
Claude's model-specific weekly limits are read from the SDK's `model_scoped` rows or the endpoint's `limits[]` rows—not an assumed `seven_day_fable` field. Missing Fable data remains **Not reported**, never 0% used. App/surface-specific caps and local behavior estimates are deliberately excluded.
|
|
72
117
|
|
|
73
118
|
## Implementation and extension points
|
|
74
119
|
|
|
@@ -78,6 +123,9 @@ src/types.ts Provider-neutral snapshots, windows, groups and errors
|
|
|
78
123
|
src/service.ts Independent provider collection, cancellation and timeouts
|
|
79
124
|
src/providers/index.ts Adapter registration
|
|
80
125
|
src/providers/openai.ts Pi auth + Codex usage adapter and normalization
|
|
126
|
+
src/providers/claude.ts Claude auth selection + native Pi OAuth HTTP adapter
|
|
127
|
+
src/providers/claude-code.ts Claude Agent SDK usage-only connection
|
|
128
|
+
src/providers/claude-normalize.ts Native/SDK quota normalization
|
|
81
129
|
src/format.ts Shared text presentation
|
|
82
130
|
src/view.ts Pi TUI overlay, refresh and scrolling
|
|
83
131
|
```
|
|
@@ -85,7 +133,7 @@ src/view.ts Pi TUI overlay, refresh and scrolling
|
|
|
85
133
|
To add a provider:
|
|
86
134
|
|
|
87
135
|
1. Implement `UsageProvider` from `src/types.ts`.
|
|
88
|
-
2. Resolve credentials through Pi and return a normalized `UsageSnapshot`.
|
|
136
|
+
2. Resolve credentials through Pi or the provider's own SDK and return a normalized `UsageSnapshot`. Set a window's `display: "used"` for Claude-style bars; the default is percent remaining.
|
|
89
137
|
3. Use `UsageError` for safe, actionable error messages. Never include tokens, raw auth errors, or response bodies.
|
|
90
138
|
4. Add the adapter to `createUsageProviders()` in `src/providers/index.ts` and add fixture-based tests.
|
|
91
139
|
|
|
@@ -94,13 +142,14 @@ No changes to the command or view should be needed. Other extensions are not dyn
|
|
|
94
142
|
### SDKs and endpoint stability
|
|
95
143
|
|
|
96
144
|
- **Pi SDK:** provider authentication and refresh; commands and UI.
|
|
97
|
-
- **OpenAI JavaScript
|
|
145
|
+
- **OpenAI and Anthropic JavaScript SDKs:** HTTP transport, error classification, timeouts and cancellation.
|
|
146
|
+
- **Claude Agent SDK:** Claude Code credential management and the structured usage control request. Version **0.3.310 is pinned** because `usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()` is explicitly experimental; the integration is isolated in one file.
|
|
98
147
|
- **jose:** decode account claims for routing/display (the server validates the bearer token).
|
|
99
148
|
- **Zod:** validate and normalize the usage response.
|
|
100
149
|
|
|
101
|
-
The
|
|
150
|
+
The direct adapters use the API SDKs' generic `get()` methods: OpenAI reads `GET https://chatgpt.com/backend-api/wham/usage`, as used by [Codex's backend client](https://github.com/openai/codex/tree/main/codex-rs/backend-client); native Anthropic OAuth reads `GET https://api.anthropic.com/api/oauth/usage` with `anthropic-beta: oauth-2025-04-20`. An optional `/api/oauth/profile` request supplies native Claude account identity. These are **not stable public billing APIs** and may change. The Claude Code path uses the Agent SDK control API instead of duplicating its auth/storage integration.
|
|
102
151
|
|
|
103
|
-
|
|
152
|
+
The direct adapters send credentials only to fixed provider endpoints; redirects and SDK logging are disabled. Account metadata is escaped for terminal display. The extension writes no token or usage cache. Pi and Claude Code may update their own original credential stores during refresh.
|
|
104
153
|
|
|
105
154
|
## Develop
|
|
106
155
|
|
|
@@ -113,4 +162,4 @@ npm test --workspace @nothumanwork/pi-usage
|
|
|
113
162
|
npm pack --workspace @nothumanwork/pi-usage --dry-run
|
|
114
163
|
```
|
|
115
164
|
|
|
116
|
-
|
|
165
|
+
Automated tests mock the network, credentials, and Claude Code process. They exercise the real API SDK transports and Pi extension loader, and cover both Claude auth paths, native/SDK quota schemas, account selection, used/remaining bars, errors, cancellation and process cleanup, multiple providers, narrow terminals, refresh, and scrolling. They do not make model requests or access a real account. Claude Code's usage-only SDK path has also been smoke-tested locally against a real subscription without any model requests.
|
package/index.ts
CHANGED
|
@@ -20,7 +20,7 @@ export function registerUsage(pi: ExtensionAPI, providers: readonly UsageProvide
|
|
|
20
20
|
return;
|
|
21
21
|
}
|
|
22
22
|
const id = args.trim().toLowerCase();
|
|
23
|
-
const selected = id ? providers.filter((provider) => provider.id === id) : providers;
|
|
23
|
+
const selected = id ? providers.filter((provider) => provider.id === id || provider.aliases?.includes(id)) : providers;
|
|
24
24
|
if (!selected.length) {
|
|
25
25
|
ctx.ui.notify(`Usage: /usage [${providers.map((provider) => provider.id).join("|")}]`, "warning");
|
|
26
26
|
return;
|
|
@@ -35,12 +35,12 @@ export function registerUsage(pi: ExtensionAPI, providers: readonly UsageProvide
|
|
|
35
35
|
if (ctx.mode === "tui") {
|
|
36
36
|
await ctx.ui.custom<void>((tui, theme, keys, done) => {
|
|
37
37
|
const view = new UsageView(tui, theme, keys, () => done(), (signal) =>
|
|
38
|
-
collectUsage(selected, ctx.modelRegistry, AbortSignal.any([signal, controller.signal])));
|
|
38
|
+
collectUsage(selected, ctx.modelRegistry, AbortSignal.any([signal, controller.signal]), { activeProvider: ctx.model?.provider }));
|
|
39
39
|
controller.signal.addEventListener("abort", () => view.close(), { once: true });
|
|
40
40
|
return view;
|
|
41
41
|
}, { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%" } });
|
|
42
42
|
} else {
|
|
43
|
-
const results = await collectUsage(selected, ctx.modelRegistry, controller.signal);
|
|
43
|
+
const results = await collectUsage(selected, ctx.modelRegistry, controller.signal, { activeProvider: ctx.model?.provider });
|
|
44
44
|
if (!controller.signal.aborted) ctx.ui.notify(formatUsage(results), "info");
|
|
45
45
|
}
|
|
46
46
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nothumanwork/pi-usage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "OpenAI ChatGPT and Claude subscription usage limits and reset times in Pi",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package"
|
|
8
8
|
],
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
"test": "tsx --test test/*.test.ts"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.310",
|
|
36
|
+
"@anthropic-ai/sdk": "^1.3.0",
|
|
35
37
|
"jose": "^6.2.14",
|
|
36
38
|
"openai": "^7.19.0",
|
|
37
39
|
"zod": "^4.6.7"
|
package/src/format.ts
CHANGED
|
@@ -24,10 +24,11 @@ export function formatWindow(window: UsageWindow, now: number): string[] {
|
|
|
24
24
|
const label = safeText(window.label);
|
|
25
25
|
if (window.remainingPercent === undefined) return [`${label}: Not reported`, ` ${formatReset(window.resetsAt, now)}`];
|
|
26
26
|
const left = Math.max(0, Math.min(100, window.remainingPercent));
|
|
27
|
-
const
|
|
27
|
+
const percent = window.display === "used" ? 100 - left : left;
|
|
28
|
+
const filled = Math.round(percent / 5);
|
|
28
29
|
const bar = "█".repeat(filled) + "░".repeat(20 - filled);
|
|
29
30
|
return [
|
|
30
|
-
`${label}: [${bar}] ${Math.round(
|
|
31
|
+
`${label}: [${bar}] ${Math.round(percent * 10) / 10}% ${window.display === "used" ? "used" : "left"}`,
|
|
31
32
|
` ${formatReset(window.resetsAt, now)}`,
|
|
32
33
|
];
|
|
33
34
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { AccountInfo, Options, Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { UsageError, type UsageSnapshot } from "../types.ts";
|
|
6
|
+
import { normalizeClaudeUsage } from "./claude-normalize.ts";
|
|
7
|
+
|
|
8
|
+
const USAGE_METHOD = "usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET";
|
|
9
|
+
export const CLAUDE_CODE_LOGIN_HINT = "Sign in with Claude Code (claude auth login), then retry /usage claude.";
|
|
10
|
+
type UsageSession = Pick<Query, "accountInfo" | typeof USAGE_METHOD | "close">;
|
|
11
|
+
export type StartUsageSession = (params: { prompt: AsyncIterable<SDKUserMessage>; options: Options }) => UsageSession;
|
|
12
|
+
|
|
13
|
+
/** Inspect the subscription, not API billing or a gateway. Preserve Code's
|
|
14
|
+
* CLAUDE_CONFIG_DIR and explicit OAuth token; never substitute another account. */
|
|
15
|
+
export function claudeUsageEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
16
|
+
const child = { ...env };
|
|
17
|
+
for (const key of Object.keys(child)) {
|
|
18
|
+
if (key.startsWith("ANTHROPIC_") || key.startsWith("CLAUDE_CODE_USE_")) delete child[key];
|
|
19
|
+
}
|
|
20
|
+
return { ...child, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_TELEMETRY: "1", DISABLE_ERROR_REPORTING: "1" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Keep stdin open for SDK control requests without ever submitting a prompt. */
|
|
24
|
+
async function* noPrompts(signal: AbortSignal): AsyncGenerator<SDKUserMessage> {
|
|
25
|
+
if (!signal.aborted) await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function fetchClaudeCodeUsage(signal: AbortSignal, options: {
|
|
29
|
+
start?: StartUsageSession;
|
|
30
|
+
env?: NodeJS.ProcessEnv;
|
|
31
|
+
now?: () => number;
|
|
32
|
+
} = {}): Promise<UsageSnapshot> {
|
|
33
|
+
signal.throwIfAborted();
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
let session: UsageSession | undefined;
|
|
36
|
+
let cwd: string | undefined;
|
|
37
|
+
let closed = false;
|
|
38
|
+
const stop = () => {
|
|
39
|
+
controller.abort();
|
|
40
|
+
if (session && !closed) {
|
|
41
|
+
closed = true;
|
|
42
|
+
try { session.close(); } catch { /* No raw SDK errors in logs/UI. */ }
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
46
|
+
try {
|
|
47
|
+
// Root entry inlines its runtime dependencies. /core needs extra MCP peers.
|
|
48
|
+
// Pin this SDK version because the structured usage method is experimental.
|
|
49
|
+
const start: StartUsageSession = options.start ?? (await import("@anthropic-ai/claude-agent-sdk")).query;
|
|
50
|
+
signal.throwIfAborted();
|
|
51
|
+
cwd = await mkdtemp(join(tmpdir(), "pi-usage-claude-"));
|
|
52
|
+
signal.throwIfAborted();
|
|
53
|
+
session = start({
|
|
54
|
+
prompt: noPrompts(controller.signal),
|
|
55
|
+
options: {
|
|
56
|
+
cwd,
|
|
57
|
+
env: claudeUsageEnvironment(options.env ?? process.env),
|
|
58
|
+
abortController: controller,
|
|
59
|
+
persistSession: false,
|
|
60
|
+
settingSources: [],
|
|
61
|
+
settings: { disableAllHooks: true, autoMemoryEnabled: false },
|
|
62
|
+
tools: [],
|
|
63
|
+
mcpServers: {},
|
|
64
|
+
strictMcpConfig: true,
|
|
65
|
+
permissionMode: "dontAsk",
|
|
66
|
+
stderr: () => {},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const account: AccountInfo = await session.accountInfo();
|
|
70
|
+
signal.throwIfAborted();
|
|
71
|
+
if (account.apiProvider && account.apiProvider !== "firstParty") {
|
|
72
|
+
throw new UsageError(`Claude Code is not using a Claude subscription. ${CLAUDE_CODE_LOGIN_HINT}`, "unavailable");
|
|
73
|
+
}
|
|
74
|
+
const report = await session[USAGE_METHOD]({ skipBehaviors: true });
|
|
75
|
+
signal.throwIfAborted();
|
|
76
|
+
if (!report.rate_limits_available) {
|
|
77
|
+
throw new UsageError(`Claude subscription usage needs a login with profile access, not an API key or inference-only setup token. ${CLAUDE_CODE_LOGIN_HINT}`, "unavailable");
|
|
78
|
+
}
|
|
79
|
+
if (!report.rate_limits) {
|
|
80
|
+
throw new UsageError("Claude Code could not retrieve subscription limits. Check Claude Code /usage and try again later.");
|
|
81
|
+
}
|
|
82
|
+
return normalizeClaudeUsage(report.rate_limits, {
|
|
83
|
+
account: account.email || "Claude Code subscription (identity not reported)",
|
|
84
|
+
plan: report.subscription_type ?? account.subscriptionType,
|
|
85
|
+
source: "Claude Code login (Agent SDK; also used by pi-claude-bridge)",
|
|
86
|
+
}, options.now?.() ?? Date.now());
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (signal.aborted) throw signal.reason;
|
|
89
|
+
if (error instanceof UsageError) throw error;
|
|
90
|
+
throw new UsageError(`Could not read usage through the Claude Agent SDK. Ensure its optional platform package is installed. ${CLAUDE_CODE_LOGIN_HINT}`);
|
|
91
|
+
} finally {
|
|
92
|
+
signal.removeEventListener("abort", stop);
|
|
93
|
+
stop();
|
|
94
|
+
if (cwd) await rm(cwd, { recursive: true, force: true }).catch(() => {});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { UsageError, type UsageSnapshot, type UsageWindow } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
const percent = z.number().finite().nonnegative().nullish();
|
|
5
|
+
const windowSchema = z.object({ utilization: percent, resets_at: z.string().nullish() });
|
|
6
|
+
const modelSchema = z.object({ display_name: z.string().min(1) });
|
|
7
|
+
const payloadSchema = z.object({
|
|
8
|
+
five_hour: windowSchema.nullish(),
|
|
9
|
+
seven_day: windowSchema.nullish(),
|
|
10
|
+
seven_day_opus: windowSchema.nullish(),
|
|
11
|
+
seven_day_sonnet: windowSchema.nullish(),
|
|
12
|
+
// Claude Agent SDK get_usage normalizes the model rows into this field.
|
|
13
|
+
model_scoped: z.array(windowSchema.extend({ display_name: z.string().min(1) })).nullish(),
|
|
14
|
+
// Native Pi OAuth uses the endpoint directly. Fable lives here, NOT in an
|
|
15
|
+
// assumed seven_day_fable field. is_active is a headline pick, not a filter.
|
|
16
|
+
limits: z.array(z.object({
|
|
17
|
+
kind: z.string(),
|
|
18
|
+
percent,
|
|
19
|
+
resets_at: z.string().nullish(),
|
|
20
|
+
scope: z.object({ model: modelSchema.nullish(), surface: modelSchema.nullish() }).nullish(),
|
|
21
|
+
})).nullish(),
|
|
22
|
+
}).refine((data) => Object.values(data).some((value) => value !== undefined));
|
|
23
|
+
|
|
24
|
+
export interface ClaudeIdentity { account: string; plan?: string; source: string }
|
|
25
|
+
|
|
26
|
+
function toWindow(label: string, value?: z.infer<typeof windowSchema> | null): UsageWindow {
|
|
27
|
+
// Invalid/absent reset times stay unknown. Never use OAuth expiry as a reset.
|
|
28
|
+
const validTime = z.iso.datetime({ offset: true }).safeParse(value?.resets_at);
|
|
29
|
+
const reset = validTime.success ? Date.parse(validTime.data) : NaN;
|
|
30
|
+
return {
|
|
31
|
+
label,
|
|
32
|
+
display: "used",
|
|
33
|
+
remainingPercent: value?.utilization == null ? undefined : Math.max(0, 100 - value.utilization),
|
|
34
|
+
resetsAt: Number.isFinite(reset) ? reset : undefined,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function normalizeClaudeUsage(payload: unknown, identity: ClaudeIdentity, now = Date.now()): UsageSnapshot {
|
|
39
|
+
const parsed = payloadSchema.safeParse(payload);
|
|
40
|
+
if (!parsed.success) throw new UsageError("Claude returned an unrecognized usage response. Check Claude Code /usage or the usage dashboard.");
|
|
41
|
+
const data = parsed.data;
|
|
42
|
+
let session = data.five_hour;
|
|
43
|
+
let weekly = data.seven_day;
|
|
44
|
+
const models = new Map<string, { name: string; window: z.infer<typeof windowSchema> }>();
|
|
45
|
+
const addModel = (name: string, window: z.infer<typeof windowSchema>) => {
|
|
46
|
+
const key = name.trim().toLowerCase();
|
|
47
|
+
if (key && !models.has(key)) models.set(key, { name: name.trim(), window });
|
|
48
|
+
};
|
|
49
|
+
if (data.seven_day_opus) addModel("Opus", data.seven_day_opus);
|
|
50
|
+
if (data.seven_day_sonnet) addModel("Sonnet", data.seven_day_sonnet);
|
|
51
|
+
for (const model of data.model_scoped ?? []) addModel(model.display_name, model);
|
|
52
|
+
for (const limit of data.limits ?? []) {
|
|
53
|
+
const value = { utilization: limit.percent, resets_at: limit.resets_at };
|
|
54
|
+
if (limit.kind === "session" && !limit.scope?.model && !limit.scope?.surface) session ??= value;
|
|
55
|
+
if (limit.kind === "weekly_all" && !limit.scope?.model && !limit.scope?.surface) weekly ??= value;
|
|
56
|
+
if (limit.kind === "weekly_scoped" && limit.scope?.model && !limit.scope.surface) {
|
|
57
|
+
addModel(limit.scope.model.display_name, value);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const windows = [toWindow("Current session (5h)", session), toWindow("Current week (all models)", weekly)];
|
|
61
|
+
// Always expose the three requested buckets. A missing Fable row is unknown,
|
|
62
|
+
// not 0% used; extra plan-dependent model buckets follow it.
|
|
63
|
+
const fable = models.get("fable");
|
|
64
|
+
windows.push(toWindow("Current week (Fable)", fable?.window));
|
|
65
|
+
for (const [key, model] of models) {
|
|
66
|
+
if (key !== "fable") windows.push(toWindow(`Current week (${model.name})`, model.window));
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
...identity,
|
|
70
|
+
fetchedAt: now,
|
|
71
|
+
groups: [{ label: "Subscription allowance", windows, limitReached: windows.some((window) => window.remainingPercent === 0) }],
|
|
72
|
+
notes: ["Claude subscription usage, not this Pi session's token totals. App-specific caps are not included."],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { UsageError, type AuthRegistry, type UsageProvider, type UsageSnapshot } from "../types.ts";
|
|
4
|
+
import { fetchClaudeCodeUsage } from "./claude-code.ts";
|
|
5
|
+
import { normalizeClaudeUsage } from "./claude-normalize.ts";
|
|
6
|
+
|
|
7
|
+
const BASE_URL = "https://api.anthropic.com";
|
|
8
|
+
const PI_LOGIN_HINT = "Run /login anthropic and choose the Claude subscription login, then retry /usage claude.";
|
|
9
|
+
|
|
10
|
+
async function piToken(registry: AuthRegistry): Promise<string | undefined> {
|
|
11
|
+
if (!registry.getProvider("anthropic")) return undefined;
|
|
12
|
+
let auth;
|
|
13
|
+
try { auth = await registry.getProviderAuth("anthropic"); }
|
|
14
|
+
catch { throw new UsageError(`Could not refresh Pi's Anthropic login. ${PI_LOGIN_HINT}`); }
|
|
15
|
+
// API keys are not subscription credentials. Never resolve bridge's dummy key.
|
|
16
|
+
if (auth?.source !== "OAuth") return undefined;
|
|
17
|
+
if (!auth.auth.apiKey) throw new UsageError(`Pi's Anthropic OAuth login is incomplete. ${PI_LOGIN_HINT}`);
|
|
18
|
+
return auth.auth.apiKey;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function fetchPiUsage(token: string, signal: AbortSignal, options: { fetch?: typeof globalThis.fetch; now?: () => number }): Promise<UsageSnapshot> {
|
|
22
|
+
const client = new Anthropic({
|
|
23
|
+
apiKey: null,
|
|
24
|
+
authToken: token,
|
|
25
|
+
baseURL: BASE_URL,
|
|
26
|
+
logLevel: "off",
|
|
27
|
+
maxRetries: 0,
|
|
28
|
+
timeout: 12_000,
|
|
29
|
+
fetch: options.fetch,
|
|
30
|
+
fetchOptions: { redirect: "error" },
|
|
31
|
+
defaultHeaders: { "anthropic-beta": "oauth-2025-04-20" },
|
|
32
|
+
});
|
|
33
|
+
let payload: unknown;
|
|
34
|
+
try {
|
|
35
|
+
// There is no generated subscription-quota method in the API SDK. Pi owns
|
|
36
|
+
// OAuth storage and refresh; the official SDK owns HTTP transport.
|
|
37
|
+
payload = await client.get<unknown>("/api/oauth/usage", { signal });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (signal.aborted) throw signal.reason;
|
|
40
|
+
if (error instanceof Anthropic.APIError) {
|
|
41
|
+
if (error.status === 401) throw new UsageError(`Pi's Anthropic login was rejected. ${PI_LOGIN_HINT}`);
|
|
42
|
+
if (error.status === 403) throw new UsageError(`Claude usage needs subscription/profile permission. ${PI_LOGIN_HINT}`);
|
|
43
|
+
if (error.status === 429) throw new UsageError("Claude throttled the usage check. Wait before refreshing; this does not mean your subscription allowance is empty.");
|
|
44
|
+
if (error.status && error.status >= 500) throw new UsageError("Claude usage is temporarily unavailable. Try again later.");
|
|
45
|
+
}
|
|
46
|
+
if (error instanceof Anthropic.APIConnectionTimeoutError) throw new UsageError("Claude usage request timed out. Try again.");
|
|
47
|
+
throw new UsageError("Could not retrieve Claude usage. Check your connection or the usage dashboard.");
|
|
48
|
+
}
|
|
49
|
+
// Identity is supplementary: a profile failure must not hide known quotas.
|
|
50
|
+
// This uses the very same token, never account data from another login store.
|
|
51
|
+
const profile = await client.get<unknown>("/api/oauth/profile", { signal, timeout: 2_000 }).catch(() => undefined);
|
|
52
|
+
signal.throwIfAborted();
|
|
53
|
+
const parsed = z.object({ account: z.object({ email: z.string().min(1) }) }).safeParse(profile);
|
|
54
|
+
return normalizeClaudeUsage(payload, {
|
|
55
|
+
account: parsed.success ? parsed.data.account.email : "Pi Anthropic subscription (identity not reported)",
|
|
56
|
+
source: "Pi Anthropic OAuth login",
|
|
57
|
+
}, options.now?.() ?? Date.now());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createClaudeProvider(options: {
|
|
61
|
+
fetch?: typeof globalThis.fetch;
|
|
62
|
+
now?: () => number;
|
|
63
|
+
readCodeUsage?: (signal: AbortSignal) => Promise<UsageSnapshot>;
|
|
64
|
+
} = {}): UsageProvider {
|
|
65
|
+
return {
|
|
66
|
+
id: "claude",
|
|
67
|
+
aliases: ["anthropic"],
|
|
68
|
+
label: "Anthropic · Claude",
|
|
69
|
+
dashboardUrl: "https://claude.ai/settings/usage",
|
|
70
|
+
async fetch({ registry, signal, activeProvider }) {
|
|
71
|
+
signal.throwIfAborted();
|
|
72
|
+
const readCode = options.readCodeUsage ?? ((signal) => fetchClaudeCodeUsage(signal, { now: options.now }));
|
|
73
|
+
// If both accounts exist, follow the selected bridge rather than silently
|
|
74
|
+
// showing a potentially different native Anthropic account. A chosen
|
|
75
|
+
// source failing is an error, not permission to switch accounts.
|
|
76
|
+
if (activeProvider === "claude-bridge") return readCode(signal);
|
|
77
|
+
const token = await piToken(registry);
|
|
78
|
+
signal.throwIfAborted();
|
|
79
|
+
return token ? fetchPiUsage(token, signal, options) : readCode(signal);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
package/src/providers/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { UsageProvider } from "../types.ts";
|
|
2
2
|
import { createOpenAIProvider } from "./openai.ts";
|
|
3
|
+
import { createClaudeProvider } from "./claude.ts";
|
|
3
4
|
|
|
4
5
|
// Add an adapter here; commands, collection, and rendering remain provider-neutral.
|
|
5
6
|
export function createUsageProviders(): UsageProvider[] {
|
|
6
|
-
return [createOpenAIProvider()];
|
|
7
|
+
return [createOpenAIProvider(), createClaudeProvider()];
|
|
7
8
|
}
|
package/src/service.ts
CHANGED
|
@@ -15,15 +15,15 @@ export async function collectUsage(
|
|
|
15
15
|
providers: readonly UsageProvider[],
|
|
16
16
|
registry: AuthRegistry,
|
|
17
17
|
signal: AbortSignal,
|
|
18
|
-
timeoutMs =
|
|
18
|
+
options: { timeoutMs?: number; activeProvider?: string } = {},
|
|
19
19
|
): Promise<UsageResult[]> {
|
|
20
20
|
signal.throwIfAborted();
|
|
21
21
|
const results = await Promise.all(providers.map(async (adapter): Promise<UsageResult> => {
|
|
22
22
|
const provider = { id: adapter.id, label: adapter.label, dashboardUrl: adapter.dashboardUrl };
|
|
23
|
-
const timeout = AbortSignal.timeout(timeoutMs);
|
|
23
|
+
const timeout = AbortSignal.timeout(options.timeoutMs ?? 15_000);
|
|
24
24
|
const combined = AbortSignal.any([signal, timeout]);
|
|
25
25
|
try {
|
|
26
|
-
const snapshot = await abortable(Promise.resolve().then(() => adapter.fetch({ registry, signal: combined })), combined);
|
|
26
|
+
const snapshot = await abortable(Promise.resolve().then(() => adapter.fetch({ registry, signal: combined, activeProvider: options.activeProvider })), combined);
|
|
27
27
|
return { provider, status: "ok", snapshot };
|
|
28
28
|
} catch (error) {
|
|
29
29
|
if (signal.aborted) throw signal.reason;
|
package/src/types.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type AuthRegistry = Pick<ExtensionContext["modelRegistry"], "getProviderA
|
|
|
6
6
|
export interface UsageWindow {
|
|
7
7
|
label: string;
|
|
8
8
|
remainingPercent?: number;
|
|
9
|
+
/** Default is remaining (Codex); Claude displays used like Claude Code. */
|
|
10
|
+
display?: "remaining" | "used";
|
|
9
11
|
resetsAt?: number; // Unix milliseconds, not credential expiry.
|
|
10
12
|
}
|
|
11
13
|
|
|
@@ -28,8 +30,9 @@ export interface UsageSnapshot {
|
|
|
28
30
|
export interface UsageProvider {
|
|
29
31
|
id: string;
|
|
30
32
|
label: string;
|
|
33
|
+
aliases?: readonly string[];
|
|
31
34
|
dashboardUrl: string;
|
|
32
|
-
fetch(context: { registry: AuthRegistry; signal: AbortSignal }): Promise<UsageSnapshot>;
|
|
35
|
+
fetch(context: { registry: AuthRegistry; signal: AbortSignal; activeProvider?: string }): Promise<UsageSnapshot>;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export type UsageResult = {
|