@llblab/pi-codex-usage 0.10.1 → 0.12.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.
@@ -0,0 +1,25 @@
1
+ /** Domain: extension composition. Owns: provider registration, Fast bridge and status wiring. Excludes: shared command ownership. */
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { registerFastProvider } from "@llblab/pi-command-fast";
4
+ import { applyFastToRequest, isFastEnabled, isFastEligibleModel, toggleFast } from "./fast.ts";
5
+ import { createCodexUsageStatus } from "./status.ts";
6
+
7
+ export default function codexUsage(pi: ExtensionAPI) {
8
+ let releaseFast: (() => void) | undefined;
9
+ pi.on("session_start", (_event, ctx) => {
10
+ releaseFast = registerFastProvider(pi, ctx, {
11
+ provider: "openai-codex",
12
+ toggle(commandCtx) {
13
+ toggleFast(commandCtx.model!.id);
14
+ status.fastChanged(commandCtx, commandCtx.model!);
15
+ },
16
+ });
17
+ });
18
+ pi.on("session_shutdown", () => { releaseFast?.(); releaseFast = undefined; });
19
+ const status = createCodexUsageStatus(pi);
20
+ pi.on("before_provider_request", (event, ctx) => {
21
+ const model = ctx.model;
22
+ if (!isFastEligibleModel(model)) return;
23
+ return applyFastToRequest(event.payload, model.id, isFastEnabled(model.id));
24
+ });
25
+ }
package/lib/fast.ts ADDED
@@ -0,0 +1,23 @@
1
+ /** Domain: Codex Fast. Owns: provider semantics and wire adaptation. Excludes: command arbitration and JSONC editing. */
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { isModelOverrideValue, toggleModelOverrideValue } from "@llblab/pi-command-fast";
4
+ import { isOpenAICodexModel } from "./usage.ts";
5
+
6
+ const target = (id: string) => ({ provider: "openai-codex", id, property: "serviceTier", enabledValue: "priority" });
7
+
8
+ export function isFastEligibleModel(model: ExtensionContext["model"]): model is NonNullable<ExtensionContext["model"]> {
9
+ return !!model && isOpenAICodexModel(model);
10
+ }
11
+ export function isFastEnabled(modelId: string, path?: string): boolean {
12
+ return isModelOverrideValue(target(modelId), path);
13
+ }
14
+ export function toggleFast(modelId: string, path?: string): boolean {
15
+ return toggleModelOverrideValue(target(modelId), path);
16
+ }
17
+
18
+ export function applyFastToRequest(payload: unknown, modelId: string, enabled: boolean): unknown {
19
+ if (!enabled || !payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
20
+ const body = payload as Record<string, unknown>;
21
+ if (body.model !== modelId || "service_tier" in body) return undefined;
22
+ return { ...body, service_tier: "priority" };
23
+ }
package/lib/query.ts ADDED
@@ -0,0 +1,368 @@
1
+ /** Domain: quota transport. Owns: Pi auth, Codex app-server fallback and errors. Excludes: shared leadership and UI. */
2
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { assertObject, errorMessage, normalizeAppServerResponse, normalizeBackendPayload, parseJsonObject, selectUsageSnapshot, type AppServerRateLimitResponse, type CodexUsageModel, type CodexUsageReport, type RateLimitStatusPayload, type UsageQueryError } from "./usage.ts";
6
+ const CODEX_PROVIDER_ID = "openai-codex";
7
+ const CODEX_USAGE_LIMIT_ID = "codex";
8
+ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
9
+ const MAX_ERROR_BODY_CHARS = 600;
10
+ type PiModel = NonNullable<ExtensionContext["model"]>;
11
+ type QueryUsageOptions = { timeoutMs: number };
12
+ export type QueryUsageResult = { ok: true; report: CodexUsageReport } | { ok: false; errors: UsageQueryError[] };
13
+ type RpcResponse = {
14
+ id?: unknown;
15
+ result?: unknown;
16
+ error?: { message?: unknown; code?: unknown };
17
+ };
18
+
19
+ type PendingRpc = {
20
+ resolve: (value: unknown) => void;
21
+ reject: (error: Error) => void;
22
+ };
23
+
24
+ export async function queryUsage(
25
+ ctx: ExtensionContext,
26
+ options: Pick<QueryUsageOptions, "timeoutMs">,
27
+ model: CodexUsageModel | undefined,
28
+ mayQuery: () => boolean,
29
+ ): Promise<QueryUsageResult | undefined> {
30
+ const errors: UsageQueryError[] = [];
31
+ const sources = ["pi-auth", "codex-app-server"] as const;
32
+
33
+ for (const source of sources) {
34
+ if (!mayQuery()) return undefined;
35
+ try {
36
+ const report =
37
+ source === "pi-auth"
38
+ ? await queryViaPiAuth(ctx, options.timeoutMs, mayQuery)
39
+ : await queryViaCodexAppServer(options.timeoutMs, mayQuery);
40
+ if (!report) return undefined;
41
+ if (
42
+ selectUsageSnapshot(report, CODEX_USAGE_LIMIT_ID) ||
43
+ report.credits
44
+ ) {
45
+ return { ok: true, report };
46
+ }
47
+ errors.push({
48
+ source,
49
+ message: `${source} returned no displayable codex rate-limit windows`,
50
+ });
51
+ } catch (cause) {
52
+ errors.push({ source, message: errorMessage(cause), cause });
53
+ }
54
+ }
55
+
56
+ return { ok: false, errors };
57
+ }
58
+
59
+ async function queryViaPiAuth(
60
+ ctx: ExtensionContext,
61
+ timeoutMs: number,
62
+ mayQuery: () => boolean,
63
+ ): Promise<CodexUsageReport | undefined> {
64
+ const auth = await resolvePiCodexAuth(ctx);
65
+ if (!auth) {
66
+ throw new Error(
67
+ "No Pi OpenAI Codex subscription auth was available. Use a Pi OpenAI Codex model or run /login for OpenAI ChatGPT Plus/Pro (Codex).",
68
+ );
69
+ }
70
+
71
+ if (!mayQuery()) return undefined;
72
+ const response = await fetchWithTimeout(
73
+ CODEX_USAGE_URL,
74
+ { headers: auth.headers },
75
+ timeoutMs,
76
+ );
77
+ const text = await response.text();
78
+ if (!response.ok) {
79
+ throw new Error(
80
+ `Codex usage endpoint returned ${response.status} ${response.statusText}: ${redactErrorBody(text)}`,
81
+ );
82
+ }
83
+
84
+ const payload = parseJsonObject(text, "Codex usage endpoint response");
85
+ return normalizeBackendPayload(
86
+ payload as RateLimitStatusPayload,
87
+ Date.now(),
88
+ "pi-auth",
89
+ );
90
+ }
91
+
92
+ async function resolvePiCodexAuth(
93
+ ctx: ExtensionContext,
94
+ ): Promise<{ headers: Record<string, string> } | undefined> {
95
+ const models = codexAuthCandidateModels(ctx);
96
+ const errors: string[] = [];
97
+
98
+ for (const model of models) {
99
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
100
+ if (!auth.ok) {
101
+ errors.push(auth.error);
102
+ continue;
103
+ }
104
+
105
+ const headers: Record<string, string> = {};
106
+ for (const [name, value] of Object.entries(auth.headers ?? {})) {
107
+ if (value !== null) headers[name] = value;
108
+ }
109
+ if (!hasHeader(headers, "Authorization") && auth.apiKey) {
110
+ headers.Authorization = `Bearer ${auth.apiKey}`;
111
+ }
112
+ if (!hasHeader(headers, "User-Agent")) {
113
+ headers["User-Agent"] = "pi-codex-usage";
114
+ }
115
+ if (hasHeader(headers, "Authorization")) {
116
+ return { headers };
117
+ }
118
+ }
119
+
120
+ if (errors.length > 0) {
121
+ throw new Error(errors.join("; "));
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ function codexAuthCandidateModels(ctx: ExtensionContext): PiModel[] {
127
+ const candidates: PiModel[] = [];
128
+ const seen = new Set<string>();
129
+ const add = (model: PiModel | undefined) => {
130
+ if (!model || model.provider !== CODEX_PROVIDER_ID) return;
131
+ const key = `${model.provider}/${model.id}`;
132
+ if (seen.has(key)) return;
133
+ seen.add(key);
134
+ candidates.push(model);
135
+ };
136
+
137
+ add(ctx.model);
138
+ for (const model of ctx.modelRegistry.getAvailable()) add(model);
139
+ for (const model of ctx.modelRegistry.getAll()) add(model);
140
+ return candidates;
141
+ }
142
+
143
+ async function fetchWithTimeout(
144
+ url: string,
145
+ init: RequestInit,
146
+ timeoutMs: number,
147
+ ): Promise<Response> {
148
+ const controller = new AbortController();
149
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
150
+ try {
151
+ return await fetch(url, { ...init, signal: controller.signal });
152
+ } catch (error) {
153
+ if (controller.signal.aborted) {
154
+ throw new Error(
155
+ `Timed out after ${Math.round(timeoutMs / 1000)}s while fetching Codex usage.`,
156
+ );
157
+ }
158
+ throw error;
159
+ } finally {
160
+ clearTimeout(timeout);
161
+ }
162
+ }
163
+
164
+ async function queryViaCodexAppServer(
165
+ timeoutMs: number,
166
+ mayQuery: () => boolean,
167
+ ): Promise<CodexUsageReport | undefined> {
168
+ const client = new CodexAppServerClient(timeoutMs);
169
+ try {
170
+ await client.start();
171
+ if (!mayQuery()) return undefined;
172
+ await client.request("initialize", {
173
+ clientInfo: {
174
+ name: "pi_codex_usage",
175
+ title: "Pi Codex Usage",
176
+ version: "0.1.0",
177
+ },
178
+ capabilities: {
179
+ experimentalApi: false,
180
+ requestAttestation: false,
181
+ optOutNotificationMethods: [],
182
+ },
183
+ });
184
+ client.notify("initialized");
185
+ if (!mayQuery()) return undefined;
186
+ const result = await client.request("account/rateLimits/read", undefined);
187
+ return normalizeAppServerResponse(
188
+ assertObject(
189
+ result,
190
+ "account/rateLimits/read result",
191
+ ) as AppServerRateLimitResponse,
192
+ Date.now(),
193
+ );
194
+ } finally {
195
+ client.dispose();
196
+ }
197
+ }
198
+
199
+ class CodexAppServerClient {
200
+ private child?: ChildProcessWithoutNullStreams;
201
+ private nextId = 1;
202
+ private stderr = "";
203
+ private readonly pending = new Map<number, PendingRpc>();
204
+ private startPromise?: Promise<void>;
205
+ private exitError?: Error;
206
+ private readonly timeoutMs: number;
207
+
208
+ constructor(timeoutMs: number) {
209
+ this.timeoutMs = timeoutMs;
210
+ }
211
+
212
+ start(): Promise<void> {
213
+ if (this.startPromise) return this.startPromise;
214
+
215
+ this.startPromise = new Promise((resolve, reject) => {
216
+ const child = spawn("codex", ["app-server", "--listen", "stdio://"], {
217
+ stdio: ["pipe", "pipe", "pipe"],
218
+ });
219
+ this.child = child;
220
+
221
+ const startupTimeout = setTimeout(() => {
222
+ reject(
223
+ new Error(
224
+ `Timed out after ${Math.round(this.timeoutMs / 1000)}s starting codex app-server.`,
225
+ ),
226
+ );
227
+ }, this.timeoutMs);
228
+
229
+ child.once("spawn", () => {
230
+ clearTimeout(startupTimeout);
231
+ resolve();
232
+ });
233
+
234
+ child.once("error", (error) => {
235
+ clearTimeout(startupTimeout);
236
+ reject(new Error(`Failed to start codex app-server: ${error.message}`));
237
+ this.rejectAll(error);
238
+ });
239
+
240
+ child.once("exit", (code, signal) => {
241
+ const suffix = this.stderr
242
+ ? ` stderr: ${redactErrorBody(this.stderr)}`
243
+ : "";
244
+ this.exitError = new Error(
245
+ `codex app-server exited before completing the request (code ${code ?? "unknown"}, signal ${signal ?? "none"}).${suffix}`,
246
+ );
247
+ this.rejectAll(this.exitError);
248
+ });
249
+
250
+ child.stderr.setEncoding("utf8");
251
+ child.stderr.on("data", (chunk: string) => {
252
+ this.stderr = truncateEnd(this.stderr + chunk, MAX_ERROR_BODY_CHARS);
253
+ });
254
+
255
+ const lines = createInterface({ input: child.stdout });
256
+ lines.on("line", (line) => this.handleLine(line));
257
+ });
258
+
259
+ return this.startPromise;
260
+ }
261
+
262
+ request(method: string, params: unknown): Promise<unknown> {
263
+ const child = this.child;
264
+ if (!child?.stdin.writable) {
265
+ throw new Error("codex app-server is not running.");
266
+ }
267
+ if (this.exitError) throw this.exitError;
268
+
269
+ const id = this.nextId++;
270
+ const payload =
271
+ params === undefined ? { method, id } : { method, id, params };
272
+ const response = new Promise<unknown>((resolve, reject) => {
273
+ const timeout = setTimeout(() => {
274
+ this.pending.delete(id);
275
+ reject(
276
+ new Error(
277
+ `Timed out after ${Math.round(this.timeoutMs / 1000)}s waiting for ${method}.`,
278
+ ),
279
+ );
280
+ }, this.timeoutMs);
281
+
282
+ this.pending.set(id, {
283
+ resolve: (value) => {
284
+ clearTimeout(timeout);
285
+ resolve(value);
286
+ },
287
+ reject: (error) => {
288
+ clearTimeout(timeout);
289
+ reject(error);
290
+ },
291
+ });
292
+ });
293
+
294
+ child.stdin.write(`${JSON.stringify(payload)}\n`);
295
+ return response;
296
+ }
297
+
298
+ notify(method: string): void {
299
+ const child = this.child;
300
+ if (!child?.stdin.writable) return;
301
+ child.stdin.write(`${JSON.stringify({ method })}\n`);
302
+ }
303
+
304
+ dispose(): void {
305
+ for (const [id, pending] of this.pending) {
306
+ pending.reject(new Error(`codex app-server request ${id} cancelled.`));
307
+ }
308
+ this.pending.clear();
309
+
310
+ const child = this.child;
311
+ if (!child) return;
312
+ child.stdin.end();
313
+ if (!child.killed) child.kill();
314
+ this.child = undefined;
315
+ }
316
+
317
+ private handleLine(line: string): void {
318
+ let parsed: RpcResponse;
319
+ try {
320
+ parsed = JSON.parse(line) as RpcResponse;
321
+ } catch {
322
+ return;
323
+ }
324
+
325
+ if (typeof parsed.id !== "number") return;
326
+ const pending = this.pending.get(parsed.id);
327
+ if (!pending) return;
328
+ this.pending.delete(parsed.id);
329
+
330
+ if (parsed.error) {
331
+ const message =
332
+ typeof parsed.error.message === "string"
333
+ ? parsed.error.message
334
+ : "unknown error";
335
+ pending.reject(new Error(`codex app-server request failed: ${message}`));
336
+ return;
337
+ }
338
+
339
+ pending.resolve(parsed.result);
340
+ }
341
+
342
+ private rejectAll(error: Error): void {
343
+ for (const pending of this.pending.values()) pending.reject(error);
344
+ this.pending.clear();
345
+ }
346
+ }
347
+
348
+
349
+ function hasHeader(headers: Record<string, string>, name: string): boolean {
350
+ return Object.keys(headers).some(
351
+ (key) => key.toLowerCase() === name.toLowerCase(),
352
+ );
353
+ }
354
+
355
+ function redactErrorBody(body: string): string {
356
+ return truncateEnd(
357
+ body
358
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer <redacted>")
359
+ .replace(/"access_token"\s*:\s*"[^"]+"/gi, '"access_token":"<redacted>"')
360
+ .trim(),
361
+ MAX_ERROR_BODY_CHARS,
362
+ );
363
+ }
364
+
365
+ function truncateEnd(value: string, maxChars: number): string {
366
+ if (value.length <= maxChars) return value;
367
+ return `${value.slice(0, maxChars - 1)}…`;
368
+ }