@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/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 the legacy `5h` and `wk` labels.
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 99% 5h 96% wk`, `kimi 56% 5h 21% mo`, or `kimi 95% 1d`
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% 5h 80% wk` or `minimax USD 98.00001`
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%`, `copilot 245/300 82%`, or `copilot chat 40/50 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% r 4% w 2% m`
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% 5h 92% wk 96% mo`
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% 5h 76% wk`; monthly MCP details remain available through `/usage`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-usage",
3
- "version": "0.64.3",
3
+ "version": "0.65.1",
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",
@@ -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: { registerSessionStart?: boolean } = {},
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
- const availability = codexFastAvailability(ctx.model, settingsRuntime.get().settings.codexFastMode);
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 rewritten = rewriteCodexFastPayload(event.payload, ctx.model, settingsRuntime.get().settings.codexFastMode);
121
- const key = activeRequestKey(ctx);
122
- if (key && ctx.model) {
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: ctx.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(event.message, request.model, request.fastRequested);
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
- return codexFastStatusLabel(status, codexFastIsEffective(model, settingsRuntime.get().settings.codexFastMode));
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 const CODEX_FAST_MODEL_IDS: ReadonlySet<string> = new Set([
8
- "gpt-5.5",
9
- "gpt-5.6-luna",
10
- "gpt-5.6-sol",
11
- "gpt-5.6-terra",
12
- "gpt-6-astra",
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(model: PiModel | undefined, enabled: boolean): 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 (!CODEX_FAST_MODEL_IDS.has(model.id)) {
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(model: PiModel | undefined, enabled: boolean): boolean {
41
- return codexFastAvailability(model, enabled).kind === "available" && enabled;
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 && CODEX_FAST_MODEL_IDS.has(model.id) ? CODEX_FAST_SERVICE_TIER : CODEX_STANDARD_SERVICE_TIER;
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
  }