@hank-warren/pi-statusline 0.2.0 → 0.2.2
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 +3 -1
- package/package.json +1 -1
- package/usage.ts +245 -20
package/README.md
CHANGED
|
@@ -25,7 +25,9 @@ When Pi's `~/.pi/agent/auth.json` contains OAuth credentials for Anthropic (Clau
|
|
|
25
25
|
|
|
26
26
|
Numbers are colored by remaining headroom: green above 60, yellow 41–60, orange 16–40, red at 15 and below.
|
|
27
27
|
|
|
28
|
-
Usage is fetched from the providers' own usage endpoints with Pi's stored tokens — read-only; tokens are never refreshed or written. Fetches happen on session start and after each turn, throttled to once
|
|
28
|
+
Usage is fetched from the providers' own usage endpoints with Pi's stored tokens — read-only; tokens are never refreshed or written. Fetches happen on session start and after each turn, throttled to at most once every five minutes (the Anthropic usage endpoint rate-limits aggressively), and are strictly best-effort: on any failure the last-known value is kept, and providers without credentials (or before the first successful fetch) are simply omitted, leaving the statusline exactly as before. While a provider that *does* have credentials still has no value, the throttle drops to 30 seconds — Pi only refreshes an expired OAuth access token when that provider is first used, so a session starting with a stale Anthropic token would otherwise show no Claude meter for a full interval.
|
|
29
|
+
|
|
30
|
+
Polling is host-wide, not per-session. Usage percentages describe the account rather than the session, and a busy machine runs dozens of pi processes, so every process shares `~/.pi/agent/statusline-usage.json`: it holds the last good snapshot plus the time the last poll was *started*, written atomically via a temp file and rename. A session adopts the cached values on its first refresh — so the meters are populated before it has issued a single request — and only polls when that shared timestamp is older than the interval. A provider answering `429` is parked for fifteen minutes (tracked per provider, so a rate-limited Anthropic never stops codex from updating) and stops counting as pending, since retrying harder is what earns the rate limit in the first place. Each provider's cache entry is keyed to a fingerprint (a sha256 prefix, never the token itself) of the credentials that fetched it: switching accounts — or rotating a token — discards that provider's cached numbers and backoff and polls immediately, so an exhausted old account's meters never masquerade as the new account's. Requires a Nerd Font new enough to include the codicon brand glyphs (v3.5.0+); older fonts render them as replacement boxes.
|
|
29
31
|
|
|
30
32
|
## Neon cache-wave celebration
|
|
31
33
|
|
package/package.json
CHANGED
package/usage.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
5
|
+
import { pid } from "node:process";
|
|
4
6
|
|
|
5
7
|
/** Remaining (not used) integer percents per provider window. */
|
|
6
8
|
export interface UsageSnapshot {
|
|
@@ -10,7 +12,24 @@ export interface UsageSnapshot {
|
|
|
10
12
|
|
|
11
13
|
const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
12
14
|
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
13
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Minimum spacing between usage polls. The Anthropic usage endpoint rate-limits
|
|
17
|
+
* (429) aggressively, so keep this well above a per-turn cadence.
|
|
18
|
+
*/
|
|
19
|
+
export const USAGE_REFRESH_INTERVAL_MS = 5 * 60_000;
|
|
20
|
+
/**
|
|
21
|
+
* Shorter spacing used while a credentialed provider still has no value. Pi
|
|
22
|
+
* refreshes an expired OAuth token only when that provider is first used, so a
|
|
23
|
+
* session that starts with a stale token would otherwise show nothing for a
|
|
24
|
+
* full refresh interval.
|
|
25
|
+
*/
|
|
26
|
+
export const USAGE_RETRY_INTERVAL_MS = 30_000;
|
|
27
|
+
/**
|
|
28
|
+
* How long a provider is left alone after it answers 429. Polls are host-wide
|
|
29
|
+
* (see the shared cache below), so a rate limit means the provider itself wants
|
|
30
|
+
* a break rather than that we are racing ourselves.
|
|
31
|
+
*/
|
|
32
|
+
export const USAGE_RATE_LIMIT_BACKOFF_MS = 15 * 60_000;
|
|
14
33
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
15
34
|
const ONE_DAY_SECONDS = 86_400;
|
|
16
35
|
|
|
@@ -89,16 +108,110 @@ export function usageBand(remaining: number): "green" | "yellow" | "orange" | "r
|
|
|
89
108
|
|
|
90
109
|
type FetchFn = (url: string, init: { headers: Record<string, string>; signal: AbortSignal }) => Promise<{
|
|
91
110
|
ok: boolean;
|
|
111
|
+
status?: number;
|
|
92
112
|
json(): Promise<unknown>;
|
|
93
113
|
}>;
|
|
94
114
|
|
|
95
115
|
export interface UsageTrackerOptions {
|
|
96
116
|
authPath?: string;
|
|
117
|
+
cachePath?: string;
|
|
97
118
|
fetchFn?: FetchFn;
|
|
98
119
|
onChange?: () => void;
|
|
99
120
|
now?: () => number;
|
|
100
121
|
}
|
|
101
122
|
|
|
123
|
+
type ProviderKey = "claude" | "codex";
|
|
124
|
+
|
|
125
|
+
const PROVIDER_KEYS = ["claude", "codex"] as const;
|
|
126
|
+
|
|
127
|
+
/** Non-reversible per-provider credential fingerprints; never raw tokens. */
|
|
128
|
+
type ProviderIdentities = Partial<Record<ProviderKey, string>>;
|
|
129
|
+
|
|
130
|
+
function fingerprint(material: string): string {
|
|
131
|
+
return createHash("sha256").update(material).digest("hex").slice(0, 16);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fingerprint the credentials each provider would be polled with. Usage
|
|
136
|
+
* percentages belong to an account, so a cached value is only trustworthy
|
|
137
|
+
* while the credentials that produced it are still the ones in auth.json —
|
|
138
|
+
* after an account switch, yesterday's "1% left" is somebody else's meter.
|
|
139
|
+
*/
|
|
140
|
+
function credentialIdentities(auth: AuthEntries): ProviderIdentities {
|
|
141
|
+
const identities: ProviderIdentities = {};
|
|
142
|
+
if (hasClaudeAuth(auth)) identities.claude = fingerprint(String(auth.anthropic?.access));
|
|
143
|
+
if (hasCodexAuth(auth)) {
|
|
144
|
+
const entry = auth["openai-codex"];
|
|
145
|
+
identities.codex = fingerprint(`${entry?.access as string}\n${entry?.accountId as string}`);
|
|
146
|
+
}
|
|
147
|
+
return identities;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Host-wide poll state shared by every pi process. Usage percentages are a
|
|
152
|
+
* property of the account, not of a session, so one poll per interval per host
|
|
153
|
+
* is both sufficient and necessary: a busy host runs dozens of pi processes, and
|
|
154
|
+
* per-process polling stampedes the endpoints into rate limiting everyone.
|
|
155
|
+
*/
|
|
156
|
+
interface UsageCache {
|
|
157
|
+
/** Last time any process started a poll; gates the shared throttle. */
|
|
158
|
+
attemptedAt: number;
|
|
159
|
+
/** Absolute times before which a rate-limited provider must not be polled. */
|
|
160
|
+
backoff: Partial<Record<ProviderKey, number>>;
|
|
161
|
+
snapshot: UsageSnapshot;
|
|
162
|
+
/** Credential fingerprints the snapshot was fetched with. */
|
|
163
|
+
identity: ProviderIdentities;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const EMPTY_CACHE: UsageCache = {
|
|
167
|
+
attemptedAt: Number.NEGATIVE_INFINITY,
|
|
168
|
+
backoff: {},
|
|
169
|
+
snapshot: {},
|
|
170
|
+
identity: {},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Drop cached values that were fetched with different credentials. A token
|
|
175
|
+
* rotation looks the same as an account switch here; the cost of treating it
|
|
176
|
+
* as one is a single immediate re-poll, while trusting a stale identity means
|
|
177
|
+
* showing another account's numbers as if they were current.
|
|
178
|
+
*/
|
|
179
|
+
function pruneCache(cache: UsageCache, identity: ProviderIdentities): UsageCache {
|
|
180
|
+
const snapshot = { ...cache.snapshot };
|
|
181
|
+
const backoff = { ...cache.backoff };
|
|
182
|
+
let changed = false;
|
|
183
|
+
for (const key of PROVIDER_KEYS) {
|
|
184
|
+
if (cache.identity[key] === identity[key]) continue;
|
|
185
|
+
if (snapshot[key] !== undefined || backoff[key] !== undefined) changed = true;
|
|
186
|
+
delete snapshot[key];
|
|
187
|
+
delete backoff[key];
|
|
188
|
+
}
|
|
189
|
+
if (!changed) return cache;
|
|
190
|
+
// The pruned values also claimed the shared throttle slot; the new account's
|
|
191
|
+
// first poll must not wait out a window started for somebody else.
|
|
192
|
+
return { attemptedAt: Number.NEGATIVE_INFINITY, backoff, snapshot, identity };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parseUsageCache(json: unknown): UsageCache | undefined {
|
|
196
|
+
if (typeof json !== "object" || json === null) return undefined;
|
|
197
|
+
const body = json as { attemptedAt?: unknown; backoff?: unknown; snapshot?: unknown; identity?: unknown };
|
|
198
|
+
if (typeof body.attemptedAt !== "number" || !Number.isFinite(body.attemptedAt)) return undefined;
|
|
199
|
+
const snapshot = typeof body.snapshot === "object" && body.snapshot !== null ? (body.snapshot as UsageSnapshot) : {};
|
|
200
|
+
const backoff: UsageCache["backoff"] = {};
|
|
201
|
+
const identity: ProviderIdentities = {};
|
|
202
|
+
for (const key of PROVIDER_KEYS) {
|
|
203
|
+
if (typeof body.backoff === "object" && body.backoff !== null) {
|
|
204
|
+
const value = (body.backoff as Record<string, unknown>)[key];
|
|
205
|
+
if (typeof value === "number" && Number.isFinite(value)) backoff[key] = value;
|
|
206
|
+
}
|
|
207
|
+
if (typeof body.identity === "object" && body.identity !== null) {
|
|
208
|
+
const value = (body.identity as Record<string, unknown>)[key];
|
|
209
|
+
if (typeof value === "string") identity[key] = value;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { attemptedAt: body.attemptedAt, backoff, snapshot, identity };
|
|
213
|
+
}
|
|
214
|
+
|
|
102
215
|
interface AuthEntries {
|
|
103
216
|
anthropic?: { access?: unknown };
|
|
104
217
|
"openai-codex"?: { access?: unknown; accountId?: unknown };
|
|
@@ -126,15 +239,20 @@ function hasCodexAuth(auth: AuthEntries): boolean {
|
|
|
126
239
|
*/
|
|
127
240
|
export class UsageTracker {
|
|
128
241
|
private readonly authPath: string;
|
|
242
|
+
private readonly cachePath: string;
|
|
129
243
|
private readonly fetchFn: FetchFn;
|
|
130
244
|
private readonly onChange?: () => void;
|
|
131
245
|
private readonly now: () => number;
|
|
132
246
|
private current: UsageSnapshot = {};
|
|
247
|
+
private identities: ProviderIdentities = {};
|
|
248
|
+
private awaitingProvider = true;
|
|
249
|
+
private readonly rateLimited = new Set<ProviderKey>();
|
|
133
250
|
private lastAttempt = Number.NEGATIVE_INFINITY;
|
|
134
251
|
private inFlight: Promise<void> | undefined;
|
|
135
252
|
|
|
136
253
|
constructor(options: UsageTrackerOptions = {}) {
|
|
137
254
|
this.authPath = options.authPath ?? join(homedir(), ".pi", "agent", "auth.json");
|
|
255
|
+
this.cachePath = options.cachePath ?? join(homedir(), ".pi", "agent", "statusline-usage.json");
|
|
138
256
|
this.fetchFn = options.fetchFn ?? ((url, init) => fetch(url, init));
|
|
139
257
|
this.onChange = options.onChange;
|
|
140
258
|
this.now = options.now ?? Date.now;
|
|
@@ -144,11 +262,17 @@ export class UsageTracker {
|
|
|
144
262
|
return this.current;
|
|
145
263
|
}
|
|
146
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Interval until the next allowed attempt: the short retry window while a
|
|
267
|
+
* credentialed provider is still missing a value, the full interval otherwise.
|
|
268
|
+
*/
|
|
269
|
+
private currentInterval(): number {
|
|
270
|
+
return this.awaitingProvider ? USAGE_RETRY_INTERVAL_MS : USAGE_REFRESH_INTERVAL_MS;
|
|
271
|
+
}
|
|
272
|
+
|
|
147
273
|
/** Throttled refresh; resolves when the current attempt (if any) settles. */
|
|
148
274
|
refresh(): Promise<void> {
|
|
149
275
|
if (this.inFlight) return this.inFlight;
|
|
150
|
-
if (this.now() - this.lastAttempt < REFRESH_INTERVAL_MS) return Promise.resolve();
|
|
151
|
-
this.lastAttempt = this.now();
|
|
152
276
|
const attempt = this.performRefresh()
|
|
153
277
|
.catch(() => {
|
|
154
278
|
// Usage display is best-effort and must never interrupt the agent.
|
|
@@ -160,25 +284,117 @@ export class UsageTracker {
|
|
|
160
284
|
return attempt;
|
|
161
285
|
}
|
|
162
286
|
|
|
287
|
+
/**
|
|
288
|
+
* One refresh cycle: adopt whatever another process has already published,
|
|
289
|
+
* then poll only if the shared throttle allows it.
|
|
290
|
+
*/
|
|
163
291
|
private async performRefresh(): Promise<void> {
|
|
164
292
|
const auth = (await this.readAuth()) ?? {};
|
|
165
|
-
const
|
|
293
|
+
const identity = credentialIdentities(auth);
|
|
294
|
+
const cache = pruneCache((await this.readCache()) ?? EMPTY_CACHE, identity);
|
|
295
|
+
this.pruneCurrent(identity);
|
|
296
|
+
// Another process's values are as good as ours and cost no request, so a new
|
|
297
|
+
// session shows real numbers on its very first render.
|
|
298
|
+
this.publish(this.merge(cache.snapshot, auth), auth, cache);
|
|
299
|
+
|
|
300
|
+
const now = this.now();
|
|
301
|
+
const lastAttempt = Math.max(this.lastAttempt, cache.attemptedAt);
|
|
302
|
+
if (now - lastAttempt < this.currentInterval()) return;
|
|
303
|
+
const claudeAllowed = hasClaudeAuth(auth) && now >= (cache.backoff.claude ?? Number.NEGATIVE_INFINITY);
|
|
304
|
+
const codexAllowed = hasCodexAuth(auth) && now >= (cache.backoff.codex ?? Number.NEGATIVE_INFINITY);
|
|
305
|
+
if (!claudeAllowed && !codexAllowed) return;
|
|
306
|
+
|
|
307
|
+
this.lastAttempt = now;
|
|
308
|
+
// Claim the slot before fetching so sibling processes skip this window even
|
|
309
|
+
// if our own request is slow or fails outright. The claim carries the fresh
|
|
310
|
+
// identity so siblings do not re-prune and stampede the same window.
|
|
311
|
+
await this.writeCache({ ...cache, attemptedAt: now, identity });
|
|
312
|
+
|
|
313
|
+
const [claude, codex] = await Promise.all([
|
|
314
|
+
claudeAllowed ? this.fetchClaude(auth) : Promise.resolve(undefined),
|
|
315
|
+
codexAllowed ? this.fetchCodex(auth) : Promise.resolve(undefined),
|
|
316
|
+
]);
|
|
317
|
+
const backoff = { ...cache.backoff };
|
|
318
|
+
for (const key of ["claude", "codex"] as const) {
|
|
319
|
+
if (this.rateLimited.has(key)) backoff[key] = now + USAGE_RATE_LIMIT_BACKOFF_MS;
|
|
320
|
+
else if ((key === "claude" ? claude : codex) !== undefined) delete backoff[key];
|
|
321
|
+
}
|
|
322
|
+
this.rateLimited.clear();
|
|
323
|
+
|
|
324
|
+
const next = this.merge({ claude, codex }, auth);
|
|
325
|
+
const updated: UsageCache = { attemptedAt: now, backoff, snapshot: next, identity };
|
|
326
|
+
this.publish(next, auth, updated);
|
|
327
|
+
await this.writeCache(updated);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Drop in-memory values fetched with previous credentials so merge() cannot
|
|
332
|
+
* resurrect another account's numbers, and let the changed identity reopen
|
|
333
|
+
* this process's own throttle gate.
|
|
334
|
+
*/
|
|
335
|
+
private pruneCurrent(identity: ProviderIdentities): void {
|
|
336
|
+
const changedKeys = PROVIDER_KEYS.filter((key) => this.identities[key] !== identity[key]);
|
|
337
|
+
this.identities = identity;
|
|
338
|
+
if (changedKeys.length === 0) return;
|
|
339
|
+
this.lastAttempt = Number.NEGATIVE_INFINITY;
|
|
340
|
+
if (!changedKeys.some((key) => this.current[key] !== undefined)) return;
|
|
341
|
+
const next = { ...this.current };
|
|
342
|
+
for (const key of changedKeys) delete next[key];
|
|
343
|
+
this.current = next;
|
|
344
|
+
// snapshot() changed even if the upcoming publish() sees no further delta.
|
|
345
|
+
this.onChange?.();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Layer fresh values over the last-known ones and drop providers without
|
|
350
|
+
* credentials: a logged-out provider must disappear immediately, while a
|
|
351
|
+
* failed fetch keeps whatever we last saw.
|
|
352
|
+
*/
|
|
353
|
+
private merge(incoming: UsageSnapshot, auth: AuthEntries): UsageSnapshot {
|
|
166
354
|
const next: UsageSnapshot = {};
|
|
167
|
-
// A logged-out provider (missing/invalid auth entry) is dropped immediately;
|
|
168
|
-
// a fetch failure with credentials present keeps the last-known value.
|
|
169
355
|
if (hasClaudeAuth(auth)) {
|
|
170
|
-
const
|
|
171
|
-
if (
|
|
356
|
+
const claude = incoming.claude ?? this.current.claude;
|
|
357
|
+
if (claude) next.claude = claude;
|
|
172
358
|
}
|
|
173
359
|
if (hasCodexAuth(auth)) {
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
360
|
+
const codex = incoming.codex ?? this.current.codex;
|
|
361
|
+
if (codex) next.codex = codex;
|
|
176
362
|
}
|
|
363
|
+
return next;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Adopt a snapshot, recompute the retry gate, and repaint only on a change. */
|
|
367
|
+
private publish(next: UsageSnapshot, auth: AuthEntries, cache: UsageCache): void {
|
|
368
|
+
const now = this.now();
|
|
369
|
+
// A provider serving 429s is not "pending": retrying it faster is exactly
|
|
370
|
+
// what got us rate limited, so it must not hold the short window open.
|
|
371
|
+
this.awaitingProvider =
|
|
372
|
+
(hasClaudeAuth(auth) && next.claude === undefined && now >= (cache.backoff.claude ?? 0)) ||
|
|
373
|
+
(hasCodexAuth(auth) && next.codex === undefined && now >= (cache.backoff.codex ?? 0));
|
|
177
374
|
if (JSON.stringify(next) === JSON.stringify(this.current)) return;
|
|
178
375
|
this.current = next;
|
|
179
376
|
this.onChange?.();
|
|
180
377
|
}
|
|
181
378
|
|
|
379
|
+
private async readCache(): Promise<UsageCache | undefined> {
|
|
380
|
+
try {
|
|
381
|
+
return parseUsageCache(JSON.parse(await readFile(this.cachePath, "utf8")));
|
|
382
|
+
} catch {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Atomic write; a lost race just costs one extra poll, never a corrupt file. */
|
|
388
|
+
private async writeCache(cache: UsageCache): Promise<void> {
|
|
389
|
+
const temporary = `${this.cachePath}.${pid}.tmp`;
|
|
390
|
+
try {
|
|
391
|
+
await writeFile(temporary, JSON.stringify(cache), { mode: 0o600 });
|
|
392
|
+
await rename(temporary, this.cachePath);
|
|
393
|
+
} catch {
|
|
394
|
+
// The cache is an optimisation; failing to share it must never surface.
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
182
398
|
private async readAuth(): Promise<AuthEntries | undefined> {
|
|
183
399
|
try {
|
|
184
400
|
const parsed: unknown = JSON.parse(await readFile(this.authPath, "utf8"));
|
|
@@ -189,11 +405,12 @@ export class UsageTracker {
|
|
|
189
405
|
}
|
|
190
406
|
}
|
|
191
407
|
|
|
192
|
-
private async fetchJson(url: string, headers: Record<string, string
|
|
408
|
+
private async fetchJson(url: string, headers: Record<string, string>, provider: ProviderKey): Promise<unknown> {
|
|
193
409
|
const controller = new AbortController();
|
|
194
410
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
195
411
|
try {
|
|
196
412
|
const response = await this.fetchFn(url, { headers, signal: controller.signal });
|
|
413
|
+
if (response.status === 429) this.rateLimited.add(provider);
|
|
197
414
|
if (!response.ok) return undefined;
|
|
198
415
|
return await response.json();
|
|
199
416
|
} finally {
|
|
@@ -205,10 +422,14 @@ export class UsageTracker {
|
|
|
205
422
|
if (!hasClaudeAuth(auth)) return undefined;
|
|
206
423
|
try {
|
|
207
424
|
return parseClaudeUsage(
|
|
208
|
-
await this.fetchJson(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
425
|
+
await this.fetchJson(
|
|
426
|
+
CLAUDE_USAGE_URL,
|
|
427
|
+
{
|
|
428
|
+
Authorization: `Bearer ${auth.anthropic?.access as string}`,
|
|
429
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
430
|
+
},
|
|
431
|
+
"claude",
|
|
432
|
+
),
|
|
212
433
|
);
|
|
213
434
|
} catch {
|
|
214
435
|
return undefined;
|
|
@@ -222,10 +443,14 @@ export class UsageTracker {
|
|
|
222
443
|
const accountId = entry?.accountId as string;
|
|
223
444
|
try {
|
|
224
445
|
return parseCodexUsage(
|
|
225
|
-
await this.fetchJson(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
446
|
+
await this.fetchJson(
|
|
447
|
+
CODEX_USAGE_URL,
|
|
448
|
+
{
|
|
449
|
+
Authorization: `Bearer ${access}`,
|
|
450
|
+
"chatgpt-account-id": accountId,
|
|
451
|
+
},
|
|
452
|
+
"codex",
|
|
453
|
+
),
|
|
229
454
|
);
|
|
230
455
|
} catch {
|
|
231
456
|
return undefined;
|