@narumitw/pi-usage 0.64.3 → 0.65.1
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 +20 -4
- package/dist/chunks/{chunk-ACPQJ2CM.ts → chunk-N2ZD5X4K.ts} +4 -4
- package/dist/chunks/{chunk-ACPQJ2CM.ts.map → chunk-N2ZD5X4K.ts.map} +2 -2
- package/dist/chunks/{openai-companion-auth-FGFC6DBT.ts → openai-companion-auth-QIKCD3WA.ts} +2 -2
- package/dist/chunks/{openai-companion-usage-TGHK2OQR.ts → openai-companion-usage-ZFAHCGEY.ts} +2 -2
- package/dist/index.ts +439 -149
- package/dist/index.ts.map +4 -4
- package/docs/providers.md +7 -7
- package/package.json +1 -1
- package/src/codex-fast-catalog.ts +272 -0
- package/src/codex-fast-runtime.ts +107 -26
- package/src/codex-fast.ts +28 -19
- package/src/format.ts +96 -34
- package/src/index.ts +3 -1
- package/src/query.ts +2 -1
- package/src/usage.ts +45 -22
- /package/dist/chunks/{openai-companion-auth-FGFC6DBT.ts.map → openai-companion-auth-QIKCD3WA.ts.map} +0 -0
- /package/dist/chunks/{openai-companion-usage-TGHK2OQR.ts.map → openai-companion-usage-ZFAHCGEY.ts.map} +0 -0
package/docs/providers.md
CHANGED
|
@@ -81,7 +81,7 @@ Contract fixtures derive from [Specode's pinned implementation](https://github.c
|
|
|
81
81
|
- Source: the Codex usage and earned-reset endpoints using Pi's resolved runtime authorization
|
|
82
82
|
- Displayed data: returned duration-based windows, resets, credits, earned usage-limit resets, and additional model buckets
|
|
83
83
|
- Reset mutation: `POST /wham/rate-limit-reset-credits/consume` with a unique redemption request ID and, when available, the selected opaque credit ID
|
|
84
|
-
- Statusline examples: `codex 59% ↻ 2h30m 61% ↻ 2d15m`, `codex fast 59% ↻ 2h30m`, or `codex spark 100% ↻ 2h30m`. Set `codexStatusPercentage` to `"used"` to show consumed percentages, or set `codexStatusResetCountdown` to `false` for
|
|
84
|
+
- Statusline examples: `codex 59% ↻ 2h30m · 61% ↻ 2d15m`, `codex fast 59% ↻ 2h30m`, or `codex spark 100% ↻ 2h30m`. Set `codexStatusPercentage` to `"used"` to show consumed percentages, or set `codexStatusResetCountdown` to `false` for static labels such as `codex 5h:59% · wk:61%`.
|
|
85
85
|
|
|
86
86
|
The statusline selects a returned bucket that matches the current Codex model when one is available.
|
|
87
87
|
The percentage preference affects only this compact status and defaults to remaining quota; it does not change the detailed report or returned usage data.
|
|
@@ -101,7 +101,7 @@ Opaque credit and account IDs are never shown or persisted by the extension.
|
|
|
101
101
|
- Source: `GET https://api.kimi.com/coding/v1/usages` using Pi's freshly resolved runtime Bearer credential
|
|
102
102
|
- Displayed plan data: count-based weekly summaries and returned sub-windows, plus reported ratio-based five-hour, weekly, or monthly quotas and valid reset times
|
|
103
103
|
- Displayed wallet data: balance, monthly spend, and monthly charge limit when the wallet supplies consistent currency and amounts
|
|
104
|
-
- Statusline examples: `kimi
|
|
104
|
+
- Statusline examples: `kimi 77% ↻ 2h52m · 87% ↻ 29d20h`, `kimi 77% ↻ 2h52m · mo:87%`, or static fallback `kimi 1d:95%`
|
|
105
105
|
|
|
106
106
|
Both Pi API-key credentials and Pi OAuth credentials are accepted because current Pi resolves each form as Bearer authorization for the same official Kimi inference origin.
|
|
107
107
|
The extension queries the fixed usage endpoint only when both the selected model origin and the effective resolved-auth origin are `https://api.kimi.com`.
|
|
@@ -148,7 +148,7 @@ The contracts were verified on 2026-08-30 against the official [Global balance r
|
|
|
148
148
|
- Token Plan source: `GET {region-api-root}/v1/token_plan/remains`
|
|
149
149
|
- Pay-as-you-go source: `GET {region-api-root}/account/query_balance`
|
|
150
150
|
- Region API roots: `https://api.minimax.io` and `https://api.minimaxi.com`
|
|
151
|
-
- Statusline examples: `minimax 15%
|
|
151
|
+
- Statusline examples: `minimax 15% ↻ 2h30m · 80% ↻ 6d`, static fallback `minimax 5h:15% · wk:80%`, unlimited `minimax cn 5h:unlimited · wk:unlimited`, or pay-as-you-go `minimax USD 98.00001`
|
|
152
152
|
|
|
153
153
|
Pi's resolved MiniMax API key selects exactly one endpoint before network access.
|
|
154
154
|
Keys with the first-party `sk-api-` prefix query pay-as-you-go balance; other MiniMax API keys query Token Plan quota.
|
|
@@ -166,7 +166,7 @@ The contract was verified on 2026-08-30 against MiniMax's [Token Plan FAQ](https
|
|
|
166
166
|
- Allowance labels: AI credits for usage-based billing, premium requests for legacy annual billing, or chat requests for Copilot Free
|
|
167
167
|
- Source: GitHub's undocumented `GET /copilot_internal/user` endpoint
|
|
168
168
|
- Displayed data: entitlement, remaining allowance, percentage, reset time, plan, and any additional usage beyond the included allowance
|
|
169
|
-
- Statusline examples: `copilot credits 1200/1500 80
|
|
169
|
+
- Statusline examples: `copilot credits 1200/1500 80% ↻ 6d`, `copilot 245/300 82% ↻ 6d`, or static fallback `copilot chat 40/50 80%`
|
|
170
170
|
|
|
171
171
|
GitHub's quota endpoint requires the original GitHub OAuth token rather than the short-lived Copilot inference token exposed by runtime auth.
|
|
172
172
|
`pi-usage` supports Copilot accounts created through Pi's `/login` flow and named accounts offered by a compatible `oauth:credential-source:v1` owner.
|
|
@@ -260,7 +260,7 @@ The contract was verified on 2026-08-30 against Baseten's [Billing and usage](ht
|
|
|
260
260
|
- Source: `GET https://opencode.ai/zen/go/v1/usage` using Pi's resolved inference API key
|
|
261
261
|
- Displayed data: used percentage and reset time for each window
|
|
262
262
|
- Status handling: `rate-limited` windows remain visible, while unknown statuses become unavailable notes
|
|
263
|
-
- Statusline examples: `zen 0%
|
|
263
|
+
- Statusline examples: `zen 0% ↻ 2h30m · 4% ↻ 6d · m:2%` or static fallback `zen r:0% · w:4% · m:2%`
|
|
264
264
|
|
|
265
265
|
The fixed endpoint is queried only when the OpenCode Go model uses the official `https://opencode.ai` origin.
|
|
266
266
|
When resolved provider auth includes a base URL, that URL must use the same origin.
|
|
@@ -275,7 +275,7 @@ Other origins fail before the credential is sent.
|
|
|
275
275
|
- Plan route: `GET https://api.commandcode.ai/alpha/billing/subscriptions`
|
|
276
276
|
- Period route: `GET https://api.commandcode.ai/alpha/usage/summary`
|
|
277
277
|
- Displayed data: five-hour and weekly spend caps in USD with reset times, the billing-period credit pool and remaining balance, plan name and status, request and token totals, and purchased or free credits when present
|
|
278
|
-
- Statusline examples: `cmd 99%
|
|
278
|
+
- Statusline examples: `cmd 99% ↻ 2h30m · 92% ↻ 6d · 96% ↻ 20d` or static fallback `cmd 5h:99% · wk:92% · mo:96%`
|
|
279
279
|
|
|
280
280
|
The official `@commandcode/pi-commandcode-provider` extension registers this provider through `pi.registerProvider()`, so `command-code` is not a Pi built-in provider.
|
|
281
281
|
Pi still resolves its stored `/login` API key through the normal provider registry, and the extension sends it as Bearer authorization only when the selected model and resolved auth share the official `https://api.commandcode.ai` origin.
|
|
@@ -329,7 +329,7 @@ xAI identity and billing requests occur only after an explicit current, configur
|
|
|
329
329
|
- Allowed origins: the model base URL must resolve to `https://api.z.ai` or `https://open.bigmodel.cn`
|
|
330
330
|
- Displayed data: explicit used and remaining values, reset times, provider-reported per-tool MCP details, and the plan name with its renewal date
|
|
331
331
|
- Percentage-only windows remain percent-based
|
|
332
|
-
- Statusline: publishes remaining plan percentages such as `zai 87%
|
|
332
|
+
- Statusline: publishes remaining plan percentages such as `zai 87% ↻ 2h30m · 76% ↻ 6d`, with static fallback `zai 5h:87% · wk:76%`; monthly MCP details remain available through `/usage`
|
|
333
333
|
|
|
334
334
|
The monitor endpoint is not a published API contract and may return legacy `TOKENS_LIMIT` or newer `CREDIT_LIMIT` window names.
|
|
335
335
|
The extension classifies both forms by the provider's window unit and does not label provider-reported counts as tokens or calls.
|
package/package.json
CHANGED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { type ExtensionContext, VERSION } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type CodexFastCapability, isOfficialCodexModel, UNKNOWN_CODEX_FAST_CAPABILITY } from "./codex-fast.js";
|
|
3
|
+
import { resolveCodexResetAuth } from "./codex-resets.js";
|
|
4
|
+
import { awaitWithDeadline, errorMessage, fingerprintResolvedAuth, redactUsageError } from "./core.js";
|
|
5
|
+
import type { OAuthCredentialCandidateReader, StoredCredentialReader } from "./oauth-credential-source.js";
|
|
6
|
+
import { AUTH_FINGERPRINT_SALT, fetchProviderJson } from "./query.js";
|
|
7
|
+
import type { PiModel, ResolvedUsageAuth } from "./types.js";
|
|
8
|
+
import { setBoundedMap } from "./usage-helpers.js";
|
|
9
|
+
|
|
10
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
11
|
+
const FAILURE_BACKOFF_MS = 30_000;
|
|
12
|
+
const MAX_ACCOUNT_STATES = 32;
|
|
13
|
+
const MAX_DIRECTORY_BODY_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
const DIRECTORY_URL = new URL("https://chatgpt.com/backend-api/codex/models");
|
|
15
|
+
DIRECTORY_URL.searchParams.set("client_version", VERSION);
|
|
16
|
+
|
|
17
|
+
type CatalogEntry = {
|
|
18
|
+
models?: ReadonlyMap<string, boolean | undefined>;
|
|
19
|
+
fetchedAt: number;
|
|
20
|
+
fingerprint?: string;
|
|
21
|
+
retryAt?: number;
|
|
22
|
+
issue?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type AccountScope = {
|
|
26
|
+
key: string;
|
|
27
|
+
fingerprint: string;
|
|
28
|
+
model: string;
|
|
29
|
+
session: string;
|
|
30
|
+
generation: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type CatalogOptions = {
|
|
34
|
+
credentialReader?: StoredCredentialReader;
|
|
35
|
+
candidateReader?: OAuthCredentialCandidateReader;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
onChanged?: (ctx: ExtensionContext) => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Account-scoped discovery; request hooks authenticate their cache read without awaiting directory I/O. */
|
|
41
|
+
export function createCodexFastCatalog(options: CatalogOptions = {}) {
|
|
42
|
+
const accounts = new Map<string, CatalogEntry>();
|
|
43
|
+
const requests = new Map<string, { controller: AbortController; promise: Promise<void> }>();
|
|
44
|
+
const latestRequests = new Map<string, number>();
|
|
45
|
+
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
46
|
+
let requestSequence = 0;
|
|
47
|
+
let identitySequence = 0;
|
|
48
|
+
let generation = 0;
|
|
49
|
+
let active: AccountScope | undefined;
|
|
50
|
+
let issue: string = UNKNOWN_CODEX_FAST_CAPABILITY.reason;
|
|
51
|
+
|
|
52
|
+
const current = (scope: AccountScope, ctx: ExtensionContext, signal: AbortSignal) =>
|
|
53
|
+
!signal.aborted &&
|
|
54
|
+
generation === scope.generation &&
|
|
55
|
+
active?.key === scope.key &&
|
|
56
|
+
active.fingerprint === scope.fingerprint &&
|
|
57
|
+
active.model === scope.model &&
|
|
58
|
+
active.session === scope.session &&
|
|
59
|
+
modelIdentity(ctx.model) === scope.model &&
|
|
60
|
+
ctx.sessionManager.getSessionId() === scope.session;
|
|
61
|
+
|
|
62
|
+
const capability = (model: PiModel | undefined, key = active?.key): CodexFastCapability => {
|
|
63
|
+
const entry = key ? accounts.get(key) : undefined;
|
|
64
|
+
const supported = model ? entry?.models?.get(model.id) : undefined;
|
|
65
|
+
if (supported !== undefined) return { kind: supported ? "supported" : "unsupported" };
|
|
66
|
+
return {
|
|
67
|
+
kind: "unknown",
|
|
68
|
+
reason:
|
|
69
|
+
entry?.issue ??
|
|
70
|
+
(entry?.models && model
|
|
71
|
+
? `The Codex model directory has no Fast capability information for ${model.id}.`
|
|
72
|
+
: issue),
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const identify = async (ctx: ExtensionContext, signal: AbortSignal) => {
|
|
77
|
+
const identity = ++identitySequence;
|
|
78
|
+
const owner = generation;
|
|
79
|
+
const model = modelIdentity(ctx.model);
|
|
80
|
+
const session = ctx.sessionManager.getSessionId();
|
|
81
|
+
if (!isOfficialCodexModel(ctx.model)) {
|
|
82
|
+
active = undefined;
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
// Reuse the existing active-runtime OAuth/account validation, without any reset request.
|
|
87
|
+
const auth = await awaitWithDeadline(
|
|
88
|
+
resolveCodexResetAuth(ctx, undefined, options.credentialReader, options.candidateReader),
|
|
89
|
+
signal,
|
|
90
|
+
timeoutMs,
|
|
91
|
+
"resolving the current Codex model-directory account",
|
|
92
|
+
);
|
|
93
|
+
if (
|
|
94
|
+
signal.aborted ||
|
|
95
|
+
owner !== generation ||
|
|
96
|
+
identity !== identitySequence ||
|
|
97
|
+
modelIdentity(ctx.model) !== model ||
|
|
98
|
+
ctx.sessionManager.getSessionId() !== session
|
|
99
|
+
) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
const accountId = auth.headers["chatgpt-account-id"];
|
|
103
|
+
if (!accountId) throw new Error("The active Codex account ID is unavailable.");
|
|
104
|
+
const scope: AccountScope = {
|
|
105
|
+
key: fingerprintResolvedAuth({ headers: { "chatgpt-account-id": accountId } }, AUTH_FINGERPRINT_SALT),
|
|
106
|
+
fingerprint: auth.fingerprint,
|
|
107
|
+
model,
|
|
108
|
+
session,
|
|
109
|
+
generation: owner,
|
|
110
|
+
};
|
|
111
|
+
const changed = active?.key !== scope.key;
|
|
112
|
+
active = scope;
|
|
113
|
+
issue = "Codex Fast capability information has not been fetched for this account.";
|
|
114
|
+
if (changed) options.onChanged?.(ctx);
|
|
115
|
+
return { auth, scope };
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (signal.aborted || owner !== generation || identity !== identitySequence) return undefined;
|
|
118
|
+
active = undefined;
|
|
119
|
+
issue = `Codex Fast capability is unknown: ${redactUsageError(errorMessage(error))}`;
|
|
120
|
+
options.onChanged?.(ctx);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const load = async (
|
|
126
|
+
ctx: ExtensionContext,
|
|
127
|
+
auth: ResolvedUsageAuth,
|
|
128
|
+
scope: AccountScope,
|
|
129
|
+
signal: AbortSignal,
|
|
130
|
+
force: boolean,
|
|
131
|
+
): Promise<void> => {
|
|
132
|
+
const entry = accounts.get(scope.key);
|
|
133
|
+
if (
|
|
134
|
+
!force &&
|
|
135
|
+
((entry?.models && entry.fingerprint === auth.fingerprint && Date.now() - entry.fetchedAt < CACHE_TTL_MS) ||
|
|
136
|
+
(entry?.retryAt && Date.now() < entry.retryAt))
|
|
137
|
+
) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const before = capability(ctx.model, scope.key);
|
|
141
|
+
const existing = requests.get(auth.fingerprint);
|
|
142
|
+
let promise = existing?.promise;
|
|
143
|
+
if (!promise) {
|
|
144
|
+
const sequence = ++requestSequence;
|
|
145
|
+
latestRequests.set(scope.key, sequence);
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
const combined = AbortSignal.any([signal, controller.signal]);
|
|
148
|
+
let status: number | undefined;
|
|
149
|
+
promise = (async () => {
|
|
150
|
+
try {
|
|
151
|
+
const payload = await fetchProviderJson(
|
|
152
|
+
DIRECTORY_URL.href,
|
|
153
|
+
auth,
|
|
154
|
+
combined,
|
|
155
|
+
timeoutMs,
|
|
156
|
+
"Codex model directory",
|
|
157
|
+
{
|
|
158
|
+
redirect: "error",
|
|
159
|
+
maxSuccessBodyBytes: MAX_DIRECTORY_BODY_BYTES,
|
|
160
|
+
responseError: (responseStatus) => {
|
|
161
|
+
status = responseStatus;
|
|
162
|
+
return undefined;
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
const models = normalizeDirectory(payload);
|
|
167
|
+
if (combined.aborted || scope.generation !== generation || latestRequests.get(scope.key) !== sequence) return;
|
|
168
|
+
setBoundedMap(
|
|
169
|
+
accounts,
|
|
170
|
+
scope.key,
|
|
171
|
+
{ models, fetchedAt: Date.now(), fingerprint: auth.fingerprint },
|
|
172
|
+
MAX_ACCOUNT_STATES,
|
|
173
|
+
);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (combined.aborted || scope.generation !== generation || latestRequests.get(scope.key) !== sequence) return;
|
|
176
|
+
const previous = accounts.get(scope.key);
|
|
177
|
+
const transient = status === undefined || status >= 500 || status === 429;
|
|
178
|
+
setBoundedMap(
|
|
179
|
+
accounts,
|
|
180
|
+
scope.key,
|
|
181
|
+
{
|
|
182
|
+
...(transient && previous?.models ? previous : { fetchedAt: 0 }),
|
|
183
|
+
retryAt: Date.now() + FAILURE_BACKOFF_MS,
|
|
184
|
+
issue: `Codex Fast capability is unknown: ${redactUsageError(errorMessage(error), auth.secrets)}`,
|
|
185
|
+
},
|
|
186
|
+
MAX_ACCOUNT_STATES,
|
|
187
|
+
);
|
|
188
|
+
} finally {
|
|
189
|
+
if (requests.get(auth.fingerprint)?.controller === controller) requests.delete(auth.fingerprint);
|
|
190
|
+
if (latestRequests.get(scope.key) === sequence) latestRequests.delete(scope.key);
|
|
191
|
+
}
|
|
192
|
+
})();
|
|
193
|
+
requests.set(auth.fingerprint, { controller, promise });
|
|
194
|
+
}
|
|
195
|
+
// A cancelled menu must stop waiting without aborting a session-owned shared request.
|
|
196
|
+
try {
|
|
197
|
+
await awaitWithDeadline(promise, signal, timeoutMs, "fetching the Codex model directory");
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (signal.aborted) return;
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
const after = capability(ctx.model, scope.key);
|
|
203
|
+
if (current(scope, ctx, signal) && JSON.stringify(before) !== JSON.stringify(after)) options.onChanged?.(ctx);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
get(model: PiModel | undefined) {
|
|
208
|
+
return capability(model);
|
|
209
|
+
},
|
|
210
|
+
async read(
|
|
211
|
+
ctx: ExtensionContext,
|
|
212
|
+
signal: AbortSignal,
|
|
213
|
+
refresh = false,
|
|
214
|
+
force = false,
|
|
215
|
+
): Promise<CodexFastCapability | undefined> {
|
|
216
|
+
const resolved = await identify(ctx, signal);
|
|
217
|
+
if (!resolved) return undefined;
|
|
218
|
+
const { auth, scope } = resolved;
|
|
219
|
+
if (refresh) await load(ctx, auth, scope, signal, force || capability(ctx.model, scope.key).kind === "unknown");
|
|
220
|
+
else void load(ctx, auth, scope, signal, false);
|
|
221
|
+
return current(scope, ctx, signal) ? capability(ctx.model, scope.key) : undefined;
|
|
222
|
+
},
|
|
223
|
+
reset(clearCache = false) {
|
|
224
|
+
generation += 1;
|
|
225
|
+
identitySequence += 1;
|
|
226
|
+
active = undefined;
|
|
227
|
+
issue = UNKNOWN_CODEX_FAST_CAPABILITY.reason;
|
|
228
|
+
for (const { controller } of requests.values()) controller.abort();
|
|
229
|
+
requests.clear();
|
|
230
|
+
latestRequests.clear();
|
|
231
|
+
if (clearCache) accounts.clear();
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function normalizeDirectory(payload: Record<string, unknown>): ReadonlyMap<string, boolean | undefined> {
|
|
237
|
+
if (!Array.isArray(payload.models)) throw new Error("The Codex model directory returned invalid models.");
|
|
238
|
+
const models = new Map<string, boolean | undefined>();
|
|
239
|
+
for (const item of payload.models) {
|
|
240
|
+
if (!isRecord(item) || typeof item.slug !== "string" || !item.slug.trim() || models.has(item.slug)) {
|
|
241
|
+
throw new Error("The Codex model directory returned an invalid or duplicate model ID.");
|
|
242
|
+
}
|
|
243
|
+
const hasTiers = item.service_tiers !== undefined;
|
|
244
|
+
const hasLegacy = item.additional_speed_tiers !== undefined;
|
|
245
|
+
if (
|
|
246
|
+
(hasTiers &&
|
|
247
|
+
(!Array.isArray(item.service_tiers) ||
|
|
248
|
+
item.service_tiers.some((tier) => !isRecord(tier) || typeof tier.id !== "string"))) ||
|
|
249
|
+
(hasLegacy &&
|
|
250
|
+
(!Array.isArray(item.additional_speed_tiers) ||
|
|
251
|
+
item.additional_speed_tiers.some((tier) => typeof tier !== "string")))
|
|
252
|
+
) {
|
|
253
|
+
throw new Error("The Codex model directory returned invalid service tiers.");
|
|
254
|
+
}
|
|
255
|
+
if (!hasTiers && !hasLegacy) {
|
|
256
|
+
models.set(item.slug, undefined);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const tiers = (item.service_tiers ?? []) as Array<{ id: string }>;
|
|
260
|
+
const legacy = (item.additional_speed_tiers ?? []) as string[];
|
|
261
|
+
models.set(item.slug, tiers.some((tier) => tier.id === "priority") || legacy.includes("fast"));
|
|
262
|
+
}
|
|
263
|
+
return models;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function modelIdentity(model: PiModel | undefined): string {
|
|
267
|
+
return model ? `${model.provider}/${model.id}` : "";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
271
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
272
|
+
}
|
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
|
+
type CodexFastCapability,
|
|
3
4
|
codexFastAvailability,
|
|
4
5
|
codexFastIsEffective,
|
|
5
6
|
codexFastStatusLabel,
|
|
6
7
|
correctCodexFastMessageCost,
|
|
8
|
+
isOfficialCodexModel,
|
|
7
9
|
rewriteCodexFastPayload,
|
|
10
|
+
UNKNOWN_CODEX_FAST_CAPABILITY,
|
|
8
11
|
} from "./codex-fast.js";
|
|
12
|
+
import { createCodexFastCatalog } from "./codex-fast-catalog.js";
|
|
9
13
|
import { errorMessage } from "./core.js";
|
|
14
|
+
import {
|
|
15
|
+
createOAuthCredentialCandidateReader,
|
|
16
|
+
type OAuthCredentialCandidateReader,
|
|
17
|
+
type StoredCredentialReader,
|
|
18
|
+
} from "./oauth-credential-source.js";
|
|
10
19
|
import { isStaleExtensionContextError } from "./query.js";
|
|
11
20
|
import type { UsageSettingsRuntime, UsageSettingsState } from "./settings.js";
|
|
12
21
|
import type { PiModel } from "./types.js";
|
|
13
22
|
|
|
14
23
|
const NO_FAST_REQUEST = Symbol("no-fast-request");
|
|
15
|
-
type PendingFastRequest = { fastRequested: boolean; model: PiModel };
|
|
24
|
+
type PendingFastRequest = { fastRequested: boolean; model: PiModel; capability: CodexFastCapability };
|
|
16
25
|
|
|
17
26
|
export const FAST_USAGE_WARNING = "Fast is about 1.5× faster and uses more of your plan allowance.";
|
|
18
27
|
|
|
@@ -20,11 +29,29 @@ export function registerCodexFastMode(
|
|
|
20
29
|
pi: ExtensionAPI,
|
|
21
30
|
settingsRuntime: UsageSettingsRuntime,
|
|
22
31
|
refreshStatus: (ctx: ExtensionContext) => void,
|
|
23
|
-
options: {
|
|
32
|
+
options: {
|
|
33
|
+
registerSessionStart?: boolean;
|
|
34
|
+
credentialReader?: StoredCredentialReader;
|
|
35
|
+
candidateReader?: OAuthCredentialCandidateReader;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
} = {},
|
|
24
38
|
) {
|
|
25
39
|
let sessionController = new AbortController();
|
|
26
40
|
let generation = 0;
|
|
27
41
|
const pendingFastRequests = new Map<string, PendingFastRequest>();
|
|
42
|
+
const catalog = createCodexFastCatalog({
|
|
43
|
+
credentialReader: options.credentialReader,
|
|
44
|
+
candidateReader: options.candidateReader ?? createOAuthCredentialCandidateReader(pi, options.credentialReader),
|
|
45
|
+
timeoutMs: options.timeoutMs,
|
|
46
|
+
onChanged(ctx) {
|
|
47
|
+
if (sessionController.signal.aborted || !settingsRuntime.get().settings.codexFastMode) return;
|
|
48
|
+
try {
|
|
49
|
+
refreshStatus(ctx);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (!isStaleExtensionContextError(error)) throw error;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
});
|
|
28
55
|
|
|
29
56
|
const toggle = async (
|
|
30
57
|
ctx: ExtensionCommandContext,
|
|
@@ -34,6 +61,39 @@ export function registerCodexFastMode(
|
|
|
34
61
|
const ownerGeneration = generation;
|
|
35
62
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
36
63
|
const signal = callerSignal ? AbortSignal.any([callerSignal, sessionController.signal]) : sessionController.signal;
|
|
64
|
+
if (ctx.model?.provider !== "openai-codex") {
|
|
65
|
+
ctx.ui.notify("/fast is available only for the active OpenAI Codex model.", "warning");
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (!isOfficialCodexModel(ctx.model)) {
|
|
69
|
+
ctx.ui.notify("Fast mode requires the official OpenAI Codex Responses endpoint.", "warning");
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
if (settingsRuntime.get().kind === "invalid") {
|
|
73
|
+
ctx.ui.notify("pi-usage.json is invalid; repair it and run /reload before changing Fast mode.", "error");
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (enabled) {
|
|
77
|
+
const capability = await catalog.read(ctx, signal, true, catalog.get(ctx.model).kind !== "supported");
|
|
78
|
+
if (signal.aborted || ownerGeneration !== generation) return false;
|
|
79
|
+
if (!capability) {
|
|
80
|
+
const latest = catalog.get(ctx.model);
|
|
81
|
+
ctx.ui.notify(
|
|
82
|
+
latest.kind === "unknown"
|
|
83
|
+
? latest.reason
|
|
84
|
+
: "The active Codex account changed while checking Fast capability.",
|
|
85
|
+
"warning",
|
|
86
|
+
);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const availability = codexFastAvailability(ctx.model, false, capability);
|
|
90
|
+
if (availability.kind !== "available") {
|
|
91
|
+
if (availability.kind === "unknown" || availability.kind === "unavailable") {
|
|
92
|
+
ctx.ui.notify(availability.reason, "warning");
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
37
97
|
try {
|
|
38
98
|
await settingsRuntime.update({ codexFastMode: enabled }, signal);
|
|
39
99
|
} catch (error) {
|
|
@@ -63,27 +123,20 @@ export function registerCodexFastMode(
|
|
|
63
123
|
return;
|
|
64
124
|
}
|
|
65
125
|
if (!ctx.hasUI) throw new Error("/fast requires TUI or RPC mode.");
|
|
66
|
-
|
|
67
|
-
if (availability.kind === "not-codex") {
|
|
68
|
-
ctx.ui.notify("/fast is available only for the active OpenAI Codex model.", "warning");
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
if (availability.kind === "unavailable") {
|
|
72
|
-
ctx.ui.notify(availability.reason, "warning");
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
if (settingsRuntime.get().kind === "invalid") {
|
|
76
|
-
ctx.ui.notify("pi-usage.json is invalid; repair it and run /reload before changing Fast mode.", "error");
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
await toggle(ctx, !availability.enabled);
|
|
126
|
+
await toggle(ctx, !settingsRuntime.get().settings.codexFastMode);
|
|
80
127
|
},
|
|
81
128
|
});
|
|
82
129
|
|
|
130
|
+
const refreshCapabilities = (ctx: ExtensionContext, callerSignal?: AbortSignal, force = false) => {
|
|
131
|
+
const signal = callerSignal ? AbortSignal.any([callerSignal, sessionController.signal]) : sessionController.signal;
|
|
132
|
+
return catalog.read(ctx, signal, true, force);
|
|
133
|
+
};
|
|
134
|
+
|
|
83
135
|
const prepareSession = (ctx: ExtensionContext): Promise<void> => {
|
|
84
136
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
85
137
|
generation += 1;
|
|
86
138
|
sessionController.abort();
|
|
139
|
+
catalog.reset();
|
|
87
140
|
pendingFastRequests.clear();
|
|
88
141
|
sessionController = new AbortController();
|
|
89
142
|
const ownerGeneration = generation;
|
|
@@ -109,20 +162,33 @@ export function registerCodexFastMode(
|
|
|
109
162
|
ctx.ui.notify(`Invalid pi-usage.json; using defaults without overwriting it. ${state.issue}`, "warning");
|
|
110
163
|
}
|
|
111
164
|
refreshStatus(ctx);
|
|
165
|
+
if (state.kind !== "invalid" && isOfficialCodexModel(ctx.model)) void refreshCapabilities(ctx);
|
|
112
166
|
})();
|
|
113
167
|
};
|
|
114
168
|
|
|
115
169
|
if (options.registerSessionStart !== false) {
|
|
116
170
|
pi.on("session_start", async (_event, ctx) => prepareSession(ctx));
|
|
117
171
|
}
|
|
172
|
+
pi.on("model_select", (_event, ctx) => {
|
|
173
|
+
void refreshCapabilities(ctx);
|
|
174
|
+
});
|
|
118
175
|
|
|
119
|
-
pi.on("before_provider_request", (event, ctx) => {
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
|
|
176
|
+
pi.on("before_provider_request", async (event, ctx) => {
|
|
177
|
+
const ownerGeneration = generation;
|
|
178
|
+
const model = ctx.model;
|
|
179
|
+
const enabled = settingsRuntime.get().settings.codexFastMode;
|
|
180
|
+
const capability =
|
|
181
|
+
enabled && isOfficialCodexModel(model)
|
|
182
|
+
? ((await catalog.read(ctx, sessionController.signal)) ?? UNKNOWN_CODEX_FAST_CAPABILITY)
|
|
183
|
+
: UNKNOWN_CODEX_FAST_CAPABILITY;
|
|
184
|
+
if (sessionController.signal.aborted || ownerGeneration !== generation) return undefined;
|
|
185
|
+
const rewritten = rewriteCodexFastPayload(event.payload, model, enabled, capability);
|
|
186
|
+
const key = activeRequestKey(ctx, model);
|
|
187
|
+
if (key && model) {
|
|
123
188
|
pendingFastRequests.set(key, {
|
|
124
189
|
fastRequested: isRecord(rewritten) && rewritten.service_tier === "priority",
|
|
125
|
-
model
|
|
190
|
+
model,
|
|
191
|
+
capability,
|
|
126
192
|
});
|
|
127
193
|
}
|
|
128
194
|
return rewritten;
|
|
@@ -130,30 +196,45 @@ export function registerCodexFastMode(
|
|
|
130
196
|
pi.on("message_end", (event, ctx) => {
|
|
131
197
|
const request = consumeFastRequest(ctx, event.message, pendingFastRequests);
|
|
132
198
|
if (request === NO_FAST_REQUEST) return undefined;
|
|
133
|
-
const message = correctCodexFastMessageCost(
|
|
199
|
+
const message = correctCodexFastMessageCost(
|
|
200
|
+
event.message,
|
|
201
|
+
request.model,
|
|
202
|
+
request.fastRequested,
|
|
203
|
+
request.capability,
|
|
204
|
+
);
|
|
134
205
|
return message ? { message: message as never } : undefined;
|
|
135
206
|
});
|
|
136
207
|
pi.on("session_shutdown", async () => {
|
|
137
208
|
generation += 1;
|
|
138
209
|
sessionController.abort();
|
|
210
|
+
catalog.reset(true);
|
|
139
211
|
pendingFastRequests.clear();
|
|
140
212
|
await settingsRuntime.flush();
|
|
141
213
|
});
|
|
142
214
|
|
|
143
215
|
return {
|
|
144
216
|
prepareSession,
|
|
217
|
+
refreshCapabilities,
|
|
145
218
|
availability(model: PiModel | undefined) {
|
|
146
|
-
return codexFastAvailability(model, settingsRuntime.get().settings.codexFastMode);
|
|
219
|
+
return codexFastAvailability(model, settingsRuntime.get().settings.codexFastMode, catalog.get(model));
|
|
147
220
|
},
|
|
148
221
|
decorateStatus(model: PiModel | undefined, status: string) {
|
|
149
|
-
|
|
222
|
+
const enabled = settingsRuntime.get().settings.codexFastMode;
|
|
223
|
+
const capability = catalog.get(model);
|
|
224
|
+
if (
|
|
225
|
+
enabled &&
|
|
226
|
+
codexFastAvailability(model, enabled, capability).kind === "unknown" &&
|
|
227
|
+
/^codex(?:\s|$)/u.test(status)
|
|
228
|
+
) {
|
|
229
|
+
return `codex (Fast unknown)${status.slice("codex".length)}`;
|
|
230
|
+
}
|
|
231
|
+
return codexFastStatusLabel(status, codexFastIsEffective(model, enabled, capability));
|
|
150
232
|
},
|
|
151
233
|
toggle,
|
|
152
234
|
};
|
|
153
235
|
}
|
|
154
236
|
|
|
155
|
-
function activeRequestKey(ctx: ExtensionContext): string | undefined {
|
|
156
|
-
const model = ctx.model;
|
|
237
|
+
function activeRequestKey(ctx: ExtensionContext, model = ctx.model): string | undefined {
|
|
157
238
|
return model ? `${ctx.sessionManager.getSessionId()}:${model.provider}/${model.id}` : undefined;
|
|
158
239
|
}
|
|
159
240
|
|
package/src/codex-fast.ts
CHANGED
|
@@ -4,23 +4,24 @@ import type { PiModel } from "./types.js";
|
|
|
4
4
|
export const CODEX_FAST_SERVICE_TIER = "priority";
|
|
5
5
|
export const CODEX_STANDARD_SERVICE_TIER = "default";
|
|
6
6
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
"gpt-6-luna",
|
|
14
|
-
"gpt-6-sol",
|
|
15
|
-
"gpt-6.1-sol",
|
|
16
|
-
]);
|
|
7
|
+
export type CodexFastCapability = { kind: "supported" } | { kind: "unsupported" } | { kind: "unknown"; reason: string };
|
|
8
|
+
|
|
9
|
+
export const UNKNOWN_CODEX_FAST_CAPABILITY = {
|
|
10
|
+
kind: "unknown",
|
|
11
|
+
reason: "Codex Fast capability information has not been fetched.",
|
|
12
|
+
} as const;
|
|
17
13
|
|
|
18
14
|
export type CodexFastAvailability =
|
|
19
15
|
| { kind: "available"; enabled: boolean }
|
|
20
16
|
| { kind: "not-codex" }
|
|
21
|
-
| { kind: "unavailable"; reason: string }
|
|
17
|
+
| { kind: "unavailable"; reason: string }
|
|
18
|
+
| { kind: "unknown"; reason: string };
|
|
22
19
|
|
|
23
|
-
export function codexFastAvailability(
|
|
20
|
+
export function codexFastAvailability(
|
|
21
|
+
model: PiModel | undefined,
|
|
22
|
+
enabled: boolean,
|
|
23
|
+
capability: CodexFastCapability = UNKNOWN_CODEX_FAST_CAPABILITY,
|
|
24
|
+
): CodexFastAvailability {
|
|
24
25
|
if (model?.provider !== "openai-codex") return { kind: "not-codex" };
|
|
25
26
|
if (!isOfficialCodexModel(model)) {
|
|
26
27
|
return {
|
|
@@ -28,7 +29,8 @@ export function codexFastAvailability(model: PiModel | undefined, enabled: boole
|
|
|
28
29
|
reason: "Fast mode requires the official OpenAI Codex Responses endpoint.",
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
|
-
if (
|
|
32
|
+
if (capability.kind === "unknown") return capability;
|
|
33
|
+
if (capability.kind === "unsupported") {
|
|
32
34
|
return {
|
|
33
35
|
kind: "unavailable",
|
|
34
36
|
reason: `${model.id} does not advertise Codex Fast support.`,
|
|
@@ -37,24 +39,30 @@ export function codexFastAvailability(model: PiModel | undefined, enabled: boole
|
|
|
37
39
|
return { kind: "available", enabled };
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
export function codexFastIsEffective(
|
|
41
|
-
|
|
42
|
+
export function codexFastIsEffective(
|
|
43
|
+
model: PiModel | undefined,
|
|
44
|
+
enabled: boolean,
|
|
45
|
+
capability: CodexFastCapability = UNKNOWN_CODEX_FAST_CAPABILITY,
|
|
46
|
+
): boolean {
|
|
47
|
+
return codexFastAvailability(model, enabled, capability).kind === "available" && enabled;
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export function codexFastRequestTier(
|
|
45
51
|
model: PiModel | undefined,
|
|
46
52
|
enabled: boolean,
|
|
53
|
+
capability: CodexFastCapability = UNKNOWN_CODEX_FAST_CAPABILITY,
|
|
47
54
|
): typeof CODEX_FAST_SERVICE_TIER | typeof CODEX_STANDARD_SERVICE_TIER | undefined {
|
|
48
55
|
if (!isOfficialCodexModel(model)) return undefined;
|
|
49
|
-
return enabled &&
|
|
56
|
+
return enabled && capability.kind === "supported" ? CODEX_FAST_SERVICE_TIER : CODEX_STANDARD_SERVICE_TIER;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
export function rewriteCodexFastPayload(
|
|
53
60
|
payload: unknown,
|
|
54
61
|
model: PiModel | undefined,
|
|
55
62
|
enabled: boolean,
|
|
63
|
+
capability: CodexFastCapability = UNKNOWN_CODEX_FAST_CAPABILITY,
|
|
56
64
|
): unknown | undefined {
|
|
57
|
-
const serviceTier = codexFastRequestTier(model, enabled);
|
|
65
|
+
const serviceTier = codexFastRequestTier(model, enabled, capability);
|
|
58
66
|
if (!serviceTier || !isRecord(payload)) return undefined;
|
|
59
67
|
return { ...payload, service_tier: serviceTier };
|
|
60
68
|
}
|
|
@@ -63,9 +71,10 @@ export function correctCodexFastMessageCost(
|
|
|
63
71
|
message: unknown,
|
|
64
72
|
model: PiModel | undefined,
|
|
65
73
|
fastRequested: boolean,
|
|
74
|
+
capability: CodexFastCapability = UNKNOWN_CODEX_FAST_CAPABILITY,
|
|
66
75
|
): unknown | undefined {
|
|
67
76
|
if (
|
|
68
|
-
!codexFastIsEffective(model, fastRequested) ||
|
|
77
|
+
!codexFastIsEffective(model, fastRequested, capability) ||
|
|
69
78
|
!isRecord(message) ||
|
|
70
79
|
message.role !== "assistant" ||
|
|
71
80
|
message.provider !== model?.provider ||
|
|
@@ -92,7 +101,7 @@ export function codexFastStatusLabel(status: string, enabled: boolean): string {
|
|
|
92
101
|
return status === "codex" ? "codex fast" : `codex fast${status.slice("codex".length)}`;
|
|
93
102
|
}
|
|
94
103
|
|
|
95
|
-
function isOfficialCodexModel(model: PiModel | undefined): model is PiModel & { api: "openai-codex-responses" } {
|
|
104
|
+
export function isOfficialCodexModel(model: PiModel | undefined): model is PiModel & { api: "openai-codex-responses" } {
|
|
96
105
|
if (model?.provider !== "openai-codex" || !hasApi(model, "openai-codex-responses")) {
|
|
97
106
|
return false;
|
|
98
107
|
}
|