@diousk/pi-usage-fast 0.2.0 → 0.3.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 +9 -7
- package/dist/index.ts +159 -119
- package/dist/index.ts.map +3 -3
- package/docs/providers.md +16 -0
- package/package.json +4 -4
- package/src/codex-fast-runtime.ts +2 -2
- package/src/codex-fast.ts +14 -7
- package/src/query.ts +53 -133
- package/src/types.ts +1 -0
- package/src/usage-http.ts +133 -0
package/docs/providers.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
This reference preserves each provider's endpoint, authentication boundary, billing semantics, and contract evidence.
|
|
6
6
|
The README contains the capability overview and shared security requirements.
|
|
7
7
|
|
|
8
|
+
- [OpenAI](#openai)
|
|
8
9
|
- [OpenAI Codex](#openai-codex)
|
|
9
10
|
- [Kimi For Coding](#kimi-for-coding)
|
|
10
11
|
- [Moonshot AI](#moonshot-ai-api-balance)
|
|
@@ -21,6 +22,21 @@ The README contains the capability overview and shared security requirements.
|
|
|
21
22
|
|
|
22
23
|
## 📋 Provider semantics
|
|
23
24
|
|
|
25
|
+
### OpenAI
|
|
26
|
+
|
|
27
|
+
- Provider ID: `openai`
|
|
28
|
+
- Semantics: ChatGPT subscription management or API billing, selected from the active runtime credential
|
|
29
|
+
- Source: no quota request; links to [ChatGPT usage settings](https://chatgpt.com/settings/usage) or [API usage](https://platform.openai.com/usage)
|
|
30
|
+
- Displayed data: a management link without numeric quotas, reset estimates, or statusline percentages
|
|
31
|
+
|
|
32
|
+
The newer ChatGPT sign-in uses OpenAI's public Responses API at `https://api.openai.com`.
|
|
33
|
+
An exactly matching OAuth credential with the `chatgpt.tokens.use.direct` scope identifies plan usage; API-key credentials select API billing.
|
|
34
|
+
Compatible pending account activation finishes before credentials are resolved, and an unmatched OAuth account fails closed.
|
|
35
|
+
Custom or proxy origins are rejected before credential resolution.
|
|
36
|
+
|
|
37
|
+
OpenAI's [token-sharing inference contract](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference) requires the public API rather than ChatGPT backend endpoints.
|
|
38
|
+
Its [usage-management guidance](https://developers.openai.com/siwc/ui-ux-guidelines) provides the settings destination, so this adapter never sends the new token to legacy Codex quota or reset endpoints.
|
|
39
|
+
|
|
24
40
|
### OpenAI Codex
|
|
25
41
|
|
|
26
42
|
- Provider ID: `openai-codex`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diousk/pi-usage-fast",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Pi provider usage and API balance extension with Codex Fast mode support for gpt-6-* models.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
"@earendil-works/pi-tui": "*"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@earendil-works/pi-ai": "
|
|
61
|
-
"@earendil-works/pi-coding-agent": "
|
|
62
|
-
"@earendil-works/pi-tui": "
|
|
60
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
61
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
62
|
+
"@earendil-works/pi-tui": "1.3.0",
|
|
63
63
|
"@types/node": "26.6.2",
|
|
64
64
|
"esbuild": "0.28.2",
|
|
65
65
|
"typescript": "7.0.2"
|
|
@@ -14,7 +14,7 @@ import type { PiModel } from "./types.js";
|
|
|
14
14
|
const NO_FAST_REQUEST = Symbol("no-fast-request");
|
|
15
15
|
type PendingFastRequest = { fastRequested: boolean; model: PiModel };
|
|
16
16
|
|
|
17
|
-
export const FAST_USAGE_WARNING = "Fast
|
|
17
|
+
export const FAST_USAGE_WARNING = "Fast uses priority routing and consumes more plan allowance or API credits.";
|
|
18
18
|
|
|
19
19
|
export function registerCodexFastMode(
|
|
20
20
|
pi: ExtensionAPI,
|
|
@@ -65,7 +65,7 @@ export function registerCodexFastMode(
|
|
|
65
65
|
if (!ctx.hasUI) throw new Error("/fast requires TUI or RPC mode.");
|
|
66
66
|
const availability = codexFastAvailability(ctx.model, settingsRuntime.get().settings.codexFastMode);
|
|
67
67
|
if (availability.kind === "not-codex") {
|
|
68
|
-
ctx.ui.notify("/fast
|
|
68
|
+
ctx.ui.notify("/fast requires a supported OpenAI or OpenAI Codex model.", "warning");
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
71
|
if (availability.kind === "unavailable") {
|
package/src/codex-fast.ts
CHANGED
|
@@ -9,6 +9,7 @@ export const CODEX_FAST_MODEL_IDS: ReadonlySet<string> = new Set([
|
|
|
9
9
|
"gpt-5.6-luna",
|
|
10
10
|
"gpt-5.6-sol",
|
|
11
11
|
"gpt-5.6-terra",
|
|
12
|
+
"gpt-6.1-sol",
|
|
12
13
|
]);
|
|
13
14
|
|
|
14
15
|
function supportsCodexFast(modelId: string): boolean {
|
|
@@ -21,11 +22,11 @@ export type CodexFastAvailability =
|
|
|
21
22
|
| { kind: "unavailable"; reason: string };
|
|
22
23
|
|
|
23
24
|
export function codexFastAvailability(model: PiModel | undefined, enabled: boolean): CodexFastAvailability {
|
|
24
|
-
if (model?.provider !== "openai-codex") return { kind: "not-codex" };
|
|
25
|
+
if (model?.provider !== "openai-codex" && model?.provider !== "openai") return { kind: "not-codex" };
|
|
25
26
|
if (!isOfficialCodexModel(model)) {
|
|
26
27
|
return {
|
|
27
28
|
kind: "unavailable",
|
|
28
|
-
reason: "Fast mode requires
|
|
29
|
+
reason: "Fast mode requires an official OpenAI Responses or Codex Responses endpoint.",
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
if (!supportsCodexFast(model.id)) {
|
|
@@ -92,12 +93,18 @@ export function codexFastStatusLabel(status: string, enabled: boolean): string {
|
|
|
92
93
|
return status === "codex" ? "codex fast" : `codex fast${status.slice("codex".length)}`;
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
function isOfficialCodexModel(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
function isOfficialCodexModel(
|
|
97
|
+
model: PiModel | undefined,
|
|
98
|
+
): model is PiModel & { api: "openai-codex-responses" | "openai-responses" } {
|
|
99
|
+
if (!model) return false;
|
|
99
100
|
try {
|
|
100
|
-
|
|
101
|
+
const origin = new URL(model.baseUrl).origin;
|
|
102
|
+
return (
|
|
103
|
+
(model.provider === "openai-codex" &&
|
|
104
|
+
hasApi(model, "openai-codex-responses") &&
|
|
105
|
+
origin === "https://chatgpt.com") ||
|
|
106
|
+
(model.provider === "openai" && hasApi(model, "openai-responses") && origin === "https://api.openai.com")
|
|
107
|
+
);
|
|
101
108
|
} catch {
|
|
102
109
|
return false;
|
|
103
110
|
}
|
package/src/query.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Adapter selection, resolved auth and origin validation share this provider security boundary.
|
|
2
|
+
// Bounded HTTP transport lives separately and receives only validated request auth.
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { type ExtensionContext, readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { errorMessage, fingerprintResolvedAuth, redactUsageError } from "./core.js";
|
|
@@ -41,8 +41,11 @@ import type {
|
|
|
41
41
|
ZaiQuotaPayload,
|
|
42
42
|
ZaiSubscriptionPayload,
|
|
43
43
|
} from "./types.js";
|
|
44
|
+
import { fetchProviderJson } from "./usage-http.js";
|
|
44
45
|
import { resolveUsageTarget } from "./usage-targets.js";
|
|
45
46
|
|
|
47
|
+
export { fetchProviderJson } from "./usage-http.js";
|
|
48
|
+
|
|
46
49
|
const BASETEN_BILLING_USAGE_URL = "https://api.baseten.co/v1/billing/usage_summary";
|
|
47
50
|
const BASETEN_USAGE_WINDOW_DAYS = 30;
|
|
48
51
|
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
@@ -68,12 +71,36 @@ const XAI_CLIENT_HEADERS = Object.freeze({
|
|
|
68
71
|
"x-grok-client-version": "1.0.10",
|
|
69
72
|
"x-grok-client-mode": "interactive",
|
|
70
73
|
});
|
|
71
|
-
const MAX_SUCCESS_BODY_BYTES = 64 * 1024;
|
|
72
|
-
const MAX_ERROR_BODY_BYTES = 4 * 1024;
|
|
73
74
|
|
|
74
75
|
export const AUTH_FINGERPRINT_SALT = randomBytes(32);
|
|
75
76
|
|
|
76
77
|
export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
|
|
78
|
+
{
|
|
79
|
+
id: "openai",
|
|
80
|
+
displayName: "OpenAI",
|
|
81
|
+
semantics: { kind: "consumer-subscription", label: "ChatGPT plan usage" },
|
|
82
|
+
publishesStatusline: false,
|
|
83
|
+
async query(auth) {
|
|
84
|
+
const subscription = auth.chatgptPlan === true;
|
|
85
|
+
return {
|
|
86
|
+
providerId: "openai",
|
|
87
|
+
providerName: subscription ? "OpenAI (ChatGPT)" : "OpenAI API",
|
|
88
|
+
capturedAt: Date.now(),
|
|
89
|
+
source: "openai-usage-settings",
|
|
90
|
+
semantics: subscription
|
|
91
|
+
? { kind: "consumer-subscription", label: "ChatGPT plan usage" }
|
|
92
|
+
: { kind: "api-key", label: "OpenAI API usage" },
|
|
93
|
+
buckets: [],
|
|
94
|
+
metrics: [],
|
|
95
|
+
notes: [
|
|
96
|
+
subscription
|
|
97
|
+
? "Review plan usage and app limits in ChatGPT Settings → Usage: https://chatgpt.com/settings/usage"
|
|
98
|
+
: "Review OpenAI API usage and billing: https://platform.openai.com/usage",
|
|
99
|
+
"Numeric usage is unavailable through this authentication flow; no quota is inferred.",
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
},
|
|
77
104
|
{
|
|
78
105
|
id: "baseten",
|
|
79
106
|
displayName: "Baseten",
|
|
@@ -344,7 +371,7 @@ export async function resolveUsageAuth(
|
|
|
344
371
|
const model = candidateModels(ctx, adapter.id).find((candidate) => hasOfficialOrigin(candidate, adapter.id));
|
|
345
372
|
if (!model) return undefined;
|
|
346
373
|
if (
|
|
347
|
-
["github-copilot", "xai"].includes(adapter.id) &&
|
|
374
|
+
["github-copilot", "xai", "openai"].includes(adapter.id) &&
|
|
348
375
|
candidateReader?.waitUntilReady &&
|
|
349
376
|
!(await candidateReader.waitUntilReady(ctx, adapter.id))
|
|
350
377
|
) {
|
|
@@ -463,12 +490,32 @@ export async function resolveUsageAuth(
|
|
|
463
490
|
const secrets = [auth.apiKey, headerValue(auth.headers, "Authorization"), authorization].filter(
|
|
464
491
|
(value): value is string => Boolean(value),
|
|
465
492
|
);
|
|
493
|
+
let chatgptPlan: boolean | undefined;
|
|
494
|
+
if (adapter.id === "openai") {
|
|
495
|
+
const offered = candidateReader
|
|
496
|
+
? await candidateReader(ctx, adapter.id)
|
|
497
|
+
: fallbackOAuthCredentialCandidates(adapter.id, credentialReader);
|
|
498
|
+
if (!offered.ok) throw new Error("OpenAI OAuth credential discovery failed closed.");
|
|
499
|
+
const access = bearerToken(authorization);
|
|
500
|
+
chatgptPlan = offered.candidates.some(
|
|
501
|
+
(credential) =>
|
|
502
|
+
credential.access === access &&
|
|
503
|
+
Array.isArray(credential.scopes) &&
|
|
504
|
+
credential.scopes.includes("chatgpt.tokens.use.direct"),
|
|
505
|
+
);
|
|
506
|
+
if (!chatgptPlan && providerResult?.source === "OAuth") {
|
|
507
|
+
throw new Error(
|
|
508
|
+
"The active OpenAI OAuth credential does not match an available ChatGPT plan credential; reload after account activation.",
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
466
512
|
return finalize({
|
|
467
513
|
apiKey: auth.apiKey,
|
|
468
514
|
headers,
|
|
469
515
|
fingerprint: "",
|
|
470
516
|
secrets,
|
|
471
517
|
model,
|
|
518
|
+
...(chatgptPlan !== undefined ? { chatgptPlan } : {}),
|
|
472
519
|
});
|
|
473
520
|
}
|
|
474
521
|
|
|
@@ -576,130 +623,6 @@ function candidateModels(ctx: ExtensionContext, providerId: string): PiModel[] {
|
|
|
576
623
|
return candidates;
|
|
577
624
|
}
|
|
578
625
|
|
|
579
|
-
export async function fetchProviderJson(
|
|
580
|
-
url: string,
|
|
581
|
-
auth: ResolvedUsageAuth,
|
|
582
|
-
signal: AbortSignal,
|
|
583
|
-
timeoutMs: number,
|
|
584
|
-
description: string,
|
|
585
|
-
request: {
|
|
586
|
-
method?: "GET" | "POST";
|
|
587
|
-
body?: Record<string, unknown>;
|
|
588
|
-
redirect?: RequestRedirect;
|
|
589
|
-
userAgent?: boolean;
|
|
590
|
-
responseError?: (status: number, text: string) => string | undefined;
|
|
591
|
-
} = {},
|
|
592
|
-
): Promise<Record<string, unknown>> {
|
|
593
|
-
const controller = new AbortController();
|
|
594
|
-
let timedOut = false;
|
|
595
|
-
const abortFromCaller = () => controller.abort();
|
|
596
|
-
if (signal.aborted) controller.abort();
|
|
597
|
-
else signal.addEventListener("abort", abortFromCaller, { once: true });
|
|
598
|
-
const timeout = setTimeout(() => {
|
|
599
|
-
timedOut = true;
|
|
600
|
-
controller.abort();
|
|
601
|
-
}, timeoutMs);
|
|
602
|
-
try {
|
|
603
|
-
const headers = { ...auth.headers };
|
|
604
|
-
if (request.userAgent !== false && !hasHeader(headers, "User-Agent")) {
|
|
605
|
-
headers["User-Agent"] = "pi-usage";
|
|
606
|
-
}
|
|
607
|
-
if (request.body && !hasHeader(headers, "Content-Type")) {
|
|
608
|
-
headers["Content-Type"] = "application/json";
|
|
609
|
-
}
|
|
610
|
-
const response = await fetch(url, {
|
|
611
|
-
method: request.method ?? "GET",
|
|
612
|
-
headers,
|
|
613
|
-
...(request.body ? { body: JSON.stringify(request.body) } : {}),
|
|
614
|
-
...(request.redirect ? { redirect: request.redirect } : {}),
|
|
615
|
-
signal: controller.signal,
|
|
616
|
-
});
|
|
617
|
-
if (response.redirected) throw new Error(`${description} refused a redirected response.`);
|
|
618
|
-
if (controller.signal.aborted) throw Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
|
|
619
|
-
const text = await readBoundedResponse(
|
|
620
|
-
response,
|
|
621
|
-
response.ok ? MAX_SUCCESS_BODY_BYTES : MAX_ERROR_BODY_BYTES,
|
|
622
|
-
!response.ok,
|
|
623
|
-
description,
|
|
624
|
-
controller.signal,
|
|
625
|
-
);
|
|
626
|
-
if (controller.signal.aborted) throw Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
|
|
627
|
-
const responseError = request.responseError?.(response.status, text);
|
|
628
|
-
if (responseError) throw new Error(responseError);
|
|
629
|
-
if (!response.ok) {
|
|
630
|
-
throw new Error(
|
|
631
|
-
`${description} returned ${response.status} ${response.statusText}: ${redactUsageError(text, auth.secrets)}`,
|
|
632
|
-
);
|
|
633
|
-
}
|
|
634
|
-
let parsed: unknown;
|
|
635
|
-
try {
|
|
636
|
-
parsed = JSON.parse(text) as unknown;
|
|
637
|
-
} catch (error) {
|
|
638
|
-
throw new Error(`${description} returned invalid JSON: ${errorMessage(error)}`);
|
|
639
|
-
}
|
|
640
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
641
|
-
throw new Error(`${description} response was not an object.`);
|
|
642
|
-
}
|
|
643
|
-
return parsed as Record<string, unknown>;
|
|
644
|
-
} catch (error) {
|
|
645
|
-
if (timedOut) {
|
|
646
|
-
throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s while fetching usage.`);
|
|
647
|
-
}
|
|
648
|
-
if (signal.aborted) throw Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
|
|
649
|
-
throw error;
|
|
650
|
-
} finally {
|
|
651
|
-
clearTimeout(timeout);
|
|
652
|
-
signal.removeEventListener("abort", abortFromCaller);
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
async function readBoundedResponse(
|
|
657
|
-
response: Response,
|
|
658
|
-
maxBytes: number,
|
|
659
|
-
truncateOverflow: boolean,
|
|
660
|
-
description: string,
|
|
661
|
-
signal: AbortSignal,
|
|
662
|
-
): Promise<string> {
|
|
663
|
-
if (!response.body) return "";
|
|
664
|
-
const reader = response.body.getReader();
|
|
665
|
-
const chunks: Uint8Array[] = [];
|
|
666
|
-
let total = 0;
|
|
667
|
-
let truncated = false;
|
|
668
|
-
const abort = () => void reader.cancel().catch(() => undefined);
|
|
669
|
-
if (signal.aborted) abort();
|
|
670
|
-
else signal.addEventListener("abort", abort, { once: true });
|
|
671
|
-
try {
|
|
672
|
-
while (true) {
|
|
673
|
-
const { done, value } = await reader.read();
|
|
674
|
-
if (done) break;
|
|
675
|
-
const remaining = maxBytes - total;
|
|
676
|
-
if (value.byteLength > remaining) {
|
|
677
|
-
if (remaining > 0) chunks.push(value.subarray(0, remaining));
|
|
678
|
-
total = maxBytes;
|
|
679
|
-
truncated = true;
|
|
680
|
-
await reader.cancel();
|
|
681
|
-
break;
|
|
682
|
-
}
|
|
683
|
-
chunks.push(value);
|
|
684
|
-
total += value.byteLength;
|
|
685
|
-
}
|
|
686
|
-
} finally {
|
|
687
|
-
signal.removeEventListener("abort", abort);
|
|
688
|
-
reader.releaseLock();
|
|
689
|
-
}
|
|
690
|
-
if (truncated && !truncateOverflow) {
|
|
691
|
-
throw new Error(`${description} response exceeded ${maxBytes} bytes.`);
|
|
692
|
-
}
|
|
693
|
-
const body = new Uint8Array(total);
|
|
694
|
-
let offset = 0;
|
|
695
|
-
for (const chunk of chunks) {
|
|
696
|
-
body.set(chunk, offset);
|
|
697
|
-
offset += chunk.byteLength;
|
|
698
|
-
}
|
|
699
|
-
const text = new TextDecoder().decode(body);
|
|
700
|
-
return truncated ? `${text}…` : text;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
626
|
type RequestAuth = {
|
|
704
627
|
apiKey?: string;
|
|
705
628
|
headers?: Record<string, string | null>;
|
|
@@ -898,6 +821,7 @@ function hasOfficialUrlOrigin(value: string, providerId: string): boolean {
|
|
|
898
821
|
return ["https://inference.baseten.co", "https://api.baseten.co"].includes(url.origin);
|
|
899
822
|
}
|
|
900
823
|
if (providerId === "openai-codex") return url.origin === "https://chatgpt.com";
|
|
824
|
+
if (providerId === "openai") return url.origin === "https://api.openai.com";
|
|
901
825
|
if (providerId === "deepseek") return url.origin === "https://api.deepseek.com";
|
|
902
826
|
if (providerId === "fireworks") return url.origin === "https://api.fireworks.ai";
|
|
903
827
|
if (providerId === "openrouter") return url.origin === "https://openrouter.ai";
|
|
@@ -925,10 +849,6 @@ function headerValue(headers: Record<string, string | null> | undefined, name: s
|
|
|
925
849
|
return entry?.[1] ?? undefined;
|
|
926
850
|
}
|
|
927
851
|
|
|
928
|
-
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
929
|
-
return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
|
|
930
|
-
}
|
|
931
|
-
|
|
932
852
|
function validatedXaiUserId(value: unknown): string {
|
|
933
853
|
if (typeof value !== "string" || !/^[A-Za-z0-9._~-]{1,128}$/u.test(value)) {
|
|
934
854
|
throw new Error("xAI consumer identity returned an unsafe canonical user ID.");
|
package/src/types.ts
CHANGED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { errorMessage, redactUsageError } from "./core.js";
|
|
2
|
+
import type { ResolvedUsageAuth } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const MAX_SUCCESS_BODY_BYTES = 64 * 1024;
|
|
5
|
+
const MAX_ERROR_BODY_BYTES = 4 * 1024;
|
|
6
|
+
|
|
7
|
+
export async function fetchProviderJson(
|
|
8
|
+
url: string,
|
|
9
|
+
auth: ResolvedUsageAuth,
|
|
10
|
+
signal: AbortSignal,
|
|
11
|
+
timeoutMs: number,
|
|
12
|
+
description: string,
|
|
13
|
+
request: {
|
|
14
|
+
method?: "GET" | "POST";
|
|
15
|
+
body?: Record<string, unknown>;
|
|
16
|
+
redirect?: RequestRedirect;
|
|
17
|
+
userAgent?: boolean;
|
|
18
|
+
responseError?: (status: number, text: string) => string | undefined;
|
|
19
|
+
} = {},
|
|
20
|
+
): Promise<Record<string, unknown>> {
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
let timedOut = false;
|
|
23
|
+
const abortFromCaller = () => controller.abort();
|
|
24
|
+
if (signal.aborted) controller.abort();
|
|
25
|
+
else signal.addEventListener("abort", abortFromCaller, { once: true });
|
|
26
|
+
const timeout = setTimeout(() => {
|
|
27
|
+
timedOut = true;
|
|
28
|
+
controller.abort();
|
|
29
|
+
}, timeoutMs);
|
|
30
|
+
try {
|
|
31
|
+
const headers = { ...auth.headers };
|
|
32
|
+
if (request.userAgent !== false && !hasHeader(headers, "User-Agent")) {
|
|
33
|
+
headers["User-Agent"] = "pi-usage";
|
|
34
|
+
}
|
|
35
|
+
if (request.body && !hasHeader(headers, "Content-Type")) {
|
|
36
|
+
headers["Content-Type"] = "application/json";
|
|
37
|
+
}
|
|
38
|
+
const response = await fetch(url, {
|
|
39
|
+
method: request.method ?? "GET",
|
|
40
|
+
headers,
|
|
41
|
+
...(request.body ? { body: JSON.stringify(request.body) } : {}),
|
|
42
|
+
...(request.redirect ? { redirect: request.redirect } : {}),
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
});
|
|
45
|
+
if (response.redirected) throw new Error(`${description} refused a redirected response.`);
|
|
46
|
+
if (controller.signal.aborted) throw Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
|
|
47
|
+
const text = await readBoundedResponse(
|
|
48
|
+
response,
|
|
49
|
+
response.ok ? MAX_SUCCESS_BODY_BYTES : MAX_ERROR_BODY_BYTES,
|
|
50
|
+
!response.ok,
|
|
51
|
+
description,
|
|
52
|
+
controller.signal,
|
|
53
|
+
);
|
|
54
|
+
if (controller.signal.aborted) throw Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
|
|
55
|
+
const responseError = request.responseError?.(response.status, text);
|
|
56
|
+
if (responseError) throw new Error(responseError);
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`${description} returned ${response.status} ${response.statusText}: ${redactUsageError(text, auth.secrets)}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
let parsed: unknown;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(text) as unknown;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
throw new Error(`${description} returned invalid JSON: ${errorMessage(error)}`);
|
|
67
|
+
}
|
|
68
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
69
|
+
throw new Error(`${description} response was not an object.`);
|
|
70
|
+
}
|
|
71
|
+
return parsed as Record<string, unknown>;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (timedOut) {
|
|
74
|
+
throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s while fetching usage.`);
|
|
75
|
+
}
|
|
76
|
+
if (signal.aborted) throw Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
|
|
77
|
+
throw error;
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timeout);
|
|
80
|
+
signal.removeEventListener("abort", abortFromCaller);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readBoundedResponse(
|
|
85
|
+
response: Response,
|
|
86
|
+
maxBytes: number,
|
|
87
|
+
truncateOverflow: boolean,
|
|
88
|
+
description: string,
|
|
89
|
+
signal: AbortSignal,
|
|
90
|
+
): Promise<string> {
|
|
91
|
+
if (!response.body) return "";
|
|
92
|
+
const reader = response.body.getReader();
|
|
93
|
+
const chunks: Uint8Array[] = [];
|
|
94
|
+
let total = 0;
|
|
95
|
+
let truncated = false;
|
|
96
|
+
const abort = () => void reader.cancel().catch(() => undefined);
|
|
97
|
+
if (signal.aborted) abort();
|
|
98
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
99
|
+
try {
|
|
100
|
+
while (true) {
|
|
101
|
+
const { done, value } = await reader.read();
|
|
102
|
+
if (done) break;
|
|
103
|
+
const remaining = maxBytes - total;
|
|
104
|
+
if (value.byteLength > remaining) {
|
|
105
|
+
if (remaining > 0) chunks.push(value.subarray(0, remaining));
|
|
106
|
+
total = maxBytes;
|
|
107
|
+
truncated = true;
|
|
108
|
+
await reader.cancel();
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
chunks.push(value);
|
|
112
|
+
total += value.byteLength;
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
signal.removeEventListener("abort", abort);
|
|
116
|
+
reader.releaseLock();
|
|
117
|
+
}
|
|
118
|
+
if (truncated && !truncateOverflow) {
|
|
119
|
+
throw new Error(`${description} response exceeded ${maxBytes} bytes.`);
|
|
120
|
+
}
|
|
121
|
+
const body = new Uint8Array(total);
|
|
122
|
+
let offset = 0;
|
|
123
|
+
for (const chunk of chunks) {
|
|
124
|
+
body.set(chunk, offset);
|
|
125
|
+
offset += chunk.byteLength;
|
|
126
|
+
}
|
|
127
|
+
const text = new TextDecoder().decode(body);
|
|
128
|
+
return truncated ? `${text}…` : text;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
132
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
|
|
133
|
+
}
|