Skip to content

Commit ac08a53

Browse files
committed
feat: add multi-account rotation for automatic account switching on rate limits
- Add AccountCredentials and MultiAccountConfig types - Create account-manager.ts for managing multiple OAuth accounts - Modify fetch to detect rate limits and auto-switch accounts - Support sequential, random, and least-used rotation strategies - Add documentation for multi-account feature - All 229 existing tests pass
1 parent bec2ad6 commit ac08a53

File tree

7 files changed

+642
-52
lines changed

7 files changed

+642
-52
lines changed

docs/multi-account.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Multi-Account Rotation
2+
3+
This feature allows you to configure multiple Codex accounts and automatically rotate between them when one reaches its usage limit.
4+
5+
## Quick Start
6+
7+
### 1. Login with multiple accounts
8+
9+
Run the `/connect` command multiple times with different accounts:
10+
11+
```bash
12+
# Login with first account
13+
opencode
14+
/connect # Select OpenAI OAuth, login with [email protected]
15+
16+
# Login with second account (will be added to rotation)
17+
/connect # Select OpenAI OAuth, login with [email protected]
18+
```
19+
20+
Each successful login automatically adds the account to the rotation pool.
21+
22+
### 2. Manual Configuration (Optional)
23+
24+
Create or edit `~/.opencode/codex-accounts.json`:
25+
26+
```json
27+
{
28+
"enabled": true,
29+
"accounts": [],
30+
"currentAccountIndex": 0,
31+
"rotationStrategy": "sequential",
32+
"autoRotateOnRateLimit": true,
33+
"allAccountsLimitedBehavior": "error"
34+
}
35+
```
36+
37+
## Configuration Options
38+
39+
| Option | Type | Default | Description |
40+
|--------|------|---------|-------------|
41+
| `enabled` | boolean | `false` | Enable multi-account rotation |
42+
| `accounts` | array | `[]` | List of account credentials (auto-populated) |
43+
| `currentAccountIndex` | number | `0` | Current active account index |
44+
| `rotationStrategy` | string | `"sequential"` | How to select next account |
45+
| `autoRotateOnRateLimit` | boolean | `true` | Auto-switch on rate limit |
46+
| `allAccountsLimitedBehavior` | string | `"error"` | What to do when all accounts are limited |
47+
48+
### Rotation Strategies
49+
50+
- `sequential`: Round-robin through accounts in order
51+
- `random`: Randomly select an available account
52+
- `least-used`: Prefer accounts that were used least recently
53+
54+
### All Accounts Limited Behavior
55+
56+
- `error`: Return an error immediately
57+
- `wait`: Return info about when the earliest account will be available
58+
59+
## How It Works
60+
61+
1. When a request hits a rate limit (HTTP 429), the plugin:
62+
- Marks the current account as rate-limited
63+
- Extracts the reset time from response headers
64+
- Switches to the next available account
65+
- Retries the request (up to 5 times)
66+
67+
2. Rate-limited accounts automatically become available again after their reset time.
68+
69+
3. The plugin logs all account switches for debugging:
70+
```
71+
[openai-codex-plugin] Rate limit hit, switching to account: xxx
72+
[openai-codex-plugin] Retrying with account xxx (attempt 1/5)
73+
```
74+
75+
## Security Notes
76+
77+
- Account credentials are stored locally in `~/.opencode/codex-accounts.json`
78+
- This file contains OAuth tokens - keep it secure
79+
- Do not share or commit this file to version control
80+
81+
## Troubleshooting
82+
83+
### Accounts not rotating
84+
85+
1. Check if multi-account is enabled:
86+
```bash
87+
cat ~/.opencode/codex-accounts.json | grep enabled
88+
```
89+
90+
2. Verify you have multiple accounts:
91+
```bash
92+
cat ~/.opencode/codex-accounts.json | grep -c "accessToken"
93+
```
94+
95+
### All accounts show as rate-limited
96+
97+
The plugin respects the reset times from Codex. Check:
98+
```bash
99+
cat ~/.opencode/codex-accounts.json | grep rateLimitResetsAt
100+
```
101+
102+
To manually reset all accounts:
103+
```bash
104+
# Delete the file to start fresh
105+
rm ~/.opencode/codex-accounts.json
106+
```
107+
108+
## API Reference
109+
110+
The plugin exports these functions for programmatic use:
111+
112+
```typescript
113+
import {
114+
addAccount,
115+
getCurrentAccount,
116+
switchToNextAccount,
117+
markAccountRateLimited,
118+
isMultiAccountEnabled,
119+
getAccountCount,
120+
} from "opencode-openai-codex-auth/lib/auth/account-manager.js";
121+
```

index.ts

Lines changed: 101 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,15 @@ import {
5656
rewriteUrlForCodex,
5757
shouldRefreshToken,
5858
transformRequestForCodex,
59+
handleRateLimitWithRotation,
60+
getActiveAccountCredentials,
61+
isRateLimitError,
5962
} from "./lib/request/fetch-helpers.js";
63+
import {
64+
isMultiAccountEnabled,
65+
addAccount,
66+
getCurrentAccount,
67+
} from "./lib/auth/account-manager.js";
6068
import type { UserConfig } from "./lib/types.js";
6169

6270
/**
@@ -164,64 +172,108 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
164172
input: Request | string | URL,
165173
init?: RequestInit,
166174
): Promise<Response> {
167-
// Step 1: Check and refresh token if needed
168-
let currentAuth = await getAuth();
169-
if (shouldRefreshToken(currentAuth)) {
170-
currentAuth = await refreshAndUpdateToken(currentAuth, client);
171-
}
175+
const MAX_RETRY_ATTEMPTS = 5;
176+
let retryCount = 0;
177+
let currentAccountId = accountId;
178+
let currentAccessToken = "";
172179

173-
// Step 2: Extract and rewrite URL for Codex backend
174-
const originalUrl = extractRequestUrl(input);
175-
const url = rewriteUrlForCodex(originalUrl);
180+
while (retryCount < MAX_RETRY_ATTEMPTS) {
181+
let authToUse = await getAuth();
176182

177-
// Step 3: Transform request body with model-specific Codex instructions
178-
// Instructions are fetched per model family (codex-max, codex, gpt-5.1)
179-
// Capture original stream value before transformation
180-
// generateText() sends no stream field, streamText() sends stream=true
181-
const originalBody = init?.body ? JSON.parse(init.body as string) : {};
182-
const isStreaming = originalBody.stream === true;
183+
if (isMultiAccountEnabled()) {
184+
const multiAccountCreds = await getActiveAccountCredentials();
185+
if (multiAccountCreds) {
186+
currentAccessToken = multiAccountCreds.accessToken;
187+
currentAccountId = multiAccountCreds.accountId;
188+
} else {
189+
if (shouldRefreshToken(authToUse)) {
190+
authToUse = await refreshAndUpdateToken(authToUse, client);
191+
}
192+
currentAccessToken = authToUse.type === "oauth" ? authToUse.access : "";
193+
currentAccountId = accountId;
194+
}
195+
} else {
196+
if (shouldRefreshToken(authToUse)) {
197+
authToUse = await refreshAndUpdateToken(authToUse, client);
198+
}
199+
currentAccessToken = authToUse.type === "oauth" ? authToUse.access : "";
200+
currentAccountId = accountId;
201+
}
183202

184-
const transformation = await transformRequestForCodex(
185-
init,
186-
url,
187-
userConfig,
188-
codexMode,
189-
);
190-
const requestInit = transformation?.updatedInit ?? init;
203+
const originalUrl = extractRequestUrl(input);
204+
const url = rewriteUrlForCodex(originalUrl);
191205

192-
// Step 4: Create headers with OAuth and ChatGPT account info
193-
const accessToken =
194-
currentAuth.type === "oauth" ? currentAuth.access : "";
195-
const headers = createCodexHeaders(
196-
requestInit,
197-
accountId,
198-
accessToken,
199-
{
200-
model: transformation?.body.model,
201-
promptCacheKey: (transformation?.body as any)?.prompt_cache_key,
202-
},
203-
);
206+
const originalBody = init?.body ? JSON.parse(init.body as string) : {};
207+
const isStreaming = originalBody.stream === true;
208+
209+
const transformation = await transformRequestForCodex(
210+
init,
211+
url,
212+
userConfig,
213+
codexMode,
214+
);
215+
const requestInit = transformation?.updatedInit ?? init;
204216

205-
// Step 5: Make request to Codex API
206-
const response = await fetch(url, {
207-
...requestInit,
208-
headers,
209-
});
217+
const headers = createCodexHeaders(
218+
requestInit,
219+
currentAccountId,
220+
currentAccessToken,
221+
{
222+
model: transformation?.body.model,
223+
promptCacheKey: (transformation?.body as any)?.prompt_cache_key,
224+
},
225+
);
210226

211-
// Step 6: Log response
212-
logRequest(LOG_STAGES.RESPONSE, {
213-
status: response.status,
214-
ok: response.ok,
215-
statusText: response.statusText,
216-
headers: Object.fromEntries(response.headers.entries()),
217-
});
227+
const response = await fetch(url, {
228+
...requestInit,
229+
headers,
230+
});
218231

219-
// Step 7: Handle error or success response
220-
if (!response.ok) {
221-
return await handleErrorResponse(response);
232+
logRequest(LOG_STAGES.RESPONSE, {
233+
status: response.status,
234+
ok: response.ok,
235+
statusText: response.statusText,
236+
headers: Object.fromEntries(response.headers.entries()),
237+
retryCount,
238+
accountId: currentAccountId,
239+
});
240+
241+
if (!response.ok) {
242+
const errorResponse = await handleErrorResponse(response);
243+
244+
if (isRateLimitError(errorResponse) && isMultiAccountEnabled()) {
245+
const rotationResult = await handleRateLimitWithRotation(
246+
errorResponse,
247+
currentAccountId,
248+
);
249+
250+
if (rotationResult.shouldRetry && rotationResult.newAccount) {
251+
retryCount++;
252+
console.log(
253+
`[${PLUGIN_NAME}] Retrying with account ${rotationResult.newAccount.id} (attempt ${retryCount}/${MAX_RETRY_ATTEMPTS})`,
254+
);
255+
continue;
256+
}
257+
}
258+
259+
return errorResponse;
260+
}
261+
262+
return await handleSuccessResponse(response, isStreaming);
222263
}
223264

224-
return await handleSuccessResponse(response, isStreaming);
265+
return new Response(
266+
JSON.stringify({
267+
error: {
268+
message: ERROR_MESSAGES.ALL_ACCOUNTS_RATE_LIMITED,
269+
type: "rate_limit_exceeded",
270+
},
271+
}),
272+
{
273+
status: 429,
274+
headers: { "content-type": "application/json" },
275+
},
276+
);
225277
},
226278
};
227279
},

0 commit comments

Comments
 (0)