mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user