diff --git a/sdk/packages/llms/src/providers/ai-sdk.ts b/sdk/packages/llms/src/providers/ai-sdk.ts index 1376a94147..f959b0e4c7 100644 --- a/sdk/packages/llms/src/providers/ai-sdk.ts +++ b/sdk/packages/llms/src/providers/ai-sdk.ts @@ -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, diff --git a/sdk/packages/llms/src/providers/gateway.test.ts b/sdk/packages/llms/src/providers/gateway.test.ts index 20f06ab4c0..4ee40ffb30 100644 --- a/sdk/packages/llms/src/providers/gateway.test.ts +++ b/sdk/packages/llms/src/providers/gateway.test.ts @@ -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",