diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..4d48bfb --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,101 @@ +# Architecture + +## System Overview + +This OpenCode plugin authenticates against the ChatGPT Codex backend, selects among configured OpenAI accounts, and retries rate-limited requests with eligible fallback accounts. + +```mermaid +flowchart TD + OpenCode[OpenCode] --> Plugin[index.ts: OpenAIAuthPlugin] + CLI[multiauth: lib/cli.ts] --> Accounts[AccountManager] + Plugin --> Accounts + Plugin --> Sessions[SessionBindingStore] + Accounts --> AccountStorage[openai-accounts.json] + Sessions --> BindingStorage[openai-multi-auth-session-bindings.json] + Plugin --> Codex[ChatGPT Codex backend] +``` + +## Module Dependencies + +```mermaid +flowchart LR + CLI[lib/cli.ts] --> Manager[lib/accounts/manager.ts] + Entry[index.ts] --> AccountIndex[lib/accounts/index.ts] + AccountIndex --> Manager + Entry --> Session[lib/session-bindings.ts] + Manager --> Auth[lib/auth/auth.ts] + Manager --> AccountTypes[lib/accounts/types.ts] + Manager --> Secure[lib/secure-file.ts] + Session --> Secure + Entry --> Fetch[lib/request/fetch-helpers.ts] + Entry --> Models[lib/models.ts] + Entry --> Status[lib/codex-status.ts] +``` + +References flow from entry points to orchestration, then storage and utility layers. The default-account feature introduces no circular dependency. + +## Entry Points + +| Entry point | Location | Contract | +|---|---|---| +| OpenCode plugin | `index.ts:70` | Initializes account and session state and returns OpenCode hooks | +| OpenAI loader | `index.ts:330` | Returns the backend URL and custom fetch implementation | +| Request executor | `index.ts:351` | Refreshes tokens, sends requests, and handles account retries | +| Request selection | `index.ts:628` | Extracts model/session data and selects the working account | +| CLI API | `lib/cli.ts:17` | `runCli(args, io)` returns exit status `0` or `1` | +| CLI executable | `lib/cli.ts:64` | Runs `runCli()` when invoked directly | +| Package command | `package.json:39` | Maps `multiauth` to `dist/lib/cli.js` | + +## Modified Data Flow + +```mermaid +sequenceDiagram + participant OC as OpenCode + participant P as index.ts + participant A as AccountManager + participant S as SessionBindingStore + participant API as Codex backend + + OC->>P: OpenAI request with model and prompt_cache_key + P->>A: getDefaultAccount(model) + alt Eligible default on first process use + A-->>P: Default account + P->>S: Bind session to default index + else Default unavailable + P->>A: getNextAvailableAccountForNewSession(model) + A-->>P: Strategy-selected account + P->>S: Bind session to selected index + end + P->>API: Send request with account credentials + alt 429 response + P->>A: markRateLimited and saveToDisk + P->>A: getNextAvailableAccountExcluding + A-->>P: Fallback account + P->>S: Rebind before retry + P->>API: Retry with fallback + end +``` + +The first observable OpenAI request is the session initialization boundary because OpenCode exposes no `session.selected` hook. The process-local initialized-session set prevents a session from returning to its default after it has been rebound to a fallback. + +## Interfaces and Contracts + +| Interface | Location | Contract | +|---|---|---| +| `AccountsStorage.defaultAccountIndex` | `lib/accounts/types.ts:27` | Optional numeric index in the existing version-1 file | +| `AccountManager.getDefaultAccount()` | `lib/accounts/manager.ts:209` | Returns an eligible default or `null` | +| `AccountManager.getDefaultAccountIndex()` | `lib/accounts/manager.ts:217` | Distinguishes configured-but-unavailable from unconfigured | +| `AccountManager.setDefaultAccount()` | `lib/accounts/manager.ts:221` | Resolves exactly one trimmed, case-insensitive email and persists it | +| `SessionBindingStore.set()` | `lib/session-bindings.ts:47` | Persists a session key to account index binding | +| `runCli()` | `lib/cli.ts:17` | Writes user-safe output and returns a process exit code | + +Existing version-1 account files without `defaultAccountIndex` remain valid. Invalid stored indexes are treated as no default. Removing the default clears it; removing an earlier account decrements it. + +## Technical Debt + +- The default and session bindings use positional account indexes rather than stable IDs. Manager-mediated removal maintains the default, while stale session bindings are repaired when encountered. +- The CLI cannot explicitly clear a default. Selecting another account replaces it, and removing the selected account clears it. +- `removeAccount()` starts persistence without awaiting completion, so callers cannot observe a write failure. This behavior predates the feature. +- Atomic rename protects individual JSON writes but does not prevent last-writer-wins races between a running plugin and the CLI. + +No new TODO markers were introduced. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8516c4a..43468c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to this project are documented here. Dates use the ISO format (YYYY-MM-DD). +## [Unreleased] + +### Added +- Added `multiauth -d ` and `multiauth --default `. +- Added optional `defaultAccountIndex` support to version-1 account storage. +- Added first-use default-account selection for OpenAI sessions. + +### Changed +- Default selection uses trimmed, case-insensitive email matching and requires exactly one matching account. +- An eligible default overrides a persisted binding once at the first observable OpenAI request in a plugin process. +- 429 cooldowns are persisted and sessions are rebound to the fallback before retry. +- Replaced the shell-specific build copy command with a cross-platform Node.js filesystem copy. +- Made the test harness portable across Windows `HOME` and `USERPROFILE` behavior. + +### Fixed +- Parse both numeric and HTTP-date `Retry-After` headers and use a conservative cooldown for malformed values. + +### Notes +- `multiauth` requires a global or locally linked installation to be directly available on `PATH`. +- Restart OpenCode after changing the default account. +- Selecting another account replaces the default; there is no clear-default command. +- Non-OpenAI providers are unaffected. + ## [5.0.0] - 2026-01-15 **Major release**: Multi-account support with automatic rotation on rate limits. diff --git a/README.md b/README.md index d872f22..2bd143a 100644 --- a/README.md +++ b/README.md @@ -69,17 +69,37 @@ opencode auth login # Repeat for as many accounts as you have ``` +### Selecting a Default Account + +Install the package globally so the `multiauth` command is available on `PATH`, then select one existing account by email: + +```bash +npm install --global opencode-openai-multi-auth +multiauth -d user@example.com +# Equivalent: multiauth --default user@example.com +``` + +For a local checkout, build and install that checkout instead: + +```bash +npm run build +npm install --global . +multiauth -d user@example.com +``` + +Matching trims whitespace and ignores email case. The email must match exactly one configured account. Restart OpenCode after changing the default. Selecting another account replaces the current default; there is no clear-default command. + ### Automatic Rotation -When you hit a rate limit: +When an OpenAI request reaches a rate limit: -1. Plugin detects 429 (rate limited) response -2. Marks current account as limited for that model -3. Keeps the current session on the same account (no mid-turn hot-swap) -4. Keeps that session/account binding; start a new session to switch accounts -5. Shows toast notification for account usage and rate limit status +1. The plugin detects the `429` response. +2. It persists the account cooldown for the affected model. +3. It selects an eligible fallback account using the configured strategy. +4. It rebinds the current session before retrying with the fallback. +5. Later requests in the same plugin process and session continue using the fallback. -Session bindings are persisted locally so the same `prompt_cache_key` stays on the same account even after plugin process restarts. +If the default is cooling down, has failed repeatedly, or does not support the requested model, the normal selection strategy chooses an account. A configured default overrides a persisted binding once, at the first observable OpenAI request for that session in a plugin process. OpenCode exposes no `session.selected` hook, so this first request is the initialization boundary. Non-OpenAI providers are unaffected. ### Account Selection Strategies @@ -126,6 +146,11 @@ All accounts are pooled - when one person's account is rate limited, the plugin ## Account Management +### Set the Default Account +```bash +multiauth -d user@example.com +``` + ### View Accounts ```bash cat ~/.config/opencode/openai-accounts.json | jq '.accounts[] | {email, planType}' @@ -185,6 +210,7 @@ npx -y opencode-openai-multi-auth@latest --uninstall ## Features - **Multi-account rotation** - Add unlimited ChatGPT accounts, auto-rotate on rate limits +- **Manual default account** - Start OpenAI sessions with a selected account - **Per-model rate tracking** - Each model's limits tracked separately per account - **Toast notifications** - Visual feedback when accounts switch - **OAuth authentication** - Same secure flow as official Codex CLI diff --git a/docs/configuration.md b/docs/configuration.md index 1fed920..146a50b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -412,7 +412,7 @@ CODEX_MODE=1 opencode run "task" # Temporarily enable ### Account Storage -Accounts are stored in `~/.config/opencode/openai-accounts.json`: +Accounts are stored in `~/.config/opencode/openai-accounts.json`. The optional `defaultAccountIndex` remains compatible with existing version-1 files: ```json { @@ -427,10 +427,13 @@ Accounts are stored in `~/.config/opencode/openai-accounts.json`: "consecutiveFailures": 0 } ], - "activeAccountIndex": 0 + "activeAccountIndex": 0, + "defaultAccountIndex": 0 } ``` +The CLI resolves an email against the current account list and stores its numeric index. Removing the selected account clears the default; removing an earlier account decrements the index so it continues to identify the same account. + ### Adding Multiple Accounts ```bash @@ -443,10 +446,30 @@ opencode auth login # Select "Add Another OpenAI Account" ``` +### Selecting a Default Account + +The package provides `multiauth` when installed globally or linked locally: + +```bash +multiauth -d user@example.com +# Equivalent: multiauth --default user@example.com +``` + +Email matching trims whitespace, ignores case, and requires exactly one match. Unknown or ambiguous emails leave storage unchanged. Restart OpenCode after changing the default. Selecting another email replaces the default; there is no clear-default command. + +### Runtime Selection Semantics + +For each session, the first observable OpenAI request in a plugin process is the initialization boundary. The configured default is used when it is eligible for the requested model, overriding an existing persisted session binding once. + +A default is ineligible when it is in global or model-specific cooldown, has at least three consecutive failures, or does not support the requested model. The configured account strategy selects a fallback in those cases. OpenCode exposes no `session.selected` hook, and non-OpenAI providers do not enter this path. + ### Rate Limit Handling - Per-model rate limits tracked separately -- Automatic rotation to next available account +- Cooldowns persisted before retry +- Automatic rotation to the next available account +- Session rebound to the fallback before retry +- Later requests in the same process and session stay on the fallback - Toast notifications show rate limit status - Accounts with 3+ consecutive failures are skipped @@ -455,6 +478,7 @@ opencode auth login - When OpenCode provides a `prompt_cache_key` (its session identifier), the plugin forwards it directly to Codex. - The same value is sent via headers (`conversation_id`, `session_id`) and request body, reducing latency and token usage. - The plugin does not synthesize a fallback key; hosts that omit `prompt_cache_key` will see uncached behaviour until they provide one. +- Requests without a key still prefer an eligible default but do not create a persisted session binding. - No configuration needed—cache headers are injected during request transformation. ### Usage limit messaging diff --git a/docs/development/ARCHITECTURE.md b/docs/development/ARCHITECTURE.md index 94bffcd..6e3a154 100644 --- a/docs/development/ARCHITECTURE.md +++ b/docs/development/ARCHITECTURE.md @@ -60,56 +60,59 @@ This document explains the technical design decisions, architecture, and impleme ## Multi-Account System -### AccountManager (`lib/accounts/manager.ts`) - -The AccountManager handles multiple ChatGPT accounts with automatic rotation: +### Module Responsibilities + +| Module | Responsibility | +|---|---| +| `index.ts` | Plugin entry point, session-aware selection, and retry handling | +| `lib/cli.ts` | `multiauth` command-line entry point | +| `lib/accounts/manager.ts` | Account storage, default resolution, strategies, cooldowns, and token refresh | +| `lib/accounts/types.ts` | Account and storage contracts | +| `lib/session-bindings.ts` | Persistent session-key to account-index bindings | +| `lib/secure-file.ts` | Secure JSON persistence | + +```mermaid +flowchart TD + CLI[lib/cli.ts] --> Manager[lib/accounts/manager.ts] + Plugin[index.ts] --> AccountIndex[lib/accounts/index.ts] + AccountIndex --> Manager + Plugin --> Bindings[lib/session-bindings.ts] + Manager --> Auth[lib/auth/auth.ts] + Manager --> Types[lib/accounts/types.ts] + Manager --> Secure[lib/secure-file.ts] + Bindings --> Secure + Plugin --> Request[lib/request/fetch-helpers.ts] +``` -```typescript -class AccountManager { - // Core state - private accounts: ManagedAccount[] = []; - private activeIndex = 0; - private config: MultiAccountConfig; - - // Key methods - async loadFromDisk(): Promise // Load accounts from JSON - async importFromOpenCodeAuth(): Promise // Import from legacy auth - async addAccount(...): Promise // Add new account - async getNextAvailableAccount(model?): Promise - markRateLimited(account, retryAfterMs, model?) - async ensureValidToken(account): Promise -} +### Default and Fallback Flow + +```mermaid +flowchart TD + Request[OpenAI request] --> Key[Extract model and prompt_cache_key] + Key --> First{First use of this key in process?} + First -->|Yes| Default[Get eligible default] + Default -->|Found| BindDefault[Bind session to default] + Default -->|Unavailable| Strategy[Run new-session strategy] + Strategy --> BindSelected[Bind selected account] + First -->|No| Existing[Read existing binding] + Existing --> Execute[Execute request] + BindDefault --> Execute + BindSelected --> Execute + Execute --> Status{Response} + Status -->|Success| Return[Return response] + Status -->|429| Cooldown[Persist cooldown] + Cooldown --> Fallback[Select account excluding tried indexes] + Fallback --> Rebind[Rebind session before retry] + Rebind --> Execute ``` -### Account Selection Flow +The first observable OpenAI request initializes a session because OpenCode exposes no `session.selected` hook. A process-local set ensures that a session rebound after a 429 does not immediately return to its default. -``` -1. Request comes in with model name - │ - ├─▶ getNextAvailableAccount(model) - │ │ - │ ├─▶ Check current account availability - │ │ ├─ consecutiveFailures < 3? - │ │ ├─ globalRateLimitReset expired? - │ │ └─ perModelRateLimit[model] expired? - │ │ - │ ├─▶ If available: use current account - │ │ - │ └─▶ If not: try next accounts in order - │ │ - │ └─▶ If all rate limited: return least-limited - │ - ├─▶ ensureValidToken(account) - │ │ - │ ├─▶ Check expiration (5 min proactive refresh) - │ └─▶ Refresh if needed - │ - └─▶ executeRequest(account, input, init) - │ - ├─▶ On 429: markRateLimited() + try next account - ├─▶ On 401: markRefreshFailed() + try next account - └─▶ On success: return response -``` +### Default Account Contract + +`AccountManager.setDefaultAccount(email)` trims and compares email addresses case-insensitively, requires exactly one match, and persists the matching numeric account index. Unknown or ambiguous input does not modify storage. + +`AccountManager.getDefaultAccount(model)` returns `null` when no default is configured or when the configured account is cooling down, has failed at least three times, or does not support the requested model. ### Account Storage Format @@ -137,10 +140,16 @@ class AccountManager { "consecutiveFailures": 0 } ], - "activeAccountIndex": 0 + "activeAccountIndex": 0, + "roundRobinCursor": 1, + "defaultAccountIndex": 0 } ``` +`defaultAccountIndex` is optional, so existing version-1 files remain valid. Removing the selected account clears the default; removing an earlier account decrements the index. + +On a `429`, `executeRequest()` records and persists the cooldown before selecting another account. It updates the session binding before recursively retrying, keeping later requests on that fallback for the current process/session. + ### Environment Variables | Variable | Description | Default | diff --git a/docs/getting-started.md b/docs/getting-started.md index 990a940..bdc5cfa 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -333,6 +333,25 @@ opencode auth login **Account storage:** `~/.config/opencode/openai-accounts.json` +### Step 2c: Select the Default Account + +Install or link the package so `multiauth` is on `PATH`, then select one existing account: + +```bash +npm install --global opencode-openai-multi-auth +multiauth -d user@example.com +``` + +The long option is equivalent: + +```bash +multiauth --default user@example.com +``` + +The lookup trims surrounding whitespace, ignores email case, and succeeds only when exactly one configured account matches. Restart OpenCode after the command succeeds because a running plugin process does not reload the setting. + +To change the default, run the command with another existing account email. There is no clear-default command. + ### Step 3: Test It ```bash @@ -395,21 +414,20 @@ npx -y opencode-openai-multi-auth@latest --uninstall --all For plugin development or testing unreleased changes: -```json -{ - "plugin": ["file:///absolute/path/to/opencode-openai-multi-auth/dist"] -} -``` - -**Note**: Must point to `dist/` folder (built output), not root. - -**Build the plugin:** ```bash cd opencode-openai-multi-auth npm install npm run build ``` +Create a JavaScript file in `~/.config/opencode/plugins/` that exports the local build: + +```javascript +export { default } from "file:///absolute/path/to/opencode-openai-multi-auth/dist/index.js"; +``` + +OpenCode loads files in that directory at startup. Keep the wrapper pointed at `dist/index.js`, then rebuild and restart OpenCode after source changes. + --- ## Verifying Installation diff --git a/index.ts b/index.ts index abb0c12..3c88cf0 100644 --- a/index.ts +++ b/index.ts @@ -60,6 +60,18 @@ function extractPromptCacheKeyFromBody(body: string | undefined): string | undef } } +function parseRetryAfterMs(value: string | null, now = Date.now()): number | undefined { + if (!value) return undefined; + + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) { + return seconds * 1000; + } + + const resetAt = Date.parse(value); + return Number.isFinite(resetAt) ? Math.max(0, resetAt - now) : undefined; +} + let lastToastAccountIndex: number | null = null; let lastToastTime = 0; const TOAST_DEBOUNCE_MS = 5000; @@ -196,6 +208,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { const sessionBindingStore = new SessionBindingStore(); sessionBindingStore.loadFromDisk(); + const initializedSessionKeys = new Set(); const findAccountByIndex = (index: number): ManagedAccount | null => { return accountManager.getAllAccounts().find((acc) => acc.index === index) || null; @@ -206,7 +219,27 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { model?: string, ): Promise => { if (!sessionKey) { - return accountManager.getNextAvailableAccount(model); + return ( + accountManager.getDefaultAccount(model) || + accountManager.getNextAvailableAccount(model) + ); + } + + if (!initializedSessionKeys.has(sessionKey)) { + initializedSessionKeys.add(sessionKey); + const defaultAccount = accountManager.getDefaultAccount(model); + if (defaultAccount) { + sessionBindingStore.set(sessionKey, defaultAccount.index); + return defaultAccount; + } + if (accountManager.getDefaultAccountIndex() !== undefined) { + const account = + await accountManager.getNextAvailableAccountForNewSession(model); + if (account) { + sessionBindingStore.set(sessionKey, account.index); + } + return account; + } } const boundIndex = sessionBindingStore.get(sessionKey); @@ -334,6 +367,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { account: ManagedAccount, input: Request | string | URL, init: RequestInit | undefined, + sessionKey: string | undefined, retryCount = 0, triedAccountIndices: Set = new Set(), ): Promise => { @@ -344,7 +378,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { const nextAccount = await accountManager.getNextAvailableAccountExcluding(triedAccountIndices); if (nextAccount && nextAccount.index !== account.index) { await showAccountSwitchToast(account, nextAccount); - return executeRequest(nextAccount, input, init, retryCount, triedAccountIndices); + return executeRequest(nextAccount, input, init, sessionKey, retryCount, triedAccountIndices); } return new Response( JSON.stringify({ @@ -451,18 +485,19 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (response.status === HTTP_STATUS.TOO_MANY_REQUESTS) { const retryAfterHeader = response.headers.get("Retry-After"); - let retryAfterMs: number; + let retryAfterMs = parseRetryAfterMs(retryAfterHeader); - if (retryAfterHeader) { - retryAfterMs = parseInt(retryAfterHeader) * 1000; - } else { + if (retryAfterMs === undefined) { try { const cloned = response.clone(); const errorBody = (await cloned.json()) as any; const resetTime = errorBody?.error?.details?.resets_at || errorBody?.resets_at; if (resetTime) { - retryAfterMs = new Date(resetTime).getTime() - Date.now(); + const parsedResetTime = Date.parse(String(resetTime)); + retryAfterMs = Number.isFinite(parsedResetTime) + ? Math.max(0, parsedResetTime - Date.now()) + : 60000; } else { retryAfterMs = 60000; } @@ -472,6 +507,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { } accountManager.markRateLimited(account, retryAfterMs, model); + await accountManager.saveToDisk(); await showRateLimitToast(account, retryAfterMs); if (debugMode) { @@ -491,8 +527,11 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { const nextAccount = await accountManager.getNextAvailableAccountExcluding(triedAccountIndices, model); if (nextAccount && nextAccount.index !== account.index) { + if (sessionKey) { + sessionBindingStore.set(sessionKey, nextAccount.index); + } await showAccountSwitchToast(account, nextAccount); - return executeRequest(nextAccount, input, init, retryCount + 1, triedAccountIndices); + return executeRequest(nextAccount, input, init, sessionKey, retryCount + 1, triedAccountIndices); } } } @@ -503,7 +542,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { await accountManager.getNextAvailableAccountExcluding(triedAccountIndices, model); if (nextAccount && nextAccount.index !== account.index) { await showAccountSwitchToast(account, nextAccount); - return executeRequest(nextAccount, input, init, retryCount + 1, triedAccountIndices); + return executeRequest(nextAccount, input, init, sessionKey, retryCount + 1, triedAccountIndices); } } @@ -558,7 +597,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { triedAccountIndices.size, accountManager.getAccountCount(), ); - return executeRequest(nextAccount, input, init, retryCount, triedAccountIndices); + return executeRequest(nextAccount, input, init, sessionKey, retryCount, triedAccountIndices); } // STEP 2: All accounts tried - fall back to older model @@ -581,10 +620,10 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { const fallbackAccount = await accountManager.getNextAvailableAccount(fallbackModel); if (fallbackAccount) { // Reset tried accounts for the new model - return executeRequest(fallbackAccount, input, modifiedInit, 0, new Set()); + return executeRequest(fallbackAccount, input, modifiedInit, sessionKey, 0, new Set()); } // If no account available, use current account - return executeRequest(account, input, modifiedInit, retryCount + 1, new Set()); + return executeRequest(account, input, modifiedInit, sessionKey, retryCount + 1, new Set()); } } } catch { @@ -624,7 +663,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { await showAccountToast(account, accountManager.getAccountCount()); - return executeRequest(account, input, init); + return executeRequest(account, input, init, sessionKey); }, }; }, diff --git a/lib/accounts/manager.ts b/lib/accounts/manager.ts index a5661e6..eaac4e4 100644 --- a/lib/accounts/manager.ts +++ b/lib/accounts/manager.ts @@ -32,6 +32,7 @@ export class AccountManager { private accounts: ManagedAccount[] = []; private activeIndex = 0; private roundRobinCursor = 0; + private defaultAccountIndex: number | undefined; private strategyInitialized = false; private config: MultiAccountConfig; @@ -51,12 +52,21 @@ export class AccountManager { this.accounts = data.accounts; this.activeIndex = data.activeAccountIndex || 0; this.roundRobinCursor = data.roundRobinCursor ?? this.activeIndex; + const defaultIndex = data.defaultAccountIndex; + this.defaultAccountIndex = + typeof defaultIndex === "number" && + Number.isInteger(defaultIndex) && + defaultIndex >= 0 && + defaultIndex < this.accounts.length + ? defaultIndex + : undefined; this.strategyInitialized = false; } } catch { this.accounts = []; this.activeIndex = 0; this.roundRobinCursor = 0; + this.defaultAccountIndex = undefined; this.strategyInitialized = false; } } @@ -70,6 +80,9 @@ export class AccountManager { accounts: this.accounts, activeAccountIndex: this.activeIndex, roundRobinCursor: this.roundRobinCursor, + ...(this.defaultAccountIndex === undefined + ? {} + : { defaultAccountIndex: this.defaultAccountIndex }), }; writeJsonSecure(ACCOUNTS_FILE, data); } @@ -193,6 +206,36 @@ export class AccountManager { return this.accounts.length; } + getDefaultAccount(model?: string): ManagedAccount | null { + if (this.defaultAccountIndex === undefined) return null; + const account = this.accounts[this.defaultAccountIndex]; + if (!account) return null; + if (model && !this.accountSupportsModel(account, model)) return null; + return this.isAccountAvailable(account, model, Date.now()) ? account : null; + } + + getDefaultAccountIndex(): number | undefined { + return this.defaultAccountIndex; + } + + async setDefaultAccount(email: string): Promise { + const normalizedEmail = email.trim().toLowerCase(); + const matches = this.accounts.filter( + (account) => account.email?.trim().toLowerCase() === normalizedEmail, + ); + + if (matches.length === 0) { + throw new Error(`No account found for email: ${email.trim()}`); + } + if (matches.length > 1) { + throw new Error(`Multiple accounts found for email: ${email.trim()}`); + } + + this.defaultAccountIndex = matches[0].index; + await this.saveToDisk(); + return matches[0]; + } + async getNextAvailableAccount( model?: string, ): Promise { @@ -370,6 +413,14 @@ export class AccountManager { removeAccount(account: ManagedAccount): void { const index = this.accounts.findIndex((a) => a.index === account.index); if (index >= 0) { + if (this.defaultAccountIndex === index) { + this.defaultAccountIndex = undefined; + } else if ( + this.defaultAccountIndex !== undefined && + index < this.defaultAccountIndex + ) { + this.defaultAccountIndex--; + } this.accounts.splice(index, 1); this.accounts.forEach((a, i) => (a.index = i)); diff --git a/lib/accounts/types.ts b/lib/accounts/types.ts index 19ba578..3b6a50b 100644 --- a/lib/accounts/types.ts +++ b/lib/accounts/types.ts @@ -24,6 +24,7 @@ export interface AccountsStorage { accounts: ManagedAccount[]; activeAccountIndex: number; roundRobinCursor?: number; + defaultAccountIndex?: number; } export interface MultiAccountConfig { diff --git a/lib/cli.ts b/lib/cli.ts new file mode 100644 index 0000000..ac6c73c --- /dev/null +++ b/lib/cli.ts @@ -0,0 +1,79 @@ +#!/usr/bin/env node + +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { AccountManager } from "./accounts/manager.js"; + +const HELP = `Usage: multiauth -d + +Options: + -d, --default Set the default OpenAI account + -h, --help Show this help`; + +export interface CliIO { + stdout: Pick; + stderr: Pick; +} + +export async function runCli( + args: string[] = process.argv.slice(2), + io: CliIO = { stdout: process.stdout, stderr: process.stderr }, +): Promise { + if (args.length === 0 || (args.length === 1 && ["-h", "--help"].includes(args[0]))) { + io.stdout.write(`${HELP}\n`); + return 0; + } + + let email: string | undefined; + for (let index = 0; index < args.length; index++) { + const argument = args[index]; + if (argument !== "-d" && argument !== "--default") { + io.stderr.write(`Unknown or unexpected argument: ${argument}\n`); + return 1; + } + if (email !== undefined) { + io.stderr.write("The default option may only be provided once.\n"); + return 1; + } + const value = args[++index]; + if (!value || value.startsWith("-") || value.trim().length === 0) { + io.stderr.write("The default option requires an email address.\n"); + return 1; + } + email = value; + } + + if (email === undefined) { + io.stderr.write("The default option requires an email address.\n"); + return 1; + } + + const manager = new AccountManager({ quietMode: true }); + await manager.loadFromDisk(); + try { + const account = await manager.setDefaultAccount(email); + io.stdout.write( + `Default OpenAI account set to ${account.email}. Restart OpenCode to apply the change.\n`, + ); + return 0; + } catch (error) { + io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } +} + +export function isDirectExecution( + entryPath: string | undefined, + moduleUrl: string, +): boolean { + if (!entryPath) return false; + try { + return realpathSync(entryPath) === realpathSync(fileURLToPath(moduleUrl)); + } catch { + return false; + } +} + +if (isDirectExecution(process.argv[1], import.meta.url)) { + process.exitCode = await runCli(); +} diff --git a/package-lock.json b/package-lock.json index d1f67b6..bb51a66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-openai-multi-auth", - "version": "5.0.5", + "version": "5.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-openai-multi-auth", - "version": "5.0.5", + "version": "5.0.6", "license": "MIT", "dependencies": { "@openauthjs/openauth": "^0.4.3", @@ -14,6 +14,7 @@ "jsonc-parser": "^3.3.1" }, "bin": { + "multiauth": "dist/lib/cli.js", "opencode-openai-multi-auth": "scripts/install-opencode-codex-auth.js" }, "devDependencies": { diff --git a/package.json b/package.json index b69525b..fc9048a 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "url": "https://github.com/dkraemerwork/opencode-openai-multi-auth/issues" }, "scripts": { - "build": "tsc && cp lib/oauth-success.html dist/lib/", + "build": "tsc && node -e \"require('node:fs').copyFileSync('lib/oauth-success.html', 'dist/lib/oauth-success.html')\"", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", @@ -37,7 +37,8 @@ "test:coverage": "vitest run --coverage" }, "bin": { - "opencode-openai-multi-auth": "./scripts/install-opencode-codex-auth.js" + "opencode-openai-multi-auth": "./scripts/install-opencode-codex-auth.js", + "multiauth": "./dist/lib/cli.js" }, "files": [ "dist/", diff --git a/test/README.md b/test/README.md index 93c3ecc..9d28862 100644 --- a/test/README.md +++ b/test/README.md @@ -4,15 +4,17 @@ This directory contains the comprehensive test suite for the OpenAI Codex OAuth ## Test Structure -``` -test/ -├── README.md # This file -├── auth.test.ts # OAuth authentication tests -├── config.test.ts # Configuration parsing tests -├── logger.test.ts # Logging functionality tests -├── request-transformer.test.ts # Request transformation tests -└── response-handler.test.ts # Response handling tests -``` +| Test file | Covered area | +|---|---| +| `auth.test.ts` | OAuth parsing, JWT decoding, PKCE, and state validation | +| `config.test.ts` | Global and per-model configuration | +| `request-transformer.test.ts` | Model normalization, prompts, reasoning, and request transformation | +| `account-manager-strategy.test.ts` | Selection strategies, default persistence, eligibility, and account removal | +| `session-bindings.test.ts` | Session binding persistence and validation | +| `runtime-fetch-parity.test.ts` | First-use default selection and 429 fallback rebinding | +| `cli.test.ts` | `multiauth` help, validation, lookup, and persistence | +| `install-script.test.ts` | JSONC installation, uninstall behavior, and Windows paths | +| Other `*.test.ts` files | Browser, logging, model prompts, status, fetch helpers, and responses | ## Running Tests @@ -30,48 +32,18 @@ npm run test:ui npm run test:coverage ``` -## Test Coverage - -### auth.test.ts (16 tests) -Tests OAuth authentication functionality: -- State generation and uniqueness -- Authorization input parsing (URL, code#state, query string formats) -- JWT decoding and payload extraction -- Authorization flow creation with PKCE -- URL parameter validation - -### config.test.ts (13 tests) -Tests configuration parsing and merging: -- Global configuration application -- Per-model configuration overrides -- Mixed configuration (global + per-model) -- Default values and fallbacks -- Reasoning effort normalization (minimal → low for codex) -- Lightweight model detection (nano, mini) - -### request-transformer.test.ts (30 tests) -Tests request body transformations: -- Model name normalization (all variants → gpt-5 or gpt-5-codex) -- Input filtering (removing stored conversation history) -- Tool remap message injection -- Reasoning configuration application -- Text verbosity settings -- Encrypted reasoning content inclusion -- Unsupported parameter removal - -### response-handler.test.ts (10 tests) -Tests SSE to JSON conversion: -- Content-type header management -- SSE stream parsing (response.done, response.completed) -- Malformed JSON handling -- Empty stream handling -- Status preservation - -### logger.test.ts (5 tests) -Tests logging functionality: -- LOGGING_ENABLED constant -- logRequest function parameter handling -- Complex data structure support +## Current Test Areas + +- OAuth authentication and token claims. +- Plugin and model configuration. +- Request transformation and response handling. +- Account rotation, cooldowns, and default-account storage. +- Trimmed, case-insensitive email matching and error behavior. +- Session default precedence and 429 fallback rebinding. +- CLI parsing and package binary metadata. +- Windows path environment behavior. + +Exact totals are intentionally omitted. Run `npm test` for the current result. ## Test Philosophy @@ -80,13 +52,15 @@ Tests logging functionality: 3. **No External Dependencies**: Tests use mocked data and don't make real API calls 4. **Type Safety**: All tests are written in TypeScript with full type checking -## CI/CD Integration +## Validation Commands -Tests automatically run in GitHub Actions on: -- Every push to main -- Every pull request +```bash +npm run typecheck +npm test +npm run build +``` -The CI workflow tests against multiple Node.js versions (18.x, 20.x, 22.x) to ensure compatibility. +Tests use mocked data and do not make real OpenAI API calls. ## Adding New Tests diff --git a/test/account-manager-strategy.test.ts b/test/account-manager-strategy.test.ts index 8307ddf..26123ae 100644 --- a/test/account-manager-strategy.test.ts +++ b/test/account-manager-strategy.test.ts @@ -1,15 +1,23 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync, statSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; async function createManager( home: string, strategy: "sticky" | "round-robin" | "hybrid", ) { process.env.HOME = home; + process.env.USERPROFILE = home; vi.resetModules(); const { AccountManager } = await import("../lib/accounts/manager.js"); return new AccountManager({ @@ -22,6 +30,7 @@ async function createManager( describe("AccountManager strategy selection", () => { afterEach(() => { process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; }); it("keeps using the same account in sticky mode", async () => { @@ -168,7 +177,172 @@ describe("AccountManager strategy selection", () => { await manager.addAccount("secure@example.com", "rt-secure"); const filePath = join(home, ".config", "opencode", "openai-accounts.json"); - const mode = statSync(filePath).mode & 0o777; - expect(mode).toBe(0o600); + if (process.platform !== "win32") { + const mode = statSync(filePath).mode & 0o777; + expect(mode).toBe(0o600); + } + }); + + it("loads existing v1 storage without a default", async () => { + const home = mkdtempSync(join(tmpdir(), "strategy-v1-storage-")); + const directory = join(home, ".config", "opencode"); + mkdirSync(directory, { recursive: true }); + writeFileSync( + join(directory, "openai-accounts.json"), + JSON.stringify({ + version: 1, + accounts: [ + { + index: 0, + email: "legacy@example.com", + addedAt: 0, + parts: { refreshToken: "legacy" }, + rateLimitResets: {}, + consecutiveFailures: 0, + }, + ], + activeAccountIndex: 0, + }), + ); + const manager = await createManager(home, "sticky"); + + await manager.loadFromDisk(); + + expect(manager.getDefaultAccountIndex()).toBeUndefined(); + expect(manager.getDefaultAccount()).toBeNull(); + expect(manager.getAllAccounts()[0].email).toBe("legacy@example.com"); + }); + + it.each([-1, 1, 1.5, "0"])( + "treats invalid stored default index %j as no default", + async (defaultAccountIndex) => { + const home = mkdtempSync(join(tmpdir(), "strategy-invalid-default-")); + const directory = join(home, ".config", "opencode"); + mkdirSync(directory, { recursive: true }); + writeFileSync( + join(directory, "openai-accounts.json"), + JSON.stringify({ + version: 1, + accounts: [ + { + index: 0, + email: "only@example.com", + addedAt: 0, + parts: { refreshToken: "only" }, + rateLimitResets: {}, + consecutiveFailures: 0, + }, + ], + activeAccountIndex: 0, + defaultAccountIndex, + }), + ); + const manager = await createManager(home, "sticky"); + + await manager.loadFromDisk(); + + expect(manager.getDefaultAccountIndex()).toBeUndefined(); + expect(manager.getDefaultAccount()).toBeNull(); + }, + ); + + it("persists and reloads a trimmed case-insensitive default lookup", async () => { + const home = mkdtempSync(join(tmpdir(), "strategy-default-persist-")); + const manager = await createManager(home, "sticky"); + await manager.addAccount("First@example.com", "rt-1"); + await manager.addAccount("Canonical@Example.com", "rt-2"); + + const selected = await manager.setDefaultAccount(" canonical@example.COM "); + const reloaded = await createManager(home, "sticky"); + await reloaded.loadFromDisk(); + + expect(selected.email).toBe("Canonical@Example.com"); + expect(reloaded.getDefaultAccountIndex()).toBe(1); + expect(reloaded.getDefaultAccount()?.email).toBe("Canonical@Example.com"); + }); + + it("rejects unknown and ambiguous emails without mutating storage", async () => { + const home = mkdtempSync(join(tmpdir(), "strategy-default-errors-")); + const manager = await createManager(home, "sticky"); + await manager.addAccount("Duplicate@example.com", "rt-1"); + await manager.addAccount(" duplicate@EXAMPLE.com ", "rt-2"); + const filePath = join(home, ".config", "opencode", "openai-accounts.json"); + const before = readFileSync(filePath, "utf8"); + + await expect(manager.setDefaultAccount("unknown@example.com")).rejects.toThrow( + "No account found", + ); + await expect(manager.setDefaultAccount("duplicate@example.com")).rejects.toThrow( + "Multiple accounts found", + ); + + expect(manager.getDefaultAccountIndex()).toBeUndefined(); + expect(readFileSync(filePath, "utf8")).toBe(before); + }); + + it.each(["sticky", "round-robin", "hybrid"] as const)( + "returns the configured default independently of the %s strategy", + async (strategy) => { + const home = mkdtempSync(join(tmpdir(), `strategy-default-${strategy}-`)); + const manager = await createManager(home, strategy); + await manager.addAccount("first@example.com", "rt-1"); + await manager.addAccount("default@example.com", "rt-2"); + await manager.setDefaultAccount("default@example.com"); + + expect(manager.getDefaultAccount("gpt-5.2-codex")?.index).toBe(1); + }, + ); + + it("skips a default account while it is cooling down", async () => { + const home = mkdtempSync(join(tmpdir(), "strategy-default-cooldown-")); + const manager = await createManager(home, "sticky"); + await manager.addAccount("default@example.com", "rt-1"); + await manager.setDefaultAccount("default@example.com"); + + manager.markRateLimited( + manager.getAllAccounts()[0], + 60_000, + "gpt-5.2-codex", + ); + + expect(manager.getDefaultAccount("gpt-5.2-codex")).toBeNull(); + }); + + it("keeps a default unavailable after persisted cooldown is reloaded", async () => { + const home = mkdtempSync(join(tmpdir(), "strategy-default-persisted-cooldown-")); + const manager = await createManager(home, "sticky"); + await manager.addAccount("default@example.com", "rt-1"); + await manager.setDefaultAccount("default@example.com"); + manager.markRateLimited( + manager.getAllAccounts()[0], + 60_000, + "gpt-5.2-codex", + ); + await manager.saveToDisk(); + + const reloaded = await createManager(home, "sticky"); + await reloaded.loadFromDisk(); + + expect(reloaded.getDefaultAccountIndex()).toBe(0); + expect(reloaded.getDefaultAccount("gpt-5.2-codex")).toBeNull(); + }); + + it("clears or decrements the default index when accounts are removed", async () => { + const clearHome = mkdtempSync(join(tmpdir(), "strategy-default-clear-")); + const clearManager = await createManager(clearHome, "sticky"); + await clearManager.addAccount("first@example.com", "rt-1"); + await clearManager.addAccount("default@example.com", "rt-2"); + await clearManager.setDefaultAccount("default@example.com"); + clearManager.removeAccount(clearManager.getAllAccounts()[1]); + expect(clearManager.getDefaultAccountIndex()).toBeUndefined(); + + const decrementHome = mkdtempSync(join(tmpdir(), "strategy-default-decrement-")); + const decrementManager = await createManager(decrementHome, "sticky"); + await decrementManager.addAccount("first@example.com", "rt-1"); + await decrementManager.addAccount("default@example.com", "rt-2"); + await decrementManager.setDefaultAccount("default@example.com"); + decrementManager.removeAccount(decrementManager.getAllAccounts()[0]); + expect(decrementManager.getDefaultAccountIndex()).toBe(0); + expect(decrementManager.getDefaultAccount()?.email).toBe("default@example.com"); }); }); diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..728d304 --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; + +function writeAccounts(home: string, emails: string[]) { + const directory = join(home, ".config", "opencode"); + mkdirSync(directory, { recursive: true }); + const filePath = join(directory, "openai-accounts.json"); + writeFileSync( + filePath, + JSON.stringify({ + version: 1, + accounts: emails.map((email, index) => ({ + index, + email, + addedAt: 0, + parts: { refreshToken: `refresh-${index}` }, + rateLimitResets: {}, + consecutiveFailures: 0, + })), + activeAccountIndex: 0, + }), + ); + return filePath; +} + +async function invoke(home: string, args: string[]) { + process.env.HOME = home; + process.env.USERPROFILE = home; + vi.resetModules(); + const { runCli } = await import("../lib/cli.js"); + let stdout = ""; + let stderr = ""; + const exitCode = await runCli(args, { + stdout: { write: (text) => ((stdout += String(text)), true) } as any, + stderr: { write: (text) => ((stderr += String(text)), true) } as any, + }); + return { exitCode, stdout, stderr }; +} + +describe("multiauth CLI", () => { + afterEach(() => { + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + }); + + it.each([[[]], [["-h"]], [["--help"]]])("prints help and succeeds for %j", async (args) => { + const home = mkdtempSync(join(tmpdir(), "multiauth-help-")); + const result = await invoke(home, args); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Usage: multiauth"); + expect(result.stderr).toBe(""); + }); + + it.each([ + [["-d"]], + [["-d", "a@example.com", "extra"]], + [["-d", "a@example.com", "--default", "b@example.com"]], + [["--unknown"]], + [["email@example.com"]], + ])("rejects invalid arguments without writing for %j", async (args) => { + const home = mkdtempSync(join(tmpdir(), "multiauth-validation-")); + const filePath = writeAccounts(home, ["a@example.com"]); + const before = readFileSync(filePath, "utf8"); + + const result = await invoke(home, args); + + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + expect(readFileSync(filePath, "utf8")).toBe(before); + }); + + it("persists a default and displays the canonical stored email", async () => { + const home = mkdtempSync(join(tmpdir(), "multiauth-success-")); + const filePath = writeAccounts(home, ["first@example.com", "Canonical@Example.com"]); + + const result = await invoke(home, ["--default", " canonical@example.COM "]); + const storage = JSON.parse(readFileSync(filePath, "utf8")); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Canonical@Example.com"); + expect(result.stdout).toContain("Restart OpenCode"); + expect(storage.defaultAccountIndex).toBe(1); + }); + + it.each([ + ["unknown@example.com", ["known@example.com"], "No account found"], + [ + "duplicate@example.com", + ["Duplicate@example.com", " duplicate@EXAMPLE.com "], + "Multiple accounts found", + ], + ])("rejects %s without mutating accounts", async (email, emails, message) => { + const home = mkdtempSync(join(tmpdir(), "multiauth-lookup-")); + const filePath = writeAccounts(home, emails); + const before = readFileSync(filePath, "utf8"); + + const result = await invoke(home, ["-d", email]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain(message); + expect(readFileSync(filePath, "utf8")).toBe(before); + }); + + it("retains the installer binary and publishes multiauth", () => { + const packageJson = JSON.parse( + readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8"), + ); + + expect(packageJson.bin).toEqual({ + "opencode-openai-multi-auth": "./scripts/install-opencode-codex-auth.js", + multiauth: "./dist/lib/cli.js", + }); + }); + + it("recognizes direct execution through a linked package path", async () => { + const root = mkdtempSync(join(tmpdir(), "multiauth-linked-entry-")); + const packageDirectory = join(root, "package"); + const linkedDirectory = join(root, "linked-package"); + mkdirSync(packageDirectory); + const realEntry = join(packageDirectory, "cli.js"); + writeFileSync(realEntry, ""); + symlinkSync(packageDirectory, linkedDirectory, "junction"); + const { isDirectExecution } = await import("../lib/cli.js"); + + expect( + isDirectExecution( + join(linkedDirectory, "cli.js"), + pathToFileURL(realEntry).href, + ), + ).toBe(true); + }); +}); diff --git a/test/install-script.test.ts b/test/install-script.test.ts index 7878cb2..f4d7fa0 100644 --- a/test/install-script.test.ts +++ b/test/install-script.test.ts @@ -9,7 +9,7 @@ const SCRIPT_PATH = resolve(process.cwd(), 'scripts', 'install-opencode-codex-au const runInstaller = (args: string[], homeDir: string) => { execFileSync(process.execPath, [SCRIPT_PATH, ...args], { - env: { ...process.env, HOME: homeDir }, + env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir }, stdio: 'pipe', }); }; diff --git a/test/runtime-fetch-parity.test.ts b/test/runtime-fetch-parity.test.ts index 64d03e1..3121120 100644 --- a/test/runtime-fetch-parity.test.ts +++ b/test/runtime-fetch-parity.test.ts @@ -1,119 +1,284 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from "vitest"; const transformRequestForCodexMock = vi.fn(); +const saveToDiskMock = vi.fn(); +const initialBindings = new Map(); +let latestBindings = new Map(); +let defaultAccountIndex: number | undefined; +let lastRetryAfterMs: number | undefined; -vi.mock('@opencode-ai/plugin', () => ({ - tool: (definition: unknown) => definition, +const accounts = [ + { + index: 0, + email: "first@example.com", + access: "access-0", + expires: Date.now() + 60_000, + accountId: "acct_0", + parts: { refreshToken: "refresh-0" }, + rateLimitResets: {}, + consecutiveFailures: 0, + addedAt: 0, + }, + { + index: 1, + email: "Default@Example.com", + access: "access-1", + expires: Date.now() + 60_000, + accountId: "acct_1", + parts: { refreshToken: "refresh-1" }, + rateLimitResets: {}, + consecutiveFailures: 0, + addedAt: 0, + }, +]; + +vi.mock("@opencode-ai/plugin", () => ({ + tool: (definition: unknown) => definition, })); -vi.mock('../lib/request/fetch-helpers.js', async () => { - const actual = await vi.importActual( - '../lib/request/fetch-helpers.js', - ); - return { - ...actual, - transformRequestForCodex: transformRequestForCodexMock, - }; +vi.mock("../lib/request/fetch-helpers.js", async () => { + const actual = await vi.importActual( + "../lib/request/fetch-helpers.js", + ); + return { ...actual, transformRequestForCodex: transformRequestForCodexMock }; }); -vi.mock('../lib/accounts/index.js', () => { - class AccountManager { - private account = { - index: 0, - email: 'test@example.com', - access: 'access-token', - expires: Date.now() + 60_000, - accountId: 'acct_123', - }; - - async loadFromDisk() {} - async importFromOpenCodeAuth() {} - getAllAccounts() { - return [this.account]; - } - getAccountCount() { - return 1; - } - getActiveAccount() { - return this.account; - } - async getNextAvailableAccount() { - return this.account; - } - async getNextAvailableAccountForNewSession() { - return this.account; - } - async ensureValidToken() { - return true; - } - markRateLimited() {} - markRefreshFailed() {} - async addAccount() {} - } - - return { AccountManager }; +vi.mock("../lib/models.js", () => ({ prefetchModels: vi.fn() })); + +vi.mock("../lib/accounts/index.js", () => { + class AccountManager { + async loadFromDisk() {} + async importFromOpenCodeAuth() {} + async saveToDisk() { + saveToDiskMock(); + } + getAllAccounts() { + return accounts; + } + getAccountCount() { + return accounts.length; + } + getActiveAccount() { + return accounts[0]; + } + getDefaultAccount() { + return defaultAccountIndex === undefined ? null : accounts[defaultAccountIndex]; + } + getDefaultAccountIndex() { + return defaultAccountIndex; + } + async getNextAvailableAccount() { + return accounts[0]; + } + async getNextAvailableAccountForNewSession() { + return accounts[0]; + } + async getNextAvailableAccountExcluding(excluded: Set) { + return accounts.find((account) => !excluded.has(account.index)) ?? null; + } + async ensureValidToken() { + return true; + } + markRateLimited(_account: unknown, retryAfterMs: number) { + lastRetryAfterMs = retryAfterMs; + } + markRefreshFailed() {} + async addAccount() {} + } + + return { AccountManager }; }); -vi.mock('../lib/session-bindings.js', () => { - class SessionBindingStore { - private map = new Map(); - loadFromDisk() {} - get(key: string) { - return this.map.get(key); - } - set(key: string, value: number) { - this.map.set(key, value); - } - delete(key: string) { - this.map.delete(key); - } - } - - return { SessionBindingStore }; +vi.mock("../lib/session-bindings.js", () => { + class SessionBindingStore { + private map = new Map(); + loadFromDisk() { + this.map = new Map(initialBindings); + latestBindings = this.map; + } + get(key: string) { + return this.map.get(key); + } + set(key: string, value: number) { + this.map.set(key, value); + } + delete(key: string) { + this.map.delete(key); + } + } + + return { SessionBindingStore }; }); -describe('Runtime fetch parity', () => { - beforeEach(() => { - transformRequestForCodexMock.mockReset(); - (globalThis as any).fetch = vi.fn(async () => { - return new Response('data: {"type":"response.done"}\n\n', { - status: 200, - headers: { 'content-type': 'text/event-stream' }, - }); - }); - }); - - it('does not call transformRequestForCodex in runtime fetch path', async () => { - const { OpenAIAuthPlugin } = await import('../index.js'); - - const plugin = await OpenAIAuthPlugin({ - client: { - auth: { set: vi.fn() }, - tui: { showToast: vi.fn() }, - }, - } as any); - - const loader = await plugin.auth.loader( - async () => ({ - type: 'oauth', - access: 'access-token', - refresh: 'refresh-token', - expires: Date.now() + 60_000, - }) as any, - {} as any, - ); - - await loader.fetch('https://chatgpt.com/backend-api/responses', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - model: 'gpt-5.3-codex', - prompt_cache_key: 'ses_test_key', - input: [{ type: 'message', role: 'user', content: 'hello' }], - }), - }); - - expect(transformRequestForCodexMock).not.toHaveBeenCalled(); - expect((globalThis as any).fetch).toHaveBeenCalled(); - }); +async function createFetch() { + const { OpenAIAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIAuthPlugin({ + client: { + auth: { set: vi.fn() }, + tui: { showToast: vi.fn() }, + }, + } as any); + + const loader = await plugin.auth.loader( + async () => ({ + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }) as any, + {} as any, + ); + return loader.fetch; +} + +function request( + fetcher: NonNullable>>, + sessionKey: string | undefined = "ses_test_key", +) { + return fetcher("https://chatgpt.com/backend-api/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-5.2-codex", + ...(sessionKey ? { prompt_cache_key: sessionKey } : {}), + input: [{ type: "message", role: "user", content: "hello" }], + }), + }); +} + +describe("Runtime fetch parity", () => { + beforeEach(() => { + transformRequestForCodexMock.mockReset(); + saveToDiskMock.mockReset(); + initialBindings.clear(); + latestBindings = new Map(); + defaultAccountIndex = undefined; + lastRetryAfterMs = undefined; + (globalThis as any).fetch = vi.fn(async () => + new Response('data: {"type":"response.done"}\n\n', { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + }); + + it("does not call transformRequestForCodex in runtime fetch path", async () => { + const fetcher = await createFetch(); + await request(fetcher); + + expect(transformRequestForCodexMock).not.toHaveBeenCalled(); + expect((globalThis as any).fetch).toHaveBeenCalled(); + }); + + it("overrides a persisted binding with the default on first use", async () => { + initialBindings.set("ses_test_key", 0); + defaultAccountIndex = 1; + const fetcher = await createFetch(); + + await request(fetcher); + + const init = (globalThis.fetch as any).mock.calls[0][1] as RequestInit; + expect(new Headers(init.headers).get("chatgpt-account-id")).toBe("acct_1"); + expect(latestBindings.get("ses_test_key")).toBe(1); + }); + + it("uses the default when a request has no session key", async () => { + defaultAccountIndex = 1; + const fetcher = await createFetch(); + + await request(fetcher, undefined); + + const init = (globalThis.fetch as any).mock.calls[0][1] as RequestInit; + expect(new Headers(init.headers).get("chatgpt-account-id")).toBe("acct_1"); + }); + + it("rebinds to the 429 fallback and keeps it for the next request", async () => { + defaultAccountIndex = 0; + (globalThis as any).fetch = vi + .fn() + .mockResolvedValueOnce(new Response("rate limited", { status: 429 })) + .mockResolvedValueOnce( + new Response('data: {"type":"response.done"}\n\n', { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ) + .mockResolvedValueOnce( + new Response('data: {"type":"response.done"}\n\n', { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + const fetcher = await createFetch(); + + await request(fetcher); + await request(fetcher); + + const accountIds = (globalThis.fetch as any).mock.calls.map( + ([, init]: [unknown, RequestInit]) => + new Headers(init.headers).get("chatgpt-account-id"), + ); + expect(accountIds).toEqual(["acct_0", "acct_1", "acct_1"]); + expect(latestBindings.get("ses_test_key")).toBe(1); + expect(saveToDiskMock).toHaveBeenCalledOnce(); + }); + + it("parses an HTTP-date Retry-After value before persisting cooldown", async () => { + defaultAccountIndex = 0; + const resetAt = new Date(Date.now() + 60_000).toUTCString(); + (globalThis as any).fetch = vi + .fn() + .mockResolvedValueOnce( + new Response("rate limited", { + status: 429, + headers: { "Retry-After": resetAt }, + }), + ) + .mockResolvedValueOnce( + new Response('data: {"type":"response.done"}\n\n', { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + const fetcher = await createFetch(); + + await request(fetcher); + + expect(lastRetryAfterMs).toBeGreaterThan(58_000); + expect(lastRetryAfterMs).toBeLessThanOrEqual(60_000); + }); + + it("uses a conservative cooldown for a malformed Retry-After value", async () => { + defaultAccountIndex = 0; + (globalThis as any).fetch = vi + .fn() + .mockResolvedValueOnce( + new Response("rate limited", { + status: 429, + headers: { "Retry-After": "not-a-date" }, + }), + ) + .mockResolvedValueOnce( + new Response('data: {"type":"response.done"}\n\n', { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + const fetcher = await createFetch(); + + await request(fetcher); + + expect(lastRetryAfterMs).toBe(60_000); + }); + + it("retains an existing session binding when no default is configured", async () => { + initialBindings.set("ses_test_key", 1); + const fetcher = await createFetch(); + + await request(fetcher); + + const init = (globalThis.fetch as any).mock.calls[0][1] as RequestInit; + expect(new Headers(init.headers).get("chatgpt-account-id")).toBe("acct_1"); + expect(latestBindings.get("ses_test_key")).toBe(1); + }); }); diff --git a/test/session-bindings.test.ts b/test/session-bindings.test.ts index 35ee364..94046c7 100644 --- a/test/session-bindings.test.ts +++ b/test/session-bindings.test.ts @@ -60,7 +60,9 @@ describe("SessionBindingStore", () => { store.loadFromDisk(); store.set("ses_secure", 1); - const mode = statSync(filePath).mode & 0o777; - expect(mode).toBe(0o600); + if (process.platform !== "win32") { + const mode = statSync(filePath).mode & 0o777; + expect(mode).toBe(0o600); + } }); });