diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6e116fe41ea7..f2400e08cffb 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -845,6 +845,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({ @@ -1007,6 +1008,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model pdf: model.modalities?.output?.includes("pdf") ?? false, }, interleaved: model.interleaved ?? false, + systemMessage: undefined, }, release_date: model.release_date ?? "", variants: {}, @@ -1163,12 +1165,15 @@ const layer: Layer.Layer< 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, }, output: { - text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true, + text: model.modalities?.output?.includes("text") ?? true, audio: model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false, image: @@ -1178,6 +1183,7 @@ const layer: Layer.Layer< pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false, }, interleaved: model.interleaved ?? false, + systemMessage: undefined, }, cost: { input: model?.cost?.input ?? existingModel?.cost?.input ?? 0, diff --git a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts b/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts index c4e15e0b4fb2..c0d1dee0115f 100644 --- a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts +++ b/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts @@ -12,15 +12,35 @@ function getOpenAIMetadata(message: { providerOptions?: SharedV3ProviderOptions export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV3Prompt): OpenAICompatibleChatPrompt { const messages: OpenAICompatibleChatPrompt = [] + + const systemPrompt: string[] = [] + for (const { role, content } of prompt) { + if (role === "system") { + systemPrompt.push(content) + } + } + + const hasSystem = systemPrompt.length > 0 + const hasOthers = prompt.some((m) => m.role !== "system") + + if (hasSystem) { + if (hasOthers) { + messages.push({ + role: "system", + content: systemPrompt.join("\n\n"), + }) + } else { + messages.push({ + role: "user", + content: systemPrompt.join("\n\n"), + }) + } + } + for (const { role, content, ...message } of prompt) { const metadata = getOpenAIMetadata({ ...message }) switch (role) { case "system": { - messages.push({ - role: "system", - content: content, - ...metadata, - }) break } diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts index 280970c41b4f..6552e5ad893a 100644 --- a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts +++ b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts @@ -226,8 +226,8 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { }) } - // reasoning content (Copilot uses reasoning_text): - const reasoning = choice.message.reasoning_text + // reasoning content (Copilot uses reasoning_text, DeepSeek/oMLX uses reasoning_content): + const reasoning = choice.message.reasoning_text ?? choice.message.reasoning_content if (reasoning != null && reasoning.length > 0) { content.push({ type: "reasoning", @@ -477,8 +477,8 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { reasoningOpaque = delta.reasoning_opaque } - // enqueue reasoning before text deltas (Copilot uses reasoning_text): - const reasoningContent = delta.reasoning_text + // enqueue reasoning before text deltas (Copilot uses reasoning_text, DeepSeek/oMLX uses reasoning_content): + const reasoningContent = delta.reasoning_text ?? delta.reasoning_content if (reasoningContent) { if (!isActiveReasoning) { controller.enqueue({ @@ -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", @@ -757,6 +766,7 @@ const OpenAICompatibleChatResponseSchema = z.object({ // Copilot-specific reasoning fields reasoning_text: z.string().nullish(), reasoning_opaque: z.string().nullish(), + reasoning_content: z.string().nullish(), tool_calls: z .array( z.object({ @@ -792,6 +802,7 @@ const createOpenAICompatibleChatChunkSchema = > +function getSystemMessageMode(model: Provider.Model): "single" | "multiple" { + if (model.capabilities.systemMessage) { + return model.capabilities.systemMessage + } + const providerDefaults: Record = { + anthropic: "multiple", + } + const mode = providerDefaults[model.providerID] ?? "single" + if (!providerDefaults[model.providerID]) { + log.info("Using default 'single' systemMessage mode for provider", { + providerID: model.providerID, + modelID: model.id, + }) + } + return mode +} + export type StreamInput = { user: MessageV2.User sessionID: string @@ -150,12 +167,14 @@ const live: Layer.Layer< : isWorkflow ? input.messages : [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), + ...(getSystemMessageMode(input.model) === "multiple" + ? system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ) + : ([{ role: "system", content: system.join("\n") }] as ModelMessage[])), ...input.messages, ] diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 20528763b8b1..1d72ad487417 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1028,6 +1028,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 12fd4d345d06..17d4feadccd5 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -63,14 +63,18 @@ export function retryable(error: Err) { return 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 = error.data?.message 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 msg }