@narumitw/pi-usage 0.61.1 → 0.62.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 +46 -3
- package/dist/chunks/chunk-WEXCFYZX.ts +3005 -0
- package/dist/chunks/chunk-WEXCFYZX.ts.map +7 -0
- package/dist/chunks/openai-companion-auth-Q456G46T.ts +85 -0
- package/dist/chunks/openai-companion-auth-Q456G46T.ts.map +7 -0
- package/dist/chunks/openai-companion-usage-Y3WTMUB2.ts +120 -0
- package/dist/chunks/openai-companion-usage-Y3WTMUB2.ts.map +7 -0
- package/dist/index.ts +563 -3226
- package/dist/index.ts.map +4 -4
- package/docs/providers.md +50 -1
- package/package.json +5 -5
- package/src/format.ts +33 -2
- package/src/openai-companion-auth.ts +103 -0
- package/src/providers/openai-chatgpt.ts +122 -0
- package/src/providers/openai-companion-usage.ts +146 -0
- package/src/query.ts +28 -3
- package/src/settings.ts +5 -0
- package/src/types.ts +4 -0
- package/src/usage-settings-ui.ts +51 -9
- package/src/usage.ts +141 -12
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 native ChatGPT OAuth](#openai-native-chatgpt-oauth)
|
|
8
9
|
- [OpenAI Codex](#openai-codex)
|
|
9
10
|
- [Kimi For Coding](#kimi-for-coding)
|
|
10
11
|
- [Moonshot AI](#moonshot-ai-api-balance)
|
|
@@ -21,9 +22,57 @@ The README contains the capability overview and shared security requirements.
|
|
|
21
22
|
|
|
22
23
|
## 📋 Provider semantics
|
|
23
24
|
|
|
25
|
+
### OpenAI native ChatGPT OAuth
|
|
26
|
+
|
|
27
|
+
- Provider ID: `openai`
|
|
28
|
+
- Default semantics/source: verified native authentication through fresh Pi OAuth resolution and an exact complete credential match; no usage HTTP endpoint
|
|
29
|
+
- Default display: **Connected (native OAuth)** and [ChatGPT usage settings](https://chatgpt.com/settings/usage)
|
|
30
|
+
- Default statusline: `chatgpt usage: web only`
|
|
31
|
+
- Opt-in source: experimental same-account Codex companion OAuth; distinct shared plan and app limits
|
|
32
|
+
|
|
33
|
+
Use `/login openai` in a Pi runtime that supports native Sign in with ChatGPT, then select an official `https://api.openai.com` model.
|
|
34
|
+
Authentication requires Pi's freshly resolved provider source to be `OAuth`, matching effective model and provider authorization, and a complete matching credential with `clientId` and the `chatgpt.tokens.use.direct` scope.
|
|
35
|
+
Equivalent duplicate candidates are accepted; conflicting, incomplete, or mismatched native grants fail closed.
|
|
36
|
+
Stored OAuth alone and Pi's cached OAuth flag do not establish the active runtime account.
|
|
37
|
+
Runtime/API-key overrides, model-level Authorization replacements or removals, and custom/proxy origins cannot inherit native subscription status.
|
|
38
|
+
OpenAI API-key usage reporting remains unsupported.
|
|
39
|
+
|
|
40
|
+
The default report verifies authentication, not the plan tier, allowance, remaining quota, or reset time.
|
|
41
|
+
Default automatic refresh only revalidates auth; it does not poll a usage endpoint.
|
|
42
|
+
The settings link is guidance, not an automatically opened browser or a promise that the browser is signed into Pi's account.
|
|
43
|
+
Native tokens are never sent to `/backend-api/wham`, and opaque native metadata is not decoded as a legacy Codex account ID.
|
|
44
|
+
Legacy Fast payload rewriting, pricing correction, and earned reset redemption do not apply to native `openai`.
|
|
45
|
+
Existing `openai-codex` behavior below is unchanged.
|
|
46
|
+
|
|
47
|
+
The boundary is verified against Pi's [`openai-chatgpt.ts`](https://github.com/earendil-works/pi/blob/d86654abb8862e201933517d6f1fce9f88dd117f/packages/ai/src/auth/oauth/openai-chatgpt.ts) at `d86654abb8862e201933517d6f1fce9f88dd117f` and OpenAI's [models and inference](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference), [token reference](https://developers.openai.com/siwc/token-sharing-open-source/token-reference), and [UI/UX guidelines](https://developers.openai.com/siwc/ui-ux-guidelines).
|
|
48
|
+
These contracts establish the public inference and authentication boundary, not a numerical usage API.
|
|
49
|
+
Verification uses synthetic credentials with Pi's real auth resolver; live browser login and account-specific quota retrieval have not been verified.
|
|
50
|
+
|
|
51
|
+
#### Experimental companion quotas
|
|
52
|
+
|
|
53
|
+
Follow the [opt-in setup](../README.md#experimental-chatgpt-companion-usage): `openaiCompanionUsage` defaults to `false` and requires a same-account/workspace `/login openai-codex`.
|
|
54
|
+
Fresh companion OAuth must match a complete grant with its own account ID; custom origins, API-key overrides, conflicting grants, and effective Authorization overrides fail closed.
|
|
55
|
+
Only the companion bearer and matching `ChatGPT-Account-Id` reach these fixed, redirect-rejecting GET routes:
|
|
56
|
+
|
|
57
|
+
- `https://chatgpt.com/backend-api/wham/usage/chatpass/apps`
|
|
58
|
+
- `https://chatgpt.com/backend-api/wham/usage`
|
|
59
|
+
|
|
60
|
+
The app list must be complete and bounded to 128 registrations, with a readable nonempty string `id` on every entry, exactly one `id` equal to the native grant's `clientId`, and 1–32 valid windows on that matched entry.
|
|
61
|
+
Unknown envelope fields or pagination markers fail closed rather than assuming uniqueness on one page.
|
|
62
|
+
Only plan primary/secondary windows are used; Codex model-specific limits are not native app quotas.
|
|
63
|
+
Each window requires a finite 0–100 used percentage and positive whole-second duration; optional remaining percentages must agree within one percentage point, and reset timestamps must be valid nonnegative epoch seconds.
|
|
64
|
+
`allowed_usage_percent` is **App allowance**, a cap on shared plan usage, not remaining quota.
|
|
65
|
+
Plan/app windows and reset boundaries stay separate in reports and the statusline; Codex display preferences do not apply.
|
|
66
|
+
|
|
67
|
+
Disabled or missing-companion paths remain web-only without requests. Other enabled-mode failures invalidate numerical data and remain observable.
|
|
68
|
+
No native token, refresh token, app name, or account ID is rendered or persisted by this feature.
|
|
69
|
+
An exact registration match is not independent identity proof; users must choose the same account/workspace for both logins.
|
|
70
|
+
The endpoints are undocumented; live availability and numerical agreement remain unverified.
|
|
71
|
+
Contract fixtures derive from [Specode's pinned implementation](https://github.com/specode/pi-subscription-usage/blob/1015f9f419dfef9ac27c9845ec2318394928fa38/src/providers/openai.ts) and [OpenAI's shared-plan/app-limit explanation](https://help.openai.com/en/articles/20001542-using-your-chatgpt-plan-in-other-apps-and-sites), not a public native quota API.
|
|
72
|
+
|
|
24
73
|
### OpenAI Codex
|
|
25
74
|
|
|
26
|
-
- Provider ID: `openai-codex`
|
|
75
|
+
- Provider ID: `openai-codex` (legacy; not an alias for native `openai`)
|
|
27
76
|
- Semantics: ChatGPT consumer subscription limits
|
|
28
77
|
- Source: the Codex usage and earned-reset endpoints using Pi's resolved runtime authorization
|
|
29
78
|
- Displayed data: returned duration-based windows, resets, credits, earned usage-limit resets, and additional model buckets
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-usage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.0",
|
|
4
4
|
"description": "Pi extension that shows current-account usage and DeepSeek API balance for supported providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -57,10 +57,10 @@
|
|
|
57
57
|
"@earendil-works/pi-tui": "*"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@earendil-works/pi-ai": "0.
|
|
61
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
62
|
-
"@earendil-works/pi-tui": "0.
|
|
63
|
-
"@types/node": "26.6.
|
|
60
|
+
"@earendil-works/pi-ai": "1.0.2",
|
|
61
|
+
"@earendil-works/pi-coding-agent": "1.0.2",
|
|
62
|
+
"@earendil-works/pi-tui": "1.0.2",
|
|
63
|
+
"@types/node": "26.6.4",
|
|
64
64
|
"esbuild": "0.28.2",
|
|
65
65
|
"typescript": "7.0.2"
|
|
66
66
|
},
|
package/src/format.ts
CHANGED
|
@@ -21,12 +21,15 @@ export function formatUsageReport(report: UsageReport, displayState: UsageDispla
|
|
|
21
21
|
? report.source === "minimax-account-balance"
|
|
22
22
|
? `${report.providerName} API Balance`
|
|
23
23
|
: `${report.providerName} Token Plan`
|
|
24
|
-
:
|
|
24
|
+
: report.source === "openai-chatgpt-auth"
|
|
25
|
+
? `${report.providerName} ChatGPT Plan Status`
|
|
26
|
+
: `${report.providerName} Usage`;
|
|
25
27
|
const lines = [`${title} · ${stateLabel}`];
|
|
26
28
|
if (report.accountLabel) lines.push(`Account: ${report.accountLabel}`);
|
|
27
29
|
lines.push(`Semantics: ${report.semantics.label}`, "");
|
|
28
30
|
|
|
29
|
-
if (report.
|
|
31
|
+
if (report.source === "openai-chatgpt-companion") formatOpenAICompanionReport(lines, report);
|
|
32
|
+
else if (report.providerId === "baseten") formatBasetenReport(lines, report);
|
|
30
33
|
else if (report.providerId === "openai-codex") formatCodexReport(lines, report);
|
|
31
34
|
else if (report.providerId === "deepseek") formatDeepSeekReport(lines, report);
|
|
32
35
|
else if (report.providerId === "fireworks") formatFireworksReport(lines, report);
|
|
@@ -57,6 +60,20 @@ export function formatUsageStatusline(
|
|
|
57
60
|
showCodexResetCountdown = true,
|
|
58
61
|
codexStatusPercentage: CodexStatusPercentage = "remaining",
|
|
59
62
|
): string | undefined {
|
|
63
|
+
if (report.providerId === "openai" && report.source === "openai-chatgpt-auth") return "chatgpt usage: web only";
|
|
64
|
+
if (report.source === "openai-chatgpt-companion") {
|
|
65
|
+
return `chatgpt ${["chatgpt-plan", "chatgpt-app"]
|
|
66
|
+
.map((group) => {
|
|
67
|
+
const windows = report.buckets
|
|
68
|
+
.filter((bucket) => bucket.groupId === group)
|
|
69
|
+
.map((bucket) => {
|
|
70
|
+
const reset = formatResetCountdown(bucket.resetsAt, now);
|
|
71
|
+
return `${bucket.remaining === undefined ? "unavailable" : `${bucket.remaining.toFixed(0)}%`} ${reset ? `↻ ${reset}` : formatWindowLabel(bucket.windowMinutes, "weekly", true)}`;
|
|
72
|
+
});
|
|
73
|
+
return `${group === "chatgpt-plan" ? "plan" : "app"} ${windows.join(" ")}`;
|
|
74
|
+
})
|
|
75
|
+
.join(" · ")}`;
|
|
76
|
+
}
|
|
60
77
|
if (report.providerId === "baseten") return formatBasetenStatusline(report);
|
|
61
78
|
if (report.providerId === "openai-codex") {
|
|
62
79
|
return formatCodexStatusline(report, model, now, showCodexResetCountdown, codexStatusPercentage);
|
|
@@ -519,6 +536,19 @@ function formatZaiReport(lines: string[], report: UsageReport): void {
|
|
|
519
536
|
}
|
|
520
537
|
}
|
|
521
538
|
|
|
539
|
+
function formatOpenAICompanionReport(lines: string[], report: UsageReport): void {
|
|
540
|
+
for (const group of ["chatgpt-plan", "chatgpt-app"]) {
|
|
541
|
+
lines.push(group === "chatgpt-plan" ? "Plan limits:" : "App limits:");
|
|
542
|
+
for (const bucket of report.buckets.filter((bucket) => bucket.groupId === group)) {
|
|
543
|
+
lines.push(
|
|
544
|
+
`${formatWindowLabel(bucket.windowMinutes, "weekly", false).padEnd(VALUE_COLUMN)}${formatPercentBucket(bucket)}`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
for (const metric of report.metrics)
|
|
549
|
+
lines.push(`${`${metric.label}:`.padEnd(VALUE_COLUMN)}${formatMetricValue(metric.value, metric.unit)}`);
|
|
550
|
+
}
|
|
551
|
+
|
|
522
552
|
function formatGenericReport(lines: string[], report: UsageReport): void {
|
|
523
553
|
for (const bucket of report.buckets) {
|
|
524
554
|
lines.push(
|
|
@@ -665,6 +695,7 @@ function formatResetCountdown(resetsAt: number | undefined, now: number): string
|
|
|
665
695
|
|
|
666
696
|
function formatMetricValue(value: number | string, unit: UsageBucket["unit"] | undefined): string {
|
|
667
697
|
if (unit === "usd" && typeof value === "number") return formatUsd(value);
|
|
698
|
+
if (unit === "percent" && typeof value === "number") return `${value}%`;
|
|
668
699
|
return String(value);
|
|
669
700
|
}
|
|
670
701
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { fingerprintResolvedAuth } from "./core.js";
|
|
3
|
+
import { fallbackOAuthCredentialCandidates, type OAuthCredentialCandidateReader } from "./oauth-credential-source.js";
|
|
4
|
+
import { openAIAuthAccessToken } from "./providers/openai-chatgpt.js";
|
|
5
|
+
import { adapterForProvider, resolveUsageAuth } from "./query.js";
|
|
6
|
+
import type { ResolvedUsageAuth } from "./types.js";
|
|
7
|
+
|
|
8
|
+
// A native app registration is routing metadata, not cryptographic same-account proof.
|
|
9
|
+
// Keep this opt-in boundary separate from legacy Codex querying and reset mutation policy.
|
|
10
|
+
export async function resolveOpenAICompanionAuth(
|
|
11
|
+
ctx: ExtensionContext,
|
|
12
|
+
native: ResolvedUsageAuth,
|
|
13
|
+
salt: Uint8Array,
|
|
14
|
+
credentialReader: (providerId: string) => unknown,
|
|
15
|
+
candidateReader?: OAuthCredentialCandidateReader,
|
|
16
|
+
): Promise<ResolvedUsageAuth> {
|
|
17
|
+
const unavailable = () => ({
|
|
18
|
+
...native,
|
|
19
|
+
openaiCompanionEnabled: true,
|
|
20
|
+
fingerprint: fingerprintResolvedAuth(
|
|
21
|
+
{ apiKey: JSON.stringify([native.fingerprint, native.openaiClientId, true, null]) },
|
|
22
|
+
salt,
|
|
23
|
+
),
|
|
24
|
+
});
|
|
25
|
+
if (candidateReader?.waitUntilReady && !(await candidateReader.waitUntilReady(ctx, "openai-codex"))) {
|
|
26
|
+
throw new Error("Companion Codex OAuth credential readiness failed closed.");
|
|
27
|
+
}
|
|
28
|
+
const adapter = adapterForProvider("openai-codex");
|
|
29
|
+
if (!adapter) throw new Error("Companion Codex usage support is unavailable.");
|
|
30
|
+
const companion = await resolveUsageAuth(ctx, adapter, salt, credentialReader);
|
|
31
|
+
if (!companion) return unavailable();
|
|
32
|
+
if (companion.source !== "OAuth")
|
|
33
|
+
throw new Error("Companion usage requires /login openai-codex OAuth, not an API key.");
|
|
34
|
+
// Legacy querying is more permissive. Enforce explicit Authorization removals and duplicates
|
|
35
|
+
// against preserved fresh provider auth before using its normalized request headers.
|
|
36
|
+
const access = openAIAuthAccessToken(companion.auth);
|
|
37
|
+
if (ctx.model?.provider === "openai-codex") {
|
|
38
|
+
const selected = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
39
|
+
if (
|
|
40
|
+
!selected.ok ||
|
|
41
|
+
(selected.baseUrl && new URL(selected.baseUrl).origin !== "https://chatgpt.com") ||
|
|
42
|
+
openAIAuthAccessToken(selected) !== access
|
|
43
|
+
) {
|
|
44
|
+
throw new Error("Selected Codex model authorization does not match the companion OAuth credential.");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!access || access !== openAIAuthAccessToken(companion)) {
|
|
48
|
+
throw new Error("Companion Codex runtime authorization did not match its OAuth credential.");
|
|
49
|
+
}
|
|
50
|
+
const accountId = codexAccountId(access);
|
|
51
|
+
if (!accountId) throw new Error("Companion Codex token did not contain a valid account ID.");
|
|
52
|
+
const offered = candidateReader
|
|
53
|
+
? await candidateReader(ctx, "openai-codex")
|
|
54
|
+
: fallbackOAuthCredentialCandidates("openai-codex", credentialReader);
|
|
55
|
+
if (!offered.ok) throw new Error("Companion Codex OAuth credential discovery failed closed.");
|
|
56
|
+
const matches = new Set<string>();
|
|
57
|
+
for (const candidate of offered.candidates) {
|
|
58
|
+
const credential = object(candidate);
|
|
59
|
+
if (credential?.type !== "oauth" || credential.access !== access) continue;
|
|
60
|
+
if (
|
|
61
|
+
credential.accountId !== accountId ||
|
|
62
|
+
typeof credential.refresh !== "string" ||
|
|
63
|
+
!credential.refresh ||
|
|
64
|
+
typeof credential.expires !== "number" ||
|
|
65
|
+
!Number.isFinite(credential.expires)
|
|
66
|
+
) {
|
|
67
|
+
throw new Error("The matching companion Codex OAuth credential was incomplete or had a different account ID.");
|
|
68
|
+
}
|
|
69
|
+
matches.add(credential.refresh);
|
|
70
|
+
}
|
|
71
|
+
if (matches.size !== 1) throw new Error("Companion Codex OAuth must uniquely match the fresh runtime credential.");
|
|
72
|
+
const headers = { Authorization: `Bearer ${access}`, "ChatGPT-Account-Id": accountId };
|
|
73
|
+
return {
|
|
74
|
+
...native,
|
|
75
|
+
openaiCompanionEnabled: true,
|
|
76
|
+
openaiCompanion: { headers },
|
|
77
|
+
fingerprint: fingerprintResolvedAuth(
|
|
78
|
+
{ apiKey: JSON.stringify([native.fingerprint, native.openaiClientId, true, companion.fingerprint, headers]) },
|
|
79
|
+
salt,
|
|
80
|
+
),
|
|
81
|
+
secrets: [
|
|
82
|
+
...new Set([...native.secrets, ...companion.secrets, ...matches, access, accountId, headers.Authorization]),
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function codexAccountId(access: string): string | undefined {
|
|
88
|
+
try {
|
|
89
|
+
const parts = access.split(".");
|
|
90
|
+
if (parts.length !== 3 || !parts[1]) return undefined;
|
|
91
|
+
const payload = object(JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")));
|
|
92
|
+
const account = object(payload?.["https://api.openai.com/auth"])?.chatgpt_account_id;
|
|
93
|
+
return typeof account === "string" && account.length > 0 && account.length <= 512 && /^[\x21-\x7e]+$/u.test(account)
|
|
94
|
+
? account
|
|
95
|
+
: undefined;
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function object(value: unknown): Record<string, unknown> | undefined {
|
|
102
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
103
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { PiModel, ResolvedUsageAuth, UsageProviderAdapter } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export const CHATGPT_USAGE_SETTINGS_URL = "https://chatgpt.com/settings/usage";
|
|
4
|
+
const DIRECT_TOKEN_SCOPE = "chatgpt.tokens.use.direct";
|
|
5
|
+
|
|
6
|
+
type RequestAuth = {
|
|
7
|
+
apiKey?: string;
|
|
8
|
+
headers?: Record<string, string | null>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export class UnsupportedOpenAIUsageAuthError extends Error {
|
|
12
|
+
override readonly name = "UnsupportedOpenAIUsageAuthError";
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
super("OpenAI API-key usage reporting is not supported; ChatGPT plan status requires native /login openai OAuth.");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Native Sign in with ChatGPT grants access to api.openai.com, not the legacy wham contract.
|
|
20
|
+
// Report only verified authentication, never invented quotas or backend endpoint compatibility.
|
|
21
|
+
// https://developers.openai.com/siwc/token-sharing-open-source/token-reference
|
|
22
|
+
// https://developers.openai.com/siwc/ui-ux-guidelines
|
|
23
|
+
export const OPENAI_CHATGPT_ADAPTER: UsageProviderAdapter = {
|
|
24
|
+
id: "openai",
|
|
25
|
+
displayName: "OpenAI",
|
|
26
|
+
semantics: { kind: "consumer-subscription", label: "ChatGPT plan authentication status" },
|
|
27
|
+
invalidateCacheOnFailure: true,
|
|
28
|
+
async query(auth, signal, timeoutMs, guard) {
|
|
29
|
+
if (auth.openaiCompanion) {
|
|
30
|
+
const { queryOpenAICompanionUsage } = await import("./openai-companion-usage.js");
|
|
31
|
+
signal.throwIfAborted();
|
|
32
|
+
return queryOpenAICompanionUsage(auth, signal, timeoutMs, guard);
|
|
33
|
+
}
|
|
34
|
+
if (!guard) throw new Error("ChatGPT plan status requires runtime-auth revalidation.");
|
|
35
|
+
signal.throwIfAborted();
|
|
36
|
+
await guard();
|
|
37
|
+
signal.throwIfAborted();
|
|
38
|
+
return {
|
|
39
|
+
providerId: "openai",
|
|
40
|
+
providerName: "OpenAI",
|
|
41
|
+
capturedAt: Date.now(),
|
|
42
|
+
source: "openai-chatgpt-auth",
|
|
43
|
+
semantics: { kind: "consumer-subscription", label: "ChatGPT plan authentication status" },
|
|
44
|
+
buckets: [],
|
|
45
|
+
metrics: [{ id: "plan-auth", label: "ChatGPT plan authentication", value: "Connected (native OAuth)" }],
|
|
46
|
+
notes: [
|
|
47
|
+
"Numerical usage and reset times are unavailable in pi-usage for native OpenAI OAuth.",
|
|
48
|
+
`Manage usage: ${CHATGPT_USAGE_SETTINGS_URL}`,
|
|
49
|
+
...(auth.openaiCompanionEnabled
|
|
50
|
+
? [
|
|
51
|
+
"Experimental companion usage requires /login openai-codex with the same ChatGPT account/workspace; native inference stays on openai.",
|
|
52
|
+
]
|
|
53
|
+
: []),
|
|
54
|
+
"Fast mode and earned reset redemption remain legacy openai-codex features.",
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function resolveOpenAIChatGPTAuth(
|
|
61
|
+
auth: RequestAuth,
|
|
62
|
+
providerAuth: RequestAuth | undefined,
|
|
63
|
+
source: string | undefined,
|
|
64
|
+
model: PiModel,
|
|
65
|
+
candidates: readonly unknown[],
|
|
66
|
+
): ResolvedUsageAuth {
|
|
67
|
+
// isUsingOAuth() is a cached snapshot; source alone is also insufficient when a model header
|
|
68
|
+
// overrides Authorization. Match both freshly resolved credentials and the exact native grant.
|
|
69
|
+
if (source !== "OAuth") throw new UnsupportedOpenAIUsageAuthError();
|
|
70
|
+
const access = openAIAuthAccessToken(auth);
|
|
71
|
+
if (!access || access !== openAIAuthAccessToken(providerAuth)) {
|
|
72
|
+
throw new Error("The active OpenAI model authorization does not match its resolved OAuth credential.");
|
|
73
|
+
}
|
|
74
|
+
const matches = new Map<string, { refresh: string; clientId: string }>();
|
|
75
|
+
for (const candidate of candidates) {
|
|
76
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
|
|
77
|
+
const credential = candidate as Record<string, unknown>;
|
|
78
|
+
if (credential.type !== "oauth" || credential.access !== access) continue;
|
|
79
|
+
if (
|
|
80
|
+
typeof credential.refresh !== "string" ||
|
|
81
|
+
!credential.refresh ||
|
|
82
|
+
typeof credential.expires !== "number" ||
|
|
83
|
+
!Number.isFinite(credential.expires) ||
|
|
84
|
+
typeof credential.clientId !== "string" ||
|
|
85
|
+
!credential.clientId.trim() ||
|
|
86
|
+
!Array.isArray(credential.scopes) ||
|
|
87
|
+
!credential.scopes.every((scope) => typeof scope === "string") ||
|
|
88
|
+
!credential.scopes.includes(DIRECT_TOKEN_SCOPE)
|
|
89
|
+
) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
"The matching OpenAI OAuth credential is not a complete native ChatGPT grant; reconnect with /login openai.",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
matches.set(JSON.stringify([credential.clientId, credential.refresh]), {
|
|
95
|
+
refresh: credential.refresh,
|
|
96
|
+
clientId: credential.clientId,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (matches.size > 1)
|
|
100
|
+
throw new Error("Conflicting native ChatGPT credentials match the active OpenAI runtime account.");
|
|
101
|
+
const match = matches.values().next().value;
|
|
102
|
+
if (!match)
|
|
103
|
+
throw new Error("The active OpenAI runtime account does not match an available native ChatGPT OAuth credential.");
|
|
104
|
+
const authorization = `Bearer ${access}`;
|
|
105
|
+
return {
|
|
106
|
+
openaiClientId: match.clientId,
|
|
107
|
+
apiKey: access,
|
|
108
|
+
headers: { Authorization: authorization },
|
|
109
|
+
fingerprint: "",
|
|
110
|
+
secrets: [access, match.refresh, authorization, match.clientId],
|
|
111
|
+
model,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function openAIAuthAccessToken(auth: RequestAuth | undefined): string | undefined {
|
|
116
|
+
if (!auth) return undefined;
|
|
117
|
+
const authorization = Object.entries(auth.headers ?? {}).filter(([name]) => name.toLowerCase() === "authorization");
|
|
118
|
+
if (authorization.length > 1) throw new Error("OpenAI runtime authorization headers are ambiguous.");
|
|
119
|
+
// Explicit null or non-Bearer overrides must not fall back to the underlying OAuth apiKey.
|
|
120
|
+
if (authorization.length === 1) return /^Bearer\s+(.+)$/iu.exec(authorization[0]?.[1] ?? "")?.[1];
|
|
121
|
+
return auth.apiKey;
|
|
122
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { fetchProviderJson } from "../query.js";
|
|
2
|
+
import type { ResolvedUsageAuth, UsageBucket, UsageReport, UsageRequestGuard } from "../types.js";
|
|
3
|
+
import { CHATGPT_USAGE_SETTINGS_URL } from "./openai-chatgpt.js";
|
|
4
|
+
|
|
5
|
+
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
6
|
+
const MAX_APPS = 128;
|
|
7
|
+
const MAX_WINDOWS = 32;
|
|
8
|
+
|
|
9
|
+
// Evidence: pinned Specode openai.ts at 1015f9f419dfef9ac27c9845ec2318394928fa38
|
|
10
|
+
// and OpenAI's Help Center article 20001542. Backend shapes remain undocumented.
|
|
11
|
+
// Accept only a complete list; unknown list envelope fields fail closed, including pagination.
|
|
12
|
+
export function matchingOpenAIAppBuckets(
|
|
13
|
+
payload: unknown,
|
|
14
|
+
clientId: string,
|
|
15
|
+
): { buckets: UsageBucket[]; allowance?: number } {
|
|
16
|
+
const root = object(payload);
|
|
17
|
+
if (
|
|
18
|
+
!root ||
|
|
19
|
+
!Array.isArray(root.items) ||
|
|
20
|
+
root.items.length > MAX_APPS ||
|
|
21
|
+
Object.keys(root).some((key) => !["items", "has_more", "next_cursor"].includes(key)) ||
|
|
22
|
+
(root.has_more !== undefined && root.has_more !== false) ||
|
|
23
|
+
(root.next_cursor !== undefined && root.next_cursor !== null && root.next_cursor !== "")
|
|
24
|
+
) {
|
|
25
|
+
throw new Error("ChatGPT app usage requires a complete, bounded registration list; pagination is unsupported.");
|
|
26
|
+
}
|
|
27
|
+
const apps = root.items.map(object);
|
|
28
|
+
if (apps.some((app) => !app || typeof app.id !== "string" || !app.id.trim())) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
"ChatGPT app usage returned an unreadable registration identity; uniqueness cannot be established.",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const matches = apps.filter((app) => app?.id === clientId);
|
|
34
|
+
if (!clientId || matches.length !== 1) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
"The native registration was not uniquely found in the companion Codex account; use the same ChatGPT account/workspace for both logins.",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const app = matches[0];
|
|
40
|
+
if (!Array.isArray(app?.windows) || app.windows.length === 0 || app.windows.length > MAX_WINDOWS) {
|
|
41
|
+
throw new Error("ChatGPT app usage returned no bounded, displayable windows.");
|
|
42
|
+
}
|
|
43
|
+
const allowance = app.allowed_usage_percent;
|
|
44
|
+
if (allowance !== undefined && !percent(allowance)) throw new Error("ChatGPT app allowance was invalid.");
|
|
45
|
+
return {
|
|
46
|
+
buckets: app.windows.map((window, index) => normalizeWindow(window, `app:${index}`, "chatgpt-app", "App limits")),
|
|
47
|
+
...(allowance === undefined ? {} : { allowance: allowance as number }),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function queryOpenAICompanionUsage(
|
|
52
|
+
auth: ResolvedUsageAuth,
|
|
53
|
+
signal: AbortSignal,
|
|
54
|
+
timeoutMs: number,
|
|
55
|
+
guard?: UsageRequestGuard,
|
|
56
|
+
): Promise<UsageReport> {
|
|
57
|
+
if (!guard) throw new Error("Companion ChatGPT usage requires request-boundary revalidation.");
|
|
58
|
+
if (!auth.openaiCompanion || !auth.openaiClientId)
|
|
59
|
+
throw new Error("Companion ChatGPT usage authentication was incomplete.");
|
|
60
|
+
const startedAt = Date.now();
|
|
61
|
+
// Never pass native apiKey/auth/env to transport: the only request credential is the companion.
|
|
62
|
+
const backend: ResolvedUsageAuth = {
|
|
63
|
+
headers: { ...auth.openaiCompanion.headers },
|
|
64
|
+
fingerprint: auth.fingerprint,
|
|
65
|
+
secrets: auth.secrets,
|
|
66
|
+
model: auth.model,
|
|
67
|
+
};
|
|
68
|
+
const read = async (url: string, description: string) => {
|
|
69
|
+
signal.throwIfAborted();
|
|
70
|
+
await guard();
|
|
71
|
+
signal.throwIfAborted();
|
|
72
|
+
const remaining = timeoutMs - (Date.now() - startedAt);
|
|
73
|
+
if (remaining <= 0) throw new Error("Timed out while fetching companion ChatGPT usage.");
|
|
74
|
+
const payload = await fetchProviderJson(url, backend, signal, remaining, description, { redirect: "error" });
|
|
75
|
+
await guard();
|
|
76
|
+
signal.throwIfAborted();
|
|
77
|
+
return payload;
|
|
78
|
+
};
|
|
79
|
+
const appPayload = await read(`${USAGE_URL}/chatpass/apps`, "ChatGPT companion app usage endpoint");
|
|
80
|
+
const app = matchingOpenAIAppBuckets(appPayload, auth.openaiClientId);
|
|
81
|
+
// Match and validate the app before reading plan data; never substitute Codex-only quotas.
|
|
82
|
+
const planPayload = await read(USAGE_URL, "ChatGPT companion plan usage endpoint");
|
|
83
|
+
const rateLimit = object(planPayload.rate_limit);
|
|
84
|
+
const planBuckets: UsageBucket[] = [];
|
|
85
|
+
for (const position of ["primary", "secondary"] as const) {
|
|
86
|
+
const window = rateLimit?.[`${position}_window`];
|
|
87
|
+
if (window !== undefined && window !== null)
|
|
88
|
+
planBuckets.push(normalizeWindow(window, `plan:${position}`, "chatgpt-plan", "Plan limits"));
|
|
89
|
+
}
|
|
90
|
+
if (planBuckets.length === 0) throw new Error("ChatGPT plan usage returned no displayable windows.");
|
|
91
|
+
return {
|
|
92
|
+
providerId: "openai",
|
|
93
|
+
providerName: "OpenAI",
|
|
94
|
+
capturedAt: Date.now(),
|
|
95
|
+
source: "openai-chatgpt-companion",
|
|
96
|
+
semantics: { kind: "consumer-subscription", label: "ChatGPT plan and app limits (experimental companion source)" },
|
|
97
|
+
buckets: [...planBuckets, ...app.buckets],
|
|
98
|
+
metrics:
|
|
99
|
+
app.allowance === undefined
|
|
100
|
+
? []
|
|
101
|
+
: [{ id: "app-allowance", label: "App allowance", value: app.allowance, unit: "percent" }],
|
|
102
|
+
notes: [
|
|
103
|
+
"Source: companion Codex OAuth; registration matching is not independent proof of the same user/workspace.",
|
|
104
|
+
"App allowance caps shared plan usage; it is not remaining or reserved quota.",
|
|
105
|
+
`Manage usage: ${CHATGPT_USAGE_SETTINGS_URL}`,
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeWindow(raw: unknown, id: string, groupId: string, groupLabel: string): UsageBucket {
|
|
111
|
+
const window = object(raw);
|
|
112
|
+
const used = window?.used_percent;
|
|
113
|
+
const remaining = window?.remaining_percent;
|
|
114
|
+
const seconds = window?.limit_window_seconds;
|
|
115
|
+
const resetsAt = window?.reset_at;
|
|
116
|
+
if (
|
|
117
|
+
!percent(used) ||
|
|
118
|
+
(remaining !== undefined && (!percent(remaining) || Math.abs(used + remaining - 100) > 1)) ||
|
|
119
|
+
typeof seconds !== "number" ||
|
|
120
|
+
!Number.isSafeInteger(seconds) ||
|
|
121
|
+
seconds <= 0 ||
|
|
122
|
+
(resetsAt !== undefined &&
|
|
123
|
+
(typeof resetsAt !== "number" || !Number.isSafeInteger(resetsAt) || resetsAt < 0 || resetsAt > 8_640_000_000_000))
|
|
124
|
+
) {
|
|
125
|
+
throw new Error("ChatGPT usage returned an invalid or contradictory window.");
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
id,
|
|
129
|
+
label: "Subscription limit",
|
|
130
|
+
groupId,
|
|
131
|
+
groupLabel,
|
|
132
|
+
used,
|
|
133
|
+
remaining: remaining === undefined ? 100 - used : (remaining as number),
|
|
134
|
+
limit: 100,
|
|
135
|
+
unit: "percent",
|
|
136
|
+
windowMinutes: Math.ceil(seconds / 60),
|
|
137
|
+
...(resetsAt === undefined ? {} : { resetsAt: resetsAt as number }),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function percent(value: unknown): value is number {
|
|
142
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100;
|
|
143
|
+
}
|
|
144
|
+
function object(value: unknown): Record<string, unknown> | undefined {
|
|
145
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
146
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -12,6 +12,11 @@ import { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.j
|
|
|
12
12
|
import { normalizeKimiCodingUsagePayload } from "./providers/kimi-coding.js";
|
|
13
13
|
import { type MiniMaxProviderId, miniMaxUsageKind, normalizeMiniMaxUsagePayload } from "./providers/minimax.js";
|
|
14
14
|
import { normalizeMoonshotBalancePayload } from "./providers/moonshot.js";
|
|
15
|
+
import {
|
|
16
|
+
OPENAI_CHATGPT_ADAPTER,
|
|
17
|
+
resolveOpenAIChatGPTAuth,
|
|
18
|
+
UnsupportedOpenAIUsageAuthError,
|
|
19
|
+
} from "./providers/openai-chatgpt.js";
|
|
15
20
|
import { normalizeOpenCodeZenPayload } from "./providers/opencode-zen.js";
|
|
16
21
|
import { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
|
|
17
22
|
import { normalizeVercelAIGatewayCreditsPayload } from "./providers/vercel-ai-gateway.js";
|
|
@@ -95,6 +100,7 @@ export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
|
|
|
95
100
|
return normalizeBasetenBillingUsagePayload(payload, Date.now());
|
|
96
101
|
},
|
|
97
102
|
},
|
|
103
|
+
OPENAI_CHATGPT_ADAPTER,
|
|
98
104
|
{
|
|
99
105
|
id: "openai-codex",
|
|
100
106
|
displayName: "OpenAI Codex",
|
|
@@ -334,6 +340,7 @@ export async function resolveUsageAuth(
|
|
|
334
340
|
salt: Uint8Array = AUTH_FINGERPRINT_SALT,
|
|
335
341
|
credentialReader: StoredCredentialReader = readStoredCredential,
|
|
336
342
|
candidateReader?: OAuthCredentialCandidateReader,
|
|
343
|
+
openaiCompanionUsage = false,
|
|
337
344
|
): Promise<ResolvedUsageAuth | undefined> {
|
|
338
345
|
if (ctx.model?.provider === adapter.id && !hasOfficialOrigin(ctx.model, adapter.id)) {
|
|
339
346
|
throw new Error(
|
|
@@ -344,7 +351,7 @@ export async function resolveUsageAuth(
|
|
|
344
351
|
const model = candidateModels(ctx, adapter.id).find((candidate) => hasOfficialOrigin(candidate, adapter.id));
|
|
345
352
|
if (!model) return undefined;
|
|
346
353
|
if (
|
|
347
|
-
["github-copilot", "xai"].includes(adapter.id) &&
|
|
354
|
+
["github-copilot", "openai", "xai"].includes(adapter.id) &&
|
|
348
355
|
candidateReader?.waitUntilReady &&
|
|
349
356
|
!(await candidateReader.waitUntilReady(ctx, adapter.id))
|
|
350
357
|
) {
|
|
@@ -364,9 +371,11 @@ export async function resolveUsageAuth(
|
|
|
364
371
|
if (!currentModel || typeof registry.getApiKeyAndHeaders !== "function") return undefined;
|
|
365
372
|
const result = await registry.getApiKeyAndHeaders(currentModel);
|
|
366
373
|
if (!result.ok) throw new Error(redactUsageError(result.error));
|
|
367
|
-
|
|
374
|
+
// Native plan status must inspect explicit empty/null Authorization overrides, not fall
|
|
375
|
+
// back to provider OAuth when the selected model removes or replaces its authorization.
|
|
376
|
+
return adapter.id === "openai" || authorizationFrom(result) ? result : undefined;
|
|
368
377
|
};
|
|
369
|
-
const resolveSelectedAuthLast = ["deepseek", "minimax", "minimax-cn"].includes(adapter.id);
|
|
378
|
+
const resolveSelectedAuthLast = ["deepseek", "minimax", "minimax-cn", "openai"].includes(adapter.id);
|
|
370
379
|
if (!resolveSelectedAuthLast) modelAuth = await resolveCurrentModelAuth();
|
|
371
380
|
if (typeof registry.getProviderAuth !== "function") {
|
|
372
381
|
throw new Error("pi-usage requires Pi 0.81.0 or newer to validate resolved provider auth.");
|
|
@@ -421,6 +430,21 @@ export async function resolveUsageAuth(
|
|
|
421
430
|
),
|
|
422
431
|
};
|
|
423
432
|
};
|
|
433
|
+
if (adapter.id === "openai") {
|
|
434
|
+
// Pi can return empty compatibility model auth without any provider credential.
|
|
435
|
+
if (!providerResult) return undefined;
|
|
436
|
+
if (providerResult.source !== "OAuth") throw new UnsupportedOpenAIUsageAuthError();
|
|
437
|
+
const offered = candidateReader
|
|
438
|
+
? await candidateReader(ctx, adapter.id)
|
|
439
|
+
: fallbackOAuthCredentialCandidates(adapter.id, credentialReader);
|
|
440
|
+
if (!offered.ok) throw new Error("OpenAI OAuth credential discovery failed closed.");
|
|
441
|
+
const native = finalize(
|
|
442
|
+
resolveOpenAIChatGPTAuth(auth, providerResult.auth, providerResult.source, model, offered.candidates),
|
|
443
|
+
);
|
|
444
|
+
if (!openaiCompanionUsage) return native;
|
|
445
|
+
const { resolveOpenAICompanionAuth } = await import("./openai-companion-auth.js");
|
|
446
|
+
return resolveOpenAICompanionAuth(ctx, native, salt, credentialReader, candidateReader);
|
|
447
|
+
}
|
|
424
448
|
if (adapter.id === "github-copilot") {
|
|
425
449
|
const offered = candidateReader
|
|
426
450
|
? await candidateReader(ctx, adapter.id)
|
|
@@ -897,6 +921,7 @@ function hasOfficialUrlOrigin(value: string, providerId: string): boolean {
|
|
|
897
921
|
if (providerId === "baseten") {
|
|
898
922
|
return ["https://inference.baseten.co", "https://api.baseten.co"].includes(url.origin);
|
|
899
923
|
}
|
|
924
|
+
if (providerId === "openai") return url.origin === "https://api.openai.com";
|
|
900
925
|
if (providerId === "openai-codex") return url.origin === "https://chatgpt.com";
|
|
901
926
|
if (providerId === "deepseek") return url.origin === "https://api.deepseek.com";
|
|
902
927
|
if (providerId === "fireworks") return url.origin === "https://api.fireworks.ai";
|
package/src/settings.ts
CHANGED
|
@@ -12,6 +12,8 @@ export const MAX_USAGE_SETTINGS_BYTES = 64 * 1024;
|
|
|
12
12
|
export type CodexStatusPercentage = "remaining" | "used";
|
|
13
13
|
|
|
14
14
|
export interface UsageSettings {
|
|
15
|
+
/** Omitted values preserve default-off compatibility for injected settings runtimes. */
|
|
16
|
+
openaiCompanionUsage?: boolean;
|
|
15
17
|
codexFastMode: boolean;
|
|
16
18
|
codexStatusResetCountdown: boolean;
|
|
17
19
|
codexStatusPercentage: CodexStatusPercentage;
|
|
@@ -19,6 +21,7 @@ export interface UsageSettings {
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export const DEFAULT_USAGE_SETTINGS: Readonly<UsageSettings> = Object.freeze({
|
|
24
|
+
openaiCompanionUsage: false,
|
|
22
25
|
codexFastMode: false,
|
|
23
26
|
codexStatusResetCountdown: true,
|
|
24
27
|
codexStatusPercentage: "remaining",
|
|
@@ -64,6 +67,7 @@ export function usageSettingsPath(): string {
|
|
|
64
67
|
|
|
65
68
|
export function normalizeUsageSettings(value: unknown): UsageSettings | undefined {
|
|
66
69
|
if (!isRecord(value)) return undefined;
|
|
70
|
+
if (Object.hasOwn(value, "openaiCompanionUsage") && typeof value.openaiCompanionUsage !== "boolean") return undefined;
|
|
67
71
|
if (Object.hasOwn(value, "codexFastMode") && typeof value.codexFastMode !== "boolean") {
|
|
68
72
|
return undefined;
|
|
69
73
|
}
|
|
@@ -87,6 +91,7 @@ export function normalizeUsageSettings(value: unknown): UsageSettings | undefine
|
|
|
87
91
|
effectiveTargets.fireworks = value.fireworksAccountId;
|
|
88
92
|
}
|
|
89
93
|
return {
|
|
94
|
+
openaiCompanionUsage: value.openaiCompanionUsage === true,
|
|
90
95
|
codexFastMode:
|
|
91
96
|
typeof value.codexFastMode === "boolean" ? value.codexFastMode : DEFAULT_USAGE_SETTINGS.codexFastMode,
|
|
92
97
|
codexStatusResetCountdown:
|
package/src/types.ts
CHANGED
|
@@ -48,6 +48,10 @@ export interface UsageReport {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export interface ResolvedUsageAuth {
|
|
51
|
+
/** Native registration metadata stays local; only companion headers reach ChatGPT. */
|
|
52
|
+
openaiClientId?: string;
|
|
53
|
+
openaiCompanionEnabled?: boolean;
|
|
54
|
+
openaiCompanion?: { headers: Record<string, string> };
|
|
51
55
|
apiKey?: string;
|
|
52
56
|
headers: Record<string, string>;
|
|
53
57
|
fingerprint: string;
|