@sreetej510/pi-usage 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 +85 -0
- package/package.json +45 -0
- package/src/anthropic-auth.ts +33 -0
- package/src/anthropic-query.ts +72 -0
- package/src/args.ts +110 -0
- package/src/codex-app-server.ts +165 -0
- package/src/codex-auth.ts +66 -0
- package/src/codex-query.ts +52 -0
- package/src/codex-reset-credits.ts +131 -0
- package/src/command.ts +184 -0
- package/src/constants.ts +56 -0
- package/src/errors.ts +49 -0
- package/src/footer.ts +188 -0
- package/src/format.ts +208 -0
- package/src/http.ts +21 -0
- package/src/index.ts +100 -0
- package/src/models.ts +73 -0
- package/src/normalize-anthropic.ts +160 -0
- package/src/normalize-codex.ts +187 -0
- package/src/query.ts +121 -0
- package/src/shared-cache.ts +70 -0
- package/src/statusline.ts +244 -0
- package/src/types.ts +228 -0
- package/src/utils.ts +144 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 omega
|
|
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
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @sreetej510/pi-usage
|
|
2
|
+
|
|
3
|
+
A [pi](https://github.com/earendil-works/pi) coding agent extension that reports provider
|
|
4
|
+
usage / rate-limit budgets — OpenAI Codex, Anthropic OAuth, and pi-auth-backed providers — via
|
|
5
|
+
`/usage`, with an optional live statusline widget.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Queries the active model's provider usage endpoint (Codex app-server usage/rate-limit-reset
|
|
10
|
+
APIs, or Anthropic's OAuth usage API) and renders remaining budget/reset windows.
|
|
11
|
+
- Caches results on disk (`~/.pi/agent/usage-cache.json`) for a few minutes so multiple
|
|
12
|
+
concurrent pi sessions don't hammer the provider APIs.
|
|
13
|
+
- Can push a compact usage summary into the statusline, refreshed automatically with retry/backoff
|
|
14
|
+
on rate limits (`429`).
|
|
15
|
+
- Supports listing and consuming Codex "banked" rate-limit resets.
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
| Command | Effect |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `/usage` | Show cached usage (fetches fresh data if the cache is stale) |
|
|
22
|
+
| `/usage --refresh` | Force a fresh fetch, bypassing the cache |
|
|
23
|
+
| `/usage --no-statusline` | Don't update the statusline widget |
|
|
24
|
+
| `/usage --clear-statusline` | Clear the usage statusline widget |
|
|
25
|
+
| `/usage --timeout <seconds>` | Set the query timeout |
|
|
26
|
+
| `/usage --raw` | Show raw usage API responses (debugging) |
|
|
27
|
+
| `/usage --list-banked-resets` | List available Codex banked resets and expiry dates |
|
|
28
|
+
| `/usage --consume-banked-reset <id>` | Consume a specific Codex banked reset |
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install -g @sreetej510/pi-usage
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then add it to your pi `settings.json`:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"packages": ["npm:@sreetej510/pi-usage"]
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or, for local development, point at the file directly:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"extensions": ["/absolute/path/to/pi-extensions/extensions/pi-usage/src/index.ts"]
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## File layout
|
|
53
|
+
|
|
54
|
+
| File | Responsibility |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `src/index.ts` | Extension entry point + session event wiring |
|
|
57
|
+
| `src/command.ts` | `/usage` command handler |
|
|
58
|
+
| `src/statusline.ts` | Statusline state, timers, background refresh |
|
|
59
|
+
| `src/footer.ts` | Custom footer with right-aligned usage status |
|
|
60
|
+
| `src/query.ts` | Top-level usage query orchestration |
|
|
61
|
+
| `src/codex-query.ts` | Codex pi-auth + app-server fallback queries |
|
|
62
|
+
| `src/anthropic-query.ts` | Anthropic OAuth usage queries |
|
|
63
|
+
| `src/codex-auth.ts` | Codex auth header resolution |
|
|
64
|
+
| `src/anthropic-auth.ts` | Anthropic auth header resolution |
|
|
65
|
+
| `src/codex-app-server.ts` | `codex app-server` RPC client |
|
|
66
|
+
| `src/codex-reset-credits.ts` | Banked reset list/consume/format |
|
|
67
|
+
| `src/normalize-codex.ts` | Codex backend + app-server payload normalization |
|
|
68
|
+
| `src/normalize-anthropic.ts` | Anthropic usage payload normalization |
|
|
69
|
+
| `src/format.ts` | Report/statusline formatting and display |
|
|
70
|
+
| `src/shared-cache.ts` | On-disk shared usage cache |
|
|
71
|
+
| `src/models.ts` | Provider/model matching helpers |
|
|
72
|
+
| `src/args.ts` | `/usage` argument parsing and completions |
|
|
73
|
+
| `src/constants.ts` | Shared constants |
|
|
74
|
+
| `src/types.ts` | Shared TypeScript types |
|
|
75
|
+
| `src/utils.ts` | Small parsing/formatting helpers |
|
|
76
|
+
| `src/errors.ts` | HTTP/rate-limit error helpers |
|
|
77
|
+
| `src/http.ts` | `fetchWithTimeout` wrapper |
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm install
|
|
83
|
+
npm run --workspace @sreetej510/pi-usage check # biome + typecheck
|
|
84
|
+
npm run --workspace @sreetej510/pi-usage format
|
|
85
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sreetej510/pi-usage",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Pi extension that reports provider usage/rate-limit budgets (Codex, Anthropic OAuth, etc.) via /usage, with an optional statusline widget.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": false,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi",
|
|
12
|
+
"usage",
|
|
13
|
+
"rate-limit",
|
|
14
|
+
"statusline"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./src/index.ts"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"check": "biome check . && npm run typecheck",
|
|
28
|
+
"format": "biome check --write .",
|
|
29
|
+
"typecheck": "tsc --noEmit"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@biomejs/biome": "^2.5.3",
|
|
33
|
+
"@earendil-works/pi-coding-agent": "^0.80.3",
|
|
34
|
+
"@earendil-works/pi-tui": "^0.80.3",
|
|
35
|
+
"typescript": "^5.7.3"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/Sreetej510/pi-extensions",
|
|
40
|
+
"directory": "extensions/pi-usage"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { anthropicAuthCandidateModels } from "./models.js";
|
|
3
|
+
import { hasHeader } from "./utils.js";
|
|
4
|
+
|
|
5
|
+
export async function resolvePiAnthropicAuth(
|
|
6
|
+
ctx: ExtensionContext,
|
|
7
|
+
): Promise<{ headers: Record<string, string> } | undefined> {
|
|
8
|
+
const models = anthropicAuthCandidateModels(ctx);
|
|
9
|
+
const errors: string[] = [];
|
|
10
|
+
|
|
11
|
+
for (const model of models) {
|
|
12
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
13
|
+
if (!auth.ok) {
|
|
14
|
+
errors.push(auth.error);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const headers = { ...(auth.headers ?? {}) };
|
|
19
|
+
if (!hasHeader(headers, "Authorization") && auth.apiKey) {
|
|
20
|
+
headers.Authorization = `Bearer ${auth.apiKey}`;
|
|
21
|
+
}
|
|
22
|
+
if (!hasHeader(headers, "Accept")) headers.Accept = "application/json";
|
|
23
|
+
if (!hasHeader(headers, "User-Agent")) headers["User-Agent"] = "pi-usage";
|
|
24
|
+
if (hasHeader(headers, "Authorization")) {
|
|
25
|
+
return { headers };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (errors.length > 0) {
|
|
30
|
+
throw new Error(errors.join("; "));
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { resolvePiAnthropicAuth } from "./anthropic-auth.js";
|
|
3
|
+
import { resolvePiCodexAuth } from "./codex-auth.js";
|
|
4
|
+
import { ANTHROPIC_OAUTH_USAGE_URL, CODEX_USAGE_URL } from "./constants.js";
|
|
5
|
+
import { isStaleExtensionContextError, throwUsageEndpointError } from "./errors.js";
|
|
6
|
+
import { fetchWithTimeout } from "./http.js";
|
|
7
|
+
import { anthropicAuthCandidateModels, codexAuthCandidateModels } from "./models.js";
|
|
8
|
+
import { normalizeAnthropicUsagePayload } from "./normalize-anthropic.js";
|
|
9
|
+
import type { AnthropicOAuthUsagePayload, AnthropicUsageReport } from "./types.js";
|
|
10
|
+
import { errorMessage, parseJsonObject, prettyJson } from "./utils.js";
|
|
11
|
+
|
|
12
|
+
export async function queryAnthropicUsage(ctx: ExtensionContext, timeoutMs: number): Promise<AnthropicUsageReport> {
|
|
13
|
+
const auth = await resolvePiAnthropicAuth(ctx);
|
|
14
|
+
if (!auth) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
"No Pi Anthropic subscription auth was available. Use a Pi Anthropic model and run /login for Anthropic.",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const response = await fetchWithTimeout(
|
|
21
|
+
ANTHROPIC_OAUTH_USAGE_URL,
|
|
22
|
+
{ headers: auth.headers },
|
|
23
|
+
timeoutMs,
|
|
24
|
+
"Anthropic usage",
|
|
25
|
+
);
|
|
26
|
+
const text = await response.text();
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throwUsageEndpointError("Anthropic", response, text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const payload = parseJsonObject(text, "Anthropic usage endpoint response");
|
|
32
|
+
return normalizeAnthropicUsagePayload(payload as AnthropicOAuthUsagePayload, Date.now());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Fetch and pretty-print the raw usage API responses for debugging (/usage --raw). */
|
|
36
|
+
export async function fetchRawUsagePayloads(ctx: ExtensionContext, timeoutMs: number): Promise<string> {
|
|
37
|
+
const sections: string[] = [];
|
|
38
|
+
|
|
39
|
+
if (anthropicAuthCandidateModels(ctx).length > 0) {
|
|
40
|
+
try {
|
|
41
|
+
const auth = await resolvePiAnthropicAuth(ctx);
|
|
42
|
+
if (!auth) throw new Error("No Anthropic auth available.");
|
|
43
|
+
const response = await fetchWithTimeout(
|
|
44
|
+
ANTHROPIC_OAUTH_USAGE_URL,
|
|
45
|
+
{ headers: auth.headers },
|
|
46
|
+
timeoutMs,
|
|
47
|
+
"Anthropic usage",
|
|
48
|
+
);
|
|
49
|
+
const text = await response.text();
|
|
50
|
+
sections.push(`>_ Anthropic raw (${response.status}):\n${prettyJson(text)}`);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (isStaleExtensionContextError(error)) throw error;
|
|
53
|
+
sections.push(`>_ Anthropic raw: error \u2014 ${errorMessage(error)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (codexAuthCandidateModels(ctx).length > 0) {
|
|
58
|
+
try {
|
|
59
|
+
const auth = await resolvePiCodexAuth(ctx);
|
|
60
|
+
if (!auth) throw new Error("No Codex auth available.");
|
|
61
|
+
const response = await fetchWithTimeout(CODEX_USAGE_URL, { headers: auth.headers }, timeoutMs, "Codex usage");
|
|
62
|
+
const text = await response.text();
|
|
63
|
+
sections.push(`>_ Codex raw (${response.status}):\n${prettyJson(text)}`);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (isStaleExtensionContextError(error)) throw error;
|
|
66
|
+
sections.push(`>_ Codex raw: error \u2014 ${errorMessage(error)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (sections.length === 0) return "No providers with usable auth found.";
|
|
71
|
+
return sections.join("\n\n");
|
|
72
|
+
}
|
package/src/args.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { COMMAND_COMPLETIONS, DEFAULT_TIMEOUT_MS } from "./constants.js";
|
|
2
|
+
import type { CommandArgumentCompletion, QueryUsageOptions } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export function completeCodexStatusArguments(argumentPrefix: string): CommandArgumentCompletion[] | null {
|
|
5
|
+
const prefix = argumentPrefix.trimStart();
|
|
6
|
+
if (prefix === "") return [...COMMAND_COMPLETIONS];
|
|
7
|
+
|
|
8
|
+
const trailingSpace = /\s$/.test(prefix);
|
|
9
|
+
const tokens = prefix.trimEnd().split(/\s+/).filter(Boolean);
|
|
10
|
+
const previous = tokens.at(-1);
|
|
11
|
+
if (previous === "--timeout" && trailingSpace) return null;
|
|
12
|
+
if (previous === "--consume-banked-reset" && trailingSpace) return null;
|
|
13
|
+
if (!trailingSpace && tokens.at(-2) === "--timeout") return null;
|
|
14
|
+
if (!trailingSpace && tokens.at(-2) === "--consume-banked-reset") return null;
|
|
15
|
+
|
|
16
|
+
const current = trailingSpace ? "" : (previous ?? "");
|
|
17
|
+
if (current && !current.startsWith("-")) return null;
|
|
18
|
+
|
|
19
|
+
const currentRaw = trailingSpace ? "" : (prefix.match(/\S+$/)?.[0] ?? "");
|
|
20
|
+
const completionPrefix = trailingSpace ? prefix : prefix.slice(0, prefix.length - currentRaw.length);
|
|
21
|
+
const matches = COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(current));
|
|
22
|
+
return matches.length > 0 ? matches.map((item) => ({ ...item, value: `${completionPrefix}${item.value}` })) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseArgs(args: string): { ok: true; value: QueryUsageOptions } | { ok: false; error: string } {
|
|
26
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
27
|
+
let clearStatusline = false;
|
|
28
|
+
let consumeBankedReset = false;
|
|
29
|
+
let consumeBankedResetId: string | undefined;
|
|
30
|
+
let listBankedResets = false;
|
|
31
|
+
let raw = false;
|
|
32
|
+
let refresh = false;
|
|
33
|
+
let statusline = true;
|
|
34
|
+
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
35
|
+
|
|
36
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
37
|
+
const token = tokens[index];
|
|
38
|
+
if (token === "--clear-statusline") {
|
|
39
|
+
clearStatusline = true;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (token === "--raw") {
|
|
43
|
+
raw = true;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (token === "--list-banked-resets") {
|
|
47
|
+
listBankedResets = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (token === "--consume-banked-reset") {
|
|
51
|
+
consumeBankedReset = true;
|
|
52
|
+
const rawValue = tokens[index + 1];
|
|
53
|
+
if (rawValue && !rawValue.startsWith("-")) {
|
|
54
|
+
consumeBankedResetId = rawValue;
|
|
55
|
+
index += 1;
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (token === "--no-statusline") {
|
|
60
|
+
statusline = false;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (token === "--refresh") {
|
|
64
|
+
refresh = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (token === "--timeout") {
|
|
68
|
+
const rawValue = tokens[index + 1];
|
|
69
|
+
if (!rawValue)
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error:
|
|
73
|
+
"Usage: /usage [--refresh] [--raw] [--list-banked-resets] [--consume-banked-reset <id>] [--timeout seconds]",
|
|
74
|
+
};
|
|
75
|
+
const parsed = Number(rawValue);
|
|
76
|
+
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 120) {
|
|
77
|
+
return { ok: false, error: "--timeout must be a number of seconds between 1 and 120." };
|
|
78
|
+
}
|
|
79
|
+
timeoutMs = Math.round(parsed * 1000);
|
|
80
|
+
index += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: `Unknown option: ${token}. Usage: /usage [--refresh] [--raw] [--list-banked-resets] [--consume-banked-reset <id>] [--no-statusline] [--clear-statusline] [--timeout seconds]`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const exclusiveActionCount = [clearStatusline, raw, listBankedResets, consumeBankedReset].filter(Boolean).length;
|
|
90
|
+
if (exclusiveActionCount > 1) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: "Choose only one of: --clear-statusline, --raw, --list-banked-resets, --consume-banked-reset.",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
value: {
|
|
100
|
+
clearStatusline,
|
|
101
|
+
consumeBankedReset,
|
|
102
|
+
consumeBankedResetId,
|
|
103
|
+
listBankedResets,
|
|
104
|
+
raw,
|
|
105
|
+
refresh,
|
|
106
|
+
statusline,
|
|
107
|
+
timeoutMs,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { MAX_ERROR_BODY_CHARS } from "./constants.js";
|
|
4
|
+
import { normalizeAppServerResponse } from "./normalize-codex.js";
|
|
5
|
+
import type { CodexUsageReport, PendingRpc, RpcResponse } from "./types.js";
|
|
6
|
+
import { assertObject, redactErrorBody, truncateEnd } from "./utils.js";
|
|
7
|
+
|
|
8
|
+
export async function queryViaCodexAppServer(timeoutMs: number): Promise<CodexUsageReport> {
|
|
9
|
+
const client = new CodexAppServerClient(timeoutMs);
|
|
10
|
+
try {
|
|
11
|
+
await client.start();
|
|
12
|
+
await client.request("initialize", {
|
|
13
|
+
clientInfo: {
|
|
14
|
+
name: "pi_codex_usage",
|
|
15
|
+
title: "Pi Codex Usage",
|
|
16
|
+
version: "0.1.0",
|
|
17
|
+
},
|
|
18
|
+
capabilities: {
|
|
19
|
+
experimentalApi: false,
|
|
20
|
+
requestAttestation: false,
|
|
21
|
+
optOutNotificationMethods: [],
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
client.notify("initialized");
|
|
25
|
+
const result = await client.request("account/rateLimits/read", undefined);
|
|
26
|
+
return normalizeAppServerResponse(assertObject(result, "account/rateLimits/read result"), Date.now());
|
|
27
|
+
} finally {
|
|
28
|
+
client.dispose();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class CodexAppServerClient {
|
|
33
|
+
private child?: ChildProcessWithoutNullStreams;
|
|
34
|
+
private nextId = 1;
|
|
35
|
+
private stderr = "";
|
|
36
|
+
private readonly pending = new Map<number, PendingRpc>();
|
|
37
|
+
private startPromise?: Promise<void>;
|
|
38
|
+
private exitError?: Error;
|
|
39
|
+
private readonly timeoutMs: number;
|
|
40
|
+
|
|
41
|
+
constructor(timeoutMs: number) {
|
|
42
|
+
this.timeoutMs = timeoutMs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
start(): Promise<void> {
|
|
46
|
+
if (this.startPromise) return this.startPromise;
|
|
47
|
+
|
|
48
|
+
this.startPromise = new Promise((resolve, reject) => {
|
|
49
|
+
const child = spawn("codex", ["app-server", "--listen", "stdio://"], {
|
|
50
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
51
|
+
});
|
|
52
|
+
this.child = child;
|
|
53
|
+
|
|
54
|
+
const startupTimeout = setTimeout(() => {
|
|
55
|
+
reject(new Error(`Timed out after ${Math.round(this.timeoutMs / 1000)}s starting codex app-server.`));
|
|
56
|
+
}, this.timeoutMs);
|
|
57
|
+
|
|
58
|
+
child.once("spawn", () => {
|
|
59
|
+
clearTimeout(startupTimeout);
|
|
60
|
+
resolve();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
child.once("error", (error) => {
|
|
64
|
+
clearTimeout(startupTimeout);
|
|
65
|
+
reject(new Error(`Failed to start codex app-server: ${error.message}`));
|
|
66
|
+
this.rejectAll(error);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
child.once("exit", (code, signal) => {
|
|
70
|
+
const suffix = this.stderr ? ` stderr: ${redactErrorBody(this.stderr)}` : "";
|
|
71
|
+
this.exitError = new Error(
|
|
72
|
+
`codex app-server exited before completing the request (code ${code ?? "unknown"}, signal ${signal ?? "none"}).${suffix}`,
|
|
73
|
+
);
|
|
74
|
+
this.rejectAll(this.exitError);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
child.stderr.setEncoding("utf8");
|
|
78
|
+
child.stderr.on("data", (chunk: string) => {
|
|
79
|
+
this.stderr = truncateEnd(this.stderr + chunk, MAX_ERROR_BODY_CHARS);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const lines = createInterface({ input: child.stdout });
|
|
83
|
+
lines.on("line", (line) => this.handleLine(line));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return this.startPromise;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
request(method: string, params: unknown): Promise<unknown> {
|
|
90
|
+
const child = this.child;
|
|
91
|
+
if (!child?.stdin.writable) {
|
|
92
|
+
throw new Error("codex app-server is not running.");
|
|
93
|
+
}
|
|
94
|
+
if (this.exitError) throw this.exitError;
|
|
95
|
+
|
|
96
|
+
const id = this.nextId++;
|
|
97
|
+
const payload = params === undefined ? { method, id } : { method, id, params };
|
|
98
|
+
const response = new Promise<unknown>((resolve, reject) => {
|
|
99
|
+
const timeout = setTimeout(() => {
|
|
100
|
+
this.pending.delete(id);
|
|
101
|
+
reject(new Error(`Timed out after ${Math.round(this.timeoutMs / 1000)}s waiting for ${method}.`));
|
|
102
|
+
}, this.timeoutMs);
|
|
103
|
+
|
|
104
|
+
this.pending.set(id, {
|
|
105
|
+
resolve: (value) => {
|
|
106
|
+
clearTimeout(timeout);
|
|
107
|
+
resolve(value);
|
|
108
|
+
},
|
|
109
|
+
reject: (error) => {
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
reject(error);
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
child.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
117
|
+
return response;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
notify(method: string): void {
|
|
121
|
+
const child = this.child;
|
|
122
|
+
if (!child?.stdin.writable) return;
|
|
123
|
+
child.stdin.write(`${JSON.stringify({ method })}\n`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
dispose(): void {
|
|
127
|
+
for (const [id, pending] of this.pending) {
|
|
128
|
+
pending.reject(new Error(`codex app-server request ${id} cancelled.`));
|
|
129
|
+
}
|
|
130
|
+
this.pending.clear();
|
|
131
|
+
|
|
132
|
+
const child = this.child;
|
|
133
|
+
if (!child) return;
|
|
134
|
+
child.stdin.end();
|
|
135
|
+
if (!child.killed) child.kill();
|
|
136
|
+
this.child = undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private handleLine(line: string): void {
|
|
140
|
+
let parsed: RpcResponse;
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(line) as RpcResponse;
|
|
143
|
+
} catch {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (typeof parsed.id !== "number") return;
|
|
148
|
+
const pending = this.pending.get(parsed.id);
|
|
149
|
+
if (!pending) return;
|
|
150
|
+
this.pending.delete(parsed.id);
|
|
151
|
+
|
|
152
|
+
if (parsed.error) {
|
|
153
|
+
const message = typeof parsed.error.message === "string" ? parsed.error.message : "unknown error";
|
|
154
|
+
pending.reject(new Error(`codex app-server request failed: ${message}`));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
pending.resolve(parsed.result);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private rejectAll(error: Error): void {
|
|
162
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
163
|
+
this.pending.clear();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { CODEX_OPENAI_BETA } from "./constants.js";
|
|
6
|
+
import { codexAuthCandidateModels } from "./models.js";
|
|
7
|
+
import { asString, hasHeader } from "./utils.js";
|
|
8
|
+
|
|
9
|
+
export async function resolvePiCodexAuth(
|
|
10
|
+
ctx: ExtensionContext,
|
|
11
|
+
): Promise<{ headers: Record<string, string> } | undefined> {
|
|
12
|
+
const models = codexAuthCandidateModels(ctx);
|
|
13
|
+
const errors: string[] = [];
|
|
14
|
+
|
|
15
|
+
for (const model of models) {
|
|
16
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
17
|
+
if (!auth.ok) {
|
|
18
|
+
errors.push(auth.error);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const headers = { ...(auth.headers ?? {}) };
|
|
23
|
+
if (!hasHeader(headers, "Authorization") && auth.apiKey) {
|
|
24
|
+
headers.Authorization = `Bearer ${auth.apiKey}`;
|
|
25
|
+
}
|
|
26
|
+
if (!hasHeader(headers, "User-Agent")) {
|
|
27
|
+
headers["User-Agent"] = "pi-codex-usage";
|
|
28
|
+
}
|
|
29
|
+
if (hasHeader(headers, "Authorization")) {
|
|
30
|
+
return { headers };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (errors.length > 0) {
|
|
35
|
+
throw new Error(errors.join("; "));
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function resolvePiCodexWhamAuth(
|
|
41
|
+
ctx: ExtensionContext,
|
|
42
|
+
): Promise<{ headers: Record<string, string> } | undefined> {
|
|
43
|
+
const auth = await resolvePiCodexAuth(ctx);
|
|
44
|
+
if (!auth) return undefined;
|
|
45
|
+
const headers = { ...auth.headers };
|
|
46
|
+
if (!hasHeader(headers, "Accept")) headers.Accept = "application/json";
|
|
47
|
+
if (!hasHeader(headers, "Content-Type")) headers["Content-Type"] = "application/json";
|
|
48
|
+
if (!hasHeader(headers, "OpenAI-Beta")) headers["OpenAI-Beta"] = CODEX_OPENAI_BETA;
|
|
49
|
+
if (!hasHeader(headers, "originator")) headers.originator = "Codex Desktop";
|
|
50
|
+
if (!hasHeader(headers, "ChatGPT-Account-ID")) {
|
|
51
|
+
const accountId = readCodexAccountIdFromAuthFile();
|
|
52
|
+
if (accountId) headers["ChatGPT-Account-ID"] = accountId;
|
|
53
|
+
}
|
|
54
|
+
return { headers };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readCodexAccountIdFromAuthFile(): string | undefined {
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(readFileSync(join(homedir(), ".codex", "auth.json"), "utf8")) as {
|
|
60
|
+
tokens?: { account_id?: unknown; accountId?: unknown };
|
|
61
|
+
};
|
|
62
|
+
return asString(raw.tokens?.account_id) ?? asString(raw.tokens?.accountId);
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { queryViaCodexAppServer } from "./codex-app-server.js";
|
|
3
|
+
import { resolvePiCodexAuth } from "./codex-auth.js";
|
|
4
|
+
import { CODEX_USAGE_URL } from "./constants.js";
|
|
5
|
+
import { isStaleExtensionContextError, throwUsageEndpointError } from "./errors.js";
|
|
6
|
+
import { fetchWithTimeout } from "./http.js";
|
|
7
|
+
import { normalizeBackendPayload } from "./normalize-codex.js";
|
|
8
|
+
import type { CodexUsageReport, RateLimitStatusPayload, UsageQueryError, UsageReport } from "./types.js";
|
|
9
|
+
import { errorMessage, parseJsonObject } from "./utils.js";
|
|
10
|
+
|
|
11
|
+
export async function queryViaPiAuth(ctx: ExtensionContext, timeoutMs: number): Promise<CodexUsageReport> {
|
|
12
|
+
const auth = await resolvePiCodexAuth(ctx);
|
|
13
|
+
if (!auth) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
"No Pi OpenAI Codex subscription auth was available. Use a Pi OpenAI Codex model or run /login for OpenAI ChatGPT Plus/Pro (Codex).",
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const response = await fetchWithTimeout(CODEX_USAGE_URL, { headers: auth.headers }, timeoutMs);
|
|
20
|
+
const text = await response.text();
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
throwUsageEndpointError("Codex", response, text);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const payload = parseJsonObject(text, "Codex usage endpoint response");
|
|
26
|
+
return normalizeBackendPayload(payload as RateLimitStatusPayload, Date.now(), "pi-auth");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function queryCodexUsageWithFallback(
|
|
30
|
+
ctx: ExtensionContext,
|
|
31
|
+
timeoutMs: number,
|
|
32
|
+
): Promise<{ report?: UsageReport; errors: UsageQueryError[] }> {
|
|
33
|
+
try {
|
|
34
|
+
const report = await queryViaPiAuth(ctx, timeoutMs);
|
|
35
|
+
return { report, errors: [] };
|
|
36
|
+
} catch (cause) {
|
|
37
|
+
if (isStaleExtensionContextError(cause)) throw cause;
|
|
38
|
+
const errors: UsageQueryError[] = [{ source: "pi-auth", message: errorMessage(cause), cause }];
|
|
39
|
+
try {
|
|
40
|
+
const report = await queryViaCodexAppServer(timeoutMs);
|
|
41
|
+
return { report, errors };
|
|
42
|
+
} catch (fallbackCause) {
|
|
43
|
+
if (isStaleExtensionContextError(fallbackCause)) throw fallbackCause;
|
|
44
|
+
errors.push({
|
|
45
|
+
source: "codex-app-server",
|
|
46
|
+
message: errorMessage(fallbackCause),
|
|
47
|
+
cause: fallbackCause,
|
|
48
|
+
});
|
|
49
|
+
return { errors };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|