diff --git a/README.md b/README.md index 76dc9b5..4cfe995 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ tg群:https://t.me/AI_INPUT_IM - 保持 Codex 继续使用现有 `auth.json` - 只把 `config.toml` 的当前 provider `base_url` 改成本地网关 - 非流式命中 `reasoning_tokens = 516` 时返回 `502` +- 上游若返回明确的容量错误(默认匹配 `429/503` 且错误文案包含 `Selected model is at capacity. Please try a different model.`),也会转成本地 `502` - 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502` - 默认同时拦截 root 路径和 `/v1` 路径: - `/responses` @@ -139,6 +140,8 @@ profile env 默认放在: - `CODEX_RETRY_GATEWAY_LISTEN_PORT` - `CODEX_RETRY_GATEWAY_UPSTREAM_BASE_URL` - `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_ENV` - `CODEX_RETRY_GATEWAY_UPSTREAM_AUTH_FILE` @@ -213,6 +216,7 @@ gateway 运行时只负责 API 与静态文件服务,不再把复杂 UI 硬写 - 切换 provider `base_url` - 切换 `passthrough` / `manual_bearer` / `fixed_bearer` / `auth_json` 认证模式 - 改 `reasoning_equals` +- 改 capacity error 的匹配状态码和错误文案 - 改 `endpoints` - 改 `non_stream_status_code` - 开关 `log_match` @@ -246,6 +250,11 @@ macOS / Linux: ~/.codex-retry-gateway/config/config.json - `reasoning_equals` - 例如 `[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` - 默认包含 root 与 `/v1` 两套路径 - `non_stream_status_code` diff --git a/config.example.json b/config.example.json index 3be5d57..c1caafe 100644 --- a/config.example.json +++ b/config.example.json @@ -12,6 +12,10 @@ "request_history_limit": 200, "endpoints": ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], "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, "stream_action": "strict_502", "log_match": true, diff --git a/gateway.mjs b/gateway.mjs index 97db2c1..e45525f 100644 --- a/gateway.mjs +++ b/gateway.mjs @@ -39,6 +39,10 @@ const DEFAULT_CONFIG = { model_remap: "", endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], 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, stream_action: "strict_502", log_match: true, @@ -223,6 +227,21 @@ function normalizeStringList(values, fallback = []) { 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) { const map = {}; 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) { return JSON.stringify({ error: { @@ -724,6 +756,11 @@ function buildProfileFormModel(env) { request_history_limit: env.CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT || `${DEFAULT_CONFIG.request_history_limit}`, model_remap: env.CODEX_RETRY_GATEWAY_MODEL_REMAP || "", 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), }; } @@ -751,6 +788,14 @@ function buildConfigFromProfileEnv(profileName, env) { model_remap: env.CODEX_RETRY_GATEWAY_MODEL_REMAP || "", 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), + 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 ? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE}`, 10) : DEFAULT_CONFIG.non_stream_status_code, @@ -847,12 +892,29 @@ async function buildProfileEnvText(payload) { 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 = [ ["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_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_ENDPOINTS", endpoints.join(",")], ]; @@ -1075,6 +1137,14 @@ async function loadConfig(configPath) { 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) { throw new Error("配置缺少 upstream_base_url"); } @@ -1602,6 +1672,14 @@ async function serveManagementUi(res, requestPathname) { function buildEditableConfig(currentConfig, payload) { 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 nextStatusCode = payload.non_stream_status_code === undefined @@ -1611,6 +1689,12 @@ function buildEditableConfig(currentConfig, payload) { if (nextReasoning.length === 0) { 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) { throw new Error("endpoints 不能为空"); } @@ -1621,6 +1705,8 @@ function buildEditableConfig(currentConfig, payload) { return { ...currentConfig, reasoning_equals: nextReasoning, + retryable_status_codes: nextRetryableStatusCodes, + retryable_error_messages: nextRetryableErrorMessages, endpoints: nextEndpoints, non_stream_status_code: nextStatusCode, 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); runtime.config = nextConfig; 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); jsonResponse(res, 200, { @@ -1996,6 +2082,109 @@ function reasoningMatched(config, 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) { if (!error) { return false; @@ -2090,14 +2279,21 @@ async function handleNonStreaming({ }) { markAndPersistFirstResponse(runtime, requestEntry); 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 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 (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); res.writeHead(upstreamResponse.status); res.end(bodyBuffer); @@ -2347,8 +2572,12 @@ async function proxyRequest(runtime, req, res) { }, logger); const shouldInspect = matchPath(config, pathname); - const responseIsStream = - requestIsStream || isSseContentType(upstreamResponse.headers.get("content-type")); + 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; diff --git a/scripts/admin-lib.mjs b/scripts/admin-lib.mjs index 30ae29b..6a07393 100644 --- a/scripts/admin-lib.mjs +++ b/scripts/admin-lib.mjs @@ -177,16 +177,27 @@ export function normalizeIntArray(values, fallback = [516]) { return normalized.length > 0 ? [...new Set(normalized)] : [...fallback]; } -export function normalizeStringArray(values, fallback = []) { - const source = values === undefined || values === null ? fallback : values; - const queue = Array.isArray(source) ? source.flat(Infinity) : [source]; - const normalized = queue +export function normalizeStringArray(values, fallback = []) { + const source = values === undefined || values === null ? fallback : values; + const queue = Array.isArray(source) ? source.flat(Infinity) : [source]; + const normalized = queue .flatMap((value) => `${value ?? ""}`.split(/[\s,]+/)) .map((value) => value.trim()) .filter(Boolean); - - 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) { try { @@ -404,19 +415,23 @@ export async function installForCurrentProvider({ } } - const gatewayConfig = { - listen_host: listenHost, - listen_port: listenPort, - upstream_base_url: originalBaseUrl, + const gatewayConfig = { + listen_host: listenHost, + listen_port: listenPort, + upstream_base_url: originalBaseUrl, request_body_limit_bytes: existingGatewayConfig?.request_body_limit_bytes === undefined || existingGatewayConfig?.request_body_limit_bytes === null ? 10485760 : Number.parseInt(`${existingGatewayConfig.request_body_limit_bytes}`, 10), - endpoints: mergedEndpoints, - reasoning_equals: normalizeIntArray(existingGatewayConfig?.reasoning_equals, [516]), - non_stream_status_code: - existingGatewayConfig?.non_stream_status_code === undefined || existingGatewayConfig?.non_stream_status_code === null - ? 502 + endpoints: mergedEndpoints, + 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: + existingGatewayConfig?.non_stream_status_code === undefined || existingGatewayConfig?.non_stream_status_code === null + ? 502 : Number.parseInt(`${existingGatewayConfig.non_stream_status_code}`, 10), stream_action: existingGatewayConfig?.stream_action || "strict_502", log_match: existingGatewayConfig?.log_match === undefined ? true : Boolean(existingGatewayConfig.log_match), diff --git a/scripts/run-profile.mjs b/scripts/run-profile.mjs index d9b0a14..fa98411 100644 --- a/scripts/run-profile.mjs +++ b/scripts/run-profile.mjs @@ -17,6 +17,7 @@ import { getGatewayBaseUrl, getGatewayStatePaths, normalizeIntArray, + normalizePhraseArray, normalizeStringArray, parseOptions, readJsonFile, @@ -185,6 +186,14 @@ function buildProfileConfig({ profileName, profileEnv, existingGatewayConfig, pr profileEnv.CODEX_RETRY_GATEWAY_REASONING_EQUALS || existingGatewayConfig?.reasoning_equals, [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 ? Number.parseInt(`${profileEnv.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE}`, 10) : Number.parseInt(`${existingGatewayConfig?.non_stream_status_code || 502}`, 10), diff --git a/scripts/test-gateway-e2e.mjs b/scripts/test-gateway-e2e.mjs index 62f1a9d..b4a7909 100644 --- a/scripts/test-gateway-e2e.mjs +++ b/scripts/test-gateway-e2e.mjs @@ -123,6 +123,20 @@ function startFakeUpstream(port) { ]); 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) { createSseResponse(res, [ 'data: {"type":"response.output_text.delta","delta":"hello"}\n\n', @@ -273,11 +287,13 @@ async function run() { listen_port: gatewayPort, upstream_base_url: `http://127.0.0.1:${upstreamPort}`, request_body_limit_bytes: 10 * 1024 * 1024, - endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], - reasoning_equals: [516], - non_stream_status_code: 502, + endpoints: ["/responses", "/chat/completions", "/v1/responses", "/v1/chat/completions"], + 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, stream_action: "strict_502", - log_match: true, + log_match: true, health_path: "/__codex_retry_gateway/health", }; @@ -349,6 +365,34 @@ async function run() { `请求体大小记录异常: ${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 [ "/responses", "/v1/responses", diff --git a/scripts/test-install-restore.mjs b/scripts/test-install-restore.mjs index f4e59db..63a4a4f 100644 --- a/scripts/test-install-restore.mjs +++ b/scripts/test-install-restore.mjs @@ -221,6 +221,8 @@ async function run() { headers: { "content-type": "application/json" }, body: JSON.stringify({ 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"], non_stream_status_code: 503, log_match: false, @@ -237,6 +239,10 @@ async function run() { JSON.stringify(updatedGatewayConfig.reasoning_equals) === JSON.stringify([1024]), "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( `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/logs?since_seq=${logsPayload.latest_seq}`, diff --git a/ui-src/src/App.tsx b/ui-src/src/App.tsx index 926dbba..d671654 100644 --- a/ui-src/src/App.tsx +++ b/ui-src/src/App.tsx @@ -15,6 +15,8 @@ type GatewayConfig = { model_remap?: string; endpoints?: string[]; reasoning_equals?: number[]; + retryable_status_codes?: number[]; + retryable_error_messages?: string[]; non_stream_status_code?: number; log_match?: boolean; }; @@ -114,6 +116,8 @@ type ProfileFormModel = { request_history_limit?: string; model_remap?: string; reasoning_equals?: string; + retryable_status_codes?: string; + retryable_error_messages?: string[]; endpoints?: string[]; }; @@ -179,11 +183,15 @@ type ProfileFormState = { request_history_limit: string; model_remap: string; reasoning_equals: string; + retryable_status_codes: string; + retryable_error_messages: string; endpoints: string; }; type RuleFormState = { reasoning_equals: string; + retryable_status_codes: string; + retryable_error_messages: string; endpoints: string; non_stream_status_code: string; log_match: boolean; @@ -263,6 +271,8 @@ const defaultProfileForm: ProfileFormState = { request_history_limit: "0", model_remap: "", 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", }; @@ -368,6 +378,12 @@ function profileFormFromStatus(status: StatusPayload | null): ProfileFormState { reasoning_equals: Array.isArray(config.reasoning_equals) ? config.reasoning_equals.join(",") : 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, }; } @@ -376,6 +392,12 @@ function ruleFormFromStatus(status: StatusPayload | null): RuleFormState { const config = status?.config || {}; return { 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") : "", non_stream_status_code: String(config.non_stream_status_code || 502), log_match: Boolean(config.log_match), @@ -517,6 +539,10 @@ export default function App() { reasoning_equals: splitList(ruleForm.reasoning_equals) .map((value) => Number.parseInt(value, 10)) .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), non_stream_status_code: Number.parseInt(ruleForm.non_stream_status_code, 10), log_match: ruleForm.log_match, @@ -563,6 +589,8 @@ export default function App() { request_history_limit: Number.parseInt(profileForm.request_history_limit, 10), model_remap: profileForm.model_remap, 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), }), }); @@ -597,6 +625,10 @@ export default function App() { request_history_limit: form.request_history_limit || defaultProfileForm.request_history_limit, model_remap: form.model_remap || "", 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") : "", }); setProfileMessage({ text: "编辑后保存 profile env;如果保存的是当前运行 profile,后端会直接后台热应用。", tone: "" }); @@ -1077,6 +1109,12 @@ export default function App() { setProfileForm({ ...profileForm, reasoning_equals: event.target.value })} /> + + setProfileForm({ ...profileForm, retryable_status_codes: event.target.value })} /> + + +