Skip to content

Commit f495243

Browse files
committed
fix: handle stream interruption for OpenAI-compatible providers
Three fixes for Ollama/cloud models using OpenAI-compatible format where responses can be interrupted mid-stream: 1. retry.ts: Add SSE timeout, connection reset, abort, and stream truncation patterns to retryable error matching so interrupted streams are automatically retried. 2. message-v2.ts: Classify 'SSE read timed out' errors as APIError(isRetryable: true) instead of Unknown, so the retry mechanism recognizes and retries chunk timeouts. 3. openai-compatible-chat-language-model.ts: When the TransformStream flush() is called without a finish_reason from the provider while output is still active (text/reasoning/tool-call), emit an error event. This triggers the error handling path which retries rather than silently accepting a truncated response. Tests: 5 new retryable() cases + 1 fromError() SSE timeout case, all passing.
1 parent 7fde75b commit f495243

5 files changed

Lines changed: 62 additions & 2 deletions

File tree

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

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

647647
flush(controller) {
648+
// If the stream ended without receiving a finish_reason from the
649+
// provider and we still have active output (incomplete text,
650+
// reasoning, or tool calls), the stream was truncated. Emit an
651+
// error event so downstream consumers can distinguish this from a
652+
// normal completion and trigger retry logic.
653+
const hasActiveOutput = isActiveReasoning || isActiveText || toolCalls.some((tc) => !tc.hasFinished)
654+
if (finishReason.unified === "other" && finishReason.raw === undefined && hasActiveOutput) {
655+
controller.enqueue({
656+
type: "error",
657+
error: new Error("Stream ended unexpectedly — no finish reason received while output was still active"),
658+
})
659+
}
648660
if (isActiveReasoning) {
649661
controller.enqueue({
650662
type: "reasoning-end",

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,6 +1021,15 @@ export namespace MessageV2 {
10211021
},
10221022
{ cause: e },
10231023
).toObject()
1024+
case e instanceof Error && e.message === "SSE read timed out":
1025+
return new MessageV2.APIError(
1026+
{
1027+
message: "Stream read timed out — no data received within the chunk timeout window",
1028+
isRetryable: true,
1029+
metadata: { code: "SSE_TIMEOUT", message: e.message },
1030+
},
1031+
{ cause: e },
1032+
).toObject()
10241033
case e instanceof Error:
10251034
return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
10261035
default:

packages/opencode/src/session/retry.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,18 @@ export namespace SessionRetry {
6161
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
6262
}
6363

64-
// Check for rate limit patterns in plain text error messages
64+
// Check for stream interruption patterns that should be retried
6565
const msg = error.data?.message
6666
if (typeof msg === "string") {
6767
const lower = msg.toLowerCase()
6868
if (
6969
lower.includes("rate increased too quickly") ||
7070
lower.includes("rate limit") ||
71-
lower.includes("too many requests")
71+
lower.includes("too many requests") ||
72+
lower.includes("sse read timed out") ||
73+
lower.includes("connection reset") ||
74+
lower.includes("aborted") ||
75+
lower.includes("stream ended unexpectedly")
7276
) {
7377
return msg
7478
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,4 +1029,15 @@ describe("session.message-v2.fromError", () => {
10291029

10301030
expect(result.name).toBe("MessageAbortedError")
10311031
})
1032+
1033+
test("classifies SSE read timed out as retryable APIError", () => {
1034+
const sseTimeout = new Error("SSE read timed out")
1035+
1036+
const result = MessageV2.fromError(sseTimeout, { providerID })
1037+
1038+
expect(MessageV2.APIError.isInstance(result)).toBe(true)
1039+
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
1040+
expect((result as MessageV2.APIError).data.message).toInclude("timed out")
1041+
expect((result as MessageV2.APIError).data.metadata?.code).toBe("SSE_TIMEOUT")
1042+
})
10321043
})

packages/opencode/test/session/retry.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,30 @@ describe("session.retry.retryable", () => {
184184
expect(retryable).toBeDefined()
185185
expect(retryable).toBe("Response decompression failed")
186186
})
187+
188+
test("retries SSE read timed out errors", () => {
189+
const msg = "SSE read timed out"
190+
const error = wrap(msg)
191+
expect(SessionRetry.retryable(error)).toBe(msg)
192+
})
193+
194+
test("retries connection reset errors", () => {
195+
const msg = "Connection reset by peer"
196+
const error = wrap(msg)
197+
expect(SessionRetry.retryable(error)).toBe(msg)
198+
})
199+
200+
test("retries aborted errors", () => {
201+
const msg = "The operation was aborted"
202+
const error = wrap(msg)
203+
expect(SessionRetry.retryable(error)).toBe(msg)
204+
})
205+
206+
test("retries stream ended unexpectedly errors", () => {
207+
const msg = "Stream ended unexpectedly — no finish reason received while output was still active"
208+
const error = wrap(msg)
209+
expect(SessionRetry.retryable(error)).toBe(msg)
210+
})
187211
})
188212

189213
describe("session.message-v2.fromError", () => {

0 commit comments

Comments
 (0)