diff --git a/packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts b/packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts index 280970c41b4f..52df3624ea72 100644 --- a/packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts +++ b/packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts @@ -645,6 +645,15 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { }, flush(controller) { + // If the stream ended without a finish_reason and output is still + // active, the stream was truncated — emit an error to trigger retry + const hasActiveOutput = isActiveReasoning || isActiveText || toolCalls.some((tc) => !tc.hasFinished) + if (finishReason.unified === "other" && finishReason.raw === undefined && hasActiveOutput) { + controller.enqueue({ + type: "error", + error: new Error("Stream ended unexpectedly — no finish reason received while output was still active"), + }) + } if (isActiveReasoning) { controller.enqueue({ type: "reasoning-end", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index fd5712333c57..c44ef8bdf8d5 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -180,7 +180,7 @@ for (const item of targets) { autoloadPackageJson: true, target: name.replace(pkg.name, "bun") as any, outfile: `dist/${name}/bin/opencode`, - execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"], + execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--no-orphans", "--"], windows: {}, }, files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2142f843404c..276ce5f28826 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -970,6 +970,7 @@ const ProviderCapabilities = Schema.Struct({ input: ProviderModalities, output: ProviderModalities, interleaved: ProviderInterleaved, + systemMessage: Schema.optional(Schema.Union([Schema.Literal("single"), Schema.Literal("multiple")])), }) const ProviderCacheCost = Schema.Struct({ @@ -1413,7 +1414,10 @@ export const layer = Layer.effect( input: { text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true, audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false, - image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false, + image: + model.modalities?.input?.includes("image") ?? + existingModel?.capabilities.input.image ?? + (provider.npm === "@ai-sdk/openai-compatible" ? true : false), video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false, pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false, }, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index c57fcd53c96f..193fe8e27a19 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -711,6 +711,15 @@ export function fromError( }, { cause: e }, ).toObject() + case e instanceof Error && e.message === "SSE read timed out": + return new APIError( + { + message: "Stream read timed out — no data received within the chunk timeout window", + isRetryable: true, + metadata: { code: "SSE_TIMEOUT", message: e.message }, + }, + { cause: e }, + ).toObject() case e instanceof Error: return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject() default: diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 4139665bd2bd..b3039d6e02f6 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -122,14 +122,18 @@ export function retryable(error: Err, provider: string) { return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } } - // Check for rate limit patterns in plain text error messages + // Check for stream interruption and rate limit patterns in plain text error messages const msg = isRecord(error.data) ? error.data.message : undefined if (typeof msg === "string") { const lower = msg.toLowerCase() if ( lower.includes("rate increased too quickly") || lower.includes("rate limit") || - lower.includes("too many requests") + lower.includes("too many requests") || + lower.includes("sse read timed out") || + lower.includes("connection reset") || + lower.includes("aborted") || + lower.includes("stream ended unexpectedly") ) { return { message: msg } }