1466 lines
44 KiB
JavaScript
1466 lines
44 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import http from "node:http";
|
|
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
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 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 RESTORE_API_PATH = `${ADMIN_BASE_PATH}/api/restore`;
|
|
|
|
const DEFAULT_CONFIG = {
|
|
listen_host: "127.0.0.1",
|
|
listen_port: 4610,
|
|
upstream_base_url: "",
|
|
request_body_limit_bytes: 10 * 1024 * 1024,
|
|
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
|
|
reasoning_equals: [516],
|
|
non_stream_status_code: 502,
|
|
stream_action: "disconnect",
|
|
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",
|
|
];
|
|
|
|
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 本地重试网关。",
|
|
" 非流式命中 reasoning_tokens=516 时返回 502。",
|
|
" 流式命中时默认直接断开连接,交给 Codex 自身重试。",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
|
|
function normalizePath(inputPath) {
|
|
const [withoutQuery] = `${inputPath || "/"}`.split("?");
|
|
const trimmed = withoutQuery.length > 1 ? withoutQuery.replace(/\/+$/, "") : withoutQuery;
|
|
return trimmed || "/";
|
|
}
|
|
|
|
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 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 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 createMonitor() {
|
|
return {
|
|
started_at: new Date().toISOString(),
|
|
next_log_seq: 1,
|
|
log_entries: [],
|
|
total_proxy_request_count: 0,
|
|
inspected_response_count: 0,
|
|
matched_response_count: 0,
|
|
observed_reasoning_counts: {},
|
|
};
|
|
}
|
|
|
|
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 buildMetricsSnapshot(monitor) {
|
|
const reasoning516Count = monitor.observed_reasoning_counts["516"] || 0;
|
|
const inspectedResponseCount = monitor.inspected_response_count;
|
|
return {
|
|
started_at: monitor.started_at,
|
|
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,
|
|
observed_reasoning_counts: { ...monitor.observed_reasoning_counts },
|
|
};
|
|
}
|
|
|
|
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 loadConfig(configPath) {
|
|
const content = await readFile(configPath, "utf8");
|
|
const loaded = JSON.parse(content);
|
|
const config = { ...DEFAULT_CONFIG, ...loaded };
|
|
config.endpoints = normalizeStringList(config.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath);
|
|
config.reasoning_equals = normalizeIntegerList(
|
|
config.reasoning_equals,
|
|
DEFAULT_CONFIG.reasoning_equals,
|
|
);
|
|
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);
|
|
return {
|
|
stateRoot,
|
|
statePath: path.join(stateRoot, "state.json"),
|
|
pidPath: path.join(stateRoot, "gateway.pid"),
|
|
configPath,
|
|
logPath,
|
|
};
|
|
}
|
|
|
|
async function readOptionalJson(jsonPath) {
|
|
try {
|
|
const content = await readFile(jsonPath, "utf8");
|
|
return JSON.parse(content);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function writeConfig(configPath, config) {
|
|
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
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 jsonResponse(res, statusCode, payload, headers = {}) {
|
|
res.writeHead(statusCode, {
|
|
"content-type": "application/json; charset=utf-8",
|
|
...headers,
|
|
});
|
|
res.end(JSON.stringify(payload));
|
|
}
|
|
|
|
function htmlResponse(res, html) {
|
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
res.end(html);
|
|
}
|
|
|
|
function buildEditableConfig(currentConfig, payload) {
|
|
const nextReasoning = normalizeIntegerList(payload.reasoning_equals, currentConfig.reasoning_equals);
|
|
const nextEndpoints = normalizeStringList(payload.endpoints, currentConfig.endpoints).map(normalizePath);
|
|
const nextStatusCode =
|
|
payload.non_stream_status_code === undefined
|
|
? currentConfig.non_stream_status_code
|
|
: Number.parseInt(`${payload.non_stream_status_code}`, 10);
|
|
|
|
if (nextReasoning.length === 0) {
|
|
throw new Error("reasoning_equals 不能为空");
|
|
}
|
|
if (nextEndpoints.length === 0) {
|
|
throw new Error("endpoints 不能为空");
|
|
}
|
|
if (!Number.isInteger(nextStatusCode) || nextStatusCode < 100 || nextStatusCode > 599) {
|
|
throw new Error("non_stream_status_code 必须是 100-599 的整数");
|
|
}
|
|
|
|
return {
|
|
...currentConfig,
|
|
reasoning_equals: nextReasoning,
|
|
endpoints: nextEndpoints,
|
|
non_stream_status_code: nextStatusCode,
|
|
log_match: payload.log_match === undefined ? currentConfig.log_match : Boolean(payload.log_match),
|
|
};
|
|
}
|
|
|
|
function buildManagementHtml() {
|
|
const uiConfig = {
|
|
statusPath: STATUS_API_PATH,
|
|
configPath: CONFIG_API_PATH,
|
|
logsPath: LOGS_API_PATH,
|
|
restorePath: RESTORE_API_PATH,
|
|
};
|
|
|
|
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>
|
|
:root {
|
|
color-scheme: light;
|
|
--bg: #f2ede3;
|
|
--panel: rgba(255, 251, 245, 0.9);
|
|
--panel-strong: #fffdf8;
|
|
--ink: #1f1d1a;
|
|
--muted: #6c655c;
|
|
--accent: #1f6f5f;
|
|
--accent-soft: #d9efe9;
|
|
--warn: #a2512f;
|
|
--line: rgba(31, 29, 26, 0.12);
|
|
--shadow: 0 18px 40px rgba(47, 34, 14, 0.12);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
font-family: "Segoe UI Variable", "Bahnschrift", "Segoe UI", sans-serif;
|
|
color: var(--ink);
|
|
background:
|
|
radial-gradient(circle at top left, rgba(31, 111, 95, 0.22), transparent 34%),
|
|
radial-gradient(circle at top right, rgba(162, 81, 47, 0.18), transparent 26%),
|
|
linear-gradient(180deg, #f8f4ec 0%, var(--bg) 100%);
|
|
}
|
|
|
|
.shell {
|
|
max-width: 1080px;
|
|
margin: 0 auto;
|
|
padding: 28px 18px 60px;
|
|
}
|
|
|
|
.hero {
|
|
padding: 26px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 28px;
|
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(249, 242, 228, 0.92));
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.eyebrow {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px 12px;
|
|
border-radius: 999px;
|
|
background: var(--accent-soft);
|
|
color: var(--accent);
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
h1 {
|
|
margin: 16px 0 8px;
|
|
font-size: clamp(30px, 6vw, 48px);
|
|
line-height: 1.05;
|
|
}
|
|
|
|
.lead {
|
|
margin: 0;
|
|
max-width: 720px;
|
|
font-size: 16px;
|
|
line-height: 1.7;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
gap: 18px;
|
|
margin-top: 22px;
|
|
}
|
|
|
|
@media (min-width: 900px) {
|
|
.grid {
|
|
grid-template-columns: 1.1fr 0.9fr;
|
|
}
|
|
}
|
|
|
|
.card {
|
|
border: 1px solid var(--line);
|
|
border-radius: 24px;
|
|
background: var(--panel);
|
|
box-shadow: var(--shadow);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.card-inner {
|
|
padding: 22px;
|
|
}
|
|
|
|
.card h2 {
|
|
margin: 0 0 14px;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.stats {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
@media (min-width: 640px) {
|
|
.stats {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
}
|
|
|
|
.stat {
|
|
padding: 14px;
|
|
border-radius: 18px;
|
|
background: var(--panel-strong);
|
|
border: 1px solid rgba(31, 29, 26, 0.08);
|
|
}
|
|
|
|
.stat label {
|
|
display: block;
|
|
font-size: 12px;
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.stat strong,
|
|
.stat span {
|
|
display: block;
|
|
font-size: 15px;
|
|
line-height: 1.5;
|
|
word-break: break-word;
|
|
}
|
|
|
|
form {
|
|
display: grid;
|
|
gap: 16px;
|
|
}
|
|
|
|
.field {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.field label {
|
|
font-weight: 700;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.hint {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
input,
|
|
textarea,
|
|
select {
|
|
width: 100%;
|
|
border: 1px solid rgba(31, 29, 26, 0.14);
|
|
border-radius: 16px;
|
|
padding: 12px 14px;
|
|
font: inherit;
|
|
color: var(--ink);
|
|
background: #fffdfa;
|
|
}
|
|
|
|
textarea {
|
|
min-height: 132px;
|
|
resize: vertical;
|
|
}
|
|
|
|
.inline-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px 14px;
|
|
border-radius: 16px;
|
|
background: var(--panel-strong);
|
|
border: 1px solid rgba(31, 29, 26, 0.08);
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
}
|
|
|
|
button {
|
|
border: 0;
|
|
border-radius: 999px;
|
|
padding: 12px 18px;
|
|
font: inherit;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.primary {
|
|
color: white;
|
|
background: linear-gradient(135deg, #236e60, #184f45);
|
|
}
|
|
|
|
.secondary {
|
|
color: var(--warn);
|
|
background: #fff4ee;
|
|
border: 1px solid rgba(162, 81, 47, 0.2);
|
|
}
|
|
|
|
.message {
|
|
min-height: 24px;
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.message[data-tone="error"] {
|
|
color: #9e2f21;
|
|
}
|
|
|
|
.message[data-tone="success"] {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.footnote {
|
|
margin-top: 12px;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.wide-card {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.live-meta {
|
|
margin: 0 0 12px;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.log-output {
|
|
margin: 0;
|
|
min-height: 320px;
|
|
max-height: 420px;
|
|
overflow: auto;
|
|
padding: 16px;
|
|
border-radius: 18px;
|
|
border: 1px solid rgba(31, 29, 26, 0.08);
|
|
background: #1e1d1a;
|
|
color: #f4efe7;
|
|
font-family: "Cascadia Code", "Consolas", monospace;
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
code {
|
|
font-family: "Cascadia Code", "Consolas", monospace;
|
|
font-size: 0.92em;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<section class="hero">
|
|
<div class="eyebrow">本地管理页</div>
|
|
<h1>Codex Retry Gateway</h1>
|
|
<p class="lead">
|
|
这个页面直接挂在正在运行的 gateway 上。你可以在这里查看当前接管状态、修改 516 拦截条件,并一键恢复 Codex 原设置。
|
|
</p>
|
|
</section>
|
|
|
|
<div class="grid">
|
|
<section class="card">
|
|
<div class="card-inner">
|
|
<h2>运行状态</h2>
|
|
<div class="stats">
|
|
<div class="stat"><label>监听地址</label><strong id="listenValue">-</strong></div>
|
|
<div class="stat"><label>真实上游</label><span id="upstreamValue">-</span></div>
|
|
<div class="stat"><label>当前 Provider</label><span id="providerValue">-</span></div>
|
|
<div class="stat"><label>当前 Codex Base URL</label><span id="codexBaseUrlValue">-</span></div>
|
|
<div class="stat"><label>Config 文件</label><span id="configPathValue">-</span></div>
|
|
<div class="stat"><label>备份文件</label><span id="backupPathValue">-</span></div>
|
|
<div class="stat"><label>本次启动时间</label><span id="startedAtValue">-</span></div>
|
|
<div class="stat"><label>代理请求总数</label><strong id="proxyRequestCountValue">0</strong></div>
|
|
<div class="stat"><label>被检查响应总数</label><strong id="inspectedCountValue">0</strong></div>
|
|
<div class="stat"><label>516 命中次数</label><strong id="reasoning516CountValue">0</strong></div>
|
|
<div class="stat"><label>516 占比</label><strong id="reasoning516RatioValue">0.00%</strong></div>
|
|
<div class="stat"><label>当前规则命中总数</label><strong id="matchedCountValue">0</strong></div>
|
|
</div>
|
|
<p class="footnote">
|
|
如果“当前 Codex Base URL”已经是本机监听地址,就说明当前 Codex 已经被这个 gateway 接管。统计口径按本次 gateway 启动以来累计。
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<div class="card-inner">
|
|
<h2>拦截规则</h2>
|
|
<form id="configForm">
|
|
<div class="field">
|
|
<label for="reasoningInput">reasoning_equals</label>
|
|
<input id="reasoningInput" name="reasoning_equals" type="text" placeholder="例如:516, 1024" />
|
|
<div class="hint">多个值用英文逗号或空格分隔。</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="endpointsInput">endpoints</label>
|
|
<textarea id="endpointsInput" name="endpoints" placeholder="/responses"></textarea>
|
|
<div class="hint">每行一个路径。默认建议同时保留 root 与 /v1 两套路径。</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="statusCodeInput">non_stream_status_code</label>
|
|
<input id="statusCodeInput" name="non_stream_status_code" type="number" min="100" max="599" />
|
|
</div>
|
|
|
|
<div class="inline-toggle">
|
|
<input id="logMatchInput" name="log_match" type="checkbox" />
|
|
<label for="logMatchInput">log_match 命中时写日志</label>
|
|
</div>
|
|
|
|
<div class="actions">
|
|
<button class="primary" id="saveButton" type="submit">保存并立即生效</button>
|
|
<button class="secondary" id="restoreButton" type="button">恢复 Codex 原设置并关闭网关</button>
|
|
</div>
|
|
</form>
|
|
<div class="message" id="messageBox"></div>
|
|
<p class="footnote">
|
|
点击“恢复”后,gateway 会停掉,所以这个页面会失联。这是预期行为,不是报错。
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card wide-card">
|
|
<div class="card-inner">
|
|
<h2>实时日志</h2>
|
|
<p class="live-meta" id="logsMeta">正在读取日志...</p>
|
|
<pre class="log-output" id="logsOutput">正在读取日志...</pre>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const ui = ${JSON.stringify(uiConfig)};
|
|
const refs = {
|
|
form: document.getElementById('configForm'),
|
|
reasoningInput: document.getElementById('reasoningInput'),
|
|
endpointsInput: document.getElementById('endpointsInput'),
|
|
statusCodeInput: document.getElementById('statusCodeInput'),
|
|
logMatchInput: document.getElementById('logMatchInput'),
|
|
saveButton: document.getElementById('saveButton'),
|
|
restoreButton: document.getElementById('restoreButton'),
|
|
messageBox: document.getElementById('messageBox'),
|
|
listenValue: document.getElementById('listenValue'),
|
|
upstreamValue: document.getElementById('upstreamValue'),
|
|
providerValue: document.getElementById('providerValue'),
|
|
codexBaseUrlValue: document.getElementById('codexBaseUrlValue'),
|
|
configPathValue: document.getElementById('configPathValue'),
|
|
backupPathValue: document.getElementById('backupPathValue'),
|
|
startedAtValue: document.getElementById('startedAtValue'),
|
|
proxyRequestCountValue: document.getElementById('proxyRequestCountValue'),
|
|
inspectedCountValue: document.getElementById('inspectedCountValue'),
|
|
reasoning516CountValue: document.getElementById('reasoning516CountValue'),
|
|
reasoning516RatioValue: document.getElementById('reasoning516RatioValue'),
|
|
matchedCountValue: document.getElementById('matchedCountValue'),
|
|
logsMeta: document.getElementById('logsMeta'),
|
|
logsOutput: document.getElementById('logsOutput'),
|
|
};
|
|
let hasLoadedForm = false;
|
|
let lastLogSeq = 0;
|
|
let pollTimer = null;
|
|
let stoppedByRestore = false;
|
|
|
|
function setMessage(text, tone) {
|
|
refs.messageBox.textContent = text || '';
|
|
refs.messageBox.dataset.tone = tone || '';
|
|
}
|
|
|
|
function formatTimestamp(value) {
|
|
if (!value) {
|
|
return '-';
|
|
}
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return value;
|
|
}
|
|
return date.toLocaleString('zh-CN', { hour12: false });
|
|
}
|
|
|
|
function formatPercent(value) {
|
|
return Number.isFinite(value) ? (value * 100).toFixed(2) + '%' : '0.00%';
|
|
}
|
|
|
|
function parseReasoningInput() {
|
|
return refs.reasoningInput.value
|
|
.split(/[\\s,]+/)
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)
|
|
.map((value) => Number.parseInt(value, 10))
|
|
.filter((value) => Number.isInteger(value));
|
|
}
|
|
|
|
function parseEndpointsInput() {
|
|
return refs.endpointsInput.value
|
|
.split(/\\r?\\n/)
|
|
.map((value) => value.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function fillStatus(payload) {
|
|
refs.listenValue.textContent = payload.listen || '-';
|
|
refs.upstreamValue.textContent = payload.config?.upstream_base_url || '-';
|
|
refs.providerValue.textContent = payload.state?.provider_name || '未检测到安装状态';
|
|
refs.codexBaseUrlValue.textContent = payload.state?.codex_current_base_url || '-';
|
|
refs.configPathValue.textContent = payload.paths?.config_path || '-';
|
|
refs.backupPathValue.textContent = payload.state?.latest_backup_path || '-';
|
|
fillMetrics(payload.metrics || {});
|
|
}
|
|
|
|
function fillMetrics(metrics) {
|
|
refs.startedAtValue.textContent = formatTimestamp(metrics.started_at);
|
|
refs.proxyRequestCountValue.textContent = String(metrics.total_proxy_request_count ?? 0);
|
|
refs.inspectedCountValue.textContent = String(metrics.inspected_response_count ?? 0);
|
|
refs.reasoning516CountValue.textContent = String(metrics.reasoning_516_count ?? 0);
|
|
refs.reasoning516RatioValue.textContent = formatPercent(metrics.reasoning_516_ratio ?? 0);
|
|
refs.matchedCountValue.textContent = String(metrics.matched_response_count ?? 0);
|
|
}
|
|
|
|
function fillForm(config) {
|
|
refs.reasoningInput.value = Array.isArray(config?.reasoning_equals) ? config.reasoning_equals.join(', ') : '';
|
|
refs.endpointsInput.value = Array.isArray(config?.endpoints) ? config.endpoints.join('\\n') : '';
|
|
refs.statusCodeInput.value = config?.non_stream_status_code ?? 502;
|
|
refs.logMatchInput.checked = Boolean(config?.log_match);
|
|
}
|
|
|
|
function renderLogs(payload, replaceAll) {
|
|
const entries = Array.isArray(payload?.entries) ? payload.entries : [];
|
|
const rendered = entries
|
|
.map((entry) => {
|
|
const at = entry?.at ? entry.at : '-';
|
|
const message = entry?.message ? entry.message : '';
|
|
return at + ' ' + message;
|
|
})
|
|
.join('\\n');
|
|
|
|
if (replaceAll) {
|
|
refs.logsOutput.textContent = rendered || '当前还没有日志。';
|
|
} else if (rendered) {
|
|
const current = refs.logsOutput.textContent.trim();
|
|
refs.logsOutput.textContent = current ? current + '\\n' + rendered : rendered;
|
|
}
|
|
|
|
if (!rendered && replaceAll) {
|
|
refs.logsOutput.textContent = '当前还没有日志。';
|
|
}
|
|
|
|
refs.logsMeta.textContent =
|
|
'已载入 ' +
|
|
String(payload?.total_entries ?? entries.length) +
|
|
' 条日志,最新序号 ' +
|
|
String(payload?.latest_seq ?? lastLogSeq) +
|
|
'。';
|
|
refs.logsOutput.scrollTop = refs.logsOutput.scrollHeight;
|
|
if (Number.isInteger(payload?.latest_seq)) {
|
|
lastLogSeq = payload.latest_seq;
|
|
}
|
|
}
|
|
|
|
async function loadLogs(incremental) {
|
|
const url = new URL(ui.logsPath, window.location.origin);
|
|
if (incremental && lastLogSeq > 0) {
|
|
url.searchParams.set('since_seq', String(lastLogSeq));
|
|
}
|
|
const response = await fetch(url.toString(), { cache: 'no-store' });
|
|
const payload = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(payload?.error?.message || '读取日志失败');
|
|
}
|
|
renderLogs(payload, !incremental || lastLogSeq === 0);
|
|
}
|
|
|
|
async function loadStatus(options) {
|
|
const refreshForm = Boolean(options?.refreshForm);
|
|
const response = await fetch(ui.statusPath);
|
|
const payload = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(payload?.error?.message || '读取状态失败');
|
|
}
|
|
fillStatus(payload);
|
|
if (refreshForm || !hasLoadedForm) {
|
|
fillForm(payload.config || {});
|
|
hasLoadedForm = true;
|
|
}
|
|
}
|
|
|
|
async function saveConfig(event) {
|
|
event.preventDefault();
|
|
refs.saveButton.disabled = true;
|
|
setMessage('正在保存配置...', '');
|
|
|
|
try {
|
|
const response = await fetch(ui.configPath, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
reasoning_equals: parseReasoningInput(),
|
|
endpoints: parseEndpointsInput(),
|
|
non_stream_status_code: Number.parseInt(refs.statusCodeInput.value, 10),
|
|
log_match: refs.logMatchInput.checked,
|
|
}),
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(payload?.error?.message || '保存失败');
|
|
}
|
|
fillStatus(payload);
|
|
fillForm(payload.config || {});
|
|
hasLoadedForm = true;
|
|
await loadLogs(false);
|
|
setMessage('配置已保存,并已对当前 gateway 立即生效。', 'success');
|
|
} catch (error) {
|
|
setMessage(error?.message || String(error), 'error');
|
|
} finally {
|
|
refs.saveButton.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function restoreConfig() {
|
|
if (!window.confirm('恢复后会关闭当前 gateway,并把 Codex 配置切回原上游。确定继续吗?')) {
|
|
return;
|
|
}
|
|
|
|
refs.restoreButton.disabled = true;
|
|
stoppedByRestore = true;
|
|
if (pollTimer) {
|
|
window.clearInterval(pollTimer);
|
|
}
|
|
setMessage('正在触发恢复,页面很快会失联...', '');
|
|
|
|
try {
|
|
const response = await fetch(ui.restorePath, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(payload?.error?.message || '恢复失败');
|
|
}
|
|
setMessage('恢复脚本已启动,等待 gateway 关闭。', 'success');
|
|
} catch (error) {
|
|
setMessage(error?.message || String(error), 'error');
|
|
refs.restoreButton.disabled = false;
|
|
return;
|
|
}
|
|
|
|
window.setTimeout(async () => {
|
|
try {
|
|
await fetch(ui.statusPath, { cache: 'no-store' });
|
|
} catch {
|
|
setMessage('gateway 已关闭,Codex 原设置应已恢复。', 'success');
|
|
}
|
|
}, 1200);
|
|
}
|
|
|
|
async function refreshLiveData() {
|
|
if (stoppedByRestore) {
|
|
return;
|
|
}
|
|
await Promise.all([
|
|
loadStatus({ refreshForm: false }),
|
|
loadLogs(true),
|
|
]);
|
|
}
|
|
|
|
refs.form.addEventListener('submit', saveConfig);
|
|
refs.restoreButton.addEventListener('click', restoreConfig);
|
|
|
|
Promise.all([
|
|
loadStatus({ refreshForm: true }),
|
|
loadLogs(false),
|
|
])
|
|
.then(() => {
|
|
pollTimer = window.setInterval(() => {
|
|
refreshLiveData().catch((error) => {
|
|
if (!stoppedByRestore) {
|
|
setMessage(error?.message || String(error), 'error');
|
|
}
|
|
});
|
|
}, 2000);
|
|
})
|
|
.catch((error) => {
|
|
setMessage(error?.message || String(error), 'error');
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
async function handleManagementRequest(runtime, req, res, requestUrl) {
|
|
const pathname = normalizePath(requestUrl.pathname);
|
|
|
|
if (pathname === UI_PATH) {
|
|
htmlResponse(res, buildManagementHtml());
|
|
return true;
|
|
}
|
|
|
|
if (pathname === STATUS_API_PATH && req.method === "GET") {
|
|
const state = await readRuntimeState(runtime);
|
|
jsonResponse(res, 200, {
|
|
ok: true,
|
|
listen: `${runtime.config.listen_host}:${runtime.config.listen_port}`,
|
|
config: runtime.config,
|
|
state,
|
|
paths: {
|
|
config_path: runtime.configPath,
|
|
state_path: runtime.paths.statePath,
|
|
state_root: runtime.paths.stateRoot,
|
|
log_path: runtime.logPath,
|
|
},
|
|
metrics: buildMetricsSnapshot(runtime.monitor),
|
|
});
|
|
return true;
|
|
}
|
|
|
|
if (pathname === LOGS_API_PATH && req.method === "GET") {
|
|
const sinceSeqRaw = requestUrl.searchParams.get("since_seq");
|
|
const sinceSeq = sinceSeqRaw === null ? null : Number.parseInt(sinceSeqRaw, 10);
|
|
jsonResponse(res, 200, {
|
|
ok: true,
|
|
...buildLogsSnapshot(runtime.monitor, Number.isInteger(sinceSeq) ? sinceSeq : null),
|
|
});
|
|
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(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_equals=${nextConfig.reasoning_equals.join(",")} endpoints=${nextConfig.endpoints.join(",")}`,
|
|
);
|
|
const state = await readRuntimeState(runtime);
|
|
jsonResponse(res, 200, {
|
|
ok: true,
|
|
message: "配置已保存并立即生效",
|
|
config: runtime.config,
|
|
state,
|
|
paths: {
|
|
config_path: runtime.configPath,
|
|
state_path: runtime.paths.statePath,
|
|
state_root: runtime.paths.stateRoot,
|
|
log_path: runtime.logPath,
|
|
},
|
|
metrics: buildMetricsSnapshot(runtime.monitor),
|
|
});
|
|
return true;
|
|
}
|
|
|
|
if (pathname === RESTORE_API_PATH && req.method === "POST") {
|
|
const state = await readRuntimeState(runtime);
|
|
if (!state) {
|
|
jsonResponse(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(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;
|
|
}
|
|
|
|
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 cloneHeadersForUpstream(headers) {
|
|
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);
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 matchPath(config, pathname) {
|
|
return config.endpoints.includes(normalizePath(pathname));
|
|
}
|
|
|
|
function reasoningMatched(config, reasoning) {
|
|
return reasoning !== null && config.reasoning_equals.includes(reasoning);
|
|
}
|
|
|
|
function inspectSseChunk(state, chunk) {
|
|
const decoded = state.decoder.decode(chunk, { stream: true });
|
|
state.buffer += decoded;
|
|
|
|
const blocks = state.buffer.split(/\r?\n\r?\n/);
|
|
state.buffer = blocks.pop() ?? "";
|
|
|
|
for (const block of blocks) {
|
|
const lines = block
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trimEnd())
|
|
.filter(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;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(payloadText);
|
|
const reasoning = extractReasoningTokens(parsed);
|
|
if (reasoning !== null) {
|
|
return reasoning;
|
|
}
|
|
} catch {
|
|
// ignore malformed SSE payloads
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function handleNonStreaming({
|
|
config,
|
|
logger,
|
|
monitor,
|
|
pathname,
|
|
upstreamResponse,
|
|
res,
|
|
}) {
|
|
const bodyBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
|
const parsed = isJsonContentType(upstreamResponse.headers.get("content-type"))
|
|
? parseJsonSafely(bodyBuffer)
|
|
: null;
|
|
const reasoning = parsed ? extractReasoningTokens(parsed) : null;
|
|
const matched = reasoningMatched(config, reasoning);
|
|
|
|
recordInspectedResponse(monitor, reasoning, matched);
|
|
|
|
if (matched) {
|
|
if (config.log_match) {
|
|
logger(
|
|
`[match] non-stream path=${pathname} reasoning_tokens=${reasoning} action=status_${config.non_stream_status_code}`,
|
|
);
|
|
}
|
|
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;
|
|
}
|
|
|
|
copyHeadersToClient(upstreamResponse.headers, res);
|
|
res.writeHead(upstreamResponse.status);
|
|
res.end(bodyBuffer);
|
|
}
|
|
|
|
async function handleStreaming({
|
|
config,
|
|
logger,
|
|
monitor,
|
|
pathname,
|
|
upstreamResponse,
|
|
res,
|
|
abortController,
|
|
}) {
|
|
copyHeadersToClient(upstreamResponse.headers, res);
|
|
res.writeHead(upstreamResponse.status);
|
|
|
|
const reader = upstreamResponse.body.getReader();
|
|
const sseState = {
|
|
decoder: new TextDecoder("utf8"),
|
|
buffer: "",
|
|
};
|
|
|
|
let wroteAnyChunk = false;
|
|
let observedReasoning = null;
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
recordInspectedResponse(monitor, observedReasoning, false);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const reasoning = inspectSseChunk(sseState, value);
|
|
if (Number.isInteger(reasoning)) {
|
|
observedReasoning = reasoning;
|
|
}
|
|
if (reasoningMatched(config, reasoning)) {
|
|
recordInspectedResponse(monitor, reasoning, true);
|
|
if (config.log_match) {
|
|
logger(
|
|
`[match] stream path=${pathname} reasoning_tokens=${reasoning} action=${config.stream_action}`,
|
|
);
|
|
}
|
|
|
|
if (!wroteAnyChunk) {
|
|
const blockedBody = buildBlockedBody(pathname, reasoning, config.non_stream_status_code);
|
|
if (!res.headersSent) {
|
|
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;
|
|
}
|
|
|
|
wroteAnyChunk = true;
|
|
res.write(Buffer.from(value));
|
|
}
|
|
}
|
|
|
|
async function proxyRequest(runtime, req, res) {
|
|
const { logger } = runtime;
|
|
const config = runtime.config;
|
|
const incomingUrl = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`);
|
|
const pathname = normalizePath(incomingUrl.pathname);
|
|
|
|
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 requestBody = await readRequestBody(req, config.request_body_limit_bytes);
|
|
const requestJson = isJsonContentType(req.headers["content-type"])
|
|
? parseJsonSafely(requestBody)
|
|
: null;
|
|
const requestIsStream = Boolean(requestJson?.stream);
|
|
|
|
const upstreamUrl = buildUpstreamUrl(config.upstream_base_url, incomingUrl);
|
|
const abortController = new AbortController();
|
|
|
|
const upstreamResponse = await fetch(upstreamUrl, {
|
|
method: req.method,
|
|
headers: cloneHeadersForUpstream(req.headers),
|
|
body: requestBody.length > 0 ? requestBody : undefined,
|
|
signal: abortController.signal,
|
|
});
|
|
|
|
const shouldInspect = matchPath(config, pathname);
|
|
const responseIsStream =
|
|
requestIsStream || isSseContentType(upstreamResponse.headers.get("content-type"));
|
|
|
|
if (!shouldInspect) {
|
|
copyHeadersToClient(upstreamResponse.headers, res);
|
|
res.writeHead(upstreamResponse.status);
|
|
const body = Buffer.from(await upstreamResponse.arrayBuffer());
|
|
res.end(body);
|
|
return;
|
|
}
|
|
|
|
if (responseIsStream) {
|
|
await handleStreaming({
|
|
config,
|
|
logger,
|
|
monitor: runtime.monitor,
|
|
pathname,
|
|
upstreamResponse,
|
|
res,
|
|
abortController,
|
|
});
|
|
return;
|
|
}
|
|
|
|
await handleNonStreaming({
|
|
config,
|
|
logger,
|
|
monitor: runtime.monitor,
|
|
pathname,
|
|
upstreamResponse,
|
|
res,
|
|
});
|
|
}
|
|
|
|
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();
|
|
|
|
if (args.log) {
|
|
await mkdir(path.dirname(args.log), { recursive: true });
|
|
}
|
|
const logger = createLogger(args.log, createMonitorRecorder(monitor));
|
|
const runtime = {
|
|
config,
|
|
configPath,
|
|
logPath: args.log || null,
|
|
logger,
|
|
monitor,
|
|
paths: buildRuntimePaths(configPath, args.log || null),
|
|
server: null,
|
|
};
|
|
|
|
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;
|
|
|
|
server.listen(config.listen_port, config.listen_host, () => {
|
|
logger(
|
|
`[start] codex retry gateway listening on http://${config.listen_host}:${config.listen_port} -> ${config.upstream_base_url}`,
|
|
);
|
|
});
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stderr.write(`${error?.stack || error}\n`);
|
|
process.exit(1);
|
|
});
|