fix: preserve responses SSE lifecycle during 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`
|
||||
- 除了 `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
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user