codex-multi-auth 1.3.2 → 2.0.1
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/README.md +40 -26
- package/config/README.md +4 -4
- package/config/codex-legacy.json +571 -646
- package/config/codex-modern.json +258 -274
- package/config/minimal-codex.json +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +85 -14
- package/dist/index.js.map +1 -1
- package/dist/lib/auto-update-checker.d.ts +23 -1
- package/dist/lib/auto-update-checker.d.ts.map +1 -1
- package/dist/lib/auto-update-checker.js +481 -8
- package/dist/lib/auto-update-checker.js.map +1 -1
- package/dist/lib/cli.d.ts +5 -0
- package/dist/lib/cli.d.ts.map +1 -1
- package/dist/lib/cli.js.map +1 -1
- package/dist/lib/codex-manager/commands/best.js +1 -1
- package/dist/lib/codex-manager/commands/best.js.map +1 -1
- package/dist/lib/codex-manager/commands/forecast.js +3 -3
- package/dist/lib/codex-manager/commands/forecast.js.map +1 -1
- package/dist/lib/codex-manager/commands/report.js +3 -3
- package/dist/lib/codex-manager/commands/report.js.map +1 -1
- package/dist/lib/codex-manager/commands/rotation.d.ts +26 -0
- package/dist/lib/codex-manager/commands/rotation.d.ts.map +1 -0
- package/dist/lib/codex-manager/commands/rotation.js +313 -0
- package/dist/lib/codex-manager/commands/rotation.js.map +1 -0
- package/dist/lib/codex-manager/commands/status.d.ts +6 -0
- package/dist/lib/codex-manager/commands/status.d.ts.map +1 -1
- package/dist/lib/codex-manager/commands/status.js +64 -10
- package/dist/lib/codex-manager/commands/status.js.map +1 -1
- package/dist/lib/codex-manager/forecast-report-commands.js +4 -4
- package/dist/lib/codex-manager/forecast-report-commands.js.map +1 -1
- package/dist/lib/codex-manager/help.d.ts.map +1 -1
- package/dist/lib/codex-manager/help.js +3 -2
- package/dist/lib/codex-manager/help.js.map +1 -1
- package/dist/lib/codex-manager/rate-limit-markers.d.ts +2 -0
- package/dist/lib/codex-manager/rate-limit-markers.d.ts.map +1 -0
- package/dist/lib/codex-manager/rate-limit-markers.js +4 -0
- package/dist/lib/codex-manager/rate-limit-markers.js.map +1 -0
- package/dist/lib/codex-manager/repair-commands.js +3 -3
- package/dist/lib/codex-manager/repair-commands.js.map +1 -1
- package/dist/lib/codex-manager.d.ts.map +1 -1
- package/dist/lib/codex-manager.js +115 -87
- package/dist/lib/codex-manager.js.map +1 -1
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +9 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.d.ts.map +1 -1
- package/dist/lib/constants.js +2 -0
- package/dist/lib/constants.js.map +1 -1
- package/dist/lib/fs-retry.d.ts +7 -0
- package/dist/lib/fs-retry.d.ts.map +1 -0
- package/dist/lib/fs-retry.js +35 -0
- package/dist/lib/fs-retry.js.map +1 -0
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +1 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/logger.d.ts.map +1 -1
- package/dist/lib/logger.js +1 -0
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/prompts/codex.d.ts +1 -1
- package/dist/lib/prompts/codex.d.ts.map +1 -1
- package/dist/lib/prompts/codex.js +3 -3
- package/dist/lib/prompts/codex.js.map +1 -1
- package/dist/lib/quota-probe.js +1 -1
- package/dist/lib/quota-probe.js.map +1 -1
- package/dist/lib/quota-readiness.d.ts +16 -0
- package/dist/lib/quota-readiness.d.ts.map +1 -0
- package/dist/lib/quota-readiness.js +82 -0
- package/dist/lib/quota-readiness.js.map +1 -0
- package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
- package/dist/lib/request/fetch-helpers.js +19 -8
- package/dist/lib/request/fetch-helpers.js.map +1 -1
- package/dist/lib/request/helpers/model-map.d.ts +2 -1
- package/dist/lib/request/helpers/model-map.d.ts.map +1 -1
- package/dist/lib/request/helpers/model-map.js +52 -60
- package/dist/lib/request/helpers/model-map.js.map +1 -1
- package/dist/lib/request/request-transformer.d.ts +2 -2
- package/dist/lib/request/request-transformer.js +4 -3
- package/dist/lib/request/request-transformer.js.map +1 -1
- package/dist/lib/runtime/app-bind.d.ts +74 -0
- package/dist/lib/runtime/app-bind.d.ts.map +1 -0
- package/dist/lib/runtime/app-bind.js +620 -0
- package/dist/lib/runtime/app-bind.js.map +1 -0
- package/dist/lib/runtime/config-toml.d.ts +10 -0
- package/dist/lib/runtime/config-toml.d.ts.map +1 -0
- package/dist/lib/runtime/config-toml.js +128 -0
- package/dist/lib/runtime/config-toml.js.map +1 -0
- package/dist/lib/runtime/login-menu-accounts.d.ts +5 -0
- package/dist/lib/runtime/login-menu-accounts.d.ts.map +1 -1
- package/dist/lib/runtime/login-menu-accounts.js +7 -2
- package/dist/lib/runtime/login-menu-accounts.js.map +1 -1
- package/dist/lib/runtime/quota-probe.js +1 -1
- package/dist/lib/runtime/quota-probe.js.map +1 -1
- package/dist/lib/runtime/runtime-current-account.d.ts +52 -0
- package/dist/lib/runtime/runtime-current-account.d.ts.map +1 -0
- package/dist/lib/runtime/runtime-current-account.js +252 -0
- package/dist/lib/runtime/runtime-current-account.js.map +1 -0
- package/dist/lib/runtime/runtime-observability.d.ts +5 -0
- package/dist/lib/runtime/runtime-observability.d.ts.map +1 -1
- package/dist/lib/runtime/runtime-observability.js +5 -0
- package/dist/lib/runtime/runtime-observability.js.map +1 -1
- package/dist/lib/runtime-constants.d.ts +3 -0
- package/dist/lib/runtime-constants.d.ts.map +1 -0
- package/dist/lib/runtime-constants.js +3 -0
- package/dist/lib/runtime-constants.js.map +1 -0
- package/dist/lib/runtime-rotation-proxy.d.ts +36 -0
- package/dist/lib/runtime-rotation-proxy.d.ts.map +1 -0
- package/dist/lib/runtime-rotation-proxy.js +828 -0
- package/dist/lib/runtime-rotation-proxy.js.map +1 -0
- package/dist/lib/schemas.d.ts +1 -0
- package/dist/lib/schemas.d.ts.map +1 -1
- package/dist/lib/schemas.js +1 -0
- package/dist/lib/schemas.js.map +1 -1
- package/dist/lib/ui/auth-menu.d.ts +6 -1
- package/dist/lib/ui/auth-menu.d.ts.map +1 -1
- package/dist/lib/ui/auth-menu.js +22 -2
- package/dist/lib/ui/auth-menu.js.map +1 -1
- package/package.json +13 -11
- package/scripts/bench-format/codex-host.mjs +142 -73
- package/scripts/bench-format/models.mjs +23 -24
- package/scripts/benchmark-edit-formats.mjs +9 -7
- package/scripts/check-pack-budget-lib.js +49 -6
- package/scripts/codex-app-launcher.js +653 -0
- package/scripts/codex-app-router.js +322 -0
- package/scripts/codex-multi-auth.js +3 -1
- package/scripts/codex-routing.js +6 -0
- package/scripts/codex.js +2428 -445
- package/scripts/postinstall.js +343 -0
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { AccountManager, extractAccountId, } from "./accounts.js";
|
|
4
|
+
import { getFetchTimeoutMs, getNetworkErrorCooldownMs, getRetryAllAccountsMaxRetries, getServerErrorCooldownMs, getSessionAffinity, getSessionAffinityMaxEntries, getSessionAffinityTtlMs, getStreamStallTimeoutMs, getTokenRefreshSkewMs, loadPluginConfig, } from "./config.js";
|
|
5
|
+
import { CODEX_BASE_URL, HTTP_STATUS, OPENAI_HEADERS, OPENAI_HEADER_VALUES, URL_PATHS, } from "./constants.js";
|
|
6
|
+
import { getModelFamily } from "./prompts/codex.js";
|
|
7
|
+
import { queuedRefresh } from "./refresh-queue.js";
|
|
8
|
+
import { mutateRuntimeObservabilitySnapshot } from "./runtime/runtime-observability.js";
|
|
9
|
+
import { SessionAffinityStore } from "./session-affinity.js";
|
|
10
|
+
import { isRecord } from "./utils.js";
|
|
11
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
12
|
+
const DEFAULT_QUOTA_REMAINING_THRESHOLD = 10;
|
|
13
|
+
const DEFAULT_AUTH_FAILURE_COOLDOWN_MS = 30_000;
|
|
14
|
+
const DEFAULT_MAX_RUNTIME_ACCOUNT_ATTEMPTS = 4;
|
|
15
|
+
const MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024;
|
|
16
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
17
|
+
"connection",
|
|
18
|
+
"content-length",
|
|
19
|
+
"expect",
|
|
20
|
+
"keep-alive",
|
|
21
|
+
"proxy-authenticate",
|
|
22
|
+
"proxy-authorization",
|
|
23
|
+
"te",
|
|
24
|
+
"trailer",
|
|
25
|
+
"transfer-encoding",
|
|
26
|
+
"upgrade",
|
|
27
|
+
]);
|
|
28
|
+
const PRIVATE_CLIENT_RESPONSE_HEADERS = new Set([
|
|
29
|
+
"x-codex-multi-auth-account-index",
|
|
30
|
+
"x-codex-multi-auth-account-label",
|
|
31
|
+
"x-codex-multi-auth-account-email",
|
|
32
|
+
"x-codex-multi-auth-account-id",
|
|
33
|
+
]);
|
|
34
|
+
const DECODED_UPSTREAM_RESPONSE_HEADERS = new Set([
|
|
35
|
+
// Node fetch returns decoded bytes while preserving the upstream encoding header.
|
|
36
|
+
"content-encoding",
|
|
37
|
+
]);
|
|
38
|
+
const ALLOWED_RESPONSES_PATHS = new Set([
|
|
39
|
+
URL_PATHS.RESPONSES,
|
|
40
|
+
URL_PATHS.CODEX_RESPONSES,
|
|
41
|
+
`/v1${URL_PATHS.RESPONSES}`,
|
|
42
|
+
`/v1${URL_PATHS.CODEX_RESPONSES}`,
|
|
43
|
+
]);
|
|
44
|
+
const ALLOWED_MODELS_PATHS = new Set([
|
|
45
|
+
URL_PATHS.MODELS,
|
|
46
|
+
`/v1${URL_PATHS.MODELS}`,
|
|
47
|
+
]);
|
|
48
|
+
function isResponsesPath(pathname) {
|
|
49
|
+
return ALLOWED_RESPONSES_PATHS.has(pathname);
|
|
50
|
+
}
|
|
51
|
+
function isModelsPath(pathname) {
|
|
52
|
+
return ALLOWED_MODELS_PATHS.has(pathname);
|
|
53
|
+
}
|
|
54
|
+
function headersFromIncoming(req) {
|
|
55
|
+
const headers = new Headers();
|
|
56
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
57
|
+
if (value === undefined)
|
|
58
|
+
continue;
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
for (const item of value) {
|
|
61
|
+
headers.append(key, item);
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
headers.set(key, value);
|
|
66
|
+
}
|
|
67
|
+
return headers;
|
|
68
|
+
}
|
|
69
|
+
function createOutboundHeaders(incoming, account, accessToken, accountId) {
|
|
70
|
+
const headers = new Headers(incoming);
|
|
71
|
+
for (const name of HOP_BY_HOP_HEADERS) {
|
|
72
|
+
headers.delete(name);
|
|
73
|
+
}
|
|
74
|
+
headers.delete("host");
|
|
75
|
+
headers.delete("x-api-key");
|
|
76
|
+
headers.set("authorization", `Bearer ${accessToken}`);
|
|
77
|
+
headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
|
|
78
|
+
headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
|
|
79
|
+
headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
|
|
80
|
+
return headers;
|
|
81
|
+
}
|
|
82
|
+
function isAuthorizedClient(headers, clientApiKey) {
|
|
83
|
+
const authorization = headers.get("authorization") ?? "";
|
|
84
|
+
const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i);
|
|
85
|
+
const bearer = bearerMatch?.[1]?.trim();
|
|
86
|
+
if (bearer && safeEqual(bearer, clientApiKey))
|
|
87
|
+
return true;
|
|
88
|
+
const apiKey = headers.get("x-api-key");
|
|
89
|
+
return typeof apiKey === "string" && safeEqual(apiKey, clientApiKey);
|
|
90
|
+
}
|
|
91
|
+
function safeEqual(left, right) {
|
|
92
|
+
const leftBuffer = Buffer.from(left, "utf8");
|
|
93
|
+
const rightBuffer = Buffer.from(right, "utf8");
|
|
94
|
+
const compareLength = Math.max(leftBuffer.length, rightBuffer.length, 1);
|
|
95
|
+
const paddedLeft = Buffer.alloc(compareLength);
|
|
96
|
+
const paddedRight = Buffer.alloc(compareLength);
|
|
97
|
+
leftBuffer.copy(paddedLeft);
|
|
98
|
+
rightBuffer.copy(paddedRight);
|
|
99
|
+
return timingSafeEqual(paddedLeft, paddedRight) && leftBuffer.length === rightBuffer.length;
|
|
100
|
+
}
|
|
101
|
+
function readTrimmedString(value) {
|
|
102
|
+
if (typeof value !== "string")
|
|
103
|
+
return null;
|
|
104
|
+
const trimmed = value.trim();
|
|
105
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
106
|
+
}
|
|
107
|
+
function accountIdentityFromAccount(account, updatedAt) {
|
|
108
|
+
return {
|
|
109
|
+
index: account.index,
|
|
110
|
+
label: `Account ${account.index + 1}`,
|
|
111
|
+
accountId: readTrimmedString(account.accountId),
|
|
112
|
+
updatedAt,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function recordLastRuntimeAccount(status, identity) {
|
|
116
|
+
status.lastAccountIndex = identity.index;
|
|
117
|
+
status.lastAccountLabel = identity.label;
|
|
118
|
+
status.lastAccountId = identity.accountId;
|
|
119
|
+
status.lastAccountUpdatedAt = identity.updatedAt;
|
|
120
|
+
mutateRuntimeObservabilitySnapshot((snapshot) => {
|
|
121
|
+
snapshot.lastAccountIndex = identity.index;
|
|
122
|
+
snapshot.lastAccountLabel = identity.label;
|
|
123
|
+
snapshot.lastAccountEmail = null;
|
|
124
|
+
snapshot.lastAccountId = identity.accountId;
|
|
125
|
+
snapshot.lastAccountUpdatedAt = identity.updatedAt;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async function persistRuntimeActiveAccount(accountManager, account, family) {
|
|
129
|
+
try {
|
|
130
|
+
accountManager.markSwitched(account, "rotation", family);
|
|
131
|
+
accountManager.saveToDiskDebounced();
|
|
132
|
+
await accountManager.syncCodexCliActiveSelectionForIndex(account.index);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Runtime forwarding must not fail after a valid upstream response just
|
|
136
|
+
// because the local status mirrors are temporarily locked.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function responseHeadersForClient(upstreamHeaders) {
|
|
140
|
+
const headers = {};
|
|
141
|
+
for (const [key, value] of upstreamHeaders.entries()) {
|
|
142
|
+
const normalizedKey = key.toLowerCase();
|
|
143
|
+
if (HOP_BY_HOP_HEADERS.has(normalizedKey))
|
|
144
|
+
continue;
|
|
145
|
+
if (PRIVATE_CLIENT_RESPONSE_HEADERS.has(normalizedKey))
|
|
146
|
+
continue;
|
|
147
|
+
if (DECODED_UPSTREAM_RESPONSE_HEADERS.has(normalizedKey))
|
|
148
|
+
continue;
|
|
149
|
+
headers[key] = value;
|
|
150
|
+
}
|
|
151
|
+
return headers;
|
|
152
|
+
}
|
|
153
|
+
function createRuntimeProxyHttpError(message, statusCode, code) {
|
|
154
|
+
return Object.assign(new Error(message), { statusCode, code });
|
|
155
|
+
}
|
|
156
|
+
function isRuntimeProxyHttpError(error) {
|
|
157
|
+
return (error instanceof Error &&
|
|
158
|
+
"statusCode" in error &&
|
|
159
|
+
typeof error.statusCode === "number" &&
|
|
160
|
+
"code" in error &&
|
|
161
|
+
typeof error.code === "string");
|
|
162
|
+
}
|
|
163
|
+
async function readRequestBody(req, maxBytes = MAX_REQUEST_BODY_BYTES) {
|
|
164
|
+
const chunks = [];
|
|
165
|
+
let totalBytes = 0;
|
|
166
|
+
for await (const chunk of req) {
|
|
167
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
168
|
+
totalBytes += buffer.byteLength;
|
|
169
|
+
if (totalBytes > maxBytes) {
|
|
170
|
+
throw createRuntimeProxyHttpError("Runtime rotation proxy request body is too large.", HTTP_STATUS.PAYLOAD_TOO_LARGE, "runtime_rotation_proxy_payload_too_large");
|
|
171
|
+
}
|
|
172
|
+
chunks.push(buffer);
|
|
173
|
+
}
|
|
174
|
+
return Buffer.concat(chunks);
|
|
175
|
+
}
|
|
176
|
+
function parseRequestBody(body) {
|
|
177
|
+
if (body.length === 0)
|
|
178
|
+
return null;
|
|
179
|
+
try {
|
|
180
|
+
const parsed = JSON.parse(body.toString("utf8"));
|
|
181
|
+
return isRecord(parsed) ? parsed : null;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function readStringRecordValue(record, key) {
|
|
188
|
+
const value = record[key];
|
|
189
|
+
return typeof value === "string" && value.trim().length > 0
|
|
190
|
+
? value.trim()
|
|
191
|
+
: null;
|
|
192
|
+
}
|
|
193
|
+
function resolveSessionKey(headers, parsedBody) {
|
|
194
|
+
const headerKey = headers.get(OPENAI_HEADERS.SESSION_ID) ??
|
|
195
|
+
headers.get(OPENAI_HEADERS.CONVERSATION_ID) ??
|
|
196
|
+
null;
|
|
197
|
+
if (headerKey && headerKey.trim().length > 0)
|
|
198
|
+
return headerKey.trim();
|
|
199
|
+
if (!parsedBody)
|
|
200
|
+
return null;
|
|
201
|
+
if (typeof parsedBody.prompt_cache_key === "string") {
|
|
202
|
+
const key = parsedBody.prompt_cache_key.trim();
|
|
203
|
+
if (key.length > 0)
|
|
204
|
+
return key;
|
|
205
|
+
}
|
|
206
|
+
if (typeof parsedBody.previous_response_id === "string") {
|
|
207
|
+
const key = parsedBody.previous_response_id.trim();
|
|
208
|
+
if (key.length > 0)
|
|
209
|
+
return key;
|
|
210
|
+
}
|
|
211
|
+
const metadata = parsedBody.metadata;
|
|
212
|
+
if (isRecord(metadata)) {
|
|
213
|
+
return (readStringRecordValue(metadata, "session_id") ??
|
|
214
|
+
readStringRecordValue(metadata, "conversation_id") ??
|
|
215
|
+
readStringRecordValue(metadata, "thread_id"));
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
function buildResponsesRequestContext(req, body) {
|
|
220
|
+
const headers = headersFromIncoming(req);
|
|
221
|
+
const parsedBody = parseRequestBody(body);
|
|
222
|
+
const model = typeof parsedBody?.model === "string" && parsedBody.model.trim().length > 0
|
|
223
|
+
? parsedBody.model.trim()
|
|
224
|
+
: null;
|
|
225
|
+
return {
|
|
226
|
+
body,
|
|
227
|
+
headers,
|
|
228
|
+
method: "POST",
|
|
229
|
+
upstreamPath: URL_PATHS.CODEX_RESPONSES,
|
|
230
|
+
model,
|
|
231
|
+
family: getModelFamily(model ?? "gpt-5.3-codex"),
|
|
232
|
+
stream: parsedBody?.stream === true,
|
|
233
|
+
sessionKey: resolveSessionKey(headers, parsedBody),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function buildModelsRequestContext(req) {
|
|
237
|
+
return {
|
|
238
|
+
body: Buffer.alloc(0),
|
|
239
|
+
headers: headersFromIncoming(req),
|
|
240
|
+
method: "GET",
|
|
241
|
+
upstreamPath: URL_PATHS.MODELS,
|
|
242
|
+
model: null,
|
|
243
|
+
family: "codex",
|
|
244
|
+
stream: false,
|
|
245
|
+
sessionKey: null,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function buildUpstreamUrl(req, upstreamBaseUrl, upstreamPath) {
|
|
249
|
+
const incomingUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
250
|
+
const upstream = new URL(upstreamBaseUrl);
|
|
251
|
+
const basePath = upstream.pathname.replace(/\/+$/, "");
|
|
252
|
+
upstream.pathname = `${basePath}${upstreamPath}`;
|
|
253
|
+
upstream.search = incomingUrl.search;
|
|
254
|
+
return upstream.toString();
|
|
255
|
+
}
|
|
256
|
+
function hasUsableAccessToken(account, now, skewMs) {
|
|
257
|
+
return (typeof account.access === "string" &&
|
|
258
|
+
account.access.trim().length > 0 &&
|
|
259
|
+
typeof account.expires === "number" &&
|
|
260
|
+
account.expires > now + Math.max(0, skewMs));
|
|
261
|
+
}
|
|
262
|
+
function isTokenRefreshRetryable(result) {
|
|
263
|
+
if (result.reason === "network_error" || result.reason === "unknown")
|
|
264
|
+
return true;
|
|
265
|
+
if (result.reason === "invalid_response")
|
|
266
|
+
return true;
|
|
267
|
+
if (result.reason === "http_error") {
|
|
268
|
+
return !(result.statusCode === HTTP_STATUS.BAD_REQUEST ||
|
|
269
|
+
result.statusCode === HTTP_STATUS.UNAUTHORIZED ||
|
|
270
|
+
result.statusCode === HTTP_STATUS.FORBIDDEN);
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
const runtimeRefreshCommitQueues = new WeakMap();
|
|
275
|
+
async function commitRefreshedAuthOnce(accountManager, account, auth) {
|
|
276
|
+
const key = [
|
|
277
|
+
account.index,
|
|
278
|
+
account.accountId ?? "",
|
|
279
|
+
account.email ?? "",
|
|
280
|
+
account.refreshToken,
|
|
281
|
+
].join("\0");
|
|
282
|
+
let queue = runtimeRefreshCommitQueues.get(accountManager);
|
|
283
|
+
if (!queue) {
|
|
284
|
+
queue = new Map();
|
|
285
|
+
runtimeRefreshCommitQueues.set(accountManager, queue);
|
|
286
|
+
}
|
|
287
|
+
const existing = queue.get(key);
|
|
288
|
+
if (existing)
|
|
289
|
+
return existing;
|
|
290
|
+
const pending = accountManager
|
|
291
|
+
.commitRefreshedAuth(account, auth)
|
|
292
|
+
.finally(() => queue?.delete(key));
|
|
293
|
+
queue.set(key, pending);
|
|
294
|
+
return pending;
|
|
295
|
+
}
|
|
296
|
+
async function ensureFreshAccessToken(params) {
|
|
297
|
+
const { accountManager, account, family, model, now, tokenRefreshSkewMs } = params;
|
|
298
|
+
if (hasUsableAccessToken(account, now, tokenRefreshSkewMs)) {
|
|
299
|
+
return { ok: true, accessToken: account.access ?? "", account };
|
|
300
|
+
}
|
|
301
|
+
const refreshResult = await queuedRefresh(account.refreshToken);
|
|
302
|
+
if (refreshResult.type === "failed") {
|
|
303
|
+
accountManager.recordFailure(account, family, model);
|
|
304
|
+
accountManager.incrementAuthFailures(account);
|
|
305
|
+
accountManager.markAccountCoolingDown(account, DEFAULT_AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
|
|
306
|
+
accountManager.saveToDiskDebounced();
|
|
307
|
+
return { ok: false, retryable: isTokenRefreshRetryable(refreshResult) };
|
|
308
|
+
}
|
|
309
|
+
const auth = {
|
|
310
|
+
type: "oauth",
|
|
311
|
+
access: refreshResult.access,
|
|
312
|
+
refresh: refreshResult.refresh,
|
|
313
|
+
expires: refreshResult.expires,
|
|
314
|
+
};
|
|
315
|
+
try {
|
|
316
|
+
const updatedAccount = (await commitRefreshedAuthOnce(accountManager, account, auth)) ?? account;
|
|
317
|
+
return {
|
|
318
|
+
ok: true,
|
|
319
|
+
accessToken: updatedAccount.access ?? refreshResult.access,
|
|
320
|
+
account: updatedAccount,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
accountManager.recordFailure(account, family, model);
|
|
325
|
+
accountManager.markAccountCoolingDown(account, DEFAULT_AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
|
|
326
|
+
accountManager.saveToDiskDebounced();
|
|
327
|
+
return { ok: false, retryable: true };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function resolveAccountId(account, accessToken) {
|
|
331
|
+
const stored = account.accountId?.trim();
|
|
332
|
+
if (stored)
|
|
333
|
+
return stored;
|
|
334
|
+
return extractAccountId(accessToken)?.trim() || null;
|
|
335
|
+
}
|
|
336
|
+
function parseRetryAfterHeaderMs(headers, now) {
|
|
337
|
+
const retryAfterMs = headers.get("retry-after-ms");
|
|
338
|
+
if (retryAfterMs) {
|
|
339
|
+
const parsed = Number.parseInt(retryAfterMs, 10);
|
|
340
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
341
|
+
return parsed;
|
|
342
|
+
}
|
|
343
|
+
const retryAfter = headers.get("retry-after");
|
|
344
|
+
if (!retryAfter)
|
|
345
|
+
return null;
|
|
346
|
+
const asSeconds = Number.parseInt(retryAfter, 10);
|
|
347
|
+
if (Number.isFinite(asSeconds) && asSeconds > 0)
|
|
348
|
+
return asSeconds * 1000;
|
|
349
|
+
const asDate = Date.parse(retryAfter);
|
|
350
|
+
if (Number.isFinite(asDate) && asDate > now)
|
|
351
|
+
return asDate - now;
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
function parseRetryAfterBodyMs(bodyText, now) {
|
|
355
|
+
if (!bodyText.trim())
|
|
356
|
+
return null;
|
|
357
|
+
try {
|
|
358
|
+
const parsed = JSON.parse(bodyText);
|
|
359
|
+
if (!isRecord(parsed) || !isRecord(parsed.error))
|
|
360
|
+
return null;
|
|
361
|
+
const retryAfterMs = Number(parsed.error.retry_after_ms);
|
|
362
|
+
if (Number.isFinite(retryAfterMs) && retryAfterMs > 0)
|
|
363
|
+
return retryAfterMs;
|
|
364
|
+
const retryAfterSeconds = Number(parsed.error.retry_after);
|
|
365
|
+
if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
|
|
366
|
+
return retryAfterSeconds * 1000;
|
|
367
|
+
}
|
|
368
|
+
const resetAtRaw = Number(parsed.error.resets_at ?? parsed.error.reset_at);
|
|
369
|
+
if (Number.isFinite(resetAtRaw) && resetAtRaw > 0) {
|
|
370
|
+
const resetAtMs = resetAtRaw < 10_000_000_000 ? resetAtRaw * 1000 : resetAtRaw;
|
|
371
|
+
if (resetAtMs > now)
|
|
372
|
+
return resetAtMs - now;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
async function readErrorBody(response) {
|
|
381
|
+
try {
|
|
382
|
+
return await response.text();
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return "";
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function getQuotaWindowWaitMs(headers, prefix, now) {
|
|
389
|
+
const resetAfterSeconds = Number.parseInt(headers.get(`${prefix}-reset-after-seconds`) ?? "", 10);
|
|
390
|
+
if (Number.isFinite(resetAfterSeconds) && resetAfterSeconds > 0) {
|
|
391
|
+
return resetAfterSeconds * 1000;
|
|
392
|
+
}
|
|
393
|
+
const resetAtRaw = headers.get(`${prefix}-reset-at`);
|
|
394
|
+
if (!resetAtRaw)
|
|
395
|
+
return 0;
|
|
396
|
+
const trimmed = resetAtRaw.trim();
|
|
397
|
+
let resetAtMs = 0;
|
|
398
|
+
if (/^\d+$/.test(trimmed)) {
|
|
399
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
400
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
401
|
+
resetAtMs = parsed < 10_000_000_000 ? parsed * 1000 : parsed;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
const parsedDate = Date.parse(trimmed);
|
|
406
|
+
if (Number.isFinite(parsedDate))
|
|
407
|
+
resetAtMs = parsedDate;
|
|
408
|
+
}
|
|
409
|
+
return resetAtMs > now ? resetAtMs - now : 0;
|
|
410
|
+
}
|
|
411
|
+
function getQuotaNearExhaustionWaitMs(headers, remainingThreshold, now) {
|
|
412
|
+
const usedThreshold = 100 - Math.max(0, Math.min(100, remainingThreshold));
|
|
413
|
+
const candidates = [];
|
|
414
|
+
for (const prefix of ["x-codex-primary", "x-codex-secondary"]) {
|
|
415
|
+
const used = Number(headers.get(`${prefix}-used-percent`) ?? "");
|
|
416
|
+
if (!Number.isFinite(used) || used < usedThreshold)
|
|
417
|
+
continue;
|
|
418
|
+
const waitMs = getQuotaWindowWaitMs(headers, prefix, now);
|
|
419
|
+
if (waitMs > 0)
|
|
420
|
+
candidates.push(waitMs);
|
|
421
|
+
}
|
|
422
|
+
return candidates.length > 0 ? Math.max(...candidates) : 0;
|
|
423
|
+
}
|
|
424
|
+
function chooseAccount(params) {
|
|
425
|
+
const { accountManager, sessionAffinityStore, sessionKey, family, model, attemptedIndexes, now, } = params;
|
|
426
|
+
const preferredIndex = sessionAffinityStore?.getPreferredAccountIndex(sessionKey, now);
|
|
427
|
+
if (typeof preferredIndex === "number" && !attemptedIndexes.has(preferredIndex)) {
|
|
428
|
+
const preferred = accountManager.getAccountByIndex(preferredIndex);
|
|
429
|
+
if (preferred &&
|
|
430
|
+
accountManager.isAccountAvailableForFamily(preferred.index, family, model)) {
|
|
431
|
+
accountManager.markSwitched(preferred, "rotation", family);
|
|
432
|
+
return preferred;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const selected = accountManager.getCurrentOrNextForFamilyHybrid(family, model);
|
|
436
|
+
if (selected && !attemptedIndexes.has(selected.index))
|
|
437
|
+
return selected;
|
|
438
|
+
for (const account of accountManager.getAccountsSnapshot()) {
|
|
439
|
+
if (attemptedIndexes.has(account.index))
|
|
440
|
+
continue;
|
|
441
|
+
if (accountManager.isAccountAvailableForFamily(account.index, family, model)) {
|
|
442
|
+
const live = accountManager.getAccountByIndex(account.index);
|
|
443
|
+
if (!live)
|
|
444
|
+
continue;
|
|
445
|
+
accountManager.markSwitched(live, "rotation", family);
|
|
446
|
+
return live;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
function writeJson(res, status, payload) {
|
|
452
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
453
|
+
res.end(`${JSON.stringify(payload)}\n`);
|
|
454
|
+
}
|
|
455
|
+
function writeMethodOrPathError(res) {
|
|
456
|
+
writeJson(res, 404, {
|
|
457
|
+
error: {
|
|
458
|
+
message: "Runtime rotation proxy only accepts Responses API and model discovery requests.",
|
|
459
|
+
code: "runtime_rotation_proxy_not_found",
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
function writeUnauthorized(res) {
|
|
464
|
+
writeJson(res, HTTP_STATUS.UNAUTHORIZED, {
|
|
465
|
+
error: {
|
|
466
|
+
message: "Runtime rotation proxy rejected an unauthenticated local request.",
|
|
467
|
+
code: "runtime_rotation_proxy_unauthorized",
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
function normalizeExhaustionStatus(reason) {
|
|
472
|
+
return reason === "rate-limit" ? HTTP_STATUS.TOO_MANY_REQUESTS : 503;
|
|
473
|
+
}
|
|
474
|
+
function writePoolExhausted(params) {
|
|
475
|
+
const { res, accountManager, family, model, reason } = params;
|
|
476
|
+
const waitMs = accountManager.getMinWaitTimeForFamily(family, model);
|
|
477
|
+
writeJson(res, normalizeExhaustionStatus(reason), {
|
|
478
|
+
error: {
|
|
479
|
+
message: "All managed Codex accounts are temporarily unavailable for this runtime request.",
|
|
480
|
+
code: "codex_runtime_rotation_pool_exhausted",
|
|
481
|
+
reason,
|
|
482
|
+
retry_after_ms: waitMs,
|
|
483
|
+
hint: "Run `codex auth rotation status` to inspect account state.",
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
async function withTimeout(promise, timeoutMs, onTimeout, message) {
|
|
488
|
+
let timeout;
|
|
489
|
+
try {
|
|
490
|
+
return await Promise.race([
|
|
491
|
+
promise,
|
|
492
|
+
new Promise((_resolve, reject) => {
|
|
493
|
+
timeout = setTimeout(() => {
|
|
494
|
+
onTimeout();
|
|
495
|
+
reject(new Error(message));
|
|
496
|
+
}, Math.max(1, timeoutMs));
|
|
497
|
+
}),
|
|
498
|
+
]);
|
|
499
|
+
}
|
|
500
|
+
finally {
|
|
501
|
+
if (timeout)
|
|
502
|
+
clearTimeout(timeout);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
async function forwardStreamingResponse(upstream, res, status, onStreamError, streamStallTimeoutMs) {
|
|
506
|
+
status.streamsStarted += 1;
|
|
507
|
+
res.writeHead(upstream.status, responseHeadersForClient(upstream.headers));
|
|
508
|
+
if (!upstream.body) {
|
|
509
|
+
res.end();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const reader = upstream.body.getReader();
|
|
513
|
+
res.on("close", () => {
|
|
514
|
+
if (!res.writableEnded) {
|
|
515
|
+
void reader.cancel().catch(() => undefined);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
try {
|
|
519
|
+
while (true) {
|
|
520
|
+
const { done, value } = await withTimeout(reader.read(), streamStallTimeoutMs, () => {
|
|
521
|
+
void reader.cancel().catch(() => undefined);
|
|
522
|
+
}, `upstream stream stalled after ${streamStallTimeoutMs}ms`);
|
|
523
|
+
if (done)
|
|
524
|
+
break;
|
|
525
|
+
if (value && value.byteLength > 0) {
|
|
526
|
+
res.write(Buffer.from(value));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
res.end();
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
status.lastError = error instanceof Error ? error.message : String(error);
|
|
533
|
+
onStreamError();
|
|
534
|
+
if (!res.destroyed) {
|
|
535
|
+
res.destroy(error instanceof Error ? error : undefined);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
export async function startRuntimeRotationProxy(options) {
|
|
540
|
+
const pluginConfig = loadPluginConfig();
|
|
541
|
+
const accountManager = options.accountManager ?? (await AccountManager.loadFromDisk());
|
|
542
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
543
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
544
|
+
const port = options.port ?? 0;
|
|
545
|
+
const upstreamBaseUrl = options.upstreamBaseUrl ?? CODEX_BASE_URL;
|
|
546
|
+
const clientApiKey = typeof options.clientApiKey === "string" &&
|
|
547
|
+
options.clientApiKey.trim().length > 0
|
|
548
|
+
? options.clientApiKey.trim()
|
|
549
|
+
: null;
|
|
550
|
+
if (!clientApiKey) {
|
|
551
|
+
throw new Error("Runtime rotation proxy requires a clientApiKey.");
|
|
552
|
+
}
|
|
553
|
+
const now = options.now ?? Date.now;
|
|
554
|
+
const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
|
|
555
|
+
const networkErrorCooldownMs = getNetworkErrorCooldownMs(pluginConfig);
|
|
556
|
+
const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig);
|
|
557
|
+
const fetchTimeoutMs = options.fetchTimeoutMs ?? getFetchTimeoutMs(pluginConfig);
|
|
558
|
+
const streamStallTimeoutMs = options.streamStallTimeoutMs ?? getStreamStallTimeoutMs(pluginConfig);
|
|
559
|
+
const configuredMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig);
|
|
560
|
+
const maxRuntimeAccountAttempts = configuredMaxRetries > 0
|
|
561
|
+
? configuredMaxRetries + 1
|
|
562
|
+
: DEFAULT_MAX_RUNTIME_ACCOUNT_ATTEMPTS;
|
|
563
|
+
const maxRequestBodyBytes = options.maxRequestBodyBytes ?? MAX_REQUEST_BODY_BYTES;
|
|
564
|
+
const quotaRemainingPercentThreshold = options.quotaRemainingPercentThreshold ?? DEFAULT_QUOTA_REMAINING_THRESHOLD;
|
|
565
|
+
const sessionAffinityStore = getSessionAffinity(pluginConfig)
|
|
566
|
+
? new SessionAffinityStore({
|
|
567
|
+
ttlMs: getSessionAffinityTtlMs(pluginConfig),
|
|
568
|
+
maxEntries: getSessionAffinityMaxEntries(pluginConfig),
|
|
569
|
+
})
|
|
570
|
+
: null;
|
|
571
|
+
const status = {
|
|
572
|
+
startedAt: now(),
|
|
573
|
+
totalRequests: 0,
|
|
574
|
+
upstreamRequests: 0,
|
|
575
|
+
retries: 0,
|
|
576
|
+
rotations: 0,
|
|
577
|
+
streamsStarted: 0,
|
|
578
|
+
lastError: null,
|
|
579
|
+
lastAccountIndex: null,
|
|
580
|
+
lastAccountLabel: null,
|
|
581
|
+
lastAccountId: null,
|
|
582
|
+
lastAccountUpdatedAt: null,
|
|
583
|
+
};
|
|
584
|
+
const handleRequest = async (req, res) => {
|
|
585
|
+
try {
|
|
586
|
+
const incomingUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
587
|
+
const isResponsesRequest = req.method === "POST" && isResponsesPath(incomingUrl.pathname);
|
|
588
|
+
const isModelsRequest = req.method === "GET" && isModelsPath(incomingUrl.pathname);
|
|
589
|
+
if (!isResponsesRequest && !isModelsRequest) {
|
|
590
|
+
writeMethodOrPathError(res);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const incomingHeaders = headersFromIncoming(req);
|
|
594
|
+
if (!isAuthorizedClient(incomingHeaders, clientApiKey)) {
|
|
595
|
+
writeUnauthorized(res);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
status.totalRequests += 1;
|
|
599
|
+
const context = isModelsRequest
|
|
600
|
+
? buildModelsRequestContext(req)
|
|
601
|
+
: buildResponsesRequestContext(req, await readRequestBody(req, maxRequestBodyBytes));
|
|
602
|
+
const upstreamUrl = buildUpstreamUrl(req, upstreamBaseUrl, context.upstreamPath);
|
|
603
|
+
const attemptedIndexes = new Set();
|
|
604
|
+
let exhaustionReason = "no-account";
|
|
605
|
+
const accountAttemptLimit = Math.max(1, Math.min(accountManager.getAccountCount(), maxRuntimeAccountAttempts));
|
|
606
|
+
while (attemptedIndexes.size < accountAttemptLimit) {
|
|
607
|
+
const selected = chooseAccount({
|
|
608
|
+
accountManager,
|
|
609
|
+
sessionAffinityStore,
|
|
610
|
+
sessionKey: context.sessionKey,
|
|
611
|
+
family: context.family,
|
|
612
|
+
model: context.model,
|
|
613
|
+
attemptedIndexes,
|
|
614
|
+
now: now(),
|
|
615
|
+
});
|
|
616
|
+
if (!selected)
|
|
617
|
+
break;
|
|
618
|
+
attemptedIndexes.add(selected.index);
|
|
619
|
+
if (!accountManager.consumeToken(selected, context.family, context.model)) {
|
|
620
|
+
exhaustionReason = "rate-limit";
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
const refreshed = await ensureFreshAccessToken({
|
|
624
|
+
accountManager,
|
|
625
|
+
account: selected,
|
|
626
|
+
family: context.family,
|
|
627
|
+
model: context.model,
|
|
628
|
+
now: now(),
|
|
629
|
+
tokenRefreshSkewMs,
|
|
630
|
+
});
|
|
631
|
+
if (!refreshed.ok) {
|
|
632
|
+
accountManager.refundToken(selected, context.family, context.model);
|
|
633
|
+
exhaustionReason = "auth-failure";
|
|
634
|
+
if (!refreshed.retryable)
|
|
635
|
+
continue;
|
|
636
|
+
status.retries += 1;
|
|
637
|
+
status.rotations += 1;
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
const accountId = resolveAccountId(refreshed.account, refreshed.accessToken);
|
|
641
|
+
if (!accountId) {
|
|
642
|
+
accountManager.refundToken(refreshed.account, context.family, context.model);
|
|
643
|
+
accountManager.recordFailure(refreshed.account, context.family, context.model);
|
|
644
|
+
accountManager.markAccountCoolingDown(refreshed.account, DEFAULT_AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
|
|
645
|
+
exhaustionReason = "auth-failure";
|
|
646
|
+
status.retries += 1;
|
|
647
|
+
status.rotations += 1;
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const accountIdentity = accountIdentityFromAccount(refreshed.account, now());
|
|
651
|
+
recordLastRuntimeAccount(status, accountIdentity);
|
|
652
|
+
const outboundHeaders = createOutboundHeaders(context.headers, refreshed.account, refreshed.accessToken, accountId);
|
|
653
|
+
let upstream;
|
|
654
|
+
try {
|
|
655
|
+
status.upstreamRequests += 1;
|
|
656
|
+
const fetchAbortController = new AbortController();
|
|
657
|
+
const upstreamRequestInit = {
|
|
658
|
+
method: context.method,
|
|
659
|
+
headers: outboundHeaders,
|
|
660
|
+
signal: fetchAbortController.signal,
|
|
661
|
+
};
|
|
662
|
+
if (context.method === "POST") {
|
|
663
|
+
upstreamRequestInit.body = context.body;
|
|
664
|
+
}
|
|
665
|
+
upstream = await withTimeout(fetchImpl(upstreamUrl, upstreamRequestInit), fetchTimeoutMs, () => fetchAbortController.abort(), `upstream fetch timed out after ${fetchTimeoutMs}ms`);
|
|
666
|
+
}
|
|
667
|
+
catch (error) {
|
|
668
|
+
status.lastError = error instanceof Error ? error.message : String(error);
|
|
669
|
+
accountManager.refundToken(refreshed.account, context.family, context.model);
|
|
670
|
+
accountManager.recordFailure(refreshed.account, context.family, context.model);
|
|
671
|
+
accountManager.markAccountCoolingDown(refreshed.account, networkErrorCooldownMs, "network-error");
|
|
672
|
+
accountManager.saveToDiskDebounced();
|
|
673
|
+
exhaustionReason = "network-error";
|
|
674
|
+
status.retries += 1;
|
|
675
|
+
status.rotations += 1;
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
if (upstream.status === HTTP_STATUS.TOO_MANY_REQUESTS) {
|
|
679
|
+
const bodyText = await readErrorBody(upstream);
|
|
680
|
+
const retryAfterMs = parseRetryAfterHeaderMs(upstream.headers, now()) ??
|
|
681
|
+
parseRetryAfterBodyMs(bodyText, now()) ??
|
|
682
|
+
60_000;
|
|
683
|
+
// A 429 is the upstream quota signal for the attempted account, so
|
|
684
|
+
// keep the consumed runtime token drained.
|
|
685
|
+
accountManager.recordRateLimit(refreshed.account, context.family, context.model);
|
|
686
|
+
accountManager.markRateLimitedWithReason(refreshed.account, retryAfterMs, context.family, "quota", context.model);
|
|
687
|
+
accountManager.saveToDiskDebounced();
|
|
688
|
+
exhaustionReason = "rate-limit";
|
|
689
|
+
status.retries += 1;
|
|
690
|
+
status.rotations += 1;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (upstream.status === HTTP_STATUS.UNAUTHORIZED) {
|
|
694
|
+
await readErrorBody(upstream);
|
|
695
|
+
accountManager.refundToken(refreshed.account, context.family, context.model);
|
|
696
|
+
accountManager.recordFailure(refreshed.account, context.family, context.model);
|
|
697
|
+
accountManager.markAccountCoolingDown(refreshed.account, DEFAULT_AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
|
|
698
|
+
accountManager.saveToDiskDebounced();
|
|
699
|
+
exhaustionReason = "auth-failure";
|
|
700
|
+
status.retries += 1;
|
|
701
|
+
status.rotations += 1;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (upstream.status >= 500) {
|
|
705
|
+
await readErrorBody(upstream);
|
|
706
|
+
accountManager.refundToken(refreshed.account, context.family, context.model);
|
|
707
|
+
accountManager.recordFailure(refreshed.account, context.family, context.model);
|
|
708
|
+
accountManager.markAccountCoolingDown(refreshed.account, serverErrorCooldownMs, "server-error");
|
|
709
|
+
accountManager.saveToDiskDebounced();
|
|
710
|
+
exhaustionReason = "server-error";
|
|
711
|
+
status.retries += 1;
|
|
712
|
+
status.rotations += 1;
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
accountManager.recordSuccess(refreshed.account, context.family, context.model);
|
|
716
|
+
const nearExhaustionWaitMs = getQuotaNearExhaustionWaitMs(upstream.headers, quotaRemainingPercentThreshold, now());
|
|
717
|
+
if (nearExhaustionWaitMs > 0) {
|
|
718
|
+
accountManager.markRateLimitedWithReason(refreshed.account, nearExhaustionWaitMs, context.family, "quota", context.model);
|
|
719
|
+
sessionAffinityStore?.forgetSession(context.sessionKey);
|
|
720
|
+
accountManager.saveToDiskDebounced();
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
sessionAffinityStore?.remember(context.sessionKey, refreshed.account.index, now());
|
|
724
|
+
}
|
|
725
|
+
await persistRuntimeActiveAccount(accountManager, refreshed.account, context.family);
|
|
726
|
+
await forwardStreamingResponse(upstream, res, status, () => {
|
|
727
|
+
accountManager.recordFailure(refreshed.account, context.family, context.model);
|
|
728
|
+
accountManager.markAccountCoolingDown(refreshed.account, networkErrorCooldownMs, "network-error");
|
|
729
|
+
sessionAffinityStore?.forgetSession(context.sessionKey);
|
|
730
|
+
accountManager.saveToDiskDebounced();
|
|
731
|
+
}, streamStallTimeoutMs);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (attemptedIndexes.size >= accountAttemptLimit &&
|
|
735
|
+
accountAttemptLimit < accountManager.getAccountCount()) {
|
|
736
|
+
exhaustionReason = "budget";
|
|
737
|
+
}
|
|
738
|
+
writePoolExhausted({
|
|
739
|
+
res,
|
|
740
|
+
accountManager,
|
|
741
|
+
family: context.family,
|
|
742
|
+
model: context.model,
|
|
743
|
+
reason: exhaustionReason,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
status.lastError = error instanceof Error ? error.message : String(error);
|
|
748
|
+
if (!res.headersSent) {
|
|
749
|
+
if (isRuntimeProxyHttpError(error)) {
|
|
750
|
+
writeJson(res, error.statusCode, {
|
|
751
|
+
error: {
|
|
752
|
+
message: error.message,
|
|
753
|
+
code: error.code,
|
|
754
|
+
},
|
|
755
|
+
});
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
writeJson(res, 500, {
|
|
759
|
+
error: {
|
|
760
|
+
message: "Runtime rotation proxy failed before forwarding the request.",
|
|
761
|
+
code: "codex_runtime_rotation_proxy_error",
|
|
762
|
+
},
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
else if (!res.destroyed) {
|
|
766
|
+
res.destroy(error instanceof Error ? error : undefined);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
const server = createServer((req, res) => {
|
|
771
|
+
void handleRequest(req, res);
|
|
772
|
+
});
|
|
773
|
+
const sockets = new Set();
|
|
774
|
+
server.on("connection", (socket) => {
|
|
775
|
+
sockets.add(socket);
|
|
776
|
+
socket.once("close", () => {
|
|
777
|
+
sockets.delete(socket);
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
const onPostStartupServerError = (error) => {
|
|
781
|
+
status.lastError = error.message;
|
|
782
|
+
};
|
|
783
|
+
await new Promise((resolve, reject) => {
|
|
784
|
+
const onError = (error) => {
|
|
785
|
+
server.off("listening", onListening);
|
|
786
|
+
reject(error);
|
|
787
|
+
};
|
|
788
|
+
const onListening = () => {
|
|
789
|
+
server.off("error", onError);
|
|
790
|
+
resolve();
|
|
791
|
+
};
|
|
792
|
+
server.once("error", onError);
|
|
793
|
+
server.once("listening", onListening);
|
|
794
|
+
server.listen(port, host);
|
|
795
|
+
});
|
|
796
|
+
server.on("error", onPostStartupServerError);
|
|
797
|
+
const address = server.address();
|
|
798
|
+
const resolvedPort = typeof address === "object" && address ? address.port : port;
|
|
799
|
+
return {
|
|
800
|
+
host,
|
|
801
|
+
port: resolvedPort,
|
|
802
|
+
baseUrl: `http://${host}:${resolvedPort}`,
|
|
803
|
+
close: async () => {
|
|
804
|
+
await closeServer(server, sockets);
|
|
805
|
+
await accountManager.flushPendingSave();
|
|
806
|
+
},
|
|
807
|
+
getStatus: () => ({ ...status }),
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
async function closeServer(server, sockets) {
|
|
811
|
+
if (!server.listening)
|
|
812
|
+
return;
|
|
813
|
+
const closed = new Promise((resolve, reject) => {
|
|
814
|
+
server.close((error) => {
|
|
815
|
+
if (error) {
|
|
816
|
+
reject(error);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
resolve();
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
server.closeIdleConnections?.();
|
|
823
|
+
for (const socket of sockets) {
|
|
824
|
+
socket.destroy();
|
|
825
|
+
}
|
|
826
|
+
await closed;
|
|
827
|
+
}
|
|
828
|
+
//# sourceMappingURL=runtime-rotation-proxy.js.map
|