@narumitw/pi-usage 0.49.3 → 0.51.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.
- package/README.md +57 -9
- package/package.json +8 -3
- package/src/codex-fast-runtime.ts +212 -0
- package/src/codex-fast.ts +124 -0
- package/src/codex-resets.ts +308 -0
- package/src/index.ts +35 -0
- package/src/query.ts +14 -2
- package/src/settings.ts +208 -0
- package/src/usage-helpers.ts +40 -0
- package/src/usage.ts +374 -81
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { fingerprintResolvedAuth, sanitizeDisplayText } from "./core.js";
|
|
4
|
+
import {
|
|
5
|
+
AUTH_FINGERPRINT_SALT,
|
|
6
|
+
adapterForProvider,
|
|
7
|
+
fetchProviderJson,
|
|
8
|
+
resolveUsageAuth,
|
|
9
|
+
} from "./query.js";
|
|
10
|
+
import type { ResolvedUsageAuth, UsageReport } from "./types.js";
|
|
11
|
+
|
|
12
|
+
const CODEX_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
|
|
13
|
+
const CODEX_RESET_CONSUME_URL = `${CODEX_RESET_CREDITS_URL}/consume`;
|
|
14
|
+
const MAX_RESET_OPTIONS = 32;
|
|
15
|
+
const MAX_CREDIT_ID_CHARS = 1_024;
|
|
16
|
+
|
|
17
|
+
type StoredCredentialReader = (providerId: string) => unknown;
|
|
18
|
+
|
|
19
|
+
export type CodexResetOutcomeCode = "reset" | "nothing_to_reset" | "no_credit" | "already_redeemed";
|
|
20
|
+
|
|
21
|
+
export interface CodexResetOption {
|
|
22
|
+
creditId?: string;
|
|
23
|
+
title: string;
|
|
24
|
+
description: string;
|
|
25
|
+
expiresAt?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CodexResetAvailability {
|
|
29
|
+
availableCount: number;
|
|
30
|
+
options: CodexResetOption[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CodexResetOutcome {
|
|
34
|
+
code: CodexResetOutcomeCode;
|
|
35
|
+
windowsReset: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function codexResetCount(report: UsageReport): number | undefined {
|
|
39
|
+
const value = report.metrics.find((metric) => metric.id === "reset-credits")?.value;
|
|
40
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function codexResetActionDescription(report: UsageReport): string {
|
|
44
|
+
const count = codexResetCount(report);
|
|
45
|
+
if (count === undefined) return "Check reset availability.";
|
|
46
|
+
if (count === 0) return "No usage limit resets available.";
|
|
47
|
+
return `You have ${count} ${resetLabel(count)} available.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function genericCodexResetOption(): CodexResetOption {
|
|
51
|
+
return {
|
|
52
|
+
title: "Full reset",
|
|
53
|
+
description: "Reset your current usage limits.",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resetOptionExpiration(option: CodexResetOption): string {
|
|
58
|
+
if (option.expiresAt === undefined) return "Does not expire.";
|
|
59
|
+
const expiration = new Date(option.expiresAt * 1_000);
|
|
60
|
+
if (Number.isNaN(expiration.getTime())) return "Expiration unavailable.";
|
|
61
|
+
return `Expires ${expiration.toLocaleString()}.`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resetConfirmationLines(option: CodexResetOption | undefined): string[] {
|
|
65
|
+
if (!option) return ["The selected reset is unavailable."];
|
|
66
|
+
return [
|
|
67
|
+
option.title,
|
|
68
|
+
resetOptionExpiration(option),
|
|
69
|
+
option.description,
|
|
70
|
+
"This consumes one earned reset for the current OpenAI Codex account.",
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatCodexResetOutcome(
|
|
75
|
+
outcome: CodexResetOutcome | undefined,
|
|
76
|
+
remainingCount: number | undefined,
|
|
77
|
+
): string {
|
|
78
|
+
const remaining =
|
|
79
|
+
remainingCount === undefined
|
|
80
|
+
? ""
|
|
81
|
+
: ` You have ${remainingCount} ${resetLabel(remainingCount)} left.`;
|
|
82
|
+
if (!outcome) return "You don't have any usage limit resets available.";
|
|
83
|
+
if (outcome.code === "reset") return `Usage reset.${remaining}`.trim();
|
|
84
|
+
if (outcome.code === "already_redeemed") {
|
|
85
|
+
return `Usage reset was already completed.${remaining}`.trim();
|
|
86
|
+
}
|
|
87
|
+
if (outcome.code === "nothing_to_reset") {
|
|
88
|
+
return "Your usage does not need a reset right now.";
|
|
89
|
+
}
|
|
90
|
+
return "No usage limit resets are available.";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function resetLabel(count: number): string {
|
|
94
|
+
return count === 1 ? "usage limit reset" : "usage limit resets";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function resolveCodexResetAuth(
|
|
98
|
+
ctx: ExtensionContext,
|
|
99
|
+
salt: Uint8Array = AUTH_FINGERPRINT_SALT,
|
|
100
|
+
credentialReader: StoredCredentialReader = readStoredCredential,
|
|
101
|
+
): Promise<ResolvedUsageAuth> {
|
|
102
|
+
const model = ctx.model;
|
|
103
|
+
if (model?.provider !== "openai-codex") {
|
|
104
|
+
throw new Error("Usage limit resets require the current model to use OpenAI Codex.");
|
|
105
|
+
}
|
|
106
|
+
const expectedModel = `${model.provider}/${model.id}`;
|
|
107
|
+
const adapter = adapterForProvider("openai-codex");
|
|
108
|
+
if (!adapter) throw new Error("OpenAI Codex usage support is unavailable.");
|
|
109
|
+
const auth = await resolveUsageAuth(ctx, adapter, salt, credentialReader);
|
|
110
|
+
if (`${ctx.model?.provider}/${ctx.model?.id}` !== expectedModel) {
|
|
111
|
+
throw new Error("The current model changed while resolving Codex reset authentication.");
|
|
112
|
+
}
|
|
113
|
+
if (!auth) throw new Error("No runtime credential is configured for OpenAI Codex.");
|
|
114
|
+
|
|
115
|
+
const credential = asObject(credentialReader("openai-codex"));
|
|
116
|
+
if (credential?.type !== "oauth") {
|
|
117
|
+
throw new Error(
|
|
118
|
+
"Usage limit resets require the OpenAI Codex OAuth account configured through Pi /login.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const storedAccess = asNonemptyString(credential.access);
|
|
122
|
+
const resolvedAccess = bearerToken(headerValue(auth.headers, "Authorization")) ?? auth.apiKey;
|
|
123
|
+
if (!storedAccess || !resolvedAccess) {
|
|
124
|
+
throw new Error("OpenAI Codex OAuth credentials were incomplete.");
|
|
125
|
+
}
|
|
126
|
+
if (storedAccess !== resolvedAccess) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
"The active OpenAI Codex runtime account does not match Pi's stored OAuth account.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const accountId = validHeaderValue(credential.accountId);
|
|
132
|
+
if (!accountId)
|
|
133
|
+
throw new Error("The OpenAI Codex OAuth credential did not include a valid account ID.");
|
|
134
|
+
|
|
135
|
+
const authorization = `Bearer ${resolvedAccess}`;
|
|
136
|
+
const headers = {
|
|
137
|
+
Authorization: authorization,
|
|
138
|
+
"chatgpt-account-id": accountId,
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
apiKey: resolvedAccess,
|
|
142
|
+
headers,
|
|
143
|
+
fingerprint: fingerprintResolvedAuth({ headers }, salt),
|
|
144
|
+
secrets: [
|
|
145
|
+
...new Set([...auth.secrets, storedAccess, resolvedAccess, authorization, accountId]),
|
|
146
|
+
],
|
|
147
|
+
model: auth.model,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function listCodexResetCredits(
|
|
152
|
+
auth: ResolvedUsageAuth,
|
|
153
|
+
signal: AbortSignal,
|
|
154
|
+
timeoutMs: number,
|
|
155
|
+
): Promise<CodexResetAvailability> {
|
|
156
|
+
const payload = await fetchProviderJson(
|
|
157
|
+
CODEX_RESET_CREDITS_URL,
|
|
158
|
+
auth,
|
|
159
|
+
signal,
|
|
160
|
+
timeoutMs,
|
|
161
|
+
"Codex usage-limit reset endpoint",
|
|
162
|
+
);
|
|
163
|
+
return normalizeCodexResetCreditsPayload(payload);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function consumeCodexResetCredit(
|
|
167
|
+
auth: ResolvedUsageAuth,
|
|
168
|
+
option: CodexResetOption,
|
|
169
|
+
redeemRequestId: string,
|
|
170
|
+
signal: AbortSignal,
|
|
171
|
+
timeoutMs: number,
|
|
172
|
+
): Promise<CodexResetOutcome> {
|
|
173
|
+
if (!redeemRequestId) throw new Error("Codex reset redemption request ID must not be empty.");
|
|
174
|
+
const payload = await fetchProviderJson(
|
|
175
|
+
CODEX_RESET_CONSUME_URL,
|
|
176
|
+
auth,
|
|
177
|
+
signal,
|
|
178
|
+
timeoutMs,
|
|
179
|
+
"Codex usage-limit reset consume endpoint",
|
|
180
|
+
{
|
|
181
|
+
method: "POST",
|
|
182
|
+
body: {
|
|
183
|
+
redeem_request_id: redeemRequestId,
|
|
184
|
+
...(option.creditId ? { credit_id: option.creditId } : {}),
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
const code = payload.code;
|
|
189
|
+
if (!isCodexResetOutcomeCode(code)) {
|
|
190
|
+
throw new Error("Codex reset consume endpoint returned an unknown outcome code.");
|
|
191
|
+
}
|
|
192
|
+
const windowsReset =
|
|
193
|
+
payload.windows_reset === undefined ? 0 : nonnegativeInteger(payload.windows_reset);
|
|
194
|
+
if (windowsReset === undefined) {
|
|
195
|
+
throw new Error("Codex reset consume endpoint returned an invalid windows_reset value.");
|
|
196
|
+
}
|
|
197
|
+
return { code, windowsReset };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function normalizeCodexResetCreditsPayload(
|
|
201
|
+
payload: Record<string, unknown>,
|
|
202
|
+
): CodexResetAvailability {
|
|
203
|
+
const availableCount = nonnegativeInteger(payload.available_count);
|
|
204
|
+
if (availableCount === undefined) {
|
|
205
|
+
throw new Error("Codex reset credits response returned an invalid available_count.");
|
|
206
|
+
}
|
|
207
|
+
const rawCredits = payload.credits;
|
|
208
|
+
if (rawCredits !== undefined && !Array.isArray(rawCredits)) {
|
|
209
|
+
throw new Error("Codex reset credits response returned invalid credits.");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const options = (rawCredits ?? [])
|
|
213
|
+
.map(asObject)
|
|
214
|
+
.filter((credit): credit is Record<string, unknown> => Boolean(credit))
|
|
215
|
+
.filter((credit) => credit.status === "available" && credit.reset_type === "codex_rate_limits")
|
|
216
|
+
.map(normalizeResetOption)
|
|
217
|
+
.sort(
|
|
218
|
+
(left, right) =>
|
|
219
|
+
(left.expiresAt ?? Number.MAX_SAFE_INTEGER) - (right.expiresAt ?? Number.MAX_SAFE_INTEGER),
|
|
220
|
+
)
|
|
221
|
+
.slice(0, Math.min(availableCount, MAX_RESET_OPTIONS));
|
|
222
|
+
|
|
223
|
+
if (availableCount > 0 && options.length === 0) {
|
|
224
|
+
options.push(genericCodexResetOption());
|
|
225
|
+
}
|
|
226
|
+
return { availableCount, options };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeResetOption(credit: Record<string, unknown>): CodexResetOption {
|
|
230
|
+
const creditId = asOpaqueId(credit.id);
|
|
231
|
+
if (!creditId) throw new Error("Codex reset credits response returned an invalid credit ID.");
|
|
232
|
+
let expiresAt: number | undefined;
|
|
233
|
+
if (credit.expires_at !== undefined && credit.expires_at !== null) {
|
|
234
|
+
if (typeof credit.expires_at !== "string") {
|
|
235
|
+
throw new Error("Codex reset credits response returned an invalid expiration time.");
|
|
236
|
+
}
|
|
237
|
+
const parsed = Date.parse(credit.expires_at);
|
|
238
|
+
if (!Number.isFinite(parsed)) {
|
|
239
|
+
throw new Error("Codex reset credits response returned an invalid expiration time.");
|
|
240
|
+
}
|
|
241
|
+
expiresAt = Math.floor(parsed / 1_000);
|
|
242
|
+
}
|
|
243
|
+
const title = displayString(credit.title) ?? "Full reset";
|
|
244
|
+
const description = displayString(credit.description) ?? "Reset your current usage limits.";
|
|
245
|
+
return {
|
|
246
|
+
creditId,
|
|
247
|
+
title,
|
|
248
|
+
description,
|
|
249
|
+
...(expiresAt === undefined ? {} : { expiresAt }),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isCodexResetOutcomeCode(value: unknown): value is CodexResetOutcomeCode {
|
|
254
|
+
return (
|
|
255
|
+
value === "reset" ||
|
|
256
|
+
value === "nothing_to_reset" ||
|
|
257
|
+
value === "no_credit" ||
|
|
258
|
+
value === "already_redeemed"
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
263
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
264
|
+
return value as Record<string, unknown>;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function asNonemptyString(value: unknown): string | undefined {
|
|
268
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function asOpaqueId(value: unknown): string | undefined {
|
|
272
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_CREDIT_ID_CHARS) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
return value;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function displayString(value: unknown): string | undefined {
|
|
279
|
+
if (typeof value !== "string") return undefined;
|
|
280
|
+
return sanitizeDisplayText(value, 160) || undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function validHeaderValue(value: unknown): string | undefined {
|
|
284
|
+
if (typeof value !== "string" || !value || value.length > 512) return undefined;
|
|
285
|
+
if (/[^\x20-\x7e]/u.test(value)) return undefined;
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function nonnegativeInteger(value: unknown): number | undefined {
|
|
290
|
+
const parsed =
|
|
291
|
+
typeof value === "number"
|
|
292
|
+
? value
|
|
293
|
+
: typeof value === "string" && value.trim()
|
|
294
|
+
? Number(value)
|
|
295
|
+
: Number.NaN;
|
|
296
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) return undefined;
|
|
297
|
+
return parsed;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function bearerToken(authorization: string | undefined): string | undefined {
|
|
301
|
+
return /^Bearer\s+(.+)$/iu.exec(authorization ?? "")?.[1];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function headerValue(headers: Record<string, string>, name: string): string | undefined {
|
|
305
|
+
return Object.entries(headers).find(
|
|
306
|
+
([candidate]) => candidate.toLowerCase() === name.toLowerCase(),
|
|
307
|
+
)?.[1];
|
|
308
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
export {
|
|
2
|
+
CODEX_FAST_MODEL_IDS,
|
|
3
|
+
CODEX_FAST_SERVICE_TIER,
|
|
4
|
+
CODEX_STANDARD_SERVICE_TIER,
|
|
5
|
+
codexFastAvailability,
|
|
6
|
+
codexFastIsEffective,
|
|
7
|
+
codexFastRequestTier,
|
|
8
|
+
codexFastStatusLabel,
|
|
9
|
+
correctCodexFastMessageCost,
|
|
10
|
+
rewriteCodexFastPayload,
|
|
11
|
+
} from "./codex-fast.js";
|
|
12
|
+
export type {
|
|
13
|
+
CodexResetAvailability,
|
|
14
|
+
CodexResetOption,
|
|
15
|
+
CodexResetOutcome,
|
|
16
|
+
CodexResetOutcomeCode,
|
|
17
|
+
} from "./codex-resets.js";
|
|
18
|
+
export {
|
|
19
|
+
consumeCodexResetCredit,
|
|
20
|
+
listCodexResetCredits,
|
|
21
|
+
normalizeCodexResetCreditsPayload,
|
|
22
|
+
resolveCodexResetAuth,
|
|
23
|
+
} from "./codex-resets.js";
|
|
1
24
|
export {
|
|
2
25
|
abortError,
|
|
3
26
|
awaitWithDeadline,
|
|
@@ -20,6 +43,18 @@ export {
|
|
|
20
43
|
resolveUsageAuth,
|
|
21
44
|
SUPPORTED_ADAPTERS,
|
|
22
45
|
} from "./query.js";
|
|
46
|
+
export type {
|
|
47
|
+
UsageSettings,
|
|
48
|
+
UsageSettingsRuntime,
|
|
49
|
+
UsageSettingsState,
|
|
50
|
+
} from "./settings.js";
|
|
51
|
+
export {
|
|
52
|
+
createUsageSettingsRuntime,
|
|
53
|
+
DEFAULT_USAGE_SETTINGS,
|
|
54
|
+
loadUsageSettings,
|
|
55
|
+
normalizeUsageSettings,
|
|
56
|
+
usageSettingsPath,
|
|
57
|
+
} from "./settings.js";
|
|
23
58
|
export type {
|
|
24
59
|
ProviderUsageState,
|
|
25
60
|
ResolvedUsageAuth,
|
package/src/query.ts
CHANGED
|
@@ -182,12 +182,16 @@ function candidateModels(ctx: ExtensionContext, providerId: string): PiModel[] {
|
|
|
182
182
|
return candidates;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
async function fetchProviderJson(
|
|
185
|
+
export async function fetchProviderJson(
|
|
186
186
|
url: string,
|
|
187
187
|
auth: ResolvedUsageAuth,
|
|
188
188
|
signal: AbortSignal,
|
|
189
189
|
timeoutMs: number,
|
|
190
190
|
description: string,
|
|
191
|
+
request: {
|
|
192
|
+
method?: "GET" | "POST";
|
|
193
|
+
body?: Record<string, unknown>;
|
|
194
|
+
} = {},
|
|
191
195
|
): Promise<Record<string, unknown>> {
|
|
192
196
|
const controller = new AbortController();
|
|
193
197
|
let timedOut = false;
|
|
@@ -201,7 +205,15 @@ async function fetchProviderJson(
|
|
|
201
205
|
try {
|
|
202
206
|
const headers = { ...auth.headers };
|
|
203
207
|
if (!hasHeader(headers, "User-Agent")) headers["User-Agent"] = "pi-usage";
|
|
204
|
-
|
|
208
|
+
if (request.body && !hasHeader(headers, "Content-Type")) {
|
|
209
|
+
headers["Content-Type"] = "application/json";
|
|
210
|
+
}
|
|
211
|
+
const response = await fetch(url, {
|
|
212
|
+
method: request.method ?? "GET",
|
|
213
|
+
headers,
|
|
214
|
+
...(request.body ? { body: JSON.stringify(request.body) } : {}),
|
|
215
|
+
signal: controller.signal,
|
|
216
|
+
});
|
|
205
217
|
if (controller.signal.aborted)
|
|
206
218
|
throw Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
|
|
207
219
|
const text = await readBoundedResponse(
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { chmod, mkdir, open, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
export const USAGE_SETTINGS_FILE = "pi-usage.json";
|
|
8
|
+
export const MAX_USAGE_SETTINGS_BYTES = 64 * 1024;
|
|
9
|
+
|
|
10
|
+
export interface UsageSettings {
|
|
11
|
+
codexFastMode: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_USAGE_SETTINGS: Readonly<UsageSettings> = Object.freeze({
|
|
15
|
+
codexFastMode: false,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export interface UsageSettingsState {
|
|
19
|
+
kind: "missing" | "loaded" | "invalid";
|
|
20
|
+
path: string;
|
|
21
|
+
settings: UsageSettings;
|
|
22
|
+
document?: Record<string, unknown>;
|
|
23
|
+
issue?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UsageSettingsRuntime {
|
|
27
|
+
get(): Readonly<UsageSettingsState>;
|
|
28
|
+
reload(signal?: AbortSignal): Promise<Readonly<UsageSettingsState>>;
|
|
29
|
+
update(
|
|
30
|
+
patch: Partial<UsageSettings>,
|
|
31
|
+
signal?: AbortSignal,
|
|
32
|
+
): Promise<Readonly<UsageSettingsState>>;
|
|
33
|
+
flush(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface UsageSettingsFileOperations {
|
|
37
|
+
rename: typeof rename;
|
|
38
|
+
writeFile: typeof writeFile;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface UsageSettingsRuntimeOptions {
|
|
42
|
+
operations?: Partial<UsageSettingsFileOperations>;
|
|
43
|
+
path?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function usageSettingsPath(): string {
|
|
47
|
+
return join(getAgentDir(), USAGE_SETTINGS_FILE);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeUsageSettings(value: unknown): UsageSettings | undefined {
|
|
51
|
+
if (!isRecord(value)) return undefined;
|
|
52
|
+
if (Object.hasOwn(value, "codexFastMode") && typeof value.codexFastMode !== "boolean") {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
codexFastMode:
|
|
57
|
+
typeof value.codexFastMode === "boolean"
|
|
58
|
+
? value.codexFastMode
|
|
59
|
+
: DEFAULT_USAGE_SETTINGS.codexFastMode,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function loadUsageSettings(
|
|
64
|
+
path = usageSettingsPath(),
|
|
65
|
+
signal?: AbortSignal,
|
|
66
|
+
): Promise<UsageSettingsState> {
|
|
67
|
+
throwIfAborted(signal);
|
|
68
|
+
try {
|
|
69
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
70
|
+
let text: string;
|
|
71
|
+
try {
|
|
72
|
+
const stats = await handle.stat();
|
|
73
|
+
throwIfAborted(signal);
|
|
74
|
+
if (!stats.isFile()) throw new Error("settings path is not a regular file");
|
|
75
|
+
if (stats.size > MAX_USAGE_SETTINGS_BYTES) {
|
|
76
|
+
throw new Error("settings file exceeds 64 KiB");
|
|
77
|
+
}
|
|
78
|
+
text = await handle.readFile("utf8");
|
|
79
|
+
} finally {
|
|
80
|
+
await handle.close();
|
|
81
|
+
}
|
|
82
|
+
throwIfAborted(signal);
|
|
83
|
+
const document = JSON.parse(text) as unknown;
|
|
84
|
+
const settings = normalizeUsageSettings(document);
|
|
85
|
+
if (!settings || !isRecord(document)) throw new Error("invalid settings shape");
|
|
86
|
+
return { kind: "loaded", path, settings, document };
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (signal?.aborted) throw error;
|
|
89
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
90
|
+
return {
|
|
91
|
+
kind: "missing",
|
|
92
|
+
path,
|
|
93
|
+
settings: { ...DEFAULT_USAGE_SETTINGS },
|
|
94
|
+
document: {},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
kind: "invalid",
|
|
99
|
+
path,
|
|
100
|
+
settings: { ...DEFAULT_USAGE_SETTINGS },
|
|
101
|
+
issue:
|
|
102
|
+
isNodeError(error) && error.code === "ELOOP"
|
|
103
|
+
? "symbolic links are not accepted"
|
|
104
|
+
: error instanceof Error
|
|
105
|
+
? error.message
|
|
106
|
+
: String(error),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createUsageSettingsRuntime(
|
|
112
|
+
options: UsageSettingsRuntimeOptions | string = {},
|
|
113
|
+
): UsageSettingsRuntime {
|
|
114
|
+
const path = typeof options === "string" ? options : (options.path ?? usageSettingsPath());
|
|
115
|
+
const operations: UsageSettingsFileOperations = {
|
|
116
|
+
rename,
|
|
117
|
+
writeFile,
|
|
118
|
+
...(typeof options === "string" ? undefined : options.operations),
|
|
119
|
+
};
|
|
120
|
+
let state: UsageSettingsState = {
|
|
121
|
+
kind: "missing",
|
|
122
|
+
path,
|
|
123
|
+
settings: { ...DEFAULT_USAGE_SETTINGS },
|
|
124
|
+
document: {},
|
|
125
|
+
};
|
|
126
|
+
let queue = Promise.resolve();
|
|
127
|
+
const enqueue = <T>(operation: () => Promise<T>): Promise<T> => {
|
|
128
|
+
const result = queue.then(operation, operation);
|
|
129
|
+
queue = result.then(
|
|
130
|
+
() => undefined,
|
|
131
|
+
() => undefined,
|
|
132
|
+
);
|
|
133
|
+
return result;
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
get: () => structuredClone(state),
|
|
137
|
+
reload: (signal) =>
|
|
138
|
+
enqueue(async () => {
|
|
139
|
+
const loaded = await loadUsageSettings(path, signal);
|
|
140
|
+
state = loaded;
|
|
141
|
+
return structuredClone(state);
|
|
142
|
+
}),
|
|
143
|
+
update: (patch, signal) =>
|
|
144
|
+
enqueue(async () => {
|
|
145
|
+
const saved = await saveUsageSettingsPatch(path, patch, operations, signal);
|
|
146
|
+
state = saved;
|
|
147
|
+
return structuredClone(state);
|
|
148
|
+
}),
|
|
149
|
+
flush: () => queue,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function saveUsageSettingsPatch(
|
|
154
|
+
path: string,
|
|
155
|
+
patch: Partial<UsageSettings>,
|
|
156
|
+
operations: UsageSettingsFileOperations,
|
|
157
|
+
signal?: AbortSignal,
|
|
158
|
+
): Promise<UsageSettingsState> {
|
|
159
|
+
const latest = await loadUsageSettings(path, signal);
|
|
160
|
+
if (latest.kind === "invalid") {
|
|
161
|
+
throw new Error("Cannot overwrite an invalid pi-usage.json; repair it and reload first");
|
|
162
|
+
}
|
|
163
|
+
const document = { ...latest.document, ...patch };
|
|
164
|
+
const settings = normalizeUsageSettings(document);
|
|
165
|
+
if (!settings) throw new Error("Refusing to save invalid pi-usage settings");
|
|
166
|
+
const directory = dirname(path);
|
|
167
|
+
const temporaryPath = join(directory, `.${basename(path)}.${randomUUID()}.tmp`);
|
|
168
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
169
|
+
throwIfAborted(signal);
|
|
170
|
+
try {
|
|
171
|
+
await operations.writeFile(temporaryPath, `${JSON.stringify(document, null, 2)}\n`, {
|
|
172
|
+
encoding: "utf8",
|
|
173
|
+
flag: "wx",
|
|
174
|
+
mode: 0o600,
|
|
175
|
+
});
|
|
176
|
+
if (process.platform !== "win32") await chmodPrivate(temporaryPath);
|
|
177
|
+
throwIfAborted(signal);
|
|
178
|
+
const current = await loadUsageSettings(path, signal);
|
|
179
|
+
if (
|
|
180
|
+
current.kind === "invalid" ||
|
|
181
|
+
current.kind !== latest.kind ||
|
|
182
|
+
JSON.stringify(current.document) !== JSON.stringify(latest.document)
|
|
183
|
+
) {
|
|
184
|
+
throw new Error("pi-usage.json changed while saving; retry the action");
|
|
185
|
+
}
|
|
186
|
+
throwIfAborted(signal);
|
|
187
|
+
await operations.rename(temporaryPath, path);
|
|
188
|
+
} finally {
|
|
189
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
190
|
+
}
|
|
191
|
+
return { kind: "loaded", path, settings, document };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function chmodPrivate(path: string): Promise<void> {
|
|
195
|
+
await chmod(path, 0o600);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
199
|
+
if (signal?.aborted) throw new DOMException("Settings operation aborted", "AbortError");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
203
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
207
|
+
return error instanceof Error && "code" in error;
|
|
208
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { sanitizeDisplayText } from "./core.js";
|
|
3
|
+
import { providerIsConfigured, SUPPORTED_ADAPTERS } from "./query.js";
|
|
4
|
+
import type { PiModel, UsageProviderAdapter } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export function configuredAdapters(ctx: ExtensionContext): UsageProviderAdapter[] {
|
|
7
|
+
return SUPPORTED_ADAPTERS.filter(
|
|
8
|
+
(adapter) => adapter.id === ctx.model?.provider || providerIsConfigured(ctx, adapter.id),
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function providerDisplayName(ctx: ExtensionContext, providerId: string): string {
|
|
13
|
+
try {
|
|
14
|
+
return sanitizeDisplayText(ctx.modelRegistry.getProviderDisplayName(providerId), 80);
|
|
15
|
+
} catch {
|
|
16
|
+
return sanitizeDisplayText(providerId, 80);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function setBoundedMap<T>(map: Map<string, T>, key: string, value: T, limit: number): void {
|
|
21
|
+
map.delete(key);
|
|
22
|
+
while (map.size >= limit) {
|
|
23
|
+
const oldest = map.keys().next().value;
|
|
24
|
+
if (oldest === undefined) break;
|
|
25
|
+
map.delete(oldest);
|
|
26
|
+
}
|
|
27
|
+
map.set(key, value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function modelIdentity(model: PiModel | undefined): string | undefined {
|
|
31
|
+
return model ? `${model.provider}/${model.id}` : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isAbortError(error: unknown): boolean {
|
|
35
|
+
return error instanceof Error && error.name === "AbortError";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isTimeoutError(error: unknown): boolean {
|
|
39
|
+
return error instanceof Error && error.name === "TimeoutError";
|
|
40
|
+
}
|