codex-multi-auth 2.20.0 → 2.22.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 +5 -4
- package/dist/lib/accounts.d.ts +5 -0
- package/dist/lib/accounts.js +33 -4
- package/dist/lib/api-route-store.d.ts +6 -0
- package/dist/lib/api-route-store.js +98 -30
- package/dist/lib/codex-manager/api-login-menu.d.ts +1 -1
- package/dist/lib/codex-manager/api-login-menu.js +4 -6
- package/dist/lib/codex-manager/commands/status.d.ts +1 -1
- package/dist/lib/codex-manager/commands/status.js +26 -9
- package/dist/lib/codex-manager.js +7 -1
- package/dist/lib/constants.d.ts +1 -0
- package/dist/lib/constants.js +1 -0
- package/dist/lib/model-route-policy.d.ts +1 -0
- package/dist/lib/model-route-policy.js +2 -1
- package/dist/lib/runtime/account-model-catalog.d.ts +1 -0
- package/dist/lib/runtime/api-model-capabilities.d.ts +11 -2
- package/dist/lib/runtime/api-model-capabilities.js +155 -24
- package/dist/lib/runtime/app-bind.d.ts +4 -0
- package/dist/lib/runtime/app-bind.js +32 -1
- package/dist/lib/runtime/automatic-api-checks.d.ts +15 -0
- package/dist/lib/runtime/automatic-api-checks.js +188 -0
- package/dist/lib/runtime/model-discovery-status.d.ts +11 -1
- package/dist/lib/runtime/model-discovery-status.js +64 -49
- package/dist/lib/runtime/persisted-api-capabilities.d.ts +20 -0
- package/dist/lib/runtime/persisted-api-capabilities.js +387 -0
- package/dist/lib/runtime/responses-websocket.d.ts +18 -5
- package/dist/lib/runtime/responses-websocket.js +33 -11
- package/dist/lib/runtime/rotation-account-selection.d.ts +20 -1
- package/dist/lib/runtime/rotation-account-selection.js +94 -4
- package/dist/lib/runtime/rotation-proxy-state.d.ts +26 -0
- package/dist/lib/runtime/rotation-proxy-state.js +376 -4
- package/dist/lib/runtime/rotation-server-types.d.ts +3 -0
- package/dist/lib/runtime/rotation-token-refresh.d.ts +23 -1
- package/dist/lib/runtime/rotation-token-refresh.js +218 -27
- package/dist/lib/runtime-rotation-proxy.js +571 -96
- package/dist/lib/storage/credential-sidecars.js +34 -2
- package/dist/lib/storage/file-lock.d.ts +2 -0
- package/dist/lib/storage/file-lock.js +44 -30
- package/dist/lib/usage/ledger.js +1 -0
- package/dist/lib/usage/redaction.js +1 -0
- package/dist/lib/usage/types.d.ts +1 -1
- package/package.json +1 -1
- package/scripts/codex-app-router.js +42 -14
|
@@ -66,6 +66,7 @@ class SocketSession {
|
|
|
66
66
|
stats;
|
|
67
67
|
httpFetch;
|
|
68
68
|
channels = new Map();
|
|
69
|
+
openedAt = new WeakMap();
|
|
69
70
|
owners = new Map();
|
|
70
71
|
chains = new Map();
|
|
71
72
|
/** Credentials whose WebSocket handshake was refused; they use HTTP for the session. */
|
|
@@ -224,34 +225,47 @@ class SocketSession {
|
|
|
224
225
|
this.httpOnly.add(key);
|
|
225
226
|
return this.httpFetch(input, init);
|
|
226
227
|
}
|
|
228
|
+
this.openedAt.set(socket, Date.now());
|
|
227
229
|
this.channels.set(key, socket);
|
|
228
230
|
}
|
|
229
231
|
const active = socket;
|
|
230
232
|
return new Promise((resolve, reject) => {
|
|
231
233
|
let accepted = false, finished = false;
|
|
234
|
+
let bodyCancelled = false;
|
|
235
|
+
let eventCount = 0;
|
|
232
236
|
let streamController;
|
|
233
237
|
const stream = new ReadableStream({
|
|
234
238
|
start(controller) {
|
|
235
239
|
streamController = controller;
|
|
236
240
|
},
|
|
237
241
|
cancel() {
|
|
242
|
+
bodyCancelled = true;
|
|
238
243
|
active.terminate();
|
|
239
244
|
},
|
|
240
245
|
}, { highWaterMark: this.maxBytes, size: (chunk) => chunk.byteLength });
|
|
241
246
|
const cleanup = () => {
|
|
242
247
|
active.off("message", onMessage);
|
|
243
|
-
active.off("close",
|
|
244
|
-
active.off("error",
|
|
248
|
+
active.off("close", onSocketClose);
|
|
249
|
+
active.off("error", onError);
|
|
245
250
|
init.signal?.removeEventListener("abort", abort);
|
|
246
251
|
context.signal.removeEventListener("abort", abort);
|
|
247
252
|
};
|
|
248
|
-
const onClose = () => {
|
|
253
|
+
const onClose = (closeCode, cause = "close") => {
|
|
249
254
|
if (finished)
|
|
250
255
|
return;
|
|
251
256
|
finished = true;
|
|
252
257
|
cleanup();
|
|
253
258
|
this.channels.delete(key);
|
|
254
|
-
const cancelled = context.signal.aborted || this.closed;
|
|
259
|
+
const cancelled = bodyCancelled || init.signal?.aborted || context.signal.aborted || this.closed;
|
|
260
|
+
if (!cancelled) {
|
|
261
|
+
const at = Date.now();
|
|
262
|
+
this.stats.lastDisconnect = {
|
|
263
|
+
at, cause, routeKind: context.routeKind,
|
|
264
|
+
connectionAgeMs: Math.max(0, at - (this.openedAt.get(active) ?? at)), eventCount,
|
|
265
|
+
stage: accepted ? "streaming" : "awaiting_first_event",
|
|
266
|
+
...(typeof closeCode === "number" ? { closeCode } : {}),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
255
269
|
// Once create has been sent, a disconnect cannot prove generation
|
|
256
270
|
// did not start. Never replay it, even before the first event.
|
|
257
271
|
const error = cancelled
|
|
@@ -269,8 +283,11 @@ class SocketSession {
|
|
|
269
283
|
}));
|
|
270
284
|
}
|
|
271
285
|
};
|
|
286
|
+
const onSocketClose = (code) => onClose(code);
|
|
287
|
+
const onError = () => onClose(undefined, "transport_error");
|
|
272
288
|
const abort = () => active.terminate();
|
|
273
289
|
const onMessage = (raw, isBinary) => {
|
|
290
|
+
let failureCause = "invalid_event";
|
|
274
291
|
try {
|
|
275
292
|
if (isBinary)
|
|
276
293
|
throw Error("Unexpected binary response");
|
|
@@ -278,6 +295,7 @@ class SocketSession {
|
|
|
278
295
|
const event = JSON.parse(text);
|
|
279
296
|
if (!isRecord(event) || typeof event.type !== "string")
|
|
280
297
|
throw Error("Invalid response event");
|
|
298
|
+
eventCount++;
|
|
281
299
|
if (event.type === "error" && !accepted) {
|
|
282
300
|
finished = true;
|
|
283
301
|
cleanup();
|
|
@@ -297,8 +315,10 @@ class SocketSession {
|
|
|
297
315
|
headers: { ...handshakeHeaders, "content-type": "text/event-stream" },
|
|
298
316
|
}));
|
|
299
317
|
}
|
|
300
|
-
if ((streamController.desiredSize ?? 0) < -this.maxBytes)
|
|
318
|
+
if ((streamController.desiredSize ?? 0) < -this.maxBytes) {
|
|
319
|
+
failureCause = "buffer_limit";
|
|
301
320
|
throw Error("Upstream buffer exceeded");
|
|
321
|
+
}
|
|
302
322
|
streamController.enqueue(Buffer.from(`data: ${text}\n\n`));
|
|
303
323
|
if (isRecord(event.response) &&
|
|
304
324
|
typeof event.response.id === "string") {
|
|
@@ -316,13 +336,13 @@ class SocketSession {
|
|
|
316
336
|
}
|
|
317
337
|
}
|
|
318
338
|
catch {
|
|
339
|
+
onClose(undefined, failureCause);
|
|
319
340
|
active.terminate();
|
|
320
|
-
onClose();
|
|
321
341
|
}
|
|
322
342
|
};
|
|
323
343
|
active.on("message", onMessage);
|
|
324
|
-
active.once("close",
|
|
325
|
-
active.once("error",
|
|
344
|
+
active.once("close", onSocketClose);
|
|
345
|
+
active.once("error", onError);
|
|
326
346
|
init.signal?.addEventListener("abort", abort, { once: true });
|
|
327
347
|
context.signal.addEventListener("abort", abort, { once: true });
|
|
328
348
|
if (init.signal?.aborted || context.signal.aborted) {
|
|
@@ -331,8 +351,10 @@ class SocketSession {
|
|
|
331
351
|
}
|
|
332
352
|
this.stats.upstreamRequests++;
|
|
333
353
|
active.send(JSON.stringify(body), (error) => {
|
|
334
|
-
if (error)
|
|
335
|
-
onClose();
|
|
354
|
+
if (error) {
|
|
355
|
+
onClose(undefined, "send_error");
|
|
356
|
+
active.terminate();
|
|
357
|
+
}
|
|
336
358
|
});
|
|
337
359
|
});
|
|
338
360
|
}
|
|
@@ -548,7 +570,7 @@ export class ResponsesWebSocketGateway {
|
|
|
548
570
|
const controller = new AbortController();
|
|
549
571
|
session.controller = controller;
|
|
550
572
|
const clientBeta = req.headers["openai-beta"];
|
|
551
|
-
this.contexts.set(id, { session, previousId, delta: event.input, signal: controller.signal, clientBeta: typeof clientBeta === "string" ? clientBeta : undefined });
|
|
573
|
+
this.contexts.set(id, { routeKind: pool, session, previousId, delta: event.input, signal: controller.signal, clientBeta: typeof clientBeta === "string" ? clientBeta : undefined });
|
|
552
574
|
const headers = new Headers();
|
|
553
575
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
554
576
|
if (typeof value === "string" &&
|
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
import { type SubscriptionQuotaPreference } from "./subscription-quota-order.js";
|
|
2
|
-
import type
|
|
2
|
+
import { type AccountManager, type ManagedAccount } from "../accounts.js";
|
|
3
3
|
import type { ModelFamily } from "../prompts/codex.js";
|
|
4
4
|
import type { RuntimePolicyDecision } from "../policy/runtime-policy.js";
|
|
5
5
|
import type { SessionAffinityStore } from "../session-affinity.js";
|
|
6
|
+
/**
|
|
7
|
+
* Select a managed credential for transcription without consulting or mutating
|
|
8
|
+
* Codex inference health, quota, cooldown, circuit-breaker, affinity, or cursor
|
|
9
|
+
* state. Transcription shares account authentication and workspace policy with
|
|
10
|
+
* inference, but it is a separately admitted operation and must not inherit
|
|
11
|
+
* model-runtime backpressure.
|
|
12
|
+
*/
|
|
13
|
+
export declare function chooseTranscriptionAccount(params: {
|
|
14
|
+
accountManager: AccountManager;
|
|
15
|
+
attemptedIndexes: ReadonlySet<number>;
|
|
16
|
+
policy: RuntimePolicyDecision | null;
|
|
17
|
+
pinnedIndex: number | null;
|
|
18
|
+
now: number;
|
|
19
|
+
transcriptionRateLimitUntilByRecordId?: ReadonlyMap<string, number>;
|
|
20
|
+
startIndex?: number | null;
|
|
21
|
+
preferredIndex?: number | null;
|
|
22
|
+
fallbackPinnedIndex?: number | null;
|
|
23
|
+
skipReasons?: Map<number, string>;
|
|
24
|
+
}): ManagedAccount | null;
|
|
6
25
|
/**
|
|
7
26
|
* `chooseAccount` is a SYNC selector that internally advances the rotation
|
|
8
27
|
* cursor (the session-affinity-preferred branch and the round-robin fallback
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { compareSubscriptionQuota, usesSubscriptionReserve } from "./subscription-quota-order.js";
|
|
2
|
+
import { AUTH_INVALIDATION_MARKER } from "../accounts.js";
|
|
2
3
|
/**
|
|
3
4
|
* Whether a runtime skip reason is a cooldown, the one blocker a pinned
|
|
4
5
|
* retry may waive. `getManagedAccountRuntimeSkipReason` emits either the
|
|
@@ -7,6 +8,84 @@ import { compareSubscriptionQuota, usesSubscriptionReserve } from "./subscriptio
|
|
|
7
8
|
function isCooldownReason(reason) {
|
|
8
9
|
return reason === "cooling-down" || reason.startsWith("cooling-down:");
|
|
9
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Select a managed credential for transcription without consulting or mutating
|
|
13
|
+
* Codex inference health, quota, cooldown, circuit-breaker, affinity, or cursor
|
|
14
|
+
* state. Transcription shares account authentication and workspace policy with
|
|
15
|
+
* inference, but it is a separately admitted operation and must not inherit
|
|
16
|
+
* model-runtime backpressure.
|
|
17
|
+
*/
|
|
18
|
+
export function chooseTranscriptionAccount(params) {
|
|
19
|
+
const { accountManager, attemptedIndexes, policy, pinnedIndex, now, transcriptionRateLimitUntilByRecordId, startIndex, preferredIndex, fallbackPinnedIndex, skipReasons, } = params;
|
|
20
|
+
/** Return only credential-level blockers; inference runtime health is intentionally ignored. */
|
|
21
|
+
const credentialReason = (index) => {
|
|
22
|
+
const account = accountManager.getAccountByIndex(index);
|
|
23
|
+
if (!account)
|
|
24
|
+
return "missing";
|
|
25
|
+
if (account.enabled === false)
|
|
26
|
+
return "disabled";
|
|
27
|
+
if (!accountManager.hasEnabledWorkspaces(account))
|
|
28
|
+
return "workspace-disabled";
|
|
29
|
+
if (accountManager.isAccountAuthInvalidated(account))
|
|
30
|
+
return AUTH_INVALIDATION_MARKER;
|
|
31
|
+
if (account.cooldownReason === "auth-failure" &&
|
|
32
|
+
typeof account.coolingDownUntil === "number" &&
|
|
33
|
+
account.coolingDownUntil > now)
|
|
34
|
+
return "cooling-down:auth-failure";
|
|
35
|
+
const transcriptionRateLimitUntil = account.recordId
|
|
36
|
+
? transcriptionRateLimitUntilByRecordId?.get(account.recordId)
|
|
37
|
+
: undefined;
|
|
38
|
+
if (typeof transcriptionRateLimitUntil === "number" && transcriptionRateLimitUntil > now) {
|
|
39
|
+
return "transcription-rate-limited";
|
|
40
|
+
}
|
|
41
|
+
if (policy?.blockedAccountIndexes.has(index))
|
|
42
|
+
return "policy-blocked";
|
|
43
|
+
if (attemptedIndexes.has(index))
|
|
44
|
+
return "already-attempted";
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
/** Resolve one candidate while preserving a diagnostic skip reason for rejected credentials. */
|
|
48
|
+
const eligible = (index) => {
|
|
49
|
+
const reason = credentialReason(index);
|
|
50
|
+
if (reason) {
|
|
51
|
+
skipReasons?.set(index, reason);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return accountManager.getAccountByIndex(index);
|
|
55
|
+
};
|
|
56
|
+
if (typeof pinnedIndex === "number")
|
|
57
|
+
return eligible(pinnedIndex);
|
|
58
|
+
if (typeof fallbackPinnedIndex === "number") {
|
|
59
|
+
const fallback = eligible(fallbackPinnedIndex);
|
|
60
|
+
if (fallback)
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
63
|
+
let candidates = accountManager.getAccountsSnapshot().filter((account) => credentialReason(account.index) === null);
|
|
64
|
+
if (!candidates.length) {
|
|
65
|
+
for (const account of accountManager.getAccountsSnapshot()) {
|
|
66
|
+
const reason = credentialReason(account.index);
|
|
67
|
+
if (reason)
|
|
68
|
+
skipReasons?.set(account.index, reason);
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (policy?.priorityByAccount) {
|
|
73
|
+
const tier = Math.min(...candidates.map((account) => policy.priorityByAccount?.[account.index] ?? 1));
|
|
74
|
+
candidates = candidates.filter((account) => (policy.priorityByAccount?.[account.index] ?? 1) === tier);
|
|
75
|
+
}
|
|
76
|
+
if (typeof preferredIndex === "number") {
|
|
77
|
+
const preferred = candidates.find((account) => account.index === preferredIndex);
|
|
78
|
+
if (preferred)
|
|
79
|
+
return accountManager.getAccountByIndex(preferred.index);
|
|
80
|
+
}
|
|
81
|
+
if (typeof startIndex === "number" && candidates.length > 1) {
|
|
82
|
+
const after = candidates.filter((account) => account.index >= startIndex);
|
|
83
|
+
const before = candidates.filter((account) => account.index < startIndex);
|
|
84
|
+
candidates = [...after, ...before];
|
|
85
|
+
}
|
|
86
|
+
const first = candidates[0];
|
|
87
|
+
return first ? accountManager.getAccountByIndex(first.index) : null;
|
|
88
|
+
}
|
|
10
89
|
/**
|
|
11
90
|
* `chooseAccount` is a SYNC selector that internally advances the rotation
|
|
12
91
|
* cursor (the session-affinity-preferred branch and the round-robin fallback
|
|
@@ -58,8 +137,13 @@ export function chooseAccount(params) {
|
|
|
58
137
|
}
|
|
59
138
|
const tier = Math.min(...candidates.map(a => priorities[a.index] ?? 1));
|
|
60
139
|
const tierIndexes = new Set(candidates.filter(a => (priorities[a.index] ?? 1) === tier).map(a => a.index));
|
|
61
|
-
|
|
62
|
-
|
|
140
|
+
// Runtime policy fills default priority 1 even without a configured order.
|
|
141
|
+
const useSavedOrder = schedulingStrategy === "sequential" &&
|
|
142
|
+
Object.values(priorities).some(priority => priority !== 1);
|
|
143
|
+
let preferred = useSavedOrder
|
|
144
|
+
? candidates.find(account => tierIndexes.has(account.index))?.index
|
|
145
|
+
: params.preferredIndex;
|
|
146
|
+
if (quotaByAccount && schedulingStrategy !== "sequential") {
|
|
63
147
|
// Rank only measured accounts: "no data" compares equal to everything, which
|
|
64
148
|
// is not transitive and would make the winner depend on storage order.
|
|
65
149
|
const ranked = candidates.flatMap(a => {
|
|
@@ -75,19 +159,25 @@ export function chooseAccount(params) {
|
|
|
75
159
|
}
|
|
76
160
|
}
|
|
77
161
|
// Re-evaluate tiers on every attempt. Never persist a temporary tier block.
|
|
78
|
-
|
|
162
|
+
const selected = chooseAccount({
|
|
79
163
|
...params,
|
|
80
164
|
policy: {
|
|
81
165
|
...policy,
|
|
82
166
|
priorityByAccount: undefined,
|
|
83
167
|
blockedAccountIndexes: new Set([
|
|
84
168
|
...policy.blockedAccountIndexes,
|
|
85
|
-
|
|
169
|
+
// A retry alone must not make a healthy sequential primary unusable.
|
|
170
|
+
...accountManager.getAccountsSnapshot().filter(a => !tierIndexes.has(a.index) && (schedulingStrategy !== "sequential" || !attemptedIndexes.has(a.index) ||
|
|
171
|
+
quotaByAccount?.[a.index]?.exhausted ||
|
|
172
|
+
(hasNonReserve && usesSubscriptionReserve(quotaByAccount?.[a.index])))).map(a => a.index),
|
|
86
173
|
]),
|
|
87
174
|
},
|
|
88
175
|
fallbackPinnedIndex: null,
|
|
89
176
|
pinnedIndex: typeof preferred === "number" && tierIndexes.has(preferred) ? preferred : null,
|
|
90
177
|
});
|
|
178
|
+
if (selected && schedulingStrategy === "sequential")
|
|
179
|
+
selected.lastUsed = now;
|
|
180
|
+
return selected;
|
|
91
181
|
}
|
|
92
182
|
// Manual pin (from `codex-multi-auth switch <n>`) overrides every other
|
|
93
183
|
// selection signal. We do NOT call markSwitched here — the proxy must not
|
|
@@ -32,6 +32,13 @@ export interface RotationProxyStateInit {
|
|
|
32
32
|
routingMutexMode: "enabled" | "legacy";
|
|
33
33
|
schedulingStrategy: "hybrid" | "sequential";
|
|
34
34
|
fetchImpl: typeof fetch;
|
|
35
|
+
/**
|
|
36
|
+
* The embedder's configured fetcher, captured before `state.fetchImpl` is
|
|
37
|
+
* rebound to the WebSocket gateway. Capability/doc probes must use this —
|
|
38
|
+
* the gateway fetch exists for Responses transport upgrades, and bare
|
|
39
|
+
* `fetch` would bypass a custom fetcher's instrumentation entirely.
|
|
40
|
+
*/
|
|
41
|
+
upstreamFetchImpl: typeof fetch;
|
|
35
42
|
upstreamBaseUrl: string;
|
|
36
43
|
clientApiKey: string;
|
|
37
44
|
now: () => number;
|
|
@@ -96,8 +103,27 @@ export interface RotationProxyState extends RotationProxyStateInit {
|
|
|
96
103
|
lastGlobalSwitchAt: number;
|
|
97
104
|
staleRuntimeReloadPromise: Promise<AccountManager | null> | null;
|
|
98
105
|
lastStaleRuntimeReloadAt: number;
|
|
106
|
+
credentialReloadPromise: Promise<AccountManager | null> | null;
|
|
107
|
+
lastCredentialReloadAt: number;
|
|
108
|
+
transcriptionNextAccountIndex: number;
|
|
109
|
+
readonly transcriptionRateLimitUntilByRecordId: Map<string, number>;
|
|
99
110
|
}
|
|
100
111
|
/** @internal */
|
|
101
112
|
export declare function createRotationProxyState(init: RotationProxyStateInit): RotationProxyState;
|
|
113
|
+
/**
|
|
114
|
+
* Reload account credentials without resetting inference runtime state.
|
|
115
|
+
*
|
|
116
|
+
* This is intentionally narrower than {@link recoverStaleRuntimeState}: it is
|
|
117
|
+
* used when a non-inference operation discovers that its in-memory credential
|
|
118
|
+
* inventory is stale after an external login/enable action. Fresh credential,
|
|
119
|
+
* workspace, enabled and auth-invalidation fields come from disk, while live
|
|
120
|
+
* inference rate-limit windows and non-auth cooldowns are carried forward by
|
|
121
|
+
* stable account identity. An auth-failure cooldown is carried only while the
|
|
122
|
+
* access token is unchanged, so a genuine re-login can repair the credential.
|
|
123
|
+
* Process-global health/token trackers and circuit breakers are never reset.
|
|
124
|
+
*
|
|
125
|
+
* @internal
|
|
126
|
+
*/
|
|
127
|
+
export declare function reloadRuntimeCredentials(state: RotationProxyState): Promise<AccountManager | null>;
|
|
102
128
|
/** @internal */
|
|
103
129
|
export declare function recoverStaleRuntimeState(state: RotationProxyState): Promise<AccountManager | null>;
|