Skip to content

Commit 18b7db5

Browse files
committed
fix: handle stream interruption for OpenAI-compatible providers
- Add SSE timeout, connection reset, abort, and stream truncation patterns to retryable() matching for automatic retry - Classify 'SSE read timed out' as APIError(isRetryable: true) - Emit error event in flush() when stream ends without finish_reason while output is still active, instead of silently accepting truncation Closes #20466
1 parent 0b49129 commit 18b7db5

File tree

4 files changed

+25
-3
lines changed

4 files changed

+25
-3
lines changed

packages/opencode/src/provider/provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,7 @@ const ProviderCapabilities = Schema.Struct({
845845
input: ProviderModalities,
846846
output: ProviderModalities,
847847
interleaved: ProviderInterleaved,
848-
systemMessage: Schema.Union([Schema.Literal("single"), Schema.Literal("multiple")]).optional(),
848+
systemMessage: Schema.optional(Schema.Union([Schema.Literal("single"), Schema.Literal("multiple")])),
849849
})
850850

851851
const ProviderCacheCost = Schema.Struct({

packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,15 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
645645
},
646646

647647
flush(controller) {
648+
// If the stream ended without a finish_reason and output is still
649+
// active, the stream was truncated — emit an error to trigger retry
650+
const hasActiveOutput = isActiveReasoning || isActiveText || toolCalls.some((tc) => !tc.hasFinished)
651+
if (finishReason.unified === "other" && finishReason.raw === undefined && hasActiveOutput) {
652+
controller.enqueue({
653+
type: "error",
654+
error: new Error("Stream ended unexpectedly — no finish reason received while output was still active"),
655+
})
656+
}
648657
if (isActiveReasoning) {
649658
controller.enqueue({
650659
type: "reasoning-end",

packages/opencode/src/session/message-v2.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,15 @@ export function fromError(
10281028
},
10291029
{ cause: e },
10301030
).toObject()
1031+
case e instanceof Error && e.message === "SSE read timed out":
1032+
return new APIError(
1033+
{
1034+
message: "Stream read timed out — no data received within the chunk timeout window",
1035+
isRetryable: true,
1036+
metadata: { code: "SSE_TIMEOUT", message: e.message },
1037+
},
1038+
{ cause: e },
1039+
).toObject()
10311040
case e instanceof Error:
10321041
return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
10331042
default:

packages/opencode/src/session/retry.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,18 @@ export function retryable(error: Err) {
6363
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
6464
}
6565

66-
// Check for rate limit patterns in plain text error messages
66+
// Check for stream interruption and rate limit patterns in plain text error messages
6767
const msg = error.data?.message
6868
if (typeof msg === "string") {
6969
const lower = msg.toLowerCase()
7070
if (
7171
lower.includes("rate increased too quickly") ||
7272
lower.includes("rate limit") ||
73-
lower.includes("too many requests")
73+
lower.includes("too many requests") ||
74+
lower.includes("sse read timed out") ||
75+
lower.includes("connection reset") ||
76+
lower.includes("aborted") ||
77+
lower.includes("stream ended unexpectedly")
7478
) {
7579
return msg
7680
}

0 commit comments

Comments
 (0)