pi-usage-lite 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +160 -2
- package/package.json +52 -4
- package/src/index.ts +558 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HossinAmin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,161 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-usage-lite
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A minimal, dependency-free [Pi Coding Agent](https://pi.dev) extension that keeps selected provider usage and quota information visible in the footer.
|
|
4
|
+
|
|
5
|
+
The extension is intentionally small: one TypeScript file, no runtime package dependencies, automatic footer refreshes, and two commands.
|
|
6
|
+
|
|
7
|
+
> **Status:** early release. Some provider usage endpoints are undocumented and may change without notice.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Shows usage in Pi's footer for the active model provider.
|
|
12
|
+
- Refreshes when a session starts, when the model changes, after each agent run, and every 60 seconds.
|
|
13
|
+
- Keeps the last known values visible when a later refresh fails.
|
|
14
|
+
- Provides `/usage` to refresh the active provider.
|
|
15
|
+
- Provides `/usage-all` to show usage for every supported provider currently connected to Pi.
|
|
16
|
+
|
|
17
|
+
## Supported providers
|
|
18
|
+
|
|
19
|
+
| Provider | Information shown |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| Kimi for Coding | Five-hour and weekly plan windows |
|
|
22
|
+
| OpenAI Codex | Available rate-limit windows and reset times |
|
|
23
|
+
| Claude Code Bridge | Five-hour, weekly, Opus, Sonnet, and extra-usage information when available |
|
|
24
|
+
| OpenCode Zen | Numeric credit balance and spending information |
|
|
25
|
+
|
|
26
|
+
Quota-window providers use the same footer style: window label, usage bar, used percentage, and reset countdown. OpenCode Zen remains numeric because it is credit-based rather than quota-based.
|
|
27
|
+
|
|
28
|
+
Provider data is shown only when Pi has compatible credentials configured.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
Install a GitHub release tag. Pinning a release prevents later changes on `main` or `dev` from affecting your installation unexpectedly:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pi install git:github.com/HossinAmin/pi-usage-lite@v0.1.1
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Check [GitHub Releases](https://github.com/HossinAmin/pi-usage-lite/releases) for the newest tag, then restart Pi or run:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
/reload
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
To try a release without installing it permanently:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pi -e git:github.com/HossinAmin/pi-usage-lite@v0.1.1
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For local development:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git clone git@github.com:HossinAmin/pi-usage-lite.git
|
|
54
|
+
cd pi-usage-lite
|
|
55
|
+
pi -e .
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
|
|
60
|
+
### `/usage`
|
|
61
|
+
|
|
62
|
+
Refreshes the footer entry for the currently selected model's provider.
|
|
63
|
+
|
|
64
|
+
### `/usage-all`
|
|
65
|
+
|
|
66
|
+
Checks every supported provider that appears connected and displays the available usage information in a notification.
|
|
67
|
+
|
|
68
|
+
## OpenCode Zen dashboard fallback
|
|
69
|
+
|
|
70
|
+
The extension first tries OpenCode's official credits API using Pi's configured `opencode` provider credentials. Credit information is displayed numerically, without a quota bar.
|
|
71
|
+
|
|
72
|
+
If that API is unavailable, it can use an optional dashboard configuration. Create this file only if you need the fallback:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
~/.pi/agent/usage-zen.json
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Example shape:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"workspaceId": "your-workspace-id",
|
|
83
|
+
"authCookie": "your-open-code-auth-cookie"
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The `authCookie` is a sensitive credential. Keep the file private:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
chmod 600 ~/.pi/agent/usage-zen.json
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Do not commit this file. It is listed in this repository's `.gitignore` in case it is accidentally copied into a checkout.
|
|
94
|
+
|
|
95
|
+
You can also provide the fallback configuration through environment variables:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
export OPENCODE_ZEN_WORKSPACE_ID="your-workspace-id"
|
|
99
|
+
export OPENCODE_ZEN_AUTH_COOKIE="your-open-code-auth-cookie"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Security and privacy
|
|
103
|
+
|
|
104
|
+
- This repository does not contain provider API keys, OAuth tokens, cookies, or workspace credentials.
|
|
105
|
+
- At runtime, the extension reads credentials from Pi's provider authentication, supported environment variables, Claude Code's OAuth configuration, or the optional local configuration files described above.
|
|
106
|
+
- Credentials are used only to request usage information from their corresponding provider endpoints.
|
|
107
|
+
- Like every Pi extension, this extension runs with your operating-system user permissions. Review the source before installing it.
|
|
108
|
+
- The OpenCode dashboard fallback relies on web-session cookies and undocumented page/server behavior. It is more fragile than the official credits API and may stop working after an OpenCode deployment.
|
|
109
|
+
|
|
110
|
+
## Compatibility notes
|
|
111
|
+
|
|
112
|
+
- The extension registers `/usage`. Do not load it alongside another extension that registers the same command.
|
|
113
|
+
- Usage endpoints and response formats are provider-controlled and may change.
|
|
114
|
+
- The footer displays the last successful value if a refresh fails, so values can be stale until the next successful refresh.
|
|
115
|
+
- This project is not affiliated with Pi, OpenAI, Anthropic, Moonshot AI, Kimi, or OpenCode.
|
|
116
|
+
|
|
117
|
+
## Release workflow
|
|
118
|
+
|
|
119
|
+
Development happens on the `dev` branch or feature branches. Releases are created from `main`.
|
|
120
|
+
|
|
121
|
+
1. Make changes on `dev` or a feature branch.
|
|
122
|
+
2. Bump `package.json` before opening a pull request into `main`:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
bun pm pkg set version=0.1.2
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Use the next appropriate semver version instead of `0.1.2`.
|
|
129
|
+
|
|
130
|
+
3. Merge the pull request into `main`.
|
|
131
|
+
4. The [`Release`](./.github/workflows/release.yml) workflow validates the version, creates the matching `vX.Y.Z` tag, and publishes a GitHub Release with generated notes.
|
|
132
|
+
|
|
133
|
+
The [`Version check`](./.github/workflows/version-check.yml) workflow blocks pull requests into `main` when `package.json` still has the base branch's version. This keeps every merge into `main` releasable.
|
|
134
|
+
|
|
135
|
+
Users should install release tags such as `@v0.1.1`, not the moving `main` or `dev` branches.
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
The extension source is [`src/index.ts`](./src/index.ts).
|
|
140
|
+
|
|
141
|
+
Useful local commands:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
# Install development dependencies
|
|
145
|
+
bun install
|
|
146
|
+
|
|
147
|
+
# Typecheck the extension
|
|
148
|
+
bun run typecheck
|
|
149
|
+
|
|
150
|
+
# Try the package locally
|
|
151
|
+
pi -e .
|
|
152
|
+
|
|
153
|
+
# Inspect the extension source
|
|
154
|
+
${EDITOR:-nano} src/index.ts
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
There is no build step for users; Pi loads the TypeScript source through Jiti. Development dependencies are used only for local typechecking and release validation.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
[MIT](./LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-usage-lite",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A minimal Pi Coding Agent extension that keeps selected provider quotas visible in the footer.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "HossinAmin",
|
|
9
|
+
"email": "hossinamin70@gmail.com"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+ssh://git@github.com/HossinAmin/pi-usage-lite.git"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/HossinAmin/pi-usage-lite#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/HossinAmin/pi-usage-lite/issues"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"pi-package",
|
|
21
|
+
"pi-extension",
|
|
22
|
+
"pi",
|
|
23
|
+
"pi-coding-agent",
|
|
24
|
+
"usage",
|
|
25
|
+
"quota",
|
|
26
|
+
"statusline",
|
|
27
|
+
"codex",
|
|
28
|
+
"kimi",
|
|
29
|
+
"claude",
|
|
30
|
+
"opencode"
|
|
31
|
+
],
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": [
|
|
39
|
+
"./src/index.ts"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@earendil-works/pi-coding-agent": "1.0.4",
|
|
47
|
+
"@types/node": "^26.6.4",
|
|
48
|
+
"typescript": "^7.0.2"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"typecheck": "tsc --noEmit"
|
|
52
|
+
},
|
|
53
|
+
"packageManager": "bun@1.3.14"
|
|
54
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage Extension
|
|
3
|
+
*
|
|
4
|
+
* Shows provider usage/quota in the footer. Currently supports Kimi for
|
|
5
|
+
* Coding (5-hour + weekly quotas), OpenAI Codex (its available rate-limit
|
|
6
|
+
* windows), Claude Code Bridge (Claude plan windows), and OpenCode Zen credits.
|
|
7
|
+
* The status only appears while the active
|
|
8
|
+
* model belongs to a supported provider. `/usage-all` shows quota information
|
|
9
|
+
* for every supported plan currently connected to pi.
|
|
10
|
+
*
|
|
11
|
+
* Updates after every prompt completion (agent_settled) and every 60s.
|
|
12
|
+
*
|
|
13
|
+
* To add more providers later, push another entry into USAGE_PROVIDERS.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Buffer } from "node:buffer";
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
|
|
22
|
+
const STATUS_ID = "usage";
|
|
23
|
+
const REFRESH_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
function fmtDuration(ms: number): string {
|
|
30
|
+
if (!Number.isFinite(ms)) return "?";
|
|
31
|
+
if (ms <= 0) return "now";
|
|
32
|
+
const m = Math.floor(ms / 60000);
|
|
33
|
+
if (m < 60) return `${m}m`;
|
|
34
|
+
const h = Math.floor(m / 60);
|
|
35
|
+
if (h < 24) return `${h}h${m % 60}m`;
|
|
36
|
+
const d = Math.floor(h / 24);
|
|
37
|
+
return `${d}d${h % 24}h`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fmtCountdown(resetIso: string): string {
|
|
41
|
+
return fmtDuration(new Date(resetIso).getTime() - Date.now());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Provider handlers
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
interface UsageProvider {
|
|
49
|
+
/** Provider id used to resolve authentication. */
|
|
50
|
+
providerId: string;
|
|
51
|
+
/** Short label shown in the footer and command output. */
|
|
52
|
+
label: string;
|
|
53
|
+
/** Return true if this handler serves the given provider id. */
|
|
54
|
+
matches(provider: string): boolean;
|
|
55
|
+
/** Optional custom connection check for providers with extra credentials. */
|
|
56
|
+
connected?(ctx: ExtensionContext): Promise<boolean>;
|
|
57
|
+
/** Fetch and format the usage text, or undefined on failure. */
|
|
58
|
+
fetch(ctx: ExtensionContext): Promise<string | undefined>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function isConnected(handler: UsageProvider, ctx: ExtensionContext): Promise<boolean> {
|
|
62
|
+
if (handler.connected) return handler.connected(ctx);
|
|
63
|
+
const result = await ctx.modelRegistry.getProviderAuth(handler.providerId);
|
|
64
|
+
if (result?.auth?.apiKey) return true;
|
|
65
|
+
return Object.keys(result?.auth?.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface KimiQuotaDetail {
|
|
69
|
+
limit?: string;
|
|
70
|
+
used?: string;
|
|
71
|
+
remaining?: string;
|
|
72
|
+
resetTime?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface KimiUsagesResponse {
|
|
76
|
+
usage?: KimiQuotaDetail; // weekly quota
|
|
77
|
+
limits?: Array<{
|
|
78
|
+
window?: { duration?: number; timeUnit?: string };
|
|
79
|
+
detail?: KimiQuotaDetail; // session (5-hour) window
|
|
80
|
+
}>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseKimiCount(value: string | undefined): number | undefined {
|
|
84
|
+
if (!value) return undefined;
|
|
85
|
+
const parsed = Number(value.replace(/,/g, ""));
|
|
86
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatKimiWindow(label: string, detail: KimiQuotaDetail | undefined): string | undefined {
|
|
90
|
+
const used = parseKimiCount(detail?.used);
|
|
91
|
+
const limit = parseKimiCount(detail?.limit);
|
|
92
|
+
if (used === undefined || limit === undefined || limit <= 0) return undefined;
|
|
93
|
+
const percent = Math.round((used / limit) * 100);
|
|
94
|
+
return `${label} ${progressBar(percent)} ${percent}% ↻${fmtCountdown(detail?.resetTime ?? "")}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const kimiCoding: UsageProvider = {
|
|
98
|
+
providerId: "kimi-coding",
|
|
99
|
+
label: "kimi",
|
|
100
|
+
matches: (provider) => /kimi/i.test(provider),
|
|
101
|
+
async fetch(ctx) {
|
|
102
|
+
const result = await ctx.modelRegistry.getProviderAuth("kimi-coding");
|
|
103
|
+
const apiKey = result?.auth?.apiKey;
|
|
104
|
+
const authHeaders = result?.auth?.headers ?? {};
|
|
105
|
+
const hasAuthorization = Object.keys(authHeaders).some((key) => key.toLowerCase() === "authorization");
|
|
106
|
+
if (!apiKey && !hasAuthorization) return undefined;
|
|
107
|
+
|
|
108
|
+
const base = (result?.auth?.baseUrl ?? "https://api.kimi.com/coding").replace(/\/+$/, "");
|
|
109
|
+
const res = await fetch(`${base}/v1/usages`, {
|
|
110
|
+
headers: {
|
|
111
|
+
"User-Agent": "KimiCLI/1.5",
|
|
112
|
+
...authHeaders,
|
|
113
|
+
// OAuth providers (including Kimi) supply Authorization in authHeaders.
|
|
114
|
+
// API-key providers use the fallback below.
|
|
115
|
+
...(hasAuthorization || !apiKey ? {} : { Authorization: `Bearer ${apiKey}` }),
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
if (!res.ok) return undefined;
|
|
119
|
+
|
|
120
|
+
const data = (await res.json()) as KimiUsagesResponse;
|
|
121
|
+
const session = data.limits?.[0]?.detail; // 5-hour session window
|
|
122
|
+
const weekly = data.usage; // weekly quota
|
|
123
|
+
|
|
124
|
+
const parts = [
|
|
125
|
+
formatKimiWindow("5h", session),
|
|
126
|
+
formatKimiWindow("wk", weekly),
|
|
127
|
+
].filter((part): part is string => part !== undefined);
|
|
128
|
+
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
interface CodexUsageWindow {
|
|
133
|
+
used_percent?: number;
|
|
134
|
+
limit_window_seconds?: number;
|
|
135
|
+
reset_after_seconds?: number;
|
|
136
|
+
reset_at?: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface CodexUsageResponse {
|
|
140
|
+
rate_limit?: {
|
|
141
|
+
primary_window?: CodexUsageWindow | null;
|
|
142
|
+
secondary_window?: CodexUsageWindow | null;
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function codexWindowLabel(seconds?: number): string {
|
|
147
|
+
if (seconds == null) return "limit";
|
|
148
|
+
if (seconds >= 6 * 24 * 60 * 60) return "wk";
|
|
149
|
+
if (seconds >= 20 * 60 * 60 && seconds <= 28 * 60 * 60) return "day";
|
|
150
|
+
if (seconds >= 4 * 60 * 60 && seconds <= 6 * 60 * 60) return "5h";
|
|
151
|
+
const hours = Math.round(seconds / 3600);
|
|
152
|
+
return hours >= 24 && hours % 24 === 0 ? `${hours / 24}d` : `${hours}h`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function progressBar(percent: number, width = 8): string {
|
|
156
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
157
|
+
const filled = Math.round((clamped / 100) * width);
|
|
158
|
+
return `[${"█".repeat(filled)}${"░".repeat(width - filled)}]`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function formatCodexWindow(window: CodexUsageWindow | null | undefined): string | undefined {
|
|
162
|
+
if (typeof window?.used_percent !== "number") return undefined;
|
|
163
|
+
const reset = typeof window.reset_after_seconds === "number"
|
|
164
|
+
? fmtDuration(window.reset_after_seconds * 1000)
|
|
165
|
+
: typeof window.reset_at === "number"
|
|
166
|
+
? fmtDuration(window.reset_at * 1000 - Date.now())
|
|
167
|
+
: "?";
|
|
168
|
+
const percent = Math.round(window.used_percent);
|
|
169
|
+
return `${codexWindowLabel(window.limit_window_seconds)} ${progressBar(percent)} ${percent}% ↻${reset}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function codexAccountId(token: string): string | undefined {
|
|
173
|
+
try {
|
|
174
|
+
const payload = token.split(".")[1];
|
|
175
|
+
if (!payload) return undefined;
|
|
176
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
|
|
177
|
+
"https://api.openai.com/auth"?: { chatgpt_account_id?: string };
|
|
178
|
+
};
|
|
179
|
+
const accountId = claims["https://api.openai.com/auth"]?.chatgpt_account_id;
|
|
180
|
+
return typeof accountId === "string" && accountId ? accountId : undefined;
|
|
181
|
+
} catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const openaiCodex: UsageProvider = {
|
|
187
|
+
providerId: "openai-codex",
|
|
188
|
+
label: "codex",
|
|
189
|
+
matches: (provider) => provider === "openai-codex",
|
|
190
|
+
async fetch(ctx) {
|
|
191
|
+
const result = await ctx.modelRegistry.getProviderAuth("openai-codex");
|
|
192
|
+
const token = result?.auth?.apiKey;
|
|
193
|
+
const accountId = token ? codexAccountId(token) : undefined;
|
|
194
|
+
if (!token || !accountId) return undefined;
|
|
195
|
+
|
|
196
|
+
const base = (result.auth.baseUrl ?? "https://chatgpt.com/backend-api").replace(/\/+$/, "");
|
|
197
|
+
const res = await fetch(`${base}/wham/usage`, {
|
|
198
|
+
headers: {
|
|
199
|
+
...result.auth.headers,
|
|
200
|
+
Authorization: `Bearer ${token}`,
|
|
201
|
+
"chatgpt-account-id": accountId,
|
|
202
|
+
originator: "pi",
|
|
203
|
+
"User-Agent": "pi",
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
if (!res.ok) return undefined;
|
|
207
|
+
|
|
208
|
+
const data = (await res.json()) as CodexUsageResponse;
|
|
209
|
+
const parts = [
|
|
210
|
+
formatCodexWindow(data.rate_limit?.primary_window),
|
|
211
|
+
formatCodexWindow(data.rate_limit?.secondary_window),
|
|
212
|
+
].filter((part): part is string => part !== undefined);
|
|
213
|
+
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
interface ClaudeCodeCredentials {
|
|
218
|
+
claudeAiOauth?: { accessToken?: string };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface ClaudeUsageWindow {
|
|
222
|
+
utilization?: number;
|
|
223
|
+
resets_at?: string | null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface ClaudeUsageResponse {
|
|
227
|
+
five_hour?: ClaudeUsageWindow | null;
|
|
228
|
+
seven_day?: ClaudeUsageWindow | null;
|
|
229
|
+
seven_day_opus?: ClaudeUsageWindow | null;
|
|
230
|
+
seven_day_sonnet?: ClaudeUsageWindow | null;
|
|
231
|
+
extra_usage?: {
|
|
232
|
+
is_enabled?: boolean;
|
|
233
|
+
used_credits?: number | null;
|
|
234
|
+
monthly_limit?: number | null;
|
|
235
|
+
currency?: string | null;
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
240
|
+
const CLAUDE_CACHE_MS = 5 * 60_000;
|
|
241
|
+
let claudeUsageCache: { fetchedAt: number; data: ClaudeUsageResponse } | undefined;
|
|
242
|
+
|
|
243
|
+
function claudeCredentialsPath(): string {
|
|
244
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
245
|
+
return join(configDir, ".credentials.json");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function loadClaudeAccessToken(): Promise<string | undefined> {
|
|
249
|
+
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
250
|
+
try {
|
|
251
|
+
const credentials = JSON.parse(await readFile(claudeCredentialsPath(), "utf8")) as ClaudeCodeCredentials;
|
|
252
|
+
return credentials.claudeAiOauth?.accessToken;
|
|
253
|
+
} catch {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function formatClaudeWindow(label: string, window: ClaudeUsageWindow | null | undefined): string | undefined {
|
|
259
|
+
if (typeof window?.utilization !== "number") return undefined;
|
|
260
|
+
const percent = Math.round(window.utilization);
|
|
261
|
+
const reset = window.resets_at ? fmtCountdown(window.resets_at) : "?";
|
|
262
|
+
return `${label} ${progressBar(percent)} ${percent}% ↻${reset}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function formatClaudeUsage(data: ClaudeUsageResponse): string | undefined {
|
|
266
|
+
const parts = [
|
|
267
|
+
formatClaudeWindow("5h", data.five_hour),
|
|
268
|
+
formatClaudeWindow("wk", data.seven_day),
|
|
269
|
+
formatClaudeWindow("opus", data.seven_day_opus),
|
|
270
|
+
formatClaudeWindow("sonnet", data.seven_day_sonnet),
|
|
271
|
+
].filter((part): part is string => part !== undefined);
|
|
272
|
+
|
|
273
|
+
if (data.extra_usage?.is_enabled) {
|
|
274
|
+
const used = data.extra_usage.used_credits;
|
|
275
|
+
const limit = data.extra_usage.monthly_limit;
|
|
276
|
+
const currency = data.extra_usage.currency ?? "USD";
|
|
277
|
+
parts.push(
|
|
278
|
+
typeof used === "number" && typeof limit === "number"
|
|
279
|
+
? `extra ${used.toFixed(2)}/${limit.toFixed(2)} ${currency}`
|
|
280
|
+
: "extra enabled",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const claudeCodeBridge: UsageProvider = {
|
|
287
|
+
// Claude Code owns authentication; claude-bridge does not expose it through
|
|
288
|
+
// pi's model registry.
|
|
289
|
+
providerId: "claude-bridge",
|
|
290
|
+
label: "claude",
|
|
291
|
+
matches: (provider) => provider === "claude-bridge",
|
|
292
|
+
async connected() {
|
|
293
|
+
return Boolean(await loadClaudeAccessToken());
|
|
294
|
+
},
|
|
295
|
+
async fetch() {
|
|
296
|
+
if (claudeUsageCache && Date.now() - claudeUsageCache.fetchedAt < CLAUDE_CACHE_MS) {
|
|
297
|
+
return formatClaudeUsage(claudeUsageCache.data);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const token = await loadClaudeAccessToken();
|
|
301
|
+
if (!token) return undefined;
|
|
302
|
+
const res = await fetch(CLAUDE_USAGE_URL, {
|
|
303
|
+
headers: {
|
|
304
|
+
Accept: "application/json",
|
|
305
|
+
Authorization: `Bearer ${token}`,
|
|
306
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
307
|
+
"User-Agent": "claude-code/usage-extension",
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
if (!res.ok) return claudeUsageCache ? formatClaudeUsage(claudeUsageCache.data) : undefined;
|
|
311
|
+
|
|
312
|
+
const data = (await res.json()) as ClaudeUsageResponse;
|
|
313
|
+
claudeUsageCache = { fetchedAt: Date.now(), data };
|
|
314
|
+
return formatClaudeUsage(data);
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
interface ZenDashboardConfig {
|
|
319
|
+
workspaceId: string;
|
|
320
|
+
authCookie: string;
|
|
321
|
+
serverId?: string;
|
|
322
|
+
serverInstance?: string;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
interface ZenCreditsResponse {
|
|
326
|
+
data?: {
|
|
327
|
+
total_credits?: number;
|
|
328
|
+
used_credits?: number;
|
|
329
|
+
remaining_credits?: number;
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const ZEN_CONFIG_PATH = join(getAgentDir(), "usage-zen.json");
|
|
334
|
+
const ZEN_SERVER_ID = "c83b78a614689c38ebee981f9b39a8b377716db85c1fd7dbab604adc02d3313d";
|
|
335
|
+
const ZEN_SERVER_INSTANCE = "server-fn:2";
|
|
336
|
+
const ZEN_USER_AGENT =
|
|
337
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36";
|
|
338
|
+
let zenCreditsApiSupported: boolean | undefined;
|
|
339
|
+
|
|
340
|
+
async function loadZenConfig(): Promise<ZenDashboardConfig | undefined> {
|
|
341
|
+
const envWorkspace = process.env.OPENCODE_ZEN_WORKSPACE_ID;
|
|
342
|
+
const envCookie = process.env.OPENCODE_ZEN_AUTH_COOKIE;
|
|
343
|
+
if (envWorkspace && envCookie) return { workspaceId: envWorkspace, authCookie: envCookie };
|
|
344
|
+
try {
|
|
345
|
+
const config = JSON.parse(await readFile(ZEN_CONFIG_PATH, "utf8")) as Partial<ZenDashboardConfig>;
|
|
346
|
+
if (typeof config.workspaceId !== "string" || typeof config.authCookie !== "string") return undefined;
|
|
347
|
+
return config as ZenDashboardConfig;
|
|
348
|
+
} catch {
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function zenCookieHeader(cookie: string): string {
|
|
354
|
+
return cookie.trim().startsWith("auth=") ? cookie.trim() : `auth=${cookie.trim()}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function microCentsToDollars(value: number): number {
|
|
358
|
+
return value / 100_000_000;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function parseZenDashboardResponse(body: string): string | undefined {
|
|
362
|
+
const balance = body.match(/\bbalance:\s*(-?\d+)/)?.[1];
|
|
363
|
+
if (balance == null) return undefined;
|
|
364
|
+
const parts = [`balance $${microCentsToDollars(Number(balance)).toFixed(2)}`];
|
|
365
|
+
const monthlyUsage = body.match(/\bmonthlyUsage:\s*(-?\d+)/)?.[1];
|
|
366
|
+
if (monthlyUsage != null) parts.push(`mo $${microCentsToDollars(Number(monthlyUsage)).toFixed(2)}`);
|
|
367
|
+
const reload = body.match(/\breload:\s*(true|false|null)/)?.[1];
|
|
368
|
+
const reloadAmount = body.match(/\breloadAmount:\s*(\d+(?:\.\d+)?)/)?.[1];
|
|
369
|
+
const reloadTrigger = body.match(/\breloadTrigger:\s*(\d+(?:\.\d+)?)/)?.[1];
|
|
370
|
+
if (reload === "true" && reloadAmount && reloadTrigger) {
|
|
371
|
+
parts.push(`auto $${reloadAmount} @ $${reloadTrigger}`);
|
|
372
|
+
}
|
|
373
|
+
return parts.join(" · ");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function fetchZenDashboard(config: ZenDashboardConfig): Promise<string | undefined> {
|
|
377
|
+
const serverId = config.serverId ?? ZEN_SERVER_ID;
|
|
378
|
+
const serverInstance = config.serverInstance ?? ZEN_SERVER_INSTANCE;
|
|
379
|
+
const args = JSON.stringify({
|
|
380
|
+
t: { t: 9, i: 0, l: 1, a: [{ t: 1, s: config.workspaceId }], o: 0 },
|
|
381
|
+
f: 31,
|
|
382
|
+
m: [],
|
|
383
|
+
});
|
|
384
|
+
const headers = {
|
|
385
|
+
Accept: "*/*",
|
|
386
|
+
Cookie: zenCookieHeader(config.authCookie),
|
|
387
|
+
Referer: `https://opencode.ai/workspace/${config.workspaceId}`,
|
|
388
|
+
"User-Agent": ZEN_USER_AGENT,
|
|
389
|
+
"x-server-id": serverId,
|
|
390
|
+
"x-server-instance": serverInstance,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const endpoint = new URL("https://opencode.ai/_server");
|
|
394
|
+
endpoint.searchParams.set("id", serverId);
|
|
395
|
+
endpoint.searchParams.set("args", args);
|
|
396
|
+
const direct = await fetch(endpoint, { headers });
|
|
397
|
+
if (direct.ok) {
|
|
398
|
+
const usage = parseZenDashboardResponse(await direct.text());
|
|
399
|
+
if (usage) return usage;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// The server-function hash can change after a Zen deployment. The workspace
|
|
403
|
+
// page is slower but exposes the same billing object in its SSR hydration.
|
|
404
|
+
const page = await fetch(`https://opencode.ai/workspace/${encodeURIComponent(config.workspaceId)}`, {
|
|
405
|
+
headers: {
|
|
406
|
+
Accept: "text/html",
|
|
407
|
+
Cookie: zenCookieHeader(config.authCookie),
|
|
408
|
+
"User-Agent": ZEN_USER_AGENT,
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
return page.ok ? parseZenDashboardResponse(await page.text()) : undefined;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function fetchZenCreditsApi(ctx: ExtensionContext): Promise<string | undefined> {
|
|
415
|
+
if (zenCreditsApiSupported === false) return undefined;
|
|
416
|
+
const result = await ctx.modelRegistry.getProviderAuth("opencode");
|
|
417
|
+
const apiKey = result?.auth?.apiKey;
|
|
418
|
+
if (!apiKey) return undefined;
|
|
419
|
+
const res = await fetch("https://api.opencode.ai/v1/credits", {
|
|
420
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
421
|
+
});
|
|
422
|
+
if (!res.ok) return undefined;
|
|
423
|
+
if (!(res.headers.get("content-type") ?? "").toLowerCase().includes("json")) {
|
|
424
|
+
zenCreditsApiSupported = false;
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
zenCreditsApiSupported = true;
|
|
428
|
+
const data = ((await res.json()) as ZenCreditsResponse).data;
|
|
429
|
+
if (typeof data?.total_credits !== "number" || typeof data.used_credits !== "number") return undefined;
|
|
430
|
+
const total = data.total_credits;
|
|
431
|
+
const used = data.used_credits;
|
|
432
|
+
const remaining = typeof data.remaining_credits === "number" ? data.remaining_credits : total - used;
|
|
433
|
+
const percent = total > 0 ? Math.round((used / total) * 100) : 0;
|
|
434
|
+
return `${remaining.toFixed(2)}/${total.toFixed(2)} credits · ${percent}% used`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const openCodeZen: UsageProvider = {
|
|
438
|
+
providerId: "opencode",
|
|
439
|
+
label: "zen",
|
|
440
|
+
matches: (provider) => provider === "opencode",
|
|
441
|
+
async connected(ctx) {
|
|
442
|
+
if (await loadZenConfig()) return true;
|
|
443
|
+
const result = await ctx.modelRegistry.getProviderAuth("opencode");
|
|
444
|
+
return Boolean(result?.auth?.apiKey);
|
|
445
|
+
},
|
|
446
|
+
async fetch(ctx) {
|
|
447
|
+
const official = await fetchZenCreditsApi(ctx);
|
|
448
|
+
if (official) return official;
|
|
449
|
+
const config = await loadZenConfig();
|
|
450
|
+
return config ? fetchZenDashboard(config) : undefined;
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// Add more provider handlers here later.
|
|
455
|
+
const USAGE_PROVIDERS: UsageProvider[] = [kimiCoding, openaiCodex, claudeCodeBridge, openCodeZen];
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Extension
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
export default function (pi: ExtensionAPI) {
|
|
462
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
463
|
+
let visible = false;
|
|
464
|
+
const succeededProviders = new Set<string>();
|
|
465
|
+
|
|
466
|
+
function handlerFor(ctx: ExtensionContext): UsageProvider | undefined {
|
|
467
|
+
const provider = ctx.model?.provider;
|
|
468
|
+
if (!provider) return undefined;
|
|
469
|
+
return USAGE_PROVIDERS.find((p) => p.matches(provider));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function refresh(ctx: ExtensionContext): Promise<void> {
|
|
473
|
+
if (!ctx.hasUI) return;
|
|
474
|
+
|
|
475
|
+
const handler = handlerFor(ctx);
|
|
476
|
+
if (!handler) {
|
|
477
|
+
if (visible) {
|
|
478
|
+
ctx.ui.setStatus(STATUS_ID, undefined);
|
|
479
|
+
visible = false;
|
|
480
|
+
}
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const text = await handler.fetch(ctx);
|
|
486
|
+
const model = ctx.model?.id ?? "";
|
|
487
|
+
const provider = ctx.model?.provider ?? "";
|
|
488
|
+
if (text) {
|
|
489
|
+
ctx.ui.setStatus(STATUS_ID, ctx.ui.theme.fg("dim", `${handler.label} ${model} · ${text}`));
|
|
490
|
+
succeededProviders.add(provider);
|
|
491
|
+
} else if (!succeededProviders.has(provider)) {
|
|
492
|
+
// Never fetched successfully for this provider: show that the extension is alive.
|
|
493
|
+
ctx.ui.setStatus(STATUS_ID, ctx.ui.theme.fg("dim", `${handler.label} ${model} · usage n/a`));
|
|
494
|
+
}
|
|
495
|
+
// On later failures: keep the last known values on screen.
|
|
496
|
+
visible = true;
|
|
497
|
+
} catch {
|
|
498
|
+
// Network/auth errors: keep the last known values on screen.
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
503
|
+
await refresh(ctx);
|
|
504
|
+
// Keep values and renewal countdowns fresh.
|
|
505
|
+
timer = setInterval(() => {
|
|
506
|
+
void refresh(ctx);
|
|
507
|
+
}, REFRESH_MS);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
pi.on("session_shutdown", async () => {
|
|
511
|
+
if (timer) {
|
|
512
|
+
clearInterval(timer);
|
|
513
|
+
timer = undefined;
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// Refresh after every prompt completes.
|
|
518
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
519
|
+
await refresh(ctx);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// Show/hide when the active model changes.
|
|
523
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
524
|
+
await refresh(ctx);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Manual refresh: /usage
|
|
528
|
+
pi.registerCommand("usage", {
|
|
529
|
+
description: "Refresh the usage display in the footer",
|
|
530
|
+
handler: async (_args, ctx) => {
|
|
531
|
+
await refresh(ctx);
|
|
532
|
+
ctx.ui.notify("Usage refreshed", "info");
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
pi.registerCommand("usage-all", {
|
|
537
|
+
description: "Show credits for all connected plans",
|
|
538
|
+
handler: async (_args, ctx) => {
|
|
539
|
+
const results = await Promise.all(
|
|
540
|
+
USAGE_PROVIDERS.map(async (handler) => {
|
|
541
|
+
try {
|
|
542
|
+
if (!(await isConnected(handler, ctx))) return undefined;
|
|
543
|
+
const usage = await handler.fetch(ctx);
|
|
544
|
+
return `${handler.label}: ${usage ?? "usage unavailable"}`;
|
|
545
|
+
} catch {
|
|
546
|
+
return `${handler.label}: usage unavailable`;
|
|
547
|
+
}
|
|
548
|
+
}),
|
|
549
|
+
);
|
|
550
|
+
const connected = results.filter((result): result is string => result !== undefined);
|
|
551
|
+
if (connected.length === 0) {
|
|
552
|
+
ctx.ui.notify("No supported usage plans are connected", "warning");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
ctx.ui.notify(connected.join("\n"), "info");
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
}
|