Skip to content

Commit 339d80a

Browse files
committed
feat: v4.11.0 - subdirectory detection, countdown timer, auto-remove, refresh tool
- subdirectory detection: walks up directory tree to find project root for per-project accounts - countdown timer: shows live countdown during rate limit waits (every 5s) - auto-remove: removes accounts after 3 consecutive auth failures with notification - openai-accounts-refresh: new tool to manually refresh all OAuth tokens
1 parent 29b96d4 commit 339d80a

6 files changed

Lines changed: 188 additions & 58 deletions

File tree

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,19 +252,21 @@ opencode auth login # run again to add more accounts
252252
- `openai-accounts` — list all accounts
253253
- `openai-accounts-switch` — switch active account
254254
- `openai-accounts-status` — show rate limit status
255-
- `openai-accounts-remove` — remove an account by index (new in v4.10.0)
255+
- `openai-accounts-remove` — remove an account by index
256256
- `openai-accounts-health` — check health of all accounts
257+
- `openai-accounts-refresh` — manually refresh all tokens (new in v4.11.0)
257258

258259
**how rotation works:**
259260
- health scoring tracks success/failure per account
260261
- token bucket prevents hitting rate limits
261262
- hybrid selection prefers healthy accounts with available tokens
262-
- always retries when all accounts are rate-limited (waits for reset)
263+
- always retries when all accounts are rate-limited (waits for reset with live countdown)
263264
- 20% jitter on retry delays to avoid thundering herd
265+
- auto-removes accounts after 3 consecutive auth failures (new in v4.11.0)
264266

265267
**per-project accounts (v4.10.0+):**
266268

267-
by default, each project directory gets its own account storage. this means you can have different active accounts per project. disable with `perProjectAccounts: false` in your config.
269+
by default, each project directory gets its own account storage. this means you can have different active accounts per project. works from subdirectories too — the plugin walks up to find the project root (v4.11.0). disable with `perProjectAccounts: false` in your config.
268270

269271
**storage locations:**
270272
- per-project: `{project-root}/.opencode/openai-codex-accounts.json`

index.ts

Lines changed: 138 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -674,31 +674,61 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
674674
const modelFamily = model ? getModelFamily(model) : "gpt-5.1";
675675
const quotaKey = model ? `${modelFamily}:${model}` : modelFamily;
676676

677-
const abortSignal = requestInit?.signal ?? init?.signal ?? null;
678-
const sleep = (ms: number): Promise<void> =>
679-
new Promise((resolve, reject) => {
680-
if (abortSignal?.aborted) {
681-
reject(new Error("Aborted"));
682-
return;
683-
}
684-
685-
const timeout = setTimeout(() => {
686-
cleanup();
687-
resolve();
688-
}, ms);
689-
690-
const onAbort = () => {
691-
cleanup();
692-
reject(new Error("Aborted"));
693-
};
694-
695-
const cleanup = () => {
696-
clearTimeout(timeout);
697-
abortSignal?.removeEventListener("abort", onAbort);
698-
};
677+
const abortSignal = requestInit?.signal ?? init?.signal ?? null;
678+
const sleep = (ms: number): Promise<void> =>
679+
new Promise((resolve, reject) => {
680+
if (abortSignal?.aborted) {
681+
reject(new Error("Aborted"));
682+
return;
683+
}
699684

700-
abortSignal?.addEventListener("abort", onAbort, { once: true });
701-
});
685+
const timeout = setTimeout(() => {
686+
cleanup();
687+
resolve();
688+
}, ms);
689+
690+
const onAbort = () => {
691+
cleanup();
692+
reject(new Error("Aborted"));
693+
};
694+
695+
const cleanup = () => {
696+
clearTimeout(timeout);
697+
abortSignal?.removeEventListener("abort", onAbort);
698+
};
699+
700+
abortSignal?.addEventListener("abort", onAbort, { once: true });
701+
});
702+
703+
const sleepWithCountdown = async (
704+
totalMs: number,
705+
message: string,
706+
intervalMs: number = 5000,
707+
): Promise<void> => {
708+
const startTime = Date.now();
709+
const endTime = startTime + totalMs;
710+
711+
while (Date.now() < endTime) {
712+
if (abortSignal?.aborted) {
713+
throw new Error("Aborted");
714+
}
715+
716+
const remaining = Math.max(0, endTime - Date.now());
717+
const waitLabel = formatWaitTime(remaining);
718+
await showToast(
719+
`${message} (${waitLabel} remaining)`,
720+
"warning",
721+
{ duration: Math.min(intervalMs + 1000, toastDurationMs) },
722+
);
723+
724+
const sleepTime = Math.min(intervalMs, remaining);
725+
if (sleepTime > 0) {
726+
await sleep(sleepTime);
727+
} else {
728+
break;
729+
}
730+
}
731+
};
702732

703733
let allRateLimitedRetries = 0;
704734

@@ -718,25 +748,40 @@ while (attempted.size < Math.max(1, accountCount)) {
718748
);
719749

720750
let accountAuth = accountManager.toAuthDetails(account) as OAuthAuthDetails;
721-
try {
722-
if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) {
723-
accountAuth = (await refreshAndUpdateToken(
724-
accountAuth,
725-
client,
726-
)) as OAuthAuthDetails;
727-
accountManager.updateFromAuth(account, accountAuth);
728-
accountManager.saveToDiskDebounced();
729-
}
751+
try {
752+
if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) {
753+
accountAuth = (await refreshAndUpdateToken(
754+
accountAuth,
755+
client,
756+
)) as OAuthAuthDetails;
757+
accountManager.updateFromAuth(account, accountAuth);
758+
accountManager.clearAuthFailures(account);
759+
accountManager.saveToDiskDebounced();
760+
}
730761
} catch (err) {
731762
logDebug(`[${PLUGIN_NAME}] Auth refresh failed for account: ${(err as Error)?.message ?? String(err)}`);
763+
const failures = accountManager.incrementAuthFailures(account);
764+
const accountLabel = formatAccountLabel(account, account.index);
765+
766+
if (failures >= ACCOUNT_LIMITS.MAX_AUTH_FAILURES_BEFORE_REMOVAL) {
767+
accountManager.removeAccount(account);
768+
accountManager.saveToDiskDebounced();
769+
await showToast(
770+
`Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'opencode auth login' to re-add.`,
771+
"error",
772+
{ duration: toastDurationMs * 2 },
773+
);
774+
continue;
775+
}
776+
732777
accountManager.markAccountCoolingDown(
733778
account,
734779
ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS,
735780
"auth-failure",
736781
);
737-
accountManager.saveToDiskDebounced();
738-
continue;
739-
}
782+
accountManager.saveToDiskDebounced();
783+
continue;
784+
}
740785

741786
const hadAccountId = !!account.accountId;
742787
const accountId =
@@ -883,24 +928,19 @@ while (attempted.size < Math.max(1, accountCount)) {
883928
const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model);
884929
const count = accountManager.getAccountCount();
885930

886-
if (
887-
retryAllAccountsRateLimited &&
888-
count > 0 &&
889-
waitMs > 0 &&
890-
(retryAllAccountsMaxWaitMs === 0 ||
891-
waitMs <= retryAllAccountsMaxWaitMs) &&
892-
allRateLimitedRetries < retryAllAccountsMaxRetries
893-
) {
894-
const waitLabel = formatWaitTime(waitMs);
895-
await showToast(
896-
`All ${count} account(s) are rate-limited. Waiting ${waitLabel}...`,
897-
"warning",
898-
{ duration: toastDurationMs },
899-
);
931+
if (
932+
retryAllAccountsRateLimited &&
933+
count > 0 &&
934+
waitMs > 0 &&
935+
(retryAllAccountsMaxWaitMs === 0 ||
936+
waitMs <= retryAllAccountsMaxWaitMs) &&
937+
allRateLimitedRetries < retryAllAccountsMaxRetries
938+
) {
939+
const countdownMessage = `All ${count} account(s) rate-limited. Waiting`;
940+
await sleepWithCountdown(addJitter(waitMs, 0.2), countdownMessage);
900941
allRateLimitedRetries++;
901-
await sleep(addJitter(waitMs, 0.2));
902942
continue;
903-
}
943+
}
904944

905945
const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit";
906946
const message =
@@ -1475,6 +1515,52 @@ while (attempted.size < Math.max(1, accountCount)) {
14751515
},
14761516
}),
14771517

1518+
"openai-accounts-refresh": tool({
1519+
description: "Manually refresh OAuth tokens for all accounts to verify they're still valid.",
1520+
args: {},
1521+
async execute() {
1522+
const storage = await loadAccounts();
1523+
if (!storage || storage.accounts.length === 0) {
1524+
return "No OpenAI accounts configured. Run: opencode auth login";
1525+
}
1526+
1527+
const results: string[] = [
1528+
`Refreshing ${storage.accounts.length} account(s):`,
1529+
"",
1530+
];
1531+
1532+
let refreshedCount = 0;
1533+
let failedCount = 0;
1534+
1535+
for (let i = 0; i < storage.accounts.length; i++) {
1536+
const account = storage.accounts[i];
1537+
if (!account) continue;
1538+
const label = formatAccountLabel(account, i);
1539+
1540+
try {
1541+
const refreshResult = await queuedRefresh(account.refreshToken);
1542+
if (refreshResult.type === "success") {
1543+
account.refreshToken = refreshResult.refresh;
1544+
results.push(` ✓ ${label}: Refreshed`);
1545+
refreshedCount++;
1546+
} else {
1547+
results.push(` ✗ ${label}: Failed - ${refreshResult.message ?? refreshResult.reason}`);
1548+
failedCount++;
1549+
}
1550+
} catch (error) {
1551+
const errorMsg = error instanceof Error ? error.message : String(error);
1552+
results.push(` ✗ ${label}: Error - ${errorMsg.slice(0, 50)}`);
1553+
failedCount++;
1554+
}
1555+
}
1556+
1557+
await saveAccounts(storage);
1558+
results.push("");
1559+
results.push(`Summary: ${refreshedCount} refreshed, ${failedCount} failed`);
1560+
return results.join("\n");
1561+
},
1562+
}),
1563+
14781564
},
14791565
};
14801566
};

lib/accounts.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ export interface ManagedAccount {
354354
rateLimitResetTimes: RateLimitStateV3;
355355
coolingDownUntil?: number;
356356
cooldownReason?: CooldownReason;
357+
consecutiveAuthFailures?: number;
357358
}
358359

359360
function clearExpiredRateLimits(account: ManagedAccount): void {
@@ -756,6 +757,15 @@ export class AccountManager {
756757
delete account.cooldownReason;
757758
}
758759

760+
incrementAuthFailures(account: ManagedAccount): number {
761+
account.consecutiveAuthFailures = (account.consecutiveAuthFailures ?? 0) + 1;
762+
return account.consecutiveAuthFailures;
763+
}
764+
765+
clearAuthFailures(account: ManagedAccount): void {
766+
account.consecutiveAuthFailures = 0;
767+
}
768+
759769
shouldShowAccountToast(accountIndex: number, debounceMs = 30000): boolean {
760770
const now = nowMs();
761771
if (accountIndex === this.lastToastAccountIndex && now - this.lastToastTime < debounceMs) {

lib/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,6 @@ export const ACCOUNT_LIMITS = {
8787
MAX_ACCOUNTS: 20,
8888
/** Cooldown period (ms) after auth failure before retrying account */
8989
AUTH_FAILURE_COOLDOWN_MS: 30_000,
90+
/** Number of consecutive auth failures before auto-removing account */
91+
MAX_AUTH_FAILURES_BEFORE_REMOVAL: 3,
9092
} as const;

lib/storage.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,41 @@ function isProjectDirectory(dir: string): boolean {
8989
return PROJECT_MARKERS.some((marker) => existsSync(join(dir, marker)));
9090
}
9191

92+
/**
93+
* Walk up the directory tree to find the nearest project root.
94+
* Returns the first directory containing a project marker, or null if none found.
95+
*/
96+
function findProjectRoot(startDir: string): string | null {
97+
let current = startDir;
98+
const root = dirname(current) === current ? current : null;
99+
100+
while (current) {
101+
if (isProjectDirectory(current)) {
102+
return current;
103+
}
104+
105+
const parent = dirname(current);
106+
// Reached filesystem root
107+
if (parent === current) {
108+
break;
109+
}
110+
current = parent;
111+
}
112+
113+
return root && isProjectDirectory(root) ? root : null;
114+
}
115+
92116
let currentStoragePath: string | null = null;
93117

94118
export function setStoragePath(projectPath: string | null): void {
95-
if (projectPath && isProjectDirectory(projectPath)) {
96-
currentStoragePath = join(getProjectConfigDir(projectPath), "openai-codex-accounts.json");
119+
if (!projectPath) {
120+
currentStoragePath = null;
121+
return;
122+
}
123+
124+
const projectRoot = findProjectRoot(projectPath);
125+
if (projectRoot) {
126+
currentStoragePath = join(getProjectConfigDir(projectRoot), "openai-codex-accounts.json");
97127
} else {
98128
currentStoragePath = null;
99129
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "oc-chatgpt-multi-auth",
3-
"version": "4.10.0",
3+
"version": "4.11.0",
44
"description": "Multi-account rotation plugin for ChatGPT Plus/Pro (OAuth / Codex backend)",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

0 commit comments

Comments
 (0)