Skip to content

Commit 33418a9

Browse files
authored
Merge pull request #9 from ndycode/fix/multi-account-duplicate-accountid
feat: v4.5.0 - Queued token refresh, enhanced logging, auto-update checker
2 parents e950c9b + 9970c97 commit 33418a9

15 files changed

Lines changed: 1413 additions & 89 deletions

CHANGELOG.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,83 @@ All notable changes to this project are documented here. Dates use the ISO forma
1818
- Stored account emails are trimmed/lowercased when present.
1919
- Dependency refresh: @opencode-ai plugin/sdk 1.1.34, hono 4.11.5, vitest 4.0.18, @types/node 25.0.10, @typescript-eslint 8.53.1.
2020

21+
## [4.5.0] - 2026-01-25
22+
23+
**Feature release**: Queued token refresh, enhanced logging, and auto-update notifications.
24+
25+
### Added
26+
- **Queued Token Refresh**: New `RefreshQueue` class prevents race conditions when multiple concurrent requests try to refresh the same account's token simultaneously:
27+
- Deduplicates concurrent refresh calls per account
28+
- Subsequent callers await the existing in-flight refresh promise
29+
- Automatic cleanup of stale entries after 30 seconds
30+
- New module: `lib/refresh-queue.ts`
31+
- **Enhanced Logging System**: Upgraded logger with proper log levels and timing utilities:
32+
- Log levels: `debug`, `info`, `warn`, `error`
33+
- Configurable via `CODEX_PLUGIN_LOG_LEVEL` environment variable
34+
- Scoped loggers with timing functions (`time()`, `timeEnd()`)
35+
- Duration formatting utilities
36+
- **Auto-Update Notifications**: Plugin now checks npm for newer versions on load:
37+
- Checks npm registry once per 24 hours (cached)
38+
- Shows toast notification when update is available
39+
- New module: `lib/auto-update-checker.ts`
40+
- **13 new unit tests** for RefreshQueue (now 345 total tests)
41+
42+
### Changed
43+
- Token refresh calls now use `queuedRefresh()` instead of direct `refreshAccessToken()` for race condition safety
44+
- Logger exports expanded: `logInfo()`, `logError()`, `ScopedLogger` interface, `formatDuration()`
45+
46+
### Technical Details
47+
- RefreshQueue uses a Map to track in-flight refresh promises, keyed by refresh token
48+
- Stale entries (>30s) are automatically cleaned up to prevent memory leaks
49+
- Auto-update check is non-blocking and fails silently to avoid disrupting plugin operation
50+
51+
## [4.4.0] - 2026-01-25
52+
53+
**Feature release**: Intelligent rate-limit rotation with health-based account selection.
54+
55+
### Added
56+
- **Health Score Tracking**: Accounts now track health scores based on success/failure history:
57+
- +1 point on successful request
58+
- -10 points on rate limit
59+
- -20 points on other failures
60+
- +2 points/hour passive recovery
61+
- **Token Bucket Rate Limiting**: Client-side rate limiting (50 max tokens, 6 tokens/min regeneration) to prevent hitting rate limits.
62+
- **Hybrid Account Selection**: New scoring algorithm selects optimal account:
63+
- Score = (health × 2) + (tokens × 5) + (freshness × 0.1)
64+
- Prefers healthy accounts with available tokens that haven't been used recently
65+
- **Reason-Aware Backoff**: Different rate limit reasons use different backoff multipliers:
66+
- `quota`: 3.0× (daily quota exhausted - longer wait)
67+
- `tokens`: 1.5× (token limit - moderate wait)
68+
- `concurrent`: 0.5× (concurrent requests - short wait)
69+
- `unknown`: 1.0× (default)
70+
- **New AccountManager methods**: `getCurrentOrNextForFamilyHybrid()`, `recordSuccess()`, `recordRateLimit()`, `recordFailure()`, `markRateLimitedWithReason()`
71+
- **New module**: `lib/rotation.ts` with `HealthScoreTracker`, `TokenBucketTracker`, `selectHybridAccount`
72+
73+
### Changed
74+
- **BREAKING: Default retry behavior changed** - Plugin now **always waits and retries** when all accounts are rate-limited:
75+
- `retryAllAccountsRateLimited`: `false``true`
76+
- `retryAllAccountsMaxWaitMs`: `30000``0` (no limit)
77+
- `retryAllAccountsMaxRetries`: `1``Infinity` (unlimited)
78+
- **Account selection strategy**: Uses hybrid health-based selection instead of simple round-robin
79+
- **Rate limit handling**: Records health metrics on success/failure for smarter future selections
80+
81+
### Migration Notes
82+
If you need the old behavior (give up when all accounts are rate-limited), add to `~/.opencode/openai-codex-auth-config.json`:
83+
```json
84+
{
85+
"retryAllAccountsRateLimited": false,
86+
"retryAllAccountsMaxWaitMs": 30000,
87+
"retryAllAccountsMaxRetries": 1
88+
}
89+
```
90+
91+
Or use environment variables:
92+
```bash
93+
export CODEX_AUTH_RETRY_ALL_RATE_LIMITED=0
94+
export CODEX_AUTH_RETRY_ALL_MAX_WAIT_MS=30000
95+
export CODEX_AUTH_RETRY_ALL_MAX_RETRIES=1
96+
```
97+
2198
## [4.3.0] - 2026-01-04
2299

23100
**Feature + reliability release**: variants support, one-command installer, and auth/error handling fixes.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ Enable OpenCode to authenticate against **OpenAI's Codex backend** via OAuth so
1010
## What You Get
1111

1212
- **GPT-5.2, GPT-5.2 Codex, GPT-5.1 Codex Max** and all GPT-5.x variants via ChatGPT OAuth
13-
- **Multi-account support** — add multiple ChatGPT accounts, auto-rotates when rate-limited
13+
- **Multi-account support** — add multiple ChatGPT accounts, health-aware rotation with automatic failover
14+
- **Auto-update notifications** — get notified when a new version is available
1415
- **22 model presets** — full variant system with reasoning levels (none/low/medium/high/xhigh)
1516
- **Prompt caching** — session-based caching for faster multi-turn conversations
1617
- **Usage-aware errors** — friendly messages with rate limit reset timing
@@ -438,6 +439,7 @@ Create `~/.opencode/openai-codex-auth-config.json` for optional settings:
438439
```bash
439440
DEBUG_CODEX_PLUGIN=1 opencode # Enable debug logging
440441
ENABLE_PLUGIN_REQUEST_LOGGING=1 opencode # Log all API requests
442+
CODEX_PLUGIN_LOG_LEVEL=debug opencode # Set log level (debug|info|warn|error)
441443
CODEX_MODE=0 opencode # Temporarily disable bridge prompt
442444
```
443445

index.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ import {
3030
exchangeAuthorizationCode,
3131
parseAuthorizationInput,
3232
REDIRECT_URI,
33-
refreshAccessToken,
3433
} from "./lib/auth/auth.js";
34+
import { queuedRefresh } from "./lib/refresh-queue.js";
3535
import { openBrowserUrl } from "./lib/auth/browser.js";
3636
import { startLocalOAuthServer } from "./lib/auth/server.js";
3737
import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js";
@@ -54,6 +54,7 @@ import {
5454
ACCOUNT_LIMITS,
5555
} from "./lib/constants.js";
5656
import { logRequest, logDebug } from "./lib/logger.js";
57+
import { checkAndNotify } from "./lib/auto-update-checker.js";
5758
import {
5859
AccountManager,
5960
extractAccountEmail,
@@ -370,7 +371,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
370371
await Promise.all(
371372
accountsToHydrate.map(async (account) => {
372373
try {
373-
const refreshed = await refreshAccessToken(account.refreshToken);
374+
const refreshed = await queuedRefresh(account.refreshToken);
374375
if (refreshed.type !== "success") return;
375376
const id = extractAccountId(refreshed.access);
376377
const email = sanitizeEmail(extractAccountEmail(refreshed.access));
@@ -495,6 +496,10 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
495496
const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig);
496497
const retryAllAccountsMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig);
497498

499+
checkAndNotify(async (message, variant) => {
500+
await showToast(message, variant);
501+
}).catch(() => {});
502+
498503

499504
// Return SDK configuration
500505
return {
@@ -574,11 +579,11 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
574579
const accountCount = accountManager.getAccountCount();
575580
const attempted = new Set<number>();
576581

577-
while (attempted.size < Math.max(1, accountCount)) {
578-
const account = accountManager.getCurrentOrNextForFamily(modelFamily, model);
579-
if (!account || attempted.has(account.index)) {
580-
break;
581-
}
582+
while (attempted.size < Math.max(1, accountCount)) {
583+
const account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model);
584+
if (!account || attempted.has(account.index)) {
585+
break;
586+
}
582587
attempted.add(account.index);
583588

584589
let accountAuth = accountManager.toAuthDetails(account) as OAuthAuthDetails;
@@ -690,6 +695,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
690695
modelFamily,
691696
model,
692697
);
698+
accountManager.recordRateLimit(account, modelFamily, model);
693699
account.lastSwitchReason = "rate-limit";
694700
accountManager.saveToDiskDebounced();
695701

@@ -711,8 +717,9 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
711717
return errorResponse;
712718
}
713719

714-
resetRateLimitBackoff(account.index, quotaKey);
715-
return await handleSuccessResponse(response, isStreaming);
720+
resetRateLimitBackoff(account.index, quotaKey);
721+
accountManager.recordSuccess(account, modelFamily, model);
722+
return await handleSuccessResponse(response, isStreaming);
716723
}
717724
}
718725

@@ -1200,7 +1207,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
12001207

12011208
const label = formatAccountLabel(account, i);
12021209
try {
1203-
const refreshResult = await refreshAccessToken(account.refreshToken);
1210+
const refreshResult = await queuedRefresh(account.refreshToken);
12041211
if (refreshResult.type === "success") {
12051212
results.push(` ✓ ${label}: Healthy`);
12061213
healthyCount++;

lib/accounts.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,18 @@ import {
1010
} from "./storage.js";
1111
import type { OAuthAuthDetails } from "./types.js";
1212
import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js";
13+
import {
14+
getHealthTracker,
15+
getTokenTracker,
16+
selectHybridAccount,
17+
type AccountWithMetrics,
18+
} from "./rotation.js";
1319

1420
export type BaseQuotaKey = ModelFamily;
1521
export type QuotaKey = BaseQuotaKey | `${BaseQuotaKey}:${string}`;
1622

23+
export type RateLimitReason = "quota" | "tokens" | "concurrent" | "unknown";
24+
1725
function nowMs(): number {
1826
return Date.now();
1927
}
@@ -90,6 +98,7 @@ export interface ManagedAccount {
9098
addedAt: number;
9199
lastUsed: number;
92100
lastSwitchReason?: "rate-limit" | "initial" | "rotation";
101+
lastRateLimitReason?: RateLimitReason;
93102
rateLimitResetTimes: RateLimitStateV3;
94103
coolingDownUntil?: number;
95104
cooldownReason?: CooldownReason;
@@ -370,12 +379,76 @@ export class AccountManager {
370379
return null;
371380
}
372381

382+
getCurrentOrNextForFamilyHybrid(family: ModelFamily, model?: string | null): ManagedAccount | null {
383+
const count = this.accounts.length;
384+
if (count === 0) return null;
385+
386+
const quotaKey = model ? `${family}:${model}` : family;
387+
const healthTracker = getHealthTracker();
388+
const tokenTracker = getTokenTracker();
389+
390+
const accountsWithMetrics: AccountWithMetrics[] = this.accounts
391+
.map((account): AccountWithMetrics | null => {
392+
if (!account) return null;
393+
clearExpiredRateLimits(account);
394+
const isAvailable =
395+
!isRateLimitedForFamily(account, family, model) && !this.isAccountCoolingDown(account);
396+
return {
397+
index: account.index,
398+
isAvailable,
399+
lastUsed: account.lastUsed,
400+
};
401+
})
402+
.filter((a): a is AccountWithMetrics => a !== null);
403+
404+
const selected = selectHybridAccount(accountsWithMetrics, healthTracker, tokenTracker, quotaKey);
405+
if (!selected) return null;
406+
407+
const account = this.accounts[selected.index];
408+
if (!account) return null;
409+
410+
this.currentAccountIndexByFamily[family] = account.index;
411+
this.cursorByFamily[family] = (account.index + 1) % count;
412+
account.lastUsed = nowMs();
413+
return account;
414+
}
415+
416+
recordSuccess(account: ManagedAccount, family: ModelFamily, model?: string | null): void {
417+
const quotaKey = model ? `${family}:${model}` : family;
418+
const healthTracker = getHealthTracker();
419+
healthTracker.recordSuccess(account.index, quotaKey);
420+
}
421+
422+
recordRateLimit(account: ManagedAccount, family: ModelFamily, model?: string | null): void {
423+
const quotaKey = model ? `${family}:${model}` : family;
424+
const healthTracker = getHealthTracker();
425+
const tokenTracker = getTokenTracker();
426+
healthTracker.recordRateLimit(account.index, quotaKey);
427+
tokenTracker.drain(account.index, quotaKey);
428+
}
429+
430+
recordFailure(account: ManagedAccount, family: ModelFamily, model?: string | null): void {
431+
const quotaKey = model ? `${family}:${model}` : family;
432+
const healthTracker = getHealthTracker();
433+
healthTracker.recordFailure(account.index, quotaKey);
434+
}
435+
373436
markSwitched(account: ManagedAccount, reason: "rate-limit" | "initial" | "rotation", family: ModelFamily): void {
374437
account.lastSwitchReason = reason;
375438
this.currentAccountIndexByFamily[family] = account.index;
376439
}
377440

378441
markRateLimited(account: ManagedAccount, retryAfterMs: number, family: ModelFamily, model?: string | null): void {
442+
this.markRateLimitedWithReason(account, retryAfterMs, family, "unknown", model);
443+
}
444+
445+
markRateLimitedWithReason(
446+
account: ManagedAccount,
447+
retryAfterMs: number,
448+
family: ModelFamily,
449+
reason: RateLimitReason,
450+
model?: string | null,
451+
): void {
379452
const retryMs = Math.max(0, Math.floor(retryAfterMs));
380453
const resetAt = nowMs() + retryMs;
381454

@@ -386,6 +459,8 @@ export class AccountManager {
386459
const modelKey = getQuotaKey(family, model);
387460
account.rateLimitResetTimes[modelKey] = resetAt;
388461
}
462+
463+
account.lastRateLimitReason = reason;
389464
}
390465

391466
markAccountCoolingDown(account: ManagedAccount, cooldownMs: number, reason: CooldownReason): void {

0 commit comments

Comments
 (0)