From f33d67f8edef51f2e20d1cae51a26777fa645013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chindri=C8=99=20Mihai=20Alexandru?= <12643176+chindris-mihai-alexandru@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:44:28 +0200 Subject: [PATCH 1/4] feat: add multi-account round-robin account pool --- index.ts | 156 +++++++++++++++++++++------- lib/account-pool.ts | 207 +++++++++++++++++++++++++++++++++++++ lib/config.ts | 2 + lib/types.ts | 18 ++++ test/account-pool.test.ts | 92 +++++++++++++++++ test/plugin-config.test.ts | 30 +++++- 6 files changed, 463 insertions(+), 42 deletions(-) create mode 100644 lib/account-pool.ts create mode 100644 test/account-pool.test.ts diff --git a/index.ts b/index.ts index 20ce21b..4b68a6b 100644 --- a/index.ts +++ b/index.ts @@ -29,6 +29,7 @@ import { decodeJWT, exchangeAuthorizationCode, parseAuthorizationInput, + refreshAccessToken, REDIRECT_URI, } from "./lib/auth/auth.js"; import { openBrowserUrl } from "./lib/auth/browser.js"; @@ -41,8 +42,6 @@ import { ERROR_MESSAGES, JWT_CLAIM_PATH, LOG_STAGES, - OPENAI_HEADER_VALUES, - OPENAI_HEADERS, PLUGIN_NAME, PROVIDER_ID, } from "./lib/constants.js"; @@ -52,12 +51,11 @@ import { extractRequestUrl, handleErrorResponse, handleSuccessResponse, - refreshAndUpdateToken, rewriteUrlForCodex, - shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js"; -import type { UserConfig } from "./lib/types.js"; +import type { RequestBody, UserConfig } from "./lib/types.js"; +import { AccountPool } from "./lib/account-pool.js"; /** * OpenAI Codex OAuth authentication plugin for opencode @@ -140,6 +138,22 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { // Priority: CODEX_MODE env var > config file > default (true) const pluginConfig = loadPluginConfig(); const codexMode = getCodexMode(pluginConfig); + const accountSelectionStrategy = + pluginConfig.accountSelectionStrategy === "sticky" ? "sticky" : "round-robin"; + const rateLimitCooldownMs = pluginConfig.rateLimitCooldownMs; + + const accountPool = AccountPool.load(); + accountPool.upsert({ + accountId, + access: auth.access, + refresh: auth.refresh, + expires: auth.expires, + email: + typeof decoded?.email === "string" + ? decoded.email + : undefined, + }); + accountPool.save(); // Return SDK configuration return { @@ -164,10 +178,22 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { input: Request | string | URL, init?: RequestInit, ): Promise { - // Step 1: Check and refresh token if needed - let currentAuth = await getAuth(); - if (shouldRefreshToken(currentAuth)) { - currentAuth = await refreshAndUpdateToken(currentAuth, client); + const latestAuth = await getAuth(); + if (latestAuth.type === "oauth") { + const latestDecoded = decodeJWT(latestAuth.access); + const latestAccountId = latestDecoded?.[JWT_CLAIM_PATH]?.chatgpt_account_id; + if (latestAccountId) { + accountPool.upsert({ + accountId: latestAccountId, + access: latestAuth.access, + refresh: latestAuth.refresh, + expires: latestAuth.expires, + email: + typeof latestDecoded?.email === "string" + ? latestDecoded.email + : undefined, + }); + } } // Step 2: Extract and rewrite URL for Codex backend @@ -189,39 +215,95 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); const requestInit = transformation?.updatedInit ?? init; - // Step 4: Create headers with OAuth and ChatGPT account info - const accessToken = - currentAuth.type === "oauth" ? currentAuth.access : ""; - const headers = createCodexHeaders( - requestInit, - accountId, - accessToken, - { - model: transformation?.body.model, - promptCacheKey: (transformation?.body as any)?.prompt_cache_key, - }, - ); + const attempts = Math.max(accountPool.count(), 1); + let lastRateLimitResponse: Response | null = null; + for (let i = 0; i < attempts; i++) { + const selected = accountPool.next(accountSelectionStrategy); + if (!selected) { + break; + } - // Step 5: Make request to Codex API - const response = await fetch(url, { - ...requestInit, - headers, - }); + if (selected.expires < Date.now()) { + const refreshed = await refreshAccessToken(selected.refresh); + if (refreshed.type === "failed") { + accountPool.markRateLimited( + selected.accountId, + new Headers(), + rateLimitCooldownMs, + ); + accountPool.save(); + continue; + } + accountPool.replaceAuth( + selected.accountId, + refreshed.access, + refreshed.refresh, + refreshed.expires, + ); + if (latestAuth.type === "oauth" && latestAuth.refresh === selected.refresh) { + await client.auth.set({ + path: { id: "openai" }, + body: { + type: "oauth", + access: refreshed.access, + refresh: refreshed.refresh, + expires: refreshed.expires, + }, + }); + } + } - // Step 6: Log response - logRequest(LOG_STAGES.RESPONSE, { - status: response.status, - ok: response.ok, - statusText: response.statusText, - headers: Object.fromEntries(response.headers.entries()), - }); + const headers = createCodexHeaders( + requestInit, + selected.accountId, + selected.access, + { + model: transformation?.body.model, + promptCacheKey: (transformation?.body as RequestBody | undefined) + ?.prompt_cache_key, + }, + ); - // Step 7: Handle error or success response - if (!response.ok) { - return await handleErrorResponse(response); + const response = await fetch(url, { + ...requestInit, + headers, + }); + + logRequest(LOG_STAGES.RESPONSE, { + status: response.status, + ok: response.ok, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + accountId: selected.accountId, + attempt: i + 1, + totalAttempts: attempts, + }); + + if (!response.ok) { + const mapped = await handleErrorResponse(response); + if (mapped.status === 429) { + accountPool.markRateLimited( + selected.accountId, + mapped.headers, + rateLimitCooldownMs, + ); + accountPool.save(); + lastRateLimitResponse = mapped; + continue; + } + accountPool.save(); + return mapped; + } + + accountPool.save(); + return await handleSuccessResponse(response, isStreaming); } - return await handleSuccessResponse(response, isStreaming); + accountPool.save(); + if (lastRateLimitResponse) { + return lastRateLimitResponse; + } + throw new Error("No available ChatGPT account in account pool"); }, }; }, diff --git a/lib/account-pool.ts b/lib/account-pool.ts new file mode 100644 index 0000000..b9fc985 --- /dev/null +++ b/lib/account-pool.ts @@ -0,0 +1,207 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { AccountPoolEntry, AccountPoolStorage } from "./types.js"; + +const STORAGE_VERSION = 1; +const DEFAULT_COOLDOWN_MS = 60_000; + +function storagePath(): string { + if (process.env.OPENAI_CODEX_ACCOUNTS_PATH) { + return process.env.OPENAI_CODEX_ACCOUNTS_PATH; + } + return join(homedir(), ".opencode", "openai-codex-accounts.json"); +} + +function clampIndex(index: number, size: number): number { + if (!Number.isFinite(index) || size <= 0) return 0; + const n = Math.floor(index); + if (n < 0) return 0; + if (n >= size) return size - 1; + return n; +} + +function now(): number { + return Date.now(); +} + +function normalizeEntry(entry: AccountPoolEntry): AccountPoolEntry | null { + if (!entry.accountId || !entry.refresh || !entry.access || !Number.isFinite(entry.expires)) { + return null; + } + return { + accountId: entry.accountId, + refresh: entry.refresh, + access: entry.access, + expires: Math.floor(entry.expires), + email: entry.email, + lastUsed: entry.lastUsed, + rateLimitedUntil: entry.rateLimitedUntil, + }; +} + +function parseStorage(raw: string): AccountPoolStorage | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const obj = parsed as Partial; + const accounts = Array.isArray(obj.accounts) + ? obj.accounts + .map((e) => normalizeEntry(e as AccountPoolEntry)) + .filter((e): e is AccountPoolEntry => e !== null) + : []; + return { + version: STORAGE_VERSION, + activeIndex: clampIndex(Number(obj.activeIndex ?? 0), accounts.length || 1), + accounts, + }; +} + +function normalizeCooldown(cooldownMs?: number): number { + if (!Number.isFinite(cooldownMs) || (cooldownMs as number) < 1000) { + return DEFAULT_COOLDOWN_MS; + } + return Math.floor(cooldownMs as number); +} + +function retryAfterFromHeaders(headers: Headers, fallbackMs: number): number { + const retryAfterMs = headers.get("retry-after-ms"); + if (retryAfterMs) { + const parsed = Number.parseInt(retryAfterMs, 10); + if (!Number.isNaN(parsed) && parsed > 0) return parsed; + } + const retryAfter = headers.get("retry-after"); + if (retryAfter) { + const parsed = Number.parseInt(retryAfter, 10); + if (!Number.isNaN(parsed) && parsed > 0) return parsed * 1000; + } + const codexPrimary = headers.get("x-codex-primary-reset-after-seconds"); + if (codexPrimary) { + const parsed = Number.parseInt(codexPrimary, 10); + if (!Number.isNaN(parsed) && parsed > 0) return parsed * 1000; + } + return fallbackMs; +} + +export class AccountPool { + private accounts: AccountPoolEntry[] = []; + private activeIndex = 0; + + static load(): AccountPool { + const pool = new AccountPool(); + const path = storagePath(); + if (!existsSync(path)) return pool; + try { + const raw = readFileSync(path, "utf8"); + const parsed = parseStorage(raw); + if (!parsed) return pool; + pool.accounts = parsed.accounts; + pool.activeIndex = clampIndex(parsed.activeIndex, parsed.accounts.length || 1); + return pool; + } catch { + return pool; + } + } + + save(): void { + const path = storagePath(); + const dir = dirname(path); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const payload: AccountPoolStorage = { + version: STORAGE_VERSION, + activeIndex: clampIndex(this.activeIndex, this.accounts.length || 1), + accounts: this.accounts, + }; + writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + } + + upsert(entry: AccountPoolEntry): void { + const normalized = normalizeEntry(entry); + if (!normalized) return; + const byAccount = this.accounts.findIndex((a) => a.accountId === normalized.accountId); + const byEmail = + normalized.email && byAccount < 0 + ? this.accounts.findIndex((a) => a.email && a.email === normalized.email) + : -1; + const idx = byAccount >= 0 ? byAccount : byEmail; + if (idx >= 0) { + const existing = this.accounts[idx]; + if (!existing) return; + this.accounts[idx] = { + ...existing, + ...normalized, + rateLimitedUntil: existing.rateLimitedUntil, + }; + } else { + this.accounts.push(normalized); + } + this.activeIndex = clampIndex(this.activeIndex, this.accounts.length || 1); + } + + count(): number { + return this.accounts.length; + } + + getAvailableCount(): number { + const t = now(); + return this.accounts.filter((a) => !a.rateLimitedUntil || a.rateLimitedUntil <= t).length; + } + + markRateLimited(accountId: string, headers: Headers, cooldownMs?: number): void { + const account = this.accounts.find((a) => a.accountId === accountId); + if (!account) return; + const fallback = normalizeCooldown(cooldownMs); + const retryAfter = retryAfterFromHeaders(headers, fallback); + account.rateLimitedUntil = now() + retryAfter; + } + + next(strategy: "sticky" | "round-robin"): AccountPoolEntry | null { + if (this.accounts.length === 0) return null; + this.clearExpiredLimits(); + if (strategy === "sticky") { + const current = this.accounts[this.activeIndex]; + if (current && !this.isLimited(current)) { + current.lastUsed = now(); + return current; + } + } + + const start = strategy === "round-robin" ? (this.activeIndex + 1) % this.accounts.length : this.activeIndex; + for (let i = 0; i < this.accounts.length; i++) { + const idx = (start + i) % this.accounts.length; + const candidate = this.accounts[idx]; + if (!candidate || this.isLimited(candidate)) continue; + this.activeIndex = idx; + candidate.lastUsed = now(); + return candidate; + } + return null; + } + + replaceAuth(accountId: string, access: string, refresh: string, expires: number): void { + const account = this.accounts.find((a) => a.accountId === accountId); + if (!account) return; + account.access = access; + account.refresh = refresh; + account.expires = expires; + } + + private isLimited(account: AccountPoolEntry): boolean { + return !!account.rateLimitedUntil && account.rateLimitedUntil > now(); + } + + private clearExpiredLimits(): void { + const t = now(); + for (const account of this.accounts) { + if (account.rateLimitedUntil && account.rateLimitedUntil <= t) { + delete account.rateLimitedUntil; + } + } + } +} diff --git a/lib/config.ts b/lib/config.ts index 2d7857e..a81f326 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -11,6 +11,8 @@ const CONFIG_PATH = join(homedir(), ".opencode", "openai-codex-auth-config.json" */ const DEFAULT_CONFIG: PluginConfig = { codexMode: true, + accountSelectionStrategy: "round-robin", + rateLimitCooldownMs: 60_000, }; /** diff --git a/lib/types.ts b/lib/types.ts index 40e7bc4..c5ac9fc 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -9,6 +9,24 @@ export interface PluginConfig { * @default true */ codexMode?: boolean; + accountSelectionStrategy?: "sticky" | "round-robin"; + rateLimitCooldownMs?: number; +} + +export interface AccountPoolEntry { + accountId: string; + refresh: string; + access: string; + expires: number; + email?: string; + lastUsed?: number; + rateLimitedUntil?: number; +} + +export interface AccountPoolStorage { + version: 1; + activeIndex: number; + accounts: AccountPoolEntry[]; } /** diff --git a/test/account-pool.test.ts b/test/account-pool.test.ts new file mode 100644 index 0000000..9eef795 --- /dev/null +++ b/test/account-pool.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { AccountPool } from "../lib/account-pool.js"; + +const testPath = join(tmpdir(), "opencode-openai-codex-auth-account-pool-test.json"); + +function cleanup(): void { + if (existsSync(testPath)) { + rmSync(testPath, { force: true }); + } +} + +describe("AccountPool", () => { + beforeEach(() => { + process.env.OPENAI_CODEX_ACCOUNTS_PATH = testPath; + cleanup(); + }); + + afterEach(() => { + cleanup(); + delete process.env.OPENAI_CODEX_ACCOUNTS_PATH; + }); + + it("rotates accounts in round-robin mode", () => { + const pool = AccountPool.load(); + pool.upsert({ + accountId: "a1", + access: "access-1", + refresh: "refresh-1", + expires: Date.now() + 60_000, + }); + pool.upsert({ + accountId: "a2", + access: "access-2", + refresh: "refresh-2", + expires: Date.now() + 60_000, + }); + + const one = pool.next("round-robin"); + const two = pool.next("round-robin"); + const three = pool.next("round-robin"); + + expect(one?.accountId).toBe("a2"); + expect(two?.accountId).toBe("a1"); + expect(three?.accountId).toBe("a2"); + }); + + it("keeps same account in sticky mode when available", () => { + const pool = AccountPool.load(); + pool.upsert({ + accountId: "a1", + access: "access-1", + refresh: "refresh-1", + expires: Date.now() + 60_000, + }); + pool.upsert({ + accountId: "a2", + access: "access-2", + refresh: "refresh-2", + expires: Date.now() + 60_000, + }); + + const current = pool.next("sticky"); + const next = pool.next("sticky"); + + expect(current?.accountId).toBeDefined(); + expect(next?.accountId).toBe(current?.accountId); + }); + + it("skips rate-limited account and picks another", () => { + const pool = AccountPool.load(); + pool.upsert({ + accountId: "a1", + access: "access-1", + refresh: "refresh-1", + expires: Date.now() + 60_000, + }); + pool.upsert({ + accountId: "a2", + access: "access-2", + refresh: "refresh-2", + expires: Date.now() + 60_000, + }); + + pool.markRateLimited("a2", new Headers({ "retry-after": "60" })); + const selected = pool.next("round-robin"); + + expect(selected?.accountId).toBe("a1"); + }); +}); diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index ba95d95..0b66788 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -39,7 +39,11 @@ describe('Plugin Configuration', () => { const config = loadPluginConfig(); - expect(config).toEqual({ codexMode: true }); + expect(config).toEqual({ + codexMode: true, + accountSelectionStrategy: 'round-robin', + rateLimitCooldownMs: 60000, + }); expect(mockExistsSync).toHaveBeenCalledWith( path.join(os.homedir(), '.opencode', 'openai-codex-auth-config.json') ); @@ -51,7 +55,11 @@ describe('Plugin Configuration', () => { const config = loadPluginConfig(); - expect(config).toEqual({ codexMode: false }); + expect(config).toEqual({ + codexMode: false, + accountSelectionStrategy: 'round-robin', + rateLimitCooldownMs: 60000, + }); }); it('should merge user config with defaults', () => { @@ -60,7 +68,11 @@ describe('Plugin Configuration', () => { const config = loadPluginConfig(); - expect(config).toEqual({ codexMode: true }); + expect(config).toEqual({ + codexMode: true, + accountSelectionStrategy: 'round-robin', + rateLimitCooldownMs: 60000, + }); }); it('should handle invalid JSON gracefully', () => { @@ -70,7 +82,11 @@ describe('Plugin Configuration', () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const config = loadPluginConfig(); - expect(config).toEqual({ codexMode: true }); + expect(config).toEqual({ + codexMode: true, + accountSelectionStrategy: 'round-robin', + rateLimitCooldownMs: 60000, + }); expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); }); @@ -84,7 +100,11 @@ describe('Plugin Configuration', () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const config = loadPluginConfig(); - expect(config).toEqual({ codexMode: true }); + expect(config).toEqual({ + codexMode: true, + accountSelectionStrategy: 'round-robin', + rateLimitCooldownMs: 60000, + }); expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); }); From 942dcfb472a2f624fe85d4626028ceeab6a5711a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chindri=C8=99=20Mihai=20Alexandru?= <12643176+chindris-mihai-alexandru@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:44:33 +0200 Subject: [PATCH 2/4] docs: document account rotation and cleanup path --- README.md | 1 + docs/configuration.md | 12 +++++++++++- scripts/install-opencode-codex-auth.js | 3 +++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6708114..e43c80b 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Minimal configs are not supported for GPT‑5.x; use the full configs above. - Variant system support (v1.0.210+) + legacy presets - Multimodal input enabled for all models - Usage‑aware errors + automatic token refresh +- Multi-account pool with round-robin or sticky selection (`~/.opencode/openai-codex-accounts.json`) --- ## 📚 Docs - Getting Started: `docs/getting-started.md` diff --git a/docs/configuration.md b/docs/configuration.md index 29ae0fe..28b684c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -367,7 +367,9 @@ Advanced plugin settings in `~/.opencode/openai-codex-auth-config.json`: ```json { - "codexMode": true + "codexMode": true, + "accountSelectionStrategy": "round-robin", + "rateLimitCooldownMs": 60000 } ``` @@ -389,6 +391,13 @@ CODEX_MODE=0 opencode run "task" # Temporarily disable CODEX_MODE=1 opencode run "task" # Temporarily enable ``` +### Multi-account rotation + +- `accountSelectionStrategy`: `"round-robin"` (default) rotates on each request, `"sticky"` keeps current account until limited. +- `rateLimitCooldownMs`: fallback cooldown when reset headers are missing. +- Account pool is stored in `~/.opencode/openai-codex-accounts.json` and is auto-merged when you log in again. +- To add more accounts, run `opencode auth login` again with another ChatGPT account. + ### Prompt caching - When OpenCode provides a `prompt_cache_key` (its session identifier), the plugin forwards it directly to Codex. @@ -417,6 +426,7 @@ CODEX_MODE=1 opencode run "task" # Temporarily enable - `~/.config/opencode/opencode.json` - Global config (fallback) - `/.opencode.json` - Project-specific config - `~/.opencode/openai-codex-auth-config.json` - Plugin config +- `~/.opencode/openai-codex-accounts.json` - Multi-account pool state --- diff --git a/scripts/install-opencode-codex-auth.js b/scripts/install-opencode-codex-auth.js index aaaeddf..eea08b0 100755 --- a/scripts/install-opencode-codex-auth.js +++ b/scripts/install-opencode-codex-auth.js @@ -56,6 +56,7 @@ const pluginConfigPath = join( ".opencode", "openai-codex-auth-config.json", ); +const pluginAccountsPath = join(homedir(), ".opencode", "openai-codex-accounts.json"); const pluginLogDir = join(homedir(), ".opencode", "logs", "codex-plugin"); const opencodeCacheDir = join(homedir(), ".opencode", "cache"); @@ -252,10 +253,12 @@ async function clearPluginArtifacts() { if (dryRun) { log(`[dry-run] Would remove ${opencodeAuthPath}`); log(`[dry-run] Would remove ${pluginConfigPath}`); + log(`[dry-run] Would remove ${pluginAccountsPath}`); log(`[dry-run] Would remove ${pluginLogDir}`); } else { await rm(opencodeAuthPath, { force: true }); await rm(pluginConfigPath, { force: true }); + await rm(pluginAccountsPath, { force: true }); await rm(pluginLogDir, { recursive: true, force: true }); } From b919faa2932d6d1f25b277c4d55bf65a1b1adbf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chindri=C8=99=20Mihai=20Alexandru?= <12643176+chindris-mihai-alexandru@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:58:55 +0200 Subject: [PATCH 3/4] fix: harden account rotation refresh and persistence --- index.ts | 59 ++++++++++++++++++++++++++++++++------- lib/account-pool.ts | 33 ++++++++++++++++++++-- test/account-pool.test.ts | 43 ++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 12 deletions(-) diff --git a/index.ts b/index.ts index 4b68a6b..b69f42f 100644 --- a/index.ts +++ b/index.ts @@ -45,7 +45,7 @@ import { PLUGIN_NAME, PROVIDER_ID, } from "./lib/constants.js"; -import { logRequest, logDebug } from "./lib/logger.js"; +import { logRequest, logDebug, logWarn } from "./lib/logger.js"; import { createCodexHeaders, extractRequestUrl, @@ -143,6 +143,13 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { const rateLimitCooldownMs = pluginConfig.rateLimitCooldownMs; const accountPool = AccountPool.load(); + const savePool = () => { + try { + accountPool.save(); + } catch (error) { + logWarn("Failed to persist account pool", error); + } + }; accountPool.upsert({ accountId, access: auth.access, @@ -153,7 +160,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { ? decoded.email : undefined, }); - accountPool.save(); + savePool(); // Return SDK configuration return { @@ -179,9 +186,11 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { init?: RequestInit, ): Promise { const latestAuth = await getAuth(); + let latestAccountIdFromAuth: string | undefined; if (latestAuth.type === "oauth") { const latestDecoded = decodeJWT(latestAuth.access); const latestAccountId = latestDecoded?.[JWT_CLAIM_PATH]?.chatgpt_account_id; + latestAccountIdFromAuth = latestAccountId; if (latestAccountId) { accountPool.upsert({ accountId: latestAccountId, @@ -204,7 +213,14 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { // Instructions are fetched per model family (codex-max, codex, gpt-5.1) // Capture original stream value before transformation // generateText() sends no stream field, streamText() sends stream=true - const originalBody = init?.body ? JSON.parse(init.body as string) : {}; + let originalBody: Partial = {}; + if (typeof init?.body === "string") { + try { + originalBody = JSON.parse(init.body) as Partial; + } catch { + originalBody = {}; + } + } const isStreaming = originalBody.stream === true; const transformation = await transformRequestForCodex( @@ -224,6 +240,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { } if (selected.expires < Date.now()) { + const selectedRefreshBefore = selected.refresh; const refreshed = await refreshAccessToken(selected.refresh); if (refreshed.type === "failed") { accountPool.markRateLimited( @@ -231,7 +248,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { new Headers(), rateLimitCooldownMs, ); - accountPool.save(); + savePool(); continue; } accountPool.replaceAuth( @@ -240,7 +257,11 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { refreshed.refresh, refreshed.expires, ); - if (latestAuth.type === "oauth" && latestAuth.refresh === selected.refresh) { + if ( + latestAuth.type === "oauth" && + (latestAuth.refresh === selectedRefreshBefore || + latestAccountIdFromAuth === selected.accountId) + ) { await client.auth.set({ path: { id: "openai" }, body: { @@ -287,23 +308,41 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { mapped.headers, rateLimitCooldownMs, ); - accountPool.save(); + savePool(); lastRateLimitResponse = mapped; continue; } - accountPool.save(); + savePool(); return mapped; } - accountPool.save(); + savePool(); return await handleSuccessResponse(response, isStreaming); } - accountPool.save(); + savePool(); if (lastRateLimitResponse) { return lastRateLimitResponse; } - throw new Error("No available ChatGPT account in account pool"); + const retryAfterMs = accountPool.getMinRetryAfterMs(); + const retryAfterSeconds = retryAfterMs ? Math.max(1, Math.ceil(retryAfterMs / 1000)) : null; + return new Response( + JSON.stringify({ + error: { + code: "usage_limit_reached", + message: "All ChatGPT accounts are temporarily rate-limited", + }, + }), + { + status: 429, + headers: { + "content-type": "application/json", + ...(retryAfterSeconds + ? { "retry-after": String(retryAfterSeconds) } + : {}), + }, + }, + ); }, }; }, diff --git a/lib/account-pool.ts b/lib/account-pool.ts index b9fc985..f5e0a65 100644 --- a/lib/account-pool.ts +++ b/lib/account-pool.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import type { AccountPoolEntry, AccountPoolStorage } from "./types.js"; @@ -118,7 +118,16 @@ export class AccountPool { activeIndex: clampIndex(this.activeIndex, this.accounts.length || 1), accounts: this.accounts, }; - writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + const tempPath = `${path}.tmp.${process.pid}.${Date.now()}`; + writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + renameSync(tempPath, path); + try { + chmodSync(path, 0o600); + } catch { + } } upsert(entry: AccountPoolEntry): void { @@ -133,9 +142,16 @@ export class AccountPool { if (idx >= 0) { const existing = this.accounts[idx]; if (!existing) return; + const incomingIsOlder = normalized.expires < existing.expires; + const nextAccess = incomingIsOlder ? existing.access : normalized.access; + const nextRefresh = incomingIsOlder ? existing.refresh : normalized.refresh; + const nextExpires = incomingIsOlder ? existing.expires : normalized.expires; this.accounts[idx] = { ...existing, ...normalized, + access: nextAccess, + refresh: nextRefresh, + expires: nextExpires, rateLimitedUntil: existing.rateLimitedUntil, }; } else { @@ -153,6 +169,19 @@ export class AccountPool { return this.accounts.filter((a) => !a.rateLimitedUntil || a.rateLimitedUntil <= t).length; } + getMinRetryAfterMs(): number | null { + const t = now(); + let min: number | null = null; + for (const account of this.accounts) { + if (!account.rateLimitedUntil || account.rateLimitedUntil <= t) continue; + const remaining = account.rateLimitedUntil - t; + if (min === null || remaining < min) { + min = remaining; + } + } + return min; + } + markRateLimited(accountId: string, headers: Headers, cooldownMs?: number): void { const account = this.accounts.find((a) => a.accountId === accountId); if (!account) return; diff --git a/test/account-pool.test.ts b/test/account-pool.test.ts index 9eef795..eb2380e 100644 --- a/test/account-pool.test.ts +++ b/test/account-pool.test.ts @@ -89,4 +89,47 @@ describe("AccountPool", () => { expect(selected?.accountId).toBe("a1"); }); + + it("does not overwrite newer credentials with stale auth", () => { + const pool = AccountPool.load(); + pool.upsert({ + accountId: "a1", + access: "access-new", + refresh: "refresh-new", + expires: Date.now() + 120_000, + }); + pool.upsert({ + accountId: "a1", + access: "access-old", + refresh: "refresh-old", + expires: Date.now() + 10_000, + }); + + const selected = pool.next("sticky"); + expect(selected?.access).toBe("access-new"); + expect(selected?.refresh).toBe("refresh-new"); + }); + + it("returns minimum retry-after for limited accounts", () => { + const pool = AccountPool.load(); + pool.upsert({ + accountId: "a1", + access: "access-1", + refresh: "refresh-1", + expires: Date.now() + 60_000, + }); + pool.upsert({ + accountId: "a2", + access: "access-2", + refresh: "refresh-2", + expires: Date.now() + 60_000, + }); + pool.markRateLimited("a1", new Headers({ "retry-after": "60" })); + pool.markRateLimited("a2", new Headers({ "retry-after": "10" })); + + const minRetryAfter = pool.getMinRetryAfterMs(); + expect(minRetryAfter).not.toBeNull(); + expect((minRetryAfter as number) / 1000).toBeLessThanOrEqual(10); + expect((minRetryAfter as number) / 1000).toBeGreaterThan(8); + }); }); From 2ee8c789b66fa1b0ed97fa64a7c0d127082cda8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chindri=C8=99=20Mihai=20Alexandru?= <12643176+chindris-mihai-alexandru@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:30:44 +0200 Subject: [PATCH 4/4] fix: tighten retry parsing and refresh-token resilience --- index.ts | 22 +++++++++++++--------- lib/account-pool.ts | 17 +++++++++++++---- lib/auth/auth.ts | 3 +-- test/account-pool.test.ts | 17 +++++++++++++++++ test/auth.test.ts | 28 +++++++++++++++++++++++++++- 5 files changed, 71 insertions(+), 16 deletions(-) diff --git a/index.ts b/index.ts index b69f42f..c229744 100644 --- a/index.ts +++ b/index.ts @@ -262,15 +262,19 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { (latestAuth.refresh === selectedRefreshBefore || latestAccountIdFromAuth === selected.accountId) ) { - await client.auth.set({ - path: { id: "openai" }, - body: { - type: "oauth", - access: refreshed.access, - refresh: refreshed.refresh, - expires: refreshed.expires, - }, - }); + try { + await client.auth.set({ + path: { id: "openai" }, + body: { + type: "oauth", + access: refreshed.access, + refresh: refreshed.refresh, + expires: refreshed.expires, + }, + }); + } catch (error) { + logWarn("Failed to persist refreshed auth", error); + } } } diff --git a/lib/account-pool.ts b/lib/account-pool.ts index f5e0a65..4c1a965 100644 --- a/lib/account-pool.ts +++ b/lib/account-pool.ts @@ -1,5 +1,6 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; +import { randomUUID } from "node:crypto"; import { dirname, join } from "node:path"; import type { AccountPoolEntry, AccountPoolStorage } from "./types.js"; @@ -69,15 +70,23 @@ function normalizeCooldown(cooldownMs?: number): number { } function retryAfterFromHeaders(headers: Headers, fallbackMs: number): number { + const maxRetryMs = 86_400_000; const retryAfterMs = headers.get("retry-after-ms"); if (retryAfterMs) { const parsed = Number.parseInt(retryAfterMs, 10); - if (!Number.isNaN(parsed) && parsed > 0) return parsed; + if (!Number.isNaN(parsed) && parsed > 0 && parsed <= maxRetryMs) return parsed; } const retryAfter = headers.get("retry-after"); if (retryAfter) { - const parsed = Number.parseInt(retryAfter, 10); - if (!Number.isNaN(parsed) && parsed > 0) return parsed * 1000; + const seconds = Number.parseInt(retryAfter, 10); + if (!Number.isNaN(seconds) && seconds > 0 && seconds * 1000 <= maxRetryMs) { + return seconds * 1000; + } + const retryDateMs = Date.parse(retryAfter); + if (!Number.isNaN(retryDateMs)) { + const remaining = retryDateMs - now(); + if (remaining > 0 && remaining <= maxRetryMs) return remaining; + } } const codexPrimary = headers.get("x-codex-primary-reset-after-seconds"); if (codexPrimary) { @@ -118,7 +127,7 @@ export class AccountPool { activeIndex: clampIndex(this.activeIndex, this.accounts.length || 1), accounts: this.accounts, }; - const tempPath = `${path}.tmp.${process.pid}.${Date.now()}`; + const tempPath = `${path}.tmp.${process.pid}.${Date.now()}.${randomUUID()}`; writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600, diff --git a/lib/auth/auth.ts b/lib/auth/auth.ts index 4bb9ac7..e561b0c 100644 --- a/lib/auth/auth.ts +++ b/lib/auth/auth.ts @@ -148,7 +148,6 @@ export async function refreshAccessToken(refreshToken: string): Promise { expect((minRetryAfter as number) / 1000).toBeLessThanOrEqual(10); expect((minRetryAfter as number) / 1000).toBeGreaterThan(8); }); + + it("supports retry-after HTTP date format", () => { + const pool = AccountPool.load(); + pool.upsert({ + accountId: "a1", + access: "access-1", + refresh: "refresh-1", + expires: Date.now() + 60_000, + }); + const dateHeader = new Date(Date.now() + 5_000).toUTCString(); + pool.markRateLimited("a1", new Headers({ "retry-after": dateHeader })); + + const minRetryAfter = pool.getMinRetryAfterMs(); + expect(minRetryAfter).not.toBeNull(); + expect(minRetryAfter as number).toBeGreaterThan(2000); + expect(minRetryAfter as number).toBeLessThanOrEqual(5000); + }); }); diff --git a/test/auth.test.ts b/test/auth.test.ts index 9ed0e62..5caf482 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { createState, parseAuthorizationInput, decodeJWT, createAuthorizationFlow, + refreshAccessToken, CLIENT_ID, AUTHORIZE_URL, REDIRECT_URI, @@ -11,6 +12,31 @@ import { } from '../lib/auth/auth.js'; describe('Auth Module', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('refreshAccessToken', () => { + it('uses existing refresh token when response omits refresh_token', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'new-access', + expires_in: 3600, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + + const result = await refreshAccessToken('existing-refresh'); + expect(result.type).toBe('success'); + if (result.type === 'success') { + expect(result.refresh).toBe('existing-refresh'); + expect(result.access).toBe('new-access'); + } + }); + }); + describe('createState', () => { it('should generate a random 32-character hex string', () => { const state = createState();