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/index.ts b/index.ts index 20ce21b..c229744 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,23 +42,20 @@ import { ERROR_MESSAGES, JWT_CLAIM_PATH, LOG_STAGES, - OPENAI_HEADER_VALUES, - OPENAI_HEADERS, 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, 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,29 @@ 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(); + const savePool = () => { + try { + accountPool.save(); + } catch (error) { + logWarn("Failed to persist account pool", error); + } + }; + accountPool.upsert({ + accountId, + access: auth.access, + refresh: auth.refresh, + expires: auth.expires, + email: + typeof decoded?.email === "string" + ? decoded.email + : undefined, + }); + savePool(); // Return SDK configuration return { @@ -164,10 +185,24 @@ 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(); + 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, + 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 @@ -178,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( @@ -189,39 +231,122 @@ 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 selectedRefreshBefore = selected.refresh; + const refreshed = await refreshAccessToken(selected.refresh); + if (refreshed.type === "failed") { + accountPool.markRateLimited( + selected.accountId, + new Headers(), + rateLimitCooldownMs, + ); + savePool(); + continue; + } + accountPool.replaceAuth( + selected.accountId, + refreshed.access, + refreshed.refresh, + refreshed.expires, + ); + if ( + latestAuth.type === "oauth" && + (latestAuth.refresh === selectedRefreshBefore || + latestAccountIdFromAuth === selected.accountId) + ) { + 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); + } + } + } - // 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, + ); + savePool(); + lastRateLimitResponse = mapped; + continue; + } + savePool(); + return mapped; + } + + savePool(); + return await handleSuccessResponse(response, isStreaming); } - return await handleSuccessResponse(response, isStreaming); + savePool(); + if (lastRateLimitResponse) { + return lastRateLimitResponse; + } + 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 new file mode 100644 index 0000000..4c1a965 --- /dev/null +++ b/lib/account-pool.ts @@ -0,0 +1,245 @@ +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"; + +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 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 && parsed <= maxRetryMs) return parsed; + } + const retryAfter = headers.get("retry-after"); + if (retryAfter) { + 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) { + 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, + }; + const tempPath = `${path}.tmp.${process.pid}.${Date.now()}.${randomUUID()}`; + writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + renameSync(tempPath, path); + try { + chmodSync(path, 0o600); + } catch { + } + } + + 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; + 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 { + 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; + } + + 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; + 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/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 { + 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"); + }); + + 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); + }); + + 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(); 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(); });