codex-multi-auth 2.12.0 → 2.14.0
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 +10 -1
- package/dist/lib/accounts.d.ts +75 -0
- package/dist/lib/accounts.d.ts.map +1 -1
- package/dist/lib/accounts.js +213 -42
- package/dist/lib/codex-manager/account-manager-commands.d.ts.map +1 -1
- package/dist/lib/codex-manager/account-manager-commands.js +1 -0
- package/dist/lib/codex-manager/commands/limits.d.ts +21 -0
- package/dist/lib/codex-manager/commands/limits.d.ts.map +1 -0
- package/dist/lib/codex-manager/commands/limits.js +158 -0
- package/dist/lib/codex-manager/help.d.ts.map +1 -1
- package/dist/lib/codex-manager/help.js +1 -0
- package/dist/lib/codex-manager/persist-selected-account.d.ts.map +1 -1
- package/dist/lib/codex-manager/persist-selected-account.js +14 -0
- package/dist/lib/codex-manager.d.ts.map +1 -1
- package/dist/lib/codex-manager.js +12 -0
- package/dist/lib/forecast.d.ts.map +1 -1
- package/dist/lib/forecast.js +1 -14
- package/dist/lib/preemptive-quota-scheduler.d.ts +12 -0
- package/dist/lib/preemptive-quota-scheduler.d.ts.map +1 -1
- package/dist/lib/preemptive-quota-scheduler.js +21 -0
- package/dist/lib/redaction.d.ts +17 -0
- package/dist/lib/redaction.d.ts.map +1 -0
- package/dist/lib/redaction.js +28 -0
- package/dist/lib/request/error-classification.d.ts +13 -6
- package/dist/lib/request/error-classification.d.ts.map +1 -1
- package/dist/lib/request/error-classification.js +45 -0
- package/dist/lib/request/fetch-helpers.d.ts +1 -1
- package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
- package/dist/lib/request/fetch-helpers.js +1 -1
- package/dist/lib/runtime/rotation-proxy-state.d.ts +6 -0
- package/dist/lib/runtime/rotation-proxy-state.d.ts.map +1 -1
- package/dist/lib/runtime/rotation-server-types.d.ts +7 -0
- package/dist/lib/runtime/rotation-server-types.d.ts.map +1 -1
- package/dist/lib/runtime/rotation-storage-meta.d.ts.map +1 -1
- package/dist/lib/runtime/rotation-storage-meta.js +4 -1
- package/dist/lib/runtime-rotation-proxy.d.ts +23 -0
- package/dist/lib/runtime-rotation-proxy.d.ts.map +1 -1
- package/dist/lib/runtime-rotation-proxy.js +237 -13
- package/dist/lib/storage.d.ts +20 -2
- package/dist/lib/storage.d.ts.map +1 -1
- package/dist/lib/storage.js +69 -25
- package/package.json +1 -1
- package/scripts/codex-routing.js +1 -0
- package/scripts/codex.js +157 -2
|
@@ -8,11 +8,34 @@ export { chooseAccount } from "./runtime/rotation-account-selection.js";
|
|
|
8
8
|
export { maybeInvalidateAffinityFromDisk, readPinnedAccountIndexFromDisk, readStorageMetaFromDisk, resetPinCacheForTesting, } from "./runtime/rotation-storage-meta.js";
|
|
9
9
|
export type { StorageMeta } from "./runtime/rotation-storage-meta.js";
|
|
10
10
|
/** @internal Stable identity key for in-memory quota snapshots across reloads. */
|
|
11
|
+
/**
|
|
12
|
+
* Quota-scheduler key prefix identifying ONE account across every family and
|
|
13
|
+
* model key it owns.
|
|
14
|
+
*
|
|
15
|
+
* The trailing separator is part of the contract: without it the prefix
|
|
16
|
+
* `account:email:foo` also matches `account:email:foobar:codex`, so clearing
|
|
17
|
+
* one account's observations would silently clear a neighbour's.
|
|
18
|
+
*/
|
|
19
|
+
export declare function buildQuotaScheduleAccountPrefix(account: Pick<ManagedAccount, "accountId" | "email" | "refreshToken"> & {
|
|
20
|
+
/** Stable per-record discriminator retained across token/account-id updates. */
|
|
21
|
+
addedAt?: number;
|
|
22
|
+
recordId?: string;
|
|
23
|
+
}): string;
|
|
11
24
|
export declare function buildQuotaScheduleKey(account: Pick<ManagedAccount, "accountId" | "email" | "refreshToken"> & {
|
|
12
25
|
/** Stable per-record discriminator retained across token/account-id updates. */
|
|
13
26
|
addedAt?: number;
|
|
14
27
|
recordId?: string;
|
|
15
28
|
}, family: ModelFamily, model?: string | null): string;
|
|
29
|
+
/**
|
|
30
|
+
* Total time one request may spend waiting out model capacity.
|
|
31
|
+
*
|
|
32
|
+
* `0` disables the behaviour entirely and restores the pre-#689 handling, where
|
|
33
|
+
* a capacity response rotates the pool and 503s. Anything unparseable falls
|
|
34
|
+
* back to the default rather than disabling, so a typo does not silently turn
|
|
35
|
+
* the feature off.
|
|
36
|
+
*/
|
|
37
|
+
export declare function normalizeModelCapacityRetryMs(value: unknown): number;
|
|
38
|
+
export declare function resolveModelCapacityRetryMs(env?: NodeJS.ProcessEnv): number;
|
|
16
39
|
/**
|
|
17
40
|
* Coerce a forced-account pin from an option (number) or the launcher's env
|
|
18
41
|
* string into a normalized 0-based index, or null when absent/invalid. Invalid
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime-rotation-proxy.d.ts","sourceRoot":"","sources":["../../lib/runtime-rotation-proxy.ts"],"names":[],"mappings":"AAGA,OAAO,EAIN,KAAK,cAAc,EACnB,MAAM,eAAe,CAAC;AAkCvB,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"runtime-rotation-proxy.d.ts","sourceRoot":"","sources":["../../lib/runtime-rotation-proxy.ts"],"names":[],"mappings":"AAGA,OAAO,EAIN,KAAK,cAAc,EACnB,MAAM,eAAe,CAAC;AAkCvB,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAqDtE,OAAO,KAAK,EAKX,2BAA2B,EAC3B,0BAA0B,EAE1B,MAAM,oCAAoC,CAAC;AAe5C,YAAY,EACX,2BAA2B,EAC3B,0BAA0B,EAC1B,0BAA0B,GAC1B,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EACN,+BAA+B,EAC/B,0BAA0B,GAC1B,MAAM,kCAAkC,CAAC;AAC1C,YAAY,EAAE,0BAA0B,EAAE,MAAM,kCAAkC,CAAC;AACnF,OAAO,EAAE,aAAa,EAAE,MAAM,yCAAyC,CAAC;AACxE,OAAO,EACN,+BAA+B,EAC/B,8BAA8B,EAC9B,uBAAuB,EACvB,uBAAuB,GACvB,MAAM,oCAAoC,CAAC;AAC5C,YAAY,EAAE,WAAW,EAAE,MAAM,oCAAoC,CAAC;AA8DtE,kFAAkF;AAClF;;;;;;;GAOG;AACH,wBAAgB,+BAA+B,CAC9C,OAAO,EAAE,IAAI,CAAC,cAAc,EAAE,WAAW,GAAG,OAAO,GAAG,cAAc,CAAC,GAAG;IACvE,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB,GACC,MAAM,CAmBR;AAED,wBAAgB,qBAAqB,CACpC,OAAO,EAAE,IAAI,CAAC,cAAc,EAAE,WAAW,GAAG,OAAO,GAAG,cAAc,CAAC,GAAG;IACvE,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB,EACD,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,MAAM,CAER;AA0ED;;;;;;;GAOG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAKpE;AAED,wBAAgB,2BAA2B,CAC1C,GAAG,GAAE,MAAM,CAAC,UAAwB,GAClC,MAAM,CAQR;AAueD;;;;;;;;GAQG;AACH,wBAAgB,2BAA2B,CAC1C,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,GACvC,MAAM,GAAG,IAAI,CAUf;AAED,wBAAsB,yBAAyB,CAC9C,OAAO,EAAE,2BAA2B,GAClC,OAAO,CAAC,0BAA0B,CAAC,CA6LrC"}
|
|
@@ -9,7 +9,7 @@ import { CURRENT_CODEX_MODEL } from "./request/helpers/model-map.js";
|
|
|
9
9
|
import { mutateRuntimeObservabilitySnapshot, recordRuntimeAccountRecovery, recordRuntimePoolExhaustion, } from "./runtime/runtime-observability.js";
|
|
10
10
|
import { createRuntimeUsageRecorder, evaluateRuntimePolicy, loadRuntimePolicyState, } from "./policy/runtime-policy.js";
|
|
11
11
|
import { createUsageStreamScanner } from "./usage/usage-extraction.js";
|
|
12
|
-
import { isWorkspaceDisabledError } from "./request/fetch-helpers.js";
|
|
12
|
+
import { isModelAtCapacityError, isWorkspaceDisabledError, } from "./request/fetch-helpers.js";
|
|
13
13
|
import { PreemptiveQuotaScheduler, readQuotaSchedulerSnapshot, } from "./preemptive-quota-scheduler.js";
|
|
14
14
|
import { ContextBudgetGuard } from "./context-budget-guard.js";
|
|
15
15
|
import { buildContextBudgetHeaders, createContextBudgetPauseResponse, } from "./context-budget-response.js";
|
|
@@ -80,7 +80,15 @@ const PINNED_PERMANENT_SKIP_REASONS = new Set([
|
|
|
80
80
|
]);
|
|
81
81
|
const DEFAULT_QUOTA_REMAINING_THRESHOLD = 10;
|
|
82
82
|
/** @internal Stable identity key for in-memory quota snapshots across reloads. */
|
|
83
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Quota-scheduler key prefix identifying ONE account across every family and
|
|
85
|
+
* model key it owns.
|
|
86
|
+
*
|
|
87
|
+
* The trailing separator is part of the contract: without it the prefix
|
|
88
|
+
* `account:email:foo` also matches `account:email:foobar:codex`, so clearing
|
|
89
|
+
* one account's observations would silently clear a neighbour's.
|
|
90
|
+
*/
|
|
91
|
+
export function buildQuotaScheduleAccountPrefix(account) {
|
|
84
92
|
const emailKey = normalizeEmailKey(account.email);
|
|
85
93
|
const accountId = account.accountId?.trim();
|
|
86
94
|
const refreshToken = account.refreshToken?.trim() ?? "";
|
|
@@ -97,7 +105,10 @@ export function buildQuotaScheduleKey(account, family, model) {
|
|
|
97
105
|
: accountId
|
|
98
106
|
? `id:${accountId}${recordDiscriminator}`
|
|
99
107
|
: `refresh:${createHash("sha256").update(refreshToken).digest("hex")}${recordDiscriminator}`;
|
|
100
|
-
return `account:${accountIdentity}
|
|
108
|
+
return `account:${accountIdentity}:`;
|
|
109
|
+
}
|
|
110
|
+
export function buildQuotaScheduleKey(account, family, model) {
|
|
111
|
+
return `${buildQuotaScheduleAccountPrefix(account)}${model ?? family}`;
|
|
101
112
|
}
|
|
102
113
|
const DEFAULT_MAX_RUNTIME_ACCOUNT_ATTEMPTS = 4;
|
|
103
114
|
// This is a hard safety ceiling over every pinned selection pass, including
|
|
@@ -133,6 +144,58 @@ function pinnedRetryBackoffMs(attempt) {
|
|
|
133
144
|
const index = Math.min(Math.max(0, attempt - 1), PINNED_RETRY_BACKOFF_STEPS_MS.length - 1);
|
|
134
145
|
return PINNED_RETRY_BACKOFF_STEPS_MS[index] ?? 0;
|
|
135
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Backoff schedule for a model-capacity wait (issue #689).
|
|
149
|
+
*
|
|
150
|
+
* Deliberately much longer than `PINNED_RETRY_BACKOFF_STEPS_MS`. That schedule
|
|
151
|
+
* spaces out re-sends of a request whose account might already be fine;
|
|
152
|
+
* capacity pressure is upstream and measured in minutes, so re-sending every
|
|
153
|
+
* 250ms would just be a tight poll against a busy backend. The last step
|
|
154
|
+
* repeats for every attempt past the table.
|
|
155
|
+
*/
|
|
156
|
+
const CAPACITY_RETRY_BACKOFF_STEPS_MS = [
|
|
157
|
+
2_000, 5_000, 15_000, 30_000, 60_000,
|
|
158
|
+
];
|
|
159
|
+
/**
|
|
160
|
+
* Default wall-clock ceiling on capacity waiting for ONE request.
|
|
161
|
+
*
|
|
162
|
+
* The reporter's case is a long-running task started before stepping away, so
|
|
163
|
+
* the default has to be long enough to outlast a real capacity blip. It is
|
|
164
|
+
* still a hard ceiling: the request ends with the normal pool-exhausted 503
|
|
165
|
+
* rather than hanging forever.
|
|
166
|
+
*/
|
|
167
|
+
const DEFAULT_MODEL_CAPACITY_RETRY_MS = 10 * 60_000;
|
|
168
|
+
const MAX_MODEL_CAPACITY_RETRY_MS = 60 * 60_000;
|
|
169
|
+
const MODEL_CAPACITY_RETRY_ENV = "CODEX_MULTI_AUTH_MODEL_CAPACITY_RETRY_MS";
|
|
170
|
+
function capacityRetryBackoffMs(attempt) {
|
|
171
|
+
const index = Math.min(Math.max(0, attempt - 1), CAPACITY_RETRY_BACKOFF_STEPS_MS.length - 1);
|
|
172
|
+
return CAPACITY_RETRY_BACKOFF_STEPS_MS[index] ?? 0;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Total time one request may spend waiting out model capacity.
|
|
176
|
+
*
|
|
177
|
+
* `0` disables the behaviour entirely and restores the pre-#689 handling, where
|
|
178
|
+
* a capacity response rotates the pool and 503s. Anything unparseable falls
|
|
179
|
+
* back to the default rather than disabling, so a typo does not silently turn
|
|
180
|
+
* the feature off.
|
|
181
|
+
*/
|
|
182
|
+
export function normalizeModelCapacityRetryMs(value) {
|
|
183
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
184
|
+
return DEFAULT_MODEL_CAPACITY_RETRY_MS;
|
|
185
|
+
}
|
|
186
|
+
return Math.min(Math.floor(value), MAX_MODEL_CAPACITY_RETRY_MS);
|
|
187
|
+
}
|
|
188
|
+
export function resolveModelCapacityRetryMs(env = process.env) {
|
|
189
|
+
const raw = (env[MODEL_CAPACITY_RETRY_ENV] ?? "").trim();
|
|
190
|
+
if (!raw)
|
|
191
|
+
return DEFAULT_MODEL_CAPACITY_RETRY_MS;
|
|
192
|
+
// An explicit 0 is the documented kill switch and must survive normalization,
|
|
193
|
+
// which otherwise treats "not a usable number" as "use the default".
|
|
194
|
+
const parsed = Number(raw);
|
|
195
|
+
if (parsed === 0)
|
|
196
|
+
return 0;
|
|
197
|
+
return normalizeModelCapacityRetryMs(parsed);
|
|
198
|
+
}
|
|
136
199
|
const MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024;
|
|
137
200
|
const MAX_THREAD_GOAL_FALLBACKS = 512;
|
|
138
201
|
const ALLOWED_RESPONSES_PATHS = new Set([
|
|
@@ -597,6 +660,14 @@ export async function startRuntimeRotationProxy(options) {
|
|
|
597
660
|
const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
|
|
598
661
|
const networkErrorCooldownMs = getNetworkErrorCooldownMs(pluginConfig);
|
|
599
662
|
const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig);
|
|
663
|
+
// Normalize the explicit option as well as the env var: a caller passing a
|
|
664
|
+
// negative or multi-hour value would otherwise bypass the documented cap.
|
|
665
|
+
// `0` stays 0, since it is the documented kill switch.
|
|
666
|
+
const modelCapacityRetryMs = options.modelCapacityRetryMs === 0
|
|
667
|
+
? 0
|
|
668
|
+
: options.modelCapacityRetryMs === undefined
|
|
669
|
+
? resolveModelCapacityRetryMs()
|
|
670
|
+
: normalizeModelCapacityRetryMs(options.modelCapacityRetryMs);
|
|
600
671
|
const tokenInvalidationCooldownMs = getTokenInvalidationCooldownMs(pluginConfig);
|
|
601
672
|
const minRotationIntervalMs = getMinRotationIntervalMs(pluginConfig);
|
|
602
673
|
const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig);
|
|
@@ -643,6 +714,7 @@ export async function startRuntimeRotationProxy(options) {
|
|
|
643
714
|
tokenRefreshSkewMs,
|
|
644
715
|
networkErrorCooldownMs,
|
|
645
716
|
serverErrorCooldownMs,
|
|
717
|
+
modelCapacityRetryMs,
|
|
646
718
|
tokenInvalidationCooldownMs,
|
|
647
719
|
minRotationIntervalMs,
|
|
648
720
|
pidOffsetEnabled,
|
|
@@ -852,6 +924,24 @@ async function handleRequestInner(state, req, res, traceId) {
|
|
|
852
924
|
let transientAttemptLimit = Math.max(1, Math.min(accountCount, state.maxRuntimeAccountAttempts));
|
|
853
925
|
let transientAttempts = 0;
|
|
854
926
|
let transientExhaustionReason = null;
|
|
927
|
+
// Capacity waits are counted separately from `transientAttempts` and from
|
|
928
|
+
// the pinned selection cap. Both of those bound how many DIFFERENT
|
|
929
|
+
// accounts or re-sends a failing request may burn; a capacity wait is not
|
|
930
|
+
// a failure of any account, it is the same request pausing for an
|
|
931
|
+
// upstream that is busy, and it has its own wall-clock ceiling. See #689.
|
|
932
|
+
let capacityRetries = 0;
|
|
933
|
+
// A wall-clock deadline, not a sum of sleeps. The upstream request and
|
|
934
|
+
// the error-body read also consume the budget, so summing only the
|
|
935
|
+
// planned sleeps let a slow capacity response push one request past the
|
|
936
|
+
// documented ceiling. Request-local, so concurrent requests never share
|
|
937
|
+
// a deadline.
|
|
938
|
+
let capacityDeadlineAt = null;
|
|
939
|
+
// Only the RESPONSE tells us the caller is still there. `req` is an
|
|
940
|
+
// IncomingMessage whose stream Node destroys once the request body has
|
|
941
|
+
// been fully read, which this proxy does up front, so `req.destroyed` is
|
|
942
|
+
// routinely true on a perfectly healthy request and must not be read as a
|
|
943
|
+
// disconnect.
|
|
944
|
+
const clientGone = () => res.destroyed || res.writableEnded;
|
|
855
945
|
const accountSkipReasons = new Map();
|
|
856
946
|
let reloadedAfterNoAccount = false;
|
|
857
947
|
// Read the manual pin and affinity generation from disk (mtime-cached)
|
|
@@ -862,7 +952,37 @@ async function handleRequestInner(state, req, res, traceId) {
|
|
|
862
952
|
// otherwise glue an in-flight chat thread to the previously selected
|
|
863
953
|
// account. The proxy itself never bumps the generation, so its own
|
|
864
954
|
// debounced disk writes do not clear affinity. See issue #474.
|
|
865
|
-
|
|
955
|
+
/** Reconcile once before selection or recording a new upstream observation. */
|
|
956
|
+
const reconcileManualSelection = () => {
|
|
957
|
+
const meta = readStorageMetaFromDisk();
|
|
958
|
+
for (const manager of state.knownAccountManagers) {
|
|
959
|
+
manager.applyManualSelection(meta);
|
|
960
|
+
}
|
|
961
|
+
if (meta.affinityGeneration > state.lastObservedAffinityGeneration) {
|
|
962
|
+
const switchedAccount = meta.pinnedAccountIndex === null
|
|
963
|
+
? null
|
|
964
|
+
: accountManager.getAccountByIndex(meta.pinnedAccountIndex);
|
|
965
|
+
if (switchedAccount) {
|
|
966
|
+
state.preemptiveQuotaScheduler.clearByPrefix(buildQuotaScheduleAccountPrefix(switchedAccount));
|
|
967
|
+
}
|
|
968
|
+
else if (meta.pinnedAccountIndex !== null) {
|
|
969
|
+
// A pin we cannot resolve: this long-lived proxy re-reads only
|
|
970
|
+
// pin/gen, never the account list, so a `login` that appended an
|
|
971
|
+
// account before the `switch` leaves the new index out of range
|
|
972
|
+
// here. The generation only bumps on the NEXT user-initiated
|
|
973
|
+
// switch, so skipping the clear and advancing anyway would strand
|
|
974
|
+
// the pre-switch quota observation forever, which is the exact
|
|
975
|
+
// deferral this reconciliation exists to end. Drop every cached
|
|
976
|
+
// observation instead: it only costs a re-probe, and real 429
|
|
977
|
+
// windows live on the accounts themselves, not in this cache.
|
|
978
|
+
state.preemptiveQuotaScheduler.clearAll();
|
|
979
|
+
}
|
|
980
|
+
state.sessionAffinityStore?.clearAll();
|
|
981
|
+
state.lastObservedAffinityGeneration = meta.affinityGeneration;
|
|
982
|
+
}
|
|
983
|
+
return meta;
|
|
984
|
+
};
|
|
985
|
+
const storageMeta = reconcileManualSelection();
|
|
866
986
|
// The ephemeral --account pin (issue #623) takes precedence over the
|
|
867
987
|
// persisted `switch` pin for this invocation, without ever mutating disk
|
|
868
988
|
// state. Use `??` (not `||`) so a forced index of 0 is honored. When set,
|
|
@@ -899,15 +1019,79 @@ async function handleRequestInner(state, req, res, traceId) {
|
|
|
899
1019
|
if (isPinned) {
|
|
900
1020
|
transientAttemptLimit = Math.max(1, Math.min(state.maxRuntimeAccountAttempts, MAX_PINNED_TRANSIENT_ATTEMPTS));
|
|
901
1021
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1022
|
+
/**
|
|
1023
|
+
* Wait out a "selected model is at capacity" response, then let the loop
|
|
1024
|
+
* re-send the request (issue #689).
|
|
1025
|
+
*
|
|
1026
|
+
* Capacity is a property of the MODEL, not of an account, so the normal
|
|
1027
|
+
* handling is actively wrong for it: rotating spends the pool's transient
|
|
1028
|
+
* budget against accounts that will all fail identically, and the request
|
|
1029
|
+
* ends as a pool-exhausted 503 within seconds. That is what kills a
|
|
1030
|
+
* long-running task started before the user stepped away.
|
|
1031
|
+
*
|
|
1032
|
+
* The responding account is left completely unpenalized: its pool token is
|
|
1033
|
+
* refunded, it is not marked rate limited, not cooled down, and it is
|
|
1034
|
+
* removed from `attemptedIndexes` so it stays selectable. Selection then
|
|
1035
|
+
* runs normally on the next pass, which may pick a different account. That
|
|
1036
|
+
* is deliberate: with capacity being model-wide, no account is a better bet,
|
|
1037
|
+
* and forcing a request-local pin here would duplicate the pinning path for
|
|
1038
|
+
* no gain.
|
|
1039
|
+
*
|
|
1040
|
+
* @param retryAfterMs Upstream hint when one was actually sent, else `null`
|
|
1041
|
+
* so the backoff table is used. Do NOT pass a synthesized default.
|
|
1042
|
+
* @param account Account that just saw the capacity response.
|
|
1043
|
+
* @returns `"retry"` to re-send, `"give-up"` to fall through to normal
|
|
1044
|
+
* handling, `"client-gone"` when the caller must abandon the request.
|
|
1045
|
+
*/
|
|
1046
|
+
const waitOutModelCapacity = async (retryAfterMs, account) => {
|
|
1047
|
+
const budgetMs = state.modelCapacityRetryMs;
|
|
1048
|
+
if (!Number.isFinite(budgetMs) || budgetMs <= 0)
|
|
1049
|
+
return "give-up";
|
|
1050
|
+
if (capacityDeadlineAt === null) {
|
|
1051
|
+
capacityDeadlineAt = state.now() + budgetMs;
|
|
1052
|
+
}
|
|
1053
|
+
const remainingMs = capacityDeadlineAt - state.now();
|
|
1054
|
+
if (remainingMs <= 0)
|
|
1055
|
+
return "give-up";
|
|
1056
|
+
if (clientGone())
|
|
1057
|
+
return "client-gone";
|
|
1058
|
+
const hinted = retryAfterMs !== null &&
|
|
1059
|
+
Number.isFinite(retryAfterMs) &&
|
|
1060
|
+
retryAfterMs > 0
|
|
1061
|
+
? retryAfterMs
|
|
1062
|
+
: capacityRetryBackoffMs(capacityRetries + 1);
|
|
1063
|
+
const waitMs = Math.min(Math.max(0, Math.floor(hinted)), remainingMs);
|
|
1064
|
+
if (waitMs <= 0)
|
|
1065
|
+
return "give-up";
|
|
1066
|
+
capacityRetries += 1;
|
|
1067
|
+
state.status.retries += 1;
|
|
1068
|
+
proxyLog.warn("model at capacity; waiting before re-sending", {
|
|
1069
|
+
traceId,
|
|
1070
|
+
waitMs,
|
|
1071
|
+
attempt: capacityRetries,
|
|
1072
|
+
remainingMs,
|
|
1073
|
+
budgetMs,
|
|
1074
|
+
});
|
|
1075
|
+
// The attempt debited a pool token before the upstream call. The account
|
|
1076
|
+
// did not fail, so refund it: without this a sustained capacity event
|
|
1077
|
+
// drains healthy accounts and later requests are refused admission with
|
|
1078
|
+
// `token-exhausted` well before the retry budget expires.
|
|
1079
|
+
refundConsumedPoolToken(account);
|
|
1080
|
+
attemptedIndexes.delete(account.index);
|
|
1081
|
+
await sleep(waitMs);
|
|
1082
|
+
// A capacity wait runs for tens of seconds. Re-sending an authenticated
|
|
1083
|
+
// upstream request for a response nobody is reading wastes upstream
|
|
1084
|
+
// capacity and extends how long the token is in flight.
|
|
1085
|
+
if (clientGone())
|
|
1086
|
+
return "client-gone";
|
|
1087
|
+
return "retry";
|
|
1088
|
+
};
|
|
906
1089
|
let runtimeSelectionIterations = 0;
|
|
907
1090
|
while ((isPinned || attemptedIndexes.size < accountCount) &&
|
|
908
1091
|
transientAttempts < transientAttemptLimit &&
|
|
909
1092
|
(!isPinned ||
|
|
910
|
-
runtimeSelectionIterations
|
|
1093
|
+
runtimeSelectionIterations - capacityRetries <
|
|
1094
|
+
MAX_PINNED_SELECTION_ITERATIONS)) {
|
|
911
1095
|
// Space out same-account re-sends. The loop has no other delay in it,
|
|
912
1096
|
// and the pinned retry deliberately waives the account's own cooldown
|
|
913
1097
|
// (below), so without this two copies of the same non-idempotent
|
|
@@ -1153,15 +1337,39 @@ async function handleRequestInner(state, req, res, traceId) {
|
|
|
1153
1337
|
noteRotation();
|
|
1154
1338
|
continue;
|
|
1155
1339
|
}
|
|
1340
|
+
reconcileManualSelection();
|
|
1156
1341
|
const quotaSnapshot = readQuotaSchedulerSnapshot(upstream.headers, upstream.status, state.now());
|
|
1157
1342
|
if (quotaSnapshot) {
|
|
1158
1343
|
state.preemptiveQuotaScheduler.update(quotaScheduleKey, quotaSnapshot);
|
|
1159
1344
|
}
|
|
1160
1345
|
if (upstream.status === HTTP_STATUS.TOO_MANY_REQUESTS) {
|
|
1161
1346
|
const bodyText = await readErrorBody(upstream, state.streamStallTimeoutMs);
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1347
|
+
// Keep the upstream HINT separate from the 60s fallback. Passing the
|
|
1348
|
+
// synthesized default into the capacity wait would make every
|
|
1349
|
+
// hint-less capacity 429 sleep a full minute and leave the 2s/5s/15s
|
|
1350
|
+
// backoff table dead on this path.
|
|
1351
|
+
const retryAfterHintMs = parseRetryAfterHeaderMs(upstream.headers, state.now()) ??
|
|
1352
|
+
parseRetryAfterBodyMs(bodyText, state.now());
|
|
1353
|
+
const retryAfterMs = retryAfterHintMs ?? 60_000;
|
|
1354
|
+
// Reading the body awaited I/O; a switch may have landed meanwhile.
|
|
1355
|
+
reconcileManualSelection();
|
|
1356
|
+
// A capacity 429 is not this account's quota. Marking it rate
|
|
1357
|
+
// limited would take a healthy account out of the pool for the
|
|
1358
|
+
// retry-after window on an outage that affects every account.
|
|
1359
|
+
if (isModelAtCapacityError(upstream.status, bodyText)) {
|
|
1360
|
+
const outcome = await waitOutModelCapacity(retryAfterHintMs, refreshed.account);
|
|
1361
|
+
if (outcome === "client-gone") {
|
|
1362
|
+
await usageRecorder.record({
|
|
1363
|
+
outcome: "failure",
|
|
1364
|
+
statusCode: upstream.status,
|
|
1365
|
+
errorCode: "client_disconnected_during_capacity_wait",
|
|
1366
|
+
account: refreshed.account,
|
|
1367
|
+
});
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
if (outcome === "retry")
|
|
1371
|
+
continue;
|
|
1372
|
+
}
|
|
1165
1373
|
state.preemptiveQuotaScheduler.markRateLimited(quotaScheduleKey, retryAfterMs, state.now());
|
|
1166
1374
|
// A 429 is the upstream quota signal for the attempted account, so
|
|
1167
1375
|
// keep the consumed runtime token drained.
|
|
@@ -1300,7 +1508,23 @@ async function handleRequestInner(state, req, res, traceId) {
|
|
|
1300
1508
|
continue;
|
|
1301
1509
|
}
|
|
1302
1510
|
if (upstream.status >= 500) {
|
|
1303
|
-
await readErrorBody(upstream, state.streamStallTimeoutMs);
|
|
1511
|
+
const bodyText = await readErrorBody(upstream, state.streamStallTimeoutMs);
|
|
1512
|
+
// A capacity 5xx is upstream load, not a broken account, so it
|
|
1513
|
+
// must not cool the account down or count against the pool.
|
|
1514
|
+
if (isModelAtCapacityError(upstream.status, bodyText)) {
|
|
1515
|
+
const outcome = await waitOutModelCapacity(parseRetryAfterHeaderMs(upstream.headers, state.now()), refreshed.account);
|
|
1516
|
+
if (outcome === "client-gone") {
|
|
1517
|
+
await usageRecorder.record({
|
|
1518
|
+
outcome: "failure",
|
|
1519
|
+
statusCode: upstream.status,
|
|
1520
|
+
errorCode: "client_disconnected_during_capacity_wait",
|
|
1521
|
+
account: refreshed.account,
|
|
1522
|
+
});
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
if (outcome === "retry")
|
|
1526
|
+
continue;
|
|
1527
|
+
}
|
|
1304
1528
|
refundConsumedPoolToken(refreshed.account);
|
|
1305
1529
|
accountManager.recordFailure(refreshed.account, context.family, context.model);
|
|
1306
1530
|
accountManager.markAccountCoolingDown(refreshed.account, state.serverErrorCooldownMs, "server-error");
|
package/dist/lib/storage.d.ts
CHANGED
|
@@ -109,9 +109,27 @@ export declare function bumpStorageAffinityGeneration(storage: {
|
|
|
109
109
|
* persisting routine state (rate-limit hits, cooldowns, etc.) so a CLI
|
|
110
110
|
* `switch`/`unpin` that landed between proxy startup and the save is not
|
|
111
111
|
* clobbered. Returns `pinnedAccountIndex: undefined` and
|
|
112
|
-
* `affinityGeneration: 0` on any failure. See issue #474.
|
|
112
|
+
* `affinityGeneration: 0` on any failure by default. See issue #474.
|
|
113
|
+
*
|
|
114
|
+
* `strict` callers additionally need to know when the metadata could not be
|
|
115
|
+
* OBSERVED AT ALL, because persisting a stale in-memory pin over a newer disk
|
|
116
|
+
* pin is exactly the #474 regression. Strict mode therefore throws for the two
|
|
117
|
+
* cases where a newer selection may be hiding behind the failure:
|
|
118
|
+
* - the file exists but cannot be read (EBUSY/EPERM/EACCES: on Windows an AV
|
|
119
|
+
* scanner or indexer can hold accounts.json open), and
|
|
120
|
+
* - the bytes are not valid JSON (a torn read of a concurrent atomic write).
|
|
121
|
+
*
|
|
122
|
+
* A MISSING file and a successfully parsed file with INVALID field values are
|
|
123
|
+
* deliberately not failures, in either mode. Neither can be concealing a newer
|
|
124
|
+
* pin (there is no file, or we can see the whole file and the field is
|
|
125
|
+
* unusable), and both are self-repaired by the very write that follows. Making
|
|
126
|
+
* them throw would wedge persistence permanently, since recreating or repairing
|
|
127
|
+
* storage goes through the same save path, and it would take every rate-limit
|
|
128
|
+
* window, cooldown and rotated refresh token in memory down with it.
|
|
113
129
|
*/
|
|
114
|
-
export declare function readPinAndGenFromDisk(path: string
|
|
130
|
+
export declare function readPinAndGenFromDisk(path: string, options?: {
|
|
131
|
+
strict?: boolean;
|
|
132
|
+
}): {
|
|
115
133
|
pinnedAccountIndex: number | undefined;
|
|
116
134
|
affinityGeneration: number;
|
|
117
135
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../lib/storage.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../lib/storage.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/storage.js
CHANGED
|
@@ -898,7 +898,8 @@ export function normalizeAccountStorage(data) {
|
|
|
898
898
|
return undefined;
|
|
899
899
|
if (typeof raw !== "number" ||
|
|
900
900
|
!Number.isFinite(raw) ||
|
|
901
|
-
|
|
901
|
+
// See readAffinityGenerationFromDisk: an unsafe integer wedges the bump.
|
|
902
|
+
!Number.isSafeInteger(raw) ||
|
|
902
903
|
raw < 0) {
|
|
903
904
|
log.warn("Dropping invalid affinityGeneration from storage", {
|
|
904
905
|
value: raw,
|
|
@@ -941,7 +942,11 @@ export function readAffinityGenerationFromDisk(path) {
|
|
|
941
942
|
const generation = parsed.affinityGeneration;
|
|
942
943
|
if (typeof generation === "number" &&
|
|
943
944
|
Number.isFinite(generation) &&
|
|
944
|
-
|
|
945
|
+
// Safe, not merely integral: past 2^53 `Math.max(gen, disk) + 1 === gen`,
|
|
946
|
+
// so `bumpStorageAffinityGeneration` silently stops advancing and no
|
|
947
|
+
// running proxy ever observes another `switch`. Rejecting the value
|
|
948
|
+
// lets the next bump write 1 and repair the file.
|
|
949
|
+
Number.isSafeInteger(generation) &&
|
|
945
950
|
generation >= 0) {
|
|
946
951
|
return generation;
|
|
947
952
|
}
|
|
@@ -978,7 +983,12 @@ export function bumpStorageAffinityGeneration(storage, storagePath) {
|
|
|
978
983
|
// No resolvable storage path (never configured). The in-memory counter
|
|
979
984
|
// is still bumped below; only the lost-update guard is unavailable.
|
|
980
985
|
}
|
|
981
|
-
|
|
986
|
+
// Guard the in-memory side too, not just the disk read. Past 2^53
|
|
987
|
+
// `Math.max(gen, disk) + 1 === gen`, so an unsafe value here would write the
|
|
988
|
+
// same generation forever and no running proxy would observe another
|
|
989
|
+
// `switch`. Treating it as 0 makes the next write repair the counter.
|
|
990
|
+
const rawInMemory = storage.affinityGeneration ?? 0;
|
|
991
|
+
const inMemoryGeneration = Number.isSafeInteger(rawInMemory) && rawInMemory >= 0 ? rawInMemory : 0;
|
|
982
992
|
const next = Math.max(inMemoryGeneration, diskGeneration) + 1;
|
|
983
993
|
storage.affinityGeneration = next;
|
|
984
994
|
return next;
|
|
@@ -990,34 +1000,68 @@ export function bumpStorageAffinityGeneration(storage, storagePath) {
|
|
|
990
1000
|
* persisting routine state (rate-limit hits, cooldowns, etc.) so a CLI
|
|
991
1001
|
* `switch`/`unpin` that landed between proxy startup and the save is not
|
|
992
1002
|
* clobbered. Returns `pinnedAccountIndex: undefined` and
|
|
993
|
-
* `affinityGeneration: 0` on any failure. See issue #474.
|
|
1003
|
+
* `affinityGeneration: 0` on any failure by default. See issue #474.
|
|
1004
|
+
*
|
|
1005
|
+
* `strict` callers additionally need to know when the metadata could not be
|
|
1006
|
+
* OBSERVED AT ALL, because persisting a stale in-memory pin over a newer disk
|
|
1007
|
+
* pin is exactly the #474 regression. Strict mode therefore throws for the two
|
|
1008
|
+
* cases where a newer selection may be hiding behind the failure:
|
|
1009
|
+
* - the file exists but cannot be read (EBUSY/EPERM/EACCES: on Windows an AV
|
|
1010
|
+
* scanner or indexer can hold accounts.json open), and
|
|
1011
|
+
* - the bytes are not valid JSON (a torn read of a concurrent atomic write).
|
|
1012
|
+
*
|
|
1013
|
+
* A MISSING file and a successfully parsed file with INVALID field values are
|
|
1014
|
+
* deliberately not failures, in either mode. Neither can be concealing a newer
|
|
1015
|
+
* pin (there is no file, or we can see the whole file and the field is
|
|
1016
|
+
* unusable), and both are self-repaired by the very write that follows. Making
|
|
1017
|
+
* them throw would wedge persistence permanently, since recreating or repairing
|
|
1018
|
+
* storage goes through the same save path, and it would take every rate-limit
|
|
1019
|
+
* window, cooldown and rotated refresh token in memory down with it.
|
|
994
1020
|
*/
|
|
995
|
-
export function readPinAndGenFromDisk(path) {
|
|
1021
|
+
export function readPinAndGenFromDisk(path, options) {
|
|
1022
|
+
const unselected = {
|
|
1023
|
+
pinnedAccountIndex: undefined,
|
|
1024
|
+
affinityGeneration: 0,
|
|
1025
|
+
};
|
|
996
1026
|
if (!existsSync(path)) {
|
|
997
|
-
return {
|
|
1027
|
+
return { ...unselected };
|
|
998
1028
|
}
|
|
1029
|
+
let parsed;
|
|
999
1030
|
try {
|
|
1000
|
-
|
|
1001
|
-
const parsed = JSON.parse(bytes.toString("utf8"));
|
|
1002
|
-
const rawPin = parsed.pinnedAccountIndex;
|
|
1003
|
-
const pinnedAccountIndex = typeof rawPin === "number" &&
|
|
1004
|
-
Number.isFinite(rawPin) &&
|
|
1005
|
-
Number.isInteger(rawPin) &&
|
|
1006
|
-
rawPin >= 0
|
|
1007
|
-
? rawPin
|
|
1008
|
-
: undefined;
|
|
1009
|
-
const rawGen = parsed.affinityGeneration;
|
|
1010
|
-
const affinityGeneration = typeof rawGen === "number" &&
|
|
1011
|
-
Number.isFinite(rawGen) &&
|
|
1012
|
-
Number.isInteger(rawGen) &&
|
|
1013
|
-
rawGen >= 0
|
|
1014
|
-
? rawGen
|
|
1015
|
-
: 0;
|
|
1016
|
-
return { pinnedAccountIndex, affinityGeneration };
|
|
1031
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1017
1032
|
}
|
|
1018
|
-
catch {
|
|
1019
|
-
|
|
1033
|
+
catch (error) {
|
|
1034
|
+
// The file was deleted or renamed between existsSync and the read; same
|
|
1035
|
+
// reasoning as the missing-file branch above.
|
|
1036
|
+
if (error.code === "ENOENT") {
|
|
1037
|
+
return { ...unselected };
|
|
1038
|
+
}
|
|
1039
|
+
if (options?.strict) {
|
|
1040
|
+
throw new Error("Unable to read account selection metadata", {
|
|
1041
|
+
cause: error,
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
return { ...unselected };
|
|
1045
|
+
}
|
|
1046
|
+
if (!isRecord(parsed)) {
|
|
1047
|
+
return { ...unselected };
|
|
1020
1048
|
}
|
|
1049
|
+
const rawPin = parsed.pinnedAccountIndex;
|
|
1050
|
+
const pinnedAccountIndex = typeof rawPin === "number" &&
|
|
1051
|
+
Number.isFinite(rawPin) &&
|
|
1052
|
+
Number.isInteger(rawPin) &&
|
|
1053
|
+
rawPin >= 0
|
|
1054
|
+
? rawPin
|
|
1055
|
+
: undefined;
|
|
1056
|
+
const rawGen = parsed.affinityGeneration;
|
|
1057
|
+
const affinityGeneration = typeof rawGen === "number" &&
|
|
1058
|
+
Number.isFinite(rawGen) &&
|
|
1059
|
+
// See readAffinityGenerationFromDisk: an unsafe integer wedges the bump.
|
|
1060
|
+
Number.isSafeInteger(rawGen) &&
|
|
1061
|
+
rawGen >= 0
|
|
1062
|
+
? rawGen
|
|
1063
|
+
: 0;
|
|
1064
|
+
return { pinnedAccountIndex, affinityGeneration };
|
|
1021
1065
|
}
|
|
1022
1066
|
/**
|
|
1023
1067
|
* Loads OAuth accounts from disk storage.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-multi-auth",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
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",
|