Skip to content

Commit 38480e9

Browse files
ndycodeclaude
andauthored
feat(rotation): add rotationStrategy selector and codex-warm tool (#183, #182) (#184)
Adds rotationStrategy config (hybrid/sticky/round-robin), the codex-warm tool that opens each account's usage window via a minimal /codex/responses request, and a fix so local token-bucket depletion rotates instead of 503ing under sticky/hybrid. Syncs plugin.json version and adds release-please extra-files. 41 feature tests + 19 chaos stress tests; full suite green. Closes #183 Closes #182 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e5f7bbb commit 38480e9

27 files changed

Lines changed: 1553 additions & 28 deletions

.codex-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "oc-codex-multi-auth",
3-
"version": "6.3.3",
3+
"version": "6.3.4",
44
"description": "Install and operate oc-codex-multi-auth for OpenCode with ChatGPT Plus/Pro OAuth, Codex/GPT-5 routing, multi-account rotation, account switching, health checks, diagnostics, quota status, and recovery tools.",
55
"skills": "./skills/",
66
"interface": {

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- `rotationStrategy` config (env `CODEX_AUTH_ROTATION_STRATEGY`) selects the account load-balancing algorithm: `hybrid` (default, unchanged — stick to the current account while healthy, else score-select the next), `sticky` (drain-first — stay on one account until it is rate-limited/cooling down, then move to the lowest-indexed available account so load concentrates and the other accounts keep their quota windows in reserve), or `round-robin` (advance through accounts in order). Sticky directly addresses the "all accounts share an initiation time and hit weekly-quota cooldown together" problem under round-robin. (#183)
12+
- `codex-warm` tool primes every enabled account's usage window by sending one lightweight authenticated request (the same `/wham/usage` call the usage panel uses) to each, so the rolling ~5h quota windows start at session start instead of only when rotation eventually lands on each account. Disabled accounts are skipped; per-account failures are reported without aborting the batch. Complements `codex-switch index=N`, which already switches the active account on demand. (#182)
13+
1014
### Fixed
1115
- `codex-doctor --fix` now clears stale account-health state on accounts whose token refresh succeeds during the repair: an `auth-failure`/`network-error` cooldown and any `rateLimitResetTimes` markers are removed once the refresh proves the credential is alive. Previously `--fix` refreshed the token and tried to switch to the healthiest account, but left the stale cooldown/rate-limit state in place, so no account was eligible and the dead routing persisted — the only recovery was hand-editing `oc-codex-multi-auth-accounts.json`. The stale TUI quota cache is also cleared so diagnostics no longer reference an account index/count that no longer matches the pool. (fixes #171)
1216
- `codex-doctor` now surfaces a finding when a disabled `accountIdSource: "token"` entry shadows an enabled, org-backed account that shares its email (a leftover a fresh re-login can mint instead of updating the org account). It is flagged with a `codex-remove` hint rather than auto-removed, because the only link between the two records is email and email-only merges must not collapse distinct multi-org accounts (#64). (#171)

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ If browser launch is blocked, use the alternate login paths in [docs/getting-sta
175175
| --- | --- |
176176
| `codex-list` | Which accounts are saved and which one is active? |
177177
| `codex-switch` | How do I move to a different saved account? |
178+
| `codex-warm` | How do I start every account's usage window now (stagger quota cooldowns)? |
178179
| `codex-status` | Which account, model family, and routing state are active? |
179180
| `codex-limits` | What quota or rate-limit state is visible now? |
180181
| `codex-dashboard` | Can I manage accounts from one interactive surface? |

docs/development/ARCHITECTURE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ index.ts
4545
|- ToolContext construction
4646
v
4747
lib/tools/index.ts
48-
|- registers 21 OpenCode tools
48+
|- registers 22 OpenCode tools
4949
|- each tool delegates to lib/tools/codex-*.ts
5050
5151
Request path
@@ -90,7 +90,7 @@ tui.ts
9090
| Storage | `lib/storage.ts`, `lib/storage/` | V3 JSON storage, atomic writes, migrations, per-project paths, backups, import/export, keychain opt-in, flagged accounts |
9191
| Request bridge | `lib/request/fetch-helpers.ts`, `lib/request/request-transformer.ts`, `lib/request/response-handler.ts`, `lib/request/retry-budget.ts`, `lib/request/rate-limit-backoff.ts` | URL/body/header shaping, Codex invariants, SSE conversion, retry budgets, backoff, error mapping |
9292
| Model/prompt mapping | `lib/prompts/codex.ts`, `lib/prompts/opencode-codex.ts`, `lib/prompts/codex-opencode-bridge.ts`, `lib/request/helpers/model-map.ts` | model-family detection, Codex instructions cache, OpenCode prompt adaptation, fallback aliases |
93-
| Tool registry | `lib/tools/index.ts`, `lib/tools/codex-*.ts` | 21 OpenCode tools for setup, account switching, status, health, diagnostics, backup, keychain, and recovery |
93+
| Tool registry | `lib/tools/index.ts`, `lib/tools/codex-*.ts` | 22 OpenCode tools for setup, account switching, status, health, diagnostics, backup, keychain, and recovery |
9494
| Runtime support | `lib/runtime.ts`, `lib/circuit-breaker.ts`, `lib/proactive-refresh.ts`, `lib/parallel-probe.ts`, `lib/recovery/`, `lib/shutdown.ts` | pure runtime helpers, failure isolation, refresh scheduling, health probing, session recovery, cleanup |
9595
| UI helpers | `lib/ui/` | terminal formatting, auth menu, select/confirm prompts, theme/color handling, beginner checklist |
9696
| Config templates | `config/opencode-modern.json`, `config/opencode-legacy.json`, `config/minimal-opencode.json`, `config/README.md` | copy-paste OpenCode provider templates and model catalog guidance |
@@ -169,7 +169,7 @@ Legacy mode exists for compatibility with older OpenCode/AI SDK payload behavior
169169

170170
## Tool Registry Architecture
171171

172-
The plugin exposes 21 OpenCode tools through `lib/tools/index.ts`. `index.ts` builds one `ToolContext` from plugin-closure state and helper functions, then passes it to `createToolRegistry(ctx)`.
172+
The plugin exposes 22 OpenCode tools through `lib/tools/index.ts`. `index.ts` builds one `ToolContext` from plugin-closure state and helper functions, then passes it to `createToolRegistry(ctx)`.
173173

174174
Why this shape exists:
175175

index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import {
7676
getEmptyResponseMaxRetries,
7777
getEmptyResponseRetryDelayMs,
7878
getPidOffsetEnabled,
79+
getRotationStrategy,
7980
getFetchTimeoutMs,
8081
getStreamStallTimeoutMs,
8182
getCodexTuiV2,
@@ -1630,6 +1631,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
16301631
const emptyResponseMaxRetries = getEmptyResponseMaxRetries(pluginConfig);
16311632
const emptyResponseRetryDelayMs = getEmptyResponseRetryDelayMs(pluginConfig);
16321633
const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig);
1634+
const rotationStrategy = getRotationStrategy(pluginConfig);
16331635
const effectiveUserConfig = fastSessionEnabled
16341636
? applyFastSessionDefaults(userConfig)
16351637
: userConfig;
@@ -1940,7 +1942,7 @@ while (attempted.size < Math.max(1, accountCount)) {
19401942
fallbackTo,
19411943
fallbackReason,
19421944
};
1943-
const account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model, { pidOffsetEnabled });
1945+
const account = accountManager.getAccountForStrategy(rotationStrategy, modelFamily, model, { pidOffsetEnabled });
19441946
if (!account || attempted.has(account.index)) {
19451947
break;
19461948
}
@@ -2138,6 +2140,21 @@ while (attempted.size < Math.max(1, accountCount)) {
21382140
const tokenConsumed = accountManager.consumeToken(account, modelFamily, model);
21392141
if (!tokenConsumed) {
21402142
accountManager.recordRateLimit(account, modelFamily, model);
2143+
// Mark a short, auto-expiring rate-limit window for this
2144+
// (family, model) so the depleted account becomes ineligible in
2145+
// `isRateLimitedForFamily`. Without this, drain-first `sticky`
2146+
// (and the `hybrid` fast-path) re-select the SAME depleted account
2147+
// on the next traversal iteration — `attempted` then trips the
2148+
// guard and the loop 503s while other accounts still have quota.
2149+
// The window also feeds `getMinWaitTimeForFamily`, so an all-
2150+
// depleted pool waits for refill instead of failing fast.
2151+
accountManager.markRateLimitedWithReason(
2152+
account,
2153+
ACCOUNT_LIMITS.LOCAL_TOKEN_DEPLETION_COOLDOWN_MS,
2154+
modelFamily,
2155+
"tokens",
2156+
model,
2157+
);
21412158
runtimeMetrics.accountRotations++;
21422159
runtimeMetrics.lastError =
21432160
`Local token bucket depleted for account ${account.index + 1} (${modelFamily}${model ? `:${model}` : ""})`;

lib/accounts.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import type { Auth } from "@opencode-ai/sdk";
1616
import { loadAccounts, type AccountStorageV3, type CooldownReason } from "./storage.js";
1717
import type { HybridSelectionOptions } from "./rotation.js";
18+
import type { RotationStrategy } from "./config.js";
1819
import type { OAuthAuthDetails } from "./types.js";
1920
import type { ModelFamily } from "./prompts/codex.js";
2021
import {
@@ -206,6 +207,35 @@ export class AccountManager {
206207
return this.rotation.getCurrentOrNextForFamilyHybrid(family, model, options);
207208
}
208209

210+
getCurrentOrNextForFamilySticky(
211+
family: ModelFamily,
212+
model?: string | null,
213+
): ManagedAccount | null {
214+
return this.rotation.getCurrentOrNextForFamilySticky(family, model);
215+
}
216+
217+
/**
218+
* Strategy-aware account selection (issue #183). Routes to the configured
219+
* load-balancing algorithm. `hybrid` is the historical default and is
220+
* behavior-identical to calling {@link getCurrentOrNextForFamilyHybrid}
221+
* directly, so existing callers/tests are unaffected.
222+
*/
223+
getAccountForStrategy(
224+
strategy: RotationStrategy,
225+
family: ModelFamily,
226+
model?: string | null,
227+
options?: HybridSelectionOptions,
228+
): ManagedAccount | null {
229+
switch (strategy) {
230+
case "sticky":
231+
return this.rotation.getCurrentOrNextForFamilySticky(family, model);
232+
case "round-robin":
233+
return this.rotation.getCurrentOrNextForFamily(family, model);
234+
default:
235+
return this.rotation.getCurrentOrNextForFamilyHybrid(family, model, options);
236+
}
237+
}
238+
209239
recordSuccess(
210240
account: ManagedAccount,
211241
family: ModelFamily,

lib/accounts/rotation.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,64 @@ export class AccountRotation {
154154
return account;
155155
}
156156

157+
/**
158+
* Drain-first ("sticky") selection for issue #183.
159+
*
160+
* Stays on the current account for the family while it remains healthy
161+
* (not disabled, not rate-limited for this family/model, not cooling down).
162+
* When the current account is unavailable, it picks the *lowest-indexed*
163+
* available account rather than spreading load. This concentrates traffic
164+
* on as few accounts as possible so the remaining accounts keep their
165+
* quota in reserve — staggering weekly-quota cooldowns instead of
166+
* exhausting every account simultaneously (the round-robin failure mode the
167+
* issue describes).
168+
*
169+
* Returns null when no account is available (every account disabled,
170+
* rate-limited, or cooling down), matching the other selectors' contract so
171+
* the request loop's wait/retry logic is unchanged.
172+
*/
173+
getCurrentOrNextForFamilySticky(
174+
family: ModelFamily,
175+
model?: string | null,
176+
): ManagedAccount | null {
177+
const count = this.state.accounts.length;
178+
if (count === 0) return null;
179+
180+
const isAvailable = (account: ManagedAccount): boolean => {
181+
if (account.enabled === false) return false;
182+
clearExpiredRateLimits(account);
183+
return (
184+
!isRateLimitedForFamily(account, family, model) &&
185+
!this.state.isAccountCoolingDown(account)
186+
);
187+
};
188+
189+
// Prefer the account we are already pinned to while it still has quota.
190+
const currentIndex = this.state.currentAccountIndexByFamily[family];
191+
if (currentIndex >= 0 && currentIndex < count) {
192+
const currentAccount = this.state.accounts[currentIndex];
193+
if (currentAccount && isAvailable(currentAccount)) {
194+
currentAccount.lastUsed = nowMs();
195+
return currentAccount;
196+
}
197+
}
198+
199+
// Current account exhausted: pick the lowest-indexed available account so
200+
// load concentrates rather than spreads.
201+
for (let idx = 0; idx < count; idx++) {
202+
const account = this.state.accounts[idx];
203+
if (!account) continue;
204+
if (!isAvailable(account)) continue;
205+
206+
this.state.currentAccountIndexByFamily[family] = idx;
207+
this.state.cursorByFamily[family] = (idx + 1) % count;
208+
account.lastUsed = nowMs();
209+
return account;
210+
}
211+
212+
return null;
213+
}
214+
157215
recordSuccess(
158216
account: ManagedAccount,
159217
family: ModelFamily,

lib/accounts/warm-request.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Real upstream "warm" request for issue #182.
3+
*
4+
* Sends a single minimal billable request to `POST /codex/responses` — the
5+
* same endpoint and request shape the live request path uses for its quota
6+
* probe — so the account's rolling usage window actually starts. A read-only
7+
* `GET /wham/usage` does NOT open the window (it only reports server-side
8+
* windows that already exist), so warming must send a genuine inference
9+
* request. The body is deliberately tiny (reasoning effort "none", verbosity
10+
* "low", no stored conversation) to keep the quota cost negligible, and mirrors
11+
* the proven quota-probe request shape used by the live request path.
12+
*
13+
* This module owns the network side-effect; the pure iteration/summary logic
14+
* lives in `warm.ts` and is injected this function via `codex-warm.ts`.
15+
*/
16+
17+
import { CODEX_BASE_URL } from "../constants.js";
18+
import { createCodexHeaders } from "../request/fetch-helpers.js";
19+
import { getCodexInstructions } from "../prompts/codex.js";
20+
import type { RequestBody } from "../types.js";
21+
import { createLogger } from "../logger.js";
22+
23+
const log = createLogger("warm-request");
24+
25+
/** Model used for the warm ping. Mirrors the live quota-probe default. */
26+
const WARM_MODEL = "gpt-5.4";
27+
28+
/** Hard ceiling on a warm request so a hung upstream cannot wedge the batch. */
29+
const WARM_TIMEOUT_MS = 15_000;
30+
31+
export interface WarmRequestParams {
32+
accountId: string;
33+
accessToken: string;
34+
organizationId: string | undefined;
35+
/** Override the timeout (tests). */
36+
timeoutMs?: number;
37+
/** Injectable fetch for tests; defaults to global fetch. */
38+
fetchImpl?: typeof fetch;
39+
}
40+
41+
/**
42+
* Build the minimal warm-ping request body. Exported for tests so the exact
43+
* shape (stream/store/reasoning) stays pinned.
44+
*/
45+
export async function buildWarmRequestBody(model = WARM_MODEL): Promise<RequestBody> {
46+
const instructions = await getCodexInstructions(model);
47+
return {
48+
model,
49+
stream: true,
50+
store: false,
51+
include: ["reasoning.encrypted_content"],
52+
instructions,
53+
input: [
54+
{
55+
type: "message",
56+
role: "user",
57+
content: [{ type: "input_text", text: "warm ping" }],
58+
},
59+
],
60+
reasoning: { effort: "none", summary: "auto" },
61+
text: { verbosity: "low" },
62+
};
63+
}
64+
65+
/**
66+
* Send one warm request to open the account's usage window.
67+
*
68+
* Resolves `true` when the upstream accepted the request enough to start the
69+
* window. A 2xx clearly opens it; a 429 (rate-limited) means the window is
70+
* already open/active, which still satisfies the user intent ("the window is
71+
* ticking"). Any other non-2xx, or a network/timeout error, throws so the
72+
* caller records the account as failed.
73+
*/
74+
export async function warmAccountWindow(params: WarmRequestParams): Promise<boolean> {
75+
const doFetch = params.fetchImpl ?? fetch;
76+
const body = await buildWarmRequestBody();
77+
const headers = createCodexHeaders(undefined, params.accountId, params.accessToken, {
78+
model: WARM_MODEL,
79+
organizationId: params.organizationId,
80+
});
81+
82+
const controller = new AbortController();
83+
const timeout = setTimeout(
84+
() => controller.abort(),
85+
params.timeoutMs ?? WARM_TIMEOUT_MS,
86+
);
87+
try {
88+
const response = await doFetch(`${CODEX_BASE_URL}/codex/responses`, {
89+
method: "POST",
90+
headers,
91+
body: JSON.stringify(body),
92+
signal: controller.signal,
93+
});
94+
95+
// Drain/cancel the SSE stream immediately — we only needed to start the
96+
// window, not consume the response.
97+
try {
98+
await response.body?.cancel();
99+
} catch {
100+
// Ignore cancellation failures.
101+
}
102+
103+
if (response.ok) return true;
104+
105+
// 429 = window already open and currently rate-limited. The user's goal
106+
// (window is ticking) is satisfied, so treat it as a successful warm.
107+
if (response.status === 429) {
108+
log.debug("Warm ping hit 429 — window already active", {
109+
accountId: params.accountId,
110+
});
111+
return true;
112+
}
113+
114+
throw new Error(`Warm request failed: HTTP ${response.status}`);
115+
} finally {
116+
clearTimeout(timeout);
117+
}
118+
}

0 commit comments

Comments
 (0)