Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
12 changes: 11 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,9 @@ Advanced plugin settings in `~/.opencode/openai-codex-auth-config.json`:

```json
{
"codexMode": true
"codexMode": true,
"accountSelectionStrategy": "round-robin",
"rateLimitCooldownMs": 60000
}
```

Expand All @@ -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.
Expand Down Expand Up @@ -417,6 +426,7 @@ CODEX_MODE=1 opencode run "task" # Temporarily enable
- `~/.config/opencode/opencode.json` - Global config (fallback)
- `<project>/.opencode.json` - Project-specific config
- `~/.opencode/openai-codex-auth-config.json` - Plugin config
- `~/.opencode/openai-codex-accounts.json` - Multi-account pool state

---

Expand Down
203 changes: 164 additions & 39 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
decodeJWT,
exchangeAuthorizationCode,
parseAuthorizationInput,
refreshAccessToken,
REDIRECT_URI,
} from "./lib/auth/auth.js";
import { openBrowserUrl } from "./lib/auth/browser.js";
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -164,10 +185,24 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
input: Request | string | URL,
init?: RequestInit,
): Promise<Response> {
// 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
Expand All @@ -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<RequestBody> = {};
if (typeof init?.body === "string") {
try {
originalBody = JSON.parse(init.body) as Partial<RequestBody>;
} catch {
originalBody = {};
}
}
const isStreaming = originalBody.stream === true;

const transformation = await transformRequestForCodex(
Expand All @@ -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) }
: {}),
},
},
);
},
};
},
Expand Down
Loading