Skip to content

Commit 29b96d4

Browse files
committed
feat: v4.10.0 - per-project accounts, toast duration, account removal
- per-project accounts: each project gets its own account storage (default on) - configurable toast duration (toastDurationMs, default 5000ms) - account removal tool (openai-accounts-remove) - token masking in logs (masks JWTs, API keys, bearer tokens) - account limit increased from 10 to 20 - token refresh race condition fix with tokenRotationMap - rate limit retry jitter (20%) to avoid thundering herd - apply_patch infinite loop fix in codex-opencode-bridge - documentation rewritten in casual lowercase tone
1 parent bf9517f commit 29b96d4

17 files changed

Lines changed: 932 additions & 871 deletions

CHANGELOG.md

Lines changed: 113 additions & 411 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 189 additions & 171 deletions
Large diffs are not rendered by default.

docs/configuration.md

Lines changed: 123 additions & 183 deletions
Large diffs are not rendered by default.

index.ts

Lines changed: 162 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import {
4545
getTokenRefreshSkewMs,
4646
getSessionRecovery,
4747
getAutoResume,
48+
getToastDurationMs,
49+
getPerProjectAccounts,
4850
loadPluginConfig,
4951
} from "./lib/config.js";
5052
import {
@@ -70,7 +72,7 @@ import {
7072
sanitizeEmail,
7173
shouldUpdateAccountIdFromToken,
7274
} from "./lib/accounts.js";
73-
import { getStoragePath, loadAccounts, saveAccounts, type AccountStorageV3 } from "./lib/storage.js";
75+
import { getStoragePath, loadAccounts, saveAccounts, setStoragePath, type AccountStorageV3 } from "./lib/storage.js";
7476
import {
7577
createCodexHeaders,
7678
extractRequestUrl,
@@ -86,6 +88,7 @@ import {
8688
RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS,
8789
resetRateLimitBackoff,
8890
} from "./lib/request/rate-limit-backoff.js";
91+
import { addJitter } from "./lib/rotation.js";
8992
import { getModelFamily, MODEL_FAMILIES, type ModelFamily } from "./lib/prompts/codex.js";
9093
import type { AccountIdSource, OAuthAuthDetails, TokenResult, UserConfig } from "./lib/types.js";
9194
import {
@@ -385,12 +388,15 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
385388
const showToast = async (
386389
message: string,
387390
variant: "info" | "success" | "warning" | "error" = "success",
391+
options?: { title?: string; duration?: number },
388392
): Promise<void> => {
389393
try {
390394
await client.tui.showToast({
391395
body: {
392396
message,
393397
variant,
398+
...(options?.title && { title: options.title }),
399+
...(options?.duration && { duration: options.duration }),
394400
},
395401
});
396402
} catch {
@@ -600,6 +606,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
600606
const retryAllAccountsRateLimited = getRetryAllAccountsRateLimited(pluginConfig);
601607
const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig);
602608
const retryAllAccountsMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig);
609+
const toastDurationMs = getToastDurationMs(pluginConfig);
610+
const perProjectAccounts = getPerProjectAccounts(pluginConfig);
611+
612+
if (perProjectAccounts) {
613+
setStoragePath(process.cwd());
614+
}
603615

604616
const sessionRecoveryEnabled = getSessionRecovery(pluginConfig);
605617
const autoResumeEnabled = getAutoResume(pluginConfig);
@@ -792,13 +804,14 @@ while (attempted.size < Math.max(1, accountCount)) {
792804
const { response: errorResponse, rateLimit, errorBody } =
793805
await handleErrorResponse(response);
794806

795-
if (recoveryHook && errorBody && isRecoverableError(errorBody)) {
796-
const errorType = detectErrorType(errorBody);
797-
const toastContent = getRecoveryToastContent(errorType);
798-
await showToast(
799-
`${toastContent.title}: ${toastContent.message}`,
800-
"warning",
801-
);
807+
if (recoveryHook && errorBody && isRecoverableError(errorBody)) {
808+
const errorType = detectErrorType(errorBody);
809+
const toastContent = getRecoveryToastContent(errorType);
810+
await showToast(
811+
`${toastContent.title}: ${toastContent.message}`,
812+
"warning",
813+
{ duration: toastDurationMs },
814+
);
802815
logDebug(`[${PLUGIN_NAME}] Recoverable error detected: ${errorType}`);
803816
}
804817

@@ -817,15 +830,16 @@ while (attempted.size < Math.max(1, accountCount)) {
817830
rateLimitToastDebounceMs,
818831
)
819832
) {
820-
await showToast(
821-
`Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`,
822-
"warning",
823-
);
833+
await showToast(
834+
`Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`,
835+
"warning",
836+
{ duration: toastDurationMs },
837+
);
824838
accountManager.markToastShown(account.index);
825-
}
839+
}
826840

827-
await sleep(delayMs);
828-
continue;
841+
await sleep(addJitter(delayMs, 0.2));
842+
continue;
829843
}
830844

831845
accountManager.markRateLimited(
@@ -848,10 +862,11 @@ while (attempted.size < Math.max(1, accountCount)) {
848862
rateLimitToastDebounceMs,
849863
)
850864
) {
851-
await showToast(
852-
`Rate limited. Switching accounts (retry in ${waitLabel}).`,
853-
"warning",
854-
);
865+
await showToast(
866+
`Rate limited. Switching accounts (retry in ${waitLabel}).`,
867+
"warning",
868+
{ duration: toastDurationMs },
869+
);
855870
accountManager.markToastShown(account.index);
856871
}
857872
break;
@@ -876,14 +891,15 @@ while (attempted.size < Math.max(1, accountCount)) {
876891
waitMs <= retryAllAccountsMaxWaitMs) &&
877892
allRateLimitedRetries < retryAllAccountsMaxRetries
878893
) {
879-
const waitLabel = formatWaitTime(waitMs);
880-
await showToast(
881-
`All ${count} account(s) are rate-limited. Waiting ${waitLabel}...`,
882-
"warning",
883-
);
884-
allRateLimitedRetries++;
885-
await sleep(waitMs);
886-
continue;
894+
const waitLabel = formatWaitTime(waitMs);
895+
await showToast(
896+
`All ${count} account(s) are rate-limited. Waiting ${waitLabel}...`,
897+
"warning",
898+
{ duration: toastDurationMs },
899+
);
900+
allRateLimitedRetries++;
901+
await sleep(addJitter(waitMs, 0.2));
902+
continue;
887903
}
888904

889905
const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit";
@@ -1336,52 +1352,130 @@ while (attempted.size < Math.max(1, accountCount)) {
13361352
return lines.join("\n");
13371353
},
13381354
}),
1339-
"openai-accounts-health": tool({
1340-
description: "Check health of all OpenAI accounts by validating refresh tokens.",
1341-
args: {},
1342-
async execute() {
1343-
const storage = await loadAccounts();
1344-
if (!storage || storage.accounts.length === 0) {
1345-
return "No OpenAI accounts configured. Run: opencode auth login";
1346-
}
1355+
"openai-accounts-health": tool({
1356+
description: "Check health of all OpenAI accounts by validating refresh tokens.",
1357+
args: {},
1358+
async execute() {
1359+
const storage = await loadAccounts();
1360+
if (!storage || storage.accounts.length === 0) {
1361+
return "No OpenAI accounts configured. Run: opencode auth login";
1362+
}
13471363

1348-
const results: string[] = [
1349-
`Health Check (${storage.accounts.length} accounts):`,
1350-
"",
1351-
];
1364+
const results: string[] = [
1365+
`Health Check (${storage.accounts.length} accounts):`,
1366+
"",
1367+
];
1368+
1369+
let healthyCount = 0;
1370+
let unhealthyCount = 0;
1371+
1372+
for (let i = 0; i < storage.accounts.length; i++) {
1373+
const account = storage.accounts[i];
1374+
if (!account) continue;
1375+
1376+
const label = formatAccountLabel(account, i);
1377+
try {
1378+
const refreshResult = await queuedRefresh(account.refreshToken);
1379+
if (refreshResult.type === "success") {
1380+
results.push(` ✓ ${label}: Healthy`);
1381+
healthyCount++;
1382+
} else {
1383+
results.push(` ✗ ${label}: Token refresh failed`);
1384+
unhealthyCount++;
1385+
}
1386+
} catch (error) {
1387+
const errorMsg = error instanceof Error ? error.message : String(error);
1388+
results.push(` ✗ ${label}: Error - ${errorMsg.slice(0, 50)}`);
1389+
unhealthyCount++;
1390+
}
1391+
}
13521392

1353-
let healthyCount = 0;
1354-
let unhealthyCount = 0;
1355-
1356-
for (let i = 0; i < storage.accounts.length; i++) {
1357-
const account = storage.accounts[i];
1358-
if (!account) continue;
1359-
1360-
const label = formatAccountLabel(account, i);
1361-
try {
1362-
const refreshResult = await queuedRefresh(account.refreshToken);
1363-
if (refreshResult.type === "success") {
1364-
results.push(` ✓ ${label}: Healthy`);
1365-
healthyCount++;
1366-
} else {
1367-
results.push(` ✗ ${label}: Token refresh failed`);
1368-
unhealthyCount++;
1369-
}
1370-
} catch (error) {
1371-
const errorMsg = error instanceof Error ? error.message : String(error);
1372-
results.push(` ✗ ${label}: Error - ${errorMsg.slice(0, 50)}`);
1373-
unhealthyCount++;
1374-
}
1375-
}
1393+
results.push("");
1394+
results.push(`Summary: ${healthyCount} healthy, ${unhealthyCount} unhealthy`);
1395+
1396+
return results.join("\n");
1397+
},
1398+
}),
1399+
"openai-accounts-remove": tool({
1400+
description: "Remove an OpenAI account by index (1-based). Use openai-accounts to list accounts first.",
1401+
args: {
1402+
index: tool.schema.number().describe(
1403+
"Account number to remove (1-based, e.g., 1 for first account)",
1404+
),
1405+
},
1406+
async execute({ index }) {
1407+
const storage = await loadAccounts();
1408+
if (!storage || storage.accounts.length === 0) {
1409+
return "No OpenAI accounts configured. Nothing to remove.";
1410+
}
13761411

1377-
results.push("");
1378-
results.push(`Summary: ${healthyCount} healthy, ${unhealthyCount} unhealthy`);
1412+
const targetIndex = Math.floor((index ?? 0) - 1);
1413+
if (
1414+
!Number.isFinite(targetIndex) ||
1415+
targetIndex < 0 ||
1416+
targetIndex >= storage.accounts.length
1417+
) {
1418+
return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}\n\nUse openai-accounts to list all accounts.`;
1419+
}
13791420

1380-
return results.join("\n");
1381-
},
1382-
}),
1421+
const account = storage.accounts[targetIndex];
1422+
if (!account) {
1423+
return `Account ${index} not found.`;
1424+
}
13831425

1384-
},
1426+
const label = formatAccountLabel(account, targetIndex);
1427+
1428+
storage.accounts.splice(targetIndex, 1);
1429+
1430+
if (storage.accounts.length === 0) {
1431+
storage.activeIndex = 0;
1432+
storage.activeIndexByFamily = {};
1433+
} else {
1434+
if (storage.activeIndex >= storage.accounts.length) {
1435+
storage.activeIndex = 0;
1436+
} else if (storage.activeIndex > targetIndex) {
1437+
storage.activeIndex -= 1;
1438+
}
1439+
1440+
if (storage.activeIndexByFamily) {
1441+
for (const family of MODEL_FAMILIES) {
1442+
const idx = storage.activeIndexByFamily[family];
1443+
if (typeof idx === "number") {
1444+
if (idx >= storage.accounts.length) {
1445+
storage.activeIndexByFamily[family] = 0;
1446+
} else if (idx > targetIndex) {
1447+
storage.activeIndexByFamily[family] = idx - 1;
1448+
}
1449+
}
1450+
}
1451+
}
1452+
}
1453+
1454+
await saveAccounts(storage);
1455+
1456+
if (cachedAccountManager) {
1457+
const managedAccounts = cachedAccountManager.getAccountsSnapshot();
1458+
const managedAccount = managedAccounts.find(
1459+
(acc) => acc.refreshToken === account.refreshToken
1460+
);
1461+
if (managedAccount) {
1462+
cachedAccountManager.removeAccount(managedAccount);
1463+
await cachedAccountManager.saveToDisk();
1464+
}
1465+
}
1466+
1467+
const remaining = storage.accounts.length;
1468+
return [
1469+
`Removed: ${label}`,
1470+
"",
1471+
remaining > 0
1472+
? `Remaining accounts: ${remaining}`
1473+
: "No accounts remaining. Run: opencode auth login",
1474+
].join("\n");
1475+
},
1476+
}),
1477+
1478+
},
13851479
};
13861480
};
13871481

lib/config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const DEFAULT_CONFIG: PluginConfig = {
1717
retryAllAccountsMaxRetries: Infinity,
1818
tokenRefreshSkewMs: 60_000,
1919
rateLimitToastDebounceMs: 60_000,
20+
toastDurationMs: 5_000,
21+
perProjectAccounts: true,
2022
sessionRecovery: true,
2123
autoResume: true,
2224
};
@@ -156,3 +158,20 @@ export function getAutoResume(pluginConfig: PluginConfig): boolean {
156158
true,
157159
);
158160
}
161+
162+
export function getToastDurationMs(pluginConfig: PluginConfig): number {
163+
return resolveNumberSetting(
164+
"CODEX_AUTH_TOAST_DURATION_MS",
165+
pluginConfig.toastDurationMs,
166+
5_000,
167+
{ min: 1_000 },
168+
);
169+
}
170+
171+
export function getPerProjectAccounts(pluginConfig: PluginConfig): boolean {
172+
return resolveBooleanSetting(
173+
"CODEX_AUTH_PER_PROJECT_ACCOUNTS",
174+
pluginConfig.perProjectAccounts,
175+
true,
176+
);
177+
}

lib/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export const AUTH_LABELS = {
8484
/** Multi-account configuration */
8585
export const ACCOUNT_LIMITS = {
8686
/** Maximum number of OAuth accounts that can be registered */
87-
MAX_ACCOUNTS: 10,
87+
MAX_ACCOUNTS: 20,
8888
/** Cooldown period (ms) after auth failure before retrying account */
8989
AUTH_FAILURE_COOLDOWN_MS: 30_000,
9090
} as const;

0 commit comments

Comments
 (0)