retry capacity-style upstream errors

This commit is contained in:
2026-06-29 15:54:36 +08:00
parent 778068af42
commit 283c14b6b4
8 changed files with 384 additions and 24 deletions
+9
View File
@@ -9,6 +9,7 @@ tg群:https://t.me/AI_INPUT_IM
- 保持 Codex 继续使用现有 `auth.json` - 保持 Codex 继续使用现有 `auth.json`
- 只把 `config.toml` 的当前 provider `base_url` 改成本地网关 - 只把 `config.toml` 的当前 provider `base_url` 改成本地网关
- 非流式命中 `reasoning_tokens = 516` 时返回 `502` - 非流式命中 `reasoning_tokens = 516` 时返回 `502`
- 上游若返回明确的容量错误(默认匹配 `429/503` 且错误文案包含 `Selected model is at capacity. Please try a different model.`),也会转成本地 `502`
- 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502` - 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502`
- 默认同时拦截 root 路径和 `/v1` 路径: - 默认同时拦截 root 路径和 `/v1` 路径:
- `/responses` - `/responses`
@@ -139,6 +140,8 @@ profile env 默认放在:
- `CODEX_RETRY_GATEWAY_LISTEN_PORT` - `CODEX_RETRY_GATEWAY_LISTEN_PORT`
- `CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL` - `CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL`
- `CODEX_RETRY_GATEWAY_REASONING_EQUALS` - `CODEX_RETRY_GATEWAY_REASONING_EQUALS`
- `CODEX_RETRY_GATEWAY_RETRYABLE_STATUS_CODES`
- `CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES`
- `CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE` - `CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE`
- `CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV` - `CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_ENV`
- `CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE` - `CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE`
@@ -213,6 +216,7 @@ gateway 运行时只负责 API 与静态文件服务,不再把复杂 UI 硬写
- 切换 provider `base_url` - 切换 provider `base_url`
- 切换 `passthrough` / `manual_bearer` / `fixed_bearer` / `auth_json` 认证模式 - 切换 `passthrough` / `manual_bearer` / `fixed_bearer` / `auth_json` 认证模式
-`reasoning_equals` -`reasoning_equals`
- 改 capacity error 的匹配状态码和错误文案
-`endpoints` -`endpoints`
-`non_stream_status_code` -`non_stream_status_code`
- 开关 `log_match` - 开关 `log_match`
@@ -246,6 +250,11 @@ macOS / Linux: ~/.codex-retry-gateway/config/config.json
- `reasoning_equals` - `reasoning_equals`
- 例如 `[516]` - 例如 `[516]`
- `retryable_status_codes`
- 默认 `[429, 503]`
- `retryable_error_messages`
- 默认包含 `Selected model is at capacity. Please try a different model.`
- 只要上游 JSON 错误里包含这些文案之一,gateway 就会把上游错误翻成本地 `non_stream_status_code`
- `endpoints` - `endpoints`
- 默认包含 root 与 `/v1` 两套路径 - 默认包含 root 与 `/v1` 两套路径
- `non_stream_status_code` - `non_stream_status_code`
+4
View File
@@ -12,6 +12,10 @@
"request_history_limit": 200, "request_history_limit": 200,
"endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], "endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
"reasoning_equals": [516], "reasoning_equals": [516],
"retryable_status_codes": [429, 503],
"retryable_error_messages": [
"Selected model is at capacity. Please try a different model."
],
"non_stream_status_code": 502, "non_stream_status_code": 502,
"stream_action": "strict_502", "stream_action": "strict_502",
"log_match": true, "log_match": true,
+233 -4
View File
@@ -39,6 +39,10 @@ const DEFAULT_CONFIG = {
model_remap: "", model_remap: "",
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
reasoning_equals: [516], reasoning_equals: [516],
retryable_status_codes: [429, 503],
retryable_error_messages: [
"Selected model is at capacity. Please try a different model.",
],
non_stream_status_code: 502, non_stream_status_code: 502,
stream_action: "strict_502", stream_action: "strict_502",
log_match: true, log_match: true,
@@ -223,6 +227,21 @@ function normalizeStringList(values, fallback = []) {
return [...new Set(normalized)]; 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 value.split(/\r?\n/);
}
return [value];
})
.map((value) => `${value ?? ""}`.trim())
.filter(Boolean);
return [...new Set(normalized)];
}
function parseModelRemapMap(value) { function parseModelRemapMap(value) {
const map = {}; const map = {};
for (const rawEntry of `${value || ""}`.split(/\r?\n|[;,]/)) { for (const rawEntry of `${value || ""}`.split(/\r?\n|[;,]/)) {
@@ -315,6 +334,19 @@ function buildBlockedBody(pathname, reasoning, 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) { function buildGatewayErrorBody(message) {
return JSON.stringify({ return JSON.stringify({
error: { error: {
@@ -724,6 +756,11 @@ function buildProfileFormModel(env) {
request_history_limit: env.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT || `${DEFAULT_CONFIG.request_history_limit}`, request_history_limit: env.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT || `${DEFAULT_CONFIG.request_history_limit}`,
model_remap: env.CODEX_RETRY_GATEWAY_MODEL_REMAP || "", model_remap: env.CODEX_RETRY_GATEWAY_MODEL_REMAP || "",
reasoning_equals: env.CODEX_RETRY_GATEWAY_REASONING_EQUALS || "", reasoning_equals: env.CODEX_RETRY_GATEWAY_REASONING_EQUALS || "",
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,
),
endpoints: normalizeStringList(env.CODEX_RETRY_GATEWAY_ENDPOINTS || DEFAULT_CONFIG.endpoints, DEFAULT_CONFIG.endpoints), endpoints: normalizeStringList(env.CODEX_RETRY_GATEWAY_ENDPOINTS || DEFAULT_CONFIG.endpoints, DEFAULT_CONFIG.endpoints),
}; };
} }
@@ -751,6 +788,14 @@ function buildConfigFromProfileEnv(profileName, env) {
model_remap: env.CODEX_RETRY_GATEWAY_MODEL_REMAP || "", model_remap: env.CODEX_RETRY_GATEWAY_MODEL_REMAP || "",
endpoints: normalizeStringList(env.CODEX_RETRY_GATEWAY_ENDPOINTS || DEFAULT_CONFIG.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath), endpoints: normalizeStringList(env.CODEX_RETRY_GATEWAY_ENDPOINTS || DEFAULT_CONFIG.endpoints, DEFAULT_CONFIG.endpoints).map(normalizePath),
reasoning_equals: normalizeIntegerList(env.CODEX_RETRY_GATEWAY_REASONING_EQUALS || DEFAULT_CONFIG.reasoning_equals, DEFAULT_CONFIG.reasoning_equals), reasoning_equals: normalizeIntegerList(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,
),
non_stream_status_code: env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE non_stream_status_code: env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE
? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE}`, 10) ? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE}`, 10)
: DEFAULT_CONFIG.non_stream_status_code, : DEFAULT_CONFIG.non_stream_status_code,
@@ -847,12 +892,29 @@ async function buildProfileEnvText(payload) {
throw new Error("History Limit 必须是 0 或正整数;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,
);
if (retryableStatusCodes.length === 0) {
throw new Error("retryable_status_codes 不能为空");
}
if (retryableErrorMessages.length === 0) {
throw new Error("retryable_error_messages 不能为空");
}
const envPairs = [ const envPairs = [
["CODEX_RETRY_GATEWAY_LISTEN_HOST", `${payload.listen_host || DEFAULT_CONFIG.listen_host}`.trim()], ["CODEX_RETRY_GATEWAY_LISTEN_HOST", `${payload.listen_host || DEFAULT_CONFIG.listen_host}`.trim()],
["CODEX_RETRY_GATEWAY_LISTEN_PORT", `${listenPort}`], ["CODEX_RETRY_GATEWAY_LISTEN_PORT", `${listenPort}`],
["CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL", upstreamBaseUrl], ["CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL", upstreamBaseUrl],
["CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE", authMode], ["CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_MODE", authMode],
["CODEX_RETRY_GATEWAY_REASONING_EQUALS", reasoningEquals.join(",")], ["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_REQUEST_HISTORY_LIMIT", `${requestHistoryLimit}`], ["CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT", `${requestHistoryLimit}`],
["CODEX_RETRY_GATEWAY_ENDPOINTS", endpoints.join(",")], ["CODEX_RETRY_GATEWAY_ENDPOINTS", endpoints.join(",")],
]; ];
@@ -1075,6 +1137,14 @@ async function loadConfig(configPath) {
config.reasoning_equals, config.reasoning_equals,
DEFAULT_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,
);
if (!config.upstream_base_url) { if (!config.upstream_base_url) {
throw new Error("配置缺少 upstream_base_url"); throw new Error("配置缺少 upstream_base_url");
} }
@@ -1602,6 +1672,14 @@ async function serveManagementUi(res, requestPathname) {
function buildEditableConfig(currentConfig, payload) { function buildEditableConfig(currentConfig, payload) {
const nextReasoning = normalizeIntegerList(payload.reasoning_equals, currentConfig.reasoning_equals); const nextReasoning = normalizeIntegerList(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 nextEndpoints = normalizeStringList(payload.endpoints, currentConfig.endpoints).map(normalizePath);
const nextStatusCode = const nextStatusCode =
payload.non_stream_status_code === undefined payload.non_stream_status_code === undefined
@@ -1611,6 +1689,12 @@ function buildEditableConfig(currentConfig, payload) {
if (nextReasoning.length === 0) { if (nextReasoning.length === 0) {
throw new Error("reasoning_equals 不能为空"); throw new Error("reasoning_equals 不能为空");
} }
if (nextRetryableStatusCodes.length === 0) {
throw new Error("retryable_status_codes 不能为空");
}
if (nextRetryableErrorMessages.length === 0) {
throw new Error("retryable_error_messages 不能为空");
}
if (nextEndpoints.length === 0) { if (nextEndpoints.length === 0) {
throw new Error("endpoints 不能为空"); throw new Error("endpoints 不能为空");
} }
@@ -1621,6 +1705,8 @@ function buildEditableConfig(currentConfig, payload) {
return { return {
...currentConfig, ...currentConfig,
reasoning_equals: nextReasoning, reasoning_equals: nextReasoning,
retryable_status_codes: nextRetryableStatusCodes,
retryable_error_messages: nextRetryableErrorMessages,
endpoints: nextEndpoints, endpoints: nextEndpoints,
non_stream_status_code: nextStatusCode, non_stream_status_code: nextStatusCode,
log_match: payload.log_match === undefined ? currentConfig.log_match : Boolean(payload.log_match), log_match: payload.log_match === undefined ? currentConfig.log_match : Boolean(payload.log_match),
@@ -1822,7 +1908,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) {
await writeConfig(runtime.configPath, nextConfig); await writeConfig(runtime.configPath, nextConfig);
runtime.config = nextConfig; runtime.config = nextConfig;
runtime.logger( runtime.logger(
`[config] updated reasoning_equals=${nextConfig.reasoning_equals.join(",")} endpoints=${nextConfig.endpoints.join(",")}`, `[config] updated reasoning_equals=${nextConfig.reasoning_equals.join(",")} retryable_status_codes=${nextConfig.retryable_status_codes.join(",")} endpoints=${nextConfig.endpoints.join(",")}`,
); );
const state = await readRuntimeState(runtime); const state = await readRuntimeState(runtime);
jsonResponse(res, 200, { jsonResponse(res, 200, {
@@ -1996,6 +2082,109 @@ function reasoningMatched(config, reasoning) {
return reasoning !== null && config.reasoning_equals.includes(reasoning); return reasoning !== null && config.reasoning_equals.includes(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;
}
function findRetryableUpstreamErrorMatch(config, upstreamStatusCode, parsedBody, bodyText) {
if (!isUpstreamErrorStatus(upstreamStatusCode)) {
return null;
}
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,
);
if (retryableStatusCodes.length > 0 && !retryableStatusCodes.includes(upstreamStatusCode)) {
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 isExpectedStreamTermination(error) { function isExpectedStreamTermination(error) {
if (!error) { if (!error) {
return false; return false;
@@ -2090,14 +2279,21 @@ async function handleNonStreaming({
}) { }) {
markAndPersistFirstResponse(runtime, requestEntry); markAndPersistFirstResponse(runtime, requestEntry);
const bodyBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); const bodyBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
const bodyText = bodyBuffer.toString("utf8");
const parsed = isJsonContentType(upstreamResponse.headers.get("content-type")) const parsed = isJsonContentType(upstreamResponse.headers.get("content-type"))
? parseJsonSafely(bodyBuffer) ? parseJsonSafely(bodyBuffer)
: null; : null;
const reasoning = parsed ? extractReasoningTokens(parsed) : null; const reasoning = parsed ? extractReasoningTokens(parsed) : null;
const usage = parsed ? normalizeUsageSnapshot(parsed) : null; const usage = parsed ? normalizeUsageSnapshot(parsed) : null;
const matched = reasoningMatched(config, reasoning); const matched = reasoningMatched(config, reasoning);
const retryableUpstreamError = findRetryableUpstreamErrorMatch(
config,
upstreamResponse.status,
parsed,
bodyText,
);
recordInspectedResponse(monitor, reasoning, matched); recordInspectedResponse(monitor, reasoning, matched || Boolean(retryableUpstreamError));
if (matched) { if (matched) {
if (config.log_match) { if (config.log_match) {
@@ -2121,6 +2317,35 @@ async function handleNonStreaming({
}; };
} }
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,
);
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",
};
}
copyHeadersToClient(upstreamResponse.headers, res); copyHeadersToClient(upstreamResponse.headers, res);
res.writeHead(upstreamResponse.status); res.writeHead(upstreamResponse.status);
res.end(bodyBuffer); res.end(bodyBuffer);
@@ -2347,8 +2572,12 @@ async function proxyRequest(runtime, req, res) {
}, logger); }, logger);
const shouldInspect = matchPath(config, pathname); const shouldInspect = matchPath(config, pathname);
const responseIsStream = const responseContentType = upstreamResponse.headers.get("content-type");
requestIsStream || isSseContentType(upstreamResponse.headers.get("content-type")); const responseIsStream = isSseContentType(responseContentType) || (
requestIsStream &&
!isJsonContentType(responseContentType) &&
!isUpstreamErrorStatus(upstreamResponse.status)
);
requestEntry.response_stream = responseIsStream; requestEntry.response_stream = responseIsStream;
requestEntry.inspected = shouldInspect; requestEntry.inspected = shouldInspect;
requestEntry.upstream_status_code = upstreamResponse.status; requestEntry.upstream_status_code = upstreamResponse.status;
+15
View File
@@ -188,6 +188,17 @@ export function normalizeStringArray(values, fallback = []) {
return normalized.length > 0 ? [...new Set(normalized)] : [...fallback]; return normalized.length > 0 ? [...new Set(normalized)] : [...fallback];
} }
export function normalizePhraseArray(values, fallback = []) {
const source = values === undefined || values === null ? fallback : values;
const queue = Array.isArray(source) ? source.flat(Infinity) : [source];
const normalized = queue
.flatMap((value) => (typeof value === "string" ? value.split(/\r?\n/) : [value]))
.map((value) => `${value ?? ""}`.trim())
.filter(Boolean);
return normalized.length > 0 ? [...new Set(normalized)] : [...fallback];
}
export function isProcessAlive(processId) { export function isProcessAlive(processId) {
try { try {
process.kill(processId, 0); process.kill(processId, 0);
@@ -414,6 +425,10 @@ export async function installForCurrentProvider({
: Number.parseInt(`${existingGatewayConfig.request_body_limit_bytes}`, 10), : Number.parseInt(`${existingGatewayConfig.request_body_limit_bytes}`, 10),
endpoints: mergedEndpoints, endpoints: mergedEndpoints,
reasoning_equals: normalizeIntArray(existingGatewayConfig?.reasoning_equals, [516]), reasoning_equals: normalizeIntArray(existingGatewayConfig?.reasoning_equals, [516]),
retryable_status_codes: normalizeIntArray(existingGatewayConfig?.retryable_status_codes, [429, 503]),
retryable_error_messages: normalizePhraseArray(existingGatewayConfig?.retryable_error_messages, [
"Selected model is at capacity. Please try a different model.",
]),
non_stream_status_code: non_stream_status_code:
existingGatewayConfig?.non_stream_status_code === undefined || existingGatewayConfig?.non_stream_status_code === null existingGatewayConfig?.non_stream_status_code === undefined || existingGatewayConfig?.non_stream_status_code === null
? 502 ? 502
+9
View File
@@ -17,6 +17,7 @@ import {
getGatewayBaseUrl, getGatewayBaseUrl,
getGatewayStatePaths, getGatewayStatePaths,
normalizeIntArray, normalizeIntArray,
normalizePhraseArray,
normalizeStringArray, normalizeStringArray,
parseOptions, parseOptions,
readJsonFile, readJsonFile,
@@ -185,6 +186,14 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr
profileEnv.CODEX_RETRY_GATEWAY_REASONING_EQUALS || existingGatewayConfig?.reasoning_equals, profileEnv.CODEX_RETRY_GATEWAY_REASONING_EQUALS || existingGatewayConfig?.reasoning_equals,
[516], [516],
), ),
retryable_status_codes: normalizeIntArray(
profileEnv.CODEX_RETRY_GATEWAY_RETRYABLE_STATUS_CODES || existingGatewayConfig?.retryable_status_codes,
[429, 503],
),
retryable_error_messages: normalizePhraseArray(
profileEnv.CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES || existingGatewayConfig?.retryable_error_messages,
["Selected model is at capacity. Please try a different model."],
),
non_stream_status_code: profileEnv.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE non_stream_status_code: profileEnv.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE
? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE}`, 10) ? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE}`, 10)
: Number.parseInt(`${existingGatewayConfig?.non_stream_status_code || 502}`, 10), : Number.parseInt(`${existingGatewayConfig?.non_stream_status_code || 502}`, 10),
+44
View File
@@ -123,6 +123,20 @@ function startFakeUpstream(port) {
]); ]);
return; return;
} }
if (parsed.test_capacity_error) {
createJsonResponse(
res,
parsed.test_capacity_status ?? 503,
{
error: {
message: parsed.test_capacity_message || "Selected model is at capacity. Please try a different model.",
type: "server_error",
},
},
{ "x-upstream-test": "capacity-error" },
);
return;
}
if (parsed.stream) { if (parsed.stream) {
createSseResponse(res, [ createSseResponse(res, [
'data: {"type":"response.output_text.delta","delta":"hello"}\n\n', 'data: {"type":"response.output_text.delta","delta":"hello"}\n\n',
@@ -275,6 +289,8 @@ async function run() {
request_body_limit_bytes: 10 * 1024 * 1024, request_body_limit_bytes: 10 * 1024 * 1024,
endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"],
reasoning_equals: [516], reasoning_equals: [516],
retryable_status_codes: [429, 503],
retryable_error_messages: ["Selected model is at capacity. Please try a different model."],
non_stream_status_code: 502, non_stream_status_code: 502,
stream_action: "strict_502", stream_action: "strict_502",
log_match: true, log_match: true,
@@ -349,6 +365,34 @@ async function run() {
`请求体大小记录异常: ${recoveredEntry?.request_body_bytes}`, `请求体大小记录异常: ${recoveredEntry?.request_body_bytes}`,
); );
const capacityResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ test_capacity_error: true }),
});
const capacityBody = await capacityResponse.json();
assert(capacityResponse.status === 502, `capacity error 未返回 502: ${capacityResponse.status}`);
assert(
capacityBody?.error?.code === "upstream_error_retry_triggered",
"capacity error 返回体未标记 retry trigger",
);
assert(
capacityBody?.error?.upstream_status_code === 503,
"capacity error 返回体未保留 upstream status",
);
const streamCapacityResponse = await fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ stream: true, test_capacity_error: true }),
});
const streamCapacityBody = await streamCapacityResponse.json();
assert(streamCapacityResponse.status === 502, `stream+capacity error 未返回 502: ${streamCapacityResponse.status}`);
assert(
streamCapacityBody?.error?.code === "upstream_error_retry_triggered",
"stream+capacity error 返回体未标记 retry trigger",
);
for (const streamPath of [ for (const streamPath of [
"/responses", "/responses",
"/v1/responses", "/v1/responses",
+6
View File
@@ -221,6 +221,8 @@ async function run() {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
reasoning_equals: [1024], reasoning_equals: [1024],
retryable_status_codes: [429, 503, 529],
retryable_error_messages: ["Selected model is at capacity. Please try a different model."],
endpoints: ["/responses", "/v1/responses"], endpoints: ["/responses", "/v1/responses"],
non_stream_status_code: 503, non_stream_status_code: 503,
log_match: false, log_match: false,
@@ -237,6 +239,10 @@ async function run() {
JSON.stringify(updatedGatewayConfig.reasoning_equals) === JSON.stringify([1024]), JSON.stringify(updatedGatewayConfig.reasoning_equals) === JSON.stringify([1024]),
"Saved config file did not persist reasoning_equals", "Saved config file did not persist reasoning_equals",
); );
assert(
JSON.stringify(updatedGatewayConfig.retryable_status_codes) === JSON.stringify([429, 503, 529]),
"Saved config file did not persist retryable_status_codes",
);
const incrementalLogsResponse = await fetch( const incrementalLogsResponse = await fetch(
`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/logs?since_seq=${logsPayload.latest_seq}`, `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/logs?since_seq=${logsPayload.latest_seq}`,
+44
View File
@@ -15,6 +15,8 @@ type GatewayConfig = {
model_remap?: string; model_remap?: string;
endpoints?: string[]; endpoints?: string[];
reasoning_equals?: number[]; reasoning_equals?: number[];
retryable_status_codes?: number[];
retryable_error_messages?: string[];
non_stream_status_code?: number; non_stream_status_code?: number;
log_match?: boolean; log_match?: boolean;
}; };
@@ -114,6 +116,8 @@ type ProfileFormModel = {
request_history_limit?: string; request_history_limit?: string;
model_remap?: string; model_remap?: string;
reasoning_equals?: string; reasoning_equals?: string;
retryable_status_codes?: string;
retryable_error_messages?: string[];
endpoints?: string[]; endpoints?: string[];
}; };
@@ -179,11 +183,15 @@ type ProfileFormState = {
request_history_limit: string; request_history_limit: string;
model_remap: string; model_remap: string;
reasoning_equals: string; reasoning_equals: string;
retryable_status_codes: string;
retryable_error_messages: string;
endpoints: string; endpoints: string;
}; };
type RuleFormState = { type RuleFormState = {
reasoning_equals: string; reasoning_equals: string;
retryable_status_codes: string;
retryable_error_messages: string;
endpoints: string; endpoints: string;
non_stream_status_code: string; non_stream_status_code: string;
log_match: boolean; log_match: boolean;
@@ -263,6 +271,8 @@ const defaultProfileForm: ProfileFormState = {
request_history_limit: "0", request_history_limit: "0",
model_remap: "", model_remap: "",
reasoning_equals: "516,1034,1552", reasoning_equals: "516,1034,1552",
retryable_status_codes: "429,503",
retryable_error_messages: "Selected model is at capacity. Please try a different model.",
endpoints: "/responses\n/chat/completions\n/v1/responses\n/v1/chat/completions", endpoints: "/responses\n/chat/completions\n/v1/responses\n/v1/chat/completions",
}; };
@@ -368,6 +378,12 @@ function profileFormFromStatus(status: StatusPayload | null): ProfileFormState {
reasoning_equals: Array.isArray(config.reasoning_equals) reasoning_equals: Array.isArray(config.reasoning_equals)
? config.reasoning_equals.join(",") ? config.reasoning_equals.join(",")
: defaultProfileForm.reasoning_equals, : defaultProfileForm.reasoning_equals,
retryable_status_codes: Array.isArray(config.retryable_status_codes)
? config.retryable_status_codes.join(",")
: defaultProfileForm.retryable_status_codes,
retryable_error_messages: Array.isArray(config.retryable_error_messages)
? config.retryable_error_messages.join("\n")
: defaultProfileForm.retryable_error_messages,
endpoints: Array.isArray(config.endpoints) ? config.endpoints.join("\n") : defaultProfileForm.endpoints, endpoints: Array.isArray(config.endpoints) ? config.endpoints.join("\n") : defaultProfileForm.endpoints,
}; };
} }
@@ -376,6 +392,12 @@ function ruleFormFromStatus(status: StatusPayload | null): RuleFormState {
const config = status?.config || {}; const config = status?.config || {};
return { return {
reasoning_equals: Array.isArray(config.reasoning_equals) ? config.reasoning_equals.join(", ") : "", reasoning_equals: Array.isArray(config.reasoning_equals) ? config.reasoning_equals.join(", ") : "",
retryable_status_codes: Array.isArray(config.retryable_status_codes)
? config.retryable_status_codes.join(", ")
: defaultProfileForm.retryable_status_codes,
retryable_error_messages: Array.isArray(config.retryable_error_messages)
? config.retryable_error_messages.join("\n")
: defaultProfileForm.retryable_error_messages,
endpoints: Array.isArray(config.endpoints) ? config.endpoints.join("\n") : "", endpoints: Array.isArray(config.endpoints) ? config.endpoints.join("\n") : "",
non_stream_status_code: String(config.non_stream_status_code || 502), non_stream_status_code: String(config.non_stream_status_code || 502),
log_match: Boolean(config.log_match), log_match: Boolean(config.log_match),
@@ -517,6 +539,10 @@ export default function App() {
reasoning_equals: splitList(ruleForm.reasoning_equals) reasoning_equals: splitList(ruleForm.reasoning_equals)
.map((value) => Number.parseInt(value, 10)) .map((value) => Number.parseInt(value, 10))
.filter((value) => Number.isInteger(value)), .filter((value) => Number.isInteger(value)),
retryable_status_codes: splitList(ruleForm.retryable_status_codes)
.map((value) => Number.parseInt(value, 10))
.filter((value) => Number.isInteger(value)),
retryable_error_messages: splitLines(ruleForm.retryable_error_messages),
endpoints: splitLines(ruleForm.endpoints), endpoints: splitLines(ruleForm.endpoints),
non_stream_status_code: Number.parseInt(ruleForm.non_stream_status_code, 10), non_stream_status_code: Number.parseInt(ruleForm.non_stream_status_code, 10),
log_match: ruleForm.log_match, log_match: ruleForm.log_match,
@@ -563,6 +589,8 @@ export default function App() {
request_history_limit: Number.parseInt(profileForm.request_history_limit, 10), request_history_limit: Number.parseInt(profileForm.request_history_limit, 10),
model_remap: profileForm.model_remap, model_remap: profileForm.model_remap,
reasoning_equals: splitList(profileForm.reasoning_equals), reasoning_equals: splitList(profileForm.reasoning_equals),
retryable_status_codes: splitList(profileForm.retryable_status_codes),
retryable_error_messages: splitLines(profileForm.retryable_error_messages),
endpoints: splitLines(profileForm.endpoints), endpoints: splitLines(profileForm.endpoints),
}), }),
}); });
@@ -597,6 +625,10 @@ export default function App() {
request_history_limit: form.request_history_limit || defaultProfileForm.request_history_limit, request_history_limit: form.request_history_limit || defaultProfileForm.request_history_limit,
model_remap: form.model_remap || "", model_remap: form.model_remap || "",
reasoning_equals: form.reasoning_equals || "", reasoning_equals: form.reasoning_equals || "",
retryable_status_codes: form.retryable_status_codes || defaultProfileForm.retryable_status_codes,
retryable_error_messages: Array.isArray(form.retryable_error_messages)
? form.retryable_error_messages.join("\n")
: defaultProfileForm.retryable_error_messages,
endpoints: Array.isArray(form.endpoints) ? form.endpoints.join("\n") : "", endpoints: Array.isArray(form.endpoints) ? form.endpoints.join("\n") : "",
}); });
setProfileMessage({ text: "编辑后保存 profile env;如果保存的是当前运行 profile,后端会直接后台热应用。", tone: "" }); setProfileMessage({ text: "编辑后保存 profile env;如果保存的是当前运行 profile,后端会直接后台热应用。", tone: "" });
@@ -1077,6 +1109,12 @@ export default function App() {
<Field label="reasoning_equals"> <Field label="reasoning_equals">
<input value={profileForm.reasoning_equals} placeholder="516,1034,1552" onChange={(event) => setProfileForm({ ...profileForm, reasoning_equals: event.target.value })} /> <input value={profileForm.reasoning_equals} placeholder="516,1034,1552" onChange={(event) => setProfileForm({ ...profileForm, reasoning_equals: event.target.value })} />
</Field> </Field>
<Field label="retryable_status_codes">
<input value={profileForm.retryable_status_codes} placeholder="429,503" onChange={(event) => setProfileForm({ ...profileForm, retryable_status_codes: event.target.value })} />
</Field>
<Field label="retryable_error_messages" hint="每行一条错误文案;上游 JSON 错误包含任一行时会转成本地重试状态码。">
<textarea value={profileForm.retryable_error_messages} onChange={(event) => setProfileForm({ ...profileForm, retryable_error_messages: event.target.value })} />
</Field>
<Field label="endpoints"> <Field label="endpoints">
<textarea value={profileForm.endpoints} onChange={(event) => setProfileForm({ ...profileForm, endpoints: event.target.value })} /> <textarea value={profileForm.endpoints} onChange={(event) => setProfileForm({ ...profileForm, endpoints: event.target.value })} />
</Field> </Field>
@@ -1136,6 +1174,12 @@ export default function App() {
<Field label="reasoning_equals"> <Field label="reasoning_equals">
<input value={ruleForm.reasoning_equals} placeholder="例如:516, 1034, 1552" onChange={(event) => setRuleForm({ ...ruleForm, reasoning_equals: event.target.value })} /> <input value={ruleForm.reasoning_equals} placeholder="例如:516, 1034, 1552" onChange={(event) => setRuleForm({ ...ruleForm, reasoning_equals: event.target.value })} />
</Field> </Field>
<Field label="retryable_status_codes">
<input value={ruleForm.retryable_status_codes} placeholder="例如:429, 503" onChange={(event) => setRuleForm({ ...ruleForm, retryable_status_codes: event.target.value })} />
</Field>
<Field label="retryable_error_messages" hint="每行一条错误文案;命中后会转成本地 non_stream_status_code。">
<textarea value={ruleForm.retryable_error_messages} onChange={(event) => setRuleForm({ ...ruleForm, retryable_error_messages: event.target.value })} />
</Field>
<Field label="endpoints"> <Field label="endpoints">
<textarea value={ruleForm.endpoints} onChange={(event) => setRuleForm({ ...ruleForm, endpoints: event.target.value })} /> <textarea value={ruleForm.endpoints} onChange={(event) => setRuleForm({ ...ruleForm, endpoints: event.target.value })} />
</Field> </Field>