fix: preserve responses SSE lifecycle during replay

This commit is contained in:
2026-07-11 02:36:00 +08:00
parent 92d3845eca
commit 7d0a40e1ea
3 changed files with 89 additions and 197 deletions
+5 -2
View File
@@ -24,7 +24,7 @@
- 上游若返回明确的容量错误(默认匹配错误文案 `Selected model is at capacity. Please try a different model.`,以及 `stream disconnected before completion: Concurrency limit exceeded for account, please retry later`),也会自动重试;重试耗尽后转成本地 `502`
- 除了 `429/503` JSON 错误响应,也会识别 `200` 但返回体本质是错误、以及流式失败事件里携带同样文案的情况
- 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502`
- 流式成功响应在严格检查模式下仍会先缓存完成,但会按分块 SSE 回放给 Codex,而不是单次大包 `res.end(...)`
- 流式成功响应在严格检查模式下仍会先缓存完成;成功后会按真实且规范化的 Responses 生命周期与输出顺序逐块回放给 Codex,每个 SSE 块之间让出一次事件循环,不会伪造缺少 response ID 的生命周期事件
- 默认同时拦截 root 路径和 `/v1` 路径:
- `/responses`
- `/chat/completions`
@@ -241,7 +241,7 @@ codex --dangerously-bypass-approvals-and-sandbox -c model="gpt-5.4" hello
建议同时检查:
- `curl http://<listen-host>:4610/__codex_retry_gateway/health`
- `/responses` 成功流能完整结束,不会卡 pending,必要时应能看到 `response.completed`
- `/responses` 成功流能完整结束,不会卡 pending`response.created` 必须带真实 response ID,随后依次可见 `response.in_progress`、输出 delta 与 `response.completed`
- 容量错误 `Selected model is at capacity. Please try a different model.` 仍能按网关策略自动重试
如果当前对话依赖本机 `4610`,不要把本机作为首个发布目标;先在另一台已接管相同 profile 的机器验证,再回到本机切换。
@@ -395,6 +395,9 @@ macOS / Linux: ~/.codex-retry-gateway
- `test-gateway-e2e.ps1`
- 已通过
- 验证 `/responses``/chat/completions``/v1/responses``/v1/chat/completions`
- `node scripts/test-gateway-e2e.mjs`
- 已通过
- 验证 strict capture 下 `/responses` 生命周期不注入空事件,长流 delta 按序完整回放并以 `response.completed` 结束
- `test-install-restore.ps1`
- 已通过
- 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环
+8 -157
View File
@@ -538,13 +538,7 @@ function buildSseBlock(eventName, payloadText) {
return Buffer.from(`${lines.join("\n")}\n\n`);
}
const RESPONSES_PREVIEW_SUPPRESSED_EVENT_NAMES = new Set([
"response.created",
"response.in_progress",
]);
const RESPONSES_CAPTURE_PREVIEW_START_AFTER_MS = 1000;
const RESPONSES_MODEL_HEADER_NAMES = new Set(["openai-model", "x-openai-model"]);
const RESPONSES_CAPTURE_PREVIEW_HEARTBEAT_MS = 2000;
function normalizeResponsesHeaderSubset(...sources) {
const headers = {};
@@ -562,95 +556,6 @@ function normalizeResponsesHeaderSubset(...sources) {
return Object.keys(headers).length > 0 ? headers : null;
}
function buildResponsesPreviewPayload(type = "response.in_progress") {
return {
type,
response: {},
};
}
function buildResponsesFailedPayload(message, options = {}) {
const payload = {
type: "response.failed",
response: {
status: "failed",
error: {
message: firstNonEmptyString(message, "response failed"),
type: firstNonEmptyString(options.errorType, "server_error"),
},
},
};
const responseId = firstNonEmptyString(options.responseId);
if (responseId) {
payload.response.id = responseId;
}
const code = firstNonEmptyString(options.code);
if (code) {
payload.response.error.code = code;
}
return payload;
}
function buildResponsesFailedSseDelivery(message, options = {}) {
const payload = buildResponsesFailedPayload(message, options);
const chunk = buildSseBlock("response.failed", JSON.stringify(payload));
return buildCapturedDelivery(
200,
new Headers({
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive",
}),
chunk,
{ stream_chunks: [chunk] },
);
}
function createResponsesCapturedPreview(res) {
return {
res,
created_at_ms: Date.now(),
started: false,
last_sent_at_ms: 0,
};
}
function maybeWriteResponsesCapturedPreview(preview, sourceHeaders = null, nowMs = Date.now()) {
if (!preview?.res || preview.res.writableEnded || preview.res.destroyed) {
return false;
}
if (!preview.started) {
if (nowMs - preview.created_at_ms < RESPONSES_CAPTURE_PREVIEW_START_AFTER_MS) {
return false;
}
preview.started = true;
preview.last_sent_at_ms = nowMs;
if (sourceHeaders) {
copyHeadersToClient(sourceHeaders, preview.res);
}
preview.res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive",
});
preview.res.write(
buildSseBlock("response.created", JSON.stringify(buildResponsesPreviewPayload("response.created"))),
);
return true;
}
if (nowMs - preview.last_sent_at_ms < RESPONSES_CAPTURE_PREVIEW_HEARTBEAT_MS) {
return false;
}
preview.last_sent_at_ms = nowMs;
preview.res.write(
buildSseBlock(
"response.in_progress",
JSON.stringify(buildResponsesPreviewPayload("response.in_progress")),
),
);
return true;
}
function createEmptySseInspectionResult() {
return {
reasoning: null,
@@ -845,7 +750,7 @@ function normalizeResponsesCompletedPayloadForCodex(parsed, eventName = "", fall
return normalizeResponsesLifecyclePayloadForCodex(parsed, eventName, fallbackResponseId);
}
function processResponsesSseBlockForCodex(state, blockText, fallbackResponseId = null, options = {}) {
function processResponsesSseBlockForCodex(state, blockText, fallbackResponseId = null) {
const lines = `${blockText || ""}`
.split(/\r?\n/)
.map((line) => line.trimEnd());
@@ -905,37 +810,31 @@ function processResponsesSseBlockForCodex(state, blockText, fallbackResponseId =
state.saw_terminal_failure = true;
}
if (
options?.suppressLifecycleEvents &&
RESPONSES_PREVIEW_SUPPRESSED_EVENT_NAMES.has(outputEventName)
) {
return Buffer.alloc(0);
}
if (!rewritten) {
return Buffer.from(`${lines.join("\n")}\n\n`);
}
return buildSseBlock(outputEventName, JSON.stringify(outputPayload));
}
function drainResponsesSseForCodex(state, chunk, fallbackResponseId = null, options = {}) {
function drainResponsesSseForCodex(state, chunk, fallbackResponseId = null) {
const decoded = state.decoder.decode(chunk, { stream: true });
state.buffer += decoded;
const blocks = state.buffer.split(/\r?\n\r?\n/);
state.buffer = blocks.pop() ?? "";
return blocks
.filter((block) => block.length > 0)
.map((block) => processResponsesSseBlockForCodex(state, block, fallbackResponseId, options))
.map((block) => processResponsesSseBlockForCodex(state, block, fallbackResponseId))
.filter((chunkBuffer) => chunkBuffer && chunkBuffer.length > 0);
}
function flushResponsesSseForCodex(state, fallbackResponseId = null, fallbackUsage = null, options = {}) {
function flushResponsesSseForCodex(state, fallbackResponseId = null, fallbackUsage = null) {
const flushed = state.decoder.decode();
if (flushed) {
state.buffer += flushed;
}
const outputs = [];
if (state.buffer.trim()) {
outputs.push(processResponsesSseBlockForCodex(state, state.buffer, fallbackResponseId, options));
outputs.push(processResponsesSseBlockForCodex(state, state.buffer, fallbackResponseId));
}
state.buffer = "";
@@ -4180,7 +4079,7 @@ function cloneResponseHeaders(sourceHeaders) {
return headers;
}
const CAPTURED_STREAM_REPLAY_CHUNKS_PER_TICK = 16;
const CAPTURED_STREAM_REPLAY_CHUNKS_PER_TICK = 1;
function cloneBufferList(chunks) {
if (!Array.isArray(chunks) || chunks.length === 0) {
@@ -4244,20 +4143,6 @@ async function writeCapturedResponse(res, delivery) {
res.end(delivery.body);
}
async function appendCapturedResponse(res, delivery) {
if (!delivery) {
throw new Error("missing captured response delivery");
}
if (Array.isArray(delivery.stream_chunks) && delivery.stream_chunks.length > 0) {
await writeBufferedStreamChunks(res, delivery.stream_chunks);
} else if (delivery.body?.length) {
res.write(delivery.body);
}
if (!res.writableEnded) {
res.end();
}
}
function buildCapturedDelivery(statusCode, headers, body, options = {}) {
const delivery = {
status_code: statusCode,
@@ -4933,7 +4818,6 @@ async function handleStreaming({
requestAbortSignal = null,
captureOnly = false,
persistEntry = true,
capturedResponsesPreview = null,
}) {
const strict502Mode = captureOnly || config.stream_action !== "disconnect";
const reader = upstreamResponse.body.getReader();
@@ -5056,7 +4940,6 @@ async function handleStreaming({
codexResponsesSseState,
requestEntry.response_id || null,
observedUsage,
{ suppressLifecycleEvents: Boolean(capturedResponsesPreview?.started) },
)
: [];
if (Number.isInteger(finalInspection.reasoning)) {
@@ -5290,15 +5173,11 @@ async function handleStreaming({
};
}
if (captureOnly && codexResponsesSseState && capturedResponsesPreview) {
maybeWriteResponsesCapturedPreview(capturedResponsesPreview, upstreamResponse.headers, now.getTime());
}
const outputChunks = codexResponsesSseState
? drainResponsesSseForCodex(
codexResponsesSseState,
value,
requestEntry.response_id || null,
{ suppressLifecycleEvents: Boolean(capturedResponsesPreview?.started) },
)
: [chunkBuffer];
@@ -5377,7 +5256,6 @@ async function executeGatewayQuery({
captureOnly = false,
persistEntry = true,
externalAbortSignals = [],
capturedResponsesPreview = null,
}) {
const maxUpstreamAttempts = normalizePositiveInteger(
config.upstream_fetch_retry_attempts,
@@ -5481,7 +5359,6 @@ async function executeGatewayQuery({
requestAbortSignal: queryAbortLink.controller.signal,
captureOnly,
persistEntry,
capturedResponsesPreview,
})
: await handleNonStreaming({
runtime,
@@ -5710,7 +5587,6 @@ async function runResponsesReasoningRetry({
monitor,
pathname,
req,
res,
requestBody,
requestIsStream,
upstreamUrl,
@@ -5721,9 +5597,6 @@ async function runResponsesReasoningRetry({
let round = 1;
let totalUpstreamAttempts = 0;
const retryExtraState = createReasoningRetryExtraState();
const capturedResponsesPreview = requestIsStream && res && isResponsesReasoningRetryPath(pathname)
? createResponsesCapturedPreview(res)
: null;
while (true) {
if (clientAbortSignal?.aborted) {
@@ -5766,7 +5639,6 @@ async function runResponsesReasoningRetry({
upstreamAuth,
requestEntry: attemptEntry,
externalAbortSignals: [clientAbortSignal, slotController.signal],
capturedResponsesPreview,
});
return new Promise((resolve, reject) => {
let settled = false;
@@ -5852,7 +5724,6 @@ async function runResponsesReasoningRetry({
...winner.result,
response_stream: winner.result.response_stream,
total_upstream_attempts: totalUpstreamAttempts,
responses_capture_preview_started: Boolean(capturedResponsesPreview?.started),
};
}
@@ -5875,18 +5746,8 @@ async function runResponsesReasoningRetry({
requestEntry.reasoning_retry_stop_reason = fatalOutcome.result?.match_reason || "fatal";
return {
...fatalOutcome.result,
delivery: capturedResponsesPreview?.started
? buildResponsesFailedSseDelivery(
fatalOutcome.result?.error || "reasoning retry fatal stream failure",
{
responseId: requestEntry.response_id || fatalOutcome.result?.response_id || null,
code: "reasoning_retry_failed",
},
)
: fatalOutcome.result?.delivery,
response_stream: fatalOutcome.result.response_stream,
total_upstream_attempts: totalUpstreamAttempts,
responses_capture_preview_started: Boolean(capturedResponsesPreview?.started),
};
}
@@ -5899,22 +5760,16 @@ async function runResponsesReasoningRetry({
return {
inspected: false,
matched: false,
status_code: capturedResponsesPreview?.started ? 200 : 502,
status_code: 502,
upstream_status_code: null,
error: "reasoning retry ended without a successful response",
response_stream: requestIsStream,
total_upstream_attempts: totalUpstreamAttempts,
delivery: capturedResponsesPreview?.started
? buildResponsesFailedSseDelivery("reasoning retry ended without a successful response", {
responseId: requestEntry.response_id || null,
code: "reasoning_retry_exhausted",
})
: buildCapturedDelivery(
delivery: buildCapturedDelivery(
502,
new Headers({ "content-type": "application/json; charset=utf-8" }),
buildGatewayErrorBody("reasoning retry ended without a successful response"),
),
responses_capture_preview_started: Boolean(capturedResponsesPreview?.started),
};
}
}
@@ -6014,7 +5869,6 @@ async function proxyRequest(runtime, req, res) {
monitor: runtime.monitor,
pathname,
req,
res,
requestBody,
requestIsStream,
upstreamUrl,
@@ -6041,13 +5895,10 @@ async function proxyRequest(runtime, req, res) {
const {
delivery,
total_upstream_attempts: observedUpstreamAttempts,
responses_capture_preview_started: responsesCapturePreviewStarted,
...resultFields
} = result;
if (delivery && !res.headersSent) {
await writeCapturedResponse(res, delivery);
} else if (delivery && responsesCapturePreviewStarted && !res.writableEnded) {
await appendCapturedResponse(res, delivery);
}
if (Number.isInteger(observedUpstreamAttempts)) {
requestEntry.upstream_attempt_count = observedUpstreamAttempts;
+72 -34
View File
@@ -563,6 +563,30 @@ async function readSseUntilClose(url, requestBody) {
};
}
function parseSseEvents(text) {
return `${text || ""}`
.split(/\r?\n\r?\n/)
.filter((block) => block.trim())
.map((block) => {
const lines = block.split(/\r?\n/);
const eventName = lines
.filter((line) => line.startsWith("event:"))
.map((line) => line.replace(/^event:\s?/, "").trim())
.find(Boolean) || "";
const payloadText = lines
.filter((line) => line.startsWith("data:"))
.map((line) => line.replace(/^data:\s?/, ""))
.join("\n");
let payload = null;
try {
payload = JSON.parse(payloadText);
} catch {
// [DONE] and malformed test payloads intentionally remain raw.
}
return { eventName, payloadText, payload };
});
}
async function run() {
assert(
JSON.stringify(normalizePhraseArray("a\\nb", [])) === JSON.stringify(["a", "b"]),
@@ -1611,43 +1635,57 @@ async function run() {
}
}
let previewResolved = false;
const previewFetchPromise = fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
const capturedLifecycleStream = await readSseUntilClose(
`http://127.0.0.1:${gatewayPort}/responses`,
{
stream: true,
thread_id: "thread-preview",
thread_id: "thread-captured-lifecycle",
test_reasoning_tokens: 128,
test_stream_include_lifecycle: true,
test_stream_lifecycle_marker: "preview-marker",
test_stream_delta_chunks: 4,
test_stream_chunk_delay_ms: 320,
}),
}).then((response) => {
previewResolved = true;
return response;
});
await new Promise((resolve) => setTimeout(resolve, 1350));
assert(previewResolved, "responses thread 重打预览未在首个上游 chunk 后尽早返回头部");
const previewResponse = await previewFetchPromise;
assert(previewResponse.status === 200, `responses thread 重打预览状态异常: ${previewResponse.status}`);
const previewReader = previewResponse.body.getReader();
const previewDecoder = new TextDecoder();
const firstPreviewRead = await previewReader.read();
const firstPreviewText = previewDecoder.decode(firstPreviewRead.value || new Uint8Array(), { stream: true });
assert(firstPreviewText.includes('"type":"response.created"'), "responses thread 重打预览未先发 response.created");
let previewText = firstPreviewText;
while (true) {
const { done, value } = await previewReader.read();
if (done) {
break;
}
previewText += previewDecoder.decode(value, { stream: true });
}
previewText += previewDecoder.decode();
assert(previewText.includes('"delta":"hello-1"'), "responses thread 重打预览未回放最终 delta");
assert(!previewText.includes("preview-marker"), "responses thread 重打预览不应透传巨大的 lifecycle 原始 payload");
test_stream_lifecycle_marker: "captured-lifecycle-marker",
test_stream_delta_chunks: 64,
test_stream_delta_text: "captured-chunk",
test_stream_chunk_delay_ms: 2,
},
);
assert(capturedLifecycleStream.status === 200, `/responses 捕获回放状态异常: ${capturedLifecycleStream.status}`);
assert(capturedLifecycleStream.readCount > 1, "/responses 捕获回放不应退化为单块响应");
const capturedEvents = parseSseEvents(capturedLifecycleStream.text);
const firstReplayEvent = capturedEvents.find((event) => event.payload);
assert(
firstReplayEvent?.payload?.type === "response.created" &&
firstReplayEvent.payload.response?.id === "resp_stream",
"/responses 捕获回放的首个生命周期事件必须带真实 response ID",
);
const lifecycleEvents = capturedEvents.filter((event) => [
"response.created",
"response.in_progress",
"response.completed",
].includes(event.payload?.type));
assert(lifecycleEvents.length === 3, "/responses 捕获回放不应注入额外生命周期事件");
assert(
lifecycleEvents.every((event) => event.payload?.response?.id === "resp_stream"),
"/responses 捕获回放不应发送缺少真实 response ID 的生命周期事件",
);
assert(
lifecycleEvents.map((event) => event.payload.type).join(",") ===
"response.created,response.in_progress,response.completed",
"/responses 捕获回放未保留生命周期顺序",
);
const capturedDeltas = capturedEvents
.filter((event) => event.payload?.type === "response.output_text.delta")
.map((event) => event.payload.delta);
assert(capturedDeltas.length === 64, "/responses 捕获回放未保留全部长流 delta");
assert(
capturedDeltas.every((delta, index) => delta === `captured-chunk-${index + 1}`),
"/responses 捕获回放的长流 delta 顺序不完整",
);
const completedIndex = capturedEvents.findIndex((event) => event.payload?.type === "response.completed");
const lastDeltaIndex = capturedEvents
.map((event) => event.payload?.type)
.lastIndexOf("response.output_text.delta");
assert(completedIndex > lastDeltaIndex, "/responses 捕获回放未在所有 delta 后终止");
assert(!capturedLifecycleStream.text.includes("captured-lifecycle-marker"), "/responses lifecycle 归一化仍透传了巨大的生命周期原始 payload");
const normalizedLifecycleStream = await readSseUntilClose(
`http://127.0.0.1:${gatewayPort}/responses`,