diff --git a/README.md b/README.md index 672b9b5..2f755b6 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - 上游若返回明确的容量错误(默认匹配错误文案 `Selected model is at capacity. Please try a different model.`、`stream disconnected before completion: Concurrency limit exceeded for account, please retry later`,以及 Responses 错误码 `server_is_overloaded` / `slow_down`),也会自动重试;重试耗尽后转成本地 `502` - 除了 `429/503` JSON 错误响应,也会识别 `200` 但返回体本质是错误、以及流式失败事件里携带同样文案的情况 - 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502` +- 可选 `passthrough` 直通模式会对 `stream: true` 请求跳过 reasoning retry;若上游实际返回 `text/event-stream`,则把原始 SSE chunk 直接转发给客户端,不解析、缓存、规范化或拦截 - 流式成功响应在严格检查模式下仍会先缓存完成;成功后会按真实且规范化的 Responses 生命周期与输出顺序逐块回放给 Codex,每个 SSE 块之间至少间隔 5ms,并关闭 TCP 小包聚合,避免大量 delta 在同一事件循环突发到达;不会伪造缺少 response ID 的生命周期事件 - 默认同时拦截 root 路径和 `/v1` 路径: - `/responses` @@ -388,6 +389,7 @@ macOS / Linux: ~/.codex-retry-gateway/config/config.json - 默认 `strict_502` - `strict_502`:先缓存整个流,命中 `516` 时统一返回 `502` - `disconnect`:兼容旧行为;若命中发生在已透传 chunk 之后,则直接断开连接 + - `passthrough`:对 `stream: true` 请求关闭 reasoning retry;只有上游实际返回 `text/event-stream` 时原样转发 SSE chunk,非 SSE 响应继续按单次既有规则处理 - `log_match` - 是否记录命中日志 diff --git a/gateway.mjs b/gateway.mjs index 858527b..9ed072c 100644 --- a/gateway.mjs +++ b/gateway.mjs @@ -80,6 +80,7 @@ const DEFAULT_CONFIG = { log_match: true, health_path: "/__codex_retry_gateway/health", }; +const STREAM_ACTIONS = ["strict_502", "disconnect", "passthrough"]; const REASONING_POINTERS = [ "/usage/output_tokens_details/reasoning_tokens", @@ -935,6 +936,15 @@ function normalizeReasoningMatchMode(value) { return DEFAULT_CONFIG.reasoning_match_mode; } +function normalizeStreamAction(value) { + const action = `${value || DEFAULT_CONFIG.stream_action}`.trim().toLowerCase(); + return STREAM_ACTIONS.includes(action) ? action : DEFAULT_CONFIG.stream_action; +} + +function streamPassthroughEnabled(config) { + return normalizeStreamAction(config?.stream_action) === "passthrough"; +} + function normalizeReasoningEquals(values, fallback = DEFAULT_CONFIG.reasoning_equals) { const normalized = normalizeIntegerList(values, fallback); return normalized.length > 0 ? normalized : [...fallback]; @@ -1646,6 +1656,7 @@ function buildProfileFormModel(env) { env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS || `${DEFAULT_CONFIG.upstream_fetch_retry_attempts}`, upstream_fetch_retry_backoff_ms: env.CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS || `${DEFAULT_CONFIG.upstream_fetch_retry_backoff_ms}`, + stream_action: normalizeStreamAction(env.CODEX_RETRY_GATEWAY_STREAM_ACTION), endpoints: normalizeStringList(env.CODEX_RETRY_GATEWAY_ENDPOINTS || DEFAULT_CONFIG.endpoints, DEFAULT_CONFIG.endpoints), }; } @@ -1758,7 +1769,7 @@ function buildConfigFromProfileEnv(profileName, env, imageConfig = {}) { non_stream_status_code: env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE ? Number.parseInt(`${env.CODEX_RETRY_GATEWAY_NON_STREAM_STATUS_CODE}`, 10) : DEFAULT_CONFIG.non_stream_status_code, - stream_action: env.CODEX_RETRY_GATEWAY_STREAM_ACTION || DEFAULT_CONFIG.stream_action, + stream_action: normalizeStreamAction(env.CODEX_RETRY_GATEWAY_STREAM_ACTION), log_match: env.CODEX_RETRY_GATEWAY_LOG_MATCH === undefined ? DEFAULT_CONFIG.log_match : ["1", "true", "yes", "on"].includes(`${env.CODEX_RETRY_GATEWAY_LOG_MATCH}`.trim().toLowerCase()), @@ -1895,6 +1906,7 @@ async function buildProfileEnvText(payload) { if (upstreamFetchRetryBackoffMs < 0) { throw new Error("upstream_fetch_retry_backoff_ms 不能为负数"); } + const streamAction = normalizeStreamAction(payload.stream_action); const envPairs = [ ["CODEX_RETRY_GATEWAY_LISTEN_HOST", `${payload.listen_host || DEFAULT_CONFIG.listen_host}`.trim()], @@ -1907,6 +1919,7 @@ async function buildProfileEnvText(payload) { ["CODEX_RETRY_GATEWAY_RETRYABLE_ERROR_MESSAGES", retryableErrorMessages.join("\n")], ["CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_ATTEMPTS", `${upstreamFetchRetryAttempts}`], ["CODEX_RETRY_GATEWAY_UPSTREAM_FETCH_RETRY_BACKOFF_MS", `${upstreamFetchRetryBackoffMs}`], + ["CODEX_RETRY_GATEWAY_STREAM_ACTION", streamAction], ["CODEX_RETRY_GATEWAY_REQUEST_HISTORY_LIMIT", `${requestHistoryLimit}`], ["CODEX_RETRY_GATEWAY_ENDPOINTS", endpoints.join(",")], ]; @@ -2132,6 +2145,7 @@ async function buildTextProfileExportBundle(runtime, payload) { retryable_error_messages: config.retryable_error_messages, upstream_fetch_retry_attempts: config.upstream_fetch_retry_attempts, upstream_fetch_retry_backoff_ms: config.upstream_fetch_retry_backoff_ms, + stream_action: config.stream_action, endpoints: config.endpoints, }, }; @@ -2242,6 +2256,7 @@ async function importTextProfileBundle(runtime, payload) { retryable_error_messages: imported.profile.retryable_error_messages, upstream_fetch_retry_attempts: imported.profile.upstream_fetch_retry_attempts, upstream_fetch_retry_backoff_ms: imported.profile.upstream_fetch_retry_backoff_ms, + stream_action: imported.profile.stream_action, endpoints: imported.profile.endpoints, }); return { @@ -2544,6 +2559,7 @@ async function loadConfig(configPath) { config.retryable_error_messages, DEFAULT_CONFIG.retryable_error_messages, ); + config.stream_action = normalizeStreamAction(config.stream_action); config.management_access_key = normalizeManagementAccessKey(config.management_access_key); config.upstream_fetch_retry_attempts = normalizePositiveInteger( config.upstream_fetch_retry_attempts, @@ -2941,6 +2957,7 @@ async function listProfiles(runtime) { auth_source: summarizeProfileAuthSource(env), reasoning_match_mode: form.reasoning_match_mode, reasoning_equals: form.reasoning_equals, + stream_action: form.stream_action, }, form, }); @@ -3703,6 +3720,9 @@ function buildEditableConfig(currentConfig, payload) { payload.non_stream_status_code === undefined ? currentConfig.non_stream_status_code : Number.parseInt(`${payload.non_stream_status_code}`, 10); + const nextStreamAction = normalizeStreamAction( + payload.stream_action === undefined ? currentConfig.stream_action : payload.stream_action, + ); if (nextRetryableStatusCodes.length === 0) { throw new Error("retryable_status_codes 不能为空"); @@ -3737,6 +3757,7 @@ function buildEditableConfig(currentConfig, payload) { upstream_fetch_retry_attempts: nextUpstreamFetchRetryAttempts, upstream_fetch_retry_backoff_ms: nextUpstreamFetchRetryBackoffMs, non_stream_status_code: nextStatusCode, + stream_action: nextStreamAction, log_match: payload.log_match === undefined ? currentConfig.log_match : Boolean(payload.log_match), }; } @@ -4322,7 +4343,7 @@ async function handleManagementRequest(runtime, req, res, requestUrl) { await writeConfig(runtime.configPath, nextConfig); runtime.config = nextConfig; runtime.logger( - `[config] updated reasoning_match_mode=${nextConfig.reasoning_match_mode} reasoning_equals=${nextConfig.reasoning_equals.join(",")} retryable_status_codes=${nextConfig.retryable_status_codes.join(",")} endpoints=${nextConfig.endpoints.join(",")}`, + `[config] updated reasoning_match_mode=${nextConfig.reasoning_match_mode} reasoning_equals=${nextConfig.reasoning_equals.join(",")} retryable_status_codes=${nextConfig.retryable_status_codes.join(",")} stream_action=${nextConfig.stream_action} endpoints=${nextConfig.endpoints.join(",")}`, ); const state = await readRuntimeState(runtime); jsonResponse(req, res, 200, { @@ -4538,6 +4559,34 @@ async function writeBufferedStreamChunks(res, chunks) { } } +async function writeResponseChunk(res, chunk) { + if (!chunk || chunk.length === 0 || res.destroyed || res.writableEnded) { + return false; + } + if (res.write(chunk)) { + return true; + } + await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) { + return; + } + settled = true; + res.off("drain", onDrain); + res.off("close", onClose); + res.off("error", onClose); + resolve(); + }; + const onDrain = () => finish(); + const onClose = () => finish(); + res.once("drain", onDrain); + res.once("close", onClose); + res.once("error", onClose); + }); + return !res.destroyed && !res.writableEnded; +} + async function writeCapturedResponse(res, delivery) { if (!delivery) { throw new Error("missing captured response delivery"); @@ -5281,7 +5330,7 @@ async function handleStreaming({ captureOnly = false, persistEntry = true, }) { - const strict502Mode = captureOnly || config.stream_action !== "disconnect"; + const strict502Mode = captureOnly || normalizeStreamAction(config.stream_action) !== "disconnect"; const reader = upstreamResponse.body.getReader(); const sseState = { decoder: new TextDecoder("utf8"), @@ -5659,6 +5708,97 @@ async function handleStreaming({ } } +async function handlePassthroughStreaming({ + runtime, + upstreamResponse, + res, + requestEntry, + requestAbortSignal = null, + persistEntry = true, +}) { + const reader = upstreamResponse.body?.getReader(); + if (!reader) { + throw new Error("upstream SSE response is missing a readable body"); + } + + requestEntry.inspected = false; + copyHeadersToClient(upstreamResponse.headers, res); + res.writeHead(upstreamResponse.status); + res.socket?.setNoDelay(true); + res.flushHeaders?.(); + + while (true) { + let readResult; + try { + readResult = await reader.read(); + } catch (error) { + if (requestAbortSignal?.aborted) { + throw requestAbortSignal.reason || error; + } + if (persistEntry) { + persistStreamingProgress(runtime, requestEntry, { force: true }, new Date()); + } + if (!res.destroyed && !res.writableEnded) { + res.destroy(error); + } + return { + inspected: false, + matched: false, + status_code: upstreamResponse.status, + upstream_status_code: upstreamResponse.status, + response_id: requestEntry.response_id, + thread_id: requestEntry.thread_id, + response_bytes_received: requestEntry.response_bytes_received, + stream_chunk_count: requestEntry.stream_chunk_count, + error: `upstream SSE stream terminated: ${error?.message || error}`, + }; + } + + if (readResult.done) { + if (persistEntry) { + persistStreamingProgress(runtime, requestEntry, { force: true }, new Date()); + } + if (!res.destroyed && !res.writableEnded) { + res.end(); + } + return { + inspected: false, + matched: false, + status_code: upstreamResponse.status, + upstream_status_code: upstreamResponse.status, + response_id: requestEntry.response_id, + thread_id: requestEntry.thread_id, + response_bytes_received: requestEntry.response_bytes_received, + stream_chunk_count: requestEntry.stream_chunk_count, + }; + } + + const chunk = Buffer.from(readResult.value); + const now = new Date(); + markRequestEntryFirstResponse(runtime, requestEntry, { persistEntry, at: now }); + updateStreamingProgress(requestEntry, { chunkBytes: chunk.length, at: now }); + if (persistEntry) { + persistStreamingProgress( + runtime, + requestEntry, + { force: requestEntry.stream_chunk_count === 1 }, + now, + ); + } + await writeResponseChunk(res, chunk); + if (res.destroyed || res.writableEnded) { + return { + cancelled: true, + cancel_reason: REASONING_RETRY_ABORT_CLIENT, + response_id: requestEntry.response_id, + thread_id: requestEntry.thread_id, + response_bytes_received: requestEntry.response_bytes_received, + stream_chunk_count: requestEntry.stream_chunk_count, + }; + } + } +} + function buildAttemptRequestEntry(baseEntry) { return { ...baseEntry, @@ -5778,6 +5918,22 @@ async function executeGatewayQuery({ ); } + if (isSseContentType(responseContentType) && !captureOnly && streamPassthroughEnabled(config)) { + const result = await handlePassthroughStreaming({ + runtime, + upstreamResponse, + res, + requestEntry, + requestAbortSignal: queryAbortLink.controller.signal, + persistEntry, + }); + return { + response_stream: true, + ...result, + total_upstream_attempts: totalUpstreamAttempts, + }; + } + if (!shouldInspect) { markRequestEntryFirstResponse(runtime, requestEntry, { persistEntry }); const body = Buffer.from(await upstreamResponse.arrayBuffer()); @@ -6303,6 +6459,12 @@ async function proxyRequest(runtime, req, res) { requestEntry.reasoning_summary = extractRequestReasoningSummary(requestJson); requestEntry.request_stream = requestIsStream; applyThreadReasoningState(runtime, requestEntry, pathname); + const streamPassthroughRequest = streamPassthroughEnabled(config) && requestIsStream; + if (streamPassthroughRequest && isResponsesReasoningRetryPath(pathname)) { + requestEntry.reasoning_retry_enabled = false; + requestEntry.reasoning_retry_thread_mode = "stream_passthrough"; + requestEntry.reasoning_retry_stop_reason = "stream_passthrough"; + } upsertRequestEntry(runtime, requestEntry); const upstreamRoute = selectUpstreamRoute(config, pathname); @@ -6323,7 +6485,7 @@ async function proxyRequest(runtime, req, res) { } const clientAbortContext = createClientAbortContext(req, res); try { - const result = isResponsesReasoningRetryEligible(pathname, requestEntry) + const result = !streamPassthroughRequest && isResponsesReasoningRetryEligible(pathname, requestEntry) ? await runResponsesReasoningRetry({ runtime, config, diff --git a/scripts/test-gateway-e2e.mjs b/scripts/test-gateway-e2e.mjs index 559fc73..84f321b 100644 --- a/scripts/test-gateway-e2e.mjs +++ b/scripts/test-gateway-e2e.mjs @@ -822,6 +822,7 @@ async function run() { retryable_error_messages: ["capacity test"], upstream_fetch_retry_attempts: 3, upstream_fetch_retry_backoff_ms: 50, + stream_action: "passthrough", endpoints: ["/responses", "/v1/responses"], }), }, @@ -829,6 +830,15 @@ async function run() { assert(exportableTextProfileResponse.status === 200, `可导出文本 profile 保存失败: ${exportableTextProfileResponse.status}`); const exportableTextProfilePayload = await exportableTextProfileResponse.json(); assert(!JSON.stringify(exportableTextProfilePayload).includes("text-export-secret"), "普通 profile API 不应返回导出 key"); + const exportableTextProfile = (exportableTextProfilePayload.profiles || []).find( + (profile) => profile?.name === "export-source", + ); + assert(exportableTextProfile?.form?.stream_action === "passthrough", "文本 profile 未保留 SSE 直通模式"); + assert( + (await readFile(path.join(profilesDir, "export-source.env"), "utf8")) + .includes("CODEX_RETRY_GATEWAY_STREAM_ACTION=passthrough"), + "文本 profile env 未写入 SSE 直通模式", + ); const passthroughExportResponse = await fetch( `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/profiles/export`, @@ -1916,6 +1926,59 @@ async function run() { } } + const enablePassthroughResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/config`, { + method: "POST", + headers: { ...adminHeaders, "content-type": "application/json" }, + body: JSON.stringify({ stream_action: "passthrough" }), + }); + const enablePassthroughPayload = await enablePassthroughResponse.json(); + assert(enablePassthroughResponse.status === 200, `启用 SSE 直通模式失败: ${enablePassthroughResponse.status}`); + assert(enablePassthroughPayload?.config?.stream_action === "passthrough", "当前配置未切换到 SSE 直通模式"); + + const passthroughThreadId = "thread_sse_passthrough"; + const passthroughRetryKey = reasoningRetryKeyForRequest("/responses", { + stream: true, + thread_id: passthroughThreadId, + test_reasoning_retry_key: "sse-passthrough", + }); + const passthroughStream = await readSseUntilClose( + `http://127.0.0.1:${gatewayPort}/responses`, + { + stream: true, + thread_id: passthroughThreadId, + test_reasoning_before_success_times: 1, + test_reasoning_retry_key: "sse-passthrough", + test_stream_include_lifecycle: true, + test_stream_lifecycle_marker: "passthrough-raw-marker", + test_stream_delta_chunks: 2, + test_stream_chunk_delay_ms: 2, + }, + ); + assert(passthroughStream.status === 200, `SSE 直通状态异常: ${passthroughStream.status}`); + assert(passthroughStream.headers.get("x-upstream-reasoning-attempt") === "1", "SSE 直通不应发起 reasoning 重试"); + assert(passthroughStream.text.includes("passthrough-raw-marker"), "SSE 直通不应改写上游生命周期 payload"); + assert(passthroughStream.text.includes('"reasoning_tokens":516'), "SSE 直通不应拦截匹配的 reasoning 响应"); + const passthroughRetryStats = upstream.getReasoningRetryStat(passthroughRetryKey); + assert(passthroughRetryStats.totalRequests === 1, "SSE 直通不应启动多轮 reasoning 请求"); + const passthroughRequestsResponse = await fetch( + `http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/requests?query=${encodeURIComponent(passthroughThreadId)}`, + { headers: adminHeaders }, + ); + const passthroughRequestsPayload = await passthroughRequestsResponse.json(); + const passthroughEntry = (passthroughRequestsPayload?.entries || []).find( + (entry) => entry.thread_id === passthroughThreadId, + ); + assert(passthroughEntry?.inspected === false, "SSE 直通请求不应标记为已检查"); + assert(passthroughEntry?.reasoning_retry_stop_reason === "stream_passthrough", "SSE 直通请求未记录跳过重试原因"); + assert((passthroughEntry?.stream_chunk_count || 0) >= 1, "SSE 直通请求未累计流式 chunk"); + + const restoreStrictStreamResponse = await fetch(`http://127.0.0.1:${gatewayPort}/__codex_retry_gateway/api/config`, { + method: "POST", + headers: { ...adminHeaders, "content-type": "application/json" }, + body: JSON.stringify({ stream_action: "strict_502" }), + }); + assert(restoreStrictStreamResponse.status === 200, "测试后恢复严格 SSE 模式失败"); + const capturedLifecycleStream = await readSseUntilClose( `http://127.0.0.1:${gatewayPort}/responses`, { diff --git a/ui-src/src/App.tsx b/ui-src/src/App.tsx index aaeffb5..802f91c 100644 --- a/ui-src/src/App.tsx +++ b/ui-src/src/App.tsx @@ -4,6 +4,7 @@ type PageKey = "overview" | "requests" | "profiles" | "rules" | "logs"; type Tone = "" | "success" | "error"; type ReasoningMatchMode = "formula_518n_minus_2" | "manual"; type AuthMode = "passthrough" | "fixed_bearer" | "manual_bearer" | "auth_json"; +type StreamAction = "strict_502" | "disconnect" | "passthrough"; type GatewayConfig = { profile_name?: string; @@ -30,6 +31,7 @@ type GatewayConfig = { upstream_fetch_retry_attempts?: number; upstream_fetch_retry_backoff_ms?: number; non_stream_status_code?: number; + stream_action?: StreamAction; log_match?: boolean; }; @@ -190,6 +192,7 @@ type ProfileFormModel = { retryable_error_messages?: string[]; upstream_fetch_retry_attempts?: string; upstream_fetch_retry_backoff_ms?: string; + stream_action?: StreamAction; endpoints?: string[]; }; @@ -207,6 +210,7 @@ type Profile = { model_remap?: string; reasoning_match_mode?: ReasoningMatchMode; reasoning_equals?: string; + stream_action?: StreamAction; }; form?: ProfileFormModel; }; @@ -321,6 +325,7 @@ type ProfileFormState = { retryable_error_messages: string; upstream_fetch_retry_attempts: string; upstream_fetch_retry_backoff_ms: string; + stream_action: StreamAction; endpoints: string; }; @@ -346,6 +351,7 @@ type RuleFormState = { upstream_fetch_retry_backoff_ms: string; endpoints: string; non_stream_status_code: string; + stream_action: StreamAction; log_match: boolean; }; @@ -454,6 +460,7 @@ const defaultProfileForm: ProfileFormState = { retryable_error_messages: "Selected model is at capacity. Please try a different model.\nstream disconnected before completion: Concurrency limit exceeded for account, please retry later", upstream_fetch_retry_attempts: "5", upstream_fetch_retry_backoff_ms: "350", + stream_action: "strict_502", endpoints: "/responses\n/chat/completions\n/v1/responses\n/v1/chat/completions", }; @@ -694,6 +701,24 @@ function normalizeReasoningMode(value: unknown): ReasoningMatchMode { return value === "manual" ? "manual" : "formula_518n_minus_2"; } +function normalizeStreamAction(value: unknown): StreamAction { + if (value === "disconnect" || value === "passthrough") { + return value; + } + return "strict_502"; +} + +function formatStreamAction(value: unknown) { + const action = normalizeStreamAction(value); + if (action === "passthrough") { + return "直通"; + } + if (action === "disconnect") { + return "透传后断开"; + } + return "严格检查"; +} + function formatReasoningMode(mode: ReasoningMatchMode) { return mode === "manual" ? "manual" : "518n-2"; } @@ -782,6 +807,7 @@ function profileFormFromStatus(status: StatusPayload | null): ProfileFormState { upstream_fetch_retry_backoff_ms: String( config.upstream_fetch_retry_backoff_ms ?? defaultProfileForm.upstream_fetch_retry_backoff_ms, ), + stream_action: normalizeStreamAction(config.stream_action), endpoints: Array.isArray(config.endpoints) ? config.endpoints.join("\n") : defaultProfileForm.endpoints, }; } @@ -814,6 +840,7 @@ function profileFormFromProfile(profile: Profile): ProfileFormState { form.upstream_fetch_retry_attempts || defaultProfileForm.upstream_fetch_retry_attempts, upstream_fetch_retry_backoff_ms: form.upstream_fetch_retry_backoff_ms || defaultProfileForm.upstream_fetch_retry_backoff_ms, + stream_action: normalizeStreamAction(form.stream_action), endpoints: Array.isArray(form.endpoints) ? form.endpoints.join("\n") : "", }; } @@ -866,6 +893,7 @@ function ruleFormFromStatus(status: StatusPayload | null): RuleFormState { ), endpoints: Array.isArray(config.endpoints) ? config.endpoints.join("\n") : "", non_stream_status_code: String(config.non_stream_status_code || 502), + stream_action: normalizeStreamAction(config.stream_action), log_match: Boolean(config.log_match), }; } @@ -1168,6 +1196,7 @@ export default function App() { upstream_fetch_retry_backoff_ms: Number.parseInt(ruleForm.upstream_fetch_retry_backoff_ms, 10), endpoints: splitLines(ruleForm.endpoints), non_stream_status_code: Number.parseInt(ruleForm.non_stream_status_code, 10), + stream_action: ruleForm.stream_action, log_match: ruleForm.log_match, }), }); @@ -1276,6 +1305,7 @@ export default function App() { retryable_error_messages: splitLines(profileForm.retryable_error_messages), upstream_fetch_retry_attempts: Number.parseInt(profileForm.upstream_fetch_retry_attempts, 10), upstream_fetch_retry_backoff_ms: Number.parseInt(profileForm.upstream_fetch_retry_backoff_ms, 10), + stream_action: profileForm.stream_action, endpoints: splitLines(profileForm.endpoints), }), }); @@ -2202,6 +2232,7 @@ export default function App() { label="Rule Mode" value={formatReasoningMode(normalizeReasoningMode(profile.summary?.reasoning_match_mode))} /> + )) @@ -2388,6 +2419,16 @@ export default function App() { setProfileForm({ ...profileForm, upstream_fetch_retry_backoff_ms: event.target.value })} /> + + +