fix(llms): explain provider timeouts in plain words and record where they happened

When a silent provider hits Bun's 300 s fetch timeout, the user saw only
"The operation timed out." Now the run ends with a message saying whether
the provider stopped before or partway through its reply, and what to do
next. sdk.error records the same as timeoutPhase (before_reply or
mid_reply), keeping the raw error text. Retry decisions and time limits are
unchanged.
This commit is contained in:
Max Paulus 🥪
2026-10-23 08:55:57 -07:00
parent 1619168bde
commit d503cf5061
2 changed files with 83 additions and 0 deletions
+27
View File
@@ -1487,6 +1487,29 @@ interface CapturedStreamError {
reported?: boolean;
requestId?: string;
baseMessage: string;
/** For a timeout: whether the provider went silent before or during its reply. */
timeoutPhase?: "before_reply" | "mid_reply";
}
/**
* A native timeout means the runtime stopped waiting on a silent provider
* (Bun's fetch gives up after 300 s). Tell the user what happened in plain
* words; telemetry keeps the raw error text in `baseMessage`. Updated in
* place so `reportError` still recognises an already-reported failure.
*/
function describeProviderTimeout(
captured: CapturedStreamError,
sawVisibleContent: boolean,
): void {
captured.timeoutPhase = sawVisibleContent ? "mid_reply" : "before_reply";
const what =
captured.timeoutPhase === "mid_reply"
? "The model provider stopped responding partway through its reply."
: "The model provider stopped responding before it started its reply.";
captured.message = appendRequestId(
`${what} Send a message to continue, or switch models if this keeps happening.`,
captured.requestId,
);
}
function captureStreamError(
@@ -2035,6 +2058,9 @@ async function* emitAiSdkEvents(
};
}
if (streamError && isTimeoutReason(streamError.error)) {
describeProviderTimeout(streamError, sawVisibleContent);
}
if (streamError) await reportError?.(streamError);
const finishRequestId = requestId ?? streamError?.requestId;
@@ -2234,6 +2260,7 @@ function createAiSdkProvider(
aiSdkCallId: getAiSdkCallId?.(),
errorPhase: captured.phase,
errorTurnRetryable: captured.retryable,
timeoutPhase: captured.timeoutPhase,
providerId: request.providerId,
modelId: request.modelId,
providerKind: kind,
@@ -2775,6 +2775,62 @@ describe("sdk-gateway", () => {
).toHaveLength(1);
});
it.each([
{
timeoutPhase: "before_reply",
parts: [],
message:
"The model provider stopped responding before it started its reply.",
},
{
timeoutPhase: "mid_reply",
parts: [{ type: "text-delta", text: "Now I will" }],
message:
"The model provider stopped responding partway through its reply.",
},
])("explains a provider that went silent ($timeoutPhase)", async ({
timeoutPhase,
parts,
message,
}) => {
// Bun's fetch gives up on a silent provider with this native error.
const error = new DOMException("The operation timed out.", "TimeoutError");
streamTextSpy.mockReturnValue({
fullStream: (async function* () {
yield* parts;
throw error;
})(),
});
const { telemetry, capture } = createTelemetryMock();
const gateway = createGateway({
telemetry,
logger: { error: vi.fn(), log: vi.fn() },
providerConfigs: [{ providerId: "openai-native", apiKey: "test" }],
});
const events = await collect(
await gateway.stream({
providerId: "openai-native",
modelId: "gpt-5-mini",
messages: baseMessages,
}),
);
expect(events.at(-1)).toMatchObject({
type: "finish",
reason: "error",
error: `${message} Send a message to continue, or switch models if this keeps happening.`,
// Retry decisions are unchanged: a timeout alone is not retried.
errorRetryable: false,
});
expect(capture).toHaveBeenCalledWith({
event: "sdk.error",
properties: expect.objectContaining({
error_type: "TimeoutError",
error_message: "The operation timed out.",
timeoutPhase,
}),
});
});
it.each([
"AbortError",
"TimeoutError",