@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/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.2.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": "0.86.0",
61
- "@earendil-works/pi-coding-agent": "0.86.0",
62
- "@earendil-works/pi-tui": "0.86.0",
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 is about 1.5× faster and uses more of your plan allowance.";
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 is available only for the active OpenAI Codex model.", "warning");
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 the official OpenAI Codex Responses endpoint.",
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(model: PiModel | undefined): model is PiModel & { api: "openai-codex-responses" } {
96
- if (model?.provider !== "openai-codex" || !hasApi(model, "openai-codex-responses")) {
97
- return false;
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
- return new URL(model.baseUrl).origin === "https://chatgpt.com";
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
- // 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
+ // 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
@@ -48,6 +48,7 @@ export interface UsageReport {
48
48
  }
49
49
 
50
50
  export interface ResolvedUsageAuth {
51
+ chatgptPlan?: boolean;
51
52
  apiKey?: string;
52
53
  headers: Record<string, string>;
53
54
  fingerprint: string;
@@ -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
+ }