codex-multi-auth 2.8.6 → 2.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/lib/request/rate-limit-decision.d.ts +27 -0
- package/dist/lib/request/rate-limit-decision.d.ts.map +1 -1
- package/dist/lib/request/rate-limit-decision.js +117 -2
- package/dist/lib/runtime/account-status.d.ts +39 -10
- package/dist/lib/runtime/account-status.d.ts.map +1 -1
- package/dist/lib/runtime/account-status.js +47 -42
- package/dist/lib/runtime-rotation-proxy.js +27 -5
- package/package.json +1 -1
- package/scripts/codex.js +300 -82
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-multi-auth",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.7",
|
|
4
4
|
"description": "Install and operate codex-multi-auth for the official @openai/codex CLI with multi-account OAuth rotation, switching, health checks, and recovery tools.",
|
|
5
5
|
"interface": {
|
|
6
6
|
"composerIcon": "./assets/codex-multi-auth-icon.svg"
|
package/README.md
CHANGED
|
@@ -392,7 +392,7 @@ codex-multi-auth doctor --json
|
|
|
392
392
|
|
|
393
393
|
## Release Notes
|
|
394
394
|
|
|
395
|
-
- Current stable: [docs/releases/v2.8.
|
|
395
|
+
- Current stable: [docs/releases/v2.8.7.md](docs/releases/v2.8.7.md) — install via `npm i -g codex-multi-auth`
|
|
396
396
|
- Previous stable: [docs/releases/v2.8.3.md](docs/releases/v2.8.3.md)
|
|
397
397
|
- Previous stable: [docs/releases/v2.8.2.md](docs/releases/v2.8.2.md)
|
|
398
398
|
- Previous stable: [docs/releases/v2.7.1.md](docs/releases/v2.7.1.md)
|
|
@@ -40,6 +40,33 @@ export interface PinnedUnavailableContext {
|
|
|
40
40
|
pinSource?: "forced" | "manual" | null;
|
|
41
41
|
/** Epoch ms when the blocking record ends (rate limit or cooldown). */
|
|
42
42
|
resetAtMs?: number | null;
|
|
43
|
+
/**
|
|
44
|
+
* Which class of record supplies `resetAtMs`. That deadline is the max
|
|
45
|
+
* across every gating record, so a `rate-limited` skip reason can carry a
|
|
46
|
+
* deadline supplied by a breaker or cooldown that ends later, and the
|
|
47
|
+
* quota phrasing is only true when the rate limit itself is that bound.
|
|
48
|
+
*
|
|
49
|
+
* - `"rate-limit"` — the rate-limit records bound it; word it as a reset.
|
|
50
|
+
* - `"other"` — a cooldown or breaker ends later and bounds it instead.
|
|
51
|
+
* - `"unknown"` (the default) — the caller did not measure; trust the
|
|
52
|
+
* skip reason.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately a value rather than the presence of a second deadline
|
|
55
|
+
* field. The previous shape branched on `"rateLimitResetAtMs" in context`,
|
|
56
|
+
* so a caller that spread the key with an `undefined` value got the
|
|
57
|
+
* opposite wording from one that omitted it — a contract no type could
|
|
58
|
+
* express and no compiler could check.
|
|
59
|
+
*/
|
|
60
|
+
recoveryBound?: "rate-limit" | "other" | "unknown";
|
|
61
|
+
/**
|
|
62
|
+
* The pin's CURRENT runtime skip reason, re-read from account state at the
|
|
63
|
+
* moment the 503 is built. `accountSkipReasons` holds the SELECTION
|
|
64
|
+
* verdict, and on the dominant path the retry loop has already overwritten
|
|
65
|
+
* it with its own bookkeeping token ("already-attempted") — wording the
|
|
66
|
+
* sentence from that is what issue #675 reported. Omitted when the caller
|
|
67
|
+
* did not re-read runtime state.
|
|
68
|
+
*/
|
|
69
|
+
currentSkipReason?: string | null;
|
|
43
70
|
now?: number;
|
|
44
71
|
}
|
|
45
72
|
export declare function buildPinnedUnavailableErrorBody(pinnedIndex: number | null | undefined, accountSkipReasons: ReadonlyMap<number, string>, context?: PinnedUnavailableContext): PinnedUnavailableErrorBody;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rate-limit-decision.d.ts","sourceRoot":"","sources":["../../../lib/request/rate-limit-decision.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;
|
|
1
|
+
{"version":3,"file":"rate-limit-decision.d.ts","sourceRoot":"","sources":["../../../lib/request/rate-limit-decision.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;AAE5E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAe/C,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGlE;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,OAAO,CAAC,WAAW,EAAE;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC,GAAG,OAAO,CAWjG;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAapF;AAED,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAoBlF;AAYD,wBAAgB,0BAA0B,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAuB3E;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkBxE;AA0BD,wBAAgB,4BAA4B,CAC3C,OAAO,EAAE,OAAO,EAChB,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,MAAM,GACT,MAAM,CAcR;AAED,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CAE1E;AAED;;;;;;GAMG;AACH,MAAM,WAAW,0BAA0B;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,kCAAkC,CAAC;IACzC,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,mEAAmE;IACnE,UAAU,EAAE,QAAQ,GAAG,QAAQ,GAAG,IAAI,CAAC;IACvC,2EAA2E;IAC3E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,oBAAoB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC7C;AAQD,MAAM,WAAW,wBAAwB;IACxC;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,QAAQ,GAAG,QAAQ,GAAG,IAAI,CAAC;IACvC,uEAAuE;IACvE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;;;;;;;;;;;;;;;;OAgBG;IACH,aAAa,CAAC,EAAE,YAAY,GAAG,OAAO,GAAG,SAAS,CAAC;IACnD;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,GAAG,CAAC,EAAE,MAAM,CAAC;CACb;AAwID,wBAAgB,+BAA+B,CAC9C,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACtC,kBAAkB,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/C,OAAO,CAAC,EAAE,wBAAwB,GAChC,0BAA0B,CA8E5B"}
|
|
@@ -177,12 +177,107 @@ export function normalizeExhaustionStatus(reason) {
|
|
|
177
177
|
* throws RangeError (ECMAScript time-value limit, ±100,000,000 days).
|
|
178
178
|
*/
|
|
179
179
|
const MAX_ECMASCRIPT_TIME_VALUE = 8_640_000_000_000_000;
|
|
180
|
+
const DEADLINE_SENTENCES = {
|
|
181
|
+
"available-again": (resetAt) => `the account is expected to be available again at ${resetAt}`,
|
|
182
|
+
"next-attempt": (resetAt) => `the next attempt is allowed at ${resetAt}`,
|
|
183
|
+
"cooldown-ends": (resetAt) => `the cooldown ends at ${resetAt}`,
|
|
184
|
+
"rate-limit-reset": (resetAt) => `the rate limit resets at ${resetAt}`,
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* Every cooldown variant is bounded by the same `coolingDownUntil` field, so
|
|
188
|
+
* they all word their deadline the same way. Two of them used to say "the next
|
|
189
|
+
* attempt is allowed at" while the rest said "the cooldown ends at", which
|
|
190
|
+
* reads as two different mechanisms to an operator comparing two 503s.
|
|
191
|
+
*
|
|
192
|
+
* `satisfies` is what keeps this honest: a new CooldownReason becomes a
|
|
193
|
+
* compile error here rather than falling through to the verbatim arm below,
|
|
194
|
+
* which would print the raw `cooling-down:<reason>` token in the sentence —
|
|
195
|
+
* the internal-token leak issue #675 was filed about.
|
|
196
|
+
*/
|
|
197
|
+
const COOLDOWN_DESCRIPTIONS = {
|
|
198
|
+
"auth-failure": {
|
|
199
|
+
parenthetical: "cooling down after authentication failures",
|
|
200
|
+
deadlineNoun: "cooldown-ends",
|
|
201
|
+
},
|
|
202
|
+
"network-error": {
|
|
203
|
+
parenthetical: "cooling down after network errors",
|
|
204
|
+
deadlineNoun: "cooldown-ends",
|
|
205
|
+
},
|
|
206
|
+
"server-error": {
|
|
207
|
+
parenthetical: "cooling down after upstream server errors",
|
|
208
|
+
deadlineNoun: "cooldown-ends",
|
|
209
|
+
},
|
|
210
|
+
"rate-limit": {
|
|
211
|
+
parenthetical: "cooling down after a rate limit",
|
|
212
|
+
deadlineNoun: "cooldown-ends",
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
const BLOCKER_DESCRIPTIONS = new Map([
|
|
216
|
+
[
|
|
217
|
+
"rate-limited",
|
|
218
|
+
// Upgraded to "rate-limit-reset" below when the caller confirms the
|
|
219
|
+
// rate limit is what bounds the advertised deadline.
|
|
220
|
+
{ parenthetical: "rate-limited", deadlineNoun: "available-again" },
|
|
221
|
+
],
|
|
222
|
+
[
|
|
223
|
+
"circuit-open",
|
|
224
|
+
{
|
|
225
|
+
parenthetical: "paused after repeated upstream errors",
|
|
226
|
+
deadlineNoun: "next-attempt",
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
["cooling-down", { parenthetical: "cooling down", deadlineNoun: "cooldown-ends" }],
|
|
230
|
+
...Object.entries(COOLDOWN_DESCRIPTIONS).map(([reason, description]) => [
|
|
231
|
+
`cooling-down:${reason}`,
|
|
232
|
+
description,
|
|
233
|
+
]),
|
|
234
|
+
]);
|
|
235
|
+
/** Whether the sentence has real wording for this token, or must echo it raw. */
|
|
236
|
+
function isDescribedBlocker(skipReason) {
|
|
237
|
+
return BLOCKER_DESCRIPTIONS.has(skipReason);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The human sentence derives its parenthetical and its deadline noun from the
|
|
241
|
+
* blocker class. The deadline is max(rate-limit reset, cooldown end, breaker
|
|
242
|
+
* next-attempt), so calling it a "limit" is only true for the rate-limit
|
|
243
|
+
* class: during a provider outage a tripped breaker or a server-error
|
|
244
|
+
* cooldown printed "the recorded limit resets at …", and operators read a
|
|
245
|
+
* backend incident as a blown subscription quota. Only the message changes —
|
|
246
|
+
* the machine-readable `reason` keeps the raw skip token.
|
|
247
|
+
*/
|
|
248
|
+
function describePinnedBlocker(skipReason, rateLimitBoundsRecovery) {
|
|
249
|
+
if (skipReason === null) {
|
|
250
|
+
return {
|
|
251
|
+
parenthetical: null,
|
|
252
|
+
deadline: DEADLINE_SENTENCES["available-again"],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const described = BLOCKER_DESCRIPTIONS.get(skipReason);
|
|
256
|
+
if (described === undefined) {
|
|
257
|
+
// Permanent blockers never reach the deadline clause (the call site
|
|
258
|
+
// suppresses their reset time), and future or internal tokens stay
|
|
259
|
+
// legible verbatim.
|
|
260
|
+
return {
|
|
261
|
+
parenthetical: skipReason,
|
|
262
|
+
deadline: DEADLINE_SENTENCES["available-again"],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// A breaker or cooldown can outlive the rate limit; the deadline is the max
|
|
266
|
+
// of every gating record, so it is only worded as the limit's own reset
|
|
267
|
+
// when the rate limit actually supplies it.
|
|
268
|
+
const deadlineNoun = skipReason === "rate-limited" && rateLimitBoundsRecovery
|
|
269
|
+
? "rate-limit-reset"
|
|
270
|
+
: described.deadlineNoun;
|
|
271
|
+
return {
|
|
272
|
+
parenthetical: described.parenthetical,
|
|
273
|
+
deadline: DEADLINE_SENTENCES[deadlineNoun],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
180
276
|
export function buildPinnedUnavailableErrorBody(pinnedIndex, accountSkipReasons, context) {
|
|
181
277
|
const normalizedPinnedIndex = typeof pinnedIndex === "number" ? pinnedIndex : null;
|
|
182
278
|
const skipReason = normalizedPinnedIndex !== null
|
|
183
279
|
? accountSkipReasons.get(normalizedPinnedIndex) ?? null
|
|
184
280
|
: null;
|
|
185
|
-
const reasonSuffix = skipReason ? ` (${skipReason})` : "";
|
|
186
281
|
// On the desync path the pin index is unknown (null); claiming "account 1"
|
|
187
282
|
// there would contradict the machine-readable pinnedAccountIndex: null.
|
|
188
283
|
const accountPhrase = normalizedPinnedIndex === null
|
|
@@ -206,7 +301,27 @@ export function buildPinnedUnavailableErrorBody(pinnedIndex, accountSkipReasons,
|
|
|
206
301
|
const now = context?.now ?? Date.now();
|
|
207
302
|
const retryAfterMs = resetAtMs !== null ? Math.max(0, resetAtMs - now) : null;
|
|
208
303
|
const resetAt = resetAtMs !== null ? new Date(resetAtMs).toISOString() : null;
|
|
209
|
-
|
|
304
|
+
// "unknown" (the default) means the caller did not measure which record
|
|
305
|
+
// bounds the deadline — older callers and unit seams — so trust the skip
|
|
306
|
+
// reason. Only a measured "other" demotes to the neutral phrasing, which
|
|
307
|
+
// stays true for a live rate limit either way.
|
|
308
|
+
const rateLimitBoundsRecovery = (context?.recoveryBound ?? "unknown") !== "other";
|
|
309
|
+
// The machine-readable `reason` below stays the recorded SELECTION verdict;
|
|
310
|
+
// only the human sentence follows the blocker actually gating the pin. A
|
|
311
|
+
// recorded verdict that names a real blocker class wins, because the
|
|
312
|
+
// selection-only classes ("missing", "policy-blocked") have no
|
|
313
|
+
// runtime-state equivalent and would be lost. Otherwise the re-read runtime
|
|
314
|
+
// state is the truth and the recorded token is the retry loop's own
|
|
315
|
+
// bookkeeping — the dominant path in issue #675, where a 503/429 on the pin
|
|
316
|
+
// re-enters selection and records "already-attempted" over the real class.
|
|
317
|
+
const describedSkipReason = skipReason !== null && isDescribedBlocker(skipReason)
|
|
318
|
+
? skipReason
|
|
319
|
+
: context?.currentSkipReason ?? skipReason;
|
|
320
|
+
const blocker = describePinnedBlocker(describedSkipReason, rateLimitBoundsRecovery);
|
|
321
|
+
const reasonSuffix = blocker.parenthetical
|
|
322
|
+
? ` (${blocker.parenthetical})`
|
|
323
|
+
: "";
|
|
324
|
+
const waitSuffix = resetAt !== null ? `; ${blocker.deadline(resetAt)}` : "";
|
|
210
325
|
// A forced pin belongs to the launching process, not to the persisted pin
|
|
211
326
|
// state, so `unpin` would clear nothing — say what actually helps.
|
|
212
327
|
const remedy = pinSource === "forced"
|
|
@@ -7,22 +7,51 @@ export declare function resolveActiveIndex(storage: {
|
|
|
7
7
|
export declare function getRateLimitResetTimeForFamily(account: {
|
|
8
8
|
rateLimitResetTimes?: Record<string, number | undefined>;
|
|
9
9
|
}, now: number, family: ModelFamily): number | null;
|
|
10
|
+
export interface AccountRecoveryBounds {
|
|
11
|
+
/**
|
|
12
|
+
* The moment the account becomes usable again: `rateLimitAtMs` widened by
|
|
13
|
+
* any active cooldown, because the account stays skipped while ANY gating
|
|
14
|
+
* record is active. Null when nothing bounds recovery.
|
|
15
|
+
*/
|
|
16
|
+
recoveryAtMs: number | null;
|
|
17
|
+
/**
|
|
18
|
+
* The rate-limit records' own contribution to `recoveryAtMs`, cooldowns
|
|
19
|
+
* excluded. The pinned-503 uses it to decide whether the deadline it
|
|
20
|
+
* advertises is in fact the rate limit's own reset — with a breaker or
|
|
21
|
+
* cooldown ending later, the full recovery bound outlives the rate limit
|
|
22
|
+
* and must not be worded as its reset. Null when no rate-limit record
|
|
23
|
+
* gates the request.
|
|
24
|
+
*/
|
|
25
|
+
rateLimitAtMs: number | null;
|
|
26
|
+
}
|
|
10
27
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
28
|
+
* Both recovery bounds for a `family`/`model` request, from ONE pass over
|
|
29
|
+
* `rateLimitResetTimes` against a single `now`.
|
|
30
|
+
*
|
|
31
|
+
* Two deliberate differences from getRateLimitResetTimeForFamily, whose
|
|
32
|
+
* earliest-reset answer feeds wait displays: the latest bound wins, because
|
|
33
|
+
* the earliest reset would send clients back into a 503 — and only the keys
|
|
34
|
+
* selection consults (`family`, plus `family:<model>` when a model is known;
|
|
35
|
+
* see isRateLimitedForFamily) may contribute, because another model's record
|
|
36
|
+
* does not block this request and would overstate its recovery.
|
|
37
|
+
*
|
|
38
|
+
* A caller that needs both bounds must take them from one call. Measuring
|
|
39
|
+
* them separately lets a record expire between the two walks, which reports
|
|
40
|
+
* a rate-limited account as if a cooldown bounded its recovery.
|
|
21
41
|
*/
|
|
42
|
+
export declare function getAccountRecoveryBoundsForFamily(account: {
|
|
43
|
+
rateLimitResetTimes?: Record<string, number | undefined>;
|
|
44
|
+
coolingDownUntil?: number;
|
|
45
|
+
}, now: number, family: ModelFamily, model?: string | null): AccountRecoveryBounds;
|
|
46
|
+
/** The `recoveryAtMs` bound alone; see getAccountRecoveryBoundsForFamily. */
|
|
22
47
|
export declare function getAccountRecoveryTimeForFamily(account: {
|
|
23
48
|
rateLimitResetTimes?: Record<string, number | undefined>;
|
|
24
49
|
coolingDownUntil?: number;
|
|
25
50
|
}, now: number, family: ModelFamily, model?: string | null): number | null;
|
|
51
|
+
/** The `rateLimitAtMs` bound alone; see getAccountRecoveryBoundsForFamily. */
|
|
52
|
+
export declare function getRateLimitRecoveryTimeForFamily(account: {
|
|
53
|
+
rateLimitResetTimes?: Record<string, number | undefined>;
|
|
54
|
+
}, now: number, family: ModelFamily, model?: string | null): number | null;
|
|
26
55
|
export declare function formatRateLimitEntry(account: {
|
|
27
56
|
rateLimitResetTimes?: Record<string, number | undefined>;
|
|
28
57
|
}, now: number, formatWaitTime: (ms: number) => string, family?: ModelFamily): string | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"account-status.d.ts","sourceRoot":"","sources":["../../../lib/runtime/account-status.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,wBAAgB,kBAAkB,CACjC,OAAO,EAAE;IACR,WAAW,EAAE,MAAM,CAAC;IACpB,mBAAmB,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC;IAC3D,QAAQ,EAAE,OAAO,EAAE,CAAC;CACpB,EACD,MAAM,GAAE,WAAqB,GAC3B,MAAM,CAOR;AAED,wBAAgB,8BAA8B,CAC7C,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,GACjB,MAAM,GAAG,IAAI,CAgBf;AAED
|
|
1
|
+
{"version":3,"file":"account-status.d.ts","sourceRoot":"","sources":["../../../lib/runtime/account-status.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,wBAAgB,kBAAkB,CACjC,OAAO,EAAE;IACR,WAAW,EAAE,MAAM,CAAC;IACpB,mBAAmB,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC;IAC3D,QAAQ,EAAE,OAAO,EAAE,CAAC;CACpB,EACD,MAAM,GAAE,WAAqB,GAC3B,MAAM,CAOR;AAED,wBAAgB,8BAA8B,CAC7C,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,GACjB,MAAM,GAAG,IAAI,CAgBf;AAeD,MAAM,WAAW,qBAAqB;IACrC;;;;OAIG;IACH,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B;;;;;;;OAOG;IACH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iCAAiC,CAChD,OAAO,EAAE;IACR,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B,EACD,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,qBAAqB,CAevB;AAED,6EAA6E;AAC7E,wBAAgB,+BAA+B,CAC9C,OAAO,EAAE;IACR,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B,EACD,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,MAAM,GAAG,IAAI,CAGf;AAED,8EAA8E;AAC9E,wBAAgB,iCAAiC,CAChD,OAAO,EAAE;IACR,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;CACzD,EACD,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,MAAM,GAAG,IAAI,CAGf;AAED,wBAAgB,oBAAoB,CACnC,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,EACtC,MAAM,GAAE,WAAqB,GAC3B,MAAM,GAAG,IAAI,CAMf;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,6BAA6B,CAC5C,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,MAAM,GACX,MAAM,GAAG,IAAI,CAGf"}
|
|
@@ -26,36 +26,54 @@ export function getRateLimitResetTimeForFamily(account, now, family) {
|
|
|
26
26
|
}
|
|
27
27
|
return minReset;
|
|
28
28
|
}
|
|
29
|
+
/** The value when it is still a live bound at `now`, otherwise null. */
|
|
30
|
+
function activeBound(value, now) {
|
|
31
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
32
|
+
return null;
|
|
33
|
+
return value > now ? value : null;
|
|
34
|
+
}
|
|
35
|
+
/** The later of two bounds, treating null as "does not bound". */
|
|
36
|
+
function laterBound(a, b) {
|
|
37
|
+
if (a === null)
|
|
38
|
+
return b;
|
|
39
|
+
if (b === null)
|
|
40
|
+
return a;
|
|
41
|
+
return a > b ? a : b;
|
|
42
|
+
}
|
|
29
43
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
44
|
+
* Both recovery bounds for a `family`/`model` request, from ONE pass over
|
|
45
|
+
* `rateLimitResetTimes` against a single `now`.
|
|
46
|
+
*
|
|
47
|
+
* Two deliberate differences from getRateLimitResetTimeForFamily, whose
|
|
48
|
+
* earliest-reset answer feeds wait displays: the latest bound wins, because
|
|
49
|
+
* the earliest reset would send clients back into a 503 — and only the keys
|
|
50
|
+
* selection consults (`family`, plus `family:<model>` when a model is known;
|
|
51
|
+
* see isRateLimitedForFamily) may contribute, because another model's record
|
|
52
|
+
* does not block this request and would overstate its recovery.
|
|
53
|
+
*
|
|
54
|
+
* A caller that needs both bounds must take them from one call. Measuring
|
|
55
|
+
* them separately lets a record expire between the two walks, which reports
|
|
56
|
+
* a rate-limited account as if a cooldown bounded its recovery.
|
|
40
57
|
*/
|
|
41
|
-
export function
|
|
42
|
-
let latest = null;
|
|
43
|
-
const consider = (value) => {
|
|
44
|
-
if (typeof value !== "number" || !Number.isFinite(value))
|
|
45
|
-
return;
|
|
46
|
-
if (value <= now)
|
|
47
|
-
return;
|
|
48
|
-
if (latest === null || value > latest)
|
|
49
|
-
latest = value;
|
|
50
|
-
};
|
|
58
|
+
export function getAccountRecoveryBoundsForFamily(account, now, family, model) {
|
|
51
59
|
const times = account.rateLimitResetTimes;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
const rateLimitAtMs = times
|
|
61
|
+
? laterBound(activeBound(times[getQuotaKey(family)], now), model ? activeBound(times[getQuotaKey(family, model)], now) : null)
|
|
62
|
+
: null;
|
|
63
|
+
return {
|
|
64
|
+
rateLimitAtMs,
|
|
65
|
+
recoveryAtMs: laterBound(rateLimitAtMs, activeBound(account.coolingDownUntil, now)),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** The `recoveryAtMs` bound alone; see getAccountRecoveryBoundsForFamily. */
|
|
69
|
+
export function getAccountRecoveryTimeForFamily(account, now, family, model) {
|
|
70
|
+
return getAccountRecoveryBoundsForFamily(account, now, family, model)
|
|
71
|
+
.recoveryAtMs;
|
|
72
|
+
}
|
|
73
|
+
/** The `rateLimitAtMs` bound alone; see getAccountRecoveryBoundsForFamily. */
|
|
74
|
+
export function getRateLimitRecoveryTimeForFamily(account, now, family, model) {
|
|
75
|
+
return getAccountRecoveryBoundsForFamily(account, now, family, model)
|
|
76
|
+
.rateLimitAtMs;
|
|
59
77
|
}
|
|
60
78
|
export function formatRateLimitEntry(account, now, formatWaitTime, family = "codex") {
|
|
61
79
|
const resetAt = getRateLimitResetTimeForFamily(account, now, family);
|
|
@@ -86,19 +104,6 @@ export function formatRateLimitEntry(account, now, formatWaitTime, family = "cod
|
|
|
86
104
|
* model key applies and should keep the family-wide union above.
|
|
87
105
|
*/
|
|
88
106
|
export function getRateLimitResetTimeForModel(account, now, family, model) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return null;
|
|
92
|
-
let latest = null;
|
|
93
|
-
const consider = (value) => {
|
|
94
|
-
if (typeof value !== "number" || !Number.isFinite(value))
|
|
95
|
-
return;
|
|
96
|
-
if (value <= now)
|
|
97
|
-
return;
|
|
98
|
-
if (latest === null || value > latest)
|
|
99
|
-
latest = value;
|
|
100
|
-
};
|
|
101
|
-
consider(times[getQuotaKey(family)]);
|
|
102
|
-
consider(times[getQuotaKey(family, model)]);
|
|
103
|
-
return latest;
|
|
107
|
+
return getAccountRecoveryBoundsForFamily(account, now, family, model)
|
|
108
|
+
.rateLimitAtMs;
|
|
104
109
|
}
|
|
@@ -15,7 +15,7 @@ import { CodexValidationError } from "./errors.js";
|
|
|
15
15
|
import { normalizeEmailKey } from "./storage/identity.js";
|
|
16
16
|
import { buildPinnedUnavailableErrorBody, buildTokenInvalidationBody, extractErrorCodeFromBody, isTokenInvalidationError, normalizeExhaustionStatus, parseRetryAfterBodyMs, parseRetryAfterHeaderMs, } from "./request/rate-limit-decision.js";
|
|
17
17
|
import { forwardStreamingResponse, HOP_BY_HOP_HEADERS, readErrorBody, responseHeadersForClient, withTimeout, } from "./request/stream-failover-runtime.js";
|
|
18
|
-
import {
|
|
18
|
+
import { getAccountRecoveryBoundsForFamily } from "./runtime/account-status.js";
|
|
19
19
|
import { chooseAccount } from "./runtime/rotation-account-selection.js";
|
|
20
20
|
import { createRotationProxyState, recoverStaleRuntimeState, } from "./runtime/rotation-proxy-state.js";
|
|
21
21
|
import { readStorageMetaFromDisk } from "./runtime/rotation-storage-meta.js";
|
|
@@ -1219,23 +1219,45 @@ async function handleRequestInner(state, req, res, traceId) {
|
|
|
1219
1219
|
// recovery — and with several overlapping records the account stays
|
|
1220
1220
|
// skipped until the LAST one expires, so the latest bound is the one
|
|
1221
1221
|
// worth advertising.
|
|
1222
|
-
|
|
1222
|
+
//
|
|
1223
|
+
// One clock for the whole body. The recovery bound and the
|
|
1224
|
+
// rate-limit bound are compared against each other below, so
|
|
1225
|
+
// reading state.now() per lookup would let a record expire between
|
|
1226
|
+
// them and report a rate-limited pin as bounded by something else.
|
|
1227
|
+
const evaluatedAtMs = state.now();
|
|
1228
|
+
// Both bounds come from a single pass over the account's records.
|
|
1229
|
+
const pinnedRecoveryBounds = pinnedAccount === null
|
|
1223
1230
|
? null
|
|
1224
|
-
:
|
|
1231
|
+
: getAccountRecoveryBoundsForFamily(pinnedAccount, evaluatedAtMs, context.family, context.model);
|
|
1232
|
+
const pinnedStateRecoveryAtMs = pinnedRecoveryBounds?.recoveryAtMs ?? null;
|
|
1225
1233
|
// An open circuit outlives the short failure cooldowns that tripped
|
|
1226
1234
|
// it; its deadline lives in the breaker, not the account record.
|
|
1227
1235
|
const pinnedCircuitRecoveryAtMs = pinnedAccount === null
|
|
1228
1236
|
? null
|
|
1229
|
-
: accountManager.getCircuitRecoveryTime(pinnedAccount,
|
|
1237
|
+
: accountManager.getCircuitRecoveryTime(pinnedAccount, evaluatedAtMs);
|
|
1230
1238
|
const pinnedResetAtMs = pinnedBlockedPermanently ||
|
|
1231
1239
|
(pinnedStateRecoveryAtMs === null && pinnedCircuitRecoveryAtMs === null)
|
|
1232
1240
|
? null
|
|
1233
1241
|
: Math.max(pinnedStateRecoveryAtMs ?? 0, pinnedCircuitRecoveryAtMs ?? 0);
|
|
1242
|
+
// The message only words the recovery deadline as a rate-limit reset
|
|
1243
|
+
// when the rate-limit records are in fact what supplies it — a
|
|
1244
|
+
// breaker or cooldown can end later and then bounds it instead.
|
|
1245
|
+
const pinnedRateLimitResetAtMs = pinnedRecoveryBounds?.rateLimitAtMs ?? null;
|
|
1246
|
+
const recoveryBound = pinnedResetAtMs !== null &&
|
|
1247
|
+
pinnedRateLimitResetAtMs !== null &&
|
|
1248
|
+
pinnedRateLimitResetAtMs >= pinnedResetAtMs
|
|
1249
|
+
? "rate-limit"
|
|
1250
|
+
: "other";
|
|
1234
1251
|
const errorBody = buildPinnedUnavailableErrorBody(pinnedIndex, accountSkipReasons, {
|
|
1235
1252
|
// typeof check so a forced index of 0 still reads as forced.
|
|
1236
1253
|
pinSource: typeof state.forcedAccountIndex === "number" ? "forced" : "manual",
|
|
1237
1254
|
resetAtMs: pinnedResetAtMs,
|
|
1238
|
-
|
|
1255
|
+
recoveryBound,
|
|
1256
|
+
// The recorded verdict above is the retry loop's
|
|
1257
|
+
// "already-attempted" on the dominant path; the re-read runtime
|
|
1258
|
+
// state is what the operator-facing sentence should describe.
|
|
1259
|
+
currentSkipReason: pinnedCurrentSkipReason,
|
|
1260
|
+
now: evaluatedAtMs,
|
|
1239
1261
|
});
|
|
1240
1262
|
if (errorBody.reason === null) {
|
|
1241
1263
|
state.status.lastError = `pinned-503 missing skip reason (pinnedIndex=${pinnedIndex})`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-multi-auth",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.7",
|
|
4
4
|
"description": "Codex CLI multi-account OAuth manager with account switching, health checks, runtime rotation, diagnostics, and recovery tools for @openai/codex",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
package/scripts/codex.js
CHANGED
|
@@ -421,6 +421,14 @@ function extractForcedAccountFlag(args) {
|
|
|
421
421
|
let sawFlag = false;
|
|
422
422
|
for (let index = 0; index < args.length; index += 1) {
|
|
423
423
|
const arg = args[index];
|
|
424
|
+
// `--` ends option parsing: everything after it is Codex's root
|
|
425
|
+
// `[PROMPT]` text. A `--account` spelled in there is the user's prompt,
|
|
426
|
+
// not a launcher flag — stripping it would both mangle the prompt and
|
|
427
|
+
// silently pin the run to an account nobody selected.
|
|
428
|
+
if (arg === "--") {
|
|
429
|
+
strippedArgs.push(...args.slice(index));
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
424
432
|
if (arg === "--account") {
|
|
425
433
|
sawFlag = true;
|
|
426
434
|
const next = args[index + 1];
|
|
@@ -1365,6 +1373,9 @@ function replaceRequestedModel(args, nextModel) {
|
|
|
1365
1373
|
const nextArgs = [...args];
|
|
1366
1374
|
for (let i = 0; i < nextArgs.length; i += 1) {
|
|
1367
1375
|
const arg = nextArgs[i];
|
|
1376
|
+
// Past `--` the tokens are the user's prompt. Rewriting one in place
|
|
1377
|
+
// would edit the prompt text itself before re-forwarding it.
|
|
1378
|
+
if (arg === "--") break;
|
|
1368
1379
|
if ((arg === "--model" || arg === "-m") && typeof nextArgs[i + 1] === "string") {
|
|
1369
1380
|
nextArgs[i + 1] = nextModel;
|
|
1370
1381
|
return nextArgs;
|
|
@@ -1859,6 +1870,11 @@ async function forwardToRealCodex(codexBin, rawArgs, baseEnv = process.env) {
|
|
|
1859
1870
|
function hasCliAuthCredentialsStoreOverride(args) {
|
|
1860
1871
|
for (let i = 0; i < args.length; i += 1) {
|
|
1861
1872
|
const arg = args[i];
|
|
1873
|
+
// Everything after `--` is the root `[PROMPT]` positional, not options:
|
|
1874
|
+
// a prompt whose text spells `--config=cli_auth_credentials_store=...` is
|
|
1875
|
+
// not a caller override, and treating it as one would silently drop the
|
|
1876
|
+
// file auth store this wrapper depends on.
|
|
1877
|
+
if (arg === "--") break;
|
|
1862
1878
|
if (arg === "-c" || arg === "--config") {
|
|
1863
1879
|
const next = args[i + 1];
|
|
1864
1880
|
if (!next || !next.includes("=")) continue;
|
|
@@ -2357,6 +2373,10 @@ function coerceReasoningEffortForModel(model, effort) {
|
|
|
2357
2373
|
function extractRequestedModel(args) {
|
|
2358
2374
|
for (let i = 0; i < args.length; i += 1) {
|
|
2359
2375
|
const arg = args[i];
|
|
2376
|
+
// Past `--` every token is root `[PROMPT]` text; a `-m` in there names
|
|
2377
|
+
// no model, and reading one would coerce reasoning config for a model
|
|
2378
|
+
// the user never requested.
|
|
2379
|
+
if (arg === "--") break;
|
|
2360
2380
|
if (arg === "--model" || arg === "-m") {
|
|
2361
2381
|
const next = args[i + 1];
|
|
2362
2382
|
if (typeof next === "string" && next.trim().length > 0) {
|
|
@@ -2427,6 +2447,8 @@ function rewriteReasoningConfigArgs(rawArgs) {
|
|
|
2427
2447
|
const nextArgs = [...rawArgs];
|
|
2428
2448
|
for (let i = 0; i < nextArgs.length; i += 1) {
|
|
2429
2449
|
const arg = nextArgs[i];
|
|
2450
|
+
// Past `--` a `-c key=value` pair is prompt text, not a config override.
|
|
2451
|
+
if (arg === "--") break;
|
|
2430
2452
|
if ((arg === "-c" || arg === "--config") && typeof nextArgs[i + 1] === "string") {
|
|
2431
2453
|
nextArgs[i + 1] = rewriteReasoningConfigAssignment(
|
|
2432
2454
|
nextArgs[i + 1],
|
|
@@ -4903,13 +4925,20 @@ async function createRuntimeRotationAppHelperContext(
|
|
|
4903
4925
|
}
|
|
4904
4926
|
};
|
|
4905
4927
|
|
|
4928
|
+
const injectedArgs = [
|
|
4929
|
+
...helperArgs,
|
|
4930
|
+
"-c",
|
|
4931
|
+
`model_provider=${configTomlModule.tomlStringLiteral(RUNTIME_ROTATION_PROXY_PROVIDER_ID)}`,
|
|
4932
|
+
];
|
|
4906
4933
|
return {
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4934
|
+
// Root TUI argv can end in the optional `[PROMPT]` positional, and `--`
|
|
4935
|
+
// binds everything after it to that positional — overrides appended
|
|
4936
|
+
// there would become prompt text, so the interactive branch asks for
|
|
4937
|
+
// them to be injected ahead of the prompt instead.
|
|
4938
|
+
args:
|
|
4939
|
+
options.injectArgsBeforeRootPrompt === true
|
|
4940
|
+
? insertArgsBeforeRootPrompt(baseContext.args, injectedArgs)
|
|
4941
|
+
: insertArgsBeforeForwardedSeparator(baseContext.args, injectedArgs),
|
|
4913
4942
|
env: {
|
|
4914
4943
|
...baseContext.env,
|
|
4915
4944
|
...helperEnv,
|
|
@@ -4991,13 +5020,19 @@ async function createRuntimeRotationProxyContextIfEnabled(
|
|
|
4991
5020
|
if (isCodexAppCommand(rawArgs)) {
|
|
4992
5021
|
return startAppHelperContext();
|
|
4993
5022
|
}
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
) {
|
|
5023
|
+
// Classify once: each predicate re-walks the whole argv, and the root-TUI
|
|
5024
|
+
// answer is needed twice below.
|
|
5025
|
+
const isRootTuiLaunch = isCodexInteractiveTuiCommand(rawArgs);
|
|
5026
|
+
if (isRootTuiLaunch || isCodexInteractiveResumeCommand(rawArgs)) {
|
|
4998
5027
|
return startAppHelperContext({
|
|
4999
5028
|
detachOnExit: true,
|
|
5000
5029
|
useCanonicalHome: true,
|
|
5030
|
+
// Root TUI launches may carry the optional `[PROMPT]` positional
|
|
5031
|
+
// (possibly forced by `--`); the provider overrides must stay on the
|
|
5032
|
+
// option side of it. `resume`/`fork` keep the appended ordering their
|
|
5033
|
+
// argv has always forwarded with — their tokens are subcommand args,
|
|
5034
|
+
// not the root prompt.
|
|
5035
|
+
injectArgsBeforeRootPrompt: isRootTuiLaunch,
|
|
5001
5036
|
});
|
|
5002
5037
|
}
|
|
5003
5038
|
|
|
@@ -5088,90 +5123,257 @@ function isPureHelpOrVersionArgs(rawArgs) {
|
|
|
5088
5123
|
);
|
|
5089
5124
|
}
|
|
5090
5125
|
|
|
5126
|
+
// Hoisted: every argv walk calls consumesNextArg once per token, and a Set
|
|
5127
|
+
// rebuilt per call allocated this list on every one of them.
|
|
5128
|
+
const OPTIONS_CONSUMING_NEXT_ARG = new Set([
|
|
5129
|
+
"-c",
|
|
5130
|
+
"--config",
|
|
5131
|
+
"--enable",
|
|
5132
|
+
"--disable",
|
|
5133
|
+
"--listen",
|
|
5134
|
+
"--remote",
|
|
5135
|
+
"--remote-auth-token-env",
|
|
5136
|
+
"--ws-auth",
|
|
5137
|
+
"--ws-token-file",
|
|
5138
|
+
"--ws-token-sha256",
|
|
5139
|
+
"--ws-shared-secret-file",
|
|
5140
|
+
"--ws-issuer",
|
|
5141
|
+
"--ws-audience",
|
|
5142
|
+
"--ws-max-clock-skew-seconds",
|
|
5143
|
+
"--download-url",
|
|
5144
|
+
"-i",
|
|
5145
|
+
"--image",
|
|
5146
|
+
"-m",
|
|
5147
|
+
"--model",
|
|
5148
|
+
"--local-provider",
|
|
5149
|
+
"-p",
|
|
5150
|
+
"--profile",
|
|
5151
|
+
"-s",
|
|
5152
|
+
"--sandbox",
|
|
5153
|
+
"-a",
|
|
5154
|
+
"--ask-for-approval",
|
|
5155
|
+
"-C",
|
|
5156
|
+
"--cd",
|
|
5157
|
+
"--add-dir",
|
|
5158
|
+
"--output-schema",
|
|
5159
|
+
"--color",
|
|
5160
|
+
"-o",
|
|
5161
|
+
"--output-last-message",
|
|
5162
|
+
]);
|
|
5163
|
+
|
|
5091
5164
|
function consumesNextArg(arg) {
|
|
5092
|
-
return
|
|
5093
|
-
"-c",
|
|
5094
|
-
"--config",
|
|
5095
|
-
"--enable",
|
|
5096
|
-
"--disable",
|
|
5097
|
-
"--listen",
|
|
5098
|
-
"--remote",
|
|
5099
|
-
"--remote-auth-token-env",
|
|
5100
|
-
"--ws-auth",
|
|
5101
|
-
"--ws-token-file",
|
|
5102
|
-
"--ws-token-sha256",
|
|
5103
|
-
"--ws-shared-secret-file",
|
|
5104
|
-
"--ws-issuer",
|
|
5105
|
-
"--ws-audience",
|
|
5106
|
-
"--ws-max-clock-skew-seconds",
|
|
5107
|
-
"--download-url",
|
|
5108
|
-
"-i",
|
|
5109
|
-
"--image",
|
|
5110
|
-
"-m",
|
|
5111
|
-
"--model",
|
|
5112
|
-
"--local-provider",
|
|
5113
|
-
"-p",
|
|
5114
|
-
"--profile",
|
|
5115
|
-
"-s",
|
|
5116
|
-
"--sandbox",
|
|
5117
|
-
"-a",
|
|
5118
|
-
"--ask-for-approval",
|
|
5119
|
-
"-C",
|
|
5120
|
-
"--cd",
|
|
5121
|
-
"--add-dir",
|
|
5122
|
-
"--output-schema",
|
|
5123
|
-
"--color",
|
|
5124
|
-
"-o",
|
|
5125
|
-
"--output-last-message",
|
|
5126
|
-
]).has(arg);
|
|
5165
|
+
return OPTIONS_CONSUMING_NEXT_ARG.has(arg);
|
|
5127
5166
|
}
|
|
5128
5167
|
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5168
|
+
// Codex's root grammar is `codex [OPTIONS] [PROMPT]` / `codex [OPTIONS]
|
|
5169
|
+
// <COMMAND> [ARGS]`, so the first positional is only a command when it names a
|
|
5170
|
+
// real root subcommand — otherwise it is the optional initial prompt of an
|
|
5171
|
+
// interactive TUI launch. The allowlist mirrors `codex --help`; treating an
|
|
5172
|
+
// unknown positional as a prompt matches native Codex, where an unrecognized
|
|
5173
|
+
// token also falls through to the prompt positional rather than erroring as a
|
|
5174
|
+
// bad subcommand. Routing predicates all resolve through this map so aliases
|
|
5175
|
+
// classify like their canonical command.
|
|
5176
|
+
//
|
|
5177
|
+
// A real root subcommand missing from this set is misrouted, not merely
|
|
5178
|
+
// misnamed: it classifies as an interactive prompt and takes the app-helper
|
|
5179
|
+
// branch with `detachOnExit: true`, so a short-lived command leaves a detached
|
|
5180
|
+
// helper and its loopback proxy idling until the rotation idle timeout. Keep
|
|
5181
|
+
// the set in sync with the upstream grammar — clap publishes the authoritative
|
|
5182
|
+
// root list in its own completions, so
|
|
5183
|
+
// codex completion bash | grep -oE 'opts="[^"]*"' | grep ' exec '
|
|
5184
|
+
// prints every root command (and alias) for the installed codex-cli.
|
|
5185
|
+
|
|
5186
|
+
const CODEX_ROOT_COMMAND_ALIASES = new Map([
|
|
5187
|
+
["e", "exec"],
|
|
5188
|
+
["a", "apply"],
|
|
5189
|
+
]);
|
|
5190
|
+
const CODEX_ROOT_COMMANDS = new Set([
|
|
5191
|
+
"exec",
|
|
5192
|
+
"review",
|
|
5193
|
+
"login",
|
|
5194
|
+
"logout",
|
|
5195
|
+
"mcp",
|
|
5196
|
+
"plugin",
|
|
5197
|
+
"mcp-server",
|
|
5198
|
+
"app-server",
|
|
5199
|
+
"remote-control",
|
|
5200
|
+
"app",
|
|
5201
|
+
"completion",
|
|
5202
|
+
"update",
|
|
5203
|
+
"doctor",
|
|
5204
|
+
"sandbox",
|
|
5205
|
+
"debug",
|
|
5206
|
+
"apply",
|
|
5207
|
+
"resume",
|
|
5208
|
+
"archive",
|
|
5209
|
+
"delete",
|
|
5210
|
+
"migrate-rollouts",
|
|
5211
|
+
"unarchive",
|
|
5212
|
+
"fork",
|
|
5213
|
+
"cloud",
|
|
5214
|
+
"exec-server",
|
|
5215
|
+
"features",
|
|
5216
|
+
"help",
|
|
5217
|
+
"auth",
|
|
5218
|
+
// Hidden root subcommands: absent from `codex --help`, but real — each is
|
|
5219
|
+
// documented by `codex help <name>`.
|
|
5220
|
+
"responses-api-proxy",
|
|
5221
|
+
"cloud-tasks",
|
|
5222
|
+
"execpolicy",
|
|
5223
|
+
// Internal stdio/UDS relay. Hidden like the three above, and short-lived:
|
|
5224
|
+
// omitting it classified `codex stdio-to-uds <socket>` as a root prompt,
|
|
5225
|
+
// which took the detached app-helper branch and leaked a helper per call.
|
|
5226
|
+
"stdio-to-uds",
|
|
5227
|
+
]);
|
|
5228
|
+
|
|
5229
|
+
function resolveCodexRootCommand(token) {
|
|
5230
|
+
const command = CODEX_ROOT_COMMAND_ALIASES.get(token) ?? token;
|
|
5231
|
+
return CODEX_ROOT_COMMANDS.has(command) ? command : null;
|
|
5232
|
+
}
|
|
5233
|
+
|
|
5234
|
+
// `-i/--image` is greedy (`<FILE>...`): native Codex consumes every following
|
|
5235
|
+
// free token up to the next option-like token, so a prompt can only follow the
|
|
5236
|
+
// image list after another option or a `--`. Every argv walker below skips the
|
|
5237
|
+
// same span, or the wrapper would disagree with native Codex about where the
|
|
5238
|
+
// positional side starts.
|
|
5239
|
+
function consumesRemainingFreeArgs(arg) {
|
|
5240
|
+
return arg === "-i" || arg === "--image";
|
|
5241
|
+
}
|
|
5242
|
+
|
|
5243
|
+
function skipOptionValueSpan(args, i) {
|
|
5244
|
+
const arg = args[i];
|
|
5245
|
+
if (consumesRemainingFreeArgs(arg)) {
|
|
5246
|
+
let end = i;
|
|
5247
|
+
while (
|
|
5248
|
+
end + 1 < args.length &&
|
|
5249
|
+
typeof args[end + 1] === "string" &&
|
|
5250
|
+
args[end + 1].length > 0 &&
|
|
5251
|
+
(args[end + 1] === "-" || !args[end + 1].startsWith("-"))
|
|
5252
|
+
) {
|
|
5253
|
+
end += 1;
|
|
5254
|
+
}
|
|
5255
|
+
return end;
|
|
5132
5256
|
}
|
|
5133
|
-
|
|
5134
|
-
|
|
5257
|
+
return consumesNextArg(arg) ? i + 1 : i;
|
|
5258
|
+
}
|
|
5259
|
+
|
|
5260
|
+
/**
|
|
5261
|
+
* The single definition of where an argv's option side ends. Walking from
|
|
5262
|
+
* `startIndex`, skips option flags and the values they consume — including a
|
|
5263
|
+
* greedy `-i a.png b.png` span — and reports what stopped the walk:
|
|
5264
|
+
*
|
|
5265
|
+
* - `index`: the first free (positional) token, or null when there is none.
|
|
5266
|
+
* - `terminatorIndex`: the bare `--` that ended option parsing, or null.
|
|
5267
|
+
* - `boundary`: where option-side content ends, i.e. the positional, the
|
|
5268
|
+
* `--`, or `args.length`. This is where injected overrides belong.
|
|
5269
|
+
*
|
|
5270
|
+
* Callers keep their own policy for `--` (the root grammar binds everything
|
|
5271
|
+
* after it to `[PROMPT]`; a subcommand's does not), but they must not keep
|
|
5272
|
+
* their own copy of the walk: a classifier and an injector that disagree by
|
|
5273
|
+
* one token put a `-c` override into prompt text or read prompt text as a
|
|
5274
|
+
* subcommand.
|
|
5275
|
+
*/
|
|
5276
|
+
function findRootPositional(args, startIndex = 0) {
|
|
5277
|
+
if (!Array.isArray(args)) {
|
|
5278
|
+
return { index: null, terminatorIndex: null, boundary: 0 };
|
|
5279
|
+
}
|
|
5280
|
+
for (let i = startIndex; i < args.length; i += 1) {
|
|
5281
|
+
const arg = args[i];
|
|
5135
5282
|
if (typeof arg !== "string" || arg.length === 0) continue;
|
|
5136
5283
|
if (arg === "--") {
|
|
5137
|
-
return i
|
|
5138
|
-
? { command: rawArgs[i + 1], index: i + 1 }
|
|
5139
|
-
: null;
|
|
5284
|
+
return { index: null, terminatorIndex: i, boundary: i };
|
|
5140
5285
|
}
|
|
5141
5286
|
if (arg.startsWith("--config=")) {
|
|
5142
5287
|
continue;
|
|
5143
5288
|
}
|
|
5144
5289
|
if (arg.startsWith("--") || (arg.startsWith("-") && arg !== "-")) {
|
|
5145
|
-
|
|
5146
|
-
i += 1;
|
|
5147
|
-
}
|
|
5290
|
+
i = skipOptionValueSpan(args, i);
|
|
5148
5291
|
continue;
|
|
5149
5292
|
}
|
|
5150
|
-
return {
|
|
5293
|
+
return { index: i, terminatorIndex: null, boundary: i };
|
|
5294
|
+
}
|
|
5295
|
+
return { index: null, terminatorIndex: null, boundary: args.length };
|
|
5296
|
+
}
|
|
5297
|
+
|
|
5298
|
+
function findForwardedCommand(rawArgs) {
|
|
5299
|
+
if (!Array.isArray(rawArgs) || rawArgs.length === 0) {
|
|
5300
|
+
return null;
|
|
5301
|
+
}
|
|
5302
|
+
// `--` ends option parsing: everything after it binds to the root
|
|
5303
|
+
// `[PROMPT]` positional, never to a subcommand, even when the text happens
|
|
5304
|
+
// to spell a command name. `findRootPositional` reports it as a terminator
|
|
5305
|
+
// and yields no positional, which settles the launch as interactive.
|
|
5306
|
+
//
|
|
5307
|
+
// Native Codex fills the root `[PROMPT]` slot with the first free token and
|
|
5308
|
+
// still tries a later one as a subcommand (`codex hello exec …` runs exec),
|
|
5309
|
+
// so an unknown positional keeps the scan going.
|
|
5310
|
+
for (
|
|
5311
|
+
let cursor = findRootPositional(rawArgs, 0);
|
|
5312
|
+
cursor.index !== null;
|
|
5313
|
+
cursor = findRootPositional(rawArgs, cursor.index + 1)
|
|
5314
|
+
) {
|
|
5315
|
+
const command = resolveCodexRootCommand(rawArgs[cursor.index]);
|
|
5316
|
+
if (command !== null) {
|
|
5317
|
+
return { command, index: cursor.index };
|
|
5318
|
+
}
|
|
5151
5319
|
}
|
|
5152
5320
|
|
|
5153
5321
|
return null;
|
|
5154
5322
|
}
|
|
5155
5323
|
|
|
5324
|
+
// Inserts option-side args ahead of the root `[PROMPT]` positional (or the
|
|
5325
|
+
// `--` that forces one), so injected overrides cannot be read as prompt text.
|
|
5326
|
+
// With no positional at all this is a plain append, which keeps bare-TUI argv
|
|
5327
|
+
// byte-identical to the pre-insertion shape.
|
|
5328
|
+
function insertArgsBeforeRootPrompt(baseArgs, injectedArgs) {
|
|
5329
|
+
const { boundary } = findRootPositional(baseArgs);
|
|
5330
|
+
return [
|
|
5331
|
+
...baseArgs.slice(0, boundary),
|
|
5332
|
+
...injectedArgs,
|
|
5333
|
+
...baseArgs.slice(boundary),
|
|
5334
|
+
];
|
|
5335
|
+
}
|
|
5336
|
+
|
|
5337
|
+
// Inserts args before a `--` separator when one is present, else appends.
|
|
5338
|
+
// Subcommand argv keeps its leading subcommand token — `resume`/`fork` argv is
|
|
5339
|
+
// pinned to it — but anything appended past a `--` lands in the subcommand's
|
|
5340
|
+
// own positional list: Codex rejects that outright for `exec`/`review`
|
|
5341
|
+
// ("unexpected argument '-c' found"), and worse, silently forwards it as
|
|
5342
|
+
// arguments for `sandbox` and into the stored config for `mcp add`. The
|
|
5343
|
+
// separator bounds the append.
|
|
5344
|
+
function insertArgsBeforeForwardedSeparator(baseArgs, injectedArgs) {
|
|
5345
|
+
// Deliberately not `findRootPositional`: that reports where the option side
|
|
5346
|
+
// ends, stopping at the first positional, and a subcommand's own arguments
|
|
5347
|
+
// are positionals — it would answer `exec` for `exec -- hi`. The question
|
|
5348
|
+
// here is only where the separator sits, and a bare `indexOf` answers it
|
|
5349
|
+
// exactly: clap never consumes `--` as an option value (`codex --cd -- hi`
|
|
5350
|
+
// is "a value is required for '--cd'"), so the first `--` is always the
|
|
5351
|
+
// separator, and it always follows the subcommand token because
|
|
5352
|
+
// `findForwardedCommand` returns null the moment it sees one.
|
|
5353
|
+
const separatorIndex = baseArgs.indexOf("--");
|
|
5354
|
+
if (separatorIndex === -1) {
|
|
5355
|
+
return [...baseArgs, ...injectedArgs];
|
|
5356
|
+
}
|
|
5357
|
+
return [
|
|
5358
|
+
...baseArgs.slice(0, separatorIndex),
|
|
5359
|
+
...injectedArgs,
|
|
5360
|
+
...baseArgs.slice(separatorIndex),
|
|
5361
|
+
];
|
|
5362
|
+
}
|
|
5363
|
+
|
|
5156
5364
|
function findForwardedSubcommand(rawArgs, commandIndex) {
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
if (consumesNextArg(arg)) {
|
|
5168
|
-
i += 1;
|
|
5169
|
-
}
|
|
5170
|
-
continue;
|
|
5171
|
-
}
|
|
5172
|
-
return arg;
|
|
5365
|
+
const { index, terminatorIndex } = findRootPositional(
|
|
5366
|
+
rawArgs,
|
|
5367
|
+
commandIndex + 1,
|
|
5368
|
+
);
|
|
5369
|
+
// Unlike the root grammar, a subcommand's `--` still precedes its own
|
|
5370
|
+
// positional args, so the token after it is the nested subcommand.
|
|
5371
|
+
if (terminatorIndex !== null) {
|
|
5372
|
+
return terminatorIndex + 1 < rawArgs.length
|
|
5373
|
+
? rawArgs[terminatorIndex + 1]
|
|
5374
|
+
: null;
|
|
5173
5375
|
}
|
|
5174
|
-
return null;
|
|
5376
|
+
return index === null ? null : rawArgs[index];
|
|
5175
5377
|
}
|
|
5176
5378
|
|
|
5177
5379
|
function hasHelpFlagAfterCommand(rawArgs, commandIndex) {
|
|
@@ -5179,8 +5381,8 @@ function hasHelpFlagAfterCommand(rawArgs, commandIndex) {
|
|
|
5179
5381
|
const arg = rawArgs[i];
|
|
5180
5382
|
if (arg === "--") return false;
|
|
5181
5383
|
if (arg === "--help" || arg === "-h" || arg === "help") return true;
|
|
5182
|
-
if (typeof arg === "string"
|
|
5183
|
-
i
|
|
5384
|
+
if (typeof arg === "string") {
|
|
5385
|
+
i = skipOptionValueSpan(rawArgs, i);
|
|
5184
5386
|
}
|
|
5185
5387
|
}
|
|
5186
5388
|
return false;
|
|
@@ -5194,6 +5396,11 @@ function isCodexAppServerCommand(rawArgs) {
|
|
|
5194
5396
|
return findForwardedCommand(rawArgs)?.command === "app-server";
|
|
5195
5397
|
}
|
|
5196
5398
|
|
|
5399
|
+
// True for every root TUI launch: bare `codex [OPTIONS]`, and `codex
|
|
5400
|
+
// [OPTIONS] [PROMPT]` with the optional initial prompt — including a prompt
|
|
5401
|
+
// forced by `--`. Both shapes start the same interactive session and need the
|
|
5402
|
+
// canonical home, or Codex stalls rebuilding the omitted thread index inside
|
|
5403
|
+
// the shadow mirror before it can submit the already-delivered prompt.
|
|
5197
5404
|
function isCodexInteractiveTuiCommand(rawArgs) {
|
|
5198
5405
|
return findForwardedCommand(rawArgs) === null;
|
|
5199
5406
|
}
|
|
@@ -5219,7 +5426,14 @@ function shouldUseRuntimeRoutingForForwardedArgs(rawArgs) {
|
|
|
5219
5426
|
|
|
5220
5427
|
const command = findForwardedCommand(rawArgs);
|
|
5221
5428
|
if (!command) {
|
|
5222
|
-
|
|
5429
|
+
// A help flag alongside a root prompt (`codex "prompt" --help`) prints
|
|
5430
|
+
// help and exits clean, so it must skip the transport for the same
|
|
5431
|
+
// reason as the request-command help forms below: the interactive
|
|
5432
|
+
// branch detaches its helper on a clean exit, and a helper started
|
|
5433
|
+
// just to print help would idle until its detached timeout. The scan
|
|
5434
|
+
// stops at `--`, so help-looking text inside a forced prompt still
|
|
5435
|
+
// routes normally.
|
|
5436
|
+
return !hasHelpFlagAfterCommand(rawArgs, -1);
|
|
5223
5437
|
}
|
|
5224
5438
|
|
|
5225
5439
|
const requestCommands = new Set(["exec", "review", "resume", "fork", "app"]);
|
|
@@ -5450,12 +5664,16 @@ function buildForwardArgs(rawArgs) {
|
|
|
5450
5664
|
return { args: compatibilityArgs, requestedModel };
|
|
5451
5665
|
}
|
|
5452
5666
|
|
|
5667
|
+
const authStoreArgs = ["-c", 'cli_auth_credentials_store="file"'];
|
|
5453
5668
|
return {
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5669
|
+
// A root launch may end in the `[PROMPT]` positional — possibly forced
|
|
5670
|
+
// by `--`, where an appended `-c` pair would be read as prompt text —
|
|
5671
|
+
// so the override is inserted on the option side of the prompt there.
|
|
5672
|
+
// Subcommand argv keeps the appended ordering it has always forwarded.
|
|
5673
|
+
args:
|
|
5674
|
+
findForwardedCommand(compatibilityArgs) === null
|
|
5675
|
+
? insertArgsBeforeRootPrompt(compatibilityArgs, authStoreArgs)
|
|
5676
|
+
: insertArgsBeforeForwardedSeparator(compatibilityArgs, authStoreArgs),
|
|
5459
5677
|
requestedModel,
|
|
5460
5678
|
};
|
|
5461
5679
|
}
|