@victor-software-house/pi-multicodex 1.0.3 → 1.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,7 +30,20 @@
30
30
  },
31
31
  "author": "Victor",
32
32
  "files": [
33
+ "abort-utils.ts",
34
+ "account-manager.ts",
35
+ "browser.ts",
36
+ "commands.ts",
37
+ "extension.ts",
38
+ "hooks.ts",
33
39
  "index.ts",
40
+ "provider.ts",
41
+ "quota.ts",
42
+ "selection.ts",
43
+ "storage.ts",
44
+ "stream-wrapper.ts",
45
+ "usage-client.ts",
46
+ "usage.ts",
34
47
  "README.md",
35
48
  "LICENSE",
36
49
  "assets/**"
@@ -41,8 +54,8 @@
41
54
  "tsgo": "tsgo -p tsconfig.json",
42
55
  "check": "pnpm lint && pnpm tsgo && pnpm test",
43
56
  "pack:dry": "npm pack --dry-run",
44
- "publish:dry": "bun ./scripts/publish.ts --dry-run",
45
- "publish:release": "bun ./scripts/publish.ts"
57
+ "release:dry": "bun ./scripts/publish.ts --dry-run",
58
+ "release:prepare": "bun ./scripts/publish.ts"
46
59
  },
47
60
  "peerDependencies": {
48
61
  "@mariozechner/pi-ai": "*",
package/provider.ts ADDED
@@ -0,0 +1,75 @@
1
+ import {
2
+ type Api,
3
+ type AssistantMessageEventStream,
4
+ type Context,
5
+ getApiProvider,
6
+ getModels,
7
+ type Model,
8
+ type SimpleStreamOptions,
9
+ } from "@mariozechner/pi-ai";
10
+ import type { AccountManager } from "./account-manager";
11
+ import { createStreamWrapper } from "./stream-wrapper";
12
+
13
+ export const PROVIDER_ID = "multicodex";
14
+
15
+ export interface ProviderModelDef {
16
+ id: string;
17
+ name: string;
18
+ reasoning: boolean;
19
+ input: ("text" | "image")[];
20
+ cost: {
21
+ input: number;
22
+ output: number;
23
+ cacheRead: number;
24
+ cacheWrite: number;
25
+ };
26
+ contextWindow: number;
27
+ maxTokens: number;
28
+ }
29
+
30
+ export function getOpenAICodexMirror(): {
31
+ baseUrl: string;
32
+ models: ProviderModelDef[];
33
+ } {
34
+ const sourceModels = getModels("openai-codex");
35
+ return {
36
+ baseUrl: sourceModels[0]?.baseUrl || "https://chatgpt.com/backend-api",
37
+ models: sourceModels.map((model) => ({
38
+ id: model.id,
39
+ name: model.name,
40
+ reasoning: model.reasoning,
41
+ input: model.input,
42
+ cost: model.cost,
43
+ contextWindow: model.contextWindow,
44
+ maxTokens: model.maxTokens,
45
+ })),
46
+ };
47
+ }
48
+
49
+ export function buildMulticodexProviderConfig(accountManager: AccountManager): {
50
+ baseUrl: string;
51
+ apiKey: string;
52
+ api: "openai-codex-responses";
53
+ streamSimple: (
54
+ model: Model<Api>,
55
+ context: Context,
56
+ options?: SimpleStreamOptions,
57
+ ) => AssistantMessageEventStream;
58
+ models: ProviderModelDef[];
59
+ } {
60
+ const mirror = getOpenAICodexMirror();
61
+ const baseProvider = getApiProvider("openai-codex-responses");
62
+ if (!baseProvider) {
63
+ throw new Error(
64
+ "OpenAI Codex provider not available. Please update pi to include openai-codex support.",
65
+ );
66
+ }
67
+
68
+ return {
69
+ baseUrl: mirror.baseUrl,
70
+ apiKey: "managed-by-extension",
71
+ api: "openai-codex-responses",
72
+ streamSimple: createStreamWrapper(accountManager, baseProvider),
73
+ models: mirror.models,
74
+ };
75
+ }
package/quota.ts ADDED
@@ -0,0 +1,5 @@
1
+ export function isQuotaErrorMessage(message: string): boolean {
2
+ return /\b429\b|quota|usage limit|rate.?limit|too many requests|limit reached/i.test(
3
+ message,
4
+ );
5
+ }
package/selection.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { Account } from "./storage";
2
+ import {
3
+ type CodexUsageSnapshot,
4
+ getWeeklyResetAt,
5
+ isUsageUntouched,
6
+ } from "./usage";
7
+
8
+ export function isAccountAvailable(account: Account, now: number): boolean {
9
+ return !account.quotaExhaustedUntil || account.quotaExhaustedUntil <= now;
10
+ }
11
+
12
+ function pickRandomAccount(accounts: Account[]): Account | undefined {
13
+ if (accounts.length === 0) return undefined;
14
+ return accounts[Math.floor(Math.random() * accounts.length)];
15
+ }
16
+
17
+ function pickEarliestWeeklyResetAccount(
18
+ accounts: Account[],
19
+ usageByEmail: Map<string, CodexUsageSnapshot>,
20
+ ): Account | undefined {
21
+ const candidates = accounts
22
+ .map((account) => ({
23
+ account,
24
+ resetAt: getWeeklyResetAt(usageByEmail.get(account.email)),
25
+ }))
26
+ .filter(
27
+ (entry): entry is { account: Account; resetAt: number } =>
28
+ typeof entry.resetAt === "number",
29
+ )
30
+ .sort((a, b) => a.resetAt - b.resetAt);
31
+
32
+ return candidates[0]?.account;
33
+ }
34
+
35
+ export function pickBestAccount(
36
+ accounts: Account[],
37
+ usageByEmail: Map<string, CodexUsageSnapshot>,
38
+ options?: { excludeEmails?: Set<string>; now?: number },
39
+ ): Account | undefined {
40
+ const now = options?.now ?? Date.now();
41
+ const available = accounts.filter(
42
+ (account) =>
43
+ isAccountAvailable(account, now) &&
44
+ !options?.excludeEmails?.has(account.email),
45
+ );
46
+ if (available.length === 0) return undefined;
47
+
48
+ const withUsage = available.filter((account) =>
49
+ usageByEmail.has(account.email),
50
+ );
51
+ const untouched = withUsage.filter((account) =>
52
+ isUsageUntouched(usageByEmail.get(account.email)),
53
+ );
54
+
55
+ if (untouched.length > 0) {
56
+ return (
57
+ pickEarliestWeeklyResetAccount(untouched, usageByEmail) ??
58
+ pickRandomAccount(untouched)
59
+ );
60
+ }
61
+
62
+ const earliestWeeklyReset = pickEarliestWeeklyResetAccount(
63
+ withUsage,
64
+ usageByEmail,
65
+ );
66
+ if (earliestWeeklyReset) return earliestWeeklyReset;
67
+
68
+ return pickRandomAccount(available);
69
+ }
package/storage.ts ADDED
@@ -0,0 +1,49 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ export interface Account {
6
+ email: string;
7
+ accessToken: string;
8
+ refreshToken: string;
9
+ expiresAt: number;
10
+ accountId?: string;
11
+ lastUsed?: number;
12
+ quotaExhaustedUntil?: number;
13
+ }
14
+
15
+ export interface StorageData {
16
+ accounts: Account[];
17
+ activeEmail?: string;
18
+ }
19
+
20
+ export const STORAGE_FILE = path.join(
21
+ os.homedir(),
22
+ ".pi",
23
+ "agent",
24
+ "codex-accounts.json",
25
+ );
26
+
27
+ export function loadStorage(): StorageData {
28
+ try {
29
+ if (fs.existsSync(STORAGE_FILE)) {
30
+ return JSON.parse(fs.readFileSync(STORAGE_FILE, "utf-8")) as StorageData;
31
+ }
32
+ } catch (error) {
33
+ console.error("Failed to load multicodex accounts:", error);
34
+ }
35
+
36
+ return { accounts: [] };
37
+ }
38
+
39
+ export function saveStorage(data: StorageData): void {
40
+ try {
41
+ const dir = path.dirname(STORAGE_FILE);
42
+ if (!fs.existsSync(dir)) {
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ }
45
+ fs.writeFileSync(STORAGE_FILE, JSON.stringify(data, null, 2));
46
+ } catch (error) {
47
+ console.error("Failed to save multicodex accounts:", error);
48
+ }
49
+ }
@@ -0,0 +1,191 @@
1
+ import {
2
+ type Api,
3
+ type AssistantMessage,
4
+ type AssistantMessageEvent,
5
+ type AssistantMessageEventStream,
6
+ type Context,
7
+ createAssistantMessageEventStream,
8
+ type Model,
9
+ type SimpleStreamOptions,
10
+ } from "@mariozechner/pi-ai";
11
+ import { createLinkedAbortController } from "./abort-utils";
12
+ import type { AccountManager } from "./account-manager";
13
+ import { isQuotaErrorMessage } from "./quota";
14
+
15
+ const MAX_ROTATION_RETRIES = 5;
16
+
17
+ type ApiProviderRef = {
18
+ streamSimple: (
19
+ model: Model<Api>,
20
+ context: Context,
21
+ options?: SimpleStreamOptions,
22
+ ) => AssistantMessageEventStream;
23
+ };
24
+
25
+ function withProvider(
26
+ event: AssistantMessageEvent,
27
+ provider: string,
28
+ ): AssistantMessageEvent {
29
+ if ("partial" in event) {
30
+ return { ...event, partial: { ...event.partial, provider } };
31
+ }
32
+ if (event.type === "done") {
33
+ return { ...event, message: { ...event.message, provider } };
34
+ }
35
+ if (event.type === "error") {
36
+ return { ...event, error: { ...event.error, provider } };
37
+ }
38
+ return event;
39
+ }
40
+
41
+ function createErrorAssistantMessage(
42
+ model: Model<Api>,
43
+ message: string,
44
+ ): AssistantMessage {
45
+ return {
46
+ role: "assistant",
47
+ content: [],
48
+ api: model.api,
49
+ provider: model.provider,
50
+ model: model.id,
51
+ usage: {
52
+ input: 0,
53
+ output: 0,
54
+ cacheRead: 0,
55
+ cacheWrite: 0,
56
+ totalTokens: 0,
57
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
58
+ },
59
+ stopReason: "error",
60
+ errorMessage: message,
61
+ timestamp: Date.now(),
62
+ };
63
+ }
64
+
65
+ function getErrorMessage(error: unknown): string {
66
+ if (error instanceof Error) return error.message;
67
+ return typeof error === "string" ? error : JSON.stringify(error);
68
+ }
69
+
70
+ export function createStreamWrapper(
71
+ accountManager: AccountManager,
72
+ baseProvider: ApiProviderRef,
73
+ ) {
74
+ return (
75
+ model: Model<Api>,
76
+ context: Context,
77
+ options?: SimpleStreamOptions,
78
+ ): AssistantMessageEventStream => {
79
+ const stream = createAssistantMessageEventStream();
80
+
81
+ (async () => {
82
+ try {
83
+ const excludedEmails = new Set<string>();
84
+ for (let attempt = 0; attempt <= MAX_ROTATION_RETRIES; attempt++) {
85
+ const now = Date.now();
86
+ const manual = accountManager.getAvailableManualAccount({
87
+ excludeEmails: excludedEmails,
88
+ now,
89
+ });
90
+ const usingManual = Boolean(manual);
91
+ let account = manual;
92
+ if (!account) {
93
+ if (accountManager.hasManualAccount()) {
94
+ accountManager.clearManualAccount();
95
+ }
96
+ account = await accountManager.activateBestAccount({
97
+ excludeEmails: excludedEmails,
98
+ signal: options?.signal,
99
+ });
100
+ }
101
+ if (!account) {
102
+ throw new Error(
103
+ "No available Multicodex accounts. Please use /multicodex-login.",
104
+ );
105
+ }
106
+
107
+ const token = await accountManager.ensureValidToken(account);
108
+ const abortController = createLinkedAbortController(options?.signal);
109
+
110
+ const internalModel: Model<"openai-codex-responses"> = {
111
+ ...(model as Model<"openai-codex-responses">),
112
+ provider: "openai-codex",
113
+ api: "openai-codex-responses",
114
+ };
115
+
116
+ const inner = baseProvider.streamSimple(
117
+ {
118
+ ...internalModel,
119
+ headers: {
120
+ ...(internalModel.headers || {}),
121
+ "X-Multicodex-Account": account.email,
122
+ },
123
+ },
124
+ context,
125
+ {
126
+ ...options,
127
+ apiKey: token,
128
+ signal: abortController.signal,
129
+ },
130
+ );
131
+
132
+ let forwardedAny = false;
133
+ let retry = false;
134
+
135
+ for await (const event of inner) {
136
+ if (event.type === "error") {
137
+ const msg = event.error.errorMessage || "";
138
+ const isQuota = isQuotaErrorMessage(msg);
139
+
140
+ if (isQuota && !forwardedAny && attempt < MAX_ROTATION_RETRIES) {
141
+ await accountManager.handleQuotaExceeded(account, {
142
+ signal: options?.signal,
143
+ });
144
+ if (usingManual) {
145
+ accountManager.clearManualAccount();
146
+ }
147
+ excludedEmails.add(account.email);
148
+ abortController.abort();
149
+ retry = true;
150
+ break;
151
+ }
152
+
153
+ stream.push(withProvider(event, model.provider));
154
+ stream.end();
155
+ return;
156
+ }
157
+
158
+ forwardedAny = true;
159
+ stream.push(withProvider(event, model.provider));
160
+
161
+ if (event.type === "done") {
162
+ stream.end();
163
+ return;
164
+ }
165
+ }
166
+
167
+ if (retry) {
168
+ continue;
169
+ }
170
+
171
+ stream.end();
172
+ return;
173
+ }
174
+ } catch (error) {
175
+ const message = getErrorMessage(error);
176
+ const errorEvent: AssistantMessageEvent = {
177
+ type: "error",
178
+ reason: "error",
179
+ error: createErrorAssistantMessage(
180
+ model,
181
+ `Multicodex failed: ${message}`,
182
+ ),
183
+ };
184
+ stream.push(withProvider(errorEvent, model.provider));
185
+ stream.end();
186
+ }
187
+ })();
188
+
189
+ return stream;
190
+ };
191
+ }
@@ -0,0 +1,50 @@
1
+ import { createTimeoutController } from "./abort-utils";
2
+ import { type CodexUsageSnapshot, parseCodexUsageResponse } from "./usage";
3
+
4
+ interface WhamUsageResponse {
5
+ rate_limit?: {
6
+ primary_window?: {
7
+ reset_at?: number;
8
+ used_percent?: number;
9
+ };
10
+ secondary_window?: {
11
+ reset_at?: number;
12
+ used_percent?: number;
13
+ };
14
+ };
15
+ }
16
+
17
+ export async function fetchCodexUsage(
18
+ accessToken: string,
19
+ accountId: string | undefined,
20
+ options?: { signal?: AbortSignal; timeoutMs?: number },
21
+ ): Promise<CodexUsageSnapshot> {
22
+ const { controller, clear } = createTimeoutController(
23
+ options?.signal,
24
+ options?.timeoutMs ?? 10_000,
25
+ );
26
+
27
+ try {
28
+ const headers: Record<string, string> = {
29
+ Authorization: `Bearer ${accessToken}`,
30
+ Accept: "application/json",
31
+ };
32
+ if (accountId) {
33
+ headers["ChatGPT-Account-Id"] = accountId;
34
+ }
35
+
36
+ const response = await fetch("https://chatgpt.com/backend-api/wham/usage", {
37
+ headers,
38
+ signal: controller.signal,
39
+ });
40
+
41
+ if (!response.ok) {
42
+ throw new Error(`Usage request failed: ${response.status}`);
43
+ }
44
+
45
+ const data = (await response.json()) as WhamUsageResponse;
46
+ return { ...parseCodexUsageResponse(data), fetchedAt: Date.now() };
47
+ } finally {
48
+ clear();
49
+ }
50
+ }
package/usage.ts ADDED
@@ -0,0 +1,86 @@
1
+ interface CodexUsageWindow {
2
+ usedPercent?: number;
3
+ resetAt?: number;
4
+ }
5
+
6
+ export interface CodexUsageSnapshot {
7
+ primary?: CodexUsageWindow;
8
+ secondary?: CodexUsageWindow;
9
+ fetchedAt: number;
10
+ }
11
+
12
+ interface WhamUsageResponse {
13
+ rate_limit?: {
14
+ primary_window?: WhamUsageWindow;
15
+ secondary_window?: WhamUsageWindow;
16
+ };
17
+ }
18
+
19
+ type WhamUsageWindow = {
20
+ reset_at?: number;
21
+ used_percent?: number;
22
+ };
23
+
24
+ function normalizeUsedPercent(value?: number): number | undefined {
25
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
26
+ return Math.min(100, Math.max(0, value));
27
+ }
28
+
29
+ function normalizeResetAt(value?: number): number | undefined {
30
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
31
+ return value * 1000;
32
+ }
33
+
34
+ function parseUsageWindow(
35
+ window?: WhamUsageWindow,
36
+ ): CodexUsageWindow | undefined {
37
+ if (!window) return undefined;
38
+ const usedPercent = normalizeUsedPercent(window.used_percent);
39
+ const resetAt = normalizeResetAt(window.reset_at);
40
+ if (usedPercent === undefined && resetAt === undefined) return undefined;
41
+ return { usedPercent, resetAt };
42
+ }
43
+
44
+ export function parseCodexUsageResponse(
45
+ data: WhamUsageResponse,
46
+ ): Omit<CodexUsageSnapshot, "fetchedAt"> {
47
+ return {
48
+ primary: parseUsageWindow(data.rate_limit?.primary_window),
49
+ secondary: parseUsageWindow(data.rate_limit?.secondary_window),
50
+ };
51
+ }
52
+
53
+ export function isUsageUntouched(usage?: CodexUsageSnapshot): boolean {
54
+ const primary = usage?.primary?.usedPercent;
55
+ const secondary = usage?.secondary?.usedPercent;
56
+ if (primary === undefined || secondary === undefined) return false;
57
+ return primary === 0 && secondary === 0;
58
+ }
59
+
60
+ export function getNextResetAt(usage?: CodexUsageSnapshot): number | undefined {
61
+ const candidates = [
62
+ usage?.primary?.resetAt,
63
+ usage?.secondary?.resetAt,
64
+ ].filter((value): value is number => typeof value === "number");
65
+ if (candidates.length === 0) return undefined;
66
+ return Math.min(...candidates);
67
+ }
68
+
69
+ export function getWeeklyResetAt(
70
+ usage?: CodexUsageSnapshot,
71
+ ): number | undefined {
72
+ const resetAt = usage?.secondary?.resetAt;
73
+ return typeof resetAt === "number" ? resetAt : undefined;
74
+ }
75
+
76
+ export function formatResetAt(resetAt?: number): string {
77
+ if (!resetAt) return "unknown";
78
+ const diffMs = resetAt - Date.now();
79
+ if (diffMs <= 0) return "now";
80
+ const diffMinutes = Math.max(1, Math.round(diffMs / 60000));
81
+ if (diffMinutes < 60) return `in ${diffMinutes}m`;
82
+ const diffHours = Math.round(diffMinutes / 60);
83
+ if (diffHours < 48) return `in ${diffHours}h`;
84
+ const diffDays = Math.round(diffHours / 24);
85
+ return `in ${diffDays}d`;
86
+ }