fix: pace captured SSE replay

This commit is contained in:
2026-07-11 13:16:40 +08:00
parent 7d0a40e1ea
commit 29eb91e0c3
3 changed files with 78 additions and 16 deletions
+1 -1
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` - 上游若返回明确的容量错误(默认匹配错误文案 `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` 但返回体本质是错误、以及流式失败事件里携带同样文案的情况 - 除了 `429/503` JSON 错误响应,也会识别 `200` 但返回体本质是错误、以及流式失败事件里携带同样文案的情况
- 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502` - 流式命中时默认先缓存并判断;一旦命中 `516`,统一返回 `502`
- 流式成功响应在严格检查模式下仍会先缓存完成;成功后会按真实且规范化的 Responses 生命周期与输出顺序逐块回放给 Codex,每个 SSE 块之间让出一次事件循环,不会伪造缺少 response ID 的生命周期事件 - 流式成功响应在严格检查模式下仍会先缓存完成;成功后会按真实且规范化的 Responses 生命周期与输出顺序逐块回放给 Codex,每个 SSE 块之间至少间隔 5ms,并关闭 TCP 小包聚合,避免大量 delta 在同一事件循环突发到达;不会伪造缺少 response ID 的生命周期事件
- 默认同时拦截 root 路径和 `/v1` 路径: - 默认同时拦截 root 路径和 `/v1` 路径:
- `/responses` - `/responses`
- `/chat/completions` - `/chat/completions`
+15 -11
View File
@@ -4079,7 +4079,7 @@ function cloneResponseHeaders(sourceHeaders) {
return headers; return headers;
} }
const CAPTURED_STREAM_REPLAY_CHUNKS_PER_TICK = 1; const CAPTURED_STREAM_REPLAY_DELAY_MS = 5;
function cloneBufferList(chunks) { function cloneBufferList(chunks) {
if (!Array.isArray(chunks) || chunks.length === 0) { if (!Array.isArray(chunks) || chunks.length === 0) {
@@ -4091,13 +4091,16 @@ function cloneBufferList(chunks) {
} }
async function writeBufferedStreamChunks(res, chunks) { async function writeBufferedStreamChunks(res, chunks) {
let writesSinceYield = 0; const replayChunks = (chunks || []).filter((chunk) => chunk && chunk.length > 0);
for (const chunk of chunks || []) { res.socket?.setNoDelay(true);
if (!chunk || chunk.length === 0 || res.destroyed || res.writableEnded) { res.flushHeaders?.();
continue;
for (let index = 0; index < replayChunks.length; index += 1) {
if (res.destroyed || res.writableEnded) {
break;
} }
const chunk = replayChunks[index];
const accepted = res.write(chunk); const accepted = res.write(chunk);
writesSinceYield += 1;
if (!accepted) { if (!accepted) {
await new Promise((resolve) => { await new Promise((resolve) => {
let settled = false; let settled = false;
@@ -4117,12 +4120,13 @@ async function writeBufferedStreamChunks(res, chunks) {
res.once("close", onClose); res.once("close", onClose);
res.once("error", onClose); res.once("error", onClose);
}); });
writesSinceYield = 0;
continue;
} }
if (writesSinceYield >= CAPTURED_STREAM_REPLAY_CHUNKS_PER_TICK) { if (
writesSinceYield = 0; index < replayChunks.length - 1 &&
await new Promise((resolve) => setImmediate(resolve)); !res.destroyed &&
!res.writableEnded
) {
await sleep(CAPTURED_STREAM_REPLAY_DELAY_MS);
} }
} }
} }
+61 -3
View File
@@ -303,6 +303,7 @@ function startFakeUpstream(port, options = {}) {
? parsed.test_stream_delta_chunks ? parsed.test_stream_delta_chunks
: 1; : 1;
const deltaTextBase = parsed.test_stream_delta_text || "hello"; const deltaTextBase = parsed.test_stream_delta_text || "hello";
const outputItemId = "msg_stream";
const lifecyclePayloadExtra = parsed.test_stream_lifecycle_marker const lifecyclePayloadExtra = parsed.test_stream_lifecycle_marker
? { ? {
marker: parsed.test_stream_lifecycle_marker, marker: parsed.test_stream_lifecycle_marker,
@@ -329,17 +330,46 @@ function startFakeUpstream(port, options = {}) {
output: lifecyclePayloadExtra, output: lifecyclePayloadExtra,
}, },
})}\n\n`, })}\n\n`,
`data: ${JSON.stringify({
type: "response.output_item.added",
output_index: 0,
item: {
id: outputItemId,
type: "message",
status: "in_progress",
role: "assistant",
content: [],
},
})}\n\n`,
] ]
: []; : [];
const streamChunks = Array.from({ length: deltaChunkCount }, (_, index) => { const deltaTexts = Array.from({ length: deltaChunkCount }, (_, index) => {
const deltaText = deltaChunkCount === 1 ? deltaTextBase : `${deltaTextBase}-${index + 1}`; const deltaText = deltaChunkCount === 1 ? deltaTextBase : `${deltaTextBase}-${index + 1}`;
return `data: ${JSON.stringify({ type: "response.output_text.delta", delta: deltaText, response_id: "resp_stream", thread_id: parsed.thread_id || "thread_stream", retry_attempt: reasoningAttempt })}\n\n`; return deltaText;
});
const streamChunks = deltaTexts.map((deltaText) => {
return `data: ${JSON.stringify({ type: "response.output_text.delta", item_id: outputItemId, output_index: 0, content_index: 0, delta: deltaText, response_id: "resp_stream", thread_id: parsed.thread_id || "thread_stream", retry_attempt: reasoningAttempt })}\n\n`;
}); });
if (parsed.test_stream_include_lifecycle) { if (parsed.test_stream_include_lifecycle) {
streamChunks.unshift(...lifecycleChunks); streamChunks.unshift(...lifecycleChunks);
} }
if (parsed.test_stream_include_lifecycle) { if (parsed.test_stream_include_lifecycle) {
streamChunks.push( streamChunks.push(
`data: ${JSON.stringify({
type: "response.output_item.done",
output_index: 0,
item: {
id: outputItemId,
type: "message",
status: "completed",
role: "assistant",
content: [{
type: "output_text",
text: deltaTexts.join(""),
annotations: [],
}],
},
})}\n\n`,
`data: ${JSON.stringify({ `data: ${JSON.stringify({
type: "response.completed", type: "response.completed",
response: { response: {
@@ -553,6 +583,7 @@ async function readSseUntilClose(url, requestBody) {
} }
text += decoder.decode(); text += decoder.decode();
const elapsedMs = Date.now() - startedAt;
return { return {
status: response.status, status: response.status,
headers: response.headers, headers: response.headers,
@@ -560,6 +591,7 @@ async function readSseUntilClose(url, requestBody) {
closedByError, closedByError,
readCount, readCount,
firstChunkDelayMs, firstChunkDelayMs,
elapsedMs,
}; };
} }
@@ -1650,6 +1682,11 @@ async function run() {
); );
assert(capturedLifecycleStream.status === 200, `/responses 捕获回放状态异常: ${capturedLifecycleStream.status}`); assert(capturedLifecycleStream.status === 200, `/responses 捕获回放状态异常: ${capturedLifecycleStream.status}`);
assert(capturedLifecycleStream.readCount > 1, "/responses 捕获回放不应退化为单块响应"); assert(capturedLifecycleStream.readCount > 1, "/responses 捕获回放不应退化为单块响应");
const observedReplayDurationMs = capturedLifecycleStream.elapsedMs - capturedLifecycleStream.firstChunkDelayMs;
assert(
observedReplayDurationMs >= 250,
`/responses 捕获回放缺少真实传输间隔: ${observedReplayDurationMs}ms`,
);
const capturedEvents = parseSseEvents(capturedLifecycleStream.text); const capturedEvents = parseSseEvents(capturedLifecycleStream.text);
const firstReplayEvent = capturedEvents.find((event) => event.payload); const firstReplayEvent = capturedEvents.find((event) => event.payload);
assert( assert(
@@ -1680,11 +1717,32 @@ async function run() {
capturedDeltas.every((delta, index) => delta === `captured-chunk-${index + 1}`), capturedDeltas.every((delta, index) => delta === `captured-chunk-${index + 1}`),
"/responses 捕获回放的长流 delta 顺序不完整", "/responses 捕获回放的长流 delta 顺序不完整",
); );
const outputItemAddedIndex = capturedEvents.findIndex(
(event) => event.payload?.type === "response.output_item.added",
);
const outputItemDoneIndex = capturedEvents.findIndex(
(event) => event.payload?.type === "response.output_item.done",
);
const firstDeltaIndex = capturedEvents.findIndex(
(event) => event.payload?.type === "response.output_text.delta",
);
const completedIndex = capturedEvents.findIndex((event) => event.payload?.type === "response.completed"); const completedIndex = capturedEvents.findIndex((event) => event.payload?.type === "response.completed");
const lastDeltaIndex = capturedEvents const lastDeltaIndex = capturedEvents
.map((event) => event.payload?.type) .map((event) => event.payload?.type)
.lastIndexOf("response.output_text.delta"); .lastIndexOf("response.output_text.delta");
assert(completedIndex > lastDeltaIndex, "/responses 捕获回放未在所有 delta 后终止"); assert(
outputItemAddedIndex >= 0 && outputItemAddedIndex < firstDeltaIndex,
"/responses 捕获回放未在 delta 前保留 output_item.added",
);
assert(
outputItemDoneIndex > lastDeltaIndex && completedIndex > outputItemDoneIndex,
"/responses 捕获回放未按 delta、output_item.done、response.completed 顺序终止",
);
const completedOutputItem = capturedEvents[outputItemDoneIndex]?.payload?.item;
assert(
completedOutputItem?.content?.[0]?.text === capturedDeltas.join(""),
"/responses 捕获回放的 output_item.done 与全部 delta 内容不一致",
);
assert(!capturedLifecycleStream.text.includes("captured-lifecycle-marker"), "/responses lifecycle 归一化仍透传了巨大的生命周期原始 payload"); assert(!capturedLifecycleStream.text.includes("captured-lifecycle-marker"), "/responses lifecycle 归一化仍透传了巨大的生命周期原始 payload");
const normalizedLifecycleStream = await readSseUntilClose( const normalizedLifecycleStream = await readSseUntilClose(