fix: pace captured SSE replay
This commit is contained in:
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user