@narumitw/pi-codex-usage 0.13.0 → 0.13.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/README.md +3 -2
- package/package.json +1 -1
- package/src/app-server-client.ts +216 -0
- package/src/codex-usage.ts +27 -898
- package/src/format.ts +264 -0
- package/src/normalize.ts +210 -0
- package/src/query.ts +179 -0
- package/src/types.ts +121 -0
package/src/format.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type {
|
|
3
|
+
CodexUsageModel,
|
|
4
|
+
CodexUsageReport,
|
|
5
|
+
NormalizedCredits,
|
|
6
|
+
NormalizedRateLimitSnapshot,
|
|
7
|
+
NormalizedRateLimitWindow,
|
|
8
|
+
UsageQueryError,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
|
|
11
|
+
const USAGE_SETTINGS_URL = "https://chatgpt.com/codex/settings/usage";
|
|
12
|
+
const BAR_SEGMENTS = 20;
|
|
13
|
+
const LIMIT_VALUE_COLUMN = 29;
|
|
14
|
+
const RESET_FOREGROUND = "\x1b[39m";
|
|
15
|
+
|
|
16
|
+
function isOpenAICodexModel(model: Pick<CodexUsageModel, "provider"> | undefined): boolean {
|
|
17
|
+
return model?.provider === "openai-codex";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatCodexUsageReport(report: CodexUsageReport, _cacheAgeMs?: number): string {
|
|
21
|
+
const lines = [
|
|
22
|
+
" >_ OpenAI Codex Usage",
|
|
23
|
+
"",
|
|
24
|
+
`Visit ${USAGE_SETTINGS_URL} for up-to-date`,
|
|
25
|
+
"information on rate limits and credits",
|
|
26
|
+
"",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const snapshot of report.snapshots) {
|
|
30
|
+
const label = snapshot.limitName ?? snapshot.limitId;
|
|
31
|
+
if (!isPrimaryCodexSnapshot(snapshot)) {
|
|
32
|
+
lines.push(` ${label} limit:`);
|
|
33
|
+
}
|
|
34
|
+
if (snapshot.primary) lines.push(formatWindowLine("5h limit:", snapshot.primary));
|
|
35
|
+
if (snapshot.secondary) lines.push(formatWindowLine("Weekly limit:", snapshot.secondary));
|
|
36
|
+
if (!snapshot.primary && !snapshot.secondary) {
|
|
37
|
+
lines.push(" Limits unavailable for this account");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatCodexUsageStatusline(
|
|
45
|
+
report: CodexUsageReport,
|
|
46
|
+
model?: CodexUsageModel,
|
|
47
|
+
): string {
|
|
48
|
+
const snapshot = selectSnapshotForModel(report, model);
|
|
49
|
+
if (!snapshot) return "usage unavailable";
|
|
50
|
+
|
|
51
|
+
const parts = [formatStatuslinePrefix(snapshot)];
|
|
52
|
+
if (snapshot.primary) parts.push(`${formatRemainingPercent(snapshot.primary)} 5h`);
|
|
53
|
+
if (snapshot.secondary) parts.push(`${formatRemainingPercent(snapshot.secondary)} wk`);
|
|
54
|
+
if (parts.length === 1 && snapshot.credits) parts.push(formatCredits(snapshot.credits));
|
|
55
|
+
return parts.join(" ");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function selectSnapshotForModel(
|
|
59
|
+
report: CodexUsageReport,
|
|
60
|
+
model: CodexUsageModel | undefined,
|
|
61
|
+
): NormalizedRateLimitSnapshot | undefined {
|
|
62
|
+
const codexSnapshot = report.snapshots.find(isPrimaryCodexSnapshot);
|
|
63
|
+
if (!model || !isOpenAICodexModel(model)) return codexSnapshot ?? report.snapshots[0];
|
|
64
|
+
|
|
65
|
+
const modelKeys = normalizedModelUsageKeys(model);
|
|
66
|
+
const exactMatch = report.snapshots.find((snapshot) =>
|
|
67
|
+
normalizedSnapshotUsageKeys(snapshot).some((key) => modelKeys.has(key)),
|
|
68
|
+
);
|
|
69
|
+
if (exactMatch) return exactMatch;
|
|
70
|
+
|
|
71
|
+
const variants = codexModelVariantKeys(modelKeys);
|
|
72
|
+
for (const variant of variants) {
|
|
73
|
+
const matches = report.snapshots.filter(
|
|
74
|
+
(snapshot) =>
|
|
75
|
+
!isPrimaryCodexSnapshot(snapshot) &&
|
|
76
|
+
normalizedSnapshotUsageKeys(snapshot).some((key) => normalizedKeyHasToken(key, variant)),
|
|
77
|
+
);
|
|
78
|
+
if (matches.length === 1) return matches[0];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return codexSnapshot ?? report.snapshots[0];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizedModelUsageKeys(model: CodexUsageModel): Set<string> {
|
|
85
|
+
const keys = new Set<string>();
|
|
86
|
+
addNormalizedUsageKey(keys, model.id);
|
|
87
|
+
addNormalizedUsageKey(keys, model.name);
|
|
88
|
+
|
|
89
|
+
for (const key of [...keys]) {
|
|
90
|
+
const codexIndex = key.indexOf("codex");
|
|
91
|
+
if (codexIndex >= 0) keys.add(key.slice(codexIndex));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return keys;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizedSnapshotUsageKeys(snapshot: NormalizedRateLimitSnapshot): string[] {
|
|
98
|
+
return [normalizedUsageKey(snapshot.limitId), normalizedUsageKey(snapshot.limitName)].filter(
|
|
99
|
+
(key): key is string => key !== undefined,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function addNormalizedUsageKey(keys: Set<string>, value: string | undefined): void {
|
|
104
|
+
const key = normalizedUsageKey(value);
|
|
105
|
+
if (key) keys.add(key);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizedUsageKey(value: string | undefined): string | undefined {
|
|
109
|
+
const key = value
|
|
110
|
+
?.toLowerCase()
|
|
111
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
112
|
+
.replace(/^-+|-+$/g, "");
|
|
113
|
+
return key || undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function codexModelVariantKeys(modelKeys: Set<string>): string[] {
|
|
117
|
+
const variants = new Set<string>();
|
|
118
|
+
for (const key of modelKeys) {
|
|
119
|
+
const match = key.match(/(?:^|-)codex-(.+)$/);
|
|
120
|
+
if (match?.[1]) variants.add(match[1]);
|
|
121
|
+
}
|
|
122
|
+
return [...variants];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizedKeyHasToken(key: string, token: string): boolean {
|
|
126
|
+
return (
|
|
127
|
+
key === token ||
|
|
128
|
+
key.startsWith(`${token}-`) ||
|
|
129
|
+
key.endsWith(`-${token}`) ||
|
|
130
|
+
key.includes(`-${token}-`)
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatStatuslinePrefix(snapshot: NormalizedRateLimitSnapshot): string {
|
|
135
|
+
if (isPrimaryCodexSnapshot(snapshot)) return "codex";
|
|
136
|
+
const label = snapshot.limitName ?? snapshot.limitId;
|
|
137
|
+
return `codex ${compactLimitLabel(label)}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function compactLimitLabel(label: string): string {
|
|
141
|
+
const normalized = label.replace(/[_-]+/g, " ").trim();
|
|
142
|
+
const codexVariant = normalized.match(/\bcodex\s+(.+)$/i)?.[1]?.trim();
|
|
143
|
+
const compact = codexVariant || normalized;
|
|
144
|
+
return compact.toLowerCase().replace(/\s+/g, " ");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function formatRemainingPercent(window: NormalizedRateLimitWindow): string {
|
|
148
|
+
return `${(100 - clampPercent(window.usedPercent)).toFixed(0)}%`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function showReport(
|
|
152
|
+
ctx: ExtensionCommandContext,
|
|
153
|
+
report: CodexUsageReport,
|
|
154
|
+
fromCache: boolean,
|
|
155
|
+
): void {
|
|
156
|
+
const text = formatCodexUsageReport(
|
|
157
|
+
report,
|
|
158
|
+
fromCache ? Date.now() - report.capturedAt : undefined,
|
|
159
|
+
);
|
|
160
|
+
ctx.ui.notify(ctx.hasUI ? brightenInfoNotification(text) : text, "info");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function brightenInfoNotification(text: string): string {
|
|
164
|
+
return `${RESET_FOREGROUND}${text}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isPrimaryCodexSnapshot(snapshot: NormalizedRateLimitSnapshot): boolean {
|
|
168
|
+
return (
|
|
169
|
+
normalizedUsageKey(snapshot.limitId) === "codex" ||
|
|
170
|
+
normalizedUsageKey(snapshot.limitName) === "codex"
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function formatWindowLine(label: string, window: NormalizedRateLimitWindow): string {
|
|
175
|
+
return ` ${label.padEnd(LIMIT_VALUE_COLUMN)}${formatWindow(window)}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function formatWindow(window: NormalizedRateLimitWindow): string {
|
|
179
|
+
const remaining = 100 - clampPercent(window.usedPercent);
|
|
180
|
+
const reset = window.resetsAt ? ` (resets ${formatReset(window.resetsAt)})` : "";
|
|
181
|
+
return `${progressBar(remaining)} ${remaining.toFixed(0)}% left${reset}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function progressBar(percentRemaining: number): string {
|
|
185
|
+
const filled = Math.round((clampPercent(percentRemaining) / 100) * BAR_SEGMENTS);
|
|
186
|
+
return `[${"█".repeat(filled)}${"░".repeat(BAR_SEGMENTS - filled)}]`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function formatCredits(credits: NormalizedCredits): string {
|
|
190
|
+
if (!credits.hasCredits) return "no credits";
|
|
191
|
+
if (credits.unlimited) return "unlimited credits";
|
|
192
|
+
const balance = credits.balance?.trim();
|
|
193
|
+
if (!balance) return "credits available";
|
|
194
|
+
return `${formatNumber(Number(balance), balance)} credits`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function formatReset(epochSeconds: number): string {
|
|
198
|
+
const reset = new Date(epochSeconds * 1000);
|
|
199
|
+
if (Number.isNaN(reset.getTime())) return "at an unknown time";
|
|
200
|
+
|
|
201
|
+
const now = new Date();
|
|
202
|
+
const time = `${reset.getHours().toString().padStart(2, "0")}:${reset
|
|
203
|
+
.getMinutes()
|
|
204
|
+
.toString()
|
|
205
|
+
.padStart(2, "0")}`;
|
|
206
|
+
if (reset.toDateString() === now.toDateString()) return time;
|
|
207
|
+
const day = reset.getDate().toString();
|
|
208
|
+
const month = reset.toLocaleDateString(undefined, { month: "short" });
|
|
209
|
+
return `${time} on ${day} ${month}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function formatQueryErrors(errors: UsageQueryError[]): string {
|
|
213
|
+
const lines = ["Unable to read Codex usage."];
|
|
214
|
+
for (const error of errors) {
|
|
215
|
+
const source = error.source === "pi-auth" ? "Pi auth direct" : "Codex app-server fallback";
|
|
216
|
+
lines.push(`- ${source}: ${error.message}`);
|
|
217
|
+
}
|
|
218
|
+
lines.push("");
|
|
219
|
+
lines.push(
|
|
220
|
+
"Tip: use a Pi OpenAI Codex model or run /login for OpenAI ChatGPT Plus/Pro. If Pi auth is unavailable, install Codex CLI and run codex login for the fallback.",
|
|
221
|
+
);
|
|
222
|
+
return lines.join("\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function formatPlanType(planType: string): string {
|
|
226
|
+
const key = planType
|
|
227
|
+
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
228
|
+
.toLowerCase()
|
|
229
|
+
.replace(/[^a-z0-9]+/g, "_");
|
|
230
|
+
if (key === "pro_lite" || key === "prolite") return "Pro Lite";
|
|
231
|
+
if (key === "team" || key === "self_serve_business_usage_based" || key === "business") {
|
|
232
|
+
return "Business";
|
|
233
|
+
}
|
|
234
|
+
if (key === "enterprise_cbp_usage_based") return "Enterprise";
|
|
235
|
+
|
|
236
|
+
const normalized = planType
|
|
237
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
238
|
+
.replace(/[_-]+/g, " ")
|
|
239
|
+
.trim();
|
|
240
|
+
if (!normalized) return planType;
|
|
241
|
+
return normalized
|
|
242
|
+
.split(/\s+/)
|
|
243
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
|
244
|
+
.join(" ");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function formatDuration(milliseconds: number): string {
|
|
248
|
+
const seconds = Math.max(0, Math.round(milliseconds / 1000));
|
|
249
|
+
if (seconds < 60) return `${seconds}s`;
|
|
250
|
+
const minutes = Math.round(seconds / 60);
|
|
251
|
+
if (minutes < 60) return `${minutes}m`;
|
|
252
|
+
const hours = Math.round(minutes / 60);
|
|
253
|
+
return `${hours}h`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function formatNumber(value: number, fallback: string): string {
|
|
257
|
+
if (!Number.isFinite(value)) return fallback;
|
|
258
|
+
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function clampPercent(value: number): number {
|
|
262
|
+
if (!Number.isFinite(value)) return 0;
|
|
263
|
+
return Math.min(100, Math.max(0, value));
|
|
264
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppServerCreditsSnapshot,
|
|
3
|
+
AppServerRateLimitResponse,
|
|
4
|
+
AppServerRateLimitSnapshot,
|
|
5
|
+
AppServerWindowSnapshot,
|
|
6
|
+
BackendAdditionalRateLimit,
|
|
7
|
+
BackendCreditsSnapshot,
|
|
8
|
+
BackendRateLimitDetails,
|
|
9
|
+
BackendWindowSnapshot,
|
|
10
|
+
CodexUsageReport,
|
|
11
|
+
NormalizedCredits,
|
|
12
|
+
NormalizedRateLimitSnapshot,
|
|
13
|
+
NormalizedRateLimitWindow,
|
|
14
|
+
RateLimitStatusPayload,
|
|
15
|
+
UsageSource,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
|
|
18
|
+
export function normalizeBackendPayload(
|
|
19
|
+
payload: RateLimitStatusPayload,
|
|
20
|
+
capturedAt: number,
|
|
21
|
+
source: UsageSource,
|
|
22
|
+
): CodexUsageReport {
|
|
23
|
+
const snapshots: NormalizedRateLimitSnapshot[] = [];
|
|
24
|
+
const planType = asString(payload.plan_type);
|
|
25
|
+
const primary = normalizeBackendSnapshot("codex", undefined, payload.rate_limit, payload.credits);
|
|
26
|
+
if (primary) snapshots.push(primary);
|
|
27
|
+
|
|
28
|
+
const additional = Array.isArray(payload.additional_rate_limits)
|
|
29
|
+
? payload.additional_rate_limits
|
|
30
|
+
: [];
|
|
31
|
+
for (const item of additional) {
|
|
32
|
+
const additionalLimit = assertObject(
|
|
33
|
+
item,
|
|
34
|
+
"additional rate limit",
|
|
35
|
+
) as BackendAdditionalRateLimit;
|
|
36
|
+
const limitId =
|
|
37
|
+
asString(additionalLimit.metered_feature) ?? asString(additionalLimit.limit_name);
|
|
38
|
+
if (!limitId) continue;
|
|
39
|
+
const snapshot = normalizeBackendSnapshot(
|
|
40
|
+
limitId,
|
|
41
|
+
asString(additionalLimit.limit_name),
|
|
42
|
+
additionalLimit.rate_limit,
|
|
43
|
+
undefined,
|
|
44
|
+
);
|
|
45
|
+
if (snapshot) snapshots.push(snapshot);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (snapshots.length === 0) {
|
|
49
|
+
throw new Error("Codex usage endpoint returned no displayable rate-limit windows.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { source, capturedAt, planType, snapshots };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeBackendSnapshot(
|
|
56
|
+
limitId: string,
|
|
57
|
+
limitName: string | undefined,
|
|
58
|
+
rateLimit: unknown,
|
|
59
|
+
credits: unknown,
|
|
60
|
+
): NormalizedRateLimitSnapshot | undefined {
|
|
61
|
+
if (rateLimit === null || rateLimit === undefined) {
|
|
62
|
+
const normalizedCredits = normalizeBackendCredits(credits);
|
|
63
|
+
return normalizedCredits ? { limitId, limitName, credits: normalizedCredits } : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const details = assertObject(rateLimit, "rate limit") as BackendRateLimitDetails;
|
|
67
|
+
const primary = normalizeBackendWindow(details.primary_window);
|
|
68
|
+
const secondary = normalizeBackendWindow(details.secondary_window);
|
|
69
|
+
const normalizedCredits = normalizeBackendCredits(credits);
|
|
70
|
+
|
|
71
|
+
if (!primary && !secondary && !normalizedCredits) return undefined;
|
|
72
|
+
return { limitId, limitName, primary, secondary, credits: normalizedCredits };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeBackendWindow(value: unknown): NormalizedRateLimitWindow | undefined {
|
|
76
|
+
if (value === null || value === undefined) return undefined;
|
|
77
|
+
const window = assertObject(value, "rate-limit window") as BackendWindowSnapshot;
|
|
78
|
+
const usedPercent = asNumber(window.used_percent);
|
|
79
|
+
if (usedPercent === undefined) return undefined;
|
|
80
|
+
const limitSeconds = asNumber(window.limit_window_seconds);
|
|
81
|
+
const resetsAt = asNumber(window.reset_at);
|
|
82
|
+
return {
|
|
83
|
+
usedPercent,
|
|
84
|
+
windowMinutes: limitSeconds && limitSeconds > 0 ? Math.ceil(limitSeconds / 60) : undefined,
|
|
85
|
+
resetsAt,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeBackendCredits(value: unknown): NormalizedCredits | undefined {
|
|
90
|
+
if (value === null || value === undefined) return undefined;
|
|
91
|
+
const credits = assertObject(value, "credits") as BackendCreditsSnapshot;
|
|
92
|
+
const hasCredits = asBoolean(credits.has_credits);
|
|
93
|
+
const unlimited = asBoolean(credits.unlimited);
|
|
94
|
+
if (hasCredits === undefined || unlimited === undefined) return undefined;
|
|
95
|
+
return { hasCredits, unlimited, balance: asString(credits.balance) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function normalizeAppServerResponse(
|
|
99
|
+
response: AppServerRateLimitResponse,
|
|
100
|
+
capturedAt: number,
|
|
101
|
+
): CodexUsageReport {
|
|
102
|
+
const snapshots: NormalizedRateLimitSnapshot[] = [];
|
|
103
|
+
const addSnapshot = (raw: unknown, fallbackId: string) => {
|
|
104
|
+
const snapshot = normalizeAppServerSnapshot(raw, fallbackId);
|
|
105
|
+
if (!snapshot) return;
|
|
106
|
+
const existingIndex = snapshots.findIndex((item) => item.limitId === snapshot.limitId);
|
|
107
|
+
if (existingIndex >= 0)
|
|
108
|
+
snapshots[existingIndex] = mergeSnapshot(snapshots[existingIndex], snapshot);
|
|
109
|
+
else snapshots.push(snapshot);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
addSnapshot(response.rateLimits, "codex");
|
|
113
|
+
if (response.rateLimitsByLimitId && typeof response.rateLimitsByLimitId === "object") {
|
|
114
|
+
for (const [limitId, raw] of Object.entries(response.rateLimitsByLimitId)) {
|
|
115
|
+
addSnapshot(raw, limitId);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (snapshots.length === 0) {
|
|
120
|
+
throw new Error("codex app-server returned no displayable rate-limit windows.");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const planType = asAppServerPlanType(response.rateLimits);
|
|
124
|
+
return { source: "codex-app-server", capturedAt, planType, snapshots };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function asAppServerPlanType(raw: unknown): string | undefined {
|
|
128
|
+
if (raw === null || raw === undefined) return undefined;
|
|
129
|
+
const snapshot = assertObject(
|
|
130
|
+
raw,
|
|
131
|
+
"app-server rate-limit snapshot",
|
|
132
|
+
) as AppServerRateLimitSnapshot;
|
|
133
|
+
return asString(snapshot.planType);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeAppServerSnapshot(
|
|
137
|
+
raw: unknown,
|
|
138
|
+
fallbackId: string,
|
|
139
|
+
): NormalizedRateLimitSnapshot | undefined {
|
|
140
|
+
if (raw === null || raw === undefined) return undefined;
|
|
141
|
+
const snapshot = assertObject(
|
|
142
|
+
raw,
|
|
143
|
+
"app-server rate-limit snapshot",
|
|
144
|
+
) as AppServerRateLimitSnapshot;
|
|
145
|
+
const limitId = asString(snapshot.limitId) ?? fallbackId;
|
|
146
|
+
const limitName = asString(snapshot.limitName);
|
|
147
|
+
const primary = normalizeAppServerWindow(snapshot.primary);
|
|
148
|
+
const secondary = normalizeAppServerWindow(snapshot.secondary);
|
|
149
|
+
const credits = normalizeAppServerCredits(snapshot.credits);
|
|
150
|
+
if (!primary && !secondary && !credits) return undefined;
|
|
151
|
+
return { limitId, limitName, primary, secondary, credits };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeAppServerWindow(value: unknown): NormalizedRateLimitWindow | undefined {
|
|
155
|
+
if (value === null || value === undefined) return undefined;
|
|
156
|
+
const window = assertObject(value, "app-server rate-limit window") as AppServerWindowSnapshot;
|
|
157
|
+
const usedPercent = asNumber(window.usedPercent);
|
|
158
|
+
if (usedPercent === undefined) return undefined;
|
|
159
|
+
return {
|
|
160
|
+
usedPercent,
|
|
161
|
+
windowMinutes: asNumber(window.windowDurationMins),
|
|
162
|
+
resetsAt: asNumber(window.resetsAt),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeAppServerCredits(value: unknown): NormalizedCredits | undefined {
|
|
167
|
+
if (value === null || value === undefined) return undefined;
|
|
168
|
+
const credits = assertObject(value, "app-server credits") as AppServerCreditsSnapshot;
|
|
169
|
+
const hasCredits = asBoolean(credits.hasCredits);
|
|
170
|
+
const unlimited = asBoolean(credits.unlimited);
|
|
171
|
+
if (hasCredits === undefined || unlimited === undefined) return undefined;
|
|
172
|
+
return { hasCredits, unlimited, balance: asString(credits.balance) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function mergeSnapshot(
|
|
176
|
+
left: NormalizedRateLimitSnapshot,
|
|
177
|
+
right: NormalizedRateLimitSnapshot,
|
|
178
|
+
): NormalizedRateLimitSnapshot {
|
|
179
|
+
return {
|
|
180
|
+
limitId: right.limitId || left.limitId,
|
|
181
|
+
limitName: right.limitName ?? left.limitName,
|
|
182
|
+
primary: right.primary ?? left.primary,
|
|
183
|
+
secondary: right.secondary ?? left.secondary,
|
|
184
|
+
credits: right.credits ?? left.credits,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function assertObject(value: unknown, description: string): Record<string, unknown> {
|
|
189
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
190
|
+
throw new Error(`${description} was not an object.`);
|
|
191
|
+
}
|
|
192
|
+
return value as Record<string, unknown>;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function asString(value: unknown): string | undefined {
|
|
196
|
+
return typeof value === "string" ? value : undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function asNumber(value: unknown): number | undefined {
|
|
200
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
201
|
+
if (typeof value === "string" && value.trim()) {
|
|
202
|
+
const parsed = Number(value);
|
|
203
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function asBoolean(value: unknown): boolean | undefined {
|
|
209
|
+
return typeof value === "boolean" ? value : undefined;
|
|
210
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { queryViaCodexAppServer } from "./app-server-client.js";
|
|
3
|
+
import { normalizeBackendPayload } from "./normalize.js";
|
|
4
|
+
import type {
|
|
5
|
+
CodexUsageReport,
|
|
6
|
+
PiModel,
|
|
7
|
+
QueryUsageOptions,
|
|
8
|
+
QueryUsageResult,
|
|
9
|
+
RateLimitStatusPayload,
|
|
10
|
+
UsageQueryError,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
const CODEX_PROVIDER_ID = "openai-codex";
|
|
14
|
+
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
15
|
+
const MAX_ERROR_BODY_CHARS = 600;
|
|
16
|
+
|
|
17
|
+
export function isOpenAICodexModel(model: Pick<PiModel, "provider"> | undefined): boolean {
|
|
18
|
+
return model?.provider === CODEX_PROVIDER_ID;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isStaleExtensionContextError(error: unknown): boolean {
|
|
22
|
+
return (
|
|
23
|
+
error instanceof Error &&
|
|
24
|
+
error.message.includes("This extension ctx is stale after session replacement or reload")
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function queryUsage(
|
|
29
|
+
ctx: ExtensionContext,
|
|
30
|
+
options: Pick<QueryUsageOptions, "timeoutMs">,
|
|
31
|
+
): Promise<QueryUsageResult> {
|
|
32
|
+
const errors: UsageQueryError[] = [];
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const report = await queryViaPiAuth(ctx, options.timeoutMs);
|
|
36
|
+
return { ok: true, report };
|
|
37
|
+
} catch (cause) {
|
|
38
|
+
if (isStaleExtensionContextError(cause)) throw cause;
|
|
39
|
+
errors.push({ source: "pi-auth", message: errorMessage(cause), cause });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const report = await queryViaCodexAppServer(options.timeoutMs);
|
|
44
|
+
return { ok: true, report };
|
|
45
|
+
} catch (cause) {
|
|
46
|
+
if (isStaleExtensionContextError(cause)) throw cause;
|
|
47
|
+
errors.push({ source: "codex-app-server", message: errorMessage(cause), cause });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { ok: false, errors };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function queryViaPiAuth(
|
|
54
|
+
ctx: ExtensionContext,
|
|
55
|
+
timeoutMs: number,
|
|
56
|
+
): Promise<CodexUsageReport> {
|
|
57
|
+
const auth = await resolvePiCodexAuth(ctx);
|
|
58
|
+
if (!auth) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"No Pi OpenAI Codex subscription auth was available. Use a Pi OpenAI Codex model or run /login for OpenAI ChatGPT Plus/Pro (Codex).",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const response = await fetchWithTimeout(CODEX_USAGE_URL, { headers: auth.headers }, timeoutMs);
|
|
65
|
+
const text = await response.text();
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Codex usage endpoint returned ${response.status} ${response.statusText}: ${redactErrorBody(text)}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const payload = parseJsonObject(text, "Codex usage endpoint response");
|
|
73
|
+
return normalizeBackendPayload(payload as RateLimitStatusPayload, Date.now(), "pi-auth");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function resolvePiCodexAuth(
|
|
77
|
+
ctx: ExtensionContext,
|
|
78
|
+
): Promise<{ headers: Record<string, string> } | undefined> {
|
|
79
|
+
const models = codexAuthCandidateModels(ctx);
|
|
80
|
+
const errors: string[] = [];
|
|
81
|
+
|
|
82
|
+
for (const model of models) {
|
|
83
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
84
|
+
if (!auth.ok) {
|
|
85
|
+
errors.push(auth.error);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const headers = { ...(auth.headers ?? {}) };
|
|
90
|
+
if (!hasHeader(headers, "Authorization") && auth.apiKey) {
|
|
91
|
+
headers.Authorization = `Bearer ${auth.apiKey}`;
|
|
92
|
+
}
|
|
93
|
+
if (!hasHeader(headers, "User-Agent")) {
|
|
94
|
+
headers["User-Agent"] = "pi-codex-usage";
|
|
95
|
+
}
|
|
96
|
+
if (hasHeader(headers, "Authorization")) {
|
|
97
|
+
return { headers };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (errors.length > 0) {
|
|
102
|
+
throw new Error(errors.join("; "));
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function codexAuthCandidateModels(ctx: ExtensionContext): PiModel[] {
|
|
108
|
+
const candidates: PiModel[] = [];
|
|
109
|
+
const seen = new Set<string>();
|
|
110
|
+
const add = (model: PiModel | undefined) => {
|
|
111
|
+
if (!model || model.provider !== CODEX_PROVIDER_ID) return;
|
|
112
|
+
const key = `${model.provider}/${model.id}`;
|
|
113
|
+
if (seen.has(key)) return;
|
|
114
|
+
seen.add(key);
|
|
115
|
+
candidates.push(model);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
add(ctx.model);
|
|
119
|
+
for (const model of ctx.modelRegistry.getAvailable()) add(model);
|
|
120
|
+
for (const model of ctx.modelRegistry.getAll()) add(model);
|
|
121
|
+
return candidates;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function fetchWithTimeout(
|
|
125
|
+
url: string,
|
|
126
|
+
init: RequestInit,
|
|
127
|
+
timeoutMs: number,
|
|
128
|
+
): Promise<Response> {
|
|
129
|
+
const controller = new AbortController();
|
|
130
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
131
|
+
try {
|
|
132
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (controller.signal.aborted) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Timed out after ${Math.round(timeoutMs / 1000)}s while fetching Codex usage.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
} finally {
|
|
141
|
+
clearTimeout(timeout);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseJsonObject(text: string, description: string): Record<string, unknown> {
|
|
146
|
+
let parsed: unknown;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(text) as unknown;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
throw new Error(`${description} was not valid JSON: ${errorMessage(error)}`);
|
|
151
|
+
}
|
|
152
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
153
|
+
throw new Error(`${description} was not an object.`);
|
|
154
|
+
}
|
|
155
|
+
return parsed as Record<string, unknown>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
159
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function redactErrorBody(body: string): string {
|
|
163
|
+
return truncateEnd(
|
|
164
|
+
body
|
|
165
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer <redacted>")
|
|
166
|
+
.replace(/"access_token"\s*:\s*"[^"]+"/gi, '"access_token":"<redacted>"')
|
|
167
|
+
.trim(),
|
|
168
|
+
MAX_ERROR_BODY_CHARS,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function truncateEnd(value: string, maxChars: number): string {
|
|
173
|
+
if (value.length <= maxChars) return value;
|
|
174
|
+
return `${value.slice(0, maxChars - 1)}…`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function errorMessage(error: unknown): string {
|
|
178
|
+
return error instanceof Error ? error.message : String(error);
|
|
179
|
+
}
|