6085 lines
201 KiB
JavaScript
6085 lines
201 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import http from "node:http";
|
||
import { spawn } from "node:child_process";
|
||
import { createHash } from "node:crypto";
|
||
import { chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { DatabaseSync } from "node:sqlite";
|
||
import zlib from "node:zlib";
|
||
import { TextDecoder } from "node:util";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
const ADMIN_BASE_PATH = "/__codex_retry_gateway";
|
||
const UI_PATH = `${ADMIN_BASE_PATH}/ui`;
|
||
const UI_STATIC_ROOT = path.join(__dirname, "public", "ui");
|
||
const STATUS_API_PATH = `${ADMIN_BASE_PATH}/api/status`;
|
||
const CONFIG_API_PATH = `${ADMIN_BASE_PATH}/api/config`;
|
||
const LOGS_API_PATH = `${ADMIN_BASE_PATH}/api/logs`;
|
||
const REQUESTS_API_PATH = `${ADMIN_BASE_PATH}/api/requests`;
|
||
const THREAD_RULES_API_PATH = `${ADMIN_BASE_PATH}/api/thread-rules`;
|
||
const THREAD_RULE_ITEM_API_PREFIX = `${THREAD_RULES_API_PATH}/`;
|
||
const PROFILES_API_PATH = `${ADMIN_BASE_PATH}/api/profiles`;
|
||
const PROFILE_PROBE_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/probe`;
|
||
const PROFILE_SWITCH_API_PATH = `${ADMIN_BASE_PATH}/api/profiles/switch`;
|
||
const PROFILE_ITEM_API_PREFIX = `${ADMIN_BASE_PATH}/api/profiles/`;
|
||
const IMAGE_PROFILES_API_PATH = `${ADMIN_BASE_PATH}/api/image-profiles`;
|
||
const IMAGE_PROFILE_PROBE_API_PATH = `${ADMIN_BASE_PATH}/api/image-profiles/probe`;
|
||
const IMAGE_PROFILE_SWITCH_API_PATH = `${ADMIN_BASE_PATH}/api/image-profiles/switch`;
|
||
const IMAGE_PROFILE_ITEM_API_PREFIX = `${ADMIN_BASE_PATH}/api/image-profiles/`;
|
||
const RESTORE_API_PATH = `${ADMIN_BASE_PATH}/api/restore`;
|
||
const STATUS_REASONING_COUNT_LIMIT = 24;
|
||
const MANAGEMENT_ACCESS_COOKIE = "codex_retry_gateway_access";
|
||
const RESPONSES_REASONING_RETRY_PATHS = new Set(["/responses", "/v1/responses"]);
|
||
const REASONING_RETRY_ABORT_CLIENT = "reasoning_retry_client_disconnected";
|
||
const REASONING_RETRY_ABORT_WINNER = "reasoning_retry_winner_selected";
|
||
|
||
const DEFAULT_CONFIG = {
|
||
profile_name: "default",
|
||
image_profile_name: "",
|
||
listen_host: "127.0.0.1",
|
||
listen_port: 4610,
|
||
upstream_base_url: "",
|
||
upstream_auth_mode: "passthrough",
|
||
upstream_auth_env: "CODEX_RETRY_GATEWAY_UPSTREAM_API_KEY",
|
||
upstream_auth_file: "",
|
||
upstream_auth_json_path: "",
|
||
upstream_auth_json_key: "OPENAI_API_KEY",
|
||
image_base_url: "",
|
||
image_auth_mode: "fixed_bearer",
|
||
image_auth_env: "CODEX_RETRY_GATEWAY_IMAGE_API_KEY",
|
||
image_auth_file: "",
|
||
image_auth_json_path: "",
|
||
image_auth_json_key: "OPENAI_API_KEY",
|
||
request_body_limit_bytes: 1024 * 1024 * 1024,
|
||
request_history_limit: 0,
|
||
model_remap: "",
|
||
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
||
reasoning_match_mode: "formula_518n_minus_2",
|
||
reasoning_equals: [516, 1034, 1552],
|
||
retryable_status_codes: [429, 503],
|
||
retryable_error_messages: [
|
||
"Selected model is at capacity. Please try a different model.",
|
||
"stream disconnected before completion: Concurrency limit exceeded for account, please retry later",
|
||
],
|
||
management_access_key: "",
|
||
upstream_fetch_retry_attempts: 5,
|
||
upstream_fetch_retry_backoff_ms: 350,
|
||
non_stream_status_code: 502,
|
||
stream_action: "strict_502",
|
||
log_match: true,
|
||
health_path: "/__codex_retry_gateway/health",
|
||
};
|
||
|
||
const REASONING_POINTERS = [
|
||
"/usage/output_tokens_details/reasoning_tokens",
|
||
"/usage/completion_tokens_details/reasoning_tokens",
|
||
"/response/usage/output_tokens_details/reasoning_tokens",
|
||
"/response/usage/completion_tokens_details/reasoning_tokens",
|
||
];
|
||
const REQUEST_THREAD_ID_POINTERS = [
|
||
"/thread_id",
|
||
"/client_metadata/thread_id",
|
||
"/client_metadata/x-codex-thread-id",
|
||
"/thread",
|
||
"/thread/id",
|
||
"/threadId",
|
||
"/client_metadata/threadId",
|
||
"/conversation_id",
|
||
"/conversationId",
|
||
"/conversation",
|
||
"/conversation/id",
|
||
"/metadata/thread_id",
|
||
"/metadata/threadId",
|
||
"/metadata/conversation_id",
|
||
"/metadata/conversationId",
|
||
"/x-codex-turn-metadata/thread_id",
|
||
"/x-codex-turn-metadata/threadId",
|
||
"/x-codex-turn-metadata/conversation_id",
|
||
"/x-codex-turn-metadata/conversationId",
|
||
"/previous_response_id",
|
||
];
|
||
const REQUEST_REASONING_EFFORT_POINTERS = [
|
||
"/reasoning/effort",
|
||
"/reasoning_effort",
|
||
"/reasoningEffort",
|
||
];
|
||
const REQUEST_REASONING_SUMMARY_POINTERS = [
|
||
"/reasoning/summary",
|
||
"/reasoning_summary",
|
||
"/reasoningSummary",
|
||
];
|
||
const RESPONSE_THREAD_ID_POINTERS = [
|
||
"/thread_id",
|
||
"/client_metadata/thread_id",
|
||
"/client_metadata/x-codex-thread-id",
|
||
"/thread",
|
||
"/thread/id",
|
||
"/threadId",
|
||
"/client_metadata/threadId",
|
||
"/conversation_id",
|
||
"/conversationId",
|
||
"/conversation",
|
||
"/conversation/id",
|
||
"/response/thread_id",
|
||
"/response/threadId",
|
||
"/response/thread",
|
||
"/response/thread/id",
|
||
"/response/conversation_id",
|
||
"/response/conversationId",
|
||
"/response/conversation",
|
||
"/response/conversation/id",
|
||
];
|
||
const NON_STREAM_RESPONSE_ID_POINTERS = [
|
||
"/id",
|
||
"/response_id",
|
||
"/response/id",
|
||
];
|
||
const STREAM_RESPONSE_ID_POINTERS = [
|
||
"/response_id",
|
||
"/response/id",
|
||
];
|
||
|
||
function parseArgs(argv) {
|
||
const args = { config: null, log: null };
|
||
for (let i = 2; i < argv.length; i += 1) {
|
||
const current = argv[i];
|
||
if (current === "--config") {
|
||
args.config = argv[i + 1];
|
||
i += 1;
|
||
} else if (current === "--log") {
|
||
args.log = argv[i + 1];
|
||
i += 1;
|
||
} else if (current === "--help" || current === "-h") {
|
||
printHelp();
|
||
process.exit(0);
|
||
}
|
||
}
|
||
return args;
|
||
}
|
||
|
||
function printHelp() {
|
||
process.stdout.write(
|
||
[
|
||
"用法:",
|
||
" node gateway.mjs --config <config.json> [--log <gateway.log>]",
|
||
"",
|
||
"说明:",
|
||
" 独立 Codex 本地重试网关。",
|
||
" 默认按 518n-2 公式拦截 reasoning_tokens(516, 1034, 1552, ...),非流式返回 502。",
|
||
" 流式命中时默认缓存并返回 502,避免半截流返回。",
|
||
"",
|
||
].join("\n"),
|
||
);
|
||
}
|
||
|
||
function normalizePath(inputPath) {
|
||
const [withoutQuery] = `${inputPath || "/"}`.split("?");
|
||
const trimmed = withoutQuery.length > 1 ? withoutQuery.replace(/\/+$/, "") : withoutQuery;
|
||
return trimmed || "/";
|
||
}
|
||
|
||
function expandEscapedLineBreaks(value) {
|
||
return `${value ?? ""}`
|
||
.replace(/\\r\\n/g, "\n")
|
||
.replace(/\\n/g, "\n")
|
||
.replace(/\\r/g, "\n");
|
||
}
|
||
|
||
function flattenValues(value) {
|
||
if (Array.isArray(value)) {
|
||
return value.flatMap((item) => flattenValues(item));
|
||
}
|
||
return [value];
|
||
}
|
||
|
||
function isJsonContentType(contentType) {
|
||
return `${contentType || ""}`.toLowerCase().includes("application/json");
|
||
}
|
||
|
||
function isSseContentType(contentType) {
|
||
return `${contentType || ""}`.toLowerCase().includes("text/event-stream");
|
||
}
|
||
|
||
function jsonPointerGet(value, pointer) {
|
||
if (!pointer.startsWith("/")) {
|
||
return undefined;
|
||
}
|
||
return pointer
|
||
.slice(1)
|
||
.split("/")
|
||
.map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"))
|
||
.reduce((current, segment) => {
|
||
if (current === null || current === undefined) {
|
||
return undefined;
|
||
}
|
||
return current[segment];
|
||
}, value);
|
||
}
|
||
|
||
function extractReasoningTokens(payload) {
|
||
for (const pointer of REASONING_POINTERS) {
|
||
const raw = jsonPointerGet(payload, pointer);
|
||
if (Number.isInteger(raw)) {
|
||
return raw;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function firstInteger(...values) {
|
||
for (const value of values) {
|
||
if (Number.isInteger(value)) {
|
||
return value;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function firstNonEmptyString(...values) {
|
||
for (const value of values) {
|
||
if (typeof value !== "string") {
|
||
continue;
|
||
}
|
||
const trimmed = value.trim();
|
||
if (trimmed) {
|
||
return trimmed;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function timingSafeEquals(left, right) {
|
||
const leftText = Buffer.from(`${left || ""}`);
|
||
const rightText = Buffer.from(`${right || ""}`);
|
||
if (leftText.length !== rightText.length) {
|
||
return false;
|
||
}
|
||
let mismatch = 0;
|
||
for (let i = 0; i < leftText.length; i += 1) {
|
||
mismatch |= leftText[i] ^ rightText[i];
|
||
}
|
||
return mismatch === 0;
|
||
}
|
||
|
||
function parseCookieHeader(cookieHeader) {
|
||
const cookies = {};
|
||
for (const part of `${cookieHeader || ""}`.split(";")) {
|
||
const trimmed = part.trim();
|
||
if (!trimmed) {
|
||
continue;
|
||
}
|
||
const separatorIndex = trimmed.indexOf("=");
|
||
if (separatorIndex <= 0) {
|
||
continue;
|
||
}
|
||
const key = trimmed.slice(0, separatorIndex).trim();
|
||
const value = trimmed.slice(separatorIndex + 1).trim();
|
||
cookies[key] = value;
|
||
}
|
||
return cookies;
|
||
}
|
||
|
||
function normalizeManagementAccessKey(value) {
|
||
return `${value || ""}`.trim();
|
||
}
|
||
|
||
function currentManagementAccessKey(config) {
|
||
return normalizeManagementAccessKey(config?.management_access_key);
|
||
}
|
||
|
||
function managementAccessEnabled(config) {
|
||
return Boolean(currentManagementAccessKey(config));
|
||
}
|
||
|
||
function requestManagementAccessKey(req, requestUrl) {
|
||
const headerKey = firstNonEmptyString(
|
||
req.headers["x-codex-retry-gateway-key"],
|
||
req.headers["x-codex-retry-gateway-access-key"],
|
||
req.headers.authorization?.startsWith?.("Bearer ")
|
||
? req.headers.authorization.slice("Bearer ".length)
|
||
: null,
|
||
);
|
||
if (headerKey) {
|
||
return headerKey;
|
||
}
|
||
const queryKey = firstNonEmptyString(
|
||
requestUrl?.searchParams?.get("key"),
|
||
requestUrl?.searchParams?.get("access_key"),
|
||
);
|
||
if (queryKey) {
|
||
return queryKey;
|
||
}
|
||
const cookies = parseCookieHeader(req.headers.cookie || "");
|
||
return firstNonEmptyString(cookies[MANAGEMENT_ACCESS_COOKIE]);
|
||
}
|
||
|
||
function hasManagementAccess(req, requestUrl, config) {
|
||
const expected = currentManagementAccessKey(config);
|
||
if (!expected) {
|
||
return true;
|
||
}
|
||
const provided = requestManagementAccessKey(req, requestUrl);
|
||
return Boolean(provided) && timingSafeEquals(provided, expected);
|
||
}
|
||
|
||
function clearManagementAccessCookieHeaders() {
|
||
return [
|
||
`${MANAGEMENT_ACCESS_COOKIE}=; Path=${ADMIN_BASE_PATH}; HttpOnly; SameSite=Lax; Max-Age=0`,
|
||
];
|
||
}
|
||
|
||
function buildManagementAccessCookieHeaders(config, accessKey) {
|
||
const expected = currentManagementAccessKey(config);
|
||
if (!expected || !accessKey || !timingSafeEquals(accessKey, expected)) {
|
||
return clearManagementAccessCookieHeaders();
|
||
}
|
||
return [
|
||
`${MANAGEMENT_ACCESS_COOKIE}=${accessKey}; Path=${ADMIN_BASE_PATH}; HttpOnly; SameSite=Lax`,
|
||
];
|
||
}
|
||
|
||
function managementUnauthorizedPayload() {
|
||
return {
|
||
error: {
|
||
message: "management access key required",
|
||
code: "management_access_key_required",
|
||
},
|
||
};
|
||
}
|
||
|
||
function extractStringByPointers(payload, pointers) {
|
||
for (const pointer of pointers) {
|
||
const raw = jsonPointerGet(payload, pointer);
|
||
if (typeof raw !== "string") {
|
||
continue;
|
||
}
|
||
const trimmed = raw.trim();
|
||
if (trimmed) {
|
||
return trimmed;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function extractRequestThreadId(payload) {
|
||
return extractStringByPointers(payload, REQUEST_THREAD_ID_POINTERS);
|
||
}
|
||
|
||
function extractRequestReasoningEffort(payload) {
|
||
return extractStringByPointers(payload, REQUEST_REASONING_EFFORT_POINTERS);
|
||
}
|
||
|
||
function extractRequestReasoningSummary(payload) {
|
||
return extractStringByPointers(payload, REQUEST_REASONING_SUMMARY_POINTERS);
|
||
}
|
||
|
||
function extractResponseThreadId(payload) {
|
||
return extractStringByPointers(payload, RESPONSE_THREAD_ID_POINTERS);
|
||
}
|
||
|
||
function extractHeaderThreadId(headers) {
|
||
if (!headers || typeof headers !== "object") {
|
||
return null;
|
||
}
|
||
const candidates = [
|
||
"thread-id",
|
||
"x-client-request-id",
|
||
"conversation_id",
|
||
"conversation-id",
|
||
"session_id",
|
||
"session-id",
|
||
"x-codex-parent-thread-id",
|
||
];
|
||
for (const key of candidates) {
|
||
const raw = headers[key];
|
||
if (Array.isArray(raw)) {
|
||
for (const item of raw) {
|
||
const text = firstNonEmptyString(item);
|
||
if (text) {
|
||
return text;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
const text = firstNonEmptyString(raw);
|
||
if (text) {
|
||
return text;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function extractNonStreamingResponseId(payload) {
|
||
return extractStringByPointers(payload, NON_STREAM_RESPONSE_ID_POINTERS);
|
||
}
|
||
|
||
function extractStreamingResponseId(payload) {
|
||
return extractStringByPointers(payload, STREAM_RESPONSE_ID_POINTERS);
|
||
}
|
||
|
||
function normalizeUsageSnapshot(payload) {
|
||
const usage = payload?.usage || payload?.response?.usage || null;
|
||
if (!usage || typeof usage !== "object") {
|
||
return null;
|
||
}
|
||
|
||
const inputTokens = firstInteger(usage.input_tokens, usage.prompt_tokens);
|
||
const outputTokens = firstInteger(usage.output_tokens, usage.completion_tokens);
|
||
const totalTokens = firstInteger(
|
||
usage.total_tokens,
|
||
inputTokens !== null && outputTokens !== null ? inputTokens + outputTokens : null,
|
||
);
|
||
const reasoningTokens = firstInteger(
|
||
usage.output_tokens_details?.reasoning_tokens,
|
||
usage.completion_tokens_details?.reasoning_tokens,
|
||
payload?.response?.usage?.output_tokens_details?.reasoning_tokens,
|
||
payload?.response?.usage?.completion_tokens_details?.reasoning_tokens,
|
||
);
|
||
const cachedTokens = firstInteger(
|
||
usage.input_tokens_details?.cached_tokens,
|
||
usage.prompt_tokens_details?.cached_tokens,
|
||
payload?.response?.usage?.input_tokens_details?.cached_tokens,
|
||
payload?.response?.usage?.prompt_tokens_details?.cached_tokens,
|
||
);
|
||
|
||
if (
|
||
inputTokens === null &&
|
||
outputTokens === null &&
|
||
totalTokens === null &&
|
||
reasoningTokens === null &&
|
||
cachedTokens === null
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
input_tokens: inputTokens,
|
||
output_tokens: outputTokens,
|
||
total_tokens: totalTokens,
|
||
reasoning_tokens: reasoningTokens,
|
||
cached_tokens: cachedTokens,
|
||
};
|
||
}
|
||
|
||
function mergeUsageSnapshots(current, next) {
|
||
if (!next) {
|
||
return current || null;
|
||
}
|
||
return {
|
||
input_tokens: next.input_tokens ?? current?.input_tokens ?? null,
|
||
output_tokens: next.output_tokens ?? current?.output_tokens ?? null,
|
||
total_tokens: next.total_tokens ?? current?.total_tokens ?? null,
|
||
reasoning_tokens: next.reasoning_tokens ?? current?.reasoning_tokens ?? null,
|
||
cached_tokens: next.cached_tokens ?? current?.cached_tokens ?? null,
|
||
};
|
||
}
|
||
|
||
function sumUsageSnapshots(current, next) {
|
||
if (!next) {
|
||
return current || null;
|
||
}
|
||
const result = { ...(current || {}) };
|
||
for (const key of ["input_tokens", "output_tokens", "total_tokens", "reasoning_tokens", "cached_tokens"]) {
|
||
const nextValue = next[key];
|
||
if (!Number.isInteger(nextValue)) {
|
||
continue;
|
||
}
|
||
result[key] = (Number.isInteger(result[key]) ? result[key] : 0) + nextValue;
|
||
}
|
||
return Object.keys(result).length > 0 ? result : null;
|
||
}
|
||
|
||
function denormalizeUsageSnapshotToResponsesUsage(usage) {
|
||
if (!usage || typeof usage !== "object") {
|
||
return null;
|
||
}
|
||
const payload = {};
|
||
if (Number.isInteger(usage.input_tokens)) {
|
||
payload.input_tokens = usage.input_tokens;
|
||
}
|
||
if (Number.isInteger(usage.output_tokens)) {
|
||
payload.output_tokens = usage.output_tokens;
|
||
}
|
||
if (Number.isInteger(usage.total_tokens)) {
|
||
payload.total_tokens = usage.total_tokens;
|
||
} else if (Number.isInteger(usage.input_tokens) && Number.isInteger(usage.output_tokens)) {
|
||
payload.total_tokens = usage.input_tokens + usage.output_tokens;
|
||
}
|
||
if (Number.isInteger(usage.cached_tokens)) {
|
||
payload.input_tokens_details = {
|
||
cached_tokens: usage.cached_tokens,
|
||
};
|
||
}
|
||
if (Number.isInteger(usage.reasoning_tokens)) {
|
||
payload.output_tokens_details = {
|
||
reasoning_tokens: usage.reasoning_tokens,
|
||
};
|
||
}
|
||
return Object.keys(payload).length > 0 ? payload : null;
|
||
}
|
||
|
||
function cloneJsonLike(value) {
|
||
if (value === null || value === undefined) {
|
||
return value;
|
||
}
|
||
return JSON.parse(JSON.stringify(value));
|
||
}
|
||
|
||
function buildSseBlock(eventName, payloadText) {
|
||
const lines = [];
|
||
if (`${eventName || ""}`.trim()) {
|
||
lines.push(`event: ${`${eventName}`.trim()}`);
|
||
}
|
||
lines.push(`data: ${payloadText}`);
|
||
return Buffer.from(`${lines.join("\n")}\n\n`);
|
||
}
|
||
|
||
const RESPONSES_MODEL_HEADER_NAMES = new Set(["openai-model", "x-openai-model"]);
|
||
|
||
function normalizeResponsesHeaderSubset(...sources) {
|
||
const headers = {};
|
||
for (const source of sources) {
|
||
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
||
continue;
|
||
}
|
||
for (const [key, value] of Object.entries(source)) {
|
||
if (!RESPONSES_MODEL_HEADER_NAMES.has(`${key}`.toLowerCase())) {
|
||
continue;
|
||
}
|
||
headers[key] = cloneJsonLike(value);
|
||
}
|
||
}
|
||
return Object.keys(headers).length > 0 ? headers : null;
|
||
}
|
||
|
||
function createEmptySseInspectionResult() {
|
||
return {
|
||
reasoning: null,
|
||
usage: null,
|
||
response_id: null,
|
||
thread_id: null,
|
||
retryable_upstream_error: null,
|
||
};
|
||
}
|
||
|
||
function mergeSseInspectionResult(target, next) {
|
||
if (Number.isInteger(next.reasoning)) {
|
||
target.reasoning = next.reasoning;
|
||
}
|
||
target.usage = mergeUsageSnapshots(target.usage, next.usage);
|
||
target.response_id = target.response_id || next.response_id || null;
|
||
target.thread_id = target.thread_id || next.thread_id || null;
|
||
target.retryable_upstream_error = next.retryable_upstream_error || target.retryable_upstream_error || null;
|
||
return target;
|
||
}
|
||
|
||
function inspectSseBlocks(blocks, config) {
|
||
const result = createEmptySseInspectionResult();
|
||
for (const block of blocks) {
|
||
const lines = `${block || ""}`
|
||
.split(/\r?\n/)
|
||
.map((line) => line.trimEnd())
|
||
.filter(Boolean);
|
||
const eventName = lines
|
||
.filter((line) => line.startsWith("event:"))
|
||
.map((line) => line.replace(/^event:\s?/, "").trim())
|
||
.find(Boolean) || "";
|
||
const dataLines = lines
|
||
.filter((line) => line.startsWith("data:"))
|
||
.map((line) => line.replace(/^data:\s?/, ""));
|
||
|
||
if (dataLines.length === 0) {
|
||
continue;
|
||
}
|
||
const payloadText = dataLines.join("\n");
|
||
if (payloadText === "[DONE]") {
|
||
continue;
|
||
}
|
||
let parsed = null;
|
||
try {
|
||
parsed = JSON.parse(payloadText);
|
||
const reasoning = extractReasoningTokens(parsed);
|
||
if (reasoning !== null) {
|
||
result.reasoning = reasoning;
|
||
}
|
||
result.usage = mergeUsageSnapshots(result.usage, normalizeUsageSnapshot(parsed));
|
||
result.response_id = result.response_id || extractStreamingResponseId(parsed) || extractNonStreamingResponseId(parsed);
|
||
result.thread_id = result.thread_id || extractResponseThreadId(parsed);
|
||
} catch {
|
||
// ignore malformed SSE payloads
|
||
}
|
||
const retryableUpstreamError = findRetryableStreamErrorMatch(
|
||
config,
|
||
parsed,
|
||
payloadText,
|
||
eventName,
|
||
);
|
||
if (retryableUpstreamError) {
|
||
result.retryable_upstream_error = retryableUpstreamError;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function flushSseInspectionRemainder(state, config) {
|
||
if (!state?.buffer || !state.buffer.trim()) {
|
||
state.buffer = "";
|
||
return createEmptySseInspectionResult();
|
||
}
|
||
const remainder = state.buffer;
|
||
state.buffer = "";
|
||
return inspectSseBlocks([remainder], config);
|
||
}
|
||
|
||
function createResponsesCodexSseState() {
|
||
return {
|
||
decoder: new TextDecoder("utf8"),
|
||
buffer: "",
|
||
saw_response_completed: false,
|
||
saw_terminal_failure: false,
|
||
response_id: null,
|
||
usage: null,
|
||
};
|
||
}
|
||
|
||
function normalizeResponsesFailedPayloadForCodex(parsed, eventName = "", fallbackResponseId = null) {
|
||
const eventType = firstNonEmptyString(parsed?.type, eventName);
|
||
if (eventType !== "error" && eventType !== "response.failed") {
|
||
return null;
|
||
}
|
||
const normalized = cloneJsonLike(parsed) || {};
|
||
normalized.type = "response.failed";
|
||
if (!normalized.response || typeof normalized.response !== "object" || Array.isArray(normalized.response)) {
|
||
normalized.response = {};
|
||
}
|
||
if (!firstNonEmptyString(normalized.response.status)) {
|
||
normalized.response.status = "failed";
|
||
}
|
||
const responseId = firstNonEmptyString(
|
||
normalized.response.id,
|
||
normalized.id,
|
||
fallbackResponseId,
|
||
);
|
||
if (responseId && !firstNonEmptyString(normalized.response.id)) {
|
||
normalized.response.id = responseId;
|
||
}
|
||
if (!normalized.response.error || typeof normalized.response.error !== "object" || Array.isArray(normalized.response.error)) {
|
||
if (normalized.error && typeof normalized.error === "object" && !Array.isArray(normalized.error)) {
|
||
normalized.response.error = cloneJsonLike(normalized.error);
|
||
} else {
|
||
const fallbackError = {};
|
||
const message = firstNonEmptyString(normalized.message, normalized.error_message);
|
||
const code = firstNonEmptyString(normalized.code, normalized.error_code);
|
||
const errorType = firstNonEmptyString(normalized.error_type, normalized.type);
|
||
if (message) {
|
||
fallbackError.message = message;
|
||
}
|
||
if (code) {
|
||
fallbackError.code = code;
|
||
}
|
||
if (errorType) {
|
||
fallbackError.type = errorType;
|
||
}
|
||
if (Object.keys(fallbackError).length > 0) {
|
||
normalized.response.error = fallbackError;
|
||
}
|
||
}
|
||
}
|
||
for (const key of ["usage", "output", "incomplete_details", "metadata"]) {
|
||
if (normalized.response[key] === undefined && normalized[key] !== undefined) {
|
||
normalized.response[key] = cloneJsonLike(normalized[key]);
|
||
}
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function normalizeResponsesLifecyclePayloadForCodex(parsed, eventName = "", fallbackResponseId = null) {
|
||
const eventType = firstNonEmptyString(parsed?.type, eventName);
|
||
if (
|
||
eventType !== "response.created" &&
|
||
eventType !== "response.in_progress" &&
|
||
eventType !== "response.completed" &&
|
||
eventType !== "response.done"
|
||
) {
|
||
return null;
|
||
}
|
||
const normalizedType = eventType === "response.done" ? "response.completed" : eventType;
|
||
const normalized = {
|
||
type: normalizedType,
|
||
response: {},
|
||
};
|
||
const responseId = firstNonEmptyString(
|
||
parsed?.response?.id,
|
||
parsed?.id,
|
||
fallbackResponseId,
|
||
);
|
||
if (responseId) {
|
||
normalized.response.id = responseId;
|
||
}
|
||
const status = firstNonEmptyString(parsed?.response?.status);
|
||
if (status) {
|
||
normalized.response.status = status;
|
||
}
|
||
const headers = normalizeResponsesHeaderSubset(parsed?.response?.headers, parsed?.headers);
|
||
if (headers) {
|
||
normalized.response.headers = headers;
|
||
}
|
||
if (normalizedType === "response.completed") {
|
||
const usage = cloneJsonLike(parsed?.response?.usage ?? parsed?.usage);
|
||
if (usage !== undefined) {
|
||
normalized.response.usage = usage;
|
||
}
|
||
if (parsed?.response?.end_turn !== undefined) {
|
||
normalized.response.end_turn = parsed.response.end_turn;
|
||
} else if (parsed?.end_turn !== undefined) {
|
||
normalized.response.end_turn = parsed.end_turn;
|
||
}
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function normalizeResponsesCompletedPayloadForCodex(parsed, eventName = "", fallbackResponseId = null) {
|
||
const eventType = firstNonEmptyString(parsed?.type, eventName);
|
||
if (eventType !== "response.done" && eventType !== "response.completed") {
|
||
return null;
|
||
}
|
||
return normalizeResponsesLifecyclePayloadForCodex(parsed, eventName, fallbackResponseId);
|
||
}
|
||
|
||
function processResponsesSseBlockForCodex(state, blockText, fallbackResponseId = null) {
|
||
const lines = `${blockText || ""}`
|
||
.split(/\r?\n/)
|
||
.map((line) => line.trimEnd());
|
||
const eventName = lines
|
||
.filter((line) => line.startsWith("event:"))
|
||
.map((line) => line.replace(/^event:\s?/, "").trim())
|
||
.find(Boolean) || "";
|
||
const dataLines = lines
|
||
.filter((line) => line.startsWith("data:"))
|
||
.map((line) => line.replace(/^data:\s?/, ""));
|
||
|
||
if (dataLines.length === 0) {
|
||
return Buffer.from(`${lines.join("\n")}\n\n`);
|
||
}
|
||
|
||
const payloadText = dataLines.join("\n");
|
||
if (payloadText === "[DONE]") {
|
||
return buildSseBlock(eventName, payloadText);
|
||
}
|
||
|
||
let parsed = null;
|
||
try {
|
||
parsed = JSON.parse(payloadText);
|
||
} catch {
|
||
return Buffer.from(`${lines.join("\n")}\n\n`);
|
||
}
|
||
|
||
state.usage = mergeUsageSnapshots(state.usage, normalizeUsageSnapshot(parsed));
|
||
state.response_id = state.response_id
|
||
|| extractStreamingResponseId(parsed)
|
||
|| extractNonStreamingResponseId(parsed)
|
||
|| firstNonEmptyString(parsed?.response?.id, fallbackResponseId);
|
||
|
||
let rewritten = normalizeResponsesLifecyclePayloadForCodex(
|
||
parsed,
|
||
eventName,
|
||
state.response_id || fallbackResponseId,
|
||
);
|
||
if (!rewritten) {
|
||
rewritten = normalizeResponsesCompletedPayloadForCodex(parsed, eventName, state.response_id || fallbackResponseId);
|
||
}
|
||
if (!rewritten) {
|
||
rewritten = normalizeResponsesFailedPayloadForCodex(parsed, eventName, state.response_id || fallbackResponseId);
|
||
}
|
||
const outputPayload = rewritten || parsed;
|
||
const outputEventName = firstNonEmptyString(outputPayload?.type, eventName);
|
||
state.response_id = state.response_id
|
||
|| extractStreamingResponseId(outputPayload)
|
||
|| extractNonStreamingResponseId(outputPayload)
|
||
|| firstNonEmptyString(outputPayload?.response?.id, fallbackResponseId);
|
||
state.usage = mergeUsageSnapshots(state.usage, normalizeUsageSnapshot(outputPayload));
|
||
|
||
if (outputEventName === "response.completed") {
|
||
state.saw_response_completed = true;
|
||
}
|
||
if (outputEventName === "response.failed" || outputEventName === "response.incomplete") {
|
||
state.saw_terminal_failure = true;
|
||
}
|
||
|
||
if (!rewritten) {
|
||
return Buffer.from(`${lines.join("\n")}\n\n`);
|
||
}
|
||
return buildSseBlock(outputEventName, JSON.stringify(outputPayload));
|
||
}
|
||
|
||
function drainResponsesSseForCodex(state, chunk, fallbackResponseId = null) {
|
||
const decoded = state.decoder.decode(chunk, { stream: true });
|
||
state.buffer += decoded;
|
||
const blocks = state.buffer.split(/\r?\n\r?\n/);
|
||
state.buffer = blocks.pop() ?? "";
|
||
return blocks
|
||
.filter((block) => block.length > 0)
|
||
.map((block) => processResponsesSseBlockForCodex(state, block, fallbackResponseId))
|
||
.filter((chunkBuffer) => chunkBuffer && chunkBuffer.length > 0);
|
||
}
|
||
|
||
function flushResponsesSseForCodex(state, fallbackResponseId = null, fallbackUsage = null) {
|
||
const flushed = state.decoder.decode();
|
||
if (flushed) {
|
||
state.buffer += flushed;
|
||
}
|
||
const outputs = [];
|
||
if (state.buffer.trim()) {
|
||
outputs.push(processResponsesSseBlockForCodex(state, state.buffer, fallbackResponseId));
|
||
}
|
||
state.buffer = "";
|
||
|
||
if (!state.saw_response_completed && !state.saw_terminal_failure) {
|
||
const responseId = firstNonEmptyString(state.response_id, fallbackResponseId);
|
||
if (responseId) {
|
||
const usage = denormalizeUsageSnapshotToResponsesUsage(mergeUsageSnapshots(state.usage, fallbackUsage));
|
||
const payload = {
|
||
type: "response.completed",
|
||
response: {
|
||
id: responseId,
|
||
},
|
||
};
|
||
if (usage) {
|
||
payload.response.usage = usage;
|
||
}
|
||
outputs.push(buildSseBlock("response.completed", JSON.stringify(payload)));
|
||
state.saw_response_completed = true;
|
||
}
|
||
}
|
||
return outputs.filter((chunkBuffer) => chunkBuffer && chunkBuffer.length > 0);
|
||
}
|
||
|
||
function normalizeIntegerList(values, fallback = []) {
|
||
const source = values === undefined || values === null ? fallback : values;
|
||
const normalized = flattenValues(source)
|
||
.flatMap((value) => {
|
||
if (typeof value === "string") {
|
||
return value.split(/[\s,]+/).filter(Boolean);
|
||
}
|
||
return [value];
|
||
})
|
||
.map((value) => Number.parseInt(`${value}`, 10))
|
||
.filter((value) => Number.isInteger(value));
|
||
|
||
return [...new Set(normalized)];
|
||
}
|
||
|
||
function normalizeStringList(values, fallback = []) {
|
||
const source = values === undefined || values === null ? fallback : values;
|
||
const normalized = flattenValues(source)
|
||
.flatMap((value) => `${value ?? ""}`.split(/[\s,]+/))
|
||
.map((value) => value.trim())
|
||
.filter(Boolean);
|
||
|
||
return [...new Set(normalized)];
|
||
}
|
||
|
||
function normalizePhraseList(values, fallback = []) {
|
||
const source = values === undefined || values === null ? fallback : values;
|
||
const normalized = flattenValues(source)
|
||
.flatMap((value) => {
|
||
if (typeof value === "string") {
|
||
return expandEscapedLineBreaks(value).split(/\r?\n/);
|
||
}
|
||
return [value];
|
||
})
|
||
.map((value) => `${value ?? ""}`.trim())
|
||
.filter(Boolean);
|
||
|
||
return [...new Set(normalized)];
|
||
}
|
||
|
||
function normalizeReasoningMatchMode(value) {
|
||
const mode = `${value || DEFAULT_CONFIG.reasoning_match_mode}`.trim().toLowerCase();
|
||
if (["formula_518n_minus_2", "manual"].includes(mode)) {
|
||
return mode;
|
||
}
|
||
return DEFAULT_CONFIG.reasoning_match_mode;
|
||
}
|
||
|
||
function normalizeReasoningEquals(values, fallback = DEFAULT_CONFIG.reasoning_equals) {
|
||
const normalized = normalizeIntegerList(values, fallback);
|
||
return normalized.length > 0 ? normalized : [...fallback];
|
||
}
|
||
|
||
function normalizePositiveInteger(value, fallback) {
|
||
const parsed = Number.parseInt(`${value ?? ""}`, 10);
|
||
if (Number.isInteger(parsed) && parsed > 0) {
|
||
return parsed;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function normalizeNonNegativeInteger(value, fallback) {
|
||
const parsed = Number.parseInt(`${value ?? ""}`, 10);
|
||
if (Number.isInteger(parsed) && parsed >= 0) {
|
||
return parsed;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function sleep(ms) {
|
||
if (!Number.isFinite(ms) || ms <= 0) {
|
||
return Promise.resolve();
|
||
}
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
function parseModelRemapMap(value) {
|
||
const map = {};
|
||
for (const rawEntry of `${value || ""}`.split(/\r?\n|[;,]/)) {
|
||
const entry = rawEntry.trim();
|
||
if (!entry) {
|
||
continue;
|
||
}
|
||
const separatorIndex = entry.indexOf("=");
|
||
if (separatorIndex <= 0) {
|
||
continue;
|
||
}
|
||
const from = entry.slice(0, separatorIndex).trim();
|
||
const to = entry.slice(separatorIndex + 1).trim();
|
||
if (!from || !to) {
|
||
continue;
|
||
}
|
||
map[from] = to;
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function canHotSwapProfile(currentConfig, nextConfig) {
|
||
return (
|
||
`${currentConfig?.listen_host || ""}` === `${nextConfig?.listen_host || ""}` &&
|
||
Number.parseInt(`${currentConfig?.listen_port || ""}`, 10) === Number.parseInt(`${nextConfig?.listen_port || ""}`, 10)
|
||
);
|
||
}
|
||
|
||
function parseEnvText(content) {
|
||
const values = {};
|
||
for (const rawLine of `${content || ""}`.split(/\r?\n/)) {
|
||
const line = rawLine.trim();
|
||
if (!line || line.startsWith("#")) {
|
||
continue;
|
||
}
|
||
const separatorIndex = line.indexOf("=");
|
||
if (separatorIndex <= 0) {
|
||
continue;
|
||
}
|
||
const key = line.slice(0, separatorIndex).trim();
|
||
let value = line.slice(separatorIndex + 1).trim();
|
||
if (
|
||
(value.startsWith('"') && value.endsWith('"')) ||
|
||
(value.startsWith("'") && value.endsWith("'"))
|
||
) {
|
||
value = value.slice(1, -1);
|
||
}
|
||
values[key] = value;
|
||
}
|
||
return values;
|
||
}
|
||
|
||
function isSecretLikeKey(key) {
|
||
return /TOKEN|SECRET|PASSWORD|API_KEY|AUTH|BEARER|KEY/i.test(`${key || ""}`);
|
||
}
|
||
|
||
function redactProfileValue(key, value) {
|
||
if (!value) {
|
||
return "";
|
||
}
|
||
if (isSecretLikeKey(key) || /^sk-[A-Za-z0-9_-]+/.test(value)) {
|
||
return "[configured]";
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function getProfileNameFromFile(fileName) {
|
||
if (!fileName.endsWith(".env")) {
|
||
return null;
|
||
}
|
||
const profileName = fileName.slice(0, -4);
|
||
return /^[A-Za-z0-9_.-]+$/.test(profileName) ? profileName : null;
|
||
}
|
||
|
||
function validateProfileName(profileName) {
|
||
if (!/^[A-Za-z0-9_.-]+$/.test(profileName)) {
|
||
throw new Error("profile 名称只能包含字母、数字、下划线、点和短横线");
|
||
}
|
||
}
|
||
|
||
function buildBlockedBody(pathname, reasoning, statusCode) {
|
||
return JSON.stringify({
|
||
error: {
|
||
message: `codex retry gateway blocked suspicious reasoning response on ${pathname}`,
|
||
type: "codex_retry_gateway",
|
||
code: "reasoning_guard_triggered",
|
||
reasoning_tokens: reasoning,
|
||
status_code: statusCode,
|
||
},
|
||
});
|
||
}
|
||
|
||
function buildRetryableUpstreamErrorBody(pathname, upstreamStatusCode, upstreamMessage, statusCode) {
|
||
return JSON.stringify({
|
||
error: {
|
||
message: `codex retry gateway converted retryable upstream error on ${pathname}`,
|
||
type: "codex_retry_gateway",
|
||
code: "upstream_error_retry_triggered",
|
||
upstream_status_code: upstreamStatusCode,
|
||
upstream_error_message: upstreamMessage,
|
||
status_code: statusCode,
|
||
},
|
||
});
|
||
}
|
||
|
||
function buildGatewayErrorBody(message) {
|
||
return JSON.stringify({
|
||
error: {
|
||
message,
|
||
type: "codex_retry_gateway_error",
|
||
code: "gateway_error",
|
||
},
|
||
});
|
||
}
|
||
|
||
function createMonitor() {
|
||
return {
|
||
started_at: new Date().toISOString(),
|
||
persistent_since: null,
|
||
next_log_seq: 1,
|
||
next_request_seq: 1,
|
||
log_entries: [],
|
||
request_entries: [],
|
||
total_proxy_request_count: 0,
|
||
inspected_response_count: 0,
|
||
matched_response_count: 0,
|
||
token_totals: {
|
||
input_tokens: 0,
|
||
output_tokens: 0,
|
||
total_tokens: 0,
|
||
reasoning_tokens: 0,
|
||
cached_tokens: 0,
|
||
},
|
||
observed_reasoning_counts: {},
|
||
};
|
||
}
|
||
|
||
function buildLogEntry(seq, at, message) {
|
||
return {
|
||
seq,
|
||
at,
|
||
message,
|
||
};
|
||
}
|
||
|
||
function parseLogLine(line, seq) {
|
||
const match = `${line || ""}`.match(/^(\S+)\s([\s\S]*)$/);
|
||
if (!match) {
|
||
return buildLogEntry(seq, null, line);
|
||
}
|
||
return buildLogEntry(seq, match[1], match[2]);
|
||
}
|
||
|
||
function parseJsonLine(line) {
|
||
if (!line.trim()) {
|
||
return null;
|
||
}
|
||
try {
|
||
return JSON.parse(line);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function readJsonlFile(filePath) {
|
||
const text = await readOptionalText(filePath);
|
||
if (!text) {
|
||
return [];
|
||
}
|
||
return text
|
||
.split(/\r?\n/)
|
||
.map(parseJsonLine)
|
||
.filter(Boolean);
|
||
}
|
||
|
||
async function appendJsonl(filePath, value) {
|
||
await mkdir(path.dirname(filePath), { recursive: true });
|
||
await fs.promises.appendFile(filePath, `${JSON.stringify(value)}\n`, "utf8");
|
||
}
|
||
|
||
function openRequestsDatabase(dbPath) {
|
||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||
const db = new DatabaseSync(dbPath);
|
||
db.exec(`
|
||
PRAGMA journal_mode = WAL;
|
||
CREATE TABLE IF NOT EXISTS requests (
|
||
seq INTEGER PRIMARY KEY,
|
||
request_id TEXT,
|
||
response_id TEXT,
|
||
thread_id TEXT,
|
||
started_at TEXT,
|
||
finished_at TEXT,
|
||
duration_ms INTEGER,
|
||
profile_name TEXT,
|
||
method TEXT,
|
||
path TEXT,
|
||
model TEXT,
|
||
requested_model TEXT,
|
||
forwarded_model TEXT,
|
||
reasoning_effort TEXT,
|
||
reasoning_summary TEXT,
|
||
request_stream INTEGER,
|
||
response_stream INTEGER,
|
||
inspected INTEGER,
|
||
matched INTEGER,
|
||
status_code INTEGER,
|
||
upstream_status_code INTEGER,
|
||
reasoning_tokens INTEGER,
|
||
input_tokens INTEGER,
|
||
output_tokens INTEGER,
|
||
total_tokens INTEGER,
|
||
cached_tokens INTEGER,
|
||
error TEXT,
|
||
upstream_origin TEXT,
|
||
upstream_path TEXT,
|
||
upstream_auth_mode TEXT,
|
||
upstream_auth_source TEXT,
|
||
payload_json TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_requests_started_at ON requests(started_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_requests_profile_name ON requests(profile_name);
|
||
CREATE INDEX IF NOT EXISTS idx_requests_status_code ON requests(status_code);
|
||
CREATE INDEX IF NOT EXISTS idx_requests_matched ON requests(matched);
|
||
CREATE INDEX IF NOT EXISTS idx_requests_response_stream ON requests(response_stream);
|
||
`);
|
||
const requestColumns = db.prepare("PRAGMA table_info(requests)").all();
|
||
const requestColumnNames = new Set(requestColumns.map((column) => column.name));
|
||
if (!requestColumnNames.has("request_id")) {
|
||
db.exec("ALTER TABLE requests ADD COLUMN request_id TEXT");
|
||
}
|
||
if (!requestColumnNames.has("response_id")) {
|
||
db.exec("ALTER TABLE requests ADD COLUMN response_id TEXT");
|
||
}
|
||
if (!requestColumnNames.has("thread_id")) {
|
||
db.exec("ALTER TABLE requests ADD COLUMN thread_id TEXT");
|
||
}
|
||
if (!requestColumnNames.has("reasoning_effort")) {
|
||
db.exec("ALTER TABLE requests ADD COLUMN reasoning_effort TEXT");
|
||
}
|
||
if (!requestColumnNames.has("reasoning_summary")) {
|
||
db.exec("ALTER TABLE requests ADD COLUMN reasoning_summary TEXT");
|
||
}
|
||
db.exec("CREATE INDEX IF NOT EXISTS idx_requests_request_id ON requests(request_id)");
|
||
db.exec("CREATE INDEX IF NOT EXISTS idx_requests_response_id ON requests(response_id)");
|
||
db.exec("CREATE INDEX IF NOT EXISTS idx_requests_thread_id ON requests(thread_id)");
|
||
db.exec("CREATE INDEX IF NOT EXISTS idx_requests_reasoning_effort ON requests(reasoning_effort)");
|
||
return db;
|
||
}
|
||
|
||
function boolToInt(value) {
|
||
return value ? 1 : 0;
|
||
}
|
||
|
||
function usageField(entry, key) {
|
||
const value = entry?.usage?.[key];
|
||
return Number.isInteger(value) ? value : null;
|
||
}
|
||
|
||
function buildPersistedRequestPayload(entry) {
|
||
return Object.fromEntries(
|
||
Object.entries(entry || {}).filter(([key]) => !key.startsWith("_")),
|
||
);
|
||
}
|
||
|
||
function computeRequestId(pathname, rawBody) {
|
||
const hash = createHash("sha256");
|
||
hash.update(normalizePath(pathname));
|
||
hash.update("\n");
|
||
hash.update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody || ""));
|
||
return `req_${hash.digest("hex").slice(0, 16)}`;
|
||
}
|
||
|
||
function requestRowFromEntry(entry) {
|
||
const payload = buildPersistedRequestPayload(entry);
|
||
return {
|
||
seq: entry.seq,
|
||
request_id: entry.request_id || null,
|
||
response_id: entry.response_id || null,
|
||
thread_id: entry.thread_id || null,
|
||
started_at: entry.started_at || null,
|
||
finished_at: entry.finished_at || null,
|
||
duration_ms: Number.isInteger(entry.duration_ms) ? entry.duration_ms : null,
|
||
profile_name: entry.profile_name || null,
|
||
method: entry.method || null,
|
||
path: entry.path || null,
|
||
model: entry.model || null,
|
||
requested_model: entry.requested_model || null,
|
||
forwarded_model: entry.forwarded_model || null,
|
||
reasoning_effort: entry.reasoning_effort || null,
|
||
reasoning_summary: entry.reasoning_summary || null,
|
||
request_stream: boolToInt(Boolean(entry.request_stream)),
|
||
response_stream: boolToInt(Boolean(entry.response_stream)),
|
||
inspected: boolToInt(Boolean(entry.inspected)),
|
||
matched: boolToInt(Boolean(entry.matched)),
|
||
status_code: Number.isInteger(entry.status_code) ? entry.status_code : null,
|
||
upstream_status_code: Number.isInteger(entry.upstream_status_code) ? entry.upstream_status_code : null,
|
||
reasoning_tokens: Number.isInteger(entry.reasoning_tokens) ? entry.reasoning_tokens : usageField(entry, "reasoning_tokens"),
|
||
input_tokens: usageField(entry, "input_tokens"),
|
||
output_tokens: usageField(entry, "output_tokens"),
|
||
total_tokens: usageField(entry, "total_tokens"),
|
||
cached_tokens: usageField(entry, "cached_tokens"),
|
||
error: entry.error || null,
|
||
upstream_origin: entry.upstream?.origin || null,
|
||
upstream_path: entry.upstream?.path || null,
|
||
upstream_auth_mode: entry.upstream?.auth_mode || null,
|
||
upstream_auth_source: entry.upstream?.auth_source || null,
|
||
payload_json: JSON.stringify(payload),
|
||
};
|
||
}
|
||
|
||
function insertRequestRow(db, row) {
|
||
db.prepare(`
|
||
INSERT OR REPLACE INTO requests (
|
||
seq, request_id, response_id, thread_id, started_at, finished_at, duration_ms, profile_name, method, path, model,
|
||
requested_model, forwarded_model, reasoning_effort, reasoning_summary, request_stream, response_stream, inspected, matched,
|
||
status_code, upstream_status_code, reasoning_tokens, input_tokens, output_tokens,
|
||
total_tokens, cached_tokens, error, upstream_origin, upstream_path,
|
||
upstream_auth_mode, upstream_auth_source, payload_json
|
||
) VALUES (
|
||
@seq, @request_id, @response_id, @thread_id, @started_at, @finished_at, @duration_ms, @profile_name, @method, @path, @model,
|
||
@requested_model, @forwarded_model, @reasoning_effort, @reasoning_summary, @request_stream, @response_stream, @inspected, @matched,
|
||
@status_code, @upstream_status_code, @reasoning_tokens, @input_tokens, @output_tokens,
|
||
@total_tokens, @cached_tokens, @error, @upstream_origin, @upstream_path,
|
||
@upstream_auth_mode, @upstream_auth_source, @payload_json
|
||
)
|
||
`).run(row);
|
||
}
|
||
|
||
async function importRequestsJsonlToDb(db, filePath) {
|
||
const rows = await readJsonlFile(filePath);
|
||
if (rows.length === 0) {
|
||
return 0;
|
||
}
|
||
const countRow = db.prepare("SELECT COUNT(*) AS count FROM requests").get();
|
||
if ((countRow?.count || 0) >= rows.length) {
|
||
return 0;
|
||
}
|
||
db.exec("BEGIN");
|
||
try {
|
||
for (const entry of rows) {
|
||
if (!Number.isInteger(entry?.seq)) {
|
||
continue;
|
||
}
|
||
insertRequestRow(db, requestRowFromEntry(entry));
|
||
}
|
||
db.exec("COMMIT");
|
||
} catch (error) {
|
||
try {
|
||
db.exec("ROLLBACK");
|
||
} catch {
|
||
// Ignore rollback failures so the original insert error is preserved.
|
||
}
|
||
throw error;
|
||
}
|
||
return rows.length;
|
||
}
|
||
|
||
function parseRequestRowPayload(row) {
|
||
if (!row?.payload_json) {
|
||
return null;
|
||
}
|
||
try {
|
||
return JSON.parse(row.payload_json);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function buildRequestQueryFilters({ query, filter }) {
|
||
const clauses = [];
|
||
const params = {};
|
||
|
||
const trimmedQuery = `${query || ""}`.trim().toLowerCase();
|
||
if (trimmedQuery) {
|
||
clauses.push(`
|
||
(
|
||
lower(coalesce(profile_name, '')) LIKE @query OR
|
||
lower(coalesce(method, '')) LIKE @query OR
|
||
lower(coalesce(path, '')) LIKE @query OR
|
||
lower(coalesce(request_id, '')) LIKE @query OR
|
||
lower(coalesce(response_id, '')) LIKE @query OR
|
||
lower(coalesce(thread_id, '')) LIKE @query OR
|
||
lower(coalesce(model, '')) LIKE @query OR
|
||
lower(coalesce(requested_model, '')) LIKE @query OR
|
||
lower(coalesce(forwarded_model, '')) LIKE @query OR
|
||
lower(coalesce(reasoning_effort, '')) LIKE @query OR
|
||
lower(coalesce(reasoning_summary, '')) LIKE @query OR
|
||
lower(coalesce(error, '')) LIKE @query OR
|
||
lower(coalesce(upstream_origin, '')) LIKE @query OR
|
||
lower(coalesce(upstream_path, '')) LIKE @query OR
|
||
CAST(coalesce(status_code, '') AS TEXT) LIKE @query OR
|
||
CAST(coalesce(upstream_status_code, '') AS TEXT) LIKE @query OR
|
||
CAST(coalesce(input_tokens, '') AS TEXT) LIKE @query OR
|
||
CAST(coalesce(output_tokens, '') AS TEXT) LIKE @query OR
|
||
CAST(coalesce(total_tokens, '') AS TEXT) LIKE @query OR
|
||
CAST(coalesce(cached_tokens, '') AS TEXT) LIKE @query OR
|
||
CAST(coalesce(reasoning_tokens, '') AS TEXT) LIKE @query
|
||
)
|
||
`);
|
||
params.query = `%${trimmedQuery}%`;
|
||
}
|
||
|
||
if (filter === "matched") {
|
||
clauses.push("matched = 1");
|
||
} else if (filter === "stream") {
|
||
clauses.push("response_stream = 1");
|
||
} else if (filter === "error") {
|
||
clauses.push("(error IS NOT NULL AND error != '')");
|
||
}
|
||
|
||
return {
|
||
whereSql: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "",
|
||
params,
|
||
};
|
||
}
|
||
|
||
async function hydrateMonitorFromDisk(monitor, paths, requestHistoryLimit, requestsDb = null) {
|
||
monitor.request_entries = [];
|
||
|
||
if (requestsDb) {
|
||
const totalsRow = requestsDb.prepare(`
|
||
SELECT
|
||
COUNT(*) AS total_proxy_request_count,
|
||
COALESCE(SUM(CASE WHEN inspected = 1 THEN 1 ELSE 0 END), 0) AS inspected_response_count,
|
||
COALESCE(SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END), 0) AS matched_response_count,
|
||
COALESCE(SUM(COALESCE(input_tokens, 0)), 0) AS input_tokens,
|
||
COALESCE(SUM(COALESCE(output_tokens, 0)), 0) AS output_tokens,
|
||
COALESCE(SUM(COALESCE(total_tokens, 0)), 0) AS total_tokens,
|
||
COALESCE(SUM(COALESCE(reasoning_tokens, 0)), 0) AS reasoning_tokens,
|
||
COALESCE(SUM(COALESCE(cached_tokens, 0)), 0) AS cached_tokens,
|
||
MIN(NULLIF(started_at, '')) AS persistent_since,
|
||
MAX(seq) AS max_seq
|
||
FROM requests
|
||
`).get();
|
||
|
||
monitor.total_proxy_request_count = totalsRow?.total_proxy_request_count || 0;
|
||
monitor.inspected_response_count = totalsRow?.inspected_response_count || 0;
|
||
monitor.matched_response_count = totalsRow?.matched_response_count || 0;
|
||
monitor.token_totals = {
|
||
input_tokens: totalsRow?.input_tokens || 0,
|
||
output_tokens: totalsRow?.output_tokens || 0,
|
||
total_tokens: totalsRow?.total_tokens || 0,
|
||
reasoning_tokens: totalsRow?.reasoning_tokens || 0,
|
||
cached_tokens: totalsRow?.cached_tokens || 0,
|
||
};
|
||
monitor.persistent_since = totalsRow?.persistent_since || null;
|
||
monitor.next_request_seq = Number.isInteger(totalsRow?.max_seq) ? totalsRow.max_seq + 1 : 1;
|
||
monitor.observed_reasoning_counts = {};
|
||
|
||
const reasoningRows = requestsDb.prepare(`
|
||
SELECT reasoning_tokens, COUNT(*) AS count
|
||
FROM requests
|
||
WHERE reasoning_tokens IS NOT NULL
|
||
GROUP BY reasoning_tokens
|
||
`).all();
|
||
for (const row of reasoningRows) {
|
||
if (!Number.isInteger(row?.reasoning_tokens)) {
|
||
continue;
|
||
}
|
||
monitor.observed_reasoning_counts[`${row.reasoning_tokens}`] = row.count || 0;
|
||
}
|
||
|
||
const persistedRows = requestsDb.prepare("SELECT payload_json FROM requests").all();
|
||
for (const row of persistedRows) {
|
||
const entry = parseRequestRowPayload(row);
|
||
if (!entry) {
|
||
continue;
|
||
}
|
||
applyPersistedRetryExtras(monitor, entry);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const requestEntries = await readJsonlFile(paths.requestsPath);
|
||
monitor.next_request_seq = requestEntries.reduce((maxSeq, entry) => {
|
||
return Math.max(maxSeq, Number.isInteger(entry.seq) ? entry.seq + 1 : maxSeq);
|
||
}, 1);
|
||
for (const entry of requestEntries) {
|
||
monitor.total_proxy_request_count += 1;
|
||
if (entry.inspected) {
|
||
recordInspectedResponse(monitor, entry.reasoning_tokens ?? entry.usage?.reasoning_tokens ?? null, Boolean(entry.matched));
|
||
}
|
||
applyPersistedRetryExtras(monitor, entry);
|
||
addTokenTotals(monitor, entry.usage);
|
||
}
|
||
const firstStartedAt = requestEntries
|
||
.map((entry) => `${entry?.started_at || ""}`.trim())
|
||
.filter(Boolean)
|
||
.sort()[0];
|
||
monitor.persistent_since = firstStartedAt || null;
|
||
}
|
||
|
||
function createMonitorRecorder(monitor) {
|
||
return (message) => {
|
||
const entry = {
|
||
seq: monitor.next_log_seq,
|
||
at: new Date().toISOString(),
|
||
message,
|
||
};
|
||
monitor.next_log_seq += 1;
|
||
monitor.log_entries.push(entry);
|
||
return entry;
|
||
};
|
||
}
|
||
|
||
function createLogger(logPath, recordEntry) {
|
||
if (!logPath) {
|
||
return (message) => {
|
||
const entry = recordEntry ? recordEntry(message) : { at: new Date().toISOString(), message };
|
||
process.stdout.write(`${entry.at} ${entry.message}\n`);
|
||
};
|
||
}
|
||
|
||
const stream = fs.createWriteStream(logPath, { flags: "a" });
|
||
return (message) => {
|
||
const entry = recordEntry ? recordEntry(message) : { at: new Date().toISOString(), message };
|
||
const line = `${entry.at} ${entry.message}\n`;
|
||
stream.write(line);
|
||
process.stdout.write(line);
|
||
};
|
||
}
|
||
|
||
function incrementReasoningCount(counter, reasoning) {
|
||
if (!Number.isInteger(reasoning)) {
|
||
return;
|
||
}
|
||
const key = `${reasoning}`;
|
||
counter[key] = (counter[key] || 0) + 1;
|
||
}
|
||
|
||
function recordInspectedResponse(monitor, reasoning, matched) {
|
||
monitor.inspected_response_count += 1;
|
||
incrementReasoningCount(monitor.observed_reasoning_counts, reasoning);
|
||
if (matched) {
|
||
monitor.matched_response_count += 1;
|
||
}
|
||
}
|
||
|
||
function applyPersistedRetryExtras(monitor, entry) {
|
||
const extraInspectedCount = Number.isInteger(entry?.reasoning_retry_extra_inspected_count)
|
||
? entry.reasoning_retry_extra_inspected_count
|
||
: 0;
|
||
const extraMatchedCount = Number.isInteger(entry?.reasoning_retry_extra_matched_count)
|
||
? entry.reasoning_retry_extra_matched_count
|
||
: 0;
|
||
monitor.inspected_response_count += extraInspectedCount;
|
||
monitor.matched_response_count += extraMatchedCount;
|
||
|
||
const extraReasoningCounts = entry?.reasoning_retry_extra_reasoning_counts;
|
||
if (extraReasoningCounts && typeof extraReasoningCounts === "object") {
|
||
for (const [reasoningKey, count] of Object.entries(extraReasoningCounts)) {
|
||
const parsedReasoning = Number.parseInt(reasoningKey, 10);
|
||
if (!Number.isInteger(parsedReasoning) || !Number.isInteger(count) || count <= 0) {
|
||
continue;
|
||
}
|
||
monitor.observed_reasoning_counts[`${parsedReasoning}`] = (
|
||
monitor.observed_reasoning_counts[`${parsedReasoning}`] || 0
|
||
) + count;
|
||
}
|
||
}
|
||
|
||
addTokenTotals(monitor, entry?.reasoning_retry_extra_usage || null);
|
||
}
|
||
|
||
function addTokenTotals(monitor, usage) {
|
||
if (!usage) {
|
||
return;
|
||
}
|
||
for (const key of ["input_tokens", "output_tokens", "total_tokens", "reasoning_tokens", "cached_tokens"]) {
|
||
const value = usage[key];
|
||
if (Number.isInteger(value)) {
|
||
monitor.token_totals[key] += value;
|
||
}
|
||
}
|
||
}
|
||
|
||
function upsertRequestEntry(runtime, entry, { persistJsonl = false, includeUsage = false } = {}) {
|
||
if (includeUsage) {
|
||
addTokenTotals(runtime.monitor, entry.usage);
|
||
addTokenTotals(runtime.monitor, entry.reasoning_retry_extra_usage || null);
|
||
}
|
||
if (runtime.requestsDb) {
|
||
insertRequestRow(runtime.requestsDb, requestRowFromEntry(entry));
|
||
}
|
||
if (persistJsonl) {
|
||
appendJsonl(runtime.paths.requestsPath, buildPersistedRequestPayload(entry)).catch((error) => {
|
||
runtime.logger?.(`[requests] failed to persist request seq=${entry.seq}: ${error?.message || error}`);
|
||
});
|
||
}
|
||
}
|
||
|
||
function recordRequestEntry(runtime, entry, limit = DEFAULT_CONFIG.request_history_limit) {
|
||
upsertRequestEntry(runtime, entry, {
|
||
persistJsonl: true,
|
||
includeUsage: true,
|
||
});
|
||
}
|
||
|
||
function markAndPersistFirstResponse(runtime, entry, at = new Date()) {
|
||
if (!markRequestFirstResponse(entry, at)) {
|
||
return false;
|
||
}
|
||
upsertRequestEntry(runtime, entry);
|
||
return true;
|
||
}
|
||
|
||
function markRequestEntryFirstResponse(runtime, entry, { persistEntry = true, at = new Date() } = {}) {
|
||
if (!markRequestFirstResponse(entry, at)) {
|
||
return false;
|
||
}
|
||
if (persistEntry) {
|
||
upsertRequestEntry(runtime, entry);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function summarizeProfileAuthSource(env, options = {}) {
|
||
const prefix = options.prefix || "CODEX_RETRY_GATEWAY_UPSTREAM";
|
||
const defaultMode = options.defaultMode || "passthrough";
|
||
const mode = env[`${prefix}_AUTH_MODE`] || defaultMode;
|
||
if (mode === "auth_json") {
|
||
return env[`${prefix}_AUTH_JSON_PATH`] ? "auth.json path configured" : "~/.codex/auth.json";
|
||
}
|
||
if (mode === "manual_bearer") {
|
||
const secretPath = env[`${prefix}_AUTH_FILE`] || "";
|
||
if (!secretPath) {
|
||
return "system secret file missing";
|
||
}
|
||
return fs.existsSync(secretPath) ? "system secret file configured" : "system secret file missing";
|
||
}
|
||
if (mode === "fixed_bearer") {
|
||
if (env[`${prefix}_AUTH_FILE`]) {
|
||
return "token file configured";
|
||
}
|
||
if (env[`${prefix}_AUTH_ENV`]) {
|
||
return `env:${env[`${prefix}_AUTH_ENV`]}`;
|
||
}
|
||
}
|
||
return "passthrough";
|
||
}
|
||
|
||
function summarizeImageProfileAuthSource(env) {
|
||
return summarizeProfileAuthSource(env, {
|
||
prefix: "CODEX_RETRY_GATEWAY_IMAGE",
|
||
defaultMode: DEFAULT_CONFIG.image_auth_mode,
|
||
});
|
||
}
|
||
|
||
function buildProfileFormModel(env) {
|
||
const manualSecretFile = env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || "";
|
||
return {
|
||
listen_host: env.CODEX_RETRY_GATEWAY_LISTEN_HOST || "",
|
||
listen_port: env.CODEX_RETRY_GATEWAY_LISTEN_PORT || "",
|
||
upstream_base_url: env.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL || "",
|
||
auth_mode: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE || "passthrough",
|
||
auth_env: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV || "",
|
||
auth_file: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE === "manual_bearer"
|
||
? ""
|
||
: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || "",
|
||
manual_secret_file: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE === "manual_bearer"
|
||
? manualSecretFile
|
||
: "",
|
||
manual_secret_configured: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE === "manual_bearer"
|
||
&& Boolean(manualSecretFile)
|
||
&& fs.existsSync(manualSecretFile),
|
||
auth_json_path: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH || "",
|
||
auth_json_key: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY || "",
|
||
request_history_limit: env.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT || `${DEFAULT_CONFIG.request_history_limit}`,
|
||
model_remap: env.CODEX_RETRY_GATEWAY_MODEL_REMAP || "",
|
||
reasoning_match_mode: normalizeReasoningMatchMode(env.CODEX_RETRY_GATEWAY_REASONING_MATCH_MODE),
|
||
reasoning_equals: normalizeReasoningEquals(
|
||
env.CODEX_RETRY_GATEWAY_REASONING_EQUALS || DEFAULT_CONFIG.reasoning_equals,
|
||
DEFAULT_CONFIG.reasoning_equals,
|
||
).join(","),
|
||
retryable_status_codes: env.CODEX_RETRY_GATEWAY_RETRYABLE_STATUS_CODES || "",
|
||
retryable_error_messages: normalizePhraseList(
|
||
env.CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES || DEFAULT_CONFIG.retryable_error_messages,
|
||
DEFAULT_CONFIG.retryable_error_messages,
|
||
),
|
||
upstream_fetch_retry_attempts:
|
||
env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS || `${DEFAULT_CONFIG.upstream_fetch_retry_attempts}`,
|
||
upstream_fetch_retry_backoff_ms:
|
||
env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS || `${DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms}`,
|
||
endpoints: normalizeStringList(env.CODEX_RETRY_GATEWAY_ENDPOINTS || DEFAULT_CONFIG.endpoints, DEFAULT_CONFIG.endpoints),
|
||
};
|
||
}
|
||
|
||
function buildImageProfileFormModel(env) {
|
||
const manualSecretFile = env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE || "";
|
||
return {
|
||
base_url: env.CODEX_RETRY_GATEWAY_IMAGE_BASE_URL || "",
|
||
auth_mode: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE || DEFAULT_CONFIG.image_auth_mode,
|
||
auth_env: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV || DEFAULT_CONFIG.image_auth_env,
|
||
auth_file: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE === "manual_bearer"
|
||
? ""
|
||
: manualSecretFile,
|
||
manual_secret_file: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE === "manual_bearer"
|
||
? manualSecretFile
|
||
: "",
|
||
manual_secret_configured: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE === "manual_bearer"
|
||
&& Boolean(manualSecretFile)
|
||
&& fs.existsSync(manualSecretFile),
|
||
auth_json_path: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_PATH || "",
|
||
auth_json_key: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY || DEFAULT_CONFIG.image_auth_json_key,
|
||
};
|
||
}
|
||
|
||
function imageConfigFromConfig(config) {
|
||
return {
|
||
image_profile_name: `${config?.image_profile_name || ""}`.trim(),
|
||
image_base_url: `${config?.image_base_url || ""}`.trim(),
|
||
image_auth_mode: config?.image_auth_mode || DEFAULT_CONFIG.image_auth_mode,
|
||
image_auth_env: config?.image_auth_env || DEFAULT_CONFIG.image_auth_env,
|
||
image_auth_file: config?.image_auth_file || "",
|
||
image_auth_json_path: config?.image_auth_json_path || "",
|
||
image_auth_json_key: config?.image_auth_json_key || DEFAULT_CONFIG.image_auth_json_key,
|
||
};
|
||
}
|
||
|
||
function buildImageConfigFromProfileEnv(imageProfileName, env) {
|
||
const form = buildImageProfileFormModel(env);
|
||
const baseUrl = `${form.base_url || ""}`.trim();
|
||
if (baseUrl) {
|
||
try {
|
||
new URL(baseUrl);
|
||
} catch {
|
||
throw new Error("图片上游 Base URL 必须是合法 URL");
|
||
}
|
||
}
|
||
return {
|
||
image_profile_name: imageProfileName || "",
|
||
image_base_url: baseUrl,
|
||
image_auth_mode: normalizeAuthMode(form.auth_mode),
|
||
image_auth_env: form.auth_env || DEFAULT_CONFIG.image_auth_env,
|
||
image_auth_file: env.CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE || "",
|
||
image_auth_json_path: form.auth_json_path || "",
|
||
image_auth_json_key: form.auth_json_key || DEFAULT_CONFIG.image_auth_json_key,
|
||
};
|
||
}
|
||
|
||
function buildConfigFromProfileEnv(profileName, env, imageConfig = {}) {
|
||
const reasoningMatchMode = normalizeReasoningMatchMode(env.CODEX_RETRY_GATEWAY_REASONING_MATCH_MODE);
|
||
const config = {
|
||
...DEFAULT_CONFIG,
|
||
...imageConfigFromConfig(imageConfig),
|
||
profile_name: profileName,
|
||
listen_host: env.CODEX_RETRY_GATEWAY_LISTEN_HOST || DEFAULT_CONFIG.listen_host,
|
||
listen_port: env.CODEX_RETRY_GATEWAY_LISTEN_PORT
|
||
? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_LISTEN_PORT}`, 10)
|
||
: DEFAULT_CONFIG.listen_port,
|
||
upstream_base_url: env.CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL || "",
|
||
upstream_auth_mode: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE || DEFAULT_CONFIG.upstream_auth_mode,
|
||
upstream_auth_env: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV || DEFAULT_CONFIG.upstream_auth_env,
|
||
upstream_auth_file: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE || "",
|
||
upstream_auth_json_path: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH || "",
|
||
upstream_auth_json_key: env.CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY || DEFAULT_CONFIG.upstream_auth_json_key,
|
||
request_body_limit_bytes: env.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES
|
||
? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_REQUEST_BODY_LIMIT_BYTES}`, 10)
|
||
: DEFAULT_CONFIG.request_body_limit_bytes,
|
||
request_history_limit: env.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT
|
||
? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT}`, 10)
|
||
: DEFAULT_CONFIG.request_history_limit,
|
||
model_remap: env.CODEX_RETRY_GATEWAY_MODEL_REMAP || "",
|
||
endpoints: normalizeStringList(env.CODEX_RETRY_GATEWAY_ENDPOINTS || DEFAULT_CONFIG.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath),
|
||
reasoning_match_mode: reasoningMatchMode,
|
||
reasoning_equals: normalizeReasoningEquals(
|
||
env.CODEX_RETRY_GATEWAY_REASONING_EQUALS || DEFAULT_CONFIG.reasoning_equals,
|
||
DEFAULT_CONFIG.reasoning_equals,
|
||
),
|
||
retryable_status_codes: normalizeIntegerList(
|
||
env.CODEX_RETRY_GATEWAY_RETRYABLE_STATUS_CODES || DEFAULT_CONFIG.retryable_status_codes,
|
||
DEFAULT_CONFIG.retryable_status_codes,
|
||
),
|
||
retryable_error_messages: normalizePhraseList(
|
||
env.CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES || DEFAULT_CONFIG.retryable_error_messages,
|
||
DEFAULT_CONFIG.retryable_error_messages,
|
||
),
|
||
management_access_key: normalizeManagementAccessKey(
|
||
env.CODEX_RETRY_GATEWAY_MANAGEMENT_ACCESS_KEY || DEFAULT_CONFIG.management_access_key,
|
||
),
|
||
upstream_fetch_retry_attempts: env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS
|
||
? normalizePositiveInteger(
|
||
env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS,
|
||
DEFAULT_CONFIG.upstream_fetch_retry_attempts,
|
||
)
|
||
: DEFAULT_CONFIG.upstream_fetch_retry_attempts,
|
||
upstream_fetch_retry_backoff_ms: env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS
|
||
? normalizeNonNegativeInteger(
|
||
env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS,
|
||
DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms,
|
||
)
|
||
: DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms,
|
||
non_stream_status_code: env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE
|
||
? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE}`, 10)
|
||
: DEFAULT_CONFIG.non_stream_status_code,
|
||
stream_action: env.CODEX_RETRY_GATEWAY_STREAM_ACTION || DEFAULT_CONFIG.stream_action,
|
||
log_match: env.CODEX_RETRY_GATEWAY_LOG_MATCH === undefined
|
||
? DEFAULT_CONFIG.log_match
|
||
: ["1", "true", "yes", "on"].includes(`${env.CODEX_RETRY_GATEWAY_LOG_MATCH}`.trim().toLowerCase()),
|
||
health_path: env.CODEX_RETRY_GATEWAY_HEALTH_PATH || DEFAULT_CONFIG.health_path,
|
||
};
|
||
config.model_remap_map = parseModelRemapMap(config.model_remap);
|
||
return config;
|
||
}
|
||
|
||
function assertNoInlineProfileSecret(payload, fields = [
|
||
"auth_env",
|
||
"auth_file",
|
||
"auth_json_path",
|
||
"auth_json_key",
|
||
"upstream_base_url",
|
||
]) {
|
||
for (const field of fields) {
|
||
const value = `${payload?.[field] || ""}`.trim();
|
||
if (/^sk-[A-Za-z0-9_-]+/.test(value) || /Bearer\s+sk-[A-Za-z0-9_-]+/i.test(value)) {
|
||
throw new Error("profile 不保存明文 sk 密钥;请改用 env/file/auth.json 引用");
|
||
}
|
||
}
|
||
}
|
||
|
||
function defaultManualSecretPath(profileName) {
|
||
const homeDir = process.env.HOME || "";
|
||
return path.join(homeDir, ".codex-retry-gateway", "secrets", `${profileName}.token`);
|
||
}
|
||
|
||
function defaultImageManualSecretPath(profileName) {
|
||
const homeDir = process.env.HOME || "";
|
||
return path.join(homeDir, ".codex-retry-gateway", "secrets", `${profileName}.images.token`);
|
||
}
|
||
|
||
async function writeManualSecret(profileName, secretValue) {
|
||
const text = `${secretValue || ""}`.trim();
|
||
if (!text) {
|
||
return null;
|
||
}
|
||
|
||
const secretPath = defaultManualSecretPath(profileName);
|
||
await mkdir(path.dirname(secretPath), { recursive: true, mode: 0o700 });
|
||
await writeFile(secretPath, `${text}\n`, { encoding: "utf8", mode: 0o600 });
|
||
await chmod(path.dirname(secretPath), 0o700).catch(() => {});
|
||
await chmod(secretPath, 0o600).catch(() => {});
|
||
return secretPath;
|
||
}
|
||
|
||
async function writeImageManualSecret(profileName, secretValue) {
|
||
const text = `${secretValue || ""}`.trim();
|
||
if (!text) {
|
||
return null;
|
||
}
|
||
|
||
const secretPath = defaultImageManualSecretPath(profileName);
|
||
await mkdir(path.dirname(secretPath), { recursive: true, mode: 0o700 });
|
||
await writeFile(secretPath, `${text}\n`, { encoding: "utf8", mode: 0o600 });
|
||
await chmod(path.dirname(secretPath), 0o700).catch(() => {});
|
||
await chmod(secretPath, 0o600).catch(() => {});
|
||
return secretPath;
|
||
}
|
||
|
||
function serializeEnvValue(value) {
|
||
const text = `${value ?? ""}`;
|
||
if (/^[A-Za-z0-9_./:@?&=,+-]*$/.test(text)) {
|
||
return text;
|
||
}
|
||
return JSON.stringify(text);
|
||
}
|
||
|
||
async function buildProfileEnvText(payload) {
|
||
assertNoInlineProfileSecret(payload);
|
||
|
||
const name = `${payload?.name || ""}`.trim();
|
||
validateProfileName(name);
|
||
|
||
const listenPort = Number.parseInt(`${payload.listen_port || DEFAULT_CONFIG.listen_port}`, 10);
|
||
if (!Number.isInteger(listenPort) || listenPort < 1 || listenPort > 65535) {
|
||
throw new Error("监听端口必须是 1-65535 的整数");
|
||
}
|
||
|
||
const upstreamBaseUrl = `${payload.upstream_base_url || ""}`.trim();
|
||
if (!upstreamBaseUrl) {
|
||
throw new Error("上游 Base URL 不能为空");
|
||
}
|
||
try {
|
||
new URL(upstreamBaseUrl);
|
||
} catch {
|
||
throw new Error("上游 Base URL 必须是合法 URL");
|
||
}
|
||
|
||
const authMode = normalizeAuthMode(payload.auth_mode);
|
||
const reasoningMatchMode = normalizeReasoningMatchMode(payload.reasoning_match_mode);
|
||
const reasoningEquals = normalizeReasoningEquals(payload.reasoning_equals, DEFAULT_CONFIG.reasoning_equals);
|
||
|
||
const endpoints = normalizeStringList(payload.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath);
|
||
if (endpoints.length === 0) {
|
||
throw new Error("endpoints 不能为空");
|
||
}
|
||
|
||
const requestHistoryLimit = Number.parseInt(
|
||
`${payload.request_history_limit || DEFAULT_CONFIG.request_history_limit}`,
|
||
10,
|
||
);
|
||
if (!Number.isInteger(requestHistoryLimit) || requestHistoryLimit < 0) {
|
||
throw new Error("History Limit 必须是 0 或正整数;0 表示不裁剪");
|
||
}
|
||
|
||
const retryableStatusCodes = normalizeIntegerList(
|
||
payload.retryable_status_codes,
|
||
DEFAULT_CONFIG.retryable_status_codes,
|
||
);
|
||
const retryableErrorMessages = normalizePhraseList(
|
||
payload.retryable_error_messages,
|
||
DEFAULT_CONFIG.retryable_error_messages,
|
||
);
|
||
const upstreamFetchRetryAttempts = normalizePositiveInteger(
|
||
payload.upstream_fetch_retry_attempts,
|
||
DEFAULT_CONFIG.upstream_fetch_retry_attempts,
|
||
);
|
||
const upstreamFetchRetryBackoffMs = normalizeNonNegativeInteger(
|
||
payload.upstream_fetch_retry_backoff_ms,
|
||
DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms,
|
||
);
|
||
if (retryableStatusCodes.length === 0) {
|
||
throw new Error("retryable_status_codes 不能为空");
|
||
}
|
||
if (retryableErrorMessages.length === 0) {
|
||
throw new Error("retryable_error_messages 不能为空");
|
||
}
|
||
if (upstreamFetchRetryAttempts < 1) {
|
||
throw new Error("upstream_fetch_retry_attempts 必须是正整数");
|
||
}
|
||
if (upstreamFetchRetryBackoffMs < 0) {
|
||
throw new Error("upstream_fetch_retry_backoff_ms 不能为负数");
|
||
}
|
||
|
||
const envPairs = [
|
||
["CODEX_RETRY_GATEWAY_LISTEN_HOST", `${payload.listen_host || DEFAULT_CONFIG.listen_host}`.trim()],
|
||
["CODEX_RETRY_GATEWAY_LISTEN_PORT", `${listenPort}`],
|
||
["CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL", upstreamBaseUrl],
|
||
["CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE", authMode],
|
||
["CODEX_RETRY_GATEWAY_REASONING_MATCH_MODE", reasoningMatchMode],
|
||
["CODEX_RETRY_GATEWAY_REASONING_EQUALS", reasoningEquals.join(",")],
|
||
["CODEX_RETRY_GATEWAY_RETRYABLE_STATUS_CODES", retryableStatusCodes.join(",")],
|
||
["CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES", retryableErrorMessages.join("\n")],
|
||
["CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS", `${upstreamFetchRetryAttempts}`],
|
||
["CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS", `${upstreamFetchRetryBackoffMs}`],
|
||
["CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT", `${requestHistoryLimit}`],
|
||
["CODEX_RETRY_GATEWAY_ENDPOINTS", endpoints.join(",")],
|
||
];
|
||
|
||
const managementAccessKey = normalizeManagementAccessKey(payload.management_access_key);
|
||
if (managementAccessKey) {
|
||
envPairs.push(["CODEX_RETRY_GATEWAY_MANAGEMENT_ACCESS_KEY", managementAccessKey]);
|
||
}
|
||
|
||
const modelRemap = `${payload.model_remap || ""}`.trim();
|
||
if (modelRemap) {
|
||
envPairs.push(["CODEX_RETRY_GATEWAY_MODEL_REMAP", modelRemap]);
|
||
}
|
||
|
||
if (authMode === "manual_bearer") {
|
||
const secretPath =
|
||
(await writeManualSecret(name, payload.manual_secret)) ||
|
||
`${payload.manual_secret_file || ""}`.trim() ||
|
||
defaultManualSecretPath(name);
|
||
if (!fs.existsSync(secretPath)) {
|
||
throw new Error("manual_bearer 需要手动填入一次 token/password 后才能保存");
|
||
}
|
||
envPairs.push(["CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE", secretPath]);
|
||
} else if (authMode === "fixed_bearer") {
|
||
envPairs.push([
|
||
"CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV",
|
||
`${payload.auth_env || DEFAULT_CONFIG.upstream_auth_env}`.trim(),
|
||
]);
|
||
if (`${payload.auth_file || ""}`.trim()) {
|
||
envPairs.push(["CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE", `${payload.auth_file}`.trim()]);
|
||
}
|
||
} else if (authMode === "auth_json") {
|
||
if (`${payload.auth_json_path || ""}`.trim()) {
|
||
envPairs.push(["CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_PATH", `${payload.auth_json_path}`.trim()]);
|
||
}
|
||
envPairs.push([
|
||
"CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY",
|
||
`${payload.auth_json_key || DEFAULT_CONFIG.upstream_auth_json_key}`.trim(),
|
||
]);
|
||
}
|
||
|
||
const lines = [
|
||
"# Managed by codex-retry-gateway UI.",
|
||
"# Do not put raw sk-* secrets here; use env/file/auth.json references.",
|
||
...envPairs.map(([key, value]) => `${key}=${serializeEnvValue(value)}`),
|
||
"",
|
||
];
|
||
|
||
return {
|
||
name,
|
||
content: lines.join("\n"),
|
||
};
|
||
}
|
||
|
||
async function buildImageProfileEnvText(payload) {
|
||
assertNoInlineProfileSecret(payload, [
|
||
"base_url",
|
||
"auth_env",
|
||
"auth_file",
|
||
"auth_json_path",
|
||
"auth_json_key",
|
||
]);
|
||
|
||
const name = `${payload?.name || ""}`.trim();
|
||
validateProfileName(name);
|
||
|
||
const baseUrl = `${payload?.base_url || ""}`.trim();
|
||
if (baseUrl) {
|
||
try {
|
||
new URL(baseUrl);
|
||
} catch {
|
||
throw new Error("图片上游 Base URL 必须是合法 URL");
|
||
}
|
||
}
|
||
|
||
const authMode = normalizeAuthMode(payload?.auth_mode || DEFAULT_CONFIG.image_auth_mode);
|
||
const envPairs = [
|
||
["CODEX_RETRY_GATEWAY_IMAGE_BASE_URL", baseUrl],
|
||
["CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE", authMode],
|
||
];
|
||
|
||
if (baseUrl && authMode === "manual_bearer") {
|
||
const secretPath =
|
||
(await writeImageManualSecret(name, payload?.manual_secret)) ||
|
||
`${payload?.manual_secret_file || ""}`.trim() ||
|
||
defaultImageManualSecretPath(name);
|
||
if (!fs.existsSync(secretPath)) {
|
||
throw new Error("图片 manual_bearer 需要手动填入一次 API key 后才能保存");
|
||
}
|
||
envPairs.push(["CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE", secretPath]);
|
||
} else if (baseUrl && authMode === "fixed_bearer") {
|
||
envPairs.push([
|
||
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV",
|
||
`${payload?.auth_env || DEFAULT_CONFIG.image_auth_env}`.trim(),
|
||
]);
|
||
if (`${payload?.auth_file || ""}`.trim()) {
|
||
envPairs.push(["CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE", `${payload.auth_file}`.trim()]);
|
||
}
|
||
} else if (baseUrl && authMode === "auth_json") {
|
||
if (`${payload?.auth_json_path || ""}`.trim()) {
|
||
envPairs.push(["CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_PATH", `${payload.auth_json_path}`.trim()]);
|
||
}
|
||
envPairs.push([
|
||
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY",
|
||
`${payload?.auth_json_key || DEFAULT_CONFIG.image_auth_json_key}`.trim(),
|
||
]);
|
||
}
|
||
|
||
return {
|
||
name,
|
||
content: [
|
||
"# Managed by codex-retry-gateway image profile UI.",
|
||
"# Do not put raw sk-* secrets here; use env/file/auth.json references.",
|
||
...envPairs.map(([key, value]) => `${key}=${serializeEnvValue(value)}`),
|
||
"",
|
||
].join("\n"),
|
||
};
|
||
}
|
||
|
||
async function writeProfile(runtime, payload) {
|
||
const { name, content } = await buildProfileEnvText(payload);
|
||
await mkdir(runtime.paths.profilesDir, { recursive: true });
|
||
const profilePath = path.join(runtime.paths.profilesDir, `${name}.env`);
|
||
await writeFile(profilePath, content, { encoding: "utf8", mode: 0o600 });
|
||
return {
|
||
name,
|
||
file_path: profilePath,
|
||
};
|
||
}
|
||
|
||
async function writeImageProfile(runtime, payload) {
|
||
const { name, content } = await buildImageProfileEnvText(payload);
|
||
await mkdir(runtime.paths.imageProfilesDir, { recursive: true });
|
||
const profilePath = path.join(runtime.paths.imageProfilesDir, `${name}.env`);
|
||
await writeFile(profilePath, content, { encoding: "utf8", mode: 0o600 });
|
||
return {
|
||
name,
|
||
file_path: profilePath,
|
||
};
|
||
}
|
||
|
||
function buildMetricsSnapshot(monitor) {
|
||
const reasoning516Count = monitor.observed_reasoning_counts["516"] || 0;
|
||
const inspectedResponseCount = monitor.inspected_response_count;
|
||
const reasoningEntries = Object.entries(monitor.observed_reasoning_counts).sort((left, right) => {
|
||
const countDelta = Number(right[1] || 0) - Number(left[1] || 0);
|
||
if (countDelta !== 0) {
|
||
return countDelta;
|
||
}
|
||
return Number(left[0] || 0) - Number(right[0] || 0);
|
||
});
|
||
const visibleReasoningEntries = reasoningEntries.slice(0, STATUS_REASONING_COUNT_LIMIT);
|
||
return {
|
||
started_at: monitor.started_at,
|
||
persistent_since: monitor.persistent_since,
|
||
total_proxy_request_count: monitor.total_proxy_request_count,
|
||
inspected_response_count: inspectedResponseCount,
|
||
matched_response_count: monitor.matched_response_count,
|
||
reasoning_516_count: reasoning516Count,
|
||
reasoning_516_ratio:
|
||
inspectedResponseCount === 0 ? 0 : reasoning516Count / inspectedResponseCount,
|
||
token_totals: { ...monitor.token_totals },
|
||
observed_reasoning_counts: Object.fromEntries(visibleReasoningEntries),
|
||
observed_reasoning_counts_total_keys: reasoningEntries.length,
|
||
observed_reasoning_counts_omitted: Math.max(
|
||
0,
|
||
reasoningEntries.length - visibleReasoningEntries.length,
|
||
),
|
||
};
|
||
}
|
||
|
||
function buildLogsSnapshot(monitor, sinceSeq = null) {
|
||
const entries = Number.isInteger(sinceSeq)
|
||
? monitor.log_entries.filter((entry) => entry.seq > sinceSeq)
|
||
: monitor.log_entries;
|
||
|
||
return {
|
||
total_entries: monitor.log_entries.length,
|
||
latest_seq: monitor.next_log_seq - 1,
|
||
entries,
|
||
};
|
||
}
|
||
|
||
async function buildPersistentLogsSnapshot(runtime, sinceSeq = null, limit = 500) {
|
||
const safeLimit = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 1000) : 500;
|
||
const text = runtime.logPath ? await readOptionalText(runtime.logPath) : null;
|
||
if (!text) {
|
||
return buildLogsSnapshot(runtime.monitor, sinceSeq);
|
||
}
|
||
|
||
const allEntries = text
|
||
.split(/\r?\n/)
|
||
.filter(Boolean)
|
||
.map((line, index) => parseLogLine(line, index + 1));
|
||
const entries = Number.isInteger(sinceSeq)
|
||
? allEntries.filter((entry) => entry.seq > sinceSeq)
|
||
: allEntries.slice(-safeLimit);
|
||
|
||
return {
|
||
total_entries: allEntries.length,
|
||
latest_seq: allEntries.length,
|
||
entries,
|
||
};
|
||
}
|
||
|
||
function buildRequestsSnapshot(monitor, limit = 50) {
|
||
const safeLimit = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 500) : 50;
|
||
const entries = monitor.request_entries.slice(-safeLimit).reverse();
|
||
return {
|
||
total_entries: monitor.request_entries.length,
|
||
latest_seq: monitor.next_request_seq - 1,
|
||
entries,
|
||
};
|
||
}
|
||
|
||
async function buildPersistentRequestsSnapshot(runtime, { limit = 50, offset = 0, query = "", filter = "all" } = {}) {
|
||
const safeLimit = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 500) : 50;
|
||
const safeOffset = Number.isInteger(offset) && offset > 0 ? offset : 0;
|
||
if (!runtime.requestsDb) {
|
||
const entries = await readJsonlFile(runtime.paths.requestsPath);
|
||
return {
|
||
total_entries: entries.length,
|
||
latest_seq: entries.reduce((maxSeq, entry) => {
|
||
return Math.max(maxSeq, Number.isInteger(entry.seq) ? entry.seq : maxSeq);
|
||
}, 0),
|
||
entries: entries.slice(-safeLimit).reverse().map((entry) => decorateRequestEntryWithThreadRule(runtime, entry)),
|
||
};
|
||
}
|
||
|
||
const { whereSql, params } = buildRequestQueryFilters({ query, filter });
|
||
const totalRow = runtime.requestsDb.prepare(`SELECT COUNT(*) AS count FROM requests ${whereSql}`).get(params);
|
||
const latestRow = runtime.requestsDb.prepare("SELECT MAX(seq) AS latest_seq FROM requests").get();
|
||
const rows = runtime.requestsDb.prepare(`
|
||
SELECT payload_json
|
||
FROM requests
|
||
${whereSql}
|
||
ORDER BY seq DESC
|
||
LIMIT @limit OFFSET @offset
|
||
`).all({
|
||
...params,
|
||
limit: safeLimit,
|
||
offset: safeOffset,
|
||
});
|
||
|
||
return {
|
||
total_entries: totalRow?.count || 0,
|
||
latest_seq: latestRow?.latest_seq || 0,
|
||
entries: rows.map(parseRequestRowPayload).filter(Boolean).map((entry) => decorateRequestEntryWithThreadRule(runtime, entry)),
|
||
};
|
||
}
|
||
|
||
function buildRequestEntry({ seq, startedAt, startedMs, req, pathname, requestJson, profileName }) {
|
||
return {
|
||
seq,
|
||
request_id: null,
|
||
response_id: null,
|
||
thread_id: firstNonEmptyString(
|
||
extractRequestThreadId(requestJson),
|
||
extractHeaderThreadId(req?.headers),
|
||
),
|
||
lifecycle_state: "sent",
|
||
started_at: startedAt.toISOString(),
|
||
first_response_at: null,
|
||
first_response_delay_ms: null,
|
||
last_activity_at: null,
|
||
finished_at: null,
|
||
duration_ms: null,
|
||
profile_name: profileName || "default",
|
||
method: req.method,
|
||
path: pathname,
|
||
request_body_bytes: null,
|
||
response_bytes_received: 0,
|
||
model: requestJson?.model || null,
|
||
requested_model: requestJson?.model || null,
|
||
forwarded_model: requestJson?.model || null,
|
||
reasoning_effort: extractRequestReasoningEffort(requestJson),
|
||
reasoning_summary: extractRequestReasoningSummary(requestJson),
|
||
request_stream: Boolean(requestJson?.stream),
|
||
response_stream: false,
|
||
stream_chunk_count: 0,
|
||
usage_last_updated_at: null,
|
||
upstream_attempt_count: 0,
|
||
reasoning_guard_enabled: true,
|
||
reasoning_guard_thread_override: "default",
|
||
reasoning_retry_enabled: false,
|
||
reasoning_retry_query_count: 0,
|
||
reasoning_retry_round_count: 0,
|
||
reasoning_retry_current_round: null,
|
||
reasoning_retry_current_width: 0,
|
||
reasoning_retry_current_firsts: [],
|
||
reasoning_retry_winner_round: null,
|
||
reasoning_retry_winner_slot: null,
|
||
reasoning_retry_stop_reason: null,
|
||
reasoning_retry_thread_mode: "disabled",
|
||
reasoning_retry_extra_inspected_count: 0,
|
||
reasoning_retry_extra_matched_count: 0,
|
||
reasoning_retry_extra_usage: null,
|
||
reasoning_retry_extra_reasoning_counts: {},
|
||
inspected: false,
|
||
matched: false,
|
||
status_code: null,
|
||
upstream_status_code: null,
|
||
upstream: null,
|
||
reasoning_tokens: null,
|
||
usage: null,
|
||
error: null,
|
||
_started_ms: startedMs,
|
||
};
|
||
}
|
||
|
||
function markRequestFirstResponse(entry, at = new Date()) {
|
||
if (entry.first_response_at) {
|
||
return false;
|
||
}
|
||
const firstAt = at instanceof Date ? at : new Date(at);
|
||
const firstMs = firstAt.getTime();
|
||
entry.first_response_at = firstAt.toISOString();
|
||
entry.first_response_delay_ms = Number.isFinite(entry._started_ms)
|
||
? Math.max(0, firstMs - entry._started_ms)
|
||
: null;
|
||
entry.last_activity_at = firstAt.toISOString();
|
||
entry.lifecycle_state = "receive_first";
|
||
entry._first_response_observer?.({
|
||
first_response_at: entry.first_response_at,
|
||
first_response_delay_ms: entry.first_response_delay_ms,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
const STREAM_PROGRESS_PERSIST_INTERVAL_MS = 1000;
|
||
|
||
function updateStreamingProgress(entry, { chunkBytes = 0, usage = null, reasoning = null, at = new Date() } = {}) {
|
||
const observedAt = at instanceof Date ? at : new Date(at);
|
||
const observedAtIso = observedAt.toISOString();
|
||
|
||
entry.last_activity_at = observedAtIso;
|
||
entry.lifecycle_state = "streaming";
|
||
|
||
if (chunkBytes > 0) {
|
||
entry.stream_chunk_count = (entry.stream_chunk_count || 0) + 1;
|
||
entry.response_bytes_received = (entry.response_bytes_received || 0) + chunkBytes;
|
||
}
|
||
|
||
if (usage) {
|
||
entry.usage = mergeUsageSnapshots(entry.usage, usage);
|
||
entry.usage_last_updated_at = observedAtIso;
|
||
}
|
||
|
||
if (Number.isInteger(reasoning)) {
|
||
entry.reasoning_tokens = reasoning;
|
||
}
|
||
}
|
||
|
||
function persistStreamingProgress(runtime, entry, { force = false, usageUpdated = false } = {}, at = new Date()) {
|
||
const observedAt = at instanceof Date ? at : new Date(at);
|
||
const nowMs = observedAt.getTime();
|
||
const lastPersistedMs = Number.isFinite(entry._last_stream_persisted_ms)
|
||
? entry._last_stream_persisted_ms
|
||
: 0;
|
||
if (!force && !usageUpdated && nowMs - lastPersistedMs < STREAM_PROGRESS_PERSIST_INTERVAL_MS) {
|
||
return false;
|
||
}
|
||
entry._last_stream_persisted_ms = nowMs;
|
||
upsertRequestEntry(runtime, entry);
|
||
return true;
|
||
}
|
||
|
||
function finalizeRequestEntry(entry, result = {}) {
|
||
const finishedAt = new Date();
|
||
const startedMs = entry._started_ms;
|
||
delete entry._started_ms;
|
||
delete entry._last_stream_persisted_ms;
|
||
return {
|
||
...entry,
|
||
...result,
|
||
lifecycle_state: "finish",
|
||
finished_at: finishedAt.toISOString(),
|
||
duration_ms: Number.isFinite(startedMs) ? Math.max(0, Date.now() - startedMs) : null,
|
||
};
|
||
}
|
||
|
||
async function loadConfig(configPath) {
|
||
const content = await readFile(configPath, "utf8");
|
||
const loaded = JSON.parse(content);
|
||
const config = { ...DEFAULT_CONFIG, ...loaded };
|
||
config.image_profile_name = /^[A-Za-z0-9_.-]+$/.test(`${config.image_profile_name || ""}`)
|
||
? `${config.image_profile_name}`
|
||
: "";
|
||
config.model_remap_map = parseModelRemapMap(config.model_remap);
|
||
config.endpoints = normalizeStringList(config.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath);
|
||
config.reasoning_match_mode = normalizeReasoningMatchMode(config.reasoning_match_mode);
|
||
config.reasoning_equals = normalizeReasoningEquals(
|
||
config.reasoning_equals,
|
||
DEFAULT_CONFIG.reasoning_equals,
|
||
);
|
||
config.retryable_status_codes = normalizeIntegerList(
|
||
config.retryable_status_codes,
|
||
DEFAULT_CONFIG.retryable_status_codes,
|
||
);
|
||
config.retryable_error_messages = normalizePhraseList(
|
||
config.retryable_error_messages,
|
||
DEFAULT_CONFIG.retryable_error_messages,
|
||
);
|
||
config.management_access_key = normalizeManagementAccessKey(config.management_access_key);
|
||
config.upstream_fetch_retry_attempts = normalizePositiveInteger(
|
||
config.upstream_fetch_retry_attempts,
|
||
DEFAULT_CONFIG.upstream_fetch_retry_attempts,
|
||
);
|
||
config.upstream_fetch_retry_backoff_ms = normalizeNonNegativeInteger(
|
||
config.upstream_fetch_retry_backoff_ms,
|
||
DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms,
|
||
);
|
||
config.image_base_url = `${config.image_base_url || ""}`.trim();
|
||
if (config.image_base_url) {
|
||
try {
|
||
new URL(config.image_base_url);
|
||
} catch {
|
||
throw new Error("配置中的 image_base_url 必须是合法 URL");
|
||
}
|
||
}
|
||
if (!config.upstream_base_url) {
|
||
throw new Error("配置缺少 upstream_base_url");
|
||
}
|
||
return config;
|
||
}
|
||
|
||
function buildRuntimePaths(configPath, logPath) {
|
||
const configDirectory = path.dirname(configPath);
|
||
const stateRoot = path.dirname(configDirectory);
|
||
const homeDir = process.env.HOME || "";
|
||
return {
|
||
stateRoot,
|
||
statePath: path.join(stateRoot, "state.json"),
|
||
pidPath: path.join(stateRoot, "gateway.pid"),
|
||
profilesDir: path.join(homeDir, ".config", "codex-retry-gateway", "profiles"),
|
||
imageProfilesDir: path.join(homeDir, ".config", "codex-retry-gateway", "image-profiles"),
|
||
configPath,
|
||
logPath,
|
||
threadRulesPath: path.join(stateRoot, "thread-rules.json"),
|
||
requestsPath: path.join(stateRoot, "logs", "requests.jsonl"),
|
||
requestsDbPath: path.join(stateRoot, "logs", "requests.sqlite"),
|
||
};
|
||
}
|
||
|
||
async function readOptionalJson(jsonPath) {
|
||
try {
|
||
const content = await readFile(jsonPath, "utf8");
|
||
return JSON.parse(content);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function normalizeThreadRuleEntry(value) {
|
||
const threadId = firstNonEmptyString(value?.thread_id, value?.threadId);
|
||
if (!threadId || value?.reasoning_intercept_enabled === undefined) {
|
||
return null;
|
||
}
|
||
return {
|
||
thread_id: threadId,
|
||
reasoning_intercept_enabled: Boolean(value.reasoning_intercept_enabled),
|
||
updated_at: firstNonEmptyString(value?.updated_at) || new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
function normalizeThreadRulesDocument(payload) {
|
||
const rules = Array.isArray(payload)
|
||
? payload
|
||
: Array.isArray(payload?.rules)
|
||
? payload.rules
|
||
: [];
|
||
const entries = [];
|
||
for (const rule of rules) {
|
||
const normalized = normalizeThreadRuleEntry(rule);
|
||
if (normalized) {
|
||
entries.push(normalized);
|
||
}
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
function compareThreadRuleEntries(left, right) {
|
||
const leftTime = Date.parse(left?.updated_at || "") || 0;
|
||
const rightTime = Date.parse(right?.updated_at || "") || 0;
|
||
if (rightTime !== leftTime) {
|
||
return rightTime - leftTime;
|
||
}
|
||
return `${left?.thread_id || ""}`.localeCompare(`${right?.thread_id || ""}`);
|
||
}
|
||
|
||
function buildThreadRulesMap(entries = []) {
|
||
const map = new Map();
|
||
for (const entry of entries) {
|
||
map.set(entry.thread_id, entry);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
async function loadThreadRules(filePath) {
|
||
const payload = await readOptionalJson(filePath);
|
||
return buildThreadRulesMap(normalizeThreadRulesDocument(payload));
|
||
}
|
||
|
||
function serializeThreadRules(threadRules) {
|
||
const rules = Array.from(threadRules?.values?.() || [])
|
||
.map((entry) => ({
|
||
thread_id: entry.thread_id,
|
||
reasoning_intercept_enabled: Boolean(entry.reasoning_intercept_enabled),
|
||
updated_at: entry.updated_at || new Date().toISOString(),
|
||
}))
|
||
.sort(compareThreadRuleEntries);
|
||
return {
|
||
version: 1,
|
||
rules,
|
||
};
|
||
}
|
||
|
||
async function writeThreadRules(filePath, threadRules) {
|
||
await mkdir(path.dirname(filePath), { recursive: true });
|
||
await writeFile(filePath, `${JSON.stringify(serializeThreadRules(threadRules), null, 2)}\n`, "utf8");
|
||
}
|
||
|
||
function listThreadRules(runtime) {
|
||
return serializeThreadRules(runtime.threadRules).rules;
|
||
}
|
||
|
||
function getThreadReasoningState(runtime, threadId) {
|
||
const normalizedThreadId = firstNonEmptyString(threadId);
|
||
if (!normalizedThreadId) {
|
||
return {
|
||
thread_id: null,
|
||
reasoning_intercept_enabled: true,
|
||
override: "default",
|
||
updated_at: null,
|
||
};
|
||
}
|
||
const entry = runtime.threadRules?.get(normalizedThreadId) || null;
|
||
if (!entry) {
|
||
return {
|
||
thread_id: normalizedThreadId,
|
||
reasoning_intercept_enabled: true,
|
||
override: "default",
|
||
updated_at: null,
|
||
};
|
||
}
|
||
return {
|
||
thread_id: normalizedThreadId,
|
||
reasoning_intercept_enabled: Boolean(entry.reasoning_intercept_enabled),
|
||
override: entry.reasoning_intercept_enabled ? "enabled" : "disabled",
|
||
updated_at: entry.updated_at || null,
|
||
};
|
||
}
|
||
|
||
function applyThreadReasoningState(runtime, requestEntry, pathname = requestEntry?.path) {
|
||
if (!requestEntry || typeof requestEntry !== "object") {
|
||
return requestEntry;
|
||
}
|
||
const threadState = getThreadReasoningState(runtime, requestEntry.thread_id);
|
||
requestEntry.reasoning_guard_enabled = threadState.reasoning_intercept_enabled;
|
||
requestEntry.reasoning_guard_thread_override = threadState.override;
|
||
const retryPath = isResponsesReasoningRetryPath(pathname);
|
||
requestEntry.reasoning_retry_enabled = retryPath && threadState.reasoning_intercept_enabled;
|
||
if (!retryPath) {
|
||
requestEntry.reasoning_retry_thread_mode = "disabled";
|
||
return requestEntry;
|
||
}
|
||
if (!threadState.reasoning_intercept_enabled) {
|
||
requestEntry.reasoning_retry_thread_mode = "thread_guard_disabled";
|
||
if (
|
||
!requestEntry.reasoning_retry_stop_reason ||
|
||
requestEntry.reasoning_retry_stop_reason === "missing_thread_id"
|
||
) {
|
||
requestEntry.reasoning_retry_stop_reason = "thread_guard_disabled";
|
||
}
|
||
return requestEntry;
|
||
}
|
||
if (requestEntry.thread_id) {
|
||
requestEntry.reasoning_retry_thread_mode = "thread_id";
|
||
if (requestEntry.reasoning_retry_stop_reason === "thread_guard_disabled") {
|
||
requestEntry.reasoning_retry_stop_reason = null;
|
||
}
|
||
return requestEntry;
|
||
}
|
||
requestEntry.reasoning_retry_thread_mode = "missing_thread_id";
|
||
if (!requestEntry.reasoning_retry_stop_reason) {
|
||
requestEntry.reasoning_retry_stop_reason = "missing_thread_id";
|
||
}
|
||
return requestEntry;
|
||
}
|
||
|
||
function decorateRequestEntryWithThreadRule(runtime, entry) {
|
||
if (!entry || typeof entry !== "object") {
|
||
return entry;
|
||
}
|
||
const threadState = getThreadReasoningState(runtime, entry.thread_id);
|
||
return {
|
||
...entry,
|
||
reasoning_guard_enabled: threadState.reasoning_intercept_enabled,
|
||
reasoning_guard_thread_override: threadState.override,
|
||
};
|
||
}
|
||
|
||
function normalizeThreadRuleUpdatePayload(payload) {
|
||
const threadId = firstNonEmptyString(payload?.thread_id, payload?.threadId);
|
||
if (!threadId) {
|
||
throw new Error("缺少 thread_id");
|
||
}
|
||
if (payload?.reasoning_intercept_enabled === undefined) {
|
||
throw new Error("缺少 reasoning_intercept_enabled");
|
||
}
|
||
return {
|
||
thread_id: threadId,
|
||
reasoning_intercept_enabled: Boolean(payload.reasoning_intercept_enabled),
|
||
updated_at: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
async function upsertThreadRule(runtime, payload) {
|
||
const entry = normalizeThreadRuleUpdatePayload(payload);
|
||
runtime.threadRules.set(entry.thread_id, entry);
|
||
await writeThreadRules(runtime.paths.threadRulesPath, runtime.threadRules);
|
||
return entry;
|
||
}
|
||
|
||
async function deleteThreadRule(runtime, threadId) {
|
||
const normalizedThreadId = firstNonEmptyString(threadId);
|
||
if (!normalizedThreadId) {
|
||
throw new Error("缺少 thread_id");
|
||
}
|
||
const previous = runtime.threadRules.get(normalizedThreadId) || null;
|
||
runtime.threadRules.delete(normalizedThreadId);
|
||
await writeThreadRules(runtime.paths.threadRulesPath, runtime.threadRules);
|
||
return previous;
|
||
}
|
||
|
||
async function readOptionalText(textPath) {
|
||
try {
|
||
return await readFile(textPath, "utf8");
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function normalizeAuthMode(value) {
|
||
const mode = `${value || "passthrough"}`.trim().toLowerCase();
|
||
if (["passthrough", "fixed_bearer", "manual_bearer", "auth_json"].includes(mode)) {
|
||
return mode;
|
||
}
|
||
return "passthrough";
|
||
}
|
||
|
||
function sanitizeConfigForStatus(config) {
|
||
const {
|
||
management_access_key,
|
||
upstream_auth_file,
|
||
upstream_auth_json_path,
|
||
upstream_auth_env,
|
||
upstream_auth_json_key,
|
||
image_auth_file,
|
||
image_auth_json_path,
|
||
image_auth_env,
|
||
image_auth_json_key,
|
||
model_remap_map,
|
||
...rest
|
||
} = config;
|
||
|
||
return {
|
||
...rest,
|
||
management_access_key_configured: Boolean(management_access_key),
|
||
upstream_auth_env: upstream_auth_env || null,
|
||
upstream_auth_file: upstream_auth_file ? "[configured]" : "",
|
||
upstream_auth_json_path: upstream_auth_json_path ? "[configured]" : "",
|
||
upstream_auth_json_key: upstream_auth_json_key || null,
|
||
image_auth_env: image_auth_env || null,
|
||
image_auth_file: image_auth_file ? "[configured]" : "",
|
||
image_auth_json_path: image_auth_json_path ? "[configured]" : "",
|
||
image_auth_json_key: image_auth_json_key || null,
|
||
};
|
||
}
|
||
|
||
function remapRequestModel(config, requestJson) {
|
||
if (!requestJson || typeof requestJson !== "object") {
|
||
return { requestJson, remapped: false, forwardedModel: null };
|
||
}
|
||
const requestedModel = `${requestJson.model || ""}`.trim();
|
||
if (!requestedModel) {
|
||
return { requestJson, remapped: false, forwardedModel: null };
|
||
}
|
||
const forwardedModel = config.model_remap_map?.[requestedModel];
|
||
if (!forwardedModel || forwardedModel === requestedModel) {
|
||
return { requestJson, remapped: false, forwardedModel: requestedModel };
|
||
}
|
||
return {
|
||
requestJson: {
|
||
...requestJson,
|
||
model: forwardedModel,
|
||
},
|
||
remapped: true,
|
||
forwardedModel,
|
||
};
|
||
}
|
||
|
||
async function resolveUpstreamAuth(config, authScope = "upstream") {
|
||
const mode = normalizeAuthMode(config.upstream_auth_mode);
|
||
if (mode === "passthrough") {
|
||
return { mode, authorization: null, source: "passthrough" };
|
||
}
|
||
|
||
let token = "";
|
||
let source = "missing";
|
||
if (mode === "fixed_bearer") {
|
||
const envName = config.upstream_auth_env || DEFAULT_CONFIG.upstream_auth_env;
|
||
token = envName ? `${process.env[envName] || ""}`.trim() : "";
|
||
source = token ? "env" : "missing";
|
||
if (!token && config.upstream_auth_file) {
|
||
token = `${(await readOptionalText(config.upstream_auth_file)) || ""}`.trim();
|
||
source = token ? "file" : "file_empty";
|
||
}
|
||
} else if (mode === "manual_bearer") {
|
||
if (config.upstream_auth_file) {
|
||
token = `${(await readOptionalText(config.upstream_auth_file)) || ""}`.trim();
|
||
source = token ? "manual_file" : "manual_file_empty";
|
||
}
|
||
} else if (mode === "auth_json") {
|
||
const authPath = config.upstream_auth_json_path || path.join(process.env.HOME || "", ".codex", "auth.json");
|
||
const key = config.upstream_auth_json_key || DEFAULT_CONFIG.upstream_auth_json_key;
|
||
const authJson = await readOptionalJson(authPath);
|
||
token = `${authJson?.[key] || ""}`.trim();
|
||
source = token ? "auth_json" : "auth_json_missing";
|
||
}
|
||
|
||
if (!token) {
|
||
throw new Error(`${authScope}_auth_mode=${mode} requires a configured token source`);
|
||
}
|
||
|
||
const authorization = token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`;
|
||
return { mode, authorization, source };
|
||
}
|
||
|
||
async function writeConfig(configPath, config) {
|
||
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||
}
|
||
|
||
async function updateRuntimeState(runtime, updates) {
|
||
const current = await readOptionalJson(runtime.paths.statePath);
|
||
if (!current) {
|
||
return;
|
||
}
|
||
await writeFile(
|
||
runtime.paths.statePath,
|
||
`${JSON.stringify({ ...current, ...updates }, null, 2)}\n`,
|
||
"utf8",
|
||
);
|
||
}
|
||
|
||
async function listProfiles(runtime) {
|
||
let files = [];
|
||
try {
|
||
files = await readdir(runtime.paths.profilesDir, { withFileTypes: true });
|
||
} catch {
|
||
files = [];
|
||
}
|
||
|
||
const activeProfile = runtime.config.profile_name || "default";
|
||
const profiles = [];
|
||
for (const file of files) {
|
||
if (!file.isFile()) {
|
||
continue;
|
||
}
|
||
const name = getProfileNameFromFile(file.name);
|
||
if (!name) {
|
||
continue;
|
||
}
|
||
const filePath = path.join(runtime.paths.profilesDir, file.name);
|
||
const env = parseEnvText((await readOptionalText(filePath)) || "");
|
||
const form = buildProfileFormModel(env);
|
||
profiles.push({
|
||
name,
|
||
active: name === activeProfile,
|
||
file_path: filePath,
|
||
summary: {
|
||
listen_host: form.listen_host,
|
||
listen_port: form.listen_port,
|
||
upstream_base_url: form.upstream_base_url,
|
||
auth_mode: form.auth_mode,
|
||
auth_env: redactProfileValue(
|
||
"CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV",
|
||
form.auth_env,
|
||
),
|
||
auth_file: form.auth_file ? "[configured]" : "",
|
||
auth_json_path: form.auth_json_path ? "[configured]" : "",
|
||
auth_json_key: redactProfileValue(
|
||
"CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_JSON_KEY",
|
||
form.auth_json_key,
|
||
),
|
||
request_history_limit: form.request_history_limit,
|
||
model_remap: form.model_remap || "",
|
||
auth_source: summarizeProfileAuthSource(env),
|
||
reasoning_match_mode: form.reasoning_match_mode,
|
||
reasoning_equals: form.reasoning_equals,
|
||
},
|
||
form,
|
||
});
|
||
}
|
||
profiles.sort((left, right) => left.name.localeCompare(right.name));
|
||
return profiles;
|
||
}
|
||
|
||
const IMAGE_PROFILE_ENV_KEYS = [
|
||
"CODEX_RETRY_GATEWAY_IMAGE_BASE_URL",
|
||
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE",
|
||
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV",
|
||
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE",
|
||
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_PATH",
|
||
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY",
|
||
];
|
||
|
||
function hasLegacyImageProfileConfig(env) {
|
||
return Boolean(`${env?.CODEX_RETRY_GATEWAY_IMAGE_BASE_URL || ""}`.trim());
|
||
}
|
||
|
||
function legacyImageProfileEnvFromConfig(config) {
|
||
const baseUrl = `${config?.image_base_url || ""}`.trim();
|
||
if (!baseUrl) {
|
||
return {};
|
||
}
|
||
return {
|
||
CODEX_RETRY_GATEWAY_IMAGE_BASE_URL: baseUrl,
|
||
CODEX_RETRY_GATEWAY_IMAGE_AUTH_MODE: config?.image_auth_mode || DEFAULT_CONFIG.image_auth_mode,
|
||
CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV: config?.image_auth_env || "",
|
||
CODEX_RETRY_GATEWAY_IMAGE_AUTH_FILE: config?.image_auth_file || "",
|
||
CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_PATH: config?.image_auth_json_path || "",
|
||
CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY: config?.image_auth_json_key || "",
|
||
};
|
||
}
|
||
|
||
function buildLegacyImageProfileEnvText(env) {
|
||
const pairs = IMAGE_PROFILE_ENV_KEYS
|
||
.filter((key) => env?.[key] !== undefined)
|
||
.map((key) => [key, env[key]]);
|
||
return [
|
||
"# Migrated from a legacy text profile by codex-retry-gateway.",
|
||
"# Image configuration is now independent from text profiles.",
|
||
...pairs.map(([key, value]) => `${key}=${serializeEnvValue(value)}`),
|
||
"",
|
||
].join("\n");
|
||
}
|
||
|
||
async function migrateLegacyImageProfile(runtime, textProfileName, textEnv = null, legacyConfig = null) {
|
||
validateProfileName(textProfileName);
|
||
const imageProfilePath = path.join(runtime.paths.imageProfilesDir, `${textProfileName}.env`);
|
||
if (fs.existsSync(imageProfilePath)) {
|
||
return { name: textProfileName, file_path: imageProfilePath, migrated: false };
|
||
}
|
||
|
||
let env = textEnv;
|
||
if (!env) {
|
||
const textProfilePath = path.join(runtime.paths.profilesDir, `${textProfileName}.env`);
|
||
if (fs.existsSync(textProfilePath)) {
|
||
env = parseEnvText((await readOptionalText(textProfilePath)) || "");
|
||
} else {
|
||
env = {};
|
||
}
|
||
}
|
||
const sourceEnv = hasLegacyImageProfileConfig(env)
|
||
? env
|
||
: legacyImageProfileEnvFromConfig(legacyConfig);
|
||
if (!hasLegacyImageProfileConfig(sourceEnv)) {
|
||
return null;
|
||
}
|
||
|
||
await mkdir(runtime.paths.imageProfilesDir, { recursive: true });
|
||
await writeFile(imageProfilePath, buildLegacyImageProfileEnvText(sourceEnv), {
|
||
encoding: "utf8",
|
||
mode: 0o600,
|
||
});
|
||
return { name: textProfileName, file_path: imageProfilePath, migrated: true };
|
||
}
|
||
|
||
async function listImageProfiles(runtime) {
|
||
let files = [];
|
||
try {
|
||
files = await readdir(runtime.paths.imageProfilesDir, { withFileTypes: true });
|
||
} catch {
|
||
files = [];
|
||
}
|
||
|
||
const activeProfile = `${runtime.config.image_profile_name || ""}`.trim();
|
||
const profiles = [];
|
||
for (const file of files) {
|
||
if (!file.isFile()) {
|
||
continue;
|
||
}
|
||
const name = getProfileNameFromFile(file.name);
|
||
if (!name) {
|
||
continue;
|
||
}
|
||
const filePath = path.join(runtime.paths.imageProfilesDir, file.name);
|
||
const env = parseEnvText((await readOptionalText(filePath)) || "");
|
||
const form = buildImageProfileFormModel(env);
|
||
profiles.push({
|
||
name,
|
||
active: name === activeProfile,
|
||
file_path: filePath,
|
||
summary: {
|
||
base_url: form.base_url,
|
||
auth_mode: form.auth_mode,
|
||
auth_env: redactProfileValue("CODEX_RETRY_GATEWAY_IMAGE_AUTH_ENV", form.auth_env),
|
||
auth_file: form.auth_file ? "[configured]" : "",
|
||
manual_secret_file: form.manual_secret_file ? "[configured]" : "",
|
||
auth_json_path: form.auth_json_path ? "[configured]" : "",
|
||
auth_json_key: redactProfileValue(
|
||
"CODEX_RETRY_GATEWAY_IMAGE_AUTH_JSON_KEY",
|
||
form.auth_json_key,
|
||
),
|
||
auth_source: form.base_url ? summarizeImageProfileAuthSource(env) : "disabled",
|
||
},
|
||
form,
|
||
});
|
||
}
|
||
profiles.sort((left, right) => left.name.localeCompare(right.name));
|
||
return profiles;
|
||
}
|
||
|
||
function runDetached(command, args) {
|
||
const child = spawn(command, args, {
|
||
detached: true,
|
||
stdio: "ignore",
|
||
windowsHide: true,
|
||
});
|
||
child.unref();
|
||
return child.pid;
|
||
}
|
||
|
||
async function triggerProfileSwitch(runtime, profileName) {
|
||
if (!/^[A-Za-z0-9_.-]+$/.test(profileName)) {
|
||
throw new Error("profile 名称只能包含字母、数字、下划线、点和短横线");
|
||
}
|
||
|
||
const profilePath = path.join(runtime.paths.profilesDir, `${profileName}.env`);
|
||
if (!fs.existsSync(profilePath)) {
|
||
throw new Error(`profile 不存在: ${profileName}`);
|
||
}
|
||
|
||
const currentUnit = `codex-retry-gateway@${runtime.config.profile_name || "default"}.service`;
|
||
const targetUnit = `codex-retry-gateway@${profileName}.service`;
|
||
const switchScript = [
|
||
"set -e",
|
||
`systemctl --user disable --now ${currentUnit}`,
|
||
`systemctl --user enable --now ${targetUnit}`,
|
||
].join("\n");
|
||
|
||
const unitName = `codex-retry-gateway-switch-${Date.now()}`;
|
||
const pid = runDetached("systemd-run", [
|
||
"--user",
|
||
"--collect",
|
||
`--unit=${unitName}`,
|
||
"--on-active=1s",
|
||
"/usr/bin/env",
|
||
"bash",
|
||
"-lc",
|
||
switchScript,
|
||
]);
|
||
|
||
return {
|
||
profile: profileName,
|
||
current_unit: currentUnit,
|
||
target_unit: targetUnit,
|
||
switch_unit: `${unitName}.service`,
|
||
pid,
|
||
};
|
||
}
|
||
|
||
async function applyProfileConfig(runtime, profileName) {
|
||
const { profilePath, config } = await loadProfileConfigForProbe(runtime, profileName);
|
||
if (!canHotSwapProfile(runtime.config, config)) {
|
||
throw new Error("该 profile 的监听地址或端口与当前实例不同,暂不支持无重启热切换");
|
||
}
|
||
|
||
runtime.config = {
|
||
...config,
|
||
model_remap_map: parseModelRemapMap(config.model_remap),
|
||
};
|
||
|
||
await writeConfig(runtime.configPath, runtime.config);
|
||
await updateRuntimeState(runtime, {
|
||
profile_name: runtime.config.profile_name || "default",
|
||
profile_env_path: profilePath,
|
||
gateway_base_url: `http://${runtime.config.listen_host}:${runtime.config.listen_port}`,
|
||
last_started_at: new Date().toISOString(),
|
||
});
|
||
runtime.logger(
|
||
`[profile] hot-swapped profile=${runtime.config.profile_name || "default"} auth=${normalizeAuthMode(runtime.config.upstream_auth_mode)} upstream=${runtime.config.upstream_base_url}`,
|
||
);
|
||
|
||
return {
|
||
profile: runtime.config.profile_name || "default",
|
||
profile_env_path: profilePath,
|
||
hot_swapped: true,
|
||
listen: `${runtime.config.listen_host}:${runtime.config.listen_port}`,
|
||
upstream_base_url: runtime.config.upstream_base_url,
|
||
};
|
||
}
|
||
|
||
async function loadProfileConfigForProbe(runtime, profileName) {
|
||
validateProfileName(profileName);
|
||
const profilePath = path.join(runtime.paths.profilesDir, `${profileName}.env`);
|
||
if (!fs.existsSync(profilePath)) {
|
||
throw new Error(`profile 不存在: ${profileName}`);
|
||
}
|
||
const env = parseEnvText((await readOptionalText(profilePath)) || "");
|
||
const config = buildConfigFromProfileEnv(profileName, env, imageConfigFromConfig(runtime.config));
|
||
if (!config.upstream_base_url) {
|
||
throw new Error(`profile ${profileName} 缺少 upstream_base_url`);
|
||
}
|
||
return { profilePath, env, config };
|
||
}
|
||
|
||
async function loadImageProfileConfigForProbe(runtime, profileName) {
|
||
validateProfileName(profileName);
|
||
const profilePath = path.join(runtime.paths.imageProfilesDir, `${profileName}.env`);
|
||
if (!fs.existsSync(profilePath)) {
|
||
throw new Error(`图片 profile 不存在: ${profileName}`);
|
||
}
|
||
const env = parseEnvText((await readOptionalText(profilePath)) || "");
|
||
const config = buildImageConfigFromProfileEnv(profileName, env);
|
||
return { profilePath, env, config };
|
||
}
|
||
|
||
async function applyImageProfileConfig(runtime, profileName) {
|
||
const { profilePath, config } = await loadImageProfileConfigForProbe(runtime, profileName);
|
||
runtime.config = {
|
||
...runtime.config,
|
||
...config,
|
||
model_remap_map: parseModelRemapMap(runtime.config.model_remap),
|
||
};
|
||
await writeConfig(runtime.configPath, runtime.config);
|
||
await updateRuntimeState(runtime, {
|
||
image_profile_name: runtime.config.image_profile_name || "",
|
||
image_profile_env_path: profilePath,
|
||
last_started_at: new Date().toISOString(),
|
||
});
|
||
runtime.logger(
|
||
`[image-profile] hot-swapped profile=${runtime.config.image_profile_name || "-"} auth=${normalizeAuthMode(runtime.config.image_auth_mode)} upstream=${runtime.config.image_base_url || "disabled"}`,
|
||
);
|
||
return {
|
||
image_profile: runtime.config.image_profile_name || "",
|
||
image_profile_env_path: profilePath,
|
||
hot_swapped: true,
|
||
image_base_url: runtime.config.image_base_url || "",
|
||
};
|
||
}
|
||
|
||
async function ensureActiveImageProfile(runtime) {
|
||
const textProfileName = `${runtime.config.profile_name || "default"}`.trim();
|
||
const state = await readOptionalJson(runtime.paths.statePath);
|
||
const candidates = [
|
||
`${runtime.config.image_profile_name || ""}`.trim(),
|
||
`${state?.image_profile_name || ""}`.trim(),
|
||
textProfileName,
|
||
].filter((value, index, values) => /^[A-Za-z0-9_.-]+$/.test(value) && values.indexOf(value) === index);
|
||
|
||
let selectedName = candidates.find((name) => fs.existsSync(path.join(runtime.paths.imageProfilesDir, `${name}.env`))) || "";
|
||
let migration = null;
|
||
if (!selectedName && /^[A-Za-z0-9_.-]+$/.test(textProfileName)) {
|
||
migration = await migrateLegacyImageProfile(runtime, textProfileName, null, runtime.config);
|
||
selectedName = migration?.name || "";
|
||
}
|
||
if (!selectedName) {
|
||
return null;
|
||
}
|
||
|
||
const { profilePath, config } = await loadImageProfileConfigForProbe(runtime, selectedName);
|
||
runtime.config = {
|
||
...runtime.config,
|
||
...config,
|
||
model_remap_map: parseModelRemapMap(runtime.config.model_remap),
|
||
};
|
||
await writeConfig(runtime.configPath, runtime.config);
|
||
await updateRuntimeState(runtime, {
|
||
image_profile_name: selectedName,
|
||
image_profile_env_path: profilePath,
|
||
});
|
||
if (migration?.migrated) {
|
||
runtime.logger(
|
||
`[image-profile] migrated legacy text profile=${textProfileName} to image profile=${selectedName}`,
|
||
);
|
||
}
|
||
return {
|
||
image_profile: selectedName,
|
||
image_profile_env_path: profilePath,
|
||
migrated: Boolean(migration?.migrated),
|
||
};
|
||
}
|
||
|
||
async function deleteProfile(runtime, profileName) {
|
||
validateProfileName(profileName);
|
||
const activeProfile = runtime.config.profile_name || "default";
|
||
if (profileName === activeProfile) {
|
||
throw new Error("不能删除当前正在运行的 profile;请先切换到其他 profile");
|
||
}
|
||
|
||
const profilePath = path.join(runtime.paths.profilesDir, `${profileName}.env`);
|
||
if (!fs.existsSync(profilePath)) {
|
||
throw new Error(`profile 不存在: ${profileName}`);
|
||
}
|
||
|
||
await rm(profilePath, { force: true });
|
||
return {
|
||
name: profileName,
|
||
file_path: profilePath,
|
||
};
|
||
}
|
||
|
||
async function deleteImageProfile(runtime, profileName) {
|
||
validateProfileName(profileName);
|
||
const activeProfile = `${runtime.config.image_profile_name || ""}`.trim();
|
||
if (profileName === activeProfile) {
|
||
throw new Error("不能删除当前正在运行的图片 profile;请先切换到其他图片 profile");
|
||
}
|
||
const profilePath = path.join(runtime.paths.imageProfilesDir, `${profileName}.env`);
|
||
if (!fs.existsSync(profilePath)) {
|
||
throw new Error(`图片 profile 不存在: ${profileName}`);
|
||
}
|
||
await rm(profilePath, { force: true });
|
||
return {
|
||
name: profileName,
|
||
file_path: profilePath,
|
||
};
|
||
}
|
||
|
||
async function readProbeBodySummary(response, maxChars = 400) {
|
||
const contentType = response.headers.get("content-type") || "";
|
||
const text = await response.text();
|
||
let summary = text.slice(0, maxChars);
|
||
if (contentType.includes("application/json")) {
|
||
try {
|
||
const payload = JSON.parse(text);
|
||
if (Array.isArray(payload?.data)) {
|
||
summary = JSON.stringify(payload.data.slice(0, 8).map((item) => item.id ?? item.display_name ?? item), null, 2);
|
||
} else if (payload?.error) {
|
||
summary = JSON.stringify(payload.error);
|
||
} else {
|
||
summary = text.slice(0, maxChars);
|
||
}
|
||
} catch {
|
||
summary = text.slice(0, maxChars);
|
||
}
|
||
}
|
||
return {
|
||
content_type: contentType,
|
||
body_preview: summary,
|
||
};
|
||
}
|
||
|
||
async function probeProfile(runtime, payload) {
|
||
const profileName = `${payload?.profile || ""}`.trim();
|
||
if (!profileName) {
|
||
throw new Error("缺少 profile");
|
||
}
|
||
|
||
const { config } = await loadProfileConfigForProbe(runtime, profileName);
|
||
const upstreamAuth = await resolveUpstreamAuth(config);
|
||
const result = {
|
||
profile: profileName,
|
||
upstream_base_url: config.upstream_base_url,
|
||
auth_mode: upstreamAuth.mode,
|
||
auth_source: upstreamAuth.source,
|
||
authorization_configured: Boolean(upstreamAuth.authorization),
|
||
model_remap: config.model_remap || "",
|
||
probes: [],
|
||
};
|
||
|
||
const modelsUrl = buildUpstreamUrl(config.upstream_base_url, new URL("http://local/v1/models"));
|
||
const { response: modelsResponse } = await fetchUpstreamWithRetry(modelsUrl, {
|
||
method: "GET",
|
||
headers: cloneHeadersForUpstream({}, upstreamAuth),
|
||
}, config, runtime.logger, { method: "GET", pathname: "/v1/models" });
|
||
result.probes.push({
|
||
kind: "models",
|
||
target: "/v1/models",
|
||
status: modelsResponse.status,
|
||
...await readProbeBodySummary(modelsResponse, 600),
|
||
});
|
||
|
||
const requestedModel = `${payload?.model || ""}`.trim();
|
||
if (requestedModel) {
|
||
const { requestJson, forwardedModel } = remapRequestModel(config, {
|
||
model: requestedModel,
|
||
input: `${payload?.input || "ping"}`,
|
||
max_output_tokens: 1,
|
||
stream: false,
|
||
});
|
||
const responsesUrl = buildUpstreamUrl(config.upstream_base_url, new URL("http://local/v1/responses"));
|
||
const { response: responseProbe } = await fetchUpstreamWithRetry(responsesUrl, {
|
||
method: "POST",
|
||
headers: cloneHeadersForUpstream({ "content-type": "application/json" }, upstreamAuth),
|
||
body: JSON.stringify(requestJson),
|
||
}, config, runtime.logger, { method: "POST", pathname: "/v1/responses" });
|
||
result.probes.push({
|
||
kind: "responses",
|
||
target: "/v1/responses",
|
||
requested_model: requestedModel,
|
||
forwarded_model: forwardedModel || requestedModel,
|
||
status: responseProbe.status,
|
||
...await readProbeBodySummary(responseProbe, 600),
|
||
});
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
async function probeImageProfile(runtime, payload) {
|
||
const profileName = `${payload?.profile || ""}`.trim();
|
||
if (!profileName) {
|
||
throw new Error("缺少图片 profile");
|
||
}
|
||
|
||
const { config } = await loadImageProfileConfigForProbe(runtime, profileName);
|
||
if (!config.image_base_url) {
|
||
throw new Error(`图片 profile ${profileName} 未配置 Base URL`);
|
||
}
|
||
const probeConfig = {
|
||
...runtime.config,
|
||
...config,
|
||
};
|
||
const upstreamAuth = await resolveUpstreamAuth(
|
||
{
|
||
upstream_auth_mode: config.image_auth_mode,
|
||
upstream_auth_env: config.image_auth_env,
|
||
upstream_auth_file: config.image_auth_file,
|
||
upstream_auth_json_path: config.image_auth_json_path,
|
||
upstream_auth_json_key: config.image_auth_json_key,
|
||
},
|
||
"images",
|
||
);
|
||
const modelsUrl = buildUpstreamUrl(config.image_base_url, new URL("http://local/v1/models"));
|
||
const { response } = await fetchUpstreamWithRetry(modelsUrl, {
|
||
method: "GET",
|
||
headers: cloneHeadersForUpstream({}, upstreamAuth),
|
||
}, probeConfig, runtime.logger, { method: "GET", pathname: "/v1/models" });
|
||
|
||
return {
|
||
image_profile: profileName,
|
||
image_base_url: config.image_base_url,
|
||
auth_mode: upstreamAuth.mode,
|
||
auth_source: upstreamAuth.source,
|
||
authorization_configured: Boolean(upstreamAuth.authorization),
|
||
probes: [{
|
||
kind: "models",
|
||
target: "/v1/models",
|
||
status: response.status,
|
||
...await readProbeBodySummary(response, 600),
|
||
}],
|
||
};
|
||
}
|
||
|
||
function extractProviderBaseUrl(content, providerName) {
|
||
if (!content || !providerName) {
|
||
return null;
|
||
}
|
||
|
||
const sectionPattern = new RegExp(
|
||
String.raw`^\[model_providers\.${providerName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\]\s*$[\s\S]*?(?=^\[|\Z)`,
|
||
"m",
|
||
);
|
||
const sectionMatch = content.match(sectionPattern);
|
||
if (!sectionMatch) {
|
||
return null;
|
||
}
|
||
|
||
const baseUrlMatch = sectionMatch[0].match(/^\s*base_url\s*=\s*"([^"]+)"\s*$/m);
|
||
return baseUrlMatch ? baseUrlMatch[1] : null;
|
||
}
|
||
|
||
async function readRuntimeState(runtime) {
|
||
const state = await readOptionalJson(runtime.paths.statePath);
|
||
if (!state) {
|
||
return null;
|
||
}
|
||
|
||
let codexCurrentBaseUrl = null;
|
||
if (state.codex_config_path && state.provider_name) {
|
||
try {
|
||
const codexConfig = await readFile(state.codex_config_path, "utf8");
|
||
codexCurrentBaseUrl = extractProviderBaseUrl(codexConfig, state.provider_name);
|
||
} catch {
|
||
codexCurrentBaseUrl = null;
|
||
}
|
||
}
|
||
|
||
return {
|
||
...state,
|
||
codex_current_base_url: codexCurrentBaseUrl,
|
||
};
|
||
}
|
||
|
||
async function restoreRuntimeState(runtime, state) {
|
||
const backupPath = state?.latest_backup_path;
|
||
const codexConfigPath = state?.codex_config_path;
|
||
|
||
if (!backupPath || !fs.existsSync(backupPath)) {
|
||
throw new Error(`未找到可恢复备份: ${backupPath || "unknown"}`);
|
||
}
|
||
if (!codexConfigPath) {
|
||
throw new Error("安装状态里缺少 codex_config_path");
|
||
}
|
||
|
||
await copyFile(backupPath, codexConfigPath);
|
||
await Promise.all([
|
||
rm(runtime.paths.statePath, { force: true }),
|
||
rm(runtime.paths.pidPath, { force: true }),
|
||
]);
|
||
}
|
||
|
||
function pickContentEncoding(acceptEncoding = "") {
|
||
const value = `${acceptEncoding}`.toLowerCase();
|
||
if (value.includes("br")) {
|
||
return "br";
|
||
}
|
||
if (value.includes("gzip")) {
|
||
return "gzip";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function shouldCompressContent(headers = {}) {
|
||
const contentType = `${headers["content-type"] || headers["Content-Type"] || ""}`.toLowerCase();
|
||
return (
|
||
contentType.startsWith("text/") ||
|
||
contentType.includes("javascript") ||
|
||
contentType.includes("json") ||
|
||
contentType.includes("xml") ||
|
||
contentType.includes("svg")
|
||
);
|
||
}
|
||
|
||
function maybeCompressBody(body, headers = {}, acceptEncoding = "") {
|
||
if (!body || body.length < 1024 || !shouldCompressContent(headers)) {
|
||
return { body, encoding: null };
|
||
}
|
||
const encoding = pickContentEncoding(acceptEncoding);
|
||
if (encoding === "br") {
|
||
return { body: zlib.brotliCompressSync(body), encoding };
|
||
}
|
||
if (encoding === "gzip") {
|
||
return { body: zlib.gzipSync(body), encoding };
|
||
}
|
||
return { body, encoding: null };
|
||
}
|
||
|
||
function respondBuffer(res, statusCode, body, headers = {}, acceptEncoding = "") {
|
||
const { body: responseBody, encoding } = maybeCompressBody(body, headers, acceptEncoding);
|
||
res.writeHead(statusCode, {
|
||
"content-length": responseBody.length,
|
||
vary: "accept-encoding",
|
||
...(encoding ? { "content-encoding": encoding } : {}),
|
||
...headers,
|
||
});
|
||
res.end(responseBody);
|
||
}
|
||
|
||
function jsonResponse(req, res, statusCode, payload, headers = {}) {
|
||
const body = Buffer.from(JSON.stringify(payload));
|
||
respondBuffer(
|
||
res,
|
||
statusCode,
|
||
body,
|
||
{
|
||
"content-type": "application/json; charset=utf-8",
|
||
...headers,
|
||
},
|
||
req?.headers?.["accept-encoding"] || "",
|
||
);
|
||
}
|
||
|
||
|
||
const STATIC_CONTENT_TYPES = {
|
||
".html": "text/html; charset=utf-8",
|
||
".js": "text/javascript; charset=utf-8",
|
||
".css": "text/css; charset=utf-8",
|
||
".json": "application/json; charset=utf-8",
|
||
".svg": "image/svg+xml",
|
||
".png": "image/png",
|
||
".ico": "image/x-icon",
|
||
".woff": "font/woff",
|
||
".woff2": "font/woff2",
|
||
};
|
||
|
||
function contentTypeForFile(filePath) {
|
||
return STATIC_CONTENT_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
|
||
}
|
||
|
||
function safeJoinStatic(root, requestPath) {
|
||
const decoded = decodeURIComponent(requestPath);
|
||
const relative = decoded.replace(/^\/+/, "");
|
||
const fullPath = path.resolve(root, relative);
|
||
const rootPath = path.resolve(root);
|
||
if (fullPath !== rootPath && !fullPath.startsWith(`${rootPath}${path.sep}`)) {
|
||
return null;
|
||
}
|
||
return fullPath;
|
||
}
|
||
|
||
async function serveStaticFile(req, res, filePath) {
|
||
try {
|
||
const body = await readFile(filePath);
|
||
respondBuffer(
|
||
res,
|
||
200,
|
||
body,
|
||
{
|
||
"content-type": contentTypeForFile(filePath),
|
||
"cache-control": filePath.includes(`${path.sep}assets${path.sep}`)
|
||
? "public, max-age=31536000, immutable"
|
||
: "no-cache",
|
||
},
|
||
req?.headers?.["accept-encoding"] || "",
|
||
);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function serveManagementUi(req, res, requestPathname) {
|
||
const uiPrefix = `${UI_PATH}/`;
|
||
if (requestPathname === UI_PATH || requestPathname === `${UI_PATH}/`) {
|
||
return serveStaticFile(req, res, path.join(UI_STATIC_ROOT, "index.html"));
|
||
}
|
||
|
||
if (!requestPathname.startsWith(uiPrefix)) {
|
||
return false;
|
||
}
|
||
|
||
const staticPath = safeJoinStatic(UI_STATIC_ROOT, requestPathname.slice(uiPrefix.length));
|
||
if (!staticPath) {
|
||
jsonResponse(req, res, 403, {
|
||
error: {
|
||
message: "invalid static path",
|
||
code: "invalid_static_path",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (await serveStaticFile(req, res, staticPath)) {
|
||
return true;
|
||
}
|
||
|
||
return serveStaticFile(req, res, path.join(UI_STATIC_ROOT, "index.html"));
|
||
}
|
||
|
||
async function serveManagementUiWithOptionalCookie(req, res, requestPathname, cookieHeaders) {
|
||
const uiPrefix = `${UI_PATH}/`;
|
||
if (requestPathname === UI_PATH || requestPathname === `${UI_PATH}/`) {
|
||
try {
|
||
const body = await readFile(path.join(UI_STATIC_ROOT, "index.html"));
|
||
respondBuffer(
|
||
res,
|
||
200,
|
||
body,
|
||
{
|
||
"content-type": "text/html; charset=utf-8",
|
||
"cache-control": "no-cache",
|
||
...(cookieHeaders?.length ? { "set-cookie": cookieHeaders } : {}),
|
||
},
|
||
req?.headers?.["accept-encoding"] || "",
|
||
);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
if (!requestPathname.startsWith(uiPrefix)) {
|
||
return false;
|
||
}
|
||
return serveManagementUi(req, res, requestPathname);
|
||
}
|
||
|
||
function renderManagementAccessPage(requestPathname, errorMessage = "") {
|
||
const escapedAction = `${requestPathname || UI_PATH}`.replace(/&/g, "&").replace(/"/g, """);
|
||
const escapedError = `${errorMessage || ""}`
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
return `<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Codex Retry Gateway</title>
|
||
<style>
|
||
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #0b1020; color: #eef2ff; }
|
||
main { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
|
||
form { width: min(420px, 100%); background: #121933; border: 1px solid #26304f; padding: 24px; border-radius: 8px; }
|
||
h1 { margin: 0 0 8px; font-size: 20px; }
|
||
p { margin: 0 0 16px; color: #aab4d6; line-height: 1.5; }
|
||
label { display: block; margin-bottom: 8px; font-size: 14px; color: #cbd5f5; }
|
||
input { width: 100%; box-sizing: border-box; padding: 12px; border-radius: 6px; border: 1px solid #33406a; background: #0f1630; color: #eef2ff; }
|
||
button { margin-top: 16px; width: 100%; padding: 12px; border: 0; border-radius: 6px; background: #7c9cff; color: #09101f; font-weight: 600; cursor: pointer; }
|
||
.error { min-height: 20px; margin-top: 12px; color: #fca5a5; font-size: 14px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<form method="get" action="${escapedAction}">
|
||
<h1>Codex Retry Gateway</h1>
|
||
<p>此页面已启用访问 key。输入后会在当前浏览器保存后台访问状态。</p>
|
||
<label for="key">Access key</label>
|
||
<input id="key" name="key" type="password" autofocus autocomplete="current-password">
|
||
<button type="submit">进入后台</button>
|
||
<div class="error">${escapedError}</div>
|
||
</form>
|
||
</main>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
function buildEditableConfig(currentConfig, payload) {
|
||
const nextReasoningMatchMode = normalizeReasoningMatchMode(
|
||
payload.reasoning_match_mode === undefined
|
||
? currentConfig.reasoning_match_mode
|
||
: payload.reasoning_match_mode,
|
||
);
|
||
const nextReasoning = normalizeReasoningEquals(
|
||
payload.reasoning_equals === undefined ? currentConfig.reasoning_equals : payload.reasoning_equals,
|
||
currentConfig.reasoning_equals,
|
||
);
|
||
const nextRetryableStatusCodes = normalizeIntegerList(
|
||
payload.retryable_status_codes,
|
||
currentConfig.retryable_status_codes,
|
||
);
|
||
const nextRetryableErrorMessages = normalizePhraseList(
|
||
payload.retryable_error_messages,
|
||
currentConfig.retryable_error_messages,
|
||
);
|
||
const nextEndpoints = normalizeStringList(payload.endpoints, currentConfig.endpoints).map(normalizePath);
|
||
const nextUpstreamFetchRetryAttempts =
|
||
payload.upstream_fetch_retry_attempts === undefined
|
||
? normalizePositiveInteger(
|
||
currentConfig.upstream_fetch_retry_attempts,
|
||
DEFAULT_CONFIG.upstream_fetch_retry_attempts,
|
||
)
|
||
: normalizePositiveInteger(
|
||
payload.upstream_fetch_retry_attempts,
|
||
DEFAULT_CONFIG.upstream_fetch_retry_attempts,
|
||
);
|
||
const nextUpstreamFetchRetryBackoffMs =
|
||
payload.upstream_fetch_retry_backoff_ms === undefined
|
||
? normalizeNonNegativeInteger(
|
||
currentConfig.upstream_fetch_retry_backoff_ms,
|
||
DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms,
|
||
)
|
||
: normalizeNonNegativeInteger(
|
||
payload.upstream_fetch_retry_backoff_ms,
|
||
DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms,
|
||
);
|
||
const nextStatusCode =
|
||
payload.non_stream_status_code === undefined
|
||
? currentConfig.non_stream_status_code
|
||
: Number.parseInt(`${payload.non_stream_status_code}`, 10);
|
||
|
||
if (nextRetryableStatusCodes.length === 0) {
|
||
throw new Error("retryable_status_codes 不能为空");
|
||
}
|
||
if (nextRetryableErrorMessages.length === 0) {
|
||
throw new Error("retryable_error_messages 不能为空");
|
||
}
|
||
if (nextEndpoints.length === 0) {
|
||
throw new Error("endpoints 不能为空");
|
||
}
|
||
if (nextUpstreamFetchRetryAttempts < 1) {
|
||
throw new Error("upstream_fetch_retry_attempts 必须是正整数");
|
||
}
|
||
if (nextUpstreamFetchRetryBackoffMs < 0) {
|
||
throw new Error("upstream_fetch_retry_backoff_ms 不能为负数");
|
||
}
|
||
if (!Number.isInteger(nextStatusCode) || nextStatusCode < 100 || nextStatusCode > 599) {
|
||
throw new Error("non_stream_status_code 必须是 100-599 的整数");
|
||
}
|
||
|
||
return {
|
||
...currentConfig,
|
||
reasoning_match_mode: nextReasoningMatchMode,
|
||
reasoning_equals: nextReasoning,
|
||
retryable_status_codes: nextRetryableStatusCodes,
|
||
retryable_error_messages: nextRetryableErrorMessages,
|
||
management_access_key:
|
||
payload.management_access_key === undefined
|
||
? currentManagementAccessKey(currentConfig)
|
||
: normalizeManagementAccessKey(payload.management_access_key),
|
||
endpoints: nextEndpoints,
|
||
upstream_fetch_retry_attempts: nextUpstreamFetchRetryAttempts,
|
||
upstream_fetch_retry_backoff_ms: nextUpstreamFetchRetryBackoffMs,
|
||
non_stream_status_code: nextStatusCode,
|
||
log_match: payload.log_match === undefined ? currentConfig.log_match : Boolean(payload.log_match),
|
||
};
|
||
}
|
||
|
||
async function handleManagementRequest(runtime, req, res, requestUrl) {
|
||
const pathname = normalizePath(requestUrl.pathname);
|
||
const isManagementPath = pathname === ADMIN_BASE_PATH || pathname.startsWith(`${ADMIN_BASE_PATH}/`);
|
||
const accessEnabled = managementAccessEnabled(runtime.config);
|
||
const accessGranted = hasManagementAccess(req, requestUrl, runtime.config);
|
||
|
||
if (pathname === UI_PATH || pathname.startsWith(`${UI_PATH}/`)) {
|
||
if (accessEnabled && !accessGranted) {
|
||
if (
|
||
pathname === UI_PATH ||
|
||
pathname === `${UI_PATH}/` ||
|
||
pathname === `${UI_PATH}/index.html`
|
||
) {
|
||
const body = Buffer.from(
|
||
renderManagementAccessPage(pathname, requestUrl.searchParams.get("key") ? "access key 不正确" : ""),
|
||
"utf8",
|
||
);
|
||
return respondBuffer(
|
||
res,
|
||
401,
|
||
body,
|
||
{
|
||
"content-type": "text/html; charset=utf-8",
|
||
"cache-control": "no-store",
|
||
"set-cookie": clearManagementAccessCookieHeaders(),
|
||
},
|
||
req?.headers?.["accept-encoding"] || "",
|
||
);
|
||
}
|
||
jsonResponse(req, res, 401, managementUnauthorizedPayload(), {
|
||
"cache-control": "no-store",
|
||
"set-cookie": clearManagementAccessCookieHeaders(),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
const cookieHeaders = accessEnabled
|
||
? buildManagementAccessCookieHeaders(runtime.config, requestManagementAccessKey(req, requestUrl))
|
||
: [];
|
||
if (!(await serveManagementUiWithOptionalCookie(req, res, requestUrl.pathname, cookieHeaders))) {
|
||
jsonResponse(req, res, 503, {
|
||
error: {
|
||
message: "UI assets were not built. Run: npm run build:ui",
|
||
code: "ui_not_built",
|
||
},
|
||
});
|
||
}
|
||
return true;
|
||
}
|
||
|
||
if (accessEnabled && !accessGranted && isManagementPath) {
|
||
jsonResponse(req, res, 401, managementUnauthorizedPayload(), {
|
||
"cache-control": "no-store",
|
||
"set-cookie": clearManagementAccessCookieHeaders(),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname === STATUS_API_PATH && req.method === "GET") {
|
||
const state = await readRuntimeState(runtime);
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
listen: `${runtime.config.listen_host}:${runtime.config.listen_port}`,
|
||
config: sanitizeConfigForStatus(runtime.config),
|
||
state,
|
||
paths: {
|
||
config_path: runtime.configPath,
|
||
state_path: runtime.paths.statePath,
|
||
state_root: runtime.paths.stateRoot,
|
||
log_path: runtime.logPath,
|
||
requests_path: runtime.paths.requestsPath,
|
||
thread_rules_path: runtime.paths.threadRulesPath,
|
||
profiles_dir: runtime.paths.profilesDir,
|
||
image_profiles_dir: runtime.paths.imageProfilesDir,
|
||
},
|
||
metrics: buildMetricsSnapshot(runtime.monitor),
|
||
}, accessEnabled && accessGranted ? { "set-cookie": buildManagementAccessCookieHeaders(runtime.config, requestManagementAccessKey(req, requestUrl)) } : {});
|
||
return true;
|
||
}
|
||
|
||
if (pathname === LOGS_API_PATH && req.method === "GET") {
|
||
const sinceSeqRaw = requestUrl.searchParams.get("since_seq");
|
||
const limitRaw = requestUrl.searchParams.get("limit");
|
||
const sinceSeq = sinceSeqRaw === null ? null : Number.parseInt(sinceSeqRaw, 10);
|
||
const limit = limitRaw === null ? 500 : Number.parseInt(limitRaw, 10);
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
...await buildPersistentLogsSnapshot(
|
||
runtime,
|
||
Number.isInteger(sinceSeq) ? sinceSeq : null,
|
||
Number.isInteger(limit) ? limit : 500,
|
||
),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname === REQUESTS_API_PATH && req.method === "GET") {
|
||
const limitRaw = requestUrl.searchParams.get("limit");
|
||
const offsetRaw = requestUrl.searchParams.get("offset");
|
||
const query = requestUrl.searchParams.get("query") || "";
|
||
const filter = requestUrl.searchParams.get("filter") || "all";
|
||
const limit = limitRaw === null ? 50 : Number.parseInt(limitRaw, 10);
|
||
const offset = offsetRaw === null ? 0 : Number.parseInt(offsetRaw, 10);
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
...await buildPersistentRequestsSnapshot(runtime, {
|
||
limit: Number.isInteger(limit) ? limit : 50,
|
||
offset: Number.isInteger(offset) ? offset : 0,
|
||
query,
|
||
filter,
|
||
}),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname === THREAD_RULES_API_PATH && req.method === "GET") {
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
thread_rules_path: runtime.paths.threadRulesPath,
|
||
rules: listThreadRules(runtime),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname === THREAD_RULES_API_PATH && req.method === "POST") {
|
||
const body = await readRequestBody(req, runtime.config.request_body_limit_bytes);
|
||
const payload = parseJsonSafely(body);
|
||
if (!payload) {
|
||
jsonResponse(req, res, 400, {
|
||
error: {
|
||
message: "thread rule 保存请求必须是有效 JSON",
|
||
code: "invalid_json",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
let savedRule;
|
||
try {
|
||
savedRule = await upsertThreadRule(runtime, payload);
|
||
} catch (error) {
|
||
jsonResponse(req, res, 400, {
|
||
error: {
|
||
message: `${error?.message || error}`,
|
||
code: "invalid_thread_rule",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
runtime.logger(
|
||
`[thread-rule] thread_id=${savedRule.thread_id} reasoning_intercept_enabled=${savedRule.reasoning_intercept_enabled}`,
|
||
);
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
message: savedRule.reasoning_intercept_enabled ? "thread 已开启 reasoning 拦截" : "thread 已关闭 reasoning 拦截",
|
||
saved_rule: savedRule,
|
||
thread_rules_path: runtime.paths.threadRulesPath,
|
||
rules: listThreadRules(runtime),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname.startsWith(THREAD_RULE_ITEM_API_PREFIX) && req.method === "DELETE") {
|
||
const rawThreadId = pathname.slice(THREAD_RULE_ITEM_API_PREFIX.length);
|
||
if (!rawThreadId || rawThreadId.includes("/")) {
|
||
jsonResponse(req, res, 400, {
|
||
error: {
|
||
message: "无效的 thread_id",
|
||
code: "invalid_thread_id",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
const threadId = decodeURIComponent(rawThreadId);
|
||
let removedRule;
|
||
try {
|
||
removedRule = await deleteThreadRule(runtime, threadId);
|
||
} catch (error) {
|
||
jsonResponse(req, res, 400, {
|
||
error: {
|
||
message: `${error?.message || error}`,
|
||
code: "invalid_thread_rule",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
runtime.logger(`[thread-rule] thread_id=${threadId} restored_to_default`);
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
message: "thread 已恢复默认拦截策略",
|
||
removed_rule: removedRule,
|
||
thread_rules_path: runtime.paths.threadRulesPath,
|
||
rules: listThreadRules(runtime),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname === PROFILES_API_PATH && req.method === "GET") {
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
profiles_dir: runtime.paths.profilesDir,
|
||
active_profile: runtime.config.profile_name || "default",
|
||
profiles: await listProfiles(runtime),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname === PROFILES_API_PATH && req.method === "POST") {
|
||
const body = await readRequestBody(req, runtime.config.request_body_limit_bytes);
|
||
const payload = parseJsonSafely(body);
|
||
if (!payload) {
|
||
jsonResponse(req, res, 400, {
|
||
error: {
|
||
message: "profile 保存请求必须是有效 JSON",
|
||
code: "invalid_json",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
const result = await writeProfile(runtime, payload);
|
||
let applied = null;
|
||
if (result.name === (runtime.config.profile_name || "default")) {
|
||
applied = await applyProfileConfig(runtime, result.name);
|
||
}
|
||
runtime.logger(`[profile] saved name=${result.name} path=${result.file_path}`);
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
message: applied ? "profile 已保存并已热应用" : "profile 已保存",
|
||
saved_profile: result,
|
||
applied_profile: applied,
|
||
profiles_dir: runtime.paths.profilesDir,
|
||
active_profile: runtime.config.profile_name || "default",
|
||
profiles: await listProfiles(runtime),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname === PROFILE_PROBE_API_PATH && req.method === "POST") {
|
||
const body = await readRequestBody(req, runtime.config.request_body_limit_bytes);
|
||
const payload = parseJsonSafely(body);
|
||
if (!payload) {
|
||
jsonResponse(req, res, 400, {
|
||
error: {
|
||
message: "profile probe 请求必须是有效 JSON",
|
||
code: "invalid_json",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
const result = await probeProfile(runtime, payload);
|
||
runtime.logger(
|
||
`[profile-probe] profile=${result.profile} auth=${result.auth_mode}/${result.auth_source} upstream=${result.upstream_base_url}`,
|
||
);
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
...result,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname === PROFILE_SWITCH_API_PATH && req.method === "POST") {
|
||
const body = await readRequestBody(req, runtime.config.request_body_limit_bytes);
|
||
const payload = parseJsonSafely(body);
|
||
const profileName = `${payload?.profile || ""}`.trim();
|
||
if (!profileName) {
|
||
jsonResponse(req, res, 400, {
|
||
error: {
|
||
message: "缺少 profile",
|
||
code: "profile_required",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
const result = await applyProfileConfig(runtime, profileName);
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
message: "profile 已热切换,无需重启 gateway",
|
||
...result,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname.startsWith(PROFILE_ITEM_API_PREFIX) && req.method === "DELETE") {
|
||
const rawName = pathname.slice(PROFILE_ITEM_API_PREFIX.length);
|
||
if (!rawName || rawName.includes("/")) {
|
||
jsonResponse(req, res, 400, {
|
||
error: {
|
||
message: "无效的 profile 名称",
|
||
code: "invalid_profile_name",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
const profileName = decodeURIComponent(rawName);
|
||
const result = await deleteProfile(runtime, profileName);
|
||
runtime.logger(`[profile] deleted name=${result.name} path=${result.file_path}`);
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
message: "profile 已删除",
|
||
deleted_profile: result,
|
||
profiles_dir: runtime.paths.profilesDir,
|
||
active_profile: runtime.config.profile_name || "default",
|
||
profiles: await listProfiles(runtime),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname === IMAGE_PROFILES_API_PATH && req.method === "GET") {
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
image_profiles_dir: runtime.paths.imageProfilesDir,
|
||
active_image_profile: runtime.config.image_profile_name || "",
|
||
image_profiles: await listImageProfiles(runtime),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname === IMAGE_PROFILES_API_PATH && req.method === "POST") {
|
||
const body = await readRequestBody(req, runtime.config.request_body_limit_bytes);
|
||
const payload = parseJsonSafely(body);
|
||
if (!payload) {
|
||
jsonResponse(req, res, 400, {
|
||
error: {
|
||
message: "图片 profile 保存请求必须是有效 JSON",
|
||
code: "invalid_json",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
const result = await writeImageProfile(runtime, payload);
|
||
let applied = null;
|
||
if (result.name === `${runtime.config.image_profile_name || ""}`.trim()) {
|
||
applied = await applyImageProfileConfig(runtime, result.name);
|
||
}
|
||
runtime.logger(`[image-profile] saved name=${result.name} path=${result.file_path}`);
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
message: applied ? "图片 profile 已保存并已热应用" : "图片 profile 已保存",
|
||
saved_image_profile: result,
|
||
applied_image_profile: applied,
|
||
image_profiles_dir: runtime.paths.imageProfilesDir,
|
||
active_image_profile: runtime.config.image_profile_name || "",
|
||
image_profiles: await listImageProfiles(runtime),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname === IMAGE_PROFILE_PROBE_API_PATH && req.method === "POST") {
|
||
const body = await readRequestBody(req, runtime.config.request_body_limit_bytes);
|
||
const payload = parseJsonSafely(body);
|
||
if (!payload) {
|
||
jsonResponse(req, res, 400, {
|
||
error: {
|
||
message: "图片 profile probe 请求必须是有效 JSON",
|
||
code: "invalid_json",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
const result = await probeImageProfile(runtime, payload);
|
||
runtime.logger(
|
||
`[image-profile-probe] profile=${result.image_profile} auth=${result.auth_mode}/${result.auth_source} upstream=${result.image_base_url}`,
|
||
);
|
||
jsonResponse(req, res, 200, { ok: true, ...result });
|
||
return true;
|
||
}
|
||
|
||
if (pathname === IMAGE_PROFILE_SWITCH_API_PATH && req.method === "POST") {
|
||
const body = await readRequestBody(req, runtime.config.request_body_limit_bytes);
|
||
const payload = parseJsonSafely(body);
|
||
const profileName = `${payload?.profile || ""}`.trim();
|
||
if (!profileName) {
|
||
jsonResponse(req, res, 400, {
|
||
error: {
|
||
message: "缺少图片 profile",
|
||
code: "profile_required",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
const result = await applyImageProfileConfig(runtime, profileName);
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
message: "图片 profile 已热切换,无需重启 gateway",
|
||
...result,
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname.startsWith(IMAGE_PROFILE_ITEM_API_PREFIX) && req.method === "DELETE") {
|
||
const rawName = pathname.slice(IMAGE_PROFILE_ITEM_API_PREFIX.length);
|
||
if (!rawName || rawName.includes("/")) {
|
||
jsonResponse(req, res, 400, {
|
||
error: {
|
||
message: "无效的图片 profile 名称",
|
||
code: "invalid_profile_name",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
const profileName = decodeURIComponent(rawName);
|
||
const result = await deleteImageProfile(runtime, profileName);
|
||
runtime.logger(`[image-profile] deleted name=${result.name} path=${result.file_path}`);
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
message: "图片 profile 已删除",
|
||
deleted_image_profile: result,
|
||
image_profiles_dir: runtime.paths.imageProfilesDir,
|
||
active_image_profile: runtime.config.image_profile_name || "",
|
||
image_profiles: await listImageProfiles(runtime),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname === CONFIG_API_PATH && req.method === "POST") {
|
||
const body = await readRequestBody(req, runtime.config.request_body_limit_bytes);
|
||
const payload = parseJsonSafely(body);
|
||
if (!payload) {
|
||
jsonResponse(req, res, 400, {
|
||
error: {
|
||
message: "配置保存请求必须是有效 JSON",
|
||
code: "invalid_json",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
const nextConfig = buildEditableConfig(runtime.config, payload);
|
||
await writeConfig(runtime.configPath, nextConfig);
|
||
runtime.config = nextConfig;
|
||
runtime.logger(
|
||
`[config] updated reasoning_match_mode=${nextConfig.reasoning_match_mode} reasoning_equals=${nextConfig.reasoning_equals.join(",")} retryable_status_codes=${nextConfig.retryable_status_codes.join(",")} endpoints=${nextConfig.endpoints.join(",")}`,
|
||
);
|
||
const state = await readRuntimeState(runtime);
|
||
jsonResponse(req, res, 200, {
|
||
ok: true,
|
||
message: "配置已保存并立即生效",
|
||
config: sanitizeConfigForStatus(runtime.config),
|
||
state,
|
||
paths: {
|
||
config_path: runtime.configPath,
|
||
state_path: runtime.paths.statePath,
|
||
state_root: runtime.paths.stateRoot,
|
||
log_path: runtime.logPath,
|
||
thread_rules_path: runtime.paths.threadRulesPath,
|
||
},
|
||
metrics: buildMetricsSnapshot(runtime.monitor),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (pathname === RESTORE_API_PATH && req.method === "POST") {
|
||
const state = await readRuntimeState(runtime);
|
||
if (!state) {
|
||
jsonResponse(req, res, 409, {
|
||
error: {
|
||
message: "当前未检测到安装状态,无法恢复 Codex 原设置",
|
||
code: "state_not_found",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
await restoreRuntimeState(runtime, state);
|
||
runtime.logger(`[restore] restored via UI state_root=${runtime.paths.stateRoot}`);
|
||
jsonResponse(req, res, 202, {
|
||
ok: true,
|
||
message: "原设置已恢复,gateway 即将关闭",
|
||
});
|
||
res.on("finish", () => {
|
||
const exitTimer = setTimeout(() => {
|
||
if (runtime.server) {
|
||
runtime.server.close(() => {
|
||
process.exit(0);
|
||
});
|
||
} else {
|
||
process.exit(0);
|
||
}
|
||
|
||
const hardExitTimer = setTimeout(() => {
|
||
process.exit(0);
|
||
}, 600);
|
||
hardExitTimer.unref();
|
||
}, 120);
|
||
exitTimer.unref();
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (isManagementPath) {
|
||
jsonResponse(req, res, 404, {
|
||
error: {
|
||
message: "管理面路径不存在",
|
||
code: "management_endpoint_not_found",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function buildUpstreamUrl(baseUrl, requestUrl) {
|
||
const upstream = new URL(baseUrl);
|
||
const normalizedBasePath = upstream.pathname.endsWith("/")
|
||
? upstream.pathname.slice(0, -1)
|
||
: upstream.pathname;
|
||
const incomingPath = requestUrl.pathname;
|
||
|
||
let finalPath = incomingPath;
|
||
if (normalizedBasePath && normalizedBasePath !== "/") {
|
||
if (incomingPath.startsWith(`${normalizedBasePath}/`) || incomingPath === normalizedBasePath) {
|
||
finalPath = incomingPath;
|
||
} else if (normalizedBasePath.endsWith("/v1") && incomingPath.startsWith("/v1/")) {
|
||
finalPath = `${normalizedBasePath}${incomingPath.slice(3)}`;
|
||
} else {
|
||
finalPath = `${normalizedBasePath}${incomingPath}`;
|
||
}
|
||
}
|
||
|
||
upstream.pathname = finalPath;
|
||
upstream.search = requestUrl.search;
|
||
return upstream.toString();
|
||
}
|
||
|
||
function buildUpstreamSnapshot({ upstreamUrl, upstreamAuth = null, upstreamResponse = null }) {
|
||
const parsedUrl = new URL(upstreamUrl);
|
||
const snapshot = {
|
||
origin: parsedUrl.origin,
|
||
path: parsedUrl.pathname,
|
||
auth_mode: upstreamAuth?.mode || "unknown",
|
||
auth_source: upstreamAuth?.source || "unknown",
|
||
authorization_configured: Boolean(upstreamAuth?.authorization),
|
||
};
|
||
|
||
if (upstreamResponse) {
|
||
snapshot.status = upstreamResponse.status;
|
||
snapshot.content_type = upstreamResponse.headers.get("content-type") || "";
|
||
}
|
||
|
||
return snapshot;
|
||
}
|
||
|
||
function cloneHeadersForUpstream(headers, upstreamAuth = null) {
|
||
const outgoing = new Headers();
|
||
for (const [key, value] of Object.entries(headers)) {
|
||
if (value === undefined) {
|
||
continue;
|
||
}
|
||
const lowerKey = key.toLowerCase();
|
||
if (
|
||
lowerKey === "host" ||
|
||
lowerKey === "content-length" ||
|
||
lowerKey === "connection" ||
|
||
lowerKey === "transfer-encoding"
|
||
) {
|
||
continue;
|
||
}
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) {
|
||
outgoing.append(key, item);
|
||
}
|
||
} else {
|
||
outgoing.set(key, value);
|
||
}
|
||
}
|
||
if (upstreamAuth?.authorization) {
|
||
outgoing.set("authorization", upstreamAuth.authorization);
|
||
}
|
||
return outgoing;
|
||
}
|
||
|
||
function copyHeadersToClient(sourceHeaders, target) {
|
||
for (const [key, value] of sourceHeaders.entries()) {
|
||
const lowerKey = key.toLowerCase();
|
||
if (
|
||
lowerKey === "content-length" ||
|
||
lowerKey === "transfer-encoding" ||
|
||
lowerKey === "content-encoding" ||
|
||
lowerKey === "connection"
|
||
) {
|
||
continue;
|
||
}
|
||
target.setHeader(key, value);
|
||
}
|
||
}
|
||
|
||
function cloneResponseHeaders(sourceHeaders) {
|
||
const headers = new Headers();
|
||
for (const [key, value] of sourceHeaders.entries()) {
|
||
headers.set(key, value);
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
const CAPTURED_STREAM_REPLAY_DELAY_MS = 5;
|
||
|
||
function cloneBufferList(chunks) {
|
||
if (!Array.isArray(chunks) || chunks.length === 0) {
|
||
return null;
|
||
}
|
||
return chunks
|
||
.filter((chunk) => chunk && chunk.length > 0)
|
||
.map((chunk) => (Buffer.isBuffer(chunk) ? Buffer.from(chunk) : Buffer.from(chunk)));
|
||
}
|
||
|
||
async function writeBufferedStreamChunks(res, chunks) {
|
||
const replayChunks = (chunks || []).filter((chunk) => chunk && chunk.length > 0);
|
||
res.socket?.setNoDelay(true);
|
||
res.flushHeaders?.();
|
||
|
||
for (let index = 0; index < replayChunks.length; index += 1) {
|
||
if (res.destroyed || res.writableEnded) {
|
||
break;
|
||
}
|
||
const chunk = replayChunks[index];
|
||
const accepted = res.write(chunk);
|
||
if (!accepted) {
|
||
await new Promise((resolve) => {
|
||
let settled = false;
|
||
const finish = () => {
|
||
if (settled) {
|
||
return;
|
||
}
|
||
settled = true;
|
||
res.off("drain", onDrain);
|
||
res.off("close", onClose);
|
||
res.off("error", onClose);
|
||
resolve();
|
||
};
|
||
const onDrain = () => finish();
|
||
const onClose = () => finish();
|
||
res.once("drain", onDrain);
|
||
res.once("close", onClose);
|
||
res.once("error", onClose);
|
||
});
|
||
}
|
||
if (
|
||
index < replayChunks.length - 1 &&
|
||
!res.destroyed &&
|
||
!res.writableEnded
|
||
) {
|
||
await sleep(CAPTURED_STREAM_REPLAY_DELAY_MS);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function writeCapturedResponse(res, delivery) {
|
||
if (!delivery) {
|
||
throw new Error("missing captured response delivery");
|
||
}
|
||
copyHeadersToClient(delivery.headers, res);
|
||
res.writeHead(delivery.status_code);
|
||
if (Array.isArray(delivery.stream_chunks) && delivery.stream_chunks.length > 0) {
|
||
await writeBufferedStreamChunks(res, delivery.stream_chunks);
|
||
if (!res.writableEnded) {
|
||
res.end();
|
||
}
|
||
return;
|
||
}
|
||
res.end(delivery.body);
|
||
}
|
||
|
||
function buildCapturedDelivery(statusCode, headers, body, options = {}) {
|
||
const delivery = {
|
||
status_code: statusCode,
|
||
headers: cloneResponseHeaders(headers),
|
||
body: Buffer.isBuffer(body) ? body : Buffer.from(body || ""),
|
||
};
|
||
const streamChunks = cloneBufferList(options.stream_chunks);
|
||
if (streamChunks && streamChunks.length > 0) {
|
||
delivery.stream_chunks = streamChunks;
|
||
}
|
||
return delivery;
|
||
}
|
||
|
||
function createAbortReason(code, message) {
|
||
const error = new Error(message);
|
||
error.name = "AbortError";
|
||
error.code = code;
|
||
return error;
|
||
}
|
||
|
||
function abortReasonCode(value) {
|
||
return value?.reason?.code || value?.code || null;
|
||
}
|
||
|
||
function createLinkedAbortController(signals = []) {
|
||
const controller = new AbortController();
|
||
const cleanups = [];
|
||
const linkSignal = (signal) => {
|
||
if (!signal) {
|
||
return;
|
||
}
|
||
const abort = () => {
|
||
if (!controller.signal.aborted) {
|
||
controller.abort(signal.reason || createAbortReason(REASONING_RETRY_ABORT_CLIENT, "request aborted"));
|
||
}
|
||
};
|
||
if (signal.aborted) {
|
||
abort();
|
||
return;
|
||
}
|
||
signal.addEventListener("abort", abort, { once: true });
|
||
cleanups.push(() => signal.removeEventListener("abort", abort));
|
||
};
|
||
for (const signal of signals) {
|
||
linkSignal(signal);
|
||
if (controller.signal.aborted) {
|
||
break;
|
||
}
|
||
}
|
||
return {
|
||
controller,
|
||
cleanup() {
|
||
for (const cleanup of cleanups) {
|
||
cleanup();
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
function createClientAbortContext(req, res) {
|
||
const controller = new AbortController();
|
||
const abort = () => {
|
||
if (!controller.signal.aborted) {
|
||
controller.abort(
|
||
createAbortReason(REASONING_RETRY_ABORT_CLIENT, "client disconnected before reasoning retry completed"),
|
||
);
|
||
}
|
||
};
|
||
const onAborted = () => abort();
|
||
const onReqClose = () => {
|
||
if (req.destroyed && !res.writableEnded) {
|
||
abort();
|
||
}
|
||
};
|
||
const onResClose = () => {
|
||
if (!res.writableEnded) {
|
||
abort();
|
||
}
|
||
};
|
||
req.on("aborted", onAborted);
|
||
req.on("close", onReqClose);
|
||
res.on("close", onResClose);
|
||
return {
|
||
signal: controller.signal,
|
||
cleanup() {
|
||
req.off("aborted", onAborted);
|
||
req.off("close", onReqClose);
|
||
res.off("close", onResClose);
|
||
},
|
||
};
|
||
}
|
||
|
||
function isResponsesReasoningRetryPath(pathname) {
|
||
return RESPONSES_REASONING_RETRY_PATHS.has(normalizePath(pathname));
|
||
}
|
||
|
||
function isResponsesReasoningRetryEligible(pathname, requestEntry) {
|
||
return isResponsesReasoningRetryPath(pathname) && Boolean(requestEntry?.thread_id) && Boolean(requestEntry?.reasoning_retry_enabled);
|
||
}
|
||
|
||
function reasoningRetryWaveWidth(round) {
|
||
if (round <= 2) {
|
||
return 1;
|
||
}
|
||
if (round <= 4) {
|
||
return 2;
|
||
}
|
||
return 4;
|
||
}
|
||
|
||
async function readRequestBody(req, limitBytes) {
|
||
const chunks = [];
|
||
let total = 0;
|
||
for await (const chunk of req) {
|
||
total += chunk.length;
|
||
if (total > limitBytes) {
|
||
throw new Error(`请求体超过限制: ${limitBytes} bytes`);
|
||
}
|
||
chunks.push(chunk);
|
||
}
|
||
return Buffer.concat(chunks);
|
||
}
|
||
|
||
function parseJsonSafely(buffer) {
|
||
try {
|
||
return JSON.parse(buffer.toString("utf8"));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function rebuildBufferedResponse(response, bodyBuffer) {
|
||
return new Response(bodyBuffer, {
|
||
status: response.status,
|
||
statusText: response.statusText,
|
||
headers: new Headers(response.headers),
|
||
});
|
||
}
|
||
|
||
function matchPath(config, pathname) {
|
||
return config.endpoints.includes(normalizePath(pathname));
|
||
}
|
||
|
||
function isImageRequestPath(pathname) {
|
||
const normalizedPath = normalizePath(pathname);
|
||
return (
|
||
normalizedPath === "/images" ||
|
||
normalizedPath.startsWith("/images/") ||
|
||
normalizedPath === "/v1/images" ||
|
||
normalizedPath.startsWith("/v1/images/")
|
||
);
|
||
}
|
||
|
||
function normalizeImageRequestUrl(requestUrl) {
|
||
const normalized = new URL(requestUrl.toString());
|
||
if (normalized.pathname === "/images" || normalized.pathname.startsWith("/images/")) {
|
||
normalized.pathname = `/v1${normalized.pathname}`;
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function selectUpstreamRoute(config, pathname) {
|
||
if (isImageRequestPath(pathname) && `${config.image_base_url || ""}`.trim()) {
|
||
return {
|
||
kind: "images",
|
||
baseUrl: config.image_base_url,
|
||
authConfig: {
|
||
upstream_auth_mode: config.image_auth_mode,
|
||
upstream_auth_env: config.image_auth_env,
|
||
upstream_auth_file: config.image_auth_file,
|
||
upstream_auth_json_path: config.image_auth_json_path,
|
||
upstream_auth_json_key: config.image_auth_json_key,
|
||
},
|
||
};
|
||
}
|
||
return {
|
||
kind: "default",
|
||
baseUrl: config.upstream_base_url,
|
||
authConfig: config,
|
||
};
|
||
}
|
||
|
||
function reasoningMatchesFormula518nMinus2(reasoning) {
|
||
return Number.isInteger(reasoning) && reasoning >= 516 && (reasoning + 2) % 518 === 0;
|
||
}
|
||
|
||
function reasoningMatchedByConfig(config, reasoning) {
|
||
if (!Number.isInteger(reasoning)) {
|
||
return false;
|
||
}
|
||
if (normalizeReasoningMatchMode(config.reasoning_match_mode) === "manual") {
|
||
return config.reasoning_equals.includes(reasoning);
|
||
}
|
||
return reasoningMatchesFormula518nMinus2(reasoning);
|
||
}
|
||
|
||
function reasoningMatched(runtime, config, requestEntry, reasoning) {
|
||
if (!getThreadReasoningState(runtime, requestEntry?.thread_id).reasoning_intercept_enabled) {
|
||
return false;
|
||
}
|
||
return reasoningMatchedByConfig(config, reasoning);
|
||
}
|
||
|
||
function collectRetryableMessageCandidates(value, state = { seen: new Set(), results: [] }, depth = 0) {
|
||
if (value === null || value === undefined || depth > 5 || state.results.length >= 64) {
|
||
return state.results;
|
||
}
|
||
|
||
if (typeof value === "string") {
|
||
const trimmed = value.trim();
|
||
if (trimmed) {
|
||
state.results.push(trimmed);
|
||
}
|
||
return state.results;
|
||
}
|
||
|
||
if (typeof value !== "object") {
|
||
return state.results;
|
||
}
|
||
|
||
if (state.seen.has(value)) {
|
||
return state.results;
|
||
}
|
||
state.seen.add(value);
|
||
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) {
|
||
collectRetryableMessageCandidates(item, state, depth + 1);
|
||
}
|
||
return state.results;
|
||
}
|
||
|
||
const preferredKeys = ["message", "error", "detail", "details", "description", "title"];
|
||
for (const key of preferredKeys) {
|
||
if (Object.hasOwn(value, key)) {
|
||
collectRetryableMessageCandidates(value[key], state, depth + 1);
|
||
}
|
||
}
|
||
for (const [key, nested] of Object.entries(value)) {
|
||
if (preferredKeys.includes(key)) {
|
||
continue;
|
||
}
|
||
collectRetryableMessageCandidates(nested, state, depth + 1);
|
||
}
|
||
|
||
return state.results;
|
||
}
|
||
|
||
function truncateRetryableMessage(value, maxLength = 280) {
|
||
const text = `${value || ""}`.trim();
|
||
if (!text) {
|
||
return "";
|
||
}
|
||
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
|
||
}
|
||
|
||
const RETRYABLE_OVERLOAD_ERROR_CODES = new Set([
|
||
"server_is_overloaded",
|
||
"slow_down",
|
||
]);
|
||
|
||
function findRetryableOverloadErrorCode(value, state = { seen: new Set() }, depth = 0) {
|
||
if (!value || typeof value !== "object" || depth > 6 || state.seen.has(value)) {
|
||
return null;
|
||
}
|
||
state.seen.add(value);
|
||
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) {
|
||
const match = findRetryableOverloadErrorCode(item, state, depth + 1);
|
||
if (match) {
|
||
return match;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const code = typeof value.code === "string" ? value.code.trim().toLowerCase() : "";
|
||
if (RETRYABLE_OVERLOAD_ERROR_CODES.has(code)) {
|
||
return {
|
||
matched_code: code,
|
||
matched_pattern: `error_code:${code}`,
|
||
matched_message: truncateRetryableMessage(
|
||
firstNonEmptyString(value.message, value.detail, value.description, code),
|
||
),
|
||
};
|
||
}
|
||
|
||
for (const nested of Object.values(value)) {
|
||
const match = findRetryableOverloadErrorCode(nested, state, depth + 1);
|
||
if (match) {
|
||
return match;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function findRetryableUpstreamErrorMatch(config, upstreamStatusCode, parsedBody, bodyText) {
|
||
const retryableMessages = normalizePhraseList(
|
||
config.retryable_error_messages,
|
||
DEFAULT_CONFIG.retryable_error_messages,
|
||
);
|
||
if (retryableMessages.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const retryableStatusCodes = normalizeIntegerList(
|
||
config.retryable_status_codes,
|
||
DEFAULT_CONFIG.retryable_status_codes,
|
||
);
|
||
const statusEligible = isUpstreamErrorStatus(upstreamStatusCode) && (
|
||
retryableStatusCodes.length === 0 ||
|
||
retryableStatusCodes.includes(upstreamStatusCode)
|
||
);
|
||
const failurePayload = isRetryableErrorPayloadShape(parsedBody);
|
||
if (!statusEligible && !failurePayload) {
|
||
return null;
|
||
}
|
||
|
||
const overloadCodeMatch = findRetryableOverloadErrorCode(parsedBody);
|
||
if (overloadCodeMatch) {
|
||
return overloadCodeMatch;
|
||
}
|
||
|
||
return matchRetryableMessage(config, parsedBody, bodyText);
|
||
}
|
||
|
||
function matchRetryableMessage(config, parsedBody, bodyText) {
|
||
const retryableMessages = normalizePhraseList(
|
||
config.retryable_error_messages,
|
||
DEFAULT_CONFIG.retryable_error_messages,
|
||
);
|
||
if (retryableMessages.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const normalizedPatterns = retryableMessages.map((message) => ({
|
||
original: message,
|
||
normalized: message.toLowerCase(),
|
||
}));
|
||
const candidateMessages = [];
|
||
|
||
if (parsedBody) {
|
||
candidateMessages.push(...collectRetryableMessageCandidates(parsedBody));
|
||
}
|
||
const trimmedBodyText = `${bodyText || ""}`.trim();
|
||
if (trimmedBodyText) {
|
||
candidateMessages.push(trimmedBodyText);
|
||
}
|
||
|
||
for (const candidate of candidateMessages) {
|
||
const normalizedCandidate = candidate.toLowerCase();
|
||
for (const pattern of normalizedPatterns) {
|
||
if (normalizedCandidate.includes(pattern.normalized)) {
|
||
return {
|
||
matched_pattern: pattern.original,
|
||
matched_message: truncateRetryableMessage(candidate),
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function hasRetryableFailureKeyword(value) {
|
||
const normalized = `${value || ""}`.trim().toLowerCase();
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
return (
|
||
normalized.includes("error") ||
|
||
normalized.includes("failed") ||
|
||
normalized.includes("failure") ||
|
||
normalized.includes("cancelled") ||
|
||
normalized.includes("canceled") ||
|
||
normalized.includes("rate_limit") ||
|
||
normalized.includes("overloaded") ||
|
||
normalized.includes("unavailable")
|
||
);
|
||
}
|
||
|
||
function hasRetryableFailureShape(value, depth = 0) {
|
||
if (!value || depth > 4) {
|
||
return false;
|
||
}
|
||
if (Array.isArray(value)) {
|
||
return value.some((item) => hasRetryableFailureShape(item, depth + 1));
|
||
}
|
||
if (typeof value !== "object") {
|
||
return false;
|
||
}
|
||
if (Object.hasOwn(value, "error")) {
|
||
return true;
|
||
}
|
||
if (Number.isInteger(value.status) && value.status >= 400) {
|
||
return true;
|
||
}
|
||
for (const key of ["type", "event", "status", "state", "result", "code"]) {
|
||
if (typeof value[key] === "string" && hasRetryableFailureKeyword(value[key])) {
|
||
return true;
|
||
}
|
||
}
|
||
for (const key of ["response", "data", "meta", "details", "detail"]) {
|
||
if (hasRetryableFailureShape(value[key], depth + 1)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function isRetryableErrorPayloadShape(parsedBody, eventName = "") {
|
||
if (hasRetryableFailureKeyword(eventName)) {
|
||
return true;
|
||
}
|
||
if (!parsedBody || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
|
||
return false;
|
||
}
|
||
return hasRetryableFailureShape(parsedBody);
|
||
}
|
||
|
||
function findRetryableStreamErrorMatch(config, parsedBody, bodyText, eventName = "") {
|
||
if (!isRetryableErrorPayloadShape(parsedBody, eventName)) {
|
||
return null;
|
||
}
|
||
const overloadCodeMatch = findRetryableOverloadErrorCode(parsedBody);
|
||
if (overloadCodeMatch) {
|
||
return overloadCodeMatch;
|
||
}
|
||
return matchRetryableMessage(config, parsedBody, bodyText);
|
||
}
|
||
|
||
function findRetryableStreamTerminationMatch(config, error) {
|
||
const message = `${error?.message || error || ""}`.trim();
|
||
if (!message) {
|
||
return null;
|
||
}
|
||
return matchRetryableMessage(config, null, message);
|
||
}
|
||
|
||
function isExpectedStreamTermination(error) {
|
||
if (!error) {
|
||
return false;
|
||
}
|
||
const message = `${error?.message || ""}`.trim().toLowerCase();
|
||
if (error.name === "AbortError") {
|
||
return true;
|
||
}
|
||
return error instanceof TypeError && (
|
||
message === "terminated" ||
|
||
message.includes("stream disconnected before completion")
|
||
);
|
||
}
|
||
|
||
function isRetryableUpstreamFetchError(error) {
|
||
if (!error) {
|
||
return false;
|
||
}
|
||
return error instanceof TypeError && error.message === "fetch failed";
|
||
}
|
||
|
||
function isUpstreamErrorStatus(statusCode) {
|
||
return Number.isInteger(statusCode) && statusCode >= 400;
|
||
}
|
||
|
||
function computeRetryBackoffMs(config, attempt) {
|
||
const baseDelay = normalizeNonNegativeInteger(
|
||
config?.upstream_fetch_retry_backoff_ms,
|
||
DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms,
|
||
);
|
||
if (baseDelay <= 0 || attempt <= 1) {
|
||
return baseDelay;
|
||
}
|
||
const multiplier = Math.min(attempt - 1, 4);
|
||
return baseDelay * multiplier;
|
||
}
|
||
|
||
async function fetchUpstreamWithRetry(upstreamUrl, init, config, logger, requestContext = {}) {
|
||
const maxAttempts = normalizePositiveInteger(
|
||
config?.upstream_fetch_retry_attempts,
|
||
DEFAULT_CONFIG.upstream_fetch_retry_attempts,
|
||
);
|
||
const method = `${requestContext.method || init?.method || "GET"}`.toUpperCase();
|
||
const pathname = requestContext.pathname || "";
|
||
|
||
let lastError = null;
|
||
let lastResponse = null;
|
||
let retryableUpstreamError = null;
|
||
|
||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||
try {
|
||
const response = await fetch(upstreamUrl, init);
|
||
lastResponse = response;
|
||
|
||
if (method === "GET") {
|
||
return {
|
||
response,
|
||
attempt_count: attempt,
|
||
retryable_upstream_error: null,
|
||
};
|
||
}
|
||
|
||
const contentType = response.headers.get("content-type");
|
||
if (!isJsonContentType(contentType)) {
|
||
return {
|
||
response,
|
||
attempt_count: attempt,
|
||
retryable_upstream_error: null,
|
||
};
|
||
}
|
||
|
||
const bodyBuffer = Buffer.from(await response.arrayBuffer());
|
||
const parsed = parseJsonSafely(bodyBuffer);
|
||
const bodyText = bodyBuffer.toString("utf8");
|
||
retryableUpstreamError = findRetryableUpstreamErrorMatch(
|
||
config,
|
||
response.status,
|
||
parsed,
|
||
bodyText,
|
||
);
|
||
|
||
if (!retryableUpstreamError) {
|
||
return {
|
||
response: rebuildBufferedResponse(response, bodyBuffer),
|
||
attempt_count: attempt,
|
||
retryable_upstream_error: null,
|
||
};
|
||
}
|
||
|
||
if (attempt === maxAttempts) {
|
||
return {
|
||
response: rebuildBufferedResponse(response, bodyBuffer),
|
||
attempt_count: attempt,
|
||
retryable_upstream_error: {
|
||
...retryableUpstreamError,
|
||
upstream_status_code: response.status,
|
||
},
|
||
};
|
||
}
|
||
|
||
const backoffMs = computeRetryBackoffMs(config, attempt);
|
||
logger?.(
|
||
`[retry] upstream retryable error attempt=${attempt} next_attempt=${attempt + 1} status=${response.status} path=${pathname || "-"} reason=${JSON.stringify(retryableUpstreamError.matched_pattern)} backoff_ms=${backoffMs}`,
|
||
);
|
||
if (backoffMs > 0) {
|
||
await sleep(backoffMs);
|
||
}
|
||
} catch (error) {
|
||
lastError = error;
|
||
if (error && typeof error === "object") {
|
||
error.gatewayAttemptCount = attempt;
|
||
}
|
||
if (!isRetryableUpstreamFetchError(error) || attempt === maxAttempts) {
|
||
break;
|
||
}
|
||
const backoffMs = computeRetryBackoffMs(config, attempt);
|
||
logger?.(
|
||
`[retry] upstream fetch failed attempt=${attempt} next_attempt=${attempt + 1} method=${method} path=${pathname || "-"} url=${upstreamUrl} backoff_ms=${backoffMs}`,
|
||
);
|
||
if (backoffMs > 0) {
|
||
await sleep(backoffMs);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (lastError) {
|
||
throw lastError;
|
||
}
|
||
|
||
return {
|
||
response: lastResponse,
|
||
attempt_count: maxAttempts,
|
||
retryable_upstream_error: retryableUpstreamError,
|
||
};
|
||
}
|
||
|
||
function inspectSseChunk(state, chunk, config) {
|
||
const decoded = state.decoder.decode(chunk, { stream: true });
|
||
state.buffer += decoded;
|
||
const blocks = state.buffer.split(/\r?\n\r?\n/);
|
||
state.buffer = blocks.pop() ?? "";
|
||
return inspectSseBlocks(blocks, config);
|
||
}
|
||
|
||
async function handleNonStreaming({
|
||
runtime,
|
||
config,
|
||
logger,
|
||
monitor,
|
||
pathname,
|
||
upstreamResponse,
|
||
res,
|
||
requestEntry,
|
||
terminalRetryableUpstreamError = null,
|
||
captureOnly = false,
|
||
persistEntry = true,
|
||
}) {
|
||
markRequestEntryFirstResponse(runtime, requestEntry, { persistEntry });
|
||
const bodyBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
||
const bodyText = bodyBuffer.toString("utf8");
|
||
const parsed = isJsonContentType(upstreamResponse.headers.get("content-type"))
|
||
? parseJsonSafely(bodyBuffer)
|
||
: null;
|
||
const reasoning = parsed ? extractReasoningTokens(parsed) : null;
|
||
const usage = parsed ? normalizeUsageSnapshot(parsed) : null;
|
||
const responseId = parsed ? extractNonStreamingResponseId(parsed) : null;
|
||
const threadId = firstNonEmptyString(
|
||
requestEntry.thread_id,
|
||
parsed ? extractResponseThreadId(parsed) : null,
|
||
);
|
||
const retryableUpstreamError = terminalRetryableUpstreamError || findRetryableUpstreamErrorMatch(
|
||
config,
|
||
upstreamResponse.status,
|
||
parsed,
|
||
bodyText,
|
||
);
|
||
requestEntry.response_id = responseId || requestEntry.response_id || null;
|
||
requestEntry.thread_id = threadId || requestEntry.thread_id || null;
|
||
applyThreadReasoningState(runtime, requestEntry, pathname);
|
||
const matched = reasoningMatched(runtime, config, requestEntry, reasoning);
|
||
|
||
recordInspectedResponse(monitor, reasoning, matched || Boolean(retryableUpstreamError));
|
||
|
||
if (matched) {
|
||
if (config.log_match) {
|
||
logger(
|
||
`[match] non-stream path=${pathname} reasoning_tokens=${reasoning} action=status_${config.non_stream_status_code}`,
|
||
);
|
||
}
|
||
if (!captureOnly) {
|
||
const blockedBody = buildBlockedBody(pathname, reasoning, config.non_stream_status_code);
|
||
res.writeHead(config.non_stream_status_code, {
|
||
"content-type": "application/json; charset=utf-8",
|
||
"x-codex-retry-gateway-reason": "reasoning-guard-triggered",
|
||
});
|
||
res.end(blockedBody);
|
||
}
|
||
return {
|
||
inspected: true,
|
||
matched,
|
||
match_reason: "reasoning_guard",
|
||
status_code: config.non_stream_status_code,
|
||
upstream_status_code: upstreamResponse.status,
|
||
reasoning_tokens: reasoning,
|
||
usage,
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
};
|
||
}
|
||
|
||
if (retryableUpstreamError) {
|
||
if (config.log_match) {
|
||
logger(
|
||
`[match] non-stream path=${pathname} upstream_status=${upstreamResponse.status} retryable_error=${JSON.stringify(retryableUpstreamError.matched_pattern)} action=status_${config.non_stream_status_code}`,
|
||
);
|
||
}
|
||
const blockedBody = buildRetryableUpstreamErrorBody(
|
||
pathname,
|
||
upstreamResponse.status,
|
||
retryableUpstreamError.matched_message || retryableUpstreamError.matched_pattern,
|
||
config.non_stream_status_code,
|
||
);
|
||
if (!captureOnly) {
|
||
res.writeHead(config.non_stream_status_code, {
|
||
"content-type": "application/json; charset=utf-8",
|
||
"x-codex-retry-gateway-reason": "upstream-error-retry-triggered",
|
||
});
|
||
res.end(blockedBody);
|
||
}
|
||
return {
|
||
inspected: true,
|
||
matched: true,
|
||
status_code: config.non_stream_status_code,
|
||
upstream_status_code: upstreamResponse.status,
|
||
reasoning_tokens: reasoning,
|
||
usage,
|
||
error: `retryable upstream error: ${retryableUpstreamError.matched_pattern}`,
|
||
match_reason: "retryable_upstream_error",
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
delivery: captureOnly
|
||
? buildCapturedDelivery(
|
||
config.non_stream_status_code,
|
||
new Headers({
|
||
"content-type": "application/json; charset=utf-8",
|
||
"x-codex-retry-gateway-reason": "upstream-error-retry-triggered",
|
||
}),
|
||
blockedBody,
|
||
)
|
||
: null,
|
||
};
|
||
}
|
||
|
||
if (!captureOnly) {
|
||
copyHeadersToClient(upstreamResponse.headers, res);
|
||
res.writeHead(upstreamResponse.status);
|
||
res.end(bodyBuffer);
|
||
}
|
||
return {
|
||
inspected: true,
|
||
matched,
|
||
status_code: upstreamResponse.status,
|
||
upstream_status_code: upstreamResponse.status,
|
||
reasoning_tokens: reasoning,
|
||
usage,
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
delivery: captureOnly
|
||
? buildCapturedDelivery(upstreamResponse.status, upstreamResponse.headers, bodyBuffer)
|
||
: null,
|
||
};
|
||
}
|
||
|
||
async function handleStreaming({
|
||
runtime,
|
||
config,
|
||
logger,
|
||
monitor,
|
||
pathname,
|
||
upstreamResponse,
|
||
res,
|
||
abortController,
|
||
requestEntry,
|
||
requestAbortSignal = null,
|
||
captureOnly = false,
|
||
persistEntry = true,
|
||
}) {
|
||
const strict502Mode = captureOnly || config.stream_action !== "disconnect";
|
||
const reader = upstreamResponse.body.getReader();
|
||
const sseState = {
|
||
decoder: new TextDecoder("utf8"),
|
||
buffer: "",
|
||
};
|
||
const codexResponsesSseState = isResponsesReasoningRetryPath(pathname) && isSseContentType(
|
||
upstreamResponse.headers.get("content-type"),
|
||
)
|
||
? createResponsesCodexSseState()
|
||
: null;
|
||
|
||
let wroteAnyChunk = false;
|
||
let observedReasoning = null;
|
||
let observedUsage = null;
|
||
const bufferedChunks = [];
|
||
|
||
if (!strict502Mode && !captureOnly) {
|
||
copyHeadersToClient(upstreamResponse.headers, res);
|
||
res.writeHead(upstreamResponse.status);
|
||
}
|
||
|
||
while (true) {
|
||
let readResult;
|
||
try {
|
||
readResult = await reader.read();
|
||
} catch (error) {
|
||
if (requestAbortSignal?.aborted) {
|
||
const abortCode = abortReasonCode(requestAbortSignal);
|
||
if (abortCode === REASONING_RETRY_ABORT_WINNER) {
|
||
return {
|
||
cancelled: true,
|
||
cancel_reason: abortCode,
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
response_bytes_received: requestEntry.response_bytes_received,
|
||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||
};
|
||
}
|
||
throw requestAbortSignal.reason || error;
|
||
}
|
||
if (isExpectedStreamTermination(error)) {
|
||
const retryableTerminationError = findRetryableStreamTerminationMatch(config, error);
|
||
if (retryableTerminationError) {
|
||
return {
|
||
inspected: true,
|
||
matched: true,
|
||
retry_requested: strict502Mode || !wroteAnyChunk,
|
||
retryable_upstream_error: retryableTerminationError,
|
||
upstream_status_code: upstreamResponse.status,
|
||
reasoning_tokens: observedReasoning,
|
||
usage: observedUsage,
|
||
error: `retryable upstream error: ${retryableTerminationError.matched_pattern}`,
|
||
match_reason: "retryable_upstream_error",
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
response_bytes_received: requestEntry.response_bytes_received,
|
||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||
};
|
||
}
|
||
recordInspectedResponse(monitor, observedReasoning, false);
|
||
if (persistEntry) {
|
||
persistStreamingProgress(runtime, requestEntry, { force: true }, new Date());
|
||
}
|
||
if (strict502Mode) {
|
||
logger?.(`[stream] upstream terminated before completion path=${pathname} action=status_502`);
|
||
const gatewayErrorBody = buildGatewayErrorBody("upstream stream terminated before completion");
|
||
if (!captureOnly) {
|
||
res.writeHead(502, { "content-type": "application/json; charset=utf-8" });
|
||
res.end(gatewayErrorBody);
|
||
}
|
||
return {
|
||
inspected: true,
|
||
matched: false,
|
||
status_code: 502,
|
||
upstream_status_code: upstreamResponse.status,
|
||
reasoning_tokens: observedReasoning,
|
||
usage: observedUsage,
|
||
error: "upstream stream terminated before completion",
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
response_bytes_received: requestEntry.response_bytes_received,
|
||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||
delivery: captureOnly
|
||
? buildCapturedDelivery(
|
||
502,
|
||
new Headers({ "content-type": "application/json; charset=utf-8" }),
|
||
gatewayErrorBody,
|
||
)
|
||
: null,
|
||
};
|
||
} else {
|
||
if (!captureOnly) {
|
||
res.end();
|
||
}
|
||
return {
|
||
inspected: true,
|
||
matched: false,
|
||
status_code: upstreamResponse.status,
|
||
upstream_status_code: upstreamResponse.status,
|
||
reasoning_tokens: observedReasoning,
|
||
usage: observedUsage,
|
||
error: "upstream stream terminated before completion",
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
response_bytes_received: requestEntry.response_bytes_received,
|
||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||
};
|
||
}
|
||
}
|
||
throw error;
|
||
}
|
||
|
||
const { done, value } = readResult;
|
||
if (done) {
|
||
const finalInspection = flushSseInspectionRemainder(sseState, config);
|
||
const finalCodexChunks = codexResponsesSseState
|
||
? flushResponsesSseForCodex(
|
||
codexResponsesSseState,
|
||
requestEntry.response_id || null,
|
||
observedUsage,
|
||
)
|
||
: [];
|
||
if (Number.isInteger(finalInspection.reasoning)) {
|
||
observedReasoning = finalInspection.reasoning;
|
||
}
|
||
observedUsage = mergeUsageSnapshots(observedUsage, finalInspection.usage);
|
||
if (finalInspection.response_id) {
|
||
requestEntry.response_id = finalInspection.response_id;
|
||
}
|
||
if (finalInspection.thread_id) {
|
||
requestEntry.thread_id = finalInspection.thread_id;
|
||
}
|
||
applyThreadReasoningState(runtime, requestEntry, pathname);
|
||
if (codexResponsesSseState?.response_id && !requestEntry.response_id) {
|
||
requestEntry.response_id = codexResponsesSseState.response_id;
|
||
}
|
||
observedUsage = mergeUsageSnapshots(observedUsage, codexResponsesSseState?.usage || null);
|
||
if (finalInspection.retryable_upstream_error) {
|
||
return {
|
||
inspected: true,
|
||
matched: true,
|
||
retry_requested: strict502Mode || !wroteAnyChunk,
|
||
retryable_upstream_error: finalInspection.retryable_upstream_error,
|
||
upstream_status_code: upstreamResponse.status,
|
||
reasoning_tokens: observedReasoning,
|
||
usage: observedUsage,
|
||
error: `retryable upstream error: ${finalInspection.retryable_upstream_error.matched_pattern}`,
|
||
match_reason: "retryable_upstream_error",
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
response_bytes_received: requestEntry.response_bytes_received,
|
||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||
};
|
||
}
|
||
if (reasoningMatched(runtime, config, requestEntry, observedReasoning)) {
|
||
recordInspectedResponse(monitor, observedReasoning, true);
|
||
if (config.log_match) {
|
||
logger(
|
||
`[match] stream path=${pathname} reasoning_tokens=${observedReasoning} action=${config.stream_action}`,
|
||
);
|
||
}
|
||
if (captureOnly) {
|
||
return {
|
||
inspected: true,
|
||
matched: true,
|
||
match_reason: "reasoning_guard",
|
||
status_code: config.non_stream_status_code,
|
||
upstream_status_code: upstreamResponse.status,
|
||
reasoning_tokens: observedReasoning,
|
||
usage: observedUsage,
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
response_bytes_received: requestEntry.response_bytes_received,
|
||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||
};
|
||
}
|
||
if (strict502Mode || !wroteAnyChunk) {
|
||
const blockedBody = buildBlockedBody(pathname, observedReasoning, config.non_stream_status_code);
|
||
res.writeHead(config.non_stream_status_code, {
|
||
"content-type": "application/json; charset=utf-8",
|
||
"x-codex-retry-gateway-reason": "reasoning-guard-triggered",
|
||
});
|
||
res.end(blockedBody);
|
||
} else if (!captureOnly && !res.writableEnded) {
|
||
res.end();
|
||
}
|
||
return {
|
||
inspected: true,
|
||
matched: true,
|
||
match_reason: "reasoning_guard",
|
||
status_code: config.non_stream_status_code,
|
||
upstream_status_code: upstreamResponse.status,
|
||
reasoning_tokens: observedReasoning,
|
||
usage: observedUsage,
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
response_bytes_received: requestEntry.response_bytes_received,
|
||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||
};
|
||
}
|
||
if (finalCodexChunks.length > 0) {
|
||
if (strict502Mode) {
|
||
bufferedChunks.push(...finalCodexChunks);
|
||
} else if (!captureOnly) {
|
||
for (const chunk of finalCodexChunks) {
|
||
wroteAnyChunk = true;
|
||
res.write(chunk);
|
||
}
|
||
}
|
||
}
|
||
recordInspectedResponse(monitor, observedReasoning, false);
|
||
if (persistEntry) {
|
||
persistStreamingProgress(runtime, requestEntry, { force: true }, new Date());
|
||
}
|
||
if (strict502Mode) {
|
||
const replayChunks = cloneBufferList(bufferedChunks) || [];
|
||
const bufferedBody = Buffer.concat(replayChunks);
|
||
if (!captureOnly) {
|
||
copyHeadersToClient(upstreamResponse.headers, res);
|
||
res.writeHead(upstreamResponse.status);
|
||
await writeBufferedStreamChunks(res, replayChunks);
|
||
if (!res.writableEnded) {
|
||
res.end();
|
||
}
|
||
}
|
||
return {
|
||
inspected: true,
|
||
matched: false,
|
||
status_code: upstreamResponse.status,
|
||
upstream_status_code: upstreamResponse.status,
|
||
reasoning_tokens: observedReasoning,
|
||
usage: observedUsage,
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
response_bytes_received: requestEntry.response_bytes_received,
|
||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||
delivery: captureOnly
|
||
? buildCapturedDelivery(upstreamResponse.status, upstreamResponse.headers, bufferedBody, {
|
||
stream_chunks: replayChunks,
|
||
})
|
||
: null,
|
||
};
|
||
} else {
|
||
if (!captureOnly) {
|
||
res.end();
|
||
}
|
||
return {
|
||
inspected: true,
|
||
matched: false,
|
||
status_code: upstreamResponse.status,
|
||
upstream_status_code: upstreamResponse.status,
|
||
reasoning_tokens: observedReasoning,
|
||
usage: observedUsage,
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
response_bytes_received: requestEntry.response_bytes_received,
|
||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||
};
|
||
}
|
||
}
|
||
|
||
const chunkBuffer = Buffer.from(value);
|
||
const now = new Date();
|
||
const inspection = inspectSseChunk(sseState, value, config);
|
||
const retryableUpstreamError = inspection.retryable_upstream_error;
|
||
if (retryableUpstreamError) {
|
||
abortController.abort();
|
||
reader.cancel().catch(() => {});
|
||
return {
|
||
inspected: true,
|
||
matched: true,
|
||
retry_requested: strict502Mode || !wroteAnyChunk,
|
||
retryable_upstream_error: retryableUpstreamError,
|
||
upstream_status_code: upstreamResponse.status,
|
||
reasoning_tokens: observedReasoning,
|
||
usage: observedUsage,
|
||
error: `retryable upstream error: ${retryableUpstreamError.matched_pattern}`,
|
||
match_reason: "retryable_upstream_error",
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
response_bytes_received: requestEntry.response_bytes_received,
|
||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||
};
|
||
}
|
||
|
||
markRequestEntryFirstResponse(runtime, requestEntry, { persistEntry, at: now });
|
||
const reasoning = inspection.reasoning;
|
||
const usageUpdated = Boolean(inspection.usage);
|
||
observedUsage = mergeUsageSnapshots(observedUsage, inspection.usage);
|
||
if (Number.isInteger(reasoning)) {
|
||
observedReasoning = reasoning;
|
||
}
|
||
if (inspection.response_id) {
|
||
requestEntry.response_id = inspection.response_id;
|
||
}
|
||
if (inspection.thread_id) {
|
||
requestEntry.thread_id = inspection.thread_id;
|
||
}
|
||
applyThreadReasoningState(runtime, requestEntry, pathname);
|
||
updateStreamingProgress(requestEntry, {
|
||
chunkBytes: chunkBuffer.length,
|
||
usage: inspection.usage,
|
||
reasoning,
|
||
at: now,
|
||
});
|
||
if (persistEntry) {
|
||
persistStreamingProgress(
|
||
runtime,
|
||
requestEntry,
|
||
{ usageUpdated, force: requestEntry.stream_chunk_count === 1 },
|
||
now,
|
||
);
|
||
}
|
||
if (reasoningMatched(runtime, config, requestEntry, reasoning)) {
|
||
recordInspectedResponse(monitor, reasoning, true);
|
||
if (config.log_match) {
|
||
logger(
|
||
`[match] stream path=${pathname} reasoning_tokens=${reasoning} action=${config.stream_action}`,
|
||
);
|
||
}
|
||
|
||
if (captureOnly) {
|
||
abortController.abort(createAbortReason(REASONING_RETRY_ABORT_WINNER, "reasoning retry wave advanced"));
|
||
reader.cancel().catch(() => {});
|
||
} else if (strict502Mode || !wroteAnyChunk) {
|
||
abortController.abort();
|
||
reader.cancel().catch(() => {});
|
||
const blockedBody = buildBlockedBody(pathname, reasoning, config.non_stream_status_code);
|
||
res.writeHead(config.non_stream_status_code, {
|
||
"content-type": "application/json; charset=utf-8",
|
||
"x-codex-retry-gateway-reason": "reasoning-guard-triggered",
|
||
});
|
||
res.end(blockedBody);
|
||
} else {
|
||
abortController.abort();
|
||
reader.cancel().catch(() => {});
|
||
res.socket?.destroy();
|
||
}
|
||
return {
|
||
inspected: true,
|
||
matched: true,
|
||
match_reason: "reasoning_guard",
|
||
status_code: config.non_stream_status_code,
|
||
upstream_status_code: upstreamResponse.status,
|
||
reasoning_tokens: reasoning,
|
||
usage: observedUsage,
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
response_bytes_received: requestEntry.response_bytes_received,
|
||
stream_chunk_count: requestEntry.stream_chunk_count,
|
||
};
|
||
}
|
||
|
||
const outputChunks = codexResponsesSseState
|
||
? drainResponsesSseForCodex(
|
||
codexResponsesSseState,
|
||
value,
|
||
requestEntry.response_id || null,
|
||
)
|
||
: [chunkBuffer];
|
||
|
||
if (strict502Mode) {
|
||
bufferedChunks.push(...outputChunks);
|
||
} else {
|
||
if (!captureOnly) {
|
||
for (const outputChunk of outputChunks) {
|
||
if (!outputChunk || outputChunk.length === 0) {
|
||
continue;
|
||
}
|
||
wroteAnyChunk = true;
|
||
res.write(outputChunk);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildAttemptRequestEntry(baseEntry) {
|
||
return {
|
||
...baseEntry,
|
||
lifecycle_state: "sent",
|
||
first_response_at: null,
|
||
first_response_delay_ms: null,
|
||
last_activity_at: null,
|
||
finished_at: null,
|
||
duration_ms: null,
|
||
response_bytes_received: 0,
|
||
response_stream: false,
|
||
stream_chunk_count: 0,
|
||
usage_last_updated_at: null,
|
||
upstream_attempt_count: 0,
|
||
inspected: false,
|
||
matched: false,
|
||
status_code: null,
|
||
upstream_status_code: null,
|
||
reasoning_tokens: null,
|
||
usage: null,
|
||
error: null,
|
||
_started_ms: Date.now(),
|
||
};
|
||
}
|
||
|
||
function classifyGatewayQueryResult(result) {
|
||
if (result?.cancelled) {
|
||
return "cancelled";
|
||
}
|
||
if (result?.match_reason === "reasoning_guard") {
|
||
return "reasoning_retry";
|
||
}
|
||
if (
|
||
Number.isInteger(result?.status_code) &&
|
||
result.status_code >= 200 &&
|
||
result.status_code < 400 &&
|
||
!result?.error
|
||
) {
|
||
return "success";
|
||
}
|
||
return "fatal";
|
||
}
|
||
|
||
async function executeGatewayQuery({
|
||
runtime,
|
||
config,
|
||
logger,
|
||
monitor,
|
||
pathname,
|
||
req,
|
||
res,
|
||
requestBody,
|
||
requestIsStream,
|
||
upstreamUrl,
|
||
upstreamAuth,
|
||
requestEntry,
|
||
captureOnly = false,
|
||
persistEntry = true,
|
||
externalAbortSignals = [],
|
||
}) {
|
||
const maxUpstreamAttempts = normalizePositiveInteger(
|
||
config.upstream_fetch_retry_attempts,
|
||
DEFAULT_CONFIG.upstream_fetch_retry_attempts,
|
||
);
|
||
const shouldInspect = matchPath(config, pathname);
|
||
let totalUpstreamAttempts = 0;
|
||
const queryAbortLink = createLinkedAbortController(externalAbortSignals);
|
||
|
||
try {
|
||
while (totalUpstreamAttempts < maxUpstreamAttempts) {
|
||
if (queryAbortLink.controller.signal.aborted) {
|
||
throw queryAbortLink.controller.signal.reason || createAbortReason(
|
||
REASONING_RETRY_ABORT_CLIENT,
|
||
"request aborted",
|
||
);
|
||
}
|
||
|
||
const upstreamAbortLink = createLinkedAbortController([queryAbortLink.controller.signal]);
|
||
try {
|
||
const {
|
||
response: upstreamResponse,
|
||
attempt_count: upstreamAttemptCount,
|
||
retryable_upstream_error: terminalRetryableUpstreamError,
|
||
} = await fetchUpstreamWithRetry(upstreamUrl, {
|
||
method: req.method,
|
||
headers: cloneHeadersForUpstream(req.headers, upstreamAuth),
|
||
body: requestBody.length > 0 ? requestBody : undefined,
|
||
signal: upstreamAbortLink.controller.signal,
|
||
}, {
|
||
...config,
|
||
upstream_fetch_retry_attempts: Math.max(1, maxUpstreamAttempts - totalUpstreamAttempts),
|
||
}, logger, { method: req.method, pathname });
|
||
|
||
totalUpstreamAttempts += upstreamAttemptCount;
|
||
requestEntry.upstream_attempt_count = totalUpstreamAttempts;
|
||
|
||
const responseContentType = upstreamResponse.headers.get("content-type");
|
||
const responseIsStream = isSseContentType(responseContentType) || (
|
||
requestIsStream &&
|
||
!isJsonContentType(responseContentType) &&
|
||
!isUpstreamErrorStatus(upstreamResponse.status)
|
||
);
|
||
requestEntry.response_stream = responseIsStream;
|
||
requestEntry.inspected = shouldInspect;
|
||
requestEntry.upstream_status_code = upstreamResponse.status;
|
||
const upstreamRoute = requestEntry.upstream?.route || "default";
|
||
requestEntry.upstream = {
|
||
...buildUpstreamSnapshot({ upstreamUrl, upstreamAuth, upstreamResponse }),
|
||
route: upstreamRoute,
|
||
};
|
||
if (persistEntry) {
|
||
upsertRequestEntry(runtime, requestEntry);
|
||
}
|
||
if (isUpstreamErrorStatus(upstreamResponse.status)) {
|
||
logger?.(
|
||
`[upstream] status=${upstreamResponse.status} profile=${config.profile_name || "default"} path=${requestEntry.upstream.path} auth=${requestEntry.upstream.auth_mode}/${requestEntry.upstream.auth_source} content_type=${requestEntry.upstream.content_type || "-"}`,
|
||
);
|
||
}
|
||
|
||
if (!shouldInspect) {
|
||
markRequestEntryFirstResponse(runtime, requestEntry, { persistEntry });
|
||
const body = Buffer.from(await upstreamResponse.arrayBuffer());
|
||
requestEntry.response_bytes_received = body.length;
|
||
const parsed = isJsonContentType(responseContentType)
|
||
? parseJsonSafely(body)
|
||
: null;
|
||
requestEntry.response_id = requestEntry.response_id || (parsed ? extractNonStreamingResponseId(parsed) : null);
|
||
requestEntry.thread_id = requestEntry.thread_id || (parsed ? extractResponseThreadId(parsed) : null);
|
||
if (!captureOnly) {
|
||
copyHeadersToClient(upstreamResponse.headers, res);
|
||
res.writeHead(upstreamResponse.status);
|
||
res.end(body);
|
||
}
|
||
return {
|
||
response_stream: responseIsStream,
|
||
inspected: false,
|
||
matched: false,
|
||
status_code: upstreamResponse.status,
|
||
upstream_status_code: upstreamResponse.status,
|
||
response_id: requestEntry.response_id,
|
||
thread_id: requestEntry.thread_id,
|
||
total_upstream_attempts: totalUpstreamAttempts,
|
||
delivery: captureOnly
|
||
? buildCapturedDelivery(upstreamResponse.status, upstreamResponse.headers, body)
|
||
: null,
|
||
};
|
||
}
|
||
|
||
const result = responseIsStream
|
||
? await handleStreaming({
|
||
runtime,
|
||
config,
|
||
logger,
|
||
monitor,
|
||
pathname,
|
||
upstreamResponse,
|
||
res,
|
||
abortController: upstreamAbortLink.controller,
|
||
requestEntry,
|
||
requestAbortSignal: queryAbortLink.controller.signal,
|
||
captureOnly,
|
||
persistEntry,
|
||
})
|
||
: await handleNonStreaming({
|
||
runtime,
|
||
config,
|
||
logger,
|
||
monitor,
|
||
pathname,
|
||
upstreamResponse,
|
||
res,
|
||
requestEntry,
|
||
terminalRetryableUpstreamError,
|
||
captureOnly,
|
||
persistEntry,
|
||
});
|
||
|
||
if (result.retry_requested) {
|
||
if (totalUpstreamAttempts < maxUpstreamAttempts) {
|
||
const backoffMs = computeRetryBackoffMs(config, totalUpstreamAttempts);
|
||
logger?.(
|
||
`[retry] upstream retryable stream error attempt=${totalUpstreamAttempts} next_attempt=${totalUpstreamAttempts + 1} status=${upstreamResponse.status} path=${pathname || "-"} reason=${JSON.stringify(result.retryable_upstream_error?.matched_pattern)} backoff_ms=${backoffMs}`,
|
||
);
|
||
if (backoffMs > 0) {
|
||
await sleep(backoffMs);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
recordInspectedResponse(runtime.monitor, result.reasoning_tokens, true);
|
||
if (config.log_match) {
|
||
logger?.(
|
||
`[match] stream path=${pathname} upstream_status=${upstreamResponse.status} retryable_error=${JSON.stringify(result.retryable_upstream_error?.matched_pattern)} action=status_${config.non_stream_status_code}`,
|
||
);
|
||
}
|
||
const blockedBody = buildRetryableUpstreamErrorBody(
|
||
pathname,
|
||
upstreamResponse.status,
|
||
result.retryable_upstream_error?.matched_message || result.retryable_upstream_error?.matched_pattern,
|
||
config.non_stream_status_code,
|
||
);
|
||
if (!captureOnly) {
|
||
res.writeHead(config.non_stream_status_code, {
|
||
"content-type": "application/json; charset=utf-8",
|
||
"x-codex-retry-gateway-reason": "upstream-error-retry-triggered",
|
||
});
|
||
res.end(blockedBody);
|
||
}
|
||
return {
|
||
response_stream: true,
|
||
...result,
|
||
matched: true,
|
||
status_code: config.non_stream_status_code,
|
||
upstream_status_code: upstreamResponse.status,
|
||
total_upstream_attempts: totalUpstreamAttempts,
|
||
delivery: captureOnly
|
||
? buildCapturedDelivery(
|
||
config.non_stream_status_code,
|
||
new Headers({
|
||
"content-type": "application/json; charset=utf-8",
|
||
"x-codex-retry-gateway-reason": "upstream-error-retry-triggered",
|
||
}),
|
||
blockedBody,
|
||
)
|
||
: null,
|
||
};
|
||
}
|
||
|
||
return {
|
||
response_stream: responseIsStream,
|
||
...result,
|
||
total_upstream_attempts: totalUpstreamAttempts,
|
||
};
|
||
} finally {
|
||
upstreamAbortLink.cleanup();
|
||
}
|
||
}
|
||
} catch (error) {
|
||
const accumulatedAttempts = Number.isInteger(error?.gatewayAttemptCount)
|
||
? totalUpstreamAttempts + error.gatewayAttemptCount
|
||
: totalUpstreamAttempts;
|
||
if (error && typeof error === "object") {
|
||
error.gatewayAttemptCount = accumulatedAttempts;
|
||
}
|
||
throw error;
|
||
} finally {
|
||
queryAbortLink.cleanup();
|
||
}
|
||
|
||
throw new Error("gateway query exhausted unexpectedly");
|
||
}
|
||
|
||
async function executeCapturedGatewayQuery(args) {
|
||
try {
|
||
const result = await executeGatewayQuery({
|
||
...args,
|
||
captureOnly: true,
|
||
persistEntry: false,
|
||
});
|
||
return {
|
||
kind: classifyGatewayQueryResult(result),
|
||
result,
|
||
total_upstream_attempts: result.total_upstream_attempts || 0,
|
||
};
|
||
} catch (error) {
|
||
const abortCode = abortReasonCode(error);
|
||
if (abortCode === REASONING_RETRY_ABORT_CLIENT) {
|
||
throw error;
|
||
}
|
||
if (abortCode === REASONING_RETRY_ABORT_WINNER) {
|
||
return {
|
||
kind: "cancelled",
|
||
result: {
|
||
cancelled: true,
|
||
cancel_reason: abortCode,
|
||
},
|
||
total_upstream_attempts: Number.isInteger(error?.gatewayAttemptCount) ? error.gatewayAttemptCount : 0,
|
||
};
|
||
}
|
||
|
||
const gatewayErrorBody = buildGatewayErrorBody(`${error?.message || error}`);
|
||
return {
|
||
kind: "fatal",
|
||
result: {
|
||
inspected: false,
|
||
matched: false,
|
||
status_code: 502,
|
||
upstream_status_code: null,
|
||
error: `${error?.message || error}`,
|
||
delivery: buildCapturedDelivery(
|
||
502,
|
||
new Headers({ "content-type": "application/json; charset=utf-8" }),
|
||
gatewayErrorBody,
|
||
),
|
||
},
|
||
total_upstream_attempts: Number.isInteger(error?.gatewayAttemptCount) ? error.gatewayAttemptCount : 0,
|
||
};
|
||
}
|
||
}
|
||
|
||
function createReasoningRetryExtraState() {
|
||
return {
|
||
inspected_count: 0,
|
||
matched_count: 0,
|
||
usage: null,
|
||
reasoning_counts: {},
|
||
};
|
||
}
|
||
|
||
function accumulateReasoningRetryExtras(state, outcomes, excludedResult = null) {
|
||
for (const outcome of outcomes) {
|
||
if (outcome?.kind === "cancelled") {
|
||
continue;
|
||
}
|
||
const result = outcome?.result;
|
||
if (!result?.inspected || result === excludedResult) {
|
||
continue;
|
||
}
|
||
state.inspected_count += 1;
|
||
if (result.matched) {
|
||
state.matched_count += 1;
|
||
}
|
||
incrementReasoningCount(state.reasoning_counts, result.reasoning_tokens);
|
||
state.usage = sumUsageSnapshots(state.usage, result.usage);
|
||
}
|
||
}
|
||
|
||
function buildReasoningRetryFirstSlots(round, width) {
|
||
return Array.from({ length: width }, (_, index) => ({
|
||
round,
|
||
slot: index + 1,
|
||
first_response_at: null,
|
||
first_response_delay_ms: null,
|
||
outcome: "pending",
|
||
}));
|
||
}
|
||
|
||
function updateReasoningRetryFirstSlot(requestEntry, round, slot, fields = {}) {
|
||
if (requestEntry.reasoning_retry_current_round !== round) {
|
||
return false;
|
||
}
|
||
const currentFirsts = Array.isArray(requestEntry.reasoning_retry_current_firsts)
|
||
? requestEntry.reasoning_retry_current_firsts
|
||
: [];
|
||
const index = slot - 1;
|
||
if (index < 0 || index >= currentFirsts.length) {
|
||
return false;
|
||
}
|
||
const existing = currentFirsts[index] || { round, slot };
|
||
currentFirsts[index] = {
|
||
...existing,
|
||
round,
|
||
slot,
|
||
...fields,
|
||
};
|
||
requestEntry.reasoning_retry_current_firsts = currentFirsts;
|
||
return true;
|
||
}
|
||
|
||
function updateReasoningRetryFirstFromAttempt(requestEntry, outcome) {
|
||
const attemptEntry = outcome?.requestEntry || {};
|
||
const result = outcome?.result || {};
|
||
return updateReasoningRetryFirstSlot(requestEntry, outcome.round, outcome.slot, {
|
||
first_response_at: attemptEntry.first_response_at || null,
|
||
first_response_delay_ms: Number.isInteger(attemptEntry.first_response_delay_ms)
|
||
? attemptEntry.first_response_delay_ms
|
||
: null,
|
||
outcome: outcome.kind || "unknown",
|
||
status_code: Number.isInteger(result.status_code) ? result.status_code : null,
|
||
upstream_status_code: Number.isInteger(result.upstream_status_code) ? result.upstream_status_code : null,
|
||
reasoning_tokens: Number.isInteger(result.reasoning_tokens) ? result.reasoning_tokens : null,
|
||
matched: Boolean(result.matched),
|
||
});
|
||
}
|
||
|
||
function markReasoningRetryCancelledSlots(requestEntry, round, slots = []) {
|
||
let changed = false;
|
||
for (const slot of slots) {
|
||
changed = updateReasoningRetryFirstSlot(requestEntry, round, slot, { outcome: "cancelled" }) || changed;
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
async function runResponsesReasoningRetry({
|
||
runtime,
|
||
config,
|
||
logger,
|
||
monitor,
|
||
pathname,
|
||
req,
|
||
requestBody,
|
||
requestIsStream,
|
||
upstreamUrl,
|
||
upstreamAuth,
|
||
requestEntry,
|
||
clientAbortSignal,
|
||
}) {
|
||
let round = 1;
|
||
let totalUpstreamAttempts = 0;
|
||
const retryExtraState = createReasoningRetryExtraState();
|
||
|
||
while (true) {
|
||
if (clientAbortSignal?.aborted) {
|
||
throw clientAbortSignal.reason || createAbortReason(REASONING_RETRY_ABORT_CLIENT, "client disconnected");
|
||
}
|
||
|
||
const width = reasoningRetryWaveWidth(round);
|
||
requestEntry.reasoning_retry_round_count = round;
|
||
requestEntry.reasoning_retry_current_round = round;
|
||
requestEntry.reasoning_retry_current_width = width;
|
||
requestEntry.reasoning_retry_current_firsts = buildReasoningRetryFirstSlots(round, width);
|
||
requestEntry.reasoning_retry_query_count += width;
|
||
if (requestEntry.reasoning_retry_enabled) {
|
||
upsertRequestEntry(runtime, requestEntry);
|
||
}
|
||
logger?.(
|
||
`[reasoning-retry] round=${round} width=${width} path=${pathname} thread_id=${requestEntry.thread_id}`,
|
||
);
|
||
|
||
const slotControllers = Array.from({ length: width }, () => new AbortController());
|
||
let winner = null;
|
||
const attempts = slotControllers.map((slotController, index) => {
|
||
const slot = index + 1;
|
||
const attemptEntry = buildAttemptRequestEntry(requestEntry);
|
||
attemptEntry._first_response_observer = (firstFields) => {
|
||
if (updateReasoningRetryFirstSlot(requestEntry, round, slot, firstFields)) {
|
||
upsertRequestEntry(runtime, requestEntry);
|
||
}
|
||
};
|
||
const rawPromise = executeCapturedGatewayQuery({
|
||
runtime,
|
||
config,
|
||
logger,
|
||
monitor,
|
||
pathname,
|
||
req,
|
||
requestBody,
|
||
requestIsStream,
|
||
upstreamUrl,
|
||
upstreamAuth,
|
||
requestEntry: attemptEntry,
|
||
externalAbortSignals: [clientAbortSignal, slotController.signal],
|
||
});
|
||
return new Promise((resolve, reject) => {
|
||
let settled = false;
|
||
const finish = (fn, value) => {
|
||
if (settled) {
|
||
return;
|
||
}
|
||
settled = true;
|
||
slotController.signal.removeEventListener("abort", onAbort);
|
||
fn(value);
|
||
};
|
||
const onAbort = () => {
|
||
if (abortReasonCode(slotController.signal) !== REASONING_RETRY_ABORT_WINNER) {
|
||
return;
|
||
}
|
||
finish(resolve, {
|
||
kind: "cancelled",
|
||
result: {
|
||
cancelled: true,
|
||
cancel_reason: REASONING_RETRY_ABORT_WINNER,
|
||
},
|
||
total_upstream_attempts: 0,
|
||
round,
|
||
slot,
|
||
requestEntry: attemptEntry,
|
||
});
|
||
};
|
||
slotController.signal.addEventListener("abort", onAbort, { once: true });
|
||
rawPromise.then(
|
||
(outcome) => {
|
||
const attemptOutcome = {
|
||
...outcome,
|
||
round,
|
||
slot,
|
||
requestEntry: attemptEntry,
|
||
};
|
||
if (!winner && attemptOutcome.kind === "success") {
|
||
winner = attemptOutcome;
|
||
const cancelledSlots = [];
|
||
slotControllers.forEach((otherController, otherIndex) => {
|
||
const otherSlot = otherIndex + 1;
|
||
if (otherController !== slotController && !otherController.signal.aborted) {
|
||
cancelledSlots.push(otherSlot);
|
||
otherController.abort(
|
||
createAbortReason(REASONING_RETRY_ABORT_WINNER, "reasoning retry winner selected"),
|
||
);
|
||
}
|
||
});
|
||
if (cancelledSlots.length > 0) {
|
||
markReasoningRetryCancelledSlots(requestEntry, round, cancelledSlots);
|
||
upsertRequestEntry(runtime, requestEntry);
|
||
}
|
||
}
|
||
finish(resolve, attemptOutcome);
|
||
},
|
||
(error) => finish(reject, error),
|
||
);
|
||
});
|
||
});
|
||
|
||
const outcomes = await Promise.all(attempts);
|
||
totalUpstreamAttempts += outcomes.reduce((sum, outcome) => {
|
||
return sum + (Number.isInteger(outcome.total_upstream_attempts) ? outcome.total_upstream_attempts : 0);
|
||
}, 0);
|
||
const nonCancelledOutcomes = outcomes.filter((outcome) => outcome.kind !== "cancelled");
|
||
for (const outcome of outcomes) {
|
||
updateReasoningRetryFirstFromAttempt(requestEntry, outcome);
|
||
}
|
||
if (requestEntry.reasoning_retry_enabled) {
|
||
upsertRequestEntry(runtime, requestEntry);
|
||
}
|
||
|
||
if (winner) {
|
||
accumulateReasoningRetryExtras(retryExtraState, nonCancelledOutcomes, winner.result);
|
||
requestEntry.reasoning_retry_extra_inspected_count = retryExtraState.inspected_count;
|
||
requestEntry.reasoning_retry_extra_matched_count = retryExtraState.matched_count;
|
||
requestEntry.reasoning_retry_extra_usage = retryExtraState.usage;
|
||
requestEntry.reasoning_retry_extra_reasoning_counts = retryExtraState.reasoning_counts;
|
||
requestEntry.reasoning_retry_winner_round = winner.round;
|
||
requestEntry.reasoning_retry_winner_slot = winner.slot;
|
||
requestEntry.reasoning_retry_stop_reason = "success";
|
||
return {
|
||
...winner.result,
|
||
response_stream: winner.result.response_stream,
|
||
total_upstream_attempts: totalUpstreamAttempts,
|
||
};
|
||
}
|
||
|
||
const allReasoningRetry = nonCancelledOutcomes.length > 0 && nonCancelledOutcomes.every(
|
||
(outcome) => outcome.kind === "reasoning_retry",
|
||
);
|
||
if (allReasoningRetry) {
|
||
accumulateReasoningRetryExtras(retryExtraState, nonCancelledOutcomes);
|
||
round += 1;
|
||
continue;
|
||
}
|
||
|
||
const fatalOutcome = nonCancelledOutcomes.find((outcome) => outcome.kind === "fatal");
|
||
if (fatalOutcome) {
|
||
accumulateReasoningRetryExtras(retryExtraState, nonCancelledOutcomes, fatalOutcome.result);
|
||
requestEntry.reasoning_retry_extra_inspected_count = retryExtraState.inspected_count;
|
||
requestEntry.reasoning_retry_extra_matched_count = retryExtraState.matched_count;
|
||
requestEntry.reasoning_retry_extra_usage = retryExtraState.usage;
|
||
requestEntry.reasoning_retry_extra_reasoning_counts = retryExtraState.reasoning_counts;
|
||
requestEntry.reasoning_retry_stop_reason = fatalOutcome.result?.match_reason || "fatal";
|
||
return {
|
||
...fatalOutcome.result,
|
||
response_stream: fatalOutcome.result.response_stream,
|
||
total_upstream_attempts: totalUpstreamAttempts,
|
||
};
|
||
}
|
||
|
||
accumulateReasoningRetryExtras(retryExtraState, nonCancelledOutcomes);
|
||
requestEntry.reasoning_retry_extra_inspected_count = retryExtraState.inspected_count;
|
||
requestEntry.reasoning_retry_extra_matched_count = retryExtraState.matched_count;
|
||
requestEntry.reasoning_retry_extra_usage = retryExtraState.usage;
|
||
requestEntry.reasoning_retry_extra_reasoning_counts = retryExtraState.reasoning_counts;
|
||
requestEntry.reasoning_retry_stop_reason = "exhausted_without_winner";
|
||
return {
|
||
inspected: false,
|
||
matched: false,
|
||
status_code: 502,
|
||
upstream_status_code: null,
|
||
error: "reasoning retry ended without a successful response",
|
||
response_stream: requestIsStream,
|
||
total_upstream_attempts: totalUpstreamAttempts,
|
||
delivery: buildCapturedDelivery(
|
||
502,
|
||
new Headers({ "content-type": "application/json; charset=utf-8" }),
|
||
buildGatewayErrorBody("reasoning retry ended without a successful response"),
|
||
),
|
||
};
|
||
}
|
||
}
|
||
|
||
async function proxyRequest(runtime, req, res) {
|
||
const { logger } = runtime;
|
||
const config = runtime.config;
|
||
const requestStartedAt = new Date();
|
||
const requestStartedMs = Date.now();
|
||
const incomingUrl = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`);
|
||
const pathname = normalizePath(incomingUrl.pathname);
|
||
|
||
if (pathname === "/favicon.ico") {
|
||
res.writeHead(204, { "cache-control": "public, max-age=86400" });
|
||
res.end();
|
||
return;
|
||
}
|
||
|
||
if (pathname === config.health_path) {
|
||
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||
res.end(
|
||
JSON.stringify({
|
||
ok: true,
|
||
listen: `${config.listen_host}:${config.listen_port}`,
|
||
upstream_base_url: config.upstream_base_url,
|
||
ui_path: UI_PATH,
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (await handleManagementRequest(runtime, req, res, incomingUrl)) {
|
||
return;
|
||
}
|
||
|
||
runtime.monitor.total_proxy_request_count += 1;
|
||
const requestSeq = runtime.monitor.next_request_seq;
|
||
runtime.monitor.next_request_seq += 1;
|
||
const requestEntry = buildRequestEntry({
|
||
seq: requestSeq,
|
||
startedAt: requestStartedAt,
|
||
startedMs: requestStartedMs,
|
||
req,
|
||
pathname,
|
||
requestJson: null,
|
||
profileName: config.profile_name || "default",
|
||
});
|
||
upsertRequestEntry(runtime, requestEntry);
|
||
|
||
try {
|
||
const rawRequestBody = await readRequestBody(req, config.request_body_limit_bytes);
|
||
const parsedRequestJson = isJsonContentType(req.headers["content-type"])
|
||
? parseJsonSafely(rawRequestBody)
|
||
: null;
|
||
const { requestJson, remapped, forwardedModel } = remapRequestModel(config, parsedRequestJson);
|
||
const requestBody = remapped ? Buffer.from(JSON.stringify(requestJson)) : rawRequestBody;
|
||
const requestIsStream = Boolean(requestJson?.stream);
|
||
requestEntry.request_body_bytes = rawRequestBody.length;
|
||
requestEntry.request_id = computeRequestId(pathname, rawRequestBody);
|
||
requestEntry.thread_id = firstNonEmptyString(
|
||
extractRequestThreadId(parsedRequestJson),
|
||
extractHeaderThreadId(req.headers),
|
||
);
|
||
requestEntry.model = requestJson?.model || null;
|
||
requestEntry.requested_model = parsedRequestJson?.model || null;
|
||
requestEntry.forwarded_model = forwardedModel || parsedRequestJson?.model || null;
|
||
requestEntry.model = requestEntry.forwarded_model;
|
||
requestEntry.reasoning_effort = extractRequestReasoningEffort(requestJson);
|
||
requestEntry.reasoning_summary = extractRequestReasoningSummary(requestJson);
|
||
requestEntry.request_stream = requestIsStream;
|
||
applyThreadReasoningState(runtime, requestEntry, pathname);
|
||
upsertRequestEntry(runtime, requestEntry);
|
||
|
||
const upstreamRoute = selectUpstreamRoute(config, pathname);
|
||
const upstreamRequestUrl = upstreamRoute.kind === "images"
|
||
? normalizeImageRequestUrl(incomingUrl)
|
||
: incomingUrl;
|
||
const upstreamUrl = buildUpstreamUrl(upstreamRoute.baseUrl, upstreamRequestUrl);
|
||
const upstreamAuth = await resolveUpstreamAuth(upstreamRoute.authConfig, upstreamRoute.kind);
|
||
requestEntry.upstream = {
|
||
...buildUpstreamSnapshot({ upstreamUrl, upstreamAuth }),
|
||
route: upstreamRoute.kind,
|
||
};
|
||
upsertRequestEntry(runtime, requestEntry);
|
||
if (remapped && requestEntry.requested_model && requestEntry.forwarded_model) {
|
||
logger?.(
|
||
`[model-remap] profile=${config.profile_name || "default"} requested=${requestEntry.requested_model} forwarded=${requestEntry.forwarded_model}`,
|
||
);
|
||
}
|
||
const clientAbortContext = createClientAbortContext(req, res);
|
||
try {
|
||
const result = isResponsesReasoningRetryEligible(pathname, requestEntry)
|
||
? await runResponsesReasoningRetry({
|
||
runtime,
|
||
config,
|
||
logger,
|
||
monitor: runtime.monitor,
|
||
pathname,
|
||
req,
|
||
requestBody,
|
||
requestIsStream,
|
||
upstreamUrl,
|
||
upstreamAuth,
|
||
requestEntry,
|
||
clientAbortSignal: clientAbortContext.signal,
|
||
})
|
||
: await executeGatewayQuery({
|
||
runtime,
|
||
config,
|
||
logger,
|
||
monitor: runtime.monitor,
|
||
pathname,
|
||
req,
|
||
res,
|
||
requestBody,
|
||
requestIsStream,
|
||
upstreamUrl,
|
||
upstreamAuth,
|
||
requestEntry,
|
||
externalAbortSignals: [clientAbortContext.signal],
|
||
});
|
||
|
||
const {
|
||
delivery,
|
||
total_upstream_attempts: observedUpstreamAttempts,
|
||
...resultFields
|
||
} = result;
|
||
if (delivery && !res.headersSent) {
|
||
await writeCapturedResponse(res, delivery);
|
||
}
|
||
if (Number.isInteger(observedUpstreamAttempts)) {
|
||
requestEntry.upstream_attempt_count = observedUpstreamAttempts;
|
||
}
|
||
if (requestEntry.reasoning_retry_enabled && !requestEntry.reasoning_retry_stop_reason) {
|
||
requestEntry.reasoning_retry_stop_reason = "completed_without_retry";
|
||
}
|
||
recordRequestEntry(
|
||
runtime,
|
||
finalizeRequestEntry(requestEntry, {
|
||
response_stream: Boolean(result.response_stream),
|
||
...resultFields,
|
||
}),
|
||
config.request_history_limit,
|
||
);
|
||
return;
|
||
} finally {
|
||
clientAbortContext.cleanup();
|
||
}
|
||
} catch (error) {
|
||
if (Number.isInteger(error?.gatewayAttemptCount)) {
|
||
requestEntry.upstream_attempt_count = Math.max(
|
||
requestEntry.upstream_attempt_count || 0,
|
||
error.gatewayAttemptCount,
|
||
);
|
||
}
|
||
recordRequestEntry(
|
||
runtime,
|
||
finalizeRequestEntry(requestEntry, {
|
||
status_code: res.headersSent ? null : 502,
|
||
error: `${error?.message || error}`,
|
||
}),
|
||
config.request_history_limit,
|
||
);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
const args = parseArgs(process.argv);
|
||
const configPath = args.config || path.join(__dirname, "config.json");
|
||
const config = await loadConfig(configPath);
|
||
const monitor = createMonitor();
|
||
const paths = buildRuntimePaths(configPath, args.log || null);
|
||
|
||
if (args.log) {
|
||
await mkdir(path.dirname(args.log), { recursive: true });
|
||
}
|
||
const logger = createLogger(args.log, createMonitorRecorder(monitor));
|
||
const requestsDb = openRequestsDatabase(paths.requestsDbPath);
|
||
const runtime = {
|
||
config,
|
||
configPath,
|
||
logPath: args.log || null,
|
||
logger,
|
||
monitor,
|
||
paths,
|
||
threadRules: await loadThreadRules(paths.threadRulesPath),
|
||
requestsDb,
|
||
server: null,
|
||
};
|
||
await ensureActiveImageProfile(runtime);
|
||
const importedCount = await importRequestsJsonlToDb(requestsDb, runtime.paths.requestsPath);
|
||
await hydrateMonitorFromDisk(monitor, runtime.paths, config.request_history_limit, requestsDb);
|
||
logger(`[start] hydrated persistent request metrics from db path=${runtime.paths.requestsDbPath}`);
|
||
logger(`[start] requests db ready path=${runtime.paths.requestsDbPath} imported_jsonl_rows=${importedCount}`);
|
||
logger(`[start] thread rules ready path=${runtime.paths.threadRulesPath} count=${runtime.threadRules.size}`);
|
||
|
||
const server = http.createServer(async (req, res) => {
|
||
try {
|
||
await proxyRequest(runtime, req, res);
|
||
} catch (error) {
|
||
logger(`[error] ${error?.stack || error}`);
|
||
if (!res.headersSent) {
|
||
res.writeHead(502, { "content-type": "application/json; charset=utf-8" });
|
||
res.end(
|
||
JSON.stringify({
|
||
error: {
|
||
message: `${error?.message || error}`,
|
||
type: "codex_retry_gateway_error",
|
||
code: "gateway_error",
|
||
},
|
||
}),
|
||
);
|
||
} else {
|
||
res.socket?.destroy();
|
||
}
|
||
}
|
||
});
|
||
runtime.server = server;
|
||
|
||
let shuttingDown = false;
|
||
const shutdown = (signal) => {
|
||
if (shuttingDown) {
|
||
return;
|
||
}
|
||
shuttingDown = true;
|
||
logger(`[stop] received ${signal}, closing gateway`);
|
||
server.close(() => {
|
||
process.exit(0);
|
||
});
|
||
const hardExitTimer = setTimeout(() => {
|
||
logger("[stop] forced exit after graceful shutdown timeout");
|
||
process.exit(signal === "SIGTERM" || signal === "SIGINT" ? 0 : 1);
|
||
}, 5000);
|
||
hardExitTimer.unref();
|
||
};
|
||
|
||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||
|
||
server.listen(runtime.config.listen_port, runtime.config.listen_host, () => {
|
||
updateRuntimeState(runtime, {
|
||
last_started_at: new Date().toISOString(),
|
||
profile_name: runtime.config.profile_name || "default",
|
||
image_profile_name: runtime.config.image_profile_name || "",
|
||
gateway_base_url: `http://${runtime.config.listen_host}:${runtime.config.listen_port}`,
|
||
}).catch((error) => logger(`[state] failed to update runtime state: ${error?.message || error}`));
|
||
logger(
|
||
`[start] codex retry gateway profile=${runtime.config.profile_name || "default"} image_profile=${runtime.config.image_profile_name || "-"} auth=${normalizeAuthMode(runtime.config.upstream_auth_mode)} reasoning_match_mode=${normalizeReasoningMatchMode(runtime.config.reasoning_match_mode)} listening on http://${runtime.config.listen_host}:${runtime.config.listen_port} -> ${runtime.config.upstream_base_url}`,
|
||
);
|
||
});
|
||
}
|
||
|
||
main().catch((error) => {
|
||
process.stderr.write(`${error?.stack || error}\n`);
|
||
process.exit(1);
|
||
});
|