@victor-software-house/pi-multicodex 1.0.3
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/LICENSE +21 -0
- package/README.md +95 -0
- package/assets/multicodex.png +0 -0
- package/index.ts +985 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kim0
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @victor-software-house/pi-multicodex
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
`@victor-software-house/pi-multicodex` is a pi extension for rotating multiple ChatGPT Codex OAuth accounts when using the `openai-codex-responses` API.
|
|
6
|
+
|
|
7
|
+
Current behavior:
|
|
8
|
+
|
|
9
|
+
- rotates on quota and rate-limit failures
|
|
10
|
+
- prefers untouched accounts when usage data is available
|
|
11
|
+
- otherwise prefers the account whose weekly window resets first
|
|
12
|
+
- stays focused on Codex only
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pi install npm:@victor-software-house/pi-multicodex
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Restart `pi` after installation.
|
|
21
|
+
|
|
22
|
+
## Local development
|
|
23
|
+
|
|
24
|
+
This repo uses `mise` to pin tool versions and `pnpm` for dependency management.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
mise install
|
|
28
|
+
pnpm install
|
|
29
|
+
pnpm check
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Equivalent mise tasks:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
mise run install
|
|
36
|
+
mise run check
|
|
37
|
+
mise run pack-dry
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Run the extension directly during local development:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pi -e ./index.ts
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
- `/multicodex-login <email>`
|
|
49
|
+
- Add or update a Codex account in the rotation pool.
|
|
50
|
+
- `/multicodex-use`
|
|
51
|
+
- Select an account manually for the current session.
|
|
52
|
+
- `/multicodex-status`
|
|
53
|
+
- Show account state and cached usage information.
|
|
54
|
+
|
|
55
|
+
## Status
|
|
56
|
+
|
|
57
|
+
This package is being turned into an independent fork with deliberate breaking changes.
|
|
58
|
+
|
|
59
|
+
Current direction:
|
|
60
|
+
|
|
61
|
+
- package name: `@victor-software-house/pi-multicodex`
|
|
62
|
+
- hard break from previous storage compatibility
|
|
63
|
+
- Codex-only scope
|
|
64
|
+
- independent implementation roadmap tracked in `fork-plan.md`
|
|
65
|
+
|
|
66
|
+
## Release validation
|
|
67
|
+
|
|
68
|
+
Local development uses pnpm, but published package output must remain npm-compatible.
|
|
69
|
+
|
|
70
|
+
Minimum release checks:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pnpm check
|
|
74
|
+
npm pack --dry-run
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Recommended release flow:
|
|
78
|
+
|
|
79
|
+
1. For the first publish of a brand-new package, publish manually from a trusted local machine.
|
|
80
|
+
2. After the package exists on npm, configure npm trusted publishing for `.github/workflows/publish.yml`.
|
|
81
|
+
3. Publish subsequent releases by pushing a matching `v*` git tag.
|
|
82
|
+
|
|
83
|
+
Bootstrap publish:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm run publish:dry -- <version>
|
|
87
|
+
npm publish --access public --otp=<code>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Trusted publishing flow after bootstrap:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
git tag v<version>
|
|
94
|
+
git push origin v<version>
|
|
95
|
+
```
|
|
Binary file
|
package/index.ts
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* MultiCodex Extension
|
|
3
|
+
*
|
|
4
|
+
* Rotates multiple ChatGPT Codex OAuth accounts for the built-in
|
|
5
|
+
* openai-codex-responses API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
type Api,
|
|
13
|
+
type AssistantMessage,
|
|
14
|
+
type AssistantMessageEvent,
|
|
15
|
+
type AssistantMessageEventStream,
|
|
16
|
+
type Context,
|
|
17
|
+
createAssistantMessageEventStream,
|
|
18
|
+
getApiProvider,
|
|
19
|
+
getModels,
|
|
20
|
+
type Model,
|
|
21
|
+
type SimpleStreamOptions,
|
|
22
|
+
} from "@mariozechner/pi-ai";
|
|
23
|
+
import {
|
|
24
|
+
loginOpenAICodex,
|
|
25
|
+
type OAuthCredentials,
|
|
26
|
+
refreshOpenAICodexToken,
|
|
27
|
+
} from "@mariozechner/pi-ai/oauth";
|
|
28
|
+
import type {
|
|
29
|
+
ExtensionAPI,
|
|
30
|
+
ExtensionCommandContext,
|
|
31
|
+
ExtensionContext,
|
|
32
|
+
} from "@mariozechner/pi-coding-agent";
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// Helpers
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
const USAGE_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
39
|
+
const USAGE_REQUEST_TIMEOUT_MS = 10 * 1000;
|
|
40
|
+
|
|
41
|
+
export function isQuotaErrorMessage(message: string): boolean {
|
|
42
|
+
return /\b429\b|quota|usage limit|rate.?limit|too many requests|limit reached/i.test(
|
|
43
|
+
message,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getErrorMessage(err: unknown): string {
|
|
48
|
+
if (err instanceof Error) return err.message;
|
|
49
|
+
return typeof err === "string" ? err : JSON.stringify(err);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createErrorAssistantMessage(
|
|
53
|
+
model: Model<Api>,
|
|
54
|
+
message: string,
|
|
55
|
+
): AssistantMessage {
|
|
56
|
+
return {
|
|
57
|
+
role: "assistant",
|
|
58
|
+
content: [],
|
|
59
|
+
api: model.api,
|
|
60
|
+
provider: model.provider,
|
|
61
|
+
model: model.id,
|
|
62
|
+
usage: {
|
|
63
|
+
input: 0,
|
|
64
|
+
output: 0,
|
|
65
|
+
cacheRead: 0,
|
|
66
|
+
cacheWrite: 0,
|
|
67
|
+
totalTokens: 0,
|
|
68
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
69
|
+
},
|
|
70
|
+
stopReason: "error",
|
|
71
|
+
errorMessage: message,
|
|
72
|
+
timestamp: Date.now(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface CodexUsageWindow {
|
|
77
|
+
usedPercent?: number;
|
|
78
|
+
resetAt?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CodexUsageSnapshot {
|
|
82
|
+
primary?: CodexUsageWindow;
|
|
83
|
+
secondary?: CodexUsageWindow;
|
|
84
|
+
fetchedAt: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface WhamUsageResponse {
|
|
88
|
+
rate_limit?: {
|
|
89
|
+
primary_window?: WhamUsageWindow;
|
|
90
|
+
secondary_window?: WhamUsageWindow;
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type WhamUsageWindow = {
|
|
95
|
+
reset_at?: number;
|
|
96
|
+
used_percent?: number;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export interface ProviderModelDef {
|
|
100
|
+
id: string;
|
|
101
|
+
name: string;
|
|
102
|
+
reasoning: boolean;
|
|
103
|
+
input: ("text" | "image")[];
|
|
104
|
+
cost: {
|
|
105
|
+
input: number;
|
|
106
|
+
output: number;
|
|
107
|
+
cacheRead: number;
|
|
108
|
+
cacheWrite: number;
|
|
109
|
+
};
|
|
110
|
+
contextWindow: number;
|
|
111
|
+
maxTokens: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function getOpenAICodexMirror(): {
|
|
115
|
+
baseUrl: string;
|
|
116
|
+
models: ProviderModelDef[];
|
|
117
|
+
} {
|
|
118
|
+
const sourceModels = getModels("openai-codex");
|
|
119
|
+
return {
|
|
120
|
+
baseUrl: sourceModels[0]?.baseUrl || "https://chatgpt.com/backend-api",
|
|
121
|
+
models: sourceModels.map((m) => ({
|
|
122
|
+
id: m.id,
|
|
123
|
+
name: m.name,
|
|
124
|
+
reasoning: m.reasoning,
|
|
125
|
+
input: m.input,
|
|
126
|
+
cost: m.cost,
|
|
127
|
+
contextWindow: m.contextWindow,
|
|
128
|
+
maxTokens: m.maxTokens,
|
|
129
|
+
})),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeUsedPercent(value?: number): number | undefined {
|
|
134
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
135
|
+
return Math.min(100, Math.max(0, value));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalizeResetAt(value?: number): number | undefined {
|
|
139
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
140
|
+
return value * 1000;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseUsageWindow(
|
|
144
|
+
window?: WhamUsageWindow,
|
|
145
|
+
): CodexUsageWindow | undefined {
|
|
146
|
+
if (!window) return undefined;
|
|
147
|
+
const usedPercent = normalizeUsedPercent(window.used_percent);
|
|
148
|
+
const resetAt = normalizeResetAt(window.reset_at);
|
|
149
|
+
if (usedPercent === undefined && resetAt === undefined) return undefined;
|
|
150
|
+
return { usedPercent, resetAt };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function parseCodexUsageResponse(
|
|
154
|
+
data: WhamUsageResponse,
|
|
155
|
+
): Omit<CodexUsageSnapshot, "fetchedAt"> {
|
|
156
|
+
return {
|
|
157
|
+
primary: parseUsageWindow(data.rate_limit?.primary_window),
|
|
158
|
+
secondary: parseUsageWindow(data.rate_limit?.secondary_window),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function isUsageUntouched(usage?: CodexUsageSnapshot): boolean {
|
|
163
|
+
const primary = usage?.primary?.usedPercent;
|
|
164
|
+
const secondary = usage?.secondary?.usedPercent;
|
|
165
|
+
if (primary === undefined || secondary === undefined) return false;
|
|
166
|
+
return primary === 0 && secondary === 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getNextResetAt(usage?: CodexUsageSnapshot): number | undefined {
|
|
170
|
+
const candidates = [
|
|
171
|
+
usage?.primary?.resetAt,
|
|
172
|
+
usage?.secondary?.resetAt,
|
|
173
|
+
].filter((value): value is number => typeof value === "number");
|
|
174
|
+
if (candidates.length === 0) return undefined;
|
|
175
|
+
return Math.min(...candidates);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Weekly reset only (secondary window)
|
|
179
|
+
export function getWeeklyResetAt(
|
|
180
|
+
usage?: CodexUsageSnapshot,
|
|
181
|
+
): number | undefined {
|
|
182
|
+
const resetAt = usage?.secondary?.resetAt;
|
|
183
|
+
return typeof resetAt === "number" ? resetAt : undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function formatResetAt(resetAt?: number): string {
|
|
187
|
+
if (!resetAt) return "unknown";
|
|
188
|
+
const diffMs = resetAt - Date.now();
|
|
189
|
+
if (diffMs <= 0) return "now";
|
|
190
|
+
const diffMinutes = Math.max(1, Math.round(diffMs / 60000));
|
|
191
|
+
if (diffMinutes < 60) return `in ${diffMinutes}m`;
|
|
192
|
+
const diffHours = Math.round(diffMinutes / 60);
|
|
193
|
+
if (diffHours < 48) return `in ${diffHours}h`;
|
|
194
|
+
const diffDays = Math.round(diffHours / 24);
|
|
195
|
+
return `in ${diffDays}d`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function fetchCodexUsage(
|
|
199
|
+
accessToken: string,
|
|
200
|
+
accountId: string | undefined,
|
|
201
|
+
options?: { signal?: AbortSignal },
|
|
202
|
+
): Promise<CodexUsageSnapshot> {
|
|
203
|
+
const { controller, clear } = createTimeoutController(
|
|
204
|
+
options?.signal,
|
|
205
|
+
USAGE_REQUEST_TIMEOUT_MS,
|
|
206
|
+
);
|
|
207
|
+
try {
|
|
208
|
+
const headers: Record<string, string> = {
|
|
209
|
+
Authorization: `Bearer ${accessToken}`,
|
|
210
|
+
Accept: "application/json",
|
|
211
|
+
};
|
|
212
|
+
if (accountId) {
|
|
213
|
+
headers["ChatGPT-Account-Id"] = accountId;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const response = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
217
|
+
headers,
|
|
218
|
+
signal: controller.signal,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
throw new Error(`Usage request failed: ${response.status}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const data = (await response.json()) as WhamUsageResponse;
|
|
226
|
+
return { ...parseCodexUsageResponse(data), fetchedAt: Date.now() };
|
|
227
|
+
} finally {
|
|
228
|
+
clear();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function createLinkedAbortController(signal?: AbortSignal): AbortController {
|
|
233
|
+
const controller = new AbortController();
|
|
234
|
+
if (signal?.aborted) {
|
|
235
|
+
controller.abort();
|
|
236
|
+
return controller;
|
|
237
|
+
}
|
|
238
|
+
signal?.addEventListener("abort", () => controller.abort(), { once: true });
|
|
239
|
+
return controller;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function createTimeoutController(
|
|
243
|
+
signal: AbortSignal | undefined,
|
|
244
|
+
timeoutMs: number,
|
|
245
|
+
): { controller: AbortController; clear: () => void } {
|
|
246
|
+
const controller = createLinkedAbortController(signal);
|
|
247
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
248
|
+
return {
|
|
249
|
+
controller,
|
|
250
|
+
clear: () => clearTimeout(timeout),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function withProvider(
|
|
255
|
+
event: AssistantMessageEvent,
|
|
256
|
+
provider: string,
|
|
257
|
+
): AssistantMessageEvent {
|
|
258
|
+
if ("partial" in event) {
|
|
259
|
+
return { ...event, partial: { ...event.partial, provider } };
|
|
260
|
+
}
|
|
261
|
+
if (event.type === "done") {
|
|
262
|
+
return { ...event, message: { ...event.message, provider } };
|
|
263
|
+
}
|
|
264
|
+
if (event.type === "error") {
|
|
265
|
+
return { ...event, error: { ...event.error, provider } };
|
|
266
|
+
}
|
|
267
|
+
return event;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function openLoginInBrowser(
|
|
271
|
+
pi: ExtensionAPI,
|
|
272
|
+
ctx: ExtensionCommandContext,
|
|
273
|
+
url: string,
|
|
274
|
+
): Promise<void> {
|
|
275
|
+
let command: string;
|
|
276
|
+
let args: string[];
|
|
277
|
+
|
|
278
|
+
if (process.platform === "darwin") {
|
|
279
|
+
command = "open";
|
|
280
|
+
args = [url];
|
|
281
|
+
} else if (process.platform === "win32") {
|
|
282
|
+
command = "cmd";
|
|
283
|
+
args = ["/c", "start", "", url];
|
|
284
|
+
} else {
|
|
285
|
+
command = "xdg-open";
|
|
286
|
+
args = [url];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
await pi.exec(command, args);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
ctx.ui.notify(
|
|
293
|
+
"Could not open a browser automatically. Please open the login URL manually.",
|
|
294
|
+
"warning",
|
|
295
|
+
);
|
|
296
|
+
console.warn("[multicodex] Failed to open browser:", error);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// =============================================================================
|
|
301
|
+
// Storage
|
|
302
|
+
// =============================================================================
|
|
303
|
+
|
|
304
|
+
export interface Account {
|
|
305
|
+
email: string;
|
|
306
|
+
accessToken: string;
|
|
307
|
+
refreshToken: string;
|
|
308
|
+
expiresAt: number;
|
|
309
|
+
accountId?: string;
|
|
310
|
+
lastUsed?: number;
|
|
311
|
+
quotaExhaustedUntil?: number;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
interface StorageData {
|
|
315
|
+
accounts: Account[];
|
|
316
|
+
activeEmail?: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const STORAGE_FILE = path.join(os.homedir(), ".pi", "agent", "multicodex.json");
|
|
320
|
+
const PROVIDER_ID = "multicodex";
|
|
321
|
+
const QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
|
|
322
|
+
|
|
323
|
+
type WarningHandler = (message: string) => void;
|
|
324
|
+
|
|
325
|
+
function isAccountAvailable(account: Account, now: number): boolean {
|
|
326
|
+
return !account.quotaExhaustedUntil || account.quotaExhaustedUntil <= now;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function pickRandomAccount(accounts: Account[]): Account | undefined {
|
|
330
|
+
if (accounts.length === 0) return undefined;
|
|
331
|
+
return accounts[Math.floor(Math.random() * accounts.length)];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function pickEarliestWeeklyResetAccount(
|
|
335
|
+
accounts: Account[],
|
|
336
|
+
usageByEmail: Map<string, CodexUsageSnapshot>,
|
|
337
|
+
): Account | undefined {
|
|
338
|
+
const candidates = accounts
|
|
339
|
+
.map((account) => ({
|
|
340
|
+
account,
|
|
341
|
+
resetAt: getWeeklyResetAt(usageByEmail.get(account.email)),
|
|
342
|
+
}))
|
|
343
|
+
.filter(
|
|
344
|
+
(entry): entry is { account: Account; resetAt: number } =>
|
|
345
|
+
typeof entry.resetAt === "number",
|
|
346
|
+
)
|
|
347
|
+
.sort((a, b) => a.resetAt - b.resetAt);
|
|
348
|
+
|
|
349
|
+
return candidates[0]?.account;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function pickBestAccount(
|
|
353
|
+
accounts: Account[],
|
|
354
|
+
usageByEmail: Map<string, CodexUsageSnapshot>,
|
|
355
|
+
options?: { excludeEmails?: Set<string>; now?: number },
|
|
356
|
+
): Account | undefined {
|
|
357
|
+
const now = options?.now ?? Date.now();
|
|
358
|
+
const available = accounts.filter(
|
|
359
|
+
(account) =>
|
|
360
|
+
isAccountAvailable(account, now) &&
|
|
361
|
+
!options?.excludeEmails?.has(account.email),
|
|
362
|
+
);
|
|
363
|
+
if (available.length === 0) return undefined;
|
|
364
|
+
|
|
365
|
+
const withUsage = available.filter((account) =>
|
|
366
|
+
usageByEmail.has(account.email),
|
|
367
|
+
);
|
|
368
|
+
const untouched = withUsage.filter((account) =>
|
|
369
|
+
isUsageUntouched(usageByEmail.get(account.email)),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
if (untouched.length > 0) {
|
|
373
|
+
return (
|
|
374
|
+
pickEarliestWeeklyResetAccount(untouched, usageByEmail) ??
|
|
375
|
+
pickRandomAccount(untouched)
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const earliestWeeklyReset = pickEarliestWeeklyResetAccount(
|
|
380
|
+
withUsage,
|
|
381
|
+
usageByEmail,
|
|
382
|
+
);
|
|
383
|
+
if (earliestWeeklyReset) return earliestWeeklyReset;
|
|
384
|
+
|
|
385
|
+
return pickRandomAccount(available);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// =============================================================================
|
|
389
|
+
// Account Manager
|
|
390
|
+
// =============================================================================
|
|
391
|
+
|
|
392
|
+
export class AccountManager {
|
|
393
|
+
private data: StorageData;
|
|
394
|
+
private usageCache = new Map<string, CodexUsageSnapshot>();
|
|
395
|
+
private warningHandler?: WarningHandler;
|
|
396
|
+
private manualEmail?: string;
|
|
397
|
+
|
|
398
|
+
constructor() {
|
|
399
|
+
this.data = this.load();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private load(): StorageData {
|
|
403
|
+
try {
|
|
404
|
+
if (fs.existsSync(STORAGE_FILE)) {
|
|
405
|
+
return JSON.parse(
|
|
406
|
+
fs.readFileSync(STORAGE_FILE, "utf-8"),
|
|
407
|
+
) as StorageData;
|
|
408
|
+
}
|
|
409
|
+
} catch (e) {
|
|
410
|
+
console.error("Failed to load multicodex accounts:", e);
|
|
411
|
+
}
|
|
412
|
+
return { accounts: [] };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private save(): void {
|
|
416
|
+
try {
|
|
417
|
+
const dir = path.dirname(STORAGE_FILE);
|
|
418
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
419
|
+
fs.writeFileSync(STORAGE_FILE, JSON.stringify(this.data, null, 2));
|
|
420
|
+
} catch (e) {
|
|
421
|
+
console.error("Failed to save multicodex accounts:", e);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
getAccounts(): Account[] {
|
|
426
|
+
return this.data.accounts;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
getAccount(email: string): Account | undefined {
|
|
430
|
+
return this.data.accounts.find((a) => a.email === email);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
setWarningHandler(handler?: WarningHandler): void {
|
|
434
|
+
this.warningHandler = handler;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
addOrUpdateAccount(email: string, creds: OAuthCredentials): void {
|
|
438
|
+
const existing = this.getAccount(email);
|
|
439
|
+
const accountId =
|
|
440
|
+
typeof creds.accountId === "string" ? creds.accountId : undefined;
|
|
441
|
+
if (existing) {
|
|
442
|
+
existing.accessToken = creds.access;
|
|
443
|
+
existing.refreshToken = creds.refresh;
|
|
444
|
+
existing.expiresAt = creds.expires;
|
|
445
|
+
if (accountId) {
|
|
446
|
+
existing.accountId = accountId;
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
this.data.accounts.push({
|
|
450
|
+
email,
|
|
451
|
+
accessToken: creds.access,
|
|
452
|
+
refreshToken: creds.refresh,
|
|
453
|
+
expiresAt: creds.expires,
|
|
454
|
+
accountId,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
this.setActiveAccount(email);
|
|
458
|
+
this.save();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
getActiveAccount(): Account | undefined {
|
|
462
|
+
const manual = this.getManualAccount();
|
|
463
|
+
if (manual) return manual;
|
|
464
|
+
if (this.data.activeEmail) {
|
|
465
|
+
return this.getAccount(this.data.activeEmail);
|
|
466
|
+
}
|
|
467
|
+
return this.data.accounts[0];
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
getManualAccount(): Account | undefined {
|
|
471
|
+
if (!this.manualEmail) return undefined;
|
|
472
|
+
const account = this.getAccount(this.manualEmail);
|
|
473
|
+
if (!account) {
|
|
474
|
+
this.manualEmail = undefined;
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
return account;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
hasManualAccount(): boolean {
|
|
481
|
+
return Boolean(this.getManualAccount());
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
getAvailableManualAccount(options?: {
|
|
485
|
+
now?: number;
|
|
486
|
+
excludeEmails?: Set<string>;
|
|
487
|
+
}): Account | undefined {
|
|
488
|
+
const now = options?.now ?? Date.now();
|
|
489
|
+
this.clearExpiredExhaustion(now);
|
|
490
|
+
const manual = this.getManualAccount();
|
|
491
|
+
if (!manual) return undefined;
|
|
492
|
+
if (options?.excludeEmails?.has(manual.email)) return undefined;
|
|
493
|
+
if (!isAccountAvailable(manual, now)) return undefined;
|
|
494
|
+
return manual;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
setActiveAccount(email: string): void {
|
|
498
|
+
const account = this.getAccount(email);
|
|
499
|
+
if (!account) return;
|
|
500
|
+
this.data.activeEmail = email;
|
|
501
|
+
account.lastUsed = Date.now();
|
|
502
|
+
this.save();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
setManualAccount(email: string): void {
|
|
506
|
+
const account = this.getAccount(email);
|
|
507
|
+
if (!account) return;
|
|
508
|
+
this.manualEmail = email;
|
|
509
|
+
account.lastUsed = Date.now();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
clearManualAccount(): void {
|
|
513
|
+
this.manualEmail = undefined;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
markExhausted(email: string, until: number): void {
|
|
517
|
+
const account = this.getAccount(email);
|
|
518
|
+
if (account) {
|
|
519
|
+
account.quotaExhaustedUntil = until;
|
|
520
|
+
this.save();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
getCachedUsage(email: string): CodexUsageSnapshot | undefined {
|
|
525
|
+
return this.usageCache.get(email);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async refreshUsageForAccount(
|
|
529
|
+
account: Account,
|
|
530
|
+
options?: { force?: boolean; signal?: AbortSignal },
|
|
531
|
+
): Promise<CodexUsageSnapshot | undefined> {
|
|
532
|
+
const cached = this.usageCache.get(account.email);
|
|
533
|
+
const now = Date.now();
|
|
534
|
+
if (
|
|
535
|
+
cached &&
|
|
536
|
+
!options?.force &&
|
|
537
|
+
now - cached.fetchedAt < USAGE_CACHE_TTL_MS
|
|
538
|
+
) {
|
|
539
|
+
return cached;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
const token = await this.ensureValidToken(account);
|
|
544
|
+
const usage = await fetchCodexUsage(token, account.accountId, {
|
|
545
|
+
signal: options?.signal,
|
|
546
|
+
});
|
|
547
|
+
this.usageCache.set(account.email, usage);
|
|
548
|
+
return usage;
|
|
549
|
+
} catch (error) {
|
|
550
|
+
this.warningHandler?.(
|
|
551
|
+
`Multicodex: failed to fetch usage for ${account.email}: ${getErrorMessage(
|
|
552
|
+
error,
|
|
553
|
+
)}`,
|
|
554
|
+
);
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async refreshUsageForAllAccounts(options?: {
|
|
560
|
+
force?: boolean;
|
|
561
|
+
signal?: AbortSignal;
|
|
562
|
+
}): Promise<void> {
|
|
563
|
+
const accounts = this.getAccounts();
|
|
564
|
+
await Promise.all(
|
|
565
|
+
accounts.map((account) => this.refreshUsageForAccount(account, options)),
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async refreshUsageIfStale(
|
|
570
|
+
accounts: Account[],
|
|
571
|
+
options?: { signal?: AbortSignal },
|
|
572
|
+
): Promise<void> {
|
|
573
|
+
const now = Date.now();
|
|
574
|
+
const stale = accounts.filter((account) => {
|
|
575
|
+
const cached = this.usageCache.get(account.email);
|
|
576
|
+
return !cached || now - cached.fetchedAt >= USAGE_CACHE_TTL_MS;
|
|
577
|
+
});
|
|
578
|
+
if (stale.length === 0) return;
|
|
579
|
+
await Promise.all(
|
|
580
|
+
stale.map((account) =>
|
|
581
|
+
this.refreshUsageForAccount(account, { force: true, ...options }),
|
|
582
|
+
),
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async activateBestAccount(options?: {
|
|
587
|
+
excludeEmails?: Set<string>;
|
|
588
|
+
signal?: AbortSignal;
|
|
589
|
+
}): Promise<Account | undefined> {
|
|
590
|
+
const now = Date.now();
|
|
591
|
+
this.clearExpiredExhaustion(now);
|
|
592
|
+
const accounts = this.data.accounts;
|
|
593
|
+
await this.refreshUsageIfStale(accounts, options);
|
|
594
|
+
|
|
595
|
+
const selected = pickBestAccount(accounts, this.usageCache, {
|
|
596
|
+
excludeEmails: options?.excludeEmails,
|
|
597
|
+
now,
|
|
598
|
+
});
|
|
599
|
+
if (selected) {
|
|
600
|
+
this.setActiveAccount(selected.email);
|
|
601
|
+
}
|
|
602
|
+
return selected;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async handleQuotaExceeded(
|
|
606
|
+
account: Account,
|
|
607
|
+
options?: { signal?: AbortSignal },
|
|
608
|
+
): Promise<void> {
|
|
609
|
+
const usage = await this.refreshUsageForAccount(account, {
|
|
610
|
+
force: true,
|
|
611
|
+
signal: options?.signal,
|
|
612
|
+
});
|
|
613
|
+
const now = Date.now();
|
|
614
|
+
const resetAt = getNextResetAt(usage);
|
|
615
|
+
const fallback = now + QUOTA_COOLDOWN_MS;
|
|
616
|
+
const until = resetAt && resetAt > now ? resetAt : fallback;
|
|
617
|
+
this.markExhausted(account.email, until);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private clearExpiredExhaustion(now: number): void {
|
|
621
|
+
let changed = false;
|
|
622
|
+
for (const account of this.data.accounts) {
|
|
623
|
+
if (account.quotaExhaustedUntil && account.quotaExhaustedUntil <= now) {
|
|
624
|
+
account.quotaExhaustedUntil = undefined;
|
|
625
|
+
changed = true;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (changed) {
|
|
629
|
+
this.save();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async ensureValidToken(account: Account): Promise<string> {
|
|
634
|
+
// Valid for at least 5 more mins
|
|
635
|
+
if (Date.now() < account.expiresAt - 5 * 60 * 1000) {
|
|
636
|
+
return account.accessToken;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const result = await refreshOpenAICodexToken(account.refreshToken);
|
|
640
|
+
account.accessToken = result.access;
|
|
641
|
+
account.refreshToken = result.refresh;
|
|
642
|
+
account.expiresAt = result.expires;
|
|
643
|
+
const accountId =
|
|
644
|
+
typeof result.accountId === "string" ? result.accountId : undefined;
|
|
645
|
+
if (accountId) {
|
|
646
|
+
account.accountId = accountId;
|
|
647
|
+
}
|
|
648
|
+
this.save();
|
|
649
|
+
return account.accessToken;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// =============================================================================
|
|
654
|
+
// Extension Entry Point
|
|
655
|
+
// =============================================================================
|
|
656
|
+
|
|
657
|
+
type ApiProviderRef = NonNullable<ReturnType<typeof getApiProvider>>;
|
|
658
|
+
|
|
659
|
+
export function buildMulticodexProviderConfig(accountManager: AccountManager): {
|
|
660
|
+
baseUrl: string;
|
|
661
|
+
apiKey: string;
|
|
662
|
+
api: "openai-codex-responses";
|
|
663
|
+
streamSimple: (
|
|
664
|
+
model: Model<Api>,
|
|
665
|
+
context: Context,
|
|
666
|
+
options?: SimpleStreamOptions,
|
|
667
|
+
) => AssistantMessageEventStream;
|
|
668
|
+
models: ProviderModelDef[];
|
|
669
|
+
} {
|
|
670
|
+
const mirror = getOpenAICodexMirror();
|
|
671
|
+
const baseProvider = getApiProvider("openai-codex-responses");
|
|
672
|
+
if (!baseProvider) {
|
|
673
|
+
throw new Error(
|
|
674
|
+
"OpenAI Codex provider not available. Please update pi to include openai-codex support.",
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
baseUrl: mirror.baseUrl,
|
|
679
|
+
apiKey: "managed-by-extension",
|
|
680
|
+
api: "openai-codex-responses",
|
|
681
|
+
streamSimple: createStreamWrapper(accountManager, baseProvider),
|
|
682
|
+
models: mirror.models,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export default function multicodexExtension(pi: ExtensionAPI) {
|
|
687
|
+
const accountManager = new AccountManager();
|
|
688
|
+
let lastContext: ExtensionContext | undefined;
|
|
689
|
+
|
|
690
|
+
accountManager.setWarningHandler((message) => {
|
|
691
|
+
if (lastContext) {
|
|
692
|
+
lastContext.ui.notify(message, "warning");
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
pi.registerProvider(
|
|
697
|
+
PROVIDER_ID,
|
|
698
|
+
buildMulticodexProviderConfig(accountManager),
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
// Login command
|
|
702
|
+
pi.registerCommand("multicodex-login", {
|
|
703
|
+
description: "Login to an OpenAI Codex account for the rotation pool",
|
|
704
|
+
handler: async (
|
|
705
|
+
args: string,
|
|
706
|
+
ctx: ExtensionCommandContext,
|
|
707
|
+
): Promise<void> => {
|
|
708
|
+
const email = args.trim();
|
|
709
|
+
if (!email) {
|
|
710
|
+
ctx.ui.notify(
|
|
711
|
+
"Please provide an email/identifier: /multicodex-login my@email.com",
|
|
712
|
+
"error",
|
|
713
|
+
);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
ctx.ui.notify(
|
|
719
|
+
`Starting login for ${email}... Check your browser.`,
|
|
720
|
+
"info",
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
const creds = await loginOpenAICodex({
|
|
724
|
+
onAuth: ({ url }) => {
|
|
725
|
+
void openLoginInBrowser(pi, ctx, url);
|
|
726
|
+
ctx.ui.notify(`Please open this URL to login: ${url}`, "info");
|
|
727
|
+
console.log(`[multicodex] Login URL: ${url}`);
|
|
728
|
+
},
|
|
729
|
+
onPrompt: async ({ message }) => (await ctx.ui.input(message)) || "",
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
accountManager.addOrUpdateAccount(email, creds);
|
|
733
|
+
ctx.ui.notify(`Successfully logged in as ${email}`, "info");
|
|
734
|
+
} catch (e) {
|
|
735
|
+
ctx.ui.notify(`Login failed: ${getErrorMessage(e)}`, "error");
|
|
736
|
+
}
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// Switch active account
|
|
741
|
+
pi.registerCommand("multicodex-use", {
|
|
742
|
+
description: "Switch active Codex account for this session",
|
|
743
|
+
handler: async (
|
|
744
|
+
_args: string,
|
|
745
|
+
ctx: ExtensionCommandContext,
|
|
746
|
+
): Promise<void> => {
|
|
747
|
+
const accounts = accountManager.getAccounts();
|
|
748
|
+
if (accounts.length === 0) {
|
|
749
|
+
ctx.ui.notify(
|
|
750
|
+
"No accounts logged in. Use /multicodex-login first.",
|
|
751
|
+
"warning",
|
|
752
|
+
);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const options = accounts.map(
|
|
757
|
+
(a) =>
|
|
758
|
+
a.email +
|
|
759
|
+
(a.quotaExhaustedUntil && a.quotaExhaustedUntil > Date.now()
|
|
760
|
+
? " (Quota)"
|
|
761
|
+
: ""),
|
|
762
|
+
);
|
|
763
|
+
const selected = await ctx.ui.select("Select Account", options);
|
|
764
|
+
if (!selected) return;
|
|
765
|
+
|
|
766
|
+
const email = selected.split(" ")[0];
|
|
767
|
+
accountManager.setManualAccount(email);
|
|
768
|
+
ctx.ui.notify(`Switched to ${email}`, "info");
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
pi.registerCommand("multicodex-status", {
|
|
773
|
+
description: "Show all Codex accounts and active status",
|
|
774
|
+
handler: async (
|
|
775
|
+
_args: string,
|
|
776
|
+
ctx: ExtensionCommandContext,
|
|
777
|
+
): Promise<void> => {
|
|
778
|
+
await accountManager.refreshUsageForAllAccounts();
|
|
779
|
+
const accounts = accountManager.getAccounts();
|
|
780
|
+
if (accounts.length === 0) {
|
|
781
|
+
ctx.ui.notify(
|
|
782
|
+
"No accounts logged in. Use /multicodex-login first.",
|
|
783
|
+
"warning",
|
|
784
|
+
);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const active = accountManager.getActiveAccount();
|
|
789
|
+
const options = accounts.map((account) => {
|
|
790
|
+
const usage = accountManager.getCachedUsage(account.email);
|
|
791
|
+
const isActive = active?.email === account.email;
|
|
792
|
+
const quotaHit =
|
|
793
|
+
account.quotaExhaustedUntil &&
|
|
794
|
+
account.quotaExhaustedUntil > Date.now();
|
|
795
|
+
const untouched = isUsageUntouched(usage) ? "untouched" : null;
|
|
796
|
+
const tags = [
|
|
797
|
+
isActive ? "active" : null,
|
|
798
|
+
quotaHit ? "quota" : null,
|
|
799
|
+
untouched,
|
|
800
|
+
]
|
|
801
|
+
.filter(Boolean)
|
|
802
|
+
.join(", ");
|
|
803
|
+
const suffix = tags ? ` (${tags})` : "";
|
|
804
|
+
const primaryUsed = usage?.primary?.usedPercent;
|
|
805
|
+
const secondaryUsed = usage?.secondary?.usedPercent;
|
|
806
|
+
const primaryReset = usage?.primary?.resetAt;
|
|
807
|
+
const secondaryReset = usage?.secondary?.resetAt;
|
|
808
|
+
const primaryLabel =
|
|
809
|
+
primaryUsed === undefined ? "unknown" : `${Math.round(primaryUsed)}%`;
|
|
810
|
+
const secondaryLabel =
|
|
811
|
+
secondaryUsed === undefined
|
|
812
|
+
? "unknown"
|
|
813
|
+
: `${Math.round(secondaryUsed)}%`;
|
|
814
|
+
const usageSummary = `5h ${primaryLabel} reset:${formatResetAt(primaryReset)} | weekly ${secondaryLabel} reset:${formatResetAt(secondaryReset)}`;
|
|
815
|
+
return `${isActive ? "•" : " "} ${account.email}${suffix} - ${usageSummary}`;
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
await ctx.ui.select("MultiCodex Accounts", options);
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
// Hooks
|
|
823
|
+
pi.on("session_start", (_event: unknown, ctx: ExtensionContext) => {
|
|
824
|
+
lastContext = ctx;
|
|
825
|
+
if (accountManager.getAccounts().length === 0) return;
|
|
826
|
+
void (async () => {
|
|
827
|
+
await accountManager.refreshUsageForAllAccounts({ force: true });
|
|
828
|
+
const manual = accountManager.getAvailableManualAccount();
|
|
829
|
+
if (manual) return;
|
|
830
|
+
if (accountManager.hasManualAccount()) {
|
|
831
|
+
accountManager.clearManualAccount();
|
|
832
|
+
}
|
|
833
|
+
await accountManager.activateBestAccount();
|
|
834
|
+
})();
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
pi.on(
|
|
838
|
+
"session_switch",
|
|
839
|
+
(event: { reason?: string }, ctx: ExtensionContext) => {
|
|
840
|
+
lastContext = ctx;
|
|
841
|
+
if (event.reason === "new") {
|
|
842
|
+
void (async () => {
|
|
843
|
+
await accountManager.refreshUsageForAllAccounts({ force: true });
|
|
844
|
+
const manual = accountManager.getAvailableManualAccount();
|
|
845
|
+
if (manual) return;
|
|
846
|
+
if (accountManager.hasManualAccount()) {
|
|
847
|
+
accountManager.clearManualAccount();
|
|
848
|
+
}
|
|
849
|
+
await accountManager.activateBestAccount();
|
|
850
|
+
})();
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// =============================================================================
|
|
857
|
+
// Stream Wrapper
|
|
858
|
+
// =============================================================================
|
|
859
|
+
|
|
860
|
+
const MAX_ROTATION_RETRIES = 5;
|
|
861
|
+
|
|
862
|
+
export function createStreamWrapper(
|
|
863
|
+
accountManager: AccountManager,
|
|
864
|
+
baseProvider: ApiProviderRef,
|
|
865
|
+
) {
|
|
866
|
+
return (
|
|
867
|
+
model: Model<Api>,
|
|
868
|
+
context: Context,
|
|
869
|
+
options?: SimpleStreamOptions,
|
|
870
|
+
): AssistantMessageEventStream => {
|
|
871
|
+
const stream = createAssistantMessageEventStream();
|
|
872
|
+
|
|
873
|
+
(async () => {
|
|
874
|
+
try {
|
|
875
|
+
const excludedEmails = new Set<string>();
|
|
876
|
+
for (let attempt = 0; attempt <= MAX_ROTATION_RETRIES; attempt++) {
|
|
877
|
+
const now = Date.now();
|
|
878
|
+
const manual = accountManager.getAvailableManualAccount({
|
|
879
|
+
excludeEmails: excludedEmails,
|
|
880
|
+
now,
|
|
881
|
+
});
|
|
882
|
+
const usingManual = Boolean(manual);
|
|
883
|
+
let account = manual;
|
|
884
|
+
if (!account) {
|
|
885
|
+
if (accountManager.hasManualAccount()) {
|
|
886
|
+
accountManager.clearManualAccount();
|
|
887
|
+
}
|
|
888
|
+
account = await accountManager.activateBestAccount({
|
|
889
|
+
excludeEmails: excludedEmails,
|
|
890
|
+
signal: options?.signal,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
if (!account) {
|
|
894
|
+
throw new Error(
|
|
895
|
+
"No available Multicodex accounts. Please use /multicodex-login.",
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const token = await accountManager.ensureValidToken(account);
|
|
900
|
+
|
|
901
|
+
const abortController = createLinkedAbortController(options?.signal);
|
|
902
|
+
|
|
903
|
+
const internalModel: Model<"openai-codex-responses"> = {
|
|
904
|
+
...(model as Model<"openai-codex-responses">),
|
|
905
|
+
provider: "openai-codex",
|
|
906
|
+
api: "openai-codex-responses",
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
const inner = baseProvider.streamSimple(
|
|
910
|
+
{
|
|
911
|
+
...internalModel,
|
|
912
|
+
headers: {
|
|
913
|
+
...(internalModel.headers || {}),
|
|
914
|
+
"X-Multicodex-Account": account.email,
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
context,
|
|
918
|
+
{
|
|
919
|
+
...options,
|
|
920
|
+
apiKey: token,
|
|
921
|
+
signal: abortController.signal,
|
|
922
|
+
},
|
|
923
|
+
);
|
|
924
|
+
|
|
925
|
+
let forwardedAny = false;
|
|
926
|
+
let retry = false;
|
|
927
|
+
|
|
928
|
+
for await (const event of inner) {
|
|
929
|
+
if (event.type === "error") {
|
|
930
|
+
const msg = event.error.errorMessage || "";
|
|
931
|
+
const isQuota = isQuotaErrorMessage(msg);
|
|
932
|
+
|
|
933
|
+
if (isQuota && !forwardedAny && attempt < MAX_ROTATION_RETRIES) {
|
|
934
|
+
await accountManager.handleQuotaExceeded(account, {
|
|
935
|
+
signal: options?.signal,
|
|
936
|
+
});
|
|
937
|
+
if (usingManual) {
|
|
938
|
+
accountManager.clearManualAccount();
|
|
939
|
+
}
|
|
940
|
+
excludedEmails.add(account.email);
|
|
941
|
+
abortController.abort();
|
|
942
|
+
retry = true;
|
|
943
|
+
break;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
stream.push(withProvider(event, model.provider));
|
|
947
|
+
stream.end();
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
forwardedAny = true;
|
|
952
|
+
stream.push(withProvider(event, model.provider));
|
|
953
|
+
|
|
954
|
+
if (event.type === "done") {
|
|
955
|
+
stream.end();
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (retry) {
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// If inner finished without done/error, stop to avoid hanging.
|
|
965
|
+
stream.end();
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
} catch (e) {
|
|
969
|
+
const message = getErrorMessage(e);
|
|
970
|
+
const errorEvent: AssistantMessageEvent = {
|
|
971
|
+
type: "error",
|
|
972
|
+
reason: "error",
|
|
973
|
+
error: createErrorAssistantMessage(
|
|
974
|
+
model,
|
|
975
|
+
`Multicodex failed: ${message}`,
|
|
976
|
+
),
|
|
977
|
+
};
|
|
978
|
+
stream.push(withProvider(errorEvent, model.provider));
|
|
979
|
+
stream.end();
|
|
980
|
+
}
|
|
981
|
+
})();
|
|
982
|
+
|
|
983
|
+
return stream;
|
|
984
|
+
};
|
|
985
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@victor-software-house/pi-multicodex",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "Codex account rotation extension for pi",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"packageManager": "pnpm@10.32.1",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi",
|
|
11
|
+
"pi-coding-agent",
|
|
12
|
+
"extension",
|
|
13
|
+
"codex",
|
|
14
|
+
"openai",
|
|
15
|
+
"oauth"
|
|
16
|
+
],
|
|
17
|
+
"pi": {
|
|
18
|
+
"extensions": [
|
|
19
|
+
"./index.ts"
|
|
20
|
+
],
|
|
21
|
+
"image": "https://raw.githubusercontent.com/victor-founder/pi-multicodex/main/assets/multicodex.png"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/victor-founder/pi-multicodex.git"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/victor-founder/pi-multicodex#readme",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/victor-founder/pi-multicodex/issues"
|
|
30
|
+
},
|
|
31
|
+
"author": "Victor",
|
|
32
|
+
"files": [
|
|
33
|
+
"index.ts",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"assets/**"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"lint": "biome check .",
|
|
40
|
+
"test": "vitest run -c vitest.config.ts",
|
|
41
|
+
"tsgo": "tsgo -p tsconfig.json",
|
|
42
|
+
"check": "pnpm lint && pnpm tsgo && pnpm test",
|
|
43
|
+
"pack:dry": "npm pack --dry-run",
|
|
44
|
+
"publish:dry": "bun ./scripts/publish.ts --dry-run",
|
|
45
|
+
"publish:release": "bun ./scripts/publish.ts"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@mariozechner/pi-ai": "*",
|
|
49
|
+
"@mariozechner/pi-coding-agent": "*"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@biomejs/biome": "^2.4.7",
|
|
53
|
+
"@mariozechner/pi-ai": "^0.58.1",
|
|
54
|
+
"@mariozechner/pi-coding-agent": "^0.58.1",
|
|
55
|
+
"@types/node": "^25.5.0",
|
|
56
|
+
"@typescript/native-preview": "7.0.0-dev.20260314.1",
|
|
57
|
+
"typescript": "^5.9.3",
|
|
58
|
+
"vitest": "^4.1.0"
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": "24.14.0"
|
|
62
|
+
}
|
|
63
|
+
}
|