@narumitw/pi-usage 0.49.3 → 0.51.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 +57 -9
- package/package.json +8 -3
- package/src/codex-fast-runtime.ts +212 -0
- package/src/codex-fast.ts +124 -0
- package/src/codex-resets.ts +308 -0
- package/src/index.ts +35 -0
- package/src/query.ts +14 -2
- package/src/settings.ts +208 -0
- package/src/usage-helpers.ts +40 -0
- package/src/usage.ts +374 -81
package/README.md
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@narumitw/pi-usage) [](https://pi.dev) [](./LICENSE)
|
|
4
4
|
|
|
5
|
-
`@narumitw/pi-usage` is a native [Pi coding agent](https://pi.dev) extension that adds
|
|
5
|
+
`@narumitw/pi-usage` is a native [Pi coding agent](https://pi.dev) extension that adds an interactive `/usage` command for reading usage from the account Pi is actually using and a `/fast` shortcut for supported OpenAI Codex models. It supports Codex ChatGPT subscription windows, GitHub Copilot allowances, and OpenRouter API-key spend limits without pretending those limits have the same semantics.
|
|
6
6
|
|
|
7
7
|
## ✨ Features
|
|
8
8
|
|
|
9
9
|
- Opens one interactive `/usage` menu with current state and next actions.
|
|
10
10
|
- Automatically queries the selected model provider and active runtime account.
|
|
11
11
|
- Supports OpenAI Codex subscription windows, resets, credits, and model-specific buckets.
|
|
12
|
+
- Toggles persistent Codex Fast routing through `/fast` or the contextual `/usage` action.
|
|
13
|
+
- Redeems earned Codex usage-limit resets for the active, matching Pi OAuth account with fresh availability, explicit confirmation, and idempotent retry.
|
|
12
14
|
- Supports GitHub Copilot AI Credits, legacy premium requests, Free chat quota, additional usage, percentage, and reset time.
|
|
13
15
|
- Supports OpenRouter per-key credit limits plus daily, weekly, monthly, and all-time spend.
|
|
14
16
|
- Provides explicit refresh, another-provider, and all-configured-provider actions.
|
|
@@ -35,7 +37,7 @@ pi -e npm:@narumitw/pi-usage
|
|
|
35
37
|
Try this package locally from the repository root:
|
|
36
38
|
|
|
37
39
|
```bash
|
|
38
|
-
pi -e ./
|
|
40
|
+
pi -e ./packages/pi-usage
|
|
39
41
|
```
|
|
40
42
|
|
|
41
43
|
## 🚀 Usage
|
|
@@ -51,6 +53,8 @@ with these actions:
|
|
|
51
53
|
|
|
52
54
|
```text
|
|
53
55
|
Refresh current usage
|
|
56
|
+
Turn Fast mode on/off # Supported current Codex models only
|
|
57
|
+
Redeem usage limit reset… # Current Codex OAuth accounts only
|
|
54
58
|
View another configured provider…
|
|
55
59
|
View all configured providers…
|
|
56
60
|
Close
|
|
@@ -62,18 +66,54 @@ and closes the root menu. Print and JSON modes reject `/usage` observably becaus
|
|
|
62
66
|
interactive flow. The cancellable live-query progress view remains extension-owned because it streams
|
|
63
67
|
provider work and supports in-flight abort rather than presenting a standard menu screen.
|
|
64
68
|
|
|
69
|
+
For the current OpenAI Codex provider, **Redeem usage limit reset…** checks fresh earned-reset
|
|
70
|
+
details, lets you select a reset when details are available, and shows the exact reset before asking
|
|
71
|
+
for confirmation. **No, go back** is the safe default and cancellation before confirmation sends no
|
|
72
|
+
mutation. After confirmation, the reset operation cannot be cancelled from its progress view; session
|
|
73
|
+
replacement or shutdown still aborts owned work. A transport failure offers **Try again** with the
|
|
74
|
+
same redemption request ID so the backend can treat an uncertain retry idempotently. Successful,
|
|
75
|
+
already-completed, not-needed, and no-credit outcomes are reported separately, then usage and the
|
|
76
|
+
statusline are refreshed for the still-current account.
|
|
77
|
+
|
|
78
|
+
### Codex Fast mode
|
|
79
|
+
|
|
80
|
+
Run bare `/fast` to toggle Fast for the active supported Codex model, or use **Turn Fast mode on/off** in `/usage`.
|
|
81
|
+
|
|
82
|
+
Fast is about 1.5× faster and uses more of your plan allowance.
|
|
83
|
+
The preference defaults to Off and is saved as `codexFastMode` in Pi's user agent directory as `pi-usage.json`, normally `~/.pi/agent/pi-usage.json`.
|
|
84
|
+
The extension reloads this file at every session start and does not create it until the first successful toggle.
|
|
85
|
+
|
|
86
|
+
Fast currently applies only to official `openai-codex-responses` requests for `gpt-5.4`, `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna` at `https://chatgpt.com`.
|
|
87
|
+
It sends `service_tier: "priority"` while enabled and explicit `service_tier: "default"` otherwise.
|
|
88
|
+
The statusline adds `fast` only while the preference is effective, for example `codex fast 59% 5h`.
|
|
89
|
+
Unsupported models and custom or proxy origins are left unchanged.
|
|
90
|
+
|
|
91
|
+
`/fast` supports TUI and RPC mode, accepts no arguments, and rejects print or JSON mode before mutation.
|
|
92
|
+
A toggle affects provider requests whose payload hook starts after the save; a request already sent is unchanged.
|
|
93
|
+
Settings operations are serialized inside one Pi process, but separate Pi processes are not mutually locked.
|
|
94
|
+
Unknown JSON fields are preserved, writes use a private temporary file plus rename, and a malformed or invalid file is never overwritten.
|
|
95
|
+
Repair or remove an invalid file, then run `/reload` before trying the toggle again.
|
|
96
|
+
|
|
65
97
|
## 📋 Provider semantics
|
|
66
98
|
|
|
67
99
|
### OpenAI Codex
|
|
68
100
|
|
|
69
101
|
- Provider ID: `openai-codex`
|
|
70
102
|
- Semantics: ChatGPT consumer subscription limits
|
|
71
|
-
- Source: the Codex usage
|
|
103
|
+
- Source: the Codex usage and earned-reset endpoints using Pi's resolved runtime authorization
|
|
72
104
|
- Displayed data: returned duration-based windows, resets, credits, earned usage-limit resets, and additional model buckets
|
|
73
|
-
-
|
|
105
|
+
- Reset mutation: `POST /wham/rate-limit-reset-credits/consume` with a unique redemption request ID and, when available, the selected opaque credit ID
|
|
106
|
+
- Statusline examples: `codex 59% 5h 61% wk`, `codex fast 59% 5h`, or `codex spark 100% 5h`
|
|
74
107
|
|
|
75
108
|
The statusline selects a returned bucket that matches the current Codex model when one is available. Unlike `pi-codex-usage`, this successor intentionally has no Codex CLI fallback because the CLI may be logged into a different account than Pi's active runtime account.
|
|
76
109
|
|
|
110
|
+
Reset redemption is available only when Codex is the current provider and Pi's freshly resolved access
|
|
111
|
+
token exactly matches its stored OpenAI Codex OAuth credential. `pi-usage` forwards only the bearer
|
|
112
|
+
authorization and matching `chatgpt-account-id` to the official ChatGPT origin. API-key credentials,
|
|
113
|
+
configured-but-not-current Codex accounts, account changes during the flow, and custom/proxy origins
|
|
114
|
+
fail before mutation. Backend-provided titles and descriptions are sanitized for terminal display;
|
|
115
|
+
opaque credit and account IDs are never shown or persisted by the extension.
|
|
116
|
+
|
|
77
117
|
### GitHub Copilot
|
|
78
118
|
|
|
79
119
|
- Provider ID: `github-copilot`
|
|
@@ -119,7 +159,7 @@ Remove the deprecated package rather than loading both usage extensions together
|
|
|
119
159
|
|
|
120
160
|
Behavior changes:
|
|
121
161
|
|
|
122
|
-
- Use `/usage`
|
|
162
|
+
- Use `/usage` for usage management; `/codex-status` is no longer registered.
|
|
123
163
|
- Refresh and cross-provider operations are menu actions rather than flags.
|
|
124
164
|
- Codex CLI fallback is removed to preserve active-runtime-account correctness.
|
|
125
165
|
- The status key changes from `codex-usage` to `usage`.
|
|
@@ -127,21 +167,29 @@ Behavior changes:
|
|
|
127
167
|
## 🚧 Limitations
|
|
128
168
|
|
|
129
169
|
- Only providers with a meaningful usage source and verifiable Pi runtime auth are supported.
|
|
130
|
-
- GitHub Copilot quota
|
|
170
|
+
- GitHub Copilot quota and OpenAI Codex reset redemption use undocumented provider endpoints that may change without notice.
|
|
171
|
+
- Codex reset redemption requires a current ChatGPT OAuth login created through Pi; Codex API keys cannot redeem earned subscription resets.
|
|
131
172
|
- Credentials resolved for custom provider base URLs are never forwarded to the providers' official usage endpoints; effective auth origin validation requires Pi 0.81.0 or newer.
|
|
132
173
|
- Provider reports are snapshots and may themselves be delayed by the provider.
|
|
133
174
|
- OpenRouter successful inference responses do not expose proactive request-rate counters; `/usage` reports the documented per-key credit/spend fields instead.
|
|
134
175
|
- A provider may not return a safe human-readable account identity. In that case the provider and runtime credential state remain visible without exposing secrets.
|
|
135
176
|
- Immediate account-change events are not available from Pi; auth is re-resolved before commands, turns, and scheduled refreshes.
|
|
177
|
+
- Fast model support is intentionally conservative and may require an extension update when Codex adds or removes service tiers.
|
|
178
|
+
- Another later-loaded extension can replace the final provider payload, so arbitrary third-party payload-rewrite conflicts cannot be prevented.
|
|
136
179
|
|
|
137
180
|
## 🗂️ Package layout
|
|
138
181
|
|
|
139
182
|
```txt
|
|
140
|
-
|
|
183
|
+
packages/pi-usage/
|
|
141
184
|
├── src/
|
|
142
185
|
│ ├── index.ts # Pi package entrypoint and helper export barrel
|
|
143
|
-
│ ├── usage.ts # Menu, cache, and lifecycle orchestration
|
|
144
|
-
│ ├──
|
|
186
|
+
│ ├── usage.ts # Menu, cache, and usage lifecycle orchestration
|
|
187
|
+
│ ├── codex-fast.ts # Fast eligibility, request tier, and cost correction
|
|
188
|
+
│ ├── codex-fast-runtime.ts # Fast command, persistence lifecycle, and request hooks
|
|
189
|
+
│ ├── settings.ts # Validated user settings and atomic persistence
|
|
190
|
+
│ ├── usage-helpers.ts # Small orchestration helpers
|
|
191
|
+
│ ├── query.ts # Runtime auth resolution and bounded provider queries
|
|
192
|
+
│ ├── codex-resets.ts # Codex reset auth, API contracts, and normalization
|
|
145
193
|
│ ├── format.ts # Provider-aware notifications and statusline text
|
|
146
194
|
│ ├── core.ts # Cache, concurrency, fingerprint, and redaction helpers
|
|
147
195
|
│ ├── providers/ # Codex, GitHub Copilot, and OpenRouter normalization adapters
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-usage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.0",
|
|
4
4
|
"description": "Pi extension that shows current-account usage for Codex, GitHub Copilot, and OpenRouter.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,24 +25,29 @@
|
|
|
25
25
|
"./src/index.ts"
|
|
26
26
|
]
|
|
27
27
|
},
|
|
28
|
+
"piExtension": {
|
|
29
|
+
"lifecycle": "stable"
|
|
30
|
+
},
|
|
28
31
|
"scripts": {
|
|
29
32
|
"check": "biome check --vcs-use-ignore-file=false src test package.json tsconfig.json README.md && npm run typecheck",
|
|
30
33
|
"format": "biome check --write --vcs-use-ignore-file=false src test package.json tsconfig.json README.md",
|
|
31
34
|
"typecheck": "tsc --noEmit"
|
|
32
35
|
},
|
|
33
36
|
"peerDependencies": {
|
|
37
|
+
"@earendil-works/pi-ai": "*",
|
|
34
38
|
"@earendil-works/pi-coding-agent": "*"
|
|
35
39
|
},
|
|
36
40
|
"devDependencies": {
|
|
37
41
|
"@biomejs/biome": "2.5.7",
|
|
38
|
-
"@earendil-works/pi-
|
|
42
|
+
"@earendil-works/pi-ai": "0.84.1",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "0.84.1",
|
|
39
44
|
"@types/node": "26.1.2",
|
|
40
45
|
"typescript": "7.0.2"
|
|
41
46
|
},
|
|
42
47
|
"repository": {
|
|
43
48
|
"type": "git",
|
|
44
49
|
"url": "https://github.com/narumiruna/pi-extensions",
|
|
45
|
-
"directory": "
|
|
50
|
+
"directory": "packages/pi-usage"
|
|
46
51
|
},
|
|
47
52
|
"dependencies": {
|
|
48
53
|
"@narumitw/pi-tui-kit": "^0.49.1"
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
codexFastAvailability,
|
|
8
|
+
codexFastIsEffective,
|
|
9
|
+
codexFastStatusLabel,
|
|
10
|
+
correctCodexFastMessageCost,
|
|
11
|
+
rewriteCodexFastPayload,
|
|
12
|
+
} from "./codex-fast.js";
|
|
13
|
+
import { errorMessage } from "./core.js";
|
|
14
|
+
import { isStaleExtensionContextError } from "./query.js";
|
|
15
|
+
import type { UsageSettingsRuntime, UsageSettingsState } from "./settings.js";
|
|
16
|
+
import type { PiModel } from "./types.js";
|
|
17
|
+
|
|
18
|
+
const NO_FAST_REQUEST = Symbol("no-fast-request");
|
|
19
|
+
type PendingFastRequest = { fastRequested: boolean; model: PiModel };
|
|
20
|
+
|
|
21
|
+
export const FAST_USAGE_WARNING = "Fast is about 1.5× faster and uses more of your plan allowance.";
|
|
22
|
+
|
|
23
|
+
export function registerCodexFastMode(
|
|
24
|
+
pi: ExtensionAPI,
|
|
25
|
+
settingsRuntime: UsageSettingsRuntime,
|
|
26
|
+
refreshStatus: (ctx: ExtensionContext) => void,
|
|
27
|
+
) {
|
|
28
|
+
let sessionController = new AbortController();
|
|
29
|
+
let generation = 0;
|
|
30
|
+
const pendingFastRequests = new Map<string, PendingFastRequest>();
|
|
31
|
+
|
|
32
|
+
const toggle = async (
|
|
33
|
+
ctx: ExtensionCommandContext,
|
|
34
|
+
enabled: boolean,
|
|
35
|
+
callerSignal?: AbortSignal,
|
|
36
|
+
): Promise<boolean> => {
|
|
37
|
+
const ownerGeneration = generation;
|
|
38
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
39
|
+
const signal = callerSignal
|
|
40
|
+
? AbortSignal.any([callerSignal, sessionController.signal])
|
|
41
|
+
: sessionController.signal;
|
|
42
|
+
try {
|
|
43
|
+
await settingsRuntime.update({ codexFastMode: enabled }, signal);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (isAbortError(error) || isStaleExtensionContextError(error)) return false;
|
|
46
|
+
ctx.ui.notify(`Could not save pi-usage.json: ${errorMessage(error)}`, "error");
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
signal.aborted ||
|
|
51
|
+
ownerGeneration !== generation ||
|
|
52
|
+
ctx.sessionManager.getSessionId() !== sessionId
|
|
53
|
+
) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
refreshStatus(ctx);
|
|
57
|
+
ctx.ui.notify(
|
|
58
|
+
enabled
|
|
59
|
+
? `Codex Fast mode enabled. ${FAST_USAGE_WARNING}`
|
|
60
|
+
: "Codex Fast mode disabled; standard routing will be used.",
|
|
61
|
+
"info",
|
|
62
|
+
);
|
|
63
|
+
return true;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
pi.registerCommand("fast", {
|
|
67
|
+
description: "Toggle Codex Fast mode",
|
|
68
|
+
handler: async (args, ctx) => {
|
|
69
|
+
if (args.trim()) {
|
|
70
|
+
if (!ctx.hasUI) throw new Error("/fast does not accept arguments.");
|
|
71
|
+
ctx.ui.notify("/fast does not accept arguments.", "warning");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!ctx.hasUI) throw new Error("/fast requires TUI or RPC mode.");
|
|
75
|
+
const availability = codexFastAvailability(
|
|
76
|
+
ctx.model,
|
|
77
|
+
settingsRuntime.get().settings.codexFastMode,
|
|
78
|
+
);
|
|
79
|
+
if (availability.kind === "not-codex") {
|
|
80
|
+
ctx.ui.notify("/fast is available only for the active OpenAI Codex model.", "warning");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (availability.kind === "unavailable") {
|
|
84
|
+
ctx.ui.notify(availability.reason, "warning");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (settingsRuntime.get().kind === "invalid") {
|
|
88
|
+
ctx.ui.notify(
|
|
89
|
+
"pi-usage.json is invalid; repair it and run /reload before changing Fast mode.",
|
|
90
|
+
"error",
|
|
91
|
+
);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
await toggle(ctx, !availability.enabled);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
99
|
+
generation += 1;
|
|
100
|
+
sessionController.abort();
|
|
101
|
+
pendingFastRequests.clear();
|
|
102
|
+
sessionController = new AbortController();
|
|
103
|
+
const ownerGeneration = generation;
|
|
104
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
105
|
+
let state: Readonly<UsageSettingsState>;
|
|
106
|
+
try {
|
|
107
|
+
state = await settingsRuntime.reload(sessionController.signal);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (sessionController.signal.aborted || ownerGeneration !== generation) return;
|
|
110
|
+
if (ctx.hasUI) {
|
|
111
|
+
ctx.ui.notify(
|
|
112
|
+
`Could not load pi-usage.json; using defaults. ${errorMessage(error)}`,
|
|
113
|
+
"warning",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
sessionController.signal.aborted ||
|
|
120
|
+
ownerGeneration !== generation ||
|
|
121
|
+
ctx.sessionManager.getSessionId() !== sessionId
|
|
122
|
+
) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (ctx.hasUI && state.kind === "invalid") {
|
|
126
|
+
ctx.ui.notify(
|
|
127
|
+
`Invalid pi-usage.json; using defaults without overwriting it. ${state.issue}`,
|
|
128
|
+
"warning",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
refreshStatus(ctx);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
135
|
+
const rewritten = rewriteCodexFastPayload(
|
|
136
|
+
event.payload,
|
|
137
|
+
ctx.model,
|
|
138
|
+
settingsRuntime.get().settings.codexFastMode,
|
|
139
|
+
);
|
|
140
|
+
const key = activeRequestKey(ctx);
|
|
141
|
+
if (key && ctx.model) {
|
|
142
|
+
pendingFastRequests.set(key, {
|
|
143
|
+
fastRequested: isRecord(rewritten) && rewritten.service_tier === "priority",
|
|
144
|
+
model: ctx.model,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return rewritten;
|
|
148
|
+
});
|
|
149
|
+
pi.on("message_end", (event, ctx) => {
|
|
150
|
+
const request = consumeFastRequest(ctx, event.message, pendingFastRequests);
|
|
151
|
+
if (request === NO_FAST_REQUEST) return undefined;
|
|
152
|
+
const message = correctCodexFastMessageCost(
|
|
153
|
+
event.message,
|
|
154
|
+
request.model,
|
|
155
|
+
request.fastRequested,
|
|
156
|
+
);
|
|
157
|
+
return message ? { message: message as never } : undefined;
|
|
158
|
+
});
|
|
159
|
+
pi.on("session_shutdown", async () => {
|
|
160
|
+
generation += 1;
|
|
161
|
+
sessionController.abort();
|
|
162
|
+
pendingFastRequests.clear();
|
|
163
|
+
await settingsRuntime.flush();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
availability(model: PiModel | undefined) {
|
|
168
|
+
return codexFastAvailability(model, settingsRuntime.get().settings.codexFastMode);
|
|
169
|
+
},
|
|
170
|
+
decorateStatus(model: PiModel | undefined, status: string) {
|
|
171
|
+
return codexFastStatusLabel(
|
|
172
|
+
status,
|
|
173
|
+
codexFastIsEffective(model, settingsRuntime.get().settings.codexFastMode),
|
|
174
|
+
);
|
|
175
|
+
},
|
|
176
|
+
toggle,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function activeRequestKey(ctx: ExtensionContext): string | undefined {
|
|
181
|
+
const model = ctx.model;
|
|
182
|
+
return model ? `${ctx.sessionManager.getSessionId()}:${model.provider}/${model.id}` : undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function consumeFastRequest(
|
|
186
|
+
ctx: ExtensionContext,
|
|
187
|
+
message: unknown,
|
|
188
|
+
pending: Map<string, PendingFastRequest>,
|
|
189
|
+
): PendingFastRequest | typeof NO_FAST_REQUEST {
|
|
190
|
+
if (!isRecord(message) || message.role !== "assistant") return NO_FAST_REQUEST;
|
|
191
|
+
const key = messageRequestKey(ctx, message);
|
|
192
|
+
if (!key) return NO_FAST_REQUEST;
|
|
193
|
+
const request = pending.get(key);
|
|
194
|
+
pending.delete(key);
|
|
195
|
+
return request ?? NO_FAST_REQUEST;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function messageRequestKey(
|
|
199
|
+
ctx: ExtensionContext,
|
|
200
|
+
message: Record<string, unknown>,
|
|
201
|
+
): string | undefined {
|
|
202
|
+
if (typeof message.provider !== "string" || typeof message.model !== "string") return undefined;
|
|
203
|
+
return `${ctx.sessionManager.getSessionId()}:${message.provider}/${message.model}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
207
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isAbortError(error: unknown): boolean {
|
|
211
|
+
return error instanceof Error && error.name === "AbortError";
|
|
212
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { calculateCost, hasApi } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { PiModel } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export const CODEX_FAST_SERVICE_TIER = "priority";
|
|
5
|
+
export const CODEX_STANDARD_SERVICE_TIER = "default";
|
|
6
|
+
|
|
7
|
+
export const CODEX_FAST_MODEL_IDS: ReadonlySet<string> = new Set([
|
|
8
|
+
"gpt-5.4",
|
|
9
|
+
"gpt-5.5",
|
|
10
|
+
"gpt-5.6-luna",
|
|
11
|
+
"gpt-5.6-sol",
|
|
12
|
+
"gpt-5.6-terra",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export type CodexFastAvailability =
|
|
16
|
+
| { kind: "available"; enabled: boolean }
|
|
17
|
+
| { kind: "not-codex" }
|
|
18
|
+
| { kind: "unavailable"; reason: string };
|
|
19
|
+
|
|
20
|
+
export function codexFastAvailability(
|
|
21
|
+
model: PiModel | undefined,
|
|
22
|
+
enabled: boolean,
|
|
23
|
+
): CodexFastAvailability {
|
|
24
|
+
if (model?.provider !== "openai-codex") return { kind: "not-codex" };
|
|
25
|
+
if (!isOfficialCodexModel(model)) {
|
|
26
|
+
return {
|
|
27
|
+
kind: "unavailable",
|
|
28
|
+
reason: "Fast mode requires the official OpenAI Codex Responses endpoint.",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (!CODEX_FAST_MODEL_IDS.has(model.id)) {
|
|
32
|
+
return {
|
|
33
|
+
kind: "unavailable",
|
|
34
|
+
reason: `${model.id} does not advertise Codex Fast support.`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return { kind: "available", enabled };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function codexFastIsEffective(model: PiModel | undefined, enabled: boolean): boolean {
|
|
41
|
+
return codexFastAvailability(model, enabled).kind === "available" && enabled;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function codexFastRequestTier(
|
|
45
|
+
model: PiModel | undefined,
|
|
46
|
+
enabled: boolean,
|
|
47
|
+
): typeof CODEX_FAST_SERVICE_TIER | typeof CODEX_STANDARD_SERVICE_TIER | undefined {
|
|
48
|
+
if (!isOfficialCodexModel(model)) return undefined;
|
|
49
|
+
return enabled && CODEX_FAST_MODEL_IDS.has(model.id)
|
|
50
|
+
? CODEX_FAST_SERVICE_TIER
|
|
51
|
+
: CODEX_STANDARD_SERVICE_TIER;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function rewriteCodexFastPayload(
|
|
55
|
+
payload: unknown,
|
|
56
|
+
model: PiModel | undefined,
|
|
57
|
+
enabled: boolean,
|
|
58
|
+
): unknown | undefined {
|
|
59
|
+
const serviceTier = codexFastRequestTier(model, enabled);
|
|
60
|
+
if (!serviceTier || !isRecord(payload)) return undefined;
|
|
61
|
+
return { ...payload, service_tier: serviceTier };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function correctCodexFastMessageCost(
|
|
65
|
+
message: unknown,
|
|
66
|
+
model: PiModel | undefined,
|
|
67
|
+
fastRequested: boolean,
|
|
68
|
+
): unknown | undefined {
|
|
69
|
+
if (
|
|
70
|
+
!codexFastIsEffective(model, fastRequested) ||
|
|
71
|
+
!isRecord(message) ||
|
|
72
|
+
message.role !== "assistant" ||
|
|
73
|
+
message.provider !== model?.provider ||
|
|
74
|
+
message.model !== model?.id
|
|
75
|
+
) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const usage = isRecord(message.usage) ? message.usage : undefined;
|
|
79
|
+
const cost = usage && isRecord(usage.cost) ? usage.cost : undefined;
|
|
80
|
+
if (!usage || !cost || !hasCompleteUsage(usage) || !isOfficialCodexModel(model)) return undefined;
|
|
81
|
+
const correctedUsage = structuredClone(usage) as typeof usage;
|
|
82
|
+
calculateCost(model, correctedUsage as never);
|
|
83
|
+
const multiplier = model.id === "gpt-5.5" ? 2.5 : 2;
|
|
84
|
+
const correctedCost = correctedUsage.cost as Record<string, number>;
|
|
85
|
+
for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) {
|
|
86
|
+
correctedCost[key] *= multiplier;
|
|
87
|
+
}
|
|
88
|
+
if (costsEqual(cost, correctedCost)) return undefined;
|
|
89
|
+
return { ...message, usage: correctedUsage };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function codexFastStatusLabel(status: string, enabled: boolean): string {
|
|
93
|
+
if (!enabled || !/^codex(?:\s|$)/u.test(status)) return status;
|
|
94
|
+
return status === "codex" ? "codex fast" : `codex fast${status.slice("codex".length)}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isOfficialCodexModel(
|
|
98
|
+
model: PiModel | undefined,
|
|
99
|
+
): model is PiModel & { api: "openai-codex-responses" } {
|
|
100
|
+
if (model?.provider !== "openai-codex" || !hasApi(model, "openai-codex-responses")) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return new URL(model.baseUrl).origin === "https://chatgpt.com";
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
111
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasCompleteUsage(value: Record<string, unknown>): boolean {
|
|
115
|
+
return ["input", "output", "cacheRead", "cacheWrite"].every(
|
|
116
|
+
(key) => typeof value[key] === "number" && Number.isFinite(value[key]),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function costsEqual(left: Record<string, unknown>, right: Record<string, number>): boolean {
|
|
121
|
+
return ["input", "output", "cacheRead", "cacheWrite", "total"].every(
|
|
122
|
+
(key) => left[key] === right[key],
|
|
123
|
+
);
|
|
124
|
+
}
|