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`
|
- 上游若返回明确的容量错误(默认匹配错误文案 `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`
|
||||||
- 流式成功响应在严格检查模式下仍会先缓存完成,但会按分块 SSE 回放给 Codex,而不是单次大包 `res.end(...)`
|
- 流式成功响应在严格检查模式下仍会先缓存完成;成功后会按真实且规范化的 Responses 生命周期与输出顺序逐块回放给 Codex,每个 SSE 块之间让出一次事件循环,不会伪造缺少 response ID 的生命周期事件
|
||||||
- 默认同时拦截 root 路径和 `/v1` 路径:
|
- 默认同时拦截 root 路径和 `/v1` 路径:
|
||||||
- `/responses`
|
- `/responses`
|
||||||
- `/chat/completions`
|
- `/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`
|
- `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.` 仍能按网关策略自动重试
|
- 容量错误 `Selected model is at capacity. Please try a different model.` 仍能按网关策略自动重试
|
||||||
|
|
||||||
如果当前对话依赖本机 `4610`,不要把本机作为首个发布目标;先在另一台已接管相同 profile 的机器验证,再回到本机切换。
|
如果当前对话依赖本机 `4610`,不要把本机作为首个发布目标;先在另一台已接管相同 profile 的机器验证,再回到本机切换。
|
||||||
@@ -395,6 +395,9 @@ macOS / Linux: ~/.codex-retry-gateway
|
|||||||
- `test-gateway-e2e.ps1`
|
- `test-gateway-e2e.ps1`
|
||||||
- 已通过
|
- 已通过
|
||||||
- 验证 `/responses`、`/chat/completions`、`/v1/responses`、`/v1/chat/completions`
|
- 验证 `/responses`、`/chat/completions`、`/v1/responses`、`/v1/chat/completions`
|
||||||
|
- `node scripts/test-gateway-e2e.mjs`
|
||||||
|
- 已通过
|
||||||
|
- 验证 strict capture 下 `/responses` 生命周期不注入空事件,长流 delta 按序完整回放并以 `response.completed` 结束
|
||||||
- `test-install-restore.ps1`
|
- `test-install-restore.ps1`
|
||||||
- 已通过
|
- 已通过
|
||||||
- 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环
|
- 验证安装、透传、UI 页面、热更新配置、实时日志、516 统计、恢复闭环
|
||||||
|
|||||||
+8
-157
@@ -538,13 +538,7 @@ function buildSseBlock(eventName, payloadText) {
|
|||||||
return Buffer.from(`${lines.join("\n")}\n\n`);
|
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_MODEL_HEADER_NAMES = new Set(["openai-model", "x-openai-model"]);
|
||||||
const RESPONSES_CAPTURE_PREVIEW_HEARTBEAT_MS = 2000;
|
|
||||||
|
|
||||||
function normalizeResponsesHeaderSubset(...sources) {
|
function normalizeResponsesHeaderSubset(...sources) {
|
||||||
const headers = {};
|
const headers = {};
|
||||||
@@ -562,95 +556,6 @@ function normalizeResponsesHeaderSubset(...sources) {
|
|||||||
return Object.keys(headers).length > 0 ? headers : null;
|
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() {
|
function createEmptySseInspectionResult() {
|
||||||
return {
|
return {
|
||||||
reasoning: null,
|
reasoning: null,
|
||||||
@@ -845,7 +750,7 @@ function normalizeResponsesCompletedPayloadForCodex(parsed, eventName = "", fall
|
|||||||
return normalizeResponsesLifecyclePayloadForCodex(parsed, eventName, fallbackResponseId);
|
return normalizeResponsesLifecyclePayloadForCodex(parsed, eventName, fallbackResponseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function processResponsesSseBlockForCodex(state, blockText, fallbackResponseId = null, options = {}) {
|
function processResponsesSseBlockForCodex(state, blockText, fallbackResponseId = null) {
|
||||||
const lines = `${blockText || ""}`
|
const lines = `${blockText || ""}`
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trimEnd());
|
.map((line) => line.trimEnd());
|
||||||
@@ -905,37 +810,31 @@ function processResponsesSseBlockForCodex(state, blockText, fallbackResponseId =
|
|||||||
state.saw_terminal_failure = true;
|
state.saw_terminal_failure = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
options?.suppressLifecycleEvents &&
|
|
||||||
RESPONSES_PREVIEW_SUPPRESSED_EVENT_NAMES.has(outputEventName)
|
|
||||||
) {
|
|
||||||
return Buffer.alloc(0);
|
|
||||||
}
|
|
||||||
if (!rewritten) {
|
if (!rewritten) {
|
||||||
return Buffer.from(`${lines.join("\n")}\n\n`);
|
return Buffer.from(`${lines.join("\n")}\n\n`);
|
||||||
}
|
}
|
||||||
return buildSseBlock(outputEventName, JSON.stringify(outputPayload));
|
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 });
|
const decoded = state.decoder.decode(chunk, { stream: true });
|
||||||
state.buffer += decoded;
|
state.buffer += decoded;
|
||||||
const blocks = state.buffer.split(/\r?\n\r?\n/);
|
const blocks = state.buffer.split(/\r?\n\r?\n/);
|
||||||
state.buffer = blocks.pop() ?? "";
|
state.buffer = blocks.pop() ?? "";
|
||||||
return blocks
|
return blocks
|
||||||
.filter((block) => block.length > 0)
|
.filter((block) => block.length > 0)
|
||||||
.map((block) => processResponsesSseBlockForCodex(state, block, fallbackResponseId, options))
|
.map((block) => processResponsesSseBlockForCodex(state, block, fallbackResponseId))
|
||||||
.filter((chunkBuffer) => chunkBuffer && chunkBuffer.length > 0);
|
.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();
|
const flushed = state.decoder.decode();
|
||||||
if (flushed) {
|
if (flushed) {
|
||||||
state.buffer += flushed;
|
state.buffer += flushed;
|
||||||
}
|
}
|
||||||
const outputs = [];
|
const outputs = [];
|
||||||
if (state.buffer.trim()) {
|
if (state.buffer.trim()) {
|
||||||
outputs.push(processResponsesSseBlockForCodex(state, state.buffer, fallbackResponseId, options));
|
outputs.push(processResponsesSseBlockForCodex(state, state.buffer, fallbackResponseId));
|
||||||
}
|
}
|
||||||
state.buffer = "";
|
state.buffer = "";
|
||||||
|
|
||||||
@@ -4180,7 +4079,7 @@ function cloneResponseHeaders(sourceHeaders) {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CAPTURED_STREAM_REPLAY_CHUNKS_PER_TICK = 16;
|
const CAPTURED_STREAM_REPLAY_CHUNKS_PER_TICK = 1;
|
||||||
|
|
||||||
function cloneBufferList(chunks) {
|
function cloneBufferList(chunks) {
|
||||||
if (!Array.isArray(chunks) || chunks.length === 0) {
|
if (!Array.isArray(chunks) || chunks.length === 0) {
|
||||||
@@ -4244,20 +4143,6 @@ async function writeCapturedResponse(res, delivery) {
|
|||||||
res.end(delivery.body);
|
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 = {}) {
|
function buildCapturedDelivery(statusCode, headers, body, options = {}) {
|
||||||
const delivery = {
|
const delivery = {
|
||||||
status_code: statusCode,
|
status_code: statusCode,
|
||||||
@@ -4933,7 +4818,6 @@ async function handleStreaming({
|
|||||||
requestAbortSignal = null,
|
requestAbortSignal = null,
|
||||||
captureOnly = false,
|
captureOnly = false,
|
||||||
persistEntry = true,
|
persistEntry = true,
|
||||||
capturedResponsesPreview = null,
|
|
||||||
}) {
|
}) {
|
||||||
const strict502Mode = captureOnly || config.stream_action !== "disconnect";
|
const strict502Mode = captureOnly || config.stream_action !== "disconnect";
|
||||||
const reader = upstreamResponse.body.getReader();
|
const reader = upstreamResponse.body.getReader();
|
||||||
@@ -5056,7 +4940,6 @@ async function handleStreaming({
|
|||||||
codexResponsesSseState,
|
codexResponsesSseState,
|
||||||
requestEntry.response_id || null,
|
requestEntry.response_id || null,
|
||||||
observedUsage,
|
observedUsage,
|
||||||
{ suppressLifecycleEvents: Boolean(capturedResponsesPreview?.started) },
|
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
if (Number.isInteger(finalInspection.reasoning)) {
|
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
|
const outputChunks = codexResponsesSseState
|
||||||
? drainResponsesSseForCodex(
|
? drainResponsesSseForCodex(
|
||||||
codexResponsesSseState,
|
codexResponsesSseState,
|
||||||
value,
|
value,
|
||||||
requestEntry.response_id || null,
|
requestEntry.response_id || null,
|
||||||
{ suppressLifecycleEvents: Boolean(capturedResponsesPreview?.started) },
|
|
||||||
)
|
)
|
||||||
: [chunkBuffer];
|
: [chunkBuffer];
|
||||||
|
|
||||||
@@ -5377,7 +5256,6 @@ async function executeGatewayQuery({
|
|||||||
captureOnly = false,
|
captureOnly = false,
|
||||||
persistEntry = true,
|
persistEntry = true,
|
||||||
externalAbortSignals = [],
|
externalAbortSignals = [],
|
||||||
capturedResponsesPreview = null,
|
|
||||||
}) {
|
}) {
|
||||||
const maxUpstreamAttempts = normalizePositiveInteger(
|
const maxUpstreamAttempts = normalizePositiveInteger(
|
||||||
config.upstream_fetch_retry_attempts,
|
config.upstream_fetch_retry_attempts,
|
||||||
@@ -5481,7 +5359,6 @@ async function executeGatewayQuery({
|
|||||||
requestAbortSignal: queryAbortLink.controller.signal,
|
requestAbortSignal: queryAbortLink.controller.signal,
|
||||||
captureOnly,
|
captureOnly,
|
||||||
persistEntry,
|
persistEntry,
|
||||||
capturedResponsesPreview,
|
|
||||||
})
|
})
|
||||||
: await handleNonStreaming({
|
: await handleNonStreaming({
|
||||||
runtime,
|
runtime,
|
||||||
@@ -5710,7 +5587,6 @@ async function runResponsesReasoningRetry({
|
|||||||
monitor,
|
monitor,
|
||||||
pathname,
|
pathname,
|
||||||
req,
|
req,
|
||||||
res,
|
|
||||||
requestBody,
|
requestBody,
|
||||||
requestIsStream,
|
requestIsStream,
|
||||||
upstreamUrl,
|
upstreamUrl,
|
||||||
@@ -5721,9 +5597,6 @@ async function runResponsesReasoningRetry({
|
|||||||
let round = 1;
|
let round = 1;
|
||||||
let totalUpstreamAttempts = 0;
|
let totalUpstreamAttempts = 0;
|
||||||
const retryExtraState = createReasoningRetryExtraState();
|
const retryExtraState = createReasoningRetryExtraState();
|
||||||
const capturedResponsesPreview = requestIsStream && res && isResponsesReasoningRetryPath(pathname)
|
|
||||||
? createResponsesCapturedPreview(res)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (clientAbortSignal?.aborted) {
|
if (clientAbortSignal?.aborted) {
|
||||||
@@ -5766,7 +5639,6 @@ async function runResponsesReasoningRetry({
|
|||||||
upstreamAuth,
|
upstreamAuth,
|
||||||
requestEntry: attemptEntry,
|
requestEntry: attemptEntry,
|
||||||
externalAbortSignals: [clientAbortSignal, slotController.signal],
|
externalAbortSignals: [clientAbortSignal, slotController.signal],
|
||||||
capturedResponsesPreview,
|
|
||||||
});
|
});
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -5852,7 +5724,6 @@ async function runResponsesReasoningRetry({
|
|||||||
...winner.result,
|
...winner.result,
|
||||||
response_stream: winner.result.response_stream,
|
response_stream: winner.result.response_stream,
|
||||||
total_upstream_attempts: totalUpstreamAttempts,
|
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";
|
requestEntry.reasoning_retry_stop_reason = fatalOutcome.result?.match_reason || "fatal";
|
||||||
return {
|
return {
|
||||||
...fatalOutcome.result,
|
...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,
|
response_stream: fatalOutcome.result.response_stream,
|
||||||
total_upstream_attempts: totalUpstreamAttempts,
|
total_upstream_attempts: totalUpstreamAttempts,
|
||||||
responses_capture_preview_started: Boolean(capturedResponsesPreview?.started),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5899,22 +5760,16 @@ async function runResponsesReasoningRetry({
|
|||||||
return {
|
return {
|
||||||
inspected: false,
|
inspected: false,
|
||||||
matched: false,
|
matched: false,
|
||||||
status_code: capturedResponsesPreview?.started ? 200 : 502,
|
status_code: 502,
|
||||||
upstream_status_code: null,
|
upstream_status_code: null,
|
||||||
error: "reasoning retry ended without a successful response",
|
error: "reasoning retry ended without a successful response",
|
||||||
response_stream: requestIsStream,
|
response_stream: requestIsStream,
|
||||||
total_upstream_attempts: totalUpstreamAttempts,
|
total_upstream_attempts: totalUpstreamAttempts,
|
||||||
delivery: capturedResponsesPreview?.started
|
delivery: buildCapturedDelivery(
|
||||||
? buildResponsesFailedSseDelivery("reasoning retry ended without a successful response", {
|
|
||||||
responseId: requestEntry.response_id || null,
|
|
||||||
code: "reasoning_retry_exhausted",
|
|
||||||
})
|
|
||||||
: buildCapturedDelivery(
|
|
||||||
502,
|
502,
|
||||||
new Headers({ "content-type": "application/json; charset=utf-8" }),
|
new Headers({ "content-type": "application/json; charset=utf-8" }),
|
||||||
buildGatewayErrorBody("reasoning retry ended without a successful response"),
|
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,
|
monitor: runtime.monitor,
|
||||||
pathname,
|
pathname,
|
||||||
req,
|
req,
|
||||||
res,
|
|
||||||
requestBody,
|
requestBody,
|
||||||
requestIsStream,
|
requestIsStream,
|
||||||
upstreamUrl,
|
upstreamUrl,
|
||||||
@@ -6041,13 +5895,10 @@ async function proxyRequest(runtime, req, res) {
|
|||||||
const {
|
const {
|
||||||
delivery,
|
delivery,
|
||||||
total_upstream_attempts: observedUpstreamAttempts,
|
total_upstream_attempts: observedUpstreamAttempts,
|
||||||
responses_capture_preview_started: responsesCapturePreviewStarted,
|
|
||||||
...resultFields
|
...resultFields
|
||||||
} = result;
|
} = result;
|
||||||
if (delivery && !res.headersSent) {
|
if (delivery && !res.headersSent) {
|
||||||
await writeCapturedResponse(res, delivery);
|
await writeCapturedResponse(res, delivery);
|
||||||
} else if (delivery && responsesCapturePreviewStarted && !res.writableEnded) {
|
|
||||||
await appendCapturedResponse(res, delivery);
|
|
||||||
}
|
}
|
||||||
if (Number.isInteger(observedUpstreamAttempts)) {
|
if (Number.isInteger(observedUpstreamAttempts)) {
|
||||||
requestEntry.upstream_attempt_count = 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() {
|
async function run() {
|
||||||
assert(
|
assert(
|
||||||
JSON.stringify(normalizePhraseArray("a\\nb", [])) === JSON.stringify(["a", "b"]),
|
JSON.stringify(normalizePhraseArray("a\\nb", [])) === JSON.stringify(["a", "b"]),
|
||||||
@@ -1611,43 +1635,57 @@ async function run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let previewResolved = false;
|
const capturedLifecycleStream = await readSseUntilClose(
|
||||||
const previewFetchPromise = fetch(`http://127.0.0.1:${gatewayPort}/responses`, {
|
`http://127.0.0.1:${gatewayPort}/responses`,
|
||||||
method: "POST",
|
{
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
stream: true,
|
stream: true,
|
||||||
thread_id: "thread-preview",
|
thread_id: "thread-captured-lifecycle",
|
||||||
test_reasoning_tokens: 128,
|
test_reasoning_tokens: 128,
|
||||||
test_stream_include_lifecycle: true,
|
test_stream_include_lifecycle: true,
|
||||||
test_stream_lifecycle_marker: "preview-marker",
|
test_stream_lifecycle_marker: "captured-lifecycle-marker",
|
||||||
test_stream_delta_chunks: 4,
|
test_stream_delta_chunks: 64,
|
||||||
test_stream_chunk_delay_ms: 320,
|
test_stream_delta_text: "captured-chunk",
|
||||||
}),
|
test_stream_chunk_delay_ms: 2,
|
||||||
}).then((response) => {
|
},
|
||||||
previewResolved = true;
|
);
|
||||||
return response;
|
assert(capturedLifecycleStream.status === 200, `/responses 捕获回放状态异常: ${capturedLifecycleStream.status}`);
|
||||||
});
|
assert(capturedLifecycleStream.readCount > 1, "/responses 捕获回放不应退化为单块响应");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1350));
|
const capturedEvents = parseSseEvents(capturedLifecycleStream.text);
|
||||||
assert(previewResolved, "responses thread 重打预览未在首个上游 chunk 后尽早返回头部");
|
const firstReplayEvent = capturedEvents.find((event) => event.payload);
|
||||||
const previewResponse = await previewFetchPromise;
|
assert(
|
||||||
assert(previewResponse.status === 200, `responses thread 重打预览状态异常: ${previewResponse.status}`);
|
firstReplayEvent?.payload?.type === "response.created" &&
|
||||||
const previewReader = previewResponse.body.getReader();
|
firstReplayEvent.payload.response?.id === "resp_stream",
|
||||||
const previewDecoder = new TextDecoder();
|
"/responses 捕获回放的首个生命周期事件必须带真实 response ID",
|
||||||
const firstPreviewRead = await previewReader.read();
|
);
|
||||||
const firstPreviewText = previewDecoder.decode(firstPreviewRead.value || new Uint8Array(), { stream: true });
|
const lifecycleEvents = capturedEvents.filter((event) => [
|
||||||
assert(firstPreviewText.includes('"type":"response.created"'), "responses thread 重打预览未先发 response.created");
|
"response.created",
|
||||||
let previewText = firstPreviewText;
|
"response.in_progress",
|
||||||
while (true) {
|
"response.completed",
|
||||||
const { done, value } = await previewReader.read();
|
].includes(event.payload?.type));
|
||||||
if (done) {
|
assert(lifecycleEvents.length === 3, "/responses 捕获回放不应注入额外生命周期事件");
|
||||||
break;
|
assert(
|
||||||
}
|
lifecycleEvents.every((event) => event.payload?.response?.id === "resp_stream"),
|
||||||
previewText += previewDecoder.decode(value, { stream: true });
|
"/responses 捕获回放不应发送缺少真实 response ID 的生命周期事件",
|
||||||
}
|
);
|
||||||
previewText += previewDecoder.decode();
|
assert(
|
||||||
assert(previewText.includes('"delta":"hello-1"'), "responses thread 重打预览未回放最终 delta");
|
lifecycleEvents.map((event) => event.payload.type).join(",") ===
|
||||||
assert(!previewText.includes("preview-marker"), "responses thread 重打预览不应透传巨大的 lifecycle 原始 payload");
|
"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(
|
const normalizedLifecycleStream = await readSseUntilClose(
|
||||||
`http://127.0.0.1:${gatewayPort}/responses`,
|
`http://127.0.0.1:${gatewayPort}/responses`,
|
||||||
|
|||||||
Reference in New Issue
Block a user