fix(ai): retry safe truncated responses streams
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Retry one transient OpenAI Responses stream truncation before replay-unsafe output, preventing recoverable transport truncations from surfacing as failed turns ([#5908](https://github.com/can1357/oh-my-pi/issues/5908)).
|
||||||
|
|
||||||
## [17.0.3] - 2026-07-17
|
## [17.0.3] - 2026-07-17
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { scheduler } from "node:timers/promises";
|
||||||
import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts";
|
import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts";
|
||||||
import { $flag, logger, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
import { $flag, logger, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
||||||
import * as AIError from "../error";
|
import * as AIError from "../error";
|
||||||
@@ -154,6 +155,33 @@ const OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE =
|
|||||||
"OpenAI responses stream timed out while waiting for the first event";
|
"OpenAI responses stream timed out while waiting for the first event";
|
||||||
/** Consecutive stale-previous-response failures before chaining is disabled for the session. */
|
/** Consecutive stale-previous-response failures before chaining is disabled for the session. */
|
||||||
const OPENAI_RESPONSES_CHAIN_STALE_FAILURE_LIMIT = 3;
|
const OPENAI_RESPONSES_CHAIN_STALE_FAILURE_LIMIT = 3;
|
||||||
|
const OPENAI_RESPONSES_MAX_TRANSIENT_STREAM_RETRIES = 1;
|
||||||
|
const OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS = 500;
|
||||||
|
|
||||||
|
function isOpenAIResponsesReplayUnsafeEvent(event: ResponseStreamEvent): boolean {
|
||||||
|
switch (event.type) {
|
||||||
|
case "response.output_text.delta":
|
||||||
|
case "response.refusal.delta":
|
||||||
|
case "response.reasoning_summary_text.delta":
|
||||||
|
case "response.reasoning_text.delta":
|
||||||
|
case "response.function_call_arguments.delta":
|
||||||
|
case "response.custom_tool_call_input.delta":
|
||||||
|
return typeof event.delta === "string" && event.delta.length > 0;
|
||||||
|
case "response.reasoning_summary_part.done":
|
||||||
|
return true;
|
||||||
|
case "response.output_item.done":
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableOpenAIResponsesStreamFailure(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
AIError.isTransientStreamParseError(error) ||
|
||||||
|
(error instanceof AIError.ProviderResponseError && error.kind === "incomplete-stream")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface OpenAIResponsesProviderSessionState
|
interface OpenAIResponsesProviderSessionState
|
||||||
extends ProviderSessionState,
|
extends ProviderSessionState,
|
||||||
@@ -452,7 +480,7 @@ const streamOpenAIResponsesOnce = (
|
|||||||
return payload;
|
return payload;
|
||||||
};
|
};
|
||||||
chained = { ...chained, params: await applyPayloadReplacement(chained.params) };
|
chained = { ...chained, params: await applyPayloadReplacement(chained.params) };
|
||||||
rawRequestDump = {
|
const activeRawRequestDump: RawHttpRequestDump = {
|
||||||
provider: model.provider,
|
provider: model.provider,
|
||||||
api: output.api,
|
api: output.api,
|
||||||
model: model.id,
|
model: model.id,
|
||||||
@@ -460,6 +488,7 @@ const streamOpenAIResponsesOnce = (
|
|||||||
url: requestUrl,
|
url: requestUrl,
|
||||||
body: chained.params,
|
body: chained.params,
|
||||||
};
|
};
|
||||||
|
rawRequestDump = activeRawRequestDump;
|
||||||
const openResponsesStream = (requestParams: OpenAIResponsesSamplingParams) => {
|
const openResponsesStream = (requestParams: OpenAIResponsesSamplingParams) => {
|
||||||
activeReasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey(
|
activeReasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey(
|
||||||
"responses",
|
"responses",
|
||||||
@@ -507,10 +536,11 @@ const streamOpenAIResponsesOnce = (
|
|||||||
{ provider: model.provider, signal: requestSignal },
|
{ provider: model.provider, signal: requestSignal },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
let openaiStream: AsyncIterable<ResponseStreamEvent>;
|
|
||||||
let strictRetryAvailable = true;
|
let strictRetryAvailable = true;
|
||||||
let activeStrictToolsApplied = builtParams.strictToolsApplied;
|
let activeStrictToolsApplied = builtParams.strictToolsApplied;
|
||||||
let forceDisableStrictTools = false;
|
let forceDisableStrictTools = false;
|
||||||
|
const openResponsesStreamWithFallbacks = async (): Promise<AsyncIterable<ResponseStreamEvent>> => {
|
||||||
|
let openaiStream: AsyncIterable<ResponseStreamEvent>;
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
openaiStream = await openResponsesStream(chained.params);
|
openaiStream = await openResponsesStream(chained.params);
|
||||||
@@ -538,7 +568,7 @@ const streamOpenAIResponsesOnce = (
|
|||||||
requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback);
|
requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback);
|
||||||
applyOpenAIReasoningEffortFallback(chained.params, reasoningEffortFallback);
|
applyOpenAIReasoningEffortFallback(chained.params, reasoningEffortFallback);
|
||||||
applyOpenAIReasoningEffortFallback(activeParams, reasoningEffortFallback);
|
applyOpenAIReasoningEffortFallback(activeParams, reasoningEffortFallback);
|
||||||
rawRequestDump.body = chained.params;
|
activeRawRequestDump.body = chained.params;
|
||||||
pendingReasoningEffortFallback = {
|
pendingReasoningEffortFallback = {
|
||||||
key: activeReasoningEffortFallbackKey,
|
key: activeReasoningEffortFallbackKey,
|
||||||
fallback: reasoningEffortFallback,
|
fallback: reasoningEffortFallback,
|
||||||
@@ -582,7 +612,7 @@ const streamOpenAIResponsesOnce = (
|
|||||||
params: await applyPayloadReplacement(fallbackChained.params),
|
params: await applyPayloadReplacement(fallbackChained.params),
|
||||||
};
|
};
|
||||||
chained = fallbackChained;
|
chained = fallbackChained;
|
||||||
rawRequestDump.body = chained.params;
|
activeRawRequestDump.body = chained.params;
|
||||||
activeParams = fallbackParams;
|
activeParams = fallbackParams;
|
||||||
activeStrictToolsApplied = fallbackBuilt.strictToolsApplied;
|
activeStrictToolsApplied = fallbackBuilt.strictToolsApplied;
|
||||||
continue;
|
continue;
|
||||||
@@ -628,16 +658,29 @@ const streamOpenAIResponsesOnce = (
|
|||||||
currentParams.store = !zdrRejection;
|
currentParams.store = !zdrRejection;
|
||||||
const retryParams = await applyPayloadReplacement(currentParams);
|
const retryParams = await applyPayloadReplacement(currentParams);
|
||||||
chained = { params: retryParams };
|
chained = { params: retryParams };
|
||||||
rawRequestDump.body = retryParams;
|
activeRawRequestDump.body = retryParams;
|
||||||
activeParams = currentParams;
|
activeParams = currentParams;
|
||||||
activeStrictToolsApplied = currentBuilt.strictToolsApplied;
|
activeStrictToolsApplied = currentBuilt.strictToolsApplied;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return openaiStream;
|
||||||
|
};
|
||||||
|
let openaiStream = await openResponsesStreamWithFallbacks();
|
||||||
if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
|
if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
|
||||||
stream.push({ type: "start", partial: output });
|
stream.push({ type: "start", partial: output });
|
||||||
|
|
||||||
const nativeOutputItems: Array<Record<string, unknown>> = [];
|
const nativeOutputItems: Array<Record<string, unknown>> = [];
|
||||||
|
let transientStreamRetryAttempt = 0;
|
||||||
|
while (true) {
|
||||||
|
let sawReplayUnsafeOutput = false;
|
||||||
let sawTerminalResponseEvent = false;
|
let sawTerminalResponseEvent = false;
|
||||||
|
const attemptStream = new AssistantMessageEventStream();
|
||||||
|
let forwardAttemptLive = false;
|
||||||
|
const forwardAttemptEvents = () => {
|
||||||
|
for (const event of attemptStream.queue) stream.push(event);
|
||||||
|
attemptStream.queue.length = 0;
|
||||||
|
};
|
||||||
|
nativeOutputItems.length = 0;
|
||||||
const timedOpenaiStream = iterateWithIdleTimeout(openaiStream, {
|
const timedOpenaiStream = iterateWithIdleTimeout(openaiStream, {
|
||||||
idleTimeoutMs,
|
idleTimeoutMs,
|
||||||
firstItemTimeoutMs: firstEventTimeoutMs,
|
firstItemTimeoutMs: firstEventTimeoutMs,
|
||||||
@@ -648,7 +691,22 @@ const streamOpenAIResponsesOnce = (
|
|||||||
abortSignal: options?.signal,
|
abortSignal: options?.signal,
|
||||||
isProgressItem: isOpenAIResponsesProgressEvent,
|
isProgressItem: isOpenAIResponsesProgressEvent,
|
||||||
});
|
});
|
||||||
await processResponsesStream(timedOpenaiStream, output, stream, model, {
|
const observedOpenaiStream = (async function* (): AsyncGenerator<ResponseStreamEvent> {
|
||||||
|
for await (const event of timedOpenaiStream) {
|
||||||
|
if (isOpenAIResponsesReplayUnsafeEvent(event)) {
|
||||||
|
sawReplayUnsafeOutput = true;
|
||||||
|
if (!forwardAttemptLive) {
|
||||||
|
forwardAttemptEvents();
|
||||||
|
forwardAttemptLive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield event;
|
||||||
|
if (forwardAttemptLive) forwardAttemptEvents();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await processResponsesStream(observedOpenaiStream, output, attemptStream, model, {
|
||||||
onFirstToken: () => {
|
onFirstToken: () => {
|
||||||
if (!firstTokenTime) firstTokenTime = performance.now();
|
if (!firstTokenTime) firstTokenTime = performance.now();
|
||||||
},
|
},
|
||||||
@@ -664,18 +722,11 @@ const streamOpenAIResponsesOnce = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const localAbortReason = abortTracker.getLocalAbortReason();
|
const localAbortReason = abortTracker.getLocalAbortReason();
|
||||||
if (localAbortReason) {
|
if (localAbortReason) throw localAbortReason;
|
||||||
throw localAbortReason;
|
if (abortTracker.wasCallerAbort()) throw new AIError.AbortError();
|
||||||
}
|
|
||||||
if (abortTracker.wasCallerAbort()) {
|
|
||||||
throw new AIError.AbortError();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect premature stream closure: the HTTP stream ended without the
|
// Detect premature stream closure: the HTTP stream ended without the
|
||||||
// provider sending a recognized terminal response event.
|
// provider sending a recognized terminal response event.
|
||||||
// Custom/proxy providers may drop the connection mid-stream; without
|
|
||||||
// this guard the incomplete output is silently surfaced as a successful
|
|
||||||
// "stop".
|
|
||||||
if (!sawTerminalResponseEvent) {
|
if (!sawTerminalResponseEvent) {
|
||||||
throw new AIError.ProviderResponseError(
|
throw new AIError.ProviderResponseError(
|
||||||
"OpenAI responses stream closed before a terminal response event was received",
|
"OpenAI responses stream closed before a terminal response event was received",
|
||||||
@@ -689,6 +740,54 @@ const streamOpenAIResponsesOnce = (
|
|||||||
kind: "runtime",
|
kind: "runtime",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
forwardAttemptEvents();
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
const streamFailure = abortTracker.getLocalAbortReason() ?? error;
|
||||||
|
const canRetry =
|
||||||
|
!sawReplayUnsafeOutput &&
|
||||||
|
!requestSignal.aborted &&
|
||||||
|
!abortTracker.wasCallerAbort() &&
|
||||||
|
transientStreamRetryAttempt < OPENAI_RESPONSES_MAX_TRANSIENT_STREAM_RETRIES &&
|
||||||
|
isRetryableOpenAIResponsesStreamFailure(streamFailure);
|
||||||
|
if (!canRetry) {
|
||||||
|
forwardAttemptEvents();
|
||||||
|
throw streamFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
transientStreamRetryAttempt++;
|
||||||
|
logger.debug("OpenAI responses stream ended before replay-unsafe output; retrying", {
|
||||||
|
provider: model.provider,
|
||||||
|
model: model.id,
|
||||||
|
attempt: transientStreamRetryAttempt,
|
||||||
|
error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
|
||||||
|
});
|
||||||
|
const retryOutput = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);
|
||||||
|
output.content.length = 0;
|
||||||
|
output.responseId = undefined;
|
||||||
|
output.upstreamProvider = undefined;
|
||||||
|
output.errorMessage = undefined;
|
||||||
|
output.errorStatus = undefined;
|
||||||
|
output.errorId = undefined;
|
||||||
|
output.stopDetails = undefined;
|
||||||
|
output.providerPayload = undefined;
|
||||||
|
output.usage = retryOutput.usage;
|
||||||
|
if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
|
||||||
|
output.stopReason = "stop";
|
||||||
|
output.duration = undefined;
|
||||||
|
output.ttft = undefined;
|
||||||
|
firstTokenTime = undefined;
|
||||||
|
nativeOutputItems.length = 0;
|
||||||
|
|
||||||
|
if (options?.providerRetryWait) {
|
||||||
|
await options.providerRetryWait(OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS, options.signal);
|
||||||
|
} else {
|
||||||
|
await scheduler.wait(OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS, { signal: options?.signal });
|
||||||
|
}
|
||||||
|
if (abortTracker.wasCallerAbort()) throw new AIError.AbortError();
|
||||||
|
openaiStream = await openResponsesStreamWithFallbacks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
output.providerPayload = createOpenAIResponsesHistoryPayload(model.provider, nativeOutputItems);
|
output.providerPayload = createOpenAIResponsesHistoryPayload(model.provider, nativeOutputItems);
|
||||||
const replayableResponseItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
|
const replayableResponseItems = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(
|
||||||
|
|||||||
@@ -0,0 +1,564 @@
|
|||||||
|
import { describe, expect, it, vi } from "bun:test";
|
||||||
|
import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses";
|
||||||
|
import type {
|
||||||
|
AssistantMessageEvent,
|
||||||
|
AssistantMessageEventStream,
|
||||||
|
Context,
|
||||||
|
FetchImpl,
|
||||||
|
Model,
|
||||||
|
ProviderSessionState,
|
||||||
|
} from "@oh-my-pi/pi-ai/types";
|
||||||
|
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||||
|
|
||||||
|
const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">;
|
||||||
|
const firstUser = { role: "user" as const, content: "Read the file", timestamp: 1_000 };
|
||||||
|
const context: Context = { messages: [firstUser] };
|
||||||
|
|
||||||
|
function createSseResponse(events: unknown[]): Response {
|
||||||
|
return new Response(`${events.map(event => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n`, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTruncatedPendingToolResponse(): Response {
|
||||||
|
const prefix = [
|
||||||
|
{ type: "response.created", response: { id: "resp_partial", status: "in_progress" } },
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "fc_partial",
|
||||||
|
call_id: "call_partial",
|
||||||
|
name: "read",
|
||||||
|
arguments: "",
|
||||||
|
status: "in_progress",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const truncatedEvent = 'data: {"type":"response.function_call_arguments.delta","item_id":"fc_partial","delta":';
|
||||||
|
return new Response(`${prefix.map(event => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n${truncatedEvent}`, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTruncatedReasoningPartDoneResponse(): Response {
|
||||||
|
const prefix = [
|
||||||
|
{ type: "response.created", response: { id: "resp_reasoning", status: "in_progress" } },
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 0,
|
||||||
|
item: { type: "reasoning", id: "reasoning_partial", summary: [], status: "in_progress" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.reasoning_summary_part.added",
|
||||||
|
item_id: "reasoning_partial",
|
||||||
|
output_index: 0,
|
||||||
|
summary_index: 0,
|
||||||
|
part: { type: "summary_text", text: "" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.reasoning_summary_part.done",
|
||||||
|
item_id: "reasoning_partial",
|
||||||
|
output_index: 0,
|
||||||
|
summary_index: 0,
|
||||||
|
part: { type: "summary_text", text: "" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const truncatedEvent = 'data: {"type":"response.output_text.delta","item_id":"missing","delta":';
|
||||||
|
return new Response(`${prefix.map(event => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n${truncatedEvent}`, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCompletedToolResponse(responseId = "resp_retry"): Response {
|
||||||
|
const argumentsJson = JSON.stringify({ path: "README.md" });
|
||||||
|
return createSseResponse([
|
||||||
|
{ type: "response.created", response: { id: responseId, status: "in_progress" } },
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "fc_recovered",
|
||||||
|
call_id: "call_recovered",
|
||||||
|
name: "read",
|
||||||
|
arguments: "",
|
||||||
|
status: "in_progress",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.function_call_arguments.delta",
|
||||||
|
output_index: 0,
|
||||||
|
item_id: "fc_recovered",
|
||||||
|
delta: argumentsJson,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.function_call_arguments.done",
|
||||||
|
output_index: 0,
|
||||||
|
item_id: "fc_recovered",
|
||||||
|
arguments: argumentsJson,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "fc_recovered",
|
||||||
|
call_id: "call_recovered",
|
||||||
|
name: "read",
|
||||||
|
arguments: argumentsJson,
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.completed",
|
||||||
|
response: {
|
||||||
|
id: responseId,
|
||||||
|
status: "completed",
|
||||||
|
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCompletedTextResponse(text: string, responseId: string): Response {
|
||||||
|
return createSseResponse([
|
||||||
|
{ type: "response.created", response: { id: responseId, status: "in_progress" } },
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 0,
|
||||||
|
item: { type: "message", id: `msg_${responseId}`, role: "assistant", status: "in_progress", content: [] },
|
||||||
|
},
|
||||||
|
{ type: "response.output_text.delta", output_index: 0, item_id: `msg_${responseId}`, delta: text },
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
type: "message",
|
||||||
|
id: `msg_${responseId}`,
|
||||||
|
role: "assistant",
|
||||||
|
status: "completed",
|
||||||
|
content: [{ type: "output_text", text }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { id: responseId, status: "completed" } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGatedTextAndToolResponse(): {
|
||||||
|
response: Response;
|
||||||
|
terminalRequested: Promise<void>;
|
||||||
|
releaseTerminal: () => void;
|
||||||
|
} {
|
||||||
|
const nonTerminalEvents = [
|
||||||
|
{ type: "response.created", response: { id: "resp_live", status: "in_progress" } },
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 0,
|
||||||
|
item: { type: "message", id: "msg_live", role: "assistant", status: "in_progress", content: [] },
|
||||||
|
},
|
||||||
|
{ type: "response.output_text.delta", output_index: 0, item_id: "msg_live", delta: "draft" },
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
type: "message",
|
||||||
|
id: "msg_live",
|
||||||
|
role: "assistant",
|
||||||
|
status: "completed",
|
||||||
|
content: [{ type: "output_text", text: "draft" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 1,
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "fc_live",
|
||||||
|
call_id: "call_live",
|
||||||
|
name: "read",
|
||||||
|
arguments: "",
|
||||||
|
status: "in_progress",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.function_call_arguments.delta",
|
||||||
|
output_index: 1,
|
||||||
|
item_id: "fc_live",
|
||||||
|
delta: '{"path":"README',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const terminalEvents = [
|
||||||
|
{
|
||||||
|
type: "response.function_call_arguments.done",
|
||||||
|
output_index: 1,
|
||||||
|
item_id: "fc_live",
|
||||||
|
arguments: '{"path":"README.md"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
output_index: 1,
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "fc_live",
|
||||||
|
call_id: "call_live",
|
||||||
|
name: "read",
|
||||||
|
arguments: '{"path":"README.md"}',
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { id: "resp_live", status: "completed" } },
|
||||||
|
];
|
||||||
|
const terminalGate = Promise.withResolvers<void>();
|
||||||
|
const terminalRequest = Promise.withResolvers<void>();
|
||||||
|
let sentNonTerminal = false;
|
||||||
|
const body = new ReadableStream<Uint8Array>(
|
||||||
|
{
|
||||||
|
async pull(controller) {
|
||||||
|
if (!sentNonTerminal) {
|
||||||
|
sentNonTerminal = true;
|
||||||
|
controller.enqueue(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
`${nonTerminalEvents.map(event => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
terminalRequest.resolve();
|
||||||
|
await terminalGate.promise;
|
||||||
|
controller.enqueue(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
`${terminalEvents.map(event => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ highWaterMark: 0 },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
response: new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }),
|
||||||
|
terminalRequested: terminalRequest.promise,
|
||||||
|
releaseTerminal: terminalGate.resolve,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(init: RequestInit | undefined): Record<string, unknown> {
|
||||||
|
return JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectEvents(stream: AssistantMessageEventStream): Promise<AssistantMessageEvent[]> {
|
||||||
|
const events: AssistantMessageEvent[] = [];
|
||||||
|
for await (const event of stream) events.push(event);
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OpenAI Responses transient stream retry", () => {
|
||||||
|
it("retries a truncated pending tool call with a fresh request and clean state", async () => {
|
||||||
|
const sentRequests: Array<Record<string, unknown>> = [];
|
||||||
|
let attempt = 0;
|
||||||
|
let payloadCalls = 0;
|
||||||
|
const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
sentRequests.push(parseBody(init));
|
||||||
|
attempt++;
|
||||||
|
if (attempt === 1) return createTruncatedPendingToolResponse();
|
||||||
|
if (attempt === 2) return createCompletedToolResponse();
|
||||||
|
return createCompletedTextResponse("Follow-up", "resp_followup");
|
||||||
|
}) as FetchImpl;
|
||||||
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
||||||
|
const options = {
|
||||||
|
apiKey: "test-key",
|
||||||
|
fetch: fetchMock,
|
||||||
|
providerRetryWait: async () => {},
|
||||||
|
providerSessionState,
|
||||||
|
sessionId: "stream-retry-session",
|
||||||
|
statefulResponses: true,
|
||||||
|
onPayload: (payload: unknown) => {
|
||||||
|
payloadCalls++;
|
||||||
|
return { ...(payload as Record<string, unknown>), metadata: { retry_test: "preserved" } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const responseStream = streamOpenAIResponses(model, context, options);
|
||||||
|
const events = await collectEvents(responseStream);
|
||||||
|
const result = await responseStream.result();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(payloadCalls).toBe(1);
|
||||||
|
expect(sentRequests[0]?.metadata).toEqual({ retry_test: "preserved" });
|
||||||
|
expect(sentRequests[1]).toEqual(sentRequests[0]);
|
||||||
|
expect(result.stopReason).toBe("toolUse");
|
||||||
|
expect(JSON.parse(JSON.stringify(result.content))).toEqual([
|
||||||
|
{ type: "toolCall", id: "call_recovered|fc_recovered", name: "read", arguments: { path: "README.md" } },
|
||||||
|
]);
|
||||||
|
expect(events.map(event => event.type)).toEqual([
|
||||||
|
"start",
|
||||||
|
"toolcall_start",
|
||||||
|
"toolcall_delta",
|
||||||
|
"toolcall_end",
|
||||||
|
"done",
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(result.providerPayload)).not.toContain("partial");
|
||||||
|
|
||||||
|
const followup = await streamOpenAIResponses(
|
||||||
|
model,
|
||||||
|
{
|
||||||
|
messages: [firstUser, result, { role: "user", content: "What did it contain?", timestamp: 1_001 }],
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
).result();
|
||||||
|
expect(followup.stopReason).toBe("stop");
|
||||||
|
expect(sentRequests[2]?.previous_response_id).toBe("resp_retry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to full transcript when a fresh stream retry finds a stale chain baseline", async () => {
|
||||||
|
const sentRequests: Array<Record<string, unknown>> = [];
|
||||||
|
const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const request = parseBody(init);
|
||||||
|
sentRequests.push(request);
|
||||||
|
switch (sentRequests.length) {
|
||||||
|
case 1:
|
||||||
|
return createCompletedTextResponse("Baseline", "resp_baseline");
|
||||||
|
case 2:
|
||||||
|
return createTruncatedPendingToolResponse();
|
||||||
|
case 3:
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: "Previous response with id 'resp_baseline' not found.",
|
||||||
|
type: "invalid_request_error",
|
||||||
|
param: "previous_response_id",
|
||||||
|
code: "previous_response_not_found",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 404, headers: { "content-type": "application/json" } },
|
||||||
|
);
|
||||||
|
case 4:
|
||||||
|
return createCompletedTextResponse("Recovered", "resp_recovered");
|
||||||
|
default:
|
||||||
|
return createCompletedTextResponse("Follow-up", "resp_followup");
|
||||||
|
}
|
||||||
|
}) as FetchImpl;
|
||||||
|
const providerSessionState = new Map<string, ProviderSessionState>();
|
||||||
|
const options = {
|
||||||
|
apiKey: "test-key",
|
||||||
|
fetch: fetchMock,
|
||||||
|
providerRetryWait: async () => {},
|
||||||
|
providerSessionState,
|
||||||
|
sessionId: "stream-retry-stale-chain-session",
|
||||||
|
statefulResponses: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseline = await streamOpenAIResponses(model, context, options).result();
|
||||||
|
const secondUser = { role: "user" as const, content: "Continue after baseline", timestamp: 1_001 };
|
||||||
|
const responseStream = streamOpenAIResponses(model, { messages: [firstUser, baseline, secondUser] }, options);
|
||||||
|
const events = await collectEvents(responseStream);
|
||||||
|
const recovered = await responseStream.result();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||||
|
expect(sentRequests[0]?.previous_response_id).toBeUndefined();
|
||||||
|
expect(sentRequests[1]?.previous_response_id).toBe("resp_baseline");
|
||||||
|
expect(sentRequests[2]).toEqual(sentRequests[1]);
|
||||||
|
expect(JSON.stringify(sentRequests[1]?.input)).toContain("Continue after baseline");
|
||||||
|
expect(JSON.stringify(sentRequests[1]?.input)).not.toContain("Read the file");
|
||||||
|
expect(sentRequests[3]?.previous_response_id).toBeUndefined();
|
||||||
|
expect(sentRequests[3]?.store).toBe(true);
|
||||||
|
expect(JSON.stringify(sentRequests[3]?.input)).toContain("Read the file");
|
||||||
|
expect(JSON.stringify(sentRequests[3]?.input)).toContain("Baseline");
|
||||||
|
expect(JSON.stringify(sentRequests[3]?.input)).toContain("Continue after baseline");
|
||||||
|
expect(recovered.responseId).toBe("resp_recovered");
|
||||||
|
expect(events.map(event => event.type)).toEqual(["start", "text_start", "text_delta", "text_end", "done"]);
|
||||||
|
|
||||||
|
const followup = await streamOpenAIResponses(
|
||||||
|
model,
|
||||||
|
{
|
||||||
|
messages: [
|
||||||
|
firstUser,
|
||||||
|
baseline,
|
||||||
|
secondUser,
|
||||||
|
recovered,
|
||||||
|
{ role: "user", content: "One more question", timestamp: 1_002 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
).result();
|
||||||
|
expect(followup.stopReason).toBe("stop");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||||
|
expect(sentRequests[4]?.previous_response_id).toBe("resp_recovered");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards text and tool deltas live with their delta-time partial state", async () => {
|
||||||
|
const gated = createGatedTextAndToolResponse();
|
||||||
|
const fetchMock = vi.fn(async () => gated.response) as FetchImpl;
|
||||||
|
const responseStream = streamOpenAIResponses(model, context, {
|
||||||
|
apiKey: "test-key",
|
||||||
|
fetch: fetchMock,
|
||||||
|
providerRetryWait: async () => {},
|
||||||
|
});
|
||||||
|
const nonTerminalEvents = (async () => {
|
||||||
|
const observed: Array<{ type: AssistantMessageEvent["type"]; content: unknown }> = [];
|
||||||
|
for await (const event of responseStream) {
|
||||||
|
observed.push({
|
||||||
|
type: event.type,
|
||||||
|
content:
|
||||||
|
event.type !== "start" && "partial" in event ? structuredClone(event.partial.content) : undefined,
|
||||||
|
});
|
||||||
|
if (event.type === "toolcall_delta") return observed;
|
||||||
|
}
|
||||||
|
throw new Error("stream ended before the tool delta");
|
||||||
|
})();
|
||||||
|
|
||||||
|
await gated.terminalRequested;
|
||||||
|
const observedBeforeTerminal = await nonTerminalEvents;
|
||||||
|
const deltaText = { type: "text", text: "draft", textSignature: JSON.stringify({ v: 1, id: "msg_live" }) };
|
||||||
|
expect(observedBeforeTerminal).toEqual([
|
||||||
|
{ type: "start", content: undefined },
|
||||||
|
{ type: "text_start", content: [deltaText] },
|
||||||
|
{ type: "text_delta", content: [deltaText] },
|
||||||
|
{ type: "text_end", content: [deltaText] },
|
||||||
|
{
|
||||||
|
type: "toolcall_start",
|
||||||
|
content: [deltaText, { type: "toolCall", id: "call_live|fc_live", name: "read", arguments: {} }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "toolcall_delta",
|
||||||
|
content: [
|
||||||
|
deltaText,
|
||||||
|
{ type: "toolCall", id: "call_live|fc_live", name: "read", arguments: { path: "README" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
gated.releaseTerminal();
|
||||||
|
const result = await responseStream.result();
|
||||||
|
expect(result.stopReason).toBe("toolUse");
|
||||||
|
expect(JSON.parse(JSON.stringify(result.content[1]))).toEqual({
|
||||||
|
type: "toolCall",
|
||||||
|
id: "call_live|fc_live",
|
||||||
|
name: "read",
|
||||||
|
arguments: { path: "README.md" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry after a tool argument delta was emitted", async () => {
|
||||||
|
const partialWithDelta = createSseResponse([
|
||||||
|
{ type: "response.created", response: { id: "resp_partial", status: "in_progress" } },
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "fc_partial",
|
||||||
|
call_id: "call_partial",
|
||||||
|
name: "read",
|
||||||
|
arguments: "",
|
||||||
|
status: "in_progress",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.function_call_arguments.delta",
|
||||||
|
output_index: 0,
|
||||||
|
item_id: "fc_partial",
|
||||||
|
delta: '{"path":"README.md"}',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const fetchMock = vi.fn(async () => partialWithDelta) as FetchImpl;
|
||||||
|
|
||||||
|
const result = await streamOpenAIResponses(model, context, {
|
||||||
|
apiKey: "test-key",
|
||||||
|
fetch: fetchMock,
|
||||||
|
providerRetryWait: async () => {},
|
||||||
|
}).result();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.stopReason).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry after a reasoning summary part completion emitted thinking", async () => {
|
||||||
|
const fetchMock = vi.fn(async () => createTruncatedReasoningPartDoneResponse()) as FetchImpl;
|
||||||
|
const responseStream = streamOpenAIResponses(model, context, {
|
||||||
|
apiKey: "test-key",
|
||||||
|
fetch: fetchMock,
|
||||||
|
providerRetryWait: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = await collectEvents(responseStream);
|
||||||
|
const result = await responseStream.result();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(events.map(event => event.type)).toEqual(["start", "thinking_start", "thinking_delta", "error"]);
|
||||||
|
expect(result.stopReason).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds repeated pre-output stream corruption to one retry", async () => {
|
||||||
|
const fetchMock = vi.fn(async () => createTruncatedPendingToolResponse()) as FetchImpl;
|
||||||
|
|
||||||
|
const result = await streamOpenAIResponses(model, context, {
|
||||||
|
apiKey: "test-key",
|
||||||
|
fetch: fetchMock,
|
||||||
|
providerRetryWait: async () => {},
|
||||||
|
}).result();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result.stopReason).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors caller abort during the retry wait", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const fetchMock = vi.fn(async () => createTruncatedPendingToolResponse()) as FetchImpl;
|
||||||
|
|
||||||
|
const responseStream = streamOpenAIResponses(model, context, {
|
||||||
|
apiKey: "test-key",
|
||||||
|
fetch: fetchMock,
|
||||||
|
signal: controller.signal,
|
||||||
|
providerRetryWait: async () => controller.abort(),
|
||||||
|
});
|
||||||
|
const events = await collectEvents(responseStream);
|
||||||
|
const result = await responseStream.result();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.stopReason).toBe("aborted");
|
||||||
|
expect(events.map(event => event.type)).toEqual(["start", "error"]);
|
||||||
|
expect(result.content).toEqual([]);
|
||||||
|
expect(result.responseId).toBeUndefined();
|
||||||
|
expect(result.providerPayload).toBeUndefined();
|
||||||
|
expect(result.usage).toEqual({
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
});
|
||||||
|
expect(result.ttft).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const error of [
|
||||||
|
{ code: "invalid_request_error", message: "Tool schema is invalid" },
|
||||||
|
{ code: "insufficient_quota", message: "Persistent quota exhausted" },
|
||||||
|
]) {
|
||||||
|
it(`does not retry terminal ${error.code} failures`, async () => {
|
||||||
|
const fetchMock = vi.fn(async () =>
|
||||||
|
createSseResponse([
|
||||||
|
{
|
||||||
|
type: "response.failed",
|
||||||
|
response: { id: "resp_failed", status: "failed", error },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
) as FetchImpl;
|
||||||
|
|
||||||
|
const result = await streamOpenAIResponses(model, context, {
|
||||||
|
apiKey: "test-key",
|
||||||
|
fetch: fetchMock,
|
||||||
|
providerRetryWait: async () => {},
|
||||||
|
}).result();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.stopReason).toBe("error");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user