@narumitw/pi-usage 0.60.0 → 0.60.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 +27 -19
- package/dist/index.ts +804 -322
- package/dist/index.ts.map +4 -4
- package/package.json +1 -1
- package/src/core.ts +28 -2
- package/src/format.ts +37 -23
- package/src/index.ts +13 -0
- package/src/providers/fireworks.ts +112 -0
- package/src/providers/minimax.ts +21 -0
- package/src/query.ts +110 -150
- package/src/settings.ts +155 -8
- package/src/types.ts +39 -2
- package/src/usage-settings-ui.ts +88 -183
- package/src/usage-targets.ts +143 -0
- package/src/usage.ts +419 -107
package/src/query.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// This module intentionally keeps adapter transport beside resolved-auth and origin validation;
|
|
2
|
+
// separating that security boundary would duplicate request and redaction policy across providers.
|
|
1
3
|
import { randomBytes } from "node:crypto";
|
|
2
4
|
import { type ExtensionContext, readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
3
5
|
import { errorMessage, fingerprintResolvedAuth, redactUsageError } from "./core.js";
|
|
@@ -8,11 +10,7 @@ import {
|
|
|
8
10
|
import { normalizeBasetenBillingUsagePayload } from "./providers/baseten.js";
|
|
9
11
|
import { normalizeCodexBackendPayload } from "./providers/codex.js";
|
|
10
12
|
import { normalizeDeepSeekBalancePayload } from "./providers/deepseek.js";
|
|
11
|
-
import {
|
|
12
|
-
isFireworksAccountId,
|
|
13
|
-
normalizeFireworksAccountsPayload,
|
|
14
|
-
normalizeFireworksBillingSummaryPayload,
|
|
15
|
-
} from "./providers/fireworks.js";
|
|
13
|
+
import { createFireworksAdapter } from "./providers/fireworks.js";
|
|
16
14
|
import { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.js";
|
|
17
15
|
import { normalizeKimiCodingUsagePayload } from "./providers/kimi-coding.js";
|
|
18
16
|
import {
|
|
@@ -30,8 +28,6 @@ import type {
|
|
|
30
28
|
BasetenBillingUsagePayload,
|
|
31
29
|
CodexBackendPayload,
|
|
32
30
|
DeepSeekBalancePayload,
|
|
33
|
-
FireworksAccountsPayload,
|
|
34
|
-
FireworksBillingSummaryPayload,
|
|
35
31
|
GitHubCopilotUsagePayload,
|
|
36
32
|
KimiCodingUsagePayload,
|
|
37
33
|
MiniMaxUsagePayload,
|
|
@@ -43,6 +39,7 @@ import type {
|
|
|
43
39
|
UsageProviderAdapter,
|
|
44
40
|
UsageQuerySettings,
|
|
45
41
|
UsageReport,
|
|
42
|
+
UsageRequestGuard,
|
|
46
43
|
VercelAIGatewayCreditsPayload,
|
|
47
44
|
XaiBillingPayload,
|
|
48
45
|
XaiUserPayload,
|
|
@@ -50,14 +47,12 @@ import type {
|
|
|
50
47
|
ZaiQuotaPayload,
|
|
51
48
|
ZaiSubscriptionPayload,
|
|
52
49
|
} from "./types.js";
|
|
50
|
+
import { resolveUsageTarget } from "./usage-targets.js";
|
|
53
51
|
|
|
54
52
|
const BASETEN_BILLING_USAGE_URL = "https://api.baseten.co/v1/billing/usage_summary";
|
|
55
53
|
const BASETEN_USAGE_WINDOW_DAYS = 30;
|
|
56
54
|
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
57
55
|
const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
|
|
58
|
-
const FIREWORKS_BILLING_SUMMARY_ORIGIN = "https://api.fireworks.ai";
|
|
59
|
-
const FIREWORKS_SPEND_WINDOW_DAYS = 30;
|
|
60
|
-
const FIREWORKS_MAX_ACCOUNT_PAGES = 5;
|
|
61
56
|
const GITHUB_COPILOT_USAGE_URL = "https://api.github.com/copilot_internal/user";
|
|
62
57
|
const OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key";
|
|
63
58
|
const VERCEL_AI_GATEWAY_CREDITS_URL = "https://ai-gateway.vercel.sh/v1/credits";
|
|
@@ -84,8 +79,6 @@ const MAX_ERROR_BODY_BYTES = 4 * 1024;
|
|
|
84
79
|
|
|
85
80
|
export const AUTH_FINGERPRINT_SALT = randomBytes(32);
|
|
86
81
|
|
|
87
|
-
export type UsageRequestGuard = () => Promise<void>;
|
|
88
|
-
|
|
89
82
|
export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
|
|
90
83
|
{
|
|
91
84
|
id: "baseten",
|
|
@@ -201,35 +194,7 @@ export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
|
|
|
201
194
|
return normalizeVercelAIGatewayCreditsPayload(payload, Date.now());
|
|
202
195
|
},
|
|
203
196
|
},
|
|
204
|
-
|
|
205
|
-
id: "fireworks",
|
|
206
|
-
displayName: "Fireworks",
|
|
207
|
-
semantics: { kind: "api-key", label: "Fireworks API spend" },
|
|
208
|
-
async query(auth, signal, timeoutMs, guard, settings) {
|
|
209
|
-
if (!guard) throw new Error("Fireworks API spend requires request-boundary revalidation.");
|
|
210
|
-
const startedAt = Date.now();
|
|
211
|
-
await guard();
|
|
212
|
-
const accountId = await resolveFireworksAccountId(
|
|
213
|
-
auth,
|
|
214
|
-
signal,
|
|
215
|
-
remainingTimeout(timeoutMs, startedAt, "resolving the Fireworks account"),
|
|
216
|
-
guard,
|
|
217
|
-
settings?.fireworksAccountId,
|
|
218
|
-
);
|
|
219
|
-
await guard();
|
|
220
|
-
const billingWindowAt = Date.now();
|
|
221
|
-
const payload = (await fetchProviderJson(
|
|
222
|
-
fireworksBillingSummaryUrl(accountId, billingWindowAt),
|
|
223
|
-
auth,
|
|
224
|
-
signal,
|
|
225
|
-
remainingTimeout(timeoutMs, startedAt, "fetching Fireworks rated spend"),
|
|
226
|
-
"Fireworks billing summary endpoint",
|
|
227
|
-
{ redirect: "error" },
|
|
228
|
-
)) as FireworksBillingSummaryPayload;
|
|
229
|
-
await guard();
|
|
230
|
-
return normalizeFireworksBillingSummaryPayload(payload, accountId, Date.now());
|
|
231
|
-
},
|
|
232
|
-
},
|
|
197
|
+
createFireworksAdapter(fetchProviderJson),
|
|
233
198
|
{
|
|
234
199
|
id: "opencode-go",
|
|
235
200
|
displayName: "OpenCode Go",
|
|
@@ -402,6 +367,12 @@ export async function resolveUsageAuth(
|
|
|
402
367
|
if (!model) return undefined;
|
|
403
368
|
// SAFETY: Pi exposes the required auth methods at runtime, and checks below narrow them before use.
|
|
404
369
|
const registry = ctx.modelRegistry as unknown as UsageAuthRegistry;
|
|
370
|
+
const provider = registry.getProvider?.(adapter.id);
|
|
371
|
+
if (provider?.baseUrl && !hasOfficialUrlOrigin(provider.baseUrl, adapter.id)) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`${adapter.displayName} usage cannot send an overridden provider credential to the official usage endpoint.`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
405
376
|
let modelAuth: RequestAuth | undefined;
|
|
406
377
|
const currentModel = ctx.model?.provider === adapter.id ? ctx.model : undefined;
|
|
407
378
|
const resolveCurrentModelAuth = async (): Promise<RequestAuth | undefined> => {
|
|
@@ -429,8 +400,46 @@ export async function resolveUsageAuth(
|
|
|
429
400
|
// Providers with credential-change retries read selected-model auth last so a rotation during
|
|
430
401
|
// provider-origin validation cannot leave the earlier credential queued for the usage request.
|
|
431
402
|
if (resolveSelectedAuthLast) modelAuth = await resolveCurrentModelAuth();
|
|
403
|
+
if (modelAuth?.baseUrl && !hasOfficialUrlOrigin(modelAuth.baseUrl, adapter.id)) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`${adapter.displayName} usage cannot send model-resolved proxy credentials to the official usage endpoint.`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
432
408
|
const auth = modelAuth ?? providerResult?.auth;
|
|
433
409
|
if (!auth) return undefined;
|
|
410
|
+
const finalize = (resolved: ResolvedUsageAuth): ResolvedUsageAuth => {
|
|
411
|
+
const preservedAuth = { ...(providerResult?.auth ?? auth) };
|
|
412
|
+
const env = providerResult?.env ?? modelAuth?.env;
|
|
413
|
+
const source = providerResult?.source;
|
|
414
|
+
const effectiveBaseUrl =
|
|
415
|
+
modelAuth?.baseUrl ?? providerResult?.auth.baseUrl ?? provider?.baseUrl ?? model.baseUrl;
|
|
416
|
+
const redactionInputs = [
|
|
417
|
+
preservedAuth.apiKey,
|
|
418
|
+
...Object.values(preservedAuth.headers ?? {}),
|
|
419
|
+
...Object.values(env ?? {}),
|
|
420
|
+
modelAuth?.apiKey,
|
|
421
|
+
...Object.values(modelAuth?.headers ?? {}),
|
|
422
|
+
].filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
423
|
+
return {
|
|
424
|
+
...resolved,
|
|
425
|
+
auth: preservedAuth,
|
|
426
|
+
...(env ? { env: { ...env } } : {}),
|
|
427
|
+
...(source ? { source } : {}),
|
|
428
|
+
effectiveBaseUrl,
|
|
429
|
+
secrets: [...new Set([...resolved.secrets, ...redactionInputs])],
|
|
430
|
+
fingerprint: fingerprintResolvedAuth(
|
|
431
|
+
{
|
|
432
|
+
apiKey: resolved.apiKey,
|
|
433
|
+
headers: resolved.headers,
|
|
434
|
+
baseUrl: effectiveBaseUrl,
|
|
435
|
+
env,
|
|
436
|
+
source,
|
|
437
|
+
providerAuth: preservedAuth,
|
|
438
|
+
},
|
|
439
|
+
salt,
|
|
440
|
+
),
|
|
441
|
+
};
|
|
442
|
+
};
|
|
434
443
|
if (adapter.id === "github-copilot") {
|
|
435
444
|
const offered = candidateReader
|
|
436
445
|
? candidateReader(ctx, adapter.id)
|
|
@@ -438,12 +447,14 @@ export async function resolveUsageAuth(
|
|
|
438
447
|
if (!offered.ok) {
|
|
439
448
|
throw new Error("GitHub Copilot OAuth credential discovery failed closed.");
|
|
440
449
|
}
|
|
441
|
-
return
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
450
|
+
return finalize(
|
|
451
|
+
resolveGitHubCopilotUsageAuth(
|
|
452
|
+
auth,
|
|
453
|
+
model,
|
|
454
|
+
salt,
|
|
455
|
+
offered.candidates,
|
|
456
|
+
offered.offeredCount === 0,
|
|
457
|
+
),
|
|
447
458
|
);
|
|
448
459
|
}
|
|
449
460
|
if (adapter.id === "xai") {
|
|
@@ -451,7 +462,7 @@ export async function resolveUsageAuth(
|
|
|
451
462
|
? candidateReader(ctx, adapter.id)
|
|
452
463
|
: fallbackOAuthCredentialCandidates(adapter.id, credentialReader);
|
|
453
464
|
if (!offered.ok) throw new Error("xAI OAuth credential discovery failed closed.");
|
|
454
|
-
return resolveXaiUsageAuth(auth, model, salt, offered.candidates);
|
|
465
|
+
return finalize(resolveXaiUsageAuth(auth, model, salt, offered.candidates));
|
|
455
466
|
}
|
|
456
467
|
if (adapter.id === "deepseek") {
|
|
457
468
|
const resolvedAuthorization = authorizationFrom(auth);
|
|
@@ -459,10 +470,10 @@ export async function resolveUsageAuth(
|
|
|
459
470
|
if (!access) throw new Error("DeepSeek API balance requires Bearer authentication.");
|
|
460
471
|
const authorization = `Bearer ${access}`;
|
|
461
472
|
const headers = { Authorization: authorization };
|
|
462
|
-
return {
|
|
473
|
+
return finalize({
|
|
463
474
|
apiKey: access,
|
|
464
475
|
headers,
|
|
465
|
-
fingerprint:
|
|
476
|
+
fingerprint: "",
|
|
466
477
|
secrets: [
|
|
467
478
|
access,
|
|
468
479
|
auth.apiKey,
|
|
@@ -471,7 +482,7 @@ export async function resolveUsageAuth(
|
|
|
471
482
|
authorization,
|
|
472
483
|
].filter((value): value is string => Boolean(value)),
|
|
473
484
|
model,
|
|
474
|
-
};
|
|
485
|
+
});
|
|
475
486
|
}
|
|
476
487
|
const authorization = authorizationFrom(auth);
|
|
477
488
|
if (!authorization) return undefined;
|
|
@@ -479,13 +490,13 @@ export async function resolveUsageAuth(
|
|
|
479
490
|
const secrets = [auth.apiKey, headerValue(auth.headers, "Authorization"), authorization].filter(
|
|
480
491
|
(value): value is string => Boolean(value),
|
|
481
492
|
);
|
|
482
|
-
return {
|
|
493
|
+
return finalize({
|
|
483
494
|
apiKey: auth.apiKey,
|
|
484
495
|
headers,
|
|
485
|
-
fingerprint:
|
|
496
|
+
fingerprint: "",
|
|
486
497
|
secrets,
|
|
487
498
|
model,
|
|
488
|
-
};
|
|
499
|
+
});
|
|
489
500
|
}
|
|
490
501
|
|
|
491
502
|
export async function queryProviderUsage(
|
|
@@ -494,10 +505,46 @@ export async function queryProviderUsage(
|
|
|
494
505
|
signal: AbortSignal,
|
|
495
506
|
timeoutMs: number,
|
|
496
507
|
guard?: UsageRequestGuard,
|
|
497
|
-
|
|
508
|
+
targetOrSettings?: string | Readonly<UsageQuerySettings>,
|
|
498
509
|
): Promise<UsageReport> {
|
|
510
|
+
const startedAt = Date.now();
|
|
511
|
+
let targetId =
|
|
512
|
+
typeof targetOrSettings === "string"
|
|
513
|
+
? targetOrSettings
|
|
514
|
+
: adapter.id === "fireworks"
|
|
515
|
+
? targetOrSettings?.fireworksAccountId
|
|
516
|
+
: undefined;
|
|
517
|
+
let resolvedLegacyFireworksTarget = false;
|
|
499
518
|
try {
|
|
500
|
-
|
|
519
|
+
if (
|
|
520
|
+
adapter.id === "fireworks" &&
|
|
521
|
+
typeof targetOrSettings !== "string" &&
|
|
522
|
+
adapter.targets &&
|
|
523
|
+
guard
|
|
524
|
+
) {
|
|
525
|
+
const target = await resolveUsageTarget(
|
|
526
|
+
adapter,
|
|
527
|
+
auth,
|
|
528
|
+
targetId,
|
|
529
|
+
signal,
|
|
530
|
+
remainingTimeout(timeoutMs, startedAt, "resolving the Fireworks account"),
|
|
531
|
+
guard,
|
|
532
|
+
);
|
|
533
|
+
if (target.kind === "selection-required") {
|
|
534
|
+
throw new Error("Fireworks account selection is required.");
|
|
535
|
+
}
|
|
536
|
+
targetId = target.targetId;
|
|
537
|
+
resolvedLegacyFireworksTarget = true;
|
|
538
|
+
}
|
|
539
|
+
return await adapter.query(
|
|
540
|
+
auth,
|
|
541
|
+
signal,
|
|
542
|
+
resolvedLegacyFireworksTarget
|
|
543
|
+
? remainingTimeout(timeoutMs, startedAt, `querying ${adapter.displayName} usage`)
|
|
544
|
+
: timeoutMs,
|
|
545
|
+
guard,
|
|
546
|
+
targetId,
|
|
547
|
+
);
|
|
501
548
|
} catch (error) {
|
|
502
549
|
if (isStaleExtensionContextError(error) || isAbortError(error)) throw error;
|
|
503
550
|
throw new Error(redactUsageError(errorMessage(error), auth.secrets));
|
|
@@ -696,17 +743,22 @@ async function readBoundedResponse(
|
|
|
696
743
|
type RequestAuth = {
|
|
697
744
|
apiKey?: string;
|
|
698
745
|
headers?: Record<string, string | null>;
|
|
746
|
+
baseUrl?: string;
|
|
747
|
+
env?: Record<string, string>;
|
|
699
748
|
};
|
|
700
749
|
|
|
701
750
|
type StoredCredentialReader = (providerId: string) => unknown;
|
|
702
751
|
|
|
703
752
|
type UsageAuthRegistry = {
|
|
753
|
+
getProvider?(providerId: string): { baseUrl?: string } | undefined;
|
|
704
754
|
getApiKeyAndHeaders?(
|
|
705
755
|
model: PiModel,
|
|
706
756
|
): Promise<({ ok: true } & RequestAuth) | { ok: false; error: string }>;
|
|
707
757
|
getProviderAuth?(providerId: string): Promise<
|
|
708
758
|
| {
|
|
709
|
-
auth: RequestAuth
|
|
759
|
+
auth: RequestAuth;
|
|
760
|
+
env?: Record<string, string>;
|
|
761
|
+
source?: string;
|
|
710
762
|
}
|
|
711
763
|
| undefined
|
|
712
764
|
>;
|
|
@@ -902,7 +954,7 @@ function hasOfficialUrlOrigin(value: string, providerId: string): boolean {
|
|
|
902
954
|
}
|
|
903
955
|
if (providerId === "openai-codex") return url.origin === "https://chatgpt.com";
|
|
904
956
|
if (providerId === "deepseek") return url.origin === "https://api.deepseek.com";
|
|
905
|
-
if (providerId === "fireworks") return url.origin ===
|
|
957
|
+
if (providerId === "fireworks") return url.origin === "https://api.fireworks.ai";
|
|
906
958
|
if (providerId === "openrouter") return url.origin === "https://openrouter.ai";
|
|
907
959
|
if (providerId === "vercel-ai-gateway") return url.origin === "https://ai-gateway.vercel.sh";
|
|
908
960
|
if (providerId === "opencode-go") return url.origin === "https://opencode.ai";
|
|
@@ -1014,98 +1066,6 @@ function remainingTimeout(
|
|
|
1014
1066
|
return remaining;
|
|
1015
1067
|
}
|
|
1016
1068
|
|
|
1017
|
-
// Fireworks requires an account slug for its billing endpoints; discover it through the
|
|
1018
|
-
// documented account listing, requiring an explicit slug when a key can see several accounts.
|
|
1019
|
-
async function resolveFireworksAccountId(
|
|
1020
|
-
auth: ResolvedUsageAuth,
|
|
1021
|
-
signal: AbortSignal,
|
|
1022
|
-
timeoutMs: number,
|
|
1023
|
-
guard: () => Promise<void>,
|
|
1024
|
-
configuredAccountId: string | undefined,
|
|
1025
|
-
): Promise<string> {
|
|
1026
|
-
if (configuredAccountId !== undefined && !isFireworksAccountId(configuredAccountId)) {
|
|
1027
|
-
throw new Error("The Fireworks account setting was not a safe account slug.");
|
|
1028
|
-
}
|
|
1029
|
-
const startedAt = Date.now();
|
|
1030
|
-
const accounts: string[] = [];
|
|
1031
|
-
let pageToken: string | undefined;
|
|
1032
|
-
for (let page = 0; page < FIREWORKS_MAX_ACCOUNT_PAGES; page += 1) {
|
|
1033
|
-
await guard();
|
|
1034
|
-
const payload = (await fetchProviderJson(
|
|
1035
|
-
fireworksAccountsUrl(pageToken),
|
|
1036
|
-
auth,
|
|
1037
|
-
signal,
|
|
1038
|
-
remainingTimeout(timeoutMs, startedAt, "fetching Fireworks accounts"),
|
|
1039
|
-
"Fireworks accounts endpoint",
|
|
1040
|
-
{ redirect: "error" },
|
|
1041
|
-
)) as FireworksAccountsPayload;
|
|
1042
|
-
for (const accountId of normalizeFireworksAccountsPayload(
|
|
1043
|
-
payload as FireworksAccountsPayload,
|
|
1044
|
-
)) {
|
|
1045
|
-
if (accounts.includes(accountId)) {
|
|
1046
|
-
throw new Error(`Fireworks accounts listing repeated ${accountId}.`);
|
|
1047
|
-
}
|
|
1048
|
-
accounts.push(accountId);
|
|
1049
|
-
if (configuredAccountId === accountId) return accountId;
|
|
1050
|
-
}
|
|
1051
|
-
pageToken = fireworksNextPageToken(payload.nextPageToken);
|
|
1052
|
-
if (!pageToken) break;
|
|
1053
|
-
}
|
|
1054
|
-
if (pageToken) {
|
|
1055
|
-
throw new Error(
|
|
1056
|
-
configuredAccountId
|
|
1057
|
-
? `The configured Fireworks account was not found within the first ${FIREWORKS_MAX_ACCOUNT_PAGES} listing pages.`
|
|
1058
|
-
: `Fireworks account listing exceeded ${FIREWORKS_MAX_ACCOUNT_PAGES} pages; set fireworksAccountId in pi-usage.json to an account returned in those pages.`,
|
|
1059
|
-
);
|
|
1060
|
-
}
|
|
1061
|
-
if (accounts.length === 0) {
|
|
1062
|
-
throw new Error("Fireworks account discovery returned no accounts for this API key.");
|
|
1063
|
-
}
|
|
1064
|
-
if (configuredAccountId) {
|
|
1065
|
-
throw new Error(
|
|
1066
|
-
"The configured Fireworks account does not match an account visible to this API key.",
|
|
1067
|
-
);
|
|
1068
|
-
}
|
|
1069
|
-
if (accounts.length === 1) return accounts[0] as string;
|
|
1070
|
-
const preview = accounts.slice(0, 8).join(", ");
|
|
1071
|
-
const suffix = accounts.length > 8 ? ` …and ${accounts.length - 8} more` : "";
|
|
1072
|
-
throw new Error(
|
|
1073
|
-
`The Fireworks key can see ${accounts.length} accounts (${preview}${suffix}); set fireworksAccountId in pi-usage.json to one of them.`,
|
|
1074
|
-
);
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
function fireworksAccountsUrl(pageToken: string | undefined): string {
|
|
1078
|
-
const url = new URL("/v1/accounts", FIREWORKS_BILLING_SUMMARY_ORIGIN);
|
|
1079
|
-
url.searchParams.set("pageSize", "200");
|
|
1080
|
-
if (pageToken !== undefined) url.searchParams.set("pageToken", pageToken);
|
|
1081
|
-
return url.toString();
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
function fireworksNextPageToken(value: unknown): string | undefined {
|
|
1085
|
-
if (value === undefined || value === null) return undefined;
|
|
1086
|
-
if (typeof value !== "string" || !value || value.length > 512) {
|
|
1087
|
-
throw new Error("Fireworks accounts listing returned an invalid page token.");
|
|
1088
|
-
}
|
|
1089
|
-
return value;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
function fireworksBillingSummaryUrl(accountId: string, startedAt: number): string {
|
|
1093
|
-
const dayMs = 24 * 60 * 60 * 1000;
|
|
1094
|
-
const dayFloor = (time: number) => `${new Date(time).toISOString().slice(0, 10)}T00:00:00Z`;
|
|
1095
|
-
const url = new URL(
|
|
1096
|
-
`/v1/accounts/${accountId}/billing/summary`,
|
|
1097
|
-
FIREWORKS_BILLING_SUMMARY_ORIGIN,
|
|
1098
|
-
);
|
|
1099
|
-
// The endpoint aggregates by UTC date; endTime is exclusive, so the window includes today
|
|
1100
|
-
// plus the preceding 29 dates.
|
|
1101
|
-
url.searchParams.set(
|
|
1102
|
-
"startTime",
|
|
1103
|
-
dayFloor(startedAt - (FIREWORKS_SPEND_WINDOW_DAYS - 1) * dayMs),
|
|
1104
|
-
);
|
|
1105
|
-
url.searchParams.set("endTime", dayFloor(startedAt + dayMs));
|
|
1106
|
-
return url.toString();
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
1069
|
function zaiOrigin(baseUrl: string | undefined): string {
|
|
1110
1070
|
const base = baseUrl?.trim();
|
|
1111
1071
|
if (!base) throw new Error("Z.AI model base URL is unavailable.");
|
package/src/settings.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { chmod, mkdir, open, rename, rm, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { isFireworksAccountId } from "./providers/fireworks.js";
|
|
7
|
+
import { isBoundedTargetId } from "./usage-targets.js";
|
|
7
8
|
|
|
8
9
|
export const USAGE_SETTINGS_FILE = "pi-usage.json";
|
|
9
10
|
export const MAX_USAGE_SETTINGS_BYTES = 64 * 1024;
|
|
@@ -11,12 +12,13 @@ export const MAX_USAGE_SETTINGS_BYTES = 64 * 1024;
|
|
|
11
12
|
export interface UsageSettings {
|
|
12
13
|
codexFastMode: boolean;
|
|
13
14
|
codexStatusResetCountdown: boolean;
|
|
14
|
-
|
|
15
|
+
selectedTargets: Record<string, string>;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export const DEFAULT_USAGE_SETTINGS: Readonly<UsageSettings> = Object.freeze({
|
|
18
19
|
codexFastMode: false,
|
|
19
20
|
codexStatusResetCountdown: true,
|
|
21
|
+
selectedTargets: Object.freeze({}),
|
|
20
22
|
});
|
|
21
23
|
|
|
22
24
|
export interface UsageSettingsState {
|
|
@@ -27,6 +29,8 @@ export interface UsageSettingsState {
|
|
|
27
29
|
issue?: string;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
export type UsageTargetPublicationCheck = () => Promise<void>;
|
|
33
|
+
|
|
30
34
|
export interface UsageSettingsRuntime {
|
|
31
35
|
get(): Readonly<UsageSettingsState>;
|
|
32
36
|
reload(signal?: AbortSignal): Promise<Readonly<UsageSettingsState>>;
|
|
@@ -34,6 +38,12 @@ export interface UsageSettingsRuntime {
|
|
|
34
38
|
patch: Partial<UsageSettings>,
|
|
35
39
|
signal?: AbortSignal,
|
|
36
40
|
): Promise<Readonly<UsageSettingsState>>;
|
|
41
|
+
updateSelectedTarget(
|
|
42
|
+
providerId: string,
|
|
43
|
+
targetId: string,
|
|
44
|
+
signal?: AbortSignal,
|
|
45
|
+
checkPublishedSelection?: UsageTargetPublicationCheck,
|
|
46
|
+
): Promise<Readonly<UsageSettingsState>>;
|
|
37
47
|
flush(): Promise<void>;
|
|
38
48
|
}
|
|
39
49
|
|
|
@@ -68,6 +78,12 @@ export function normalizeUsageSettings(value: unknown): UsageSettings | undefine
|
|
|
68
78
|
) {
|
|
69
79
|
return undefined;
|
|
70
80
|
}
|
|
81
|
+
const selectedTargets = normalizeSelectedTargets(value.selectedTargets);
|
|
82
|
+
if (Object.hasOwn(value, "selectedTargets") && !selectedTargets) return undefined;
|
|
83
|
+
const effectiveTargets = { ...(selectedTargets ?? {}) };
|
|
84
|
+
if (!effectiveTargets.fireworks && isFireworksAccountId(value.fireworksAccountId)) {
|
|
85
|
+
effectiveTargets.fireworks = value.fireworksAccountId;
|
|
86
|
+
}
|
|
71
87
|
return {
|
|
72
88
|
codexFastMode:
|
|
73
89
|
typeof value.codexFastMode === "boolean"
|
|
@@ -77,9 +93,7 @@ export function normalizeUsageSettings(value: unknown): UsageSettings | undefine
|
|
|
77
93
|
typeof value.codexStatusResetCountdown === "boolean"
|
|
78
94
|
? value.codexStatusResetCountdown
|
|
79
95
|
: DEFAULT_USAGE_SETTINGS.codexStatusResetCountdown,
|
|
80
|
-
|
|
81
|
-
? { fireworksAccountId: value.fireworksAccountId }
|
|
82
|
-
: {}),
|
|
96
|
+
selectedTargets: effectiveTargets,
|
|
83
97
|
};
|
|
84
98
|
}
|
|
85
99
|
|
|
@@ -169,6 +183,39 @@ export function createUsageSettingsRuntime(
|
|
|
169
183
|
state = saved;
|
|
170
184
|
return structuredClone(state);
|
|
171
185
|
}),
|
|
186
|
+
updateSelectedTarget: (providerId, targetId, signal, checkPublishedSelection) =>
|
|
187
|
+
enqueue(async () => {
|
|
188
|
+
const transaction = await saveUsageTargetSelection(
|
|
189
|
+
path,
|
|
190
|
+
providerId,
|
|
191
|
+
targetId,
|
|
192
|
+
operations,
|
|
193
|
+
signal,
|
|
194
|
+
);
|
|
195
|
+
try {
|
|
196
|
+
await checkPublishedSelection?.();
|
|
197
|
+
throwIfAborted(signal);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
try {
|
|
200
|
+
await restoreUsageSettingsState(
|
|
201
|
+
path,
|
|
202
|
+
transaction.saved,
|
|
203
|
+
transaction.previous,
|
|
204
|
+
operations,
|
|
205
|
+
);
|
|
206
|
+
state = transaction.previous;
|
|
207
|
+
} catch (rollbackError) {
|
|
208
|
+
state = await loadUsageSettings(path);
|
|
209
|
+
throw new AggregateError(
|
|
210
|
+
[error, rollbackError],
|
|
211
|
+
"Target selection changed after publication and pi-usage.json rollback failed",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
state = transaction.saved;
|
|
217
|
+
return structuredClone(state);
|
|
218
|
+
}),
|
|
172
219
|
flush: () => queue,
|
|
173
220
|
};
|
|
174
221
|
}
|
|
@@ -178,16 +225,63 @@ async function saveUsageSettingsPatch(
|
|
|
178
225
|
patch: Partial<UsageSettings>,
|
|
179
226
|
operations: UsageSettingsFileOperations,
|
|
180
227
|
signal?: AbortSignal,
|
|
228
|
+
): Promise<UsageSettingsState> {
|
|
229
|
+
return saveUsageSettingsDocument(
|
|
230
|
+
path,
|
|
231
|
+
(document) => {
|
|
232
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
233
|
+
if (value === undefined) delete document[key];
|
|
234
|
+
else document[key] = value;
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
operations,
|
|
238
|
+
signal,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function saveUsageTargetSelection(
|
|
243
|
+
path: string,
|
|
244
|
+
providerId: string,
|
|
245
|
+
targetId: string,
|
|
246
|
+
operations: UsageSettingsFileOperations,
|
|
247
|
+
signal?: AbortSignal,
|
|
248
|
+
): Promise<{ saved: UsageSettingsState; previous: UsageSettingsState }> {
|
|
249
|
+
if (!isProviderId(providerId) || !isBoundedTargetId(targetId)) {
|
|
250
|
+
throw new Error("Refusing to save an invalid usage target selection");
|
|
251
|
+
}
|
|
252
|
+
const previous = await loadUsageSettings(path, signal);
|
|
253
|
+
const saved = await saveUsageSettingsDocument(
|
|
254
|
+
path,
|
|
255
|
+
(document) => {
|
|
256
|
+
document.selectedTargets = {
|
|
257
|
+
...(normalizeSelectedTargets(document.selectedTargets) ?? {}),
|
|
258
|
+
[providerId]: targetId,
|
|
259
|
+
};
|
|
260
|
+
if (providerId === "fireworks") delete document.fireworksAccountId;
|
|
261
|
+
},
|
|
262
|
+
operations,
|
|
263
|
+
signal,
|
|
264
|
+
previous,
|
|
265
|
+
);
|
|
266
|
+
return { saved, previous };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function saveUsageSettingsDocument(
|
|
270
|
+
path: string,
|
|
271
|
+
mutate: (document: Record<string, unknown>) => void,
|
|
272
|
+
operations: UsageSettingsFileOperations,
|
|
273
|
+
signal?: AbortSignal,
|
|
274
|
+
expected?: UsageSettingsState,
|
|
181
275
|
): Promise<UsageSettingsState> {
|
|
182
276
|
const latest = await loadUsageSettings(path, signal);
|
|
183
277
|
if (latest.kind === "invalid") {
|
|
184
278
|
throw new Error("Cannot overwrite an invalid pi-usage.json; repair it and reload first");
|
|
185
279
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (value === undefined) delete document[key];
|
|
189
|
-
else document[key] = value;
|
|
280
|
+
if (expected && !sameUsageSettingsDocument(latest, expected)) {
|
|
281
|
+
throw new Error("pi-usage.json changed while saving; retry the action");
|
|
190
282
|
}
|
|
283
|
+
const document = { ...latest.document };
|
|
284
|
+
mutate(document);
|
|
191
285
|
const settings = normalizeUsageSettings(document);
|
|
192
286
|
if (!settings) throw new Error("Refusing to save invalid pi-usage settings");
|
|
193
287
|
const directory = dirname(path);
|
|
@@ -218,6 +312,44 @@ async function saveUsageSettingsPatch(
|
|
|
218
312
|
return { kind: "loaded", path, settings, document };
|
|
219
313
|
}
|
|
220
314
|
|
|
315
|
+
async function restoreUsageSettingsState(
|
|
316
|
+
path: string,
|
|
317
|
+
published: UsageSettingsState,
|
|
318
|
+
previous: UsageSettingsState,
|
|
319
|
+
operations: UsageSettingsFileOperations,
|
|
320
|
+
): Promise<void> {
|
|
321
|
+
if (previous.kind === "missing") {
|
|
322
|
+
const current = await loadUsageSettings(path);
|
|
323
|
+
if (!sameUsageSettingsDocument(current, published)) {
|
|
324
|
+
throw new Error("pi-usage.json changed before target selection rollback");
|
|
325
|
+
}
|
|
326
|
+
await rm(path);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (previous.kind !== "loaded" || !previous.document) {
|
|
330
|
+
throw new Error("Cannot restore invalid prior pi-usage.json settings");
|
|
331
|
+
}
|
|
332
|
+
await saveUsageSettingsDocument(
|
|
333
|
+
path,
|
|
334
|
+
(document) => {
|
|
335
|
+
for (const key of Object.keys(document)) delete document[key];
|
|
336
|
+
Object.assign(document, previous.document);
|
|
337
|
+
},
|
|
338
|
+
operations,
|
|
339
|
+
undefined,
|
|
340
|
+
published,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function sameUsageSettingsDocument(
|
|
345
|
+
left: Pick<UsageSettingsState, "kind" | "document">,
|
|
346
|
+
right: Pick<UsageSettingsState, "kind" | "document">,
|
|
347
|
+
): boolean {
|
|
348
|
+
return (
|
|
349
|
+
left.kind === right.kind && JSON.stringify(left.document) === JSON.stringify(right.document)
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
221
353
|
async function chmodPrivate(path: string): Promise<void> {
|
|
222
354
|
await chmod(path, 0o600);
|
|
223
355
|
}
|
|
@@ -233,3 +365,18 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
233
365
|
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
234
366
|
return error instanceof Error && "code" in error;
|
|
235
367
|
}
|
|
368
|
+
|
|
369
|
+
function normalizeSelectedTargets(value: unknown): Record<string, string> | undefined {
|
|
370
|
+
if (value === undefined) return {};
|
|
371
|
+
if (!isRecord(value)) return undefined;
|
|
372
|
+
const targets: Record<string, string> = {};
|
|
373
|
+
for (const [providerId, targetId] of Object.entries(value)) {
|
|
374
|
+
if (!isProviderId(providerId) || !isBoundedTargetId(targetId)) return undefined;
|
|
375
|
+
targets[providerId] = targetId;
|
|
376
|
+
}
|
|
377
|
+
return targets;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function isProviderId(value: string): boolean {
|
|
381
|
+
return /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/u.test(value);
|
|
382
|
+
}
|