codex-multi-auth 2.22.0 → 2.24.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 +9 -9
- package/LICENSE +21 -21
- package/README.md +159 -150
- package/assets/codex-multi-auth-icon.svg +39 -39
- package/assets/readme-hero.svg +31 -31
- package/config/README.md +31 -31
- package/config/codex-legacy.json +1021 -1021
- package/config/codex-modern.json +347 -347
- package/config/minimal-codex.json +15 -15
- package/config/schema/config.schema.json +8 -0
- package/dist/index.js +33 -10
- package/dist/lib/account-policy.d.ts +2 -0
- package/dist/lib/account-policy.js +3 -1
- package/dist/lib/accounts/rate-limits.d.ts +6 -0
- package/dist/lib/accounts/rate-limits.js +14 -1
- package/dist/lib/accounts.d.ts +6 -1
- package/dist/lib/accounts.js +33 -8
- package/dist/lib/api-route-store.d.ts +1 -0
- package/dist/lib/api-route-store.js +2 -1
- package/dist/lib/auth/account-access.js +2 -1
- package/dist/lib/auth/auth.js +3 -2
- package/dist/lib/auth/device-auth.js +2 -1
- package/dist/lib/auth/http.d.ts +1 -0
- package/dist/lib/auth/http.js +1 -0
- package/dist/lib/auth/token-utils.d.ts +5 -0
- package/dist/lib/auth/token-utils.js +10 -0
- package/dist/lib/codex-manager/account-manager-commands.js +1 -0
- package/dist/lib/codex-manager/commands/account.js +15 -0
- package/dist/lib/codex-manager/commands/locale.d.ts +12 -0
- package/dist/lib/codex-manager/commands/locale.js +25 -0
- package/dist/lib/codex-manager/commands/resets.js +18 -5
- package/dist/lib/codex-manager/commands/status.d.ts +4 -0
- package/dist/lib/codex-manager/commands/status.js +476 -5
- package/dist/lib/codex-manager/commands/workspace.d.ts +4 -2
- package/dist/lib/codex-manager/commands/workspace.js +33 -2
- package/dist/lib/codex-manager/help.js +5 -3
- package/dist/lib/codex-manager.js +112 -30
- package/dist/lib/config.js +3 -0
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/context-budget-response.js +8 -8
- package/dist/lib/context-overflow.js +10 -10
- package/dist/lib/locale.d.ts +7 -0
- package/dist/lib/locale.js +35 -0
- package/dist/lib/oauth-success.html +125 -125
- package/dist/lib/policy/runtime-policy-cache.js +1 -1
- package/dist/lib/policy/runtime-policy.d.ts +1 -0
- package/dist/lib/policy/runtime-policy.js +4 -0
- package/dist/lib/preemptive-quota-scheduler.js +12 -3
- package/dist/lib/prompts/codex-host-bridge.js +138 -138
- package/dist/lib/prompts/codex.js +79 -79
- package/dist/lib/quota-cache.d.ts +4 -0
- package/dist/lib/quota-probe.js +2 -1
- package/dist/lib/quota-readiness.d.ts +1 -1
- package/dist/lib/quota-readiness.js +9 -2
- package/dist/lib/request/proxy-fetch.d.ts +5 -0
- package/dist/lib/request/proxy-fetch.js +37 -0
- package/dist/lib/request/rate-limit-decision.js +1 -1
- package/dist/lib/request/url-rewriting.js +17 -0
- package/dist/lib/runtime/account-model-catalog.d.ts +2 -0
- package/dist/lib/runtime/account-model-catalog.js +7 -2
- package/dist/lib/runtime/account-reset-credits.d.ts +11 -2
- package/dist/lib/runtime/account-reset-credits.js +35 -12
- package/dist/lib/runtime/app-bind.d.ts +4 -1
- package/dist/lib/runtime/app-bind.js +63 -3
- package/dist/lib/runtime/automatic-account-checks.js +6 -5
- package/dist/lib/runtime/automatic-subscription-checks.js +4 -3
- package/dist/lib/runtime/model-discovery-status.js +2 -2
- package/dist/lib/runtime/reset-credit-routing.d.ts +11 -3
- package/dist/lib/runtime/reset-credit-routing.js +66 -21
- package/dist/lib/runtime/reset-credits.d.ts +31 -7
- package/dist/lib/runtime/reset-credits.js +135 -28
- package/dist/lib/runtime/responses-websocket.d.ts +30 -0
- package/dist/lib/runtime/responses-websocket.js +243 -29
- package/dist/lib/runtime/rotation-account-selection.d.ts +5 -4
- package/dist/lib/runtime/rotation-proxy-state.d.ts +3 -0
- package/dist/lib/runtime/rotation-server-types.d.ts +4 -1
- package/dist/lib/runtime/subscription-quota-order.d.ts +1 -0
- package/dist/lib/runtime/subscription-quota-order.js +7 -3
- package/dist/lib/runtime/workspace-model-scopes.d.ts +2 -0
- package/dist/lib/runtime/workspace-model-scopes.js +14 -4
- package/dist/lib/runtime-rotation-proxy.js +138 -38
- package/dist/lib/schemas.d.ts +5 -0
- package/dist/lib/schemas.js +1 -0
- package/dist/lib/storage/snapshot-merge.js +15 -4
- package/dist/lib/web-dashboard-html.d.ts +1 -0
- package/dist/lib/web-dashboard-html.js +66 -0
- package/dist/lib/web-dashboard.d.ts +36 -0
- package/dist/lib/web-dashboard.js +322 -0
- package/node_modules/@codex-ai/plugin/dist/index.d.ts +2 -2
- package/node_modules/@codex-ai/plugin/dist/index.js +2 -2
- package/node_modules/@codex-ai/plugin/dist/tool.d.ts +42 -42
- package/node_modules/@codex-ai/plugin/dist/tool.js +29 -29
- package/node_modules/@codex-ai/plugin/package.json +9 -9
- package/node_modules/@codex-ai/sdk/dist/index.d.ts +4 -4
- package/node_modules/@codex-ai/sdk/dist/index.js +2 -2
- package/node_modules/@codex-ai/sdk/package.json +8 -8
- package/package.json +209 -207
- package/scripts/codex-app-launcher.js +653 -653
- package/scripts/codex-app-router.js +446 -402
- package/scripts/codex-bin-resolver.js +332 -332
- package/scripts/codex-multi-auth.js +65 -65
- package/scripts/codex-routing.js +63 -62
- package/scripts/codex.js +7143 -7006
- package/scripts/install-codex-auth-utils.js +213 -213
- package/scripts/mcodex.js +367 -367
- package/scripts/postinstall.js +114 -114
- package/scripts/preuninstall.js +265 -265
- package/skills/codex-auth-setup/SKILL.md +73 -73
- package/vendor/codex-ai-plugin/dist/index.d.ts +2 -2
- package/vendor/codex-ai-plugin/dist/index.js +2 -2
- package/vendor/codex-ai-plugin/dist/tool.d.ts +42 -42
- package/vendor/codex-ai-plugin/dist/tool.js +29 -29
- package/vendor/codex-ai-plugin/package.json +9 -9
- package/vendor/codex-ai-sdk/dist/index.d.ts +4 -4
- package/vendor/codex-ai-sdk/dist/index.js +2 -2
- package/vendor/codex-ai-sdk/package.json +8 -8
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { MAX_API_ROUTES } from "../api-route-store.js";
|
|
2
|
+
import { OPENAI_HEADER_VALUES } from "../constants.js";
|
|
1
3
|
import { ClientCancellationError } from "../request/client-cancellation.js";
|
|
2
4
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -7,6 +9,13 @@ import { parseModelRoute } from "../model-route-policy.js";
|
|
|
7
9
|
import { isRecord } from "../utils.js";
|
|
8
10
|
import { ResponseOutputHistory, hasOrphanToolResult } from "./response-output-history.js";
|
|
9
11
|
const CONTEXT_HEADER = "x-local-responses-transport";
|
|
12
|
+
const HTTP_FALLBACK_COOLDOWN_MS = 60_000;
|
|
13
|
+
const MAX_HTTP_FALLBACK_SCOPES = 256;
|
|
14
|
+
const HTTP_FALLBACK_FAILURE_THRESHOLD = 2;
|
|
15
|
+
const MAX_CLIENT_SESSIONS = 64;
|
|
16
|
+
// Conservative request ceiling below observed upstream 1009 rejections. This
|
|
17
|
+
// is a local routing guard, not a claim about the upstream's undocumented cap.
|
|
18
|
+
const MAX_UPSTREAM_WEBSOCKET_REQUEST_BYTES = 8 * 1024 * 1024;
|
|
10
19
|
const terminal = new Set([
|
|
11
20
|
"response.completed",
|
|
12
21
|
"response.failed",
|
|
@@ -14,6 +23,28 @@ const terminal = new Set([
|
|
|
14
23
|
"response.cancelled",
|
|
15
24
|
"error",
|
|
16
25
|
]);
|
|
26
|
+
/** A thread-scoped hint only; authentication is checked separately before use. */
|
|
27
|
+
function fallbackScope(req) {
|
|
28
|
+
// Native clients can share a session across threads. Prefer the conversation
|
|
29
|
+
// identity so a failed thread does not downgrade a healthy sibling.
|
|
30
|
+
// Current native clients send hyphenated names; retain older underscore
|
|
31
|
+
// aliases, with thread identity taking priority over a shared session.
|
|
32
|
+
const identity = ["thread-id", "conversation_id", "session-id", "session_id"]
|
|
33
|
+
.map(name => req.headers[name])
|
|
34
|
+
.find((value) => typeof value === "string" && !!value.trim())
|
|
35
|
+
?.trim();
|
|
36
|
+
if (!identity || identity.length > 1024)
|
|
37
|
+
return undefined;
|
|
38
|
+
const bearer = req.headers.authorization?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
|
|
39
|
+
const apiKey = req.headers["x-api-key"];
|
|
40
|
+
const credentials = [...new Set([
|
|
41
|
+
bearer,
|
|
42
|
+
typeof apiKey === "string" ? apiKey.trim() : undefined,
|
|
43
|
+
].filter((value) => !!value))].sort();
|
|
44
|
+
if (!credentials.length)
|
|
45
|
+
return undefined;
|
|
46
|
+
return createHash("sha256").update(JSON.stringify([credentials, identity])).digest("hex");
|
|
47
|
+
}
|
|
17
48
|
/** Headers that describe the synthetic body or the upgrade itself, never the upstream decision. */
|
|
18
49
|
const LOCAL_ONLY_HEADERS = /^(connection|upgrade|keep-alive|sec-websocket-.*|content-length|content-encoding|content-type|transfer-encoding|set-cookie)$/i;
|
|
19
50
|
function upstreamHeaders(source) {
|
|
@@ -32,12 +63,12 @@ function upstreamHeaders(source) {
|
|
|
32
63
|
}
|
|
33
64
|
return headers;
|
|
34
65
|
}
|
|
35
|
-
/**
|
|
66
|
+
/** Remove the router's HTTP-only flag; preserve every explicit client beta. */
|
|
36
67
|
function mergeBeta(current, client) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
return [...new Set(
|
|
68
|
+
const routingFlags = (current ?? "").split(",").map(item => item.trim())
|
|
69
|
+
.filter(item => item && item !== OPENAI_HEADER_VALUES.BETA_RESPONSES);
|
|
70
|
+
const clientFlags = (client ?? "").split(",").map(item => item.trim()).filter(Boolean);
|
|
71
|
+
return [...new Set([...routingFlags, ...clientFlags])].join(", ") || null;
|
|
41
72
|
}
|
|
42
73
|
function wireError(code, status = 400) {
|
|
43
74
|
return {
|
|
@@ -60,24 +91,35 @@ function bodyJson(body) {
|
|
|
60
91
|
throw Error("Invalid response body");
|
|
61
92
|
return value;
|
|
62
93
|
}
|
|
94
|
+
function transportErrorCode(error) {
|
|
95
|
+
const code = isRecord(error) ? error.code : undefined;
|
|
96
|
+
return code === "ECONNRESET" || code === "EPIPE" || code === "ETIMEDOUT" ? code : "OTHER";
|
|
97
|
+
}
|
|
63
98
|
/** Per-client memory only; credentials never leave the existing routing boundary. */
|
|
64
99
|
class SocketSession {
|
|
65
100
|
maxBytes;
|
|
66
101
|
stats;
|
|
67
102
|
httpFetch;
|
|
103
|
+
onDisconnect;
|
|
104
|
+
onComplete;
|
|
105
|
+
needsHttpRecovery;
|
|
106
|
+
httpOnly;
|
|
68
107
|
channels = new Map();
|
|
69
108
|
openedAt = new WeakMap();
|
|
109
|
+
transportEvidence = new WeakMap();
|
|
70
110
|
owners = new Map();
|
|
71
111
|
chains = new Map();
|
|
72
|
-
/** Credentials whose WebSocket handshake was refused; they use HTTP for the session. */
|
|
73
|
-
httpOnly = new Set();
|
|
74
112
|
bytes = 0;
|
|
75
113
|
controller;
|
|
76
114
|
closed = false;
|
|
77
|
-
constructor(maxBytes, stats, httpFetch = fetch) {
|
|
115
|
+
constructor(maxBytes, stats, httpFetch = fetch, onDisconnect, onComplete, needsHttpRecovery = () => false, httpOnly = new Map()) {
|
|
78
116
|
this.maxBytes = maxBytes;
|
|
79
117
|
this.stats = stats;
|
|
80
118
|
this.httpFetch = httpFetch;
|
|
119
|
+
this.onDisconnect = onDisconnect;
|
|
120
|
+
this.onComplete = onComplete;
|
|
121
|
+
this.needsHttpRecovery = needsHttpRecovery;
|
|
122
|
+
this.httpOnly = httpOnly;
|
|
81
123
|
}
|
|
82
124
|
close() {
|
|
83
125
|
this.closed = true;
|
|
@@ -92,7 +134,7 @@ class SocketSession {
|
|
|
92
134
|
remember(id, body, output, pool) {
|
|
93
135
|
const bytes = Buffer.byteLength(JSON.stringify({ body, output }));
|
|
94
136
|
if (bytes > this.maxBytes)
|
|
95
|
-
return;
|
|
137
|
+
return false;
|
|
96
138
|
const old = this.chains.get(id);
|
|
97
139
|
if (old)
|
|
98
140
|
this.bytes -= old.bytes;
|
|
@@ -108,6 +150,21 @@ class SocketSession {
|
|
|
108
150
|
this.chains.delete(first);
|
|
109
151
|
this.owners.delete(first);
|
|
110
152
|
}
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
httpRecovery(input, init, body) {
|
|
156
|
+
// generate=false is a WebSocket-only prewarm control. Never turn a
|
|
157
|
+
// transport warmup into a generating HTTP request. The local chain is
|
|
158
|
+
// remembered by serve(), so the next create can reconstruct its input.
|
|
159
|
+
if (body.generate === false) {
|
|
160
|
+
return Promise.resolve(new Response(`data: ${JSON.stringify({
|
|
161
|
+
type: "response.completed",
|
|
162
|
+
response: { id: `resp_local_prewarm_${randomUUID()}`, object: "response", status: "completed", output: [] },
|
|
163
|
+
})}\n\n`, { headers: { "content-type": "text/event-stream" } }));
|
|
164
|
+
}
|
|
165
|
+
// This is a newly submitted create. Never retry a create already sent
|
|
166
|
+
// over a WebSocket; its generation outcome can be ambiguous.
|
|
167
|
+
return this.httpFetch(input, init);
|
|
111
168
|
}
|
|
112
169
|
async fetch(input, init, context) {
|
|
113
170
|
if (context.signal.aborted || this.closed)
|
|
@@ -115,11 +172,14 @@ class SocketSession {
|
|
|
115
172
|
const url = new URL(String(input));
|
|
116
173
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
117
174
|
const headers = new Headers(init.headers);
|
|
118
|
-
// The
|
|
119
|
-
// WebSocket
|
|
175
|
+
// The OAuth HTTP beta selects an incompatible upstream protocol when
|
|
176
|
+
// combined with the native WebSocket beta (metadata then close 1011).
|
|
177
|
+
// Keep the original client flags, without the router's HTTP injection.
|
|
120
178
|
const beta = mergeBeta(headers.get("openai-beta"), context.clientBeta);
|
|
121
179
|
if (beta)
|
|
122
180
|
headers.set("openai-beta", beta);
|
|
181
|
+
else
|
|
182
|
+
headers.delete("openai-beta");
|
|
123
183
|
const key = createHash("sha256")
|
|
124
184
|
.update(url.toString())
|
|
125
185
|
.update("\0")
|
|
@@ -127,12 +187,15 @@ class SocketSession {
|
|
|
127
187
|
.update("\0")
|
|
128
188
|
.update(headers.get("chatgpt-account-id") ?? "")
|
|
129
189
|
.digest("hex");
|
|
130
|
-
// API/ZDR credentials are not ChatGPT workspaces: when their WebSocket
|
|
131
|
-
// handshake is refused, the turn (and the session) uses plain HTTP.
|
|
132
190
|
const apiCredential = !headers.has("chatgpt-account-id");
|
|
133
|
-
if (apiCredential && this.httpOnly.has(key))
|
|
134
|
-
return this.httpFetch(input, init);
|
|
135
191
|
const body = bodyJson(init.body);
|
|
192
|
+
for (const [credential, until] of this.httpOnly) {
|
|
193
|
+
if (until <= Date.now())
|
|
194
|
+
this.httpOnly.delete(credential);
|
|
195
|
+
}
|
|
196
|
+
if (this.needsHttpRecovery() || (apiCredential && this.httpOnly.has(key))) {
|
|
197
|
+
return this.httpRecovery(input, init, body);
|
|
198
|
+
}
|
|
136
199
|
delete body.stream;
|
|
137
200
|
delete body.background;
|
|
138
201
|
body.store = false;
|
|
@@ -152,6 +215,13 @@ class SocketSession {
|
|
|
152
215
|
else if (context.previousId && hasOrphanToolResult(body.input)) {
|
|
153
216
|
return Response.json(wireError("previous_response_not_found"), { status: 400 });
|
|
154
217
|
}
|
|
218
|
+
const serializedBody = JSON.stringify(body);
|
|
219
|
+
// Measure the actual UTF-8 create after connection-local delta reduction.
|
|
220
|
+
// HTTP receives the original reconstructed request, never a WS-only delta.
|
|
221
|
+
if (Buffer.byteLength(serializedBody) > MAX_UPSTREAM_WEBSOCKET_REQUEST_BYTES) {
|
|
222
|
+
return this.httpRecovery(input, init, body);
|
|
223
|
+
}
|
|
224
|
+
const reusedConnection = !!socket;
|
|
155
225
|
let handshakeHeaders;
|
|
156
226
|
if (!socket) {
|
|
157
227
|
if (context.signal.aborted || this.closed)
|
|
@@ -178,7 +248,16 @@ class SocketSession {
|
|
|
178
248
|
followRedirects: false,
|
|
179
249
|
handshakeTimeout: 15000,
|
|
180
250
|
maxPayload: this.maxBytes,
|
|
181
|
-
|
|
251
|
+
// Offer native compression with modest zlib work and request dictionary
|
|
252
|
+
// resets. Message limits also apply after inflation; peers can decline
|
|
253
|
+
// the extension and continue using WebSockets.
|
|
254
|
+
perMessageDeflate: {
|
|
255
|
+
clientNoContextTakeover: true,
|
|
256
|
+
serverNoContextTakeover: true,
|
|
257
|
+
concurrencyLimit: 4,
|
|
258
|
+
threshold: 1024,
|
|
259
|
+
zlibDeflateOptions: { level: 3 },
|
|
260
|
+
},
|
|
182
261
|
});
|
|
183
262
|
const opening = socket;
|
|
184
263
|
const rejected = await new Promise((resolve, reject) => {
|
|
@@ -195,6 +274,20 @@ class SocketSession {
|
|
|
195
274
|
init.signal?.addEventListener("abort", abort, { once: true });
|
|
196
275
|
opening.once("upgrade", (response) => {
|
|
197
276
|
handshakeHeaders = upstreamHeaders(response.headers);
|
|
277
|
+
// ws consumes TCP/TLS errors without re-emitting them. Observe
|
|
278
|
+
// the public upgrade socket before its close handler runs.
|
|
279
|
+
const tcp = response.socket;
|
|
280
|
+
const evidence = { tcpEndReceived: false };
|
|
281
|
+
this.transportEvidence.set(opening, evidence);
|
|
282
|
+
const onEnd = () => { evidence.tcpEndReceived = true; };
|
|
283
|
+
const onError = (error) => { evidence.transportErrorCode = transportErrorCode(error); };
|
|
284
|
+
tcp.once("end", onEnd);
|
|
285
|
+
tcp.once("error", onError);
|
|
286
|
+
tcp.once("close", (hadError) => {
|
|
287
|
+
evidence.tcpCloseHadError = hadError;
|
|
288
|
+
tcp.off("end", onEnd);
|
|
289
|
+
tcp.off("error", onError);
|
|
290
|
+
});
|
|
198
291
|
});
|
|
199
292
|
opening.once("open", () => {
|
|
200
293
|
cleanup();
|
|
@@ -222,8 +315,13 @@ class SocketSession {
|
|
|
222
315
|
if (rejected) {
|
|
223
316
|
if (!apiCredential)
|
|
224
317
|
return rejected;
|
|
225
|
-
this.httpOnly.
|
|
226
|
-
|
|
318
|
+
if (!this.httpOnly.has(key) && this.httpOnly.size >= MAX_API_ROUTES) {
|
|
319
|
+
const oldest = this.httpOnly.keys().next().value;
|
|
320
|
+
if (oldest)
|
|
321
|
+
this.httpOnly.delete(oldest);
|
|
322
|
+
}
|
|
323
|
+
this.httpOnly.set(key, Date.now() + HTTP_FALLBACK_COOLDOWN_MS);
|
|
324
|
+
return this.httpRecovery(input, init, body);
|
|
227
325
|
}
|
|
228
326
|
this.openedAt.set(socket, Date.now());
|
|
229
327
|
this.channels.set(key, socket);
|
|
@@ -233,6 +331,8 @@ class SocketSession {
|
|
|
233
331
|
let accepted = false, finished = false;
|
|
234
332
|
let bodyCancelled = false;
|
|
235
333
|
let eventCount = 0;
|
|
334
|
+
let sendCompleted = false;
|
|
335
|
+
let requestTransportErrorCode;
|
|
236
336
|
let streamController;
|
|
237
337
|
const stream = new ReadableStream({
|
|
238
338
|
start(controller) {
|
|
@@ -258,12 +358,25 @@ class SocketSession {
|
|
|
258
358
|
this.channels.delete(key);
|
|
259
359
|
const cancelled = bodyCancelled || init.signal?.aborted || context.signal.aborted || this.closed;
|
|
260
360
|
if (!cancelled) {
|
|
361
|
+
this.onDisconnect?.();
|
|
261
362
|
const at = Date.now();
|
|
363
|
+
const transport = this.transportEvidence.get(active);
|
|
364
|
+
const errorCode = transport?.transportErrorCode ?? requestTransportErrorCode;
|
|
262
365
|
this.stats.lastDisconnect = {
|
|
263
366
|
at, cause, routeKind: context.routeKind,
|
|
264
367
|
connectionAgeMs: Math.max(0, at - (this.openedAt.get(active) ?? at)), eventCount,
|
|
265
368
|
stage: accepted ? "streaming" : "awaiting_first_event",
|
|
266
369
|
...(typeof closeCode === "number" ? { closeCode } : {}),
|
|
370
|
+
requestBytes: Buffer.byteLength(serializedBody),
|
|
371
|
+
...(Array.isArray(body.input) ? { inputItemCount: body.input.length } : {}),
|
|
372
|
+
isPrewarm: body.generate === false,
|
|
373
|
+
hasPreviousResponse: typeof body.previous_response_id === "string" && body.previous_response_id.length > 0,
|
|
374
|
+
reusedConnection, sendCompleted,
|
|
375
|
+
compressionNegotiated: active.extensions.split(",").some(extension => extension.trim() === "permessage-deflate"),
|
|
376
|
+
...(transport ? { tcpEndReceived: transport.tcpEndReceived,
|
|
377
|
+
...(transport.tcpCloseHadError !== undefined ? { tcpCloseHadError: transport.tcpCloseHadError } : {}),
|
|
378
|
+
} : {}),
|
|
379
|
+
...(errorCode ? { transportErrorCode: errorCode } : {}),
|
|
267
380
|
};
|
|
268
381
|
}
|
|
269
382
|
// Once create has been sent, a disconnect cannot prove generation
|
|
@@ -284,7 +397,10 @@ class SocketSession {
|
|
|
284
397
|
}
|
|
285
398
|
};
|
|
286
399
|
const onSocketClose = (code) => onClose(code);
|
|
287
|
-
const onError = () =>
|
|
400
|
+
const onError = (error) => {
|
|
401
|
+
requestTransportErrorCode = transportErrorCode(error);
|
|
402
|
+
onClose(undefined, "transport_error");
|
|
403
|
+
};
|
|
288
404
|
const abort = () => active.terminate();
|
|
289
405
|
const onMessage = (raw, isBinary) => {
|
|
290
406
|
let failureCause = "invalid_event";
|
|
@@ -330,6 +446,10 @@ class SocketSession {
|
|
|
330
446
|
}
|
|
331
447
|
}
|
|
332
448
|
if (terminal.has(event.type)) {
|
|
449
|
+
// A prewarm completion proves only connection setup, not that
|
|
450
|
+
// generation works. It must not erase a real disconnect history.
|
|
451
|
+
if (event.type === "response.completed" && body.generate !== false)
|
|
452
|
+
this.onComplete?.();
|
|
333
453
|
finished = true;
|
|
334
454
|
cleanup();
|
|
335
455
|
streamController.close();
|
|
@@ -350,11 +470,14 @@ class SocketSession {
|
|
|
350
470
|
return;
|
|
351
471
|
}
|
|
352
472
|
this.stats.upstreamRequests++;
|
|
353
|
-
active.send(
|
|
473
|
+
active.send(serializedBody, (error) => {
|
|
354
474
|
if (error) {
|
|
475
|
+
requestTransportErrorCode = transportErrorCode(error);
|
|
355
476
|
onClose(undefined, "send_error");
|
|
356
477
|
active.terminate();
|
|
357
478
|
}
|
|
479
|
+
else
|
|
480
|
+
sendCompleted = true;
|
|
358
481
|
});
|
|
359
482
|
});
|
|
360
483
|
}
|
|
@@ -365,12 +488,17 @@ export class ResponsesWebSocketGateway {
|
|
|
365
488
|
options;
|
|
366
489
|
stats = {
|
|
367
490
|
connections: 0,
|
|
491
|
+
activeConnections: 0,
|
|
492
|
+
capacityReclaims: 0,
|
|
368
493
|
upstreamConnections: 0,
|
|
369
494
|
upstreamRequests: 0,
|
|
370
495
|
};
|
|
371
496
|
contexts = new Map();
|
|
372
497
|
scope = new AsyncLocalStorage();
|
|
373
|
-
sessions = new
|
|
498
|
+
sessions = new Map();
|
|
499
|
+
/** Credential-hashed, bounded API handshake backoff survives local reconnects. */
|
|
500
|
+
apiHandshakeBackoff = new Map();
|
|
501
|
+
websocketFailures = new Map();
|
|
374
502
|
wss;
|
|
375
503
|
maxBytes;
|
|
376
504
|
/** Loopback hops must never go through an environment HTTP proxy (NODE_USE_ENV_PROXY). */
|
|
@@ -384,6 +512,22 @@ export class ResponsesWebSocketGateway {
|
|
|
384
512
|
noServer: true,
|
|
385
513
|
maxPayload: this.maxBytes,
|
|
386
514
|
perMessageDeflate: false,
|
|
515
|
+
// ws validates method/key/version/subprotocol before this synchronous gate.
|
|
516
|
+
// An invalid newcomer must never evict an existing usable connection.
|
|
517
|
+
verifyClient: ({ req }, done) => {
|
|
518
|
+
if (req.socket.destroyed || !req.socket.readable || !req.socket.writable) {
|
|
519
|
+
done(false, 503);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (this.sessions.size >= MAX_CLIENT_SESSIONS && !this.reclaimIdleSession()) {
|
|
523
|
+
// Capacity is not an authentication failure. Native Codex handles
|
|
524
|
+
// 426 immediately instead of retrying five rejected handshakes.
|
|
525
|
+
this.recordUpgradeRejection("capacity", 426);
|
|
526
|
+
done(false, 426);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
done(true);
|
|
530
|
+
},
|
|
387
531
|
});
|
|
388
532
|
}
|
|
389
533
|
fetch = async (input, init) => {
|
|
@@ -394,6 +538,54 @@ export class ResponsesWebSocketGateway {
|
|
|
394
538
|
return context.session.fetch(input, init, context);
|
|
395
539
|
return this.upstreamFetch(input, init);
|
|
396
540
|
};
|
|
541
|
+
rememberDisconnect(scope) {
|
|
542
|
+
if (!scope)
|
|
543
|
+
return;
|
|
544
|
+
const now = Date.now();
|
|
545
|
+
for (const [key, failure] of this.websocketFailures) {
|
|
546
|
+
if (failure.until <= now)
|
|
547
|
+
this.websocketFailures.delete(key);
|
|
548
|
+
}
|
|
549
|
+
const count = Math.min((this.websocketFailures.get(scope)?.count ?? 0) + 1, HTTP_FALLBACK_FAILURE_THRESHOLD);
|
|
550
|
+
this.websocketFailures.delete(scope);
|
|
551
|
+
this.websocketFailures.set(scope, { until: now + HTTP_FALLBACK_COOLDOWN_MS, count });
|
|
552
|
+
while (this.websocketFailures.size > MAX_HTTP_FALLBACK_SCOPES) {
|
|
553
|
+
const oldest = this.websocketFailures.keys().next().value;
|
|
554
|
+
if (oldest === undefined)
|
|
555
|
+
break;
|
|
556
|
+
this.websocketFailures.delete(oldest);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
needsHttpFallback(req) {
|
|
560
|
+
const scope = fallbackScope(req);
|
|
561
|
+
if (!scope)
|
|
562
|
+
return false;
|
|
563
|
+
const failure = this.websocketFailures.get(scope);
|
|
564
|
+
if (!failure)
|
|
565
|
+
return false;
|
|
566
|
+
if (failure.until > Date.now())
|
|
567
|
+
return failure.count >= HTTP_FALLBACK_FAILURE_THRESHOLD;
|
|
568
|
+
this.websocketFailures.delete(scope);
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
/** One writer for the upgrade-rejection diagnostic so the two reject paths cannot drift. */
|
|
572
|
+
recordUpgradeRejection(reason, status) {
|
|
573
|
+
this.stats.lastUpgradeRejection = { at: Date.now(), reason, status, activeConnections: this.sessions.size };
|
|
574
|
+
}
|
|
575
|
+
/** Release idle setup sockets only; active work and generated history stay pinned. */
|
|
576
|
+
reclaimIdleSession() {
|
|
577
|
+
for (const [session, client] of this.sessions) {
|
|
578
|
+
if (!client.canReclaim())
|
|
579
|
+
continue;
|
|
580
|
+
this.sessions.delete(session);
|
|
581
|
+
this.stats.activeConnections = this.sessions.size;
|
|
582
|
+
this.stats.capacityReclaims++;
|
|
583
|
+
session.close();
|
|
584
|
+
client.socket.terminate();
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
397
589
|
handle(req, handler) {
|
|
398
590
|
const id = req.headers[CONTEXT_HEADER];
|
|
399
591
|
delete req.headers[CONTEXT_HEADER];
|
|
@@ -416,6 +608,12 @@ export class ResponsesWebSocketGateway {
|
|
|
416
608
|
return;
|
|
417
609
|
socket.end(`HTTP/1.1 ${status} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
418
610
|
};
|
|
611
|
+
const rejectAdmission = (reason, status) => {
|
|
612
|
+
if (socket.destroyed)
|
|
613
|
+
return;
|
|
614
|
+
this.recordUpgradeRejection(reason, status);
|
|
615
|
+
reject(status);
|
|
616
|
+
};
|
|
419
617
|
void (async () => {
|
|
420
618
|
const credentials = {};
|
|
421
619
|
for (const name of ["authorization", "x-api-key"]) {
|
|
@@ -436,7 +634,7 @@ export class ResponsesWebSocketGateway {
|
|
|
436
634
|
return;
|
|
437
635
|
}
|
|
438
636
|
if (req.headers.origin) {
|
|
439
|
-
|
|
637
|
+
rejectAdmission("origin", 403);
|
|
440
638
|
return;
|
|
441
639
|
}
|
|
442
640
|
if (await this.options.upgrade?.(req, socket, head))
|
|
@@ -450,8 +648,11 @@ export class ResponsesWebSocketGateway {
|
|
|
450
648
|
reject(404);
|
|
451
649
|
return;
|
|
452
650
|
}
|
|
453
|
-
|
|
454
|
-
|
|
651
|
+
// Keep native transport on WebSocket during temporary upstream
|
|
652
|
+
// HTTP recovery. At capacity, preserve admission behavior rather
|
|
653
|
+
// than evicting another session just to serve a recovery scope.
|
|
654
|
+
if (this.needsHttpFallback(req) && this.sessions.size >= MAX_CLIENT_SESSIONS) {
|
|
655
|
+
rejectAdmission("repeated_disconnect", 426);
|
|
455
656
|
return;
|
|
456
657
|
}
|
|
457
658
|
if (socket.destroyed)
|
|
@@ -462,10 +663,18 @@ export class ResponsesWebSocketGateway {
|
|
|
462
663
|
}
|
|
463
664
|
serve(ws, req, baseUrl) {
|
|
464
665
|
this.stats.connections++;
|
|
465
|
-
const
|
|
466
|
-
this.
|
|
666
|
+
const scope = fallbackScope(req);
|
|
667
|
+
const session = new SocketSession(this.maxBytes, this.stats, this.upstreamFetch, () => this.rememberDisconnect(scope), () => { if (scope)
|
|
668
|
+
this.websocketFailures.delete(scope); }, () => this.needsHttpFallback(req), this.apiHandshakeBackoff);
|
|
467
669
|
let queue = Promise.resolve(), queued = 0, queuedBytes = 0;
|
|
468
670
|
let generation = 0;
|
|
671
|
+
// Pending work is protected by queued/controller; rejected creates retain no history.
|
|
672
|
+
let hasGeneratedHistory = false;
|
|
673
|
+
this.sessions.set(session, {
|
|
674
|
+
socket: ws,
|
|
675
|
+
canReclaim: () => !hasGeneratedHistory && queued === 0 && !session.controller && !session.closed && ws.readyState === WebSocket.OPEN,
|
|
676
|
+
});
|
|
677
|
+
this.stats.activeConnections = this.sessions.size;
|
|
469
678
|
const send = (event) => {
|
|
470
679
|
if (ws.readyState !== WebSocket.OPEN)
|
|
471
680
|
return;
|
|
@@ -479,6 +688,7 @@ export class ResponsesWebSocketGateway {
|
|
|
479
688
|
ws.once("close", () => {
|
|
480
689
|
session.close();
|
|
481
690
|
this.sessions.delete(session);
|
|
691
|
+
this.stats.activeConnections = this.sessions.size;
|
|
482
692
|
});
|
|
483
693
|
ws.on("message", (raw, isBinary) => {
|
|
484
694
|
let event;
|
|
@@ -622,8 +832,8 @@ export class ResponsesWebSocketGateway {
|
|
|
622
832
|
isRecord(parsed.response) &&
|
|
623
833
|
typeof parsed.response.id === "string") {
|
|
624
834
|
const output = outputHistory.finish(parsed.response.output);
|
|
625
|
-
if (output)
|
|
626
|
-
|
|
835
|
+
if (output && session.remember(parsed.response.id, body, output, pool) && body.generate !== false)
|
|
836
|
+
hasGeneratedHistory = true;
|
|
627
837
|
}
|
|
628
838
|
send(parsed);
|
|
629
839
|
}
|
|
@@ -649,9 +859,13 @@ export class ResponsesWebSocketGateway {
|
|
|
649
859
|
close() {
|
|
650
860
|
for (const ws of this.wss.clients)
|
|
651
861
|
ws.terminate();
|
|
652
|
-
for (const session of this.sessions)
|
|
862
|
+
for (const session of this.sessions.keys())
|
|
653
863
|
session.close();
|
|
864
|
+
this.sessions.clear();
|
|
865
|
+
this.stats.activeConnections = 0;
|
|
654
866
|
this.contexts.clear();
|
|
867
|
+
this.websocketFailures.clear();
|
|
868
|
+
this.apiHandshakeBackoff.clear();
|
|
655
869
|
this.wss.close();
|
|
656
870
|
void this.loopback.close().catch(() => undefined);
|
|
657
871
|
}
|
|
@@ -62,16 +62,17 @@ export declare function chooseAccount(params: {
|
|
|
62
62
|
/**
|
|
63
63
|
* Admit a pinned account that is only blocked by its own cooldown.
|
|
64
64
|
*
|
|
65
|
-
* Set by the proxy for
|
|
66
|
-
*
|
|
67
|
-
*
|
|
65
|
+
* Set by the proxy for RETRY passes, or the first pass when a pin only
|
|
66
|
+
* has a shared network-error cooldown. Independent concurrent work must
|
|
67
|
+
* not fail solely because another request encountered a transport error.
|
|
68
|
+
* Other cooldown reasons still protect the pin from new requests.
|
|
68
69
|
* Conflating them meant every transient branch cooled the pin down before
|
|
69
70
|
* the next selection pass and the budget could never be spent — a pinned
|
|
70
71
|
* request got exactly one upstream attempt under shipped defaults.
|
|
71
72
|
*
|
|
72
73
|
* Only the cooldown is waived. Rate limits, an open circuit, a disabled
|
|
73
74
|
* account, workspace and policy blocks all still reject the pin, and the
|
|
74
|
-
* cooldown itself stays on the account for
|
|
75
|
+
* cooldown itself stays on the account for unpinned selection and the 503's
|
|
75
76
|
* `retry_after_ms`.
|
|
76
77
|
*
|
|
77
78
|
* One ordering note: `getAccountRuntimeSkipReason` reports the FIRST
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ResetExhaustionEvidence } from "./reset-credits.js";
|
|
1
2
|
import type { NativeAccountSnapshot } from "./native-account-storage.js";
|
|
2
3
|
import type { QuotaCacheData, QuotaCacheEntry } from "../quota-cache.js";
|
|
3
4
|
import type { RuntimeCapabilityFailures } from "./runtime-capability-failures.js";
|
|
@@ -98,6 +99,8 @@ export interface RotationProxyState extends RotationProxyStateInit {
|
|
|
98
99
|
catalogApiRoutes?: ApiRouteCredential[];
|
|
99
100
|
catalogInventory?: ModelInventory;
|
|
100
101
|
subscriptionQuotaObservations?: Map<string, QuotaCacheEntry>;
|
|
102
|
+
/** Explicit pre-inference quota rejections, scoped to immutable workspace/model identity. */
|
|
103
|
+
subscriptionQuotaRejections?: Map<string, ResetExhaustionEvidence>;
|
|
101
104
|
readonly threadGoalFallbacks: Map<string, string | null>;
|
|
102
105
|
lastGlobalAccountIndex: number | null;
|
|
103
106
|
lastGlobalSwitchAt: number;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { WebSocketDisconnectDiagnostic } from "./responses-websocket.js";
|
|
1
|
+
import type { WebSocketDisconnectDiagnostic, WebSocketUpgradeRejectionDiagnostic } from "./responses-websocket.js";
|
|
2
2
|
import type { QuotaCacheData } from "../quota-cache.js";
|
|
3
3
|
import type { ApiRouteCredential } from "../api-route-store.js";
|
|
4
4
|
import type { AccountStorageV3 } from "../storage.js";
|
|
@@ -24,6 +24,9 @@ export interface RuntimeRotationProxyStatus {
|
|
|
24
24
|
lastStreamQuotaUpdateAt?: number;
|
|
25
25
|
websocketConnections?: number;
|
|
26
26
|
websocketUpstreamRequests?: number;
|
|
27
|
+
websocketActiveConnections?: number;
|
|
28
|
+
websocketCapacityReclaims?: number;
|
|
29
|
+
lastWebsocketUpgradeRejection?: WebSocketUpgradeRejectionDiagnostic;
|
|
27
30
|
lastWebsocketDisconnect?: WebSocketDisconnectDiagnostic;
|
|
28
31
|
startedAt: number;
|
|
29
32
|
totalRequests: number;
|
|
@@ -9,6 +9,7 @@ export interface SubscriptionQuotaPreference {
|
|
|
9
9
|
}
|
|
10
10
|
export declare const SUBSCRIPTION_RESERVE_PERCENT = 5;
|
|
11
11
|
export declare const SUBSCRIPTION_QUOTA_MAX_AGE_MS: number;
|
|
12
|
+
export declare function hasFreshOrdinaryUsagePermission(entry: QuotaCacheEntry | null | undefined, now: number): boolean;
|
|
12
13
|
/** Scheduling hint only; percentages are not comparable dollar/token balances. */
|
|
13
14
|
export declare function subscriptionQuotaPreference(entry: QuotaCacheEntry | null | undefined, now: number): SubscriptionQuotaPreference;
|
|
14
15
|
export declare function usesSubscriptionReserve(preference: SubscriptionQuotaPreference | undefined): boolean;
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { isQuotaCacheEntryExhausted } from "../quota-readiness.js";
|
|
2
2
|
export const SUBSCRIPTION_RESERVE_PERCENT = 5;
|
|
3
3
|
export const SUBSCRIPTION_QUOTA_MAX_AGE_MS = 15 * 60_000;
|
|
4
|
+
export function hasFreshOrdinaryUsagePermission(entry, now) {
|
|
5
|
+
const checkedAt = entry?.ordinaryUsageCheckedAt ?? entry?.updatedAt;
|
|
6
|
+
return entry?.status === 200 && entry.ordinaryUsageAllowed === true && typeof checkedAt === "number" && Number.isFinite(checkedAt) && checkedAt <= now && now - checkedAt <= 60_000;
|
|
7
|
+
}
|
|
4
8
|
/** Scheduling hint only; percentages are not comparable dollar/token balances. */
|
|
5
9
|
export function subscriptionQuotaPreference(entry, now) {
|
|
6
10
|
const planType = entry?.planType?.trim().toLowerCase();
|
|
7
|
-
const plan = planType === "free" ? "free" : ["plus", "pro", "team", "business", "enterprise", "edu"].includes(planType ?? "") ? "subscription" : "unknown";
|
|
8
|
-
const result = { plan, remainingPercent: null, resetAtMs: null, urgency: null, exhausted: isQuotaCacheEntryExhausted(entry, now), observedAt: entry?.updatedAt ?? null };
|
|
11
|
+
const plan = planType === "free" ? "free" : ["plus", "pro", "promax", "prolite", "team", "business", "self_serve_business_prolite", "enterprise", "edu", "edu_plus", "edu_pro"].includes(planType ?? "") ? "subscription" : "unknown";
|
|
12
|
+
const result = { plan, remainingPercent: null, resetAtMs: null, urgency: null, exhausted: !hasFreshOrdinaryUsagePermission(entry, now) && isQuotaCacheEntryExhausted(entry, now), observedAt: entry?.updatedAt ?? null };
|
|
9
13
|
if (!entry || !Number.isFinite(entry.updatedAt) || entry.updatedAt > now || now - entry.updatedAt > SUBSCRIPTION_QUOTA_MAX_AGE_MS || entry.status !== 200)
|
|
10
14
|
return result;
|
|
11
15
|
const windows = [entry.primary, entry.secondary].filter(w => typeof w.usedPercent === "number" && Number.isFinite(w.usedPercent) && w.usedPercent >= 0 && w.usedPercent <= 100 && !(typeof w.resetAtMs === "number" && w.resetAtMs <= now) && !(w.resetAtMs === undefined && typeof w.windowMinutes === "number" && w.windowMinutes > 0 && now >= entry.updatedAt + w.windowMinutes * 60_000));
|
|
@@ -15,7 +19,7 @@ export function subscriptionQuotaPreference(entry, now) {
|
|
|
15
19
|
if (!window)
|
|
16
20
|
return result;
|
|
17
21
|
result.remainingPercent = 100 - (window.usedPercent ?? 0);
|
|
18
|
-
result.exhausted = result.remainingPercent === 0;
|
|
22
|
+
result.exhausted = result.remainingPercent === 0 && !hasFreshOrdinaryUsagePermission(entry, now);
|
|
19
23
|
if (typeof window.resetAtMs === "number" && Number.isFinite(window.resetAtMs) && window.resetAtMs > now) {
|
|
20
24
|
result.resetAtMs = window.resetAtMs;
|
|
21
25
|
result.urgency = result.remainingPercent / (Math.max(60_000, window.resetAtMs - now) / 3_600_000);
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { type ManagedAccount } from "../accounts.js";
|
|
2
|
+
export { nativeWorkspaceIdentity } from "../auth/token-utils.js";
|
|
2
3
|
export declare function modelScopeId(...parts: string[]): string;
|
|
3
4
|
/** Request scopes are immutable views. Discovery must never switch a saved account. */
|
|
4
5
|
export declare function workspaceModelScopes(account: ManagedAccount): {
|
|
5
6
|
accountId: string;
|
|
6
7
|
workspaceIndex: number;
|
|
7
8
|
enabled: boolean;
|
|
9
|
+
storedWorkspaceId: string | undefined;
|
|
8
10
|
id: string;
|
|
9
11
|
accountIndex: number;
|
|
10
12
|
routable: boolean;
|
|
@@ -1,32 +1,42 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { resolveAccountRecordId } from "../accounts.js";
|
|
3
|
-
import { extractAccountId } from "../auth/token-utils.js";
|
|
3
|
+
import { extractAccountId, nativeWorkspaceIdentity } from "../auth/token-utils.js";
|
|
4
|
+
export { nativeWorkspaceIdentity } from "../auth/token-utils.js";
|
|
4
5
|
export function modelScopeId(...parts) {
|
|
5
6
|
return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
6
7
|
}
|
|
7
8
|
/** Request scopes are immutable views. Discovery must never switch a saved account. */
|
|
8
9
|
export function workspaceModelScopes(account) {
|
|
9
|
-
const boundId = account.accountId?.trim() || extractAccountId(account.access);
|
|
10
|
+
const boundId = nativeWorkspaceIdentity(account, account.accountId?.trim() || extractAccountId(account.access));
|
|
11
|
+
const selectedIndex = account.currentWorkspaceIndex ?? 0;
|
|
10
12
|
const candidates = (account.workspaces ?? []).map((workspace, index) => ({
|
|
11
|
-
accountId: workspace.id.trim(), workspaceIndex: index,
|
|
13
|
+
accountId: index === selectedIndex ? nativeWorkspaceIdentity(account, workspace.id) ?? "" : workspace.id.trim(), workspaceIndex: index,
|
|
12
14
|
enabled: account.enabled !== false && workspace.enabled !== false,
|
|
13
15
|
}));
|
|
14
16
|
if (boundId && !candidates.some(scope => scope.accountId === boundId))
|
|
15
17
|
candidates.unshift({ accountId: boundId, workspaceIndex: -1, enabled: account.enabled !== false });
|
|
16
18
|
if (!candidates.length)
|
|
17
19
|
candidates.push({ accountId: "", workspaceIndex: -1, enabled: account.enabled !== false });
|
|
20
|
+
// A selected alias may resolve to the same identity as an earlier token entry.
|
|
21
|
+
// Keep the selected entry so its enablement and index remain authoritative.
|
|
18
22
|
const seen = new Set();
|
|
19
23
|
return candidates.filter(scope => {
|
|
24
|
+
if (scope.workspaceIndex !== selectedIndex && candidates.some(other => other.workspaceIndex === selectedIndex && other.accountId === scope.accountId))
|
|
25
|
+
return false;
|
|
20
26
|
if (seen.has(scope.accountId))
|
|
21
27
|
return false;
|
|
22
28
|
seen.add(scope.accountId);
|
|
23
29
|
return true;
|
|
24
30
|
}).map(scope => ({
|
|
25
31
|
...scope,
|
|
32
|
+
// Keep the stored identity across async inventory syncs, including Personal aliases.
|
|
33
|
+
storedWorkspaceId: account.workspaces?.[scope.workspaceIndex]?.id,
|
|
26
34
|
// Same record identity as reset-credit targets (resolveAccountRecordId), or their keys diverge.
|
|
27
35
|
id: modelScopeId("oauth", resolveAccountRecordId(account), scope.accountId),
|
|
28
36
|
accountIndex: account.index,
|
|
29
|
-
routable: scope.enabled && Boolean(scope.accountId)
|
|
37
|
+
routable: scope.enabled && Boolean(scope.accountId) && ((account.workspaces?.length ?? 0) > 0
|
|
38
|
+
? scope.workspaceIndex === (account.currentWorkspaceIndex ?? 0)
|
|
39
|
+
: scope.accountId === boundId),
|
|
30
40
|
bound: scope.accountId === boundId,
|
|
31
41
|
selected: scope.workspaceIndex >= 0 && scope.workspaceIndex === (account.currentWorkspaceIndex ?? 0),
|
|
32
42
|
label: `Account ${account.index + 1}${scope.workspaceIndex >= 0 ? ` / Workspace ${scope.workspaceIndex + 1}` : candidates.length > 1 ? " / Stored binding" : ""}`,
|