diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 81759160bfeb..516d20ec6a1d 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1,4 +1,4 @@ -import type { ModelMessage, ToolResultPart } from "ai" +import type { AssistantModelMessage, ModelMessage, ToolResultPart } from "ai" import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" import type * as Provider from "./provider" @@ -250,6 +250,7 @@ function normalizeMessages( } const modelID = model.api.id.toLowerCase() + const isDeepSeek = modelID.includes("deepseek") if ( model.providerID === "mistral" || ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family)) @@ -300,7 +301,7 @@ function normalizeMessages( } // Deepseek requires all assistant messages to have reasoning on them - if (model.api.id.toLowerCase().includes("deepseek")) { + if (isDeepSeek) { msgs = msgs.map((msg) => { if (msg.role !== "assistant") return msg if (Array.isArray(msg.content)) { @@ -317,26 +318,27 @@ function normalizeMessages( }) } - if ( - typeof model.capabilities.interleaved === "object" && - model.capabilities.interleaved.field && - model.api.npm !== "@openrouter/ai-sdk-provider" - ) { + const interleaved = iife(() => { + if ( + typeof model.capabilities.interleaved !== "object" || + !model.capabilities.interleaved.field || + model.api.npm === "@openrouter/ai-sdk-provider" + ) + return msgs const field = model.capabilities.interleaved.field return msgs.map((msg) => { if (msg.role === "assistant" && Array.isArray(msg.content)) { - const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") - const reasoningText = reasoningParts.map((part: any) => part.text).join("") - - // Filter out reasoning parts from content - const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") + const reasoningText = msg.content + .filter((part) => part.type === "reasoning") + .map((part) => part.text) + .join("") // Include reasoning_content | reasoning_details directly on the message for all assistant messages. // Always set the field even when empty — some providers (e.g. DeepSeek) may return empty // reasoning_content which still needs to be sent back in subsequent requests. return { ...msg, - content: filteredContent, + content: msg.content.filter((part) => part.type !== "reasoning"), providerOptions: { ...msg.providerOptions, openaiCompatible: { @@ -349,9 +351,24 @@ function normalizeMessages( return msg }) - } + }) - return msgs + if (!isDeepSeek) return interleaved + return interleaved.map((msg) => + msg.role === "assistant" ? { ...msg, content: ensureDeepSeekAssistantContent(msg.content) } : msg, + ) +} + +// Moving reasoning out of `content` can leave an assistant message with nothing +// left, which DeepSeek rejects with "all messages must have non-empty content". +// Tool calls already count as content, and on the Anthropic wire format a +// `tool_use` block must be the last one in the message — appending a text part +// after it is rejected — so only backfill when there is nothing else to send. +function ensureDeepSeekAssistantContent(content: AssistantModelMessage["content"]) { + if (typeof content === "string") return content || " " + if (content.some((part) => (part.type === "text" && part.text.length > 0) || part.type === "tool-call")) + return content + return [...content, { type: "text" as const, text: " " }] } function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index ef2b275035e1..93a9915154b9 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1,11 +1,12 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" +import type { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { LLMRequestPrep } from "@/session/llm/request" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { ModelsDev } from "@opencode-ai/core/models-dev" -import { jsonSchema } from "ai" +import { jsonSchema, type ModelMessage } from "ai" describe("ProviderTransform.options - setCacheKey", () => { const sessionID = "test-session-123" @@ -1944,6 +1945,154 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { ]) expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined() }) + + const deepseekModel = { + id: ModelV2.ID.make("deepseek/deepseek-v4-pro"), + providerID: ProviderV2.ID.make("deepseek"), + api: { + id: "deepseek-v4-pro", + url: "https://api.deepseek.com", + npm: "@ai-sdk/openai-compatible", + }, + name: "DeepSeek V4 Pro", + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: { + field: "reasoning_content", + }, + }, + cost: { + input: 0.435, + output: 0.87, + cache: { read: 0.001, write: 0.002 }, + }, + limit: { + context: 1_000_000, + output: 384_000, + }, + status: "active", + options: {}, + headers: {}, + release_date: "2025-06-01", + } satisfies Provider.Model + + test("preserves reasoning-only assistant messages with non-empty content", () => { + expect( + ProviderTransform.message( + [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [{ type: "reasoning", text: "Let me think..." }], + }, + { role: "user", content: "World" }, + ] satisfies ModelMessage[], + deepseekModel, + {}, + ), + ).toEqual([ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [{ type: "text", text: " " }], + providerOptions: { + openaiCompatible: { + reasoning_content: "Let me think...", + }, + }, + }, + { role: "user", content: "World" }, + ]) + }) + + test("preserves empty assistant messages with non-empty content", () => { + expect( + ProviderTransform.message( + [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "" }, + { role: "user", content: "World" }, + ] satisfies ModelMessage[], + deepseekModel, + {}, + ), + ).toEqual([ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [{ type: "text", text: " " }], + providerOptions: { + openaiCompatible: { + reasoning_content: "", + }, + }, + }, + { role: "user", content: "World" }, + ]) + }) + + // A `tool_use` block must be the last block on the Anthropic wire format, so + // no filler text may be appended after a tool call. + test("leaves tool-call assistant messages alone", () => { + expect( + ProviderTransform.message( + [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Let me think about this..." }, + { type: "tool-call", toolCallId: "call_1", toolName: "bash", input: { command: "ls" } }, + ], + }, + ] satisfies ModelMessage[], + deepseekModel, + {}, + ), + ).toEqual([ + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call_1", toolName: "bash", input: { command: "ls" } }], + providerOptions: { + openaiCompatible: { + reasoning_content: "Let me think about this...", + }, + }, + }, + ]) + }) + + test("preserves assistant message with text content for DeepSeek after reasoning extraction", () => { + expect( + ProviderTransform.message( + [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Let me think..." }, + { type: "text", text: "Here is the answer" }, + ], + }, + ] satisfies ModelMessage[], + deepseekModel, + {}, + ), + ).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "Here is the answer" }], + providerOptions: { + openaiCompatible: { + reasoning_content: "Let me think...", + }, + }, + }, + ]) + }) }) describe("ProviderTransform.message - surrogate sanitization", () => { diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 3bfc722e2bec..b9ed6b398050 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -832,6 +832,113 @@ describe("session.llm.stream", () => { }, ) + const deepseekFixture = { providerID: "deepseek", modelID: "deepseek-v4-pro" } + it.instance( + "sends non-empty content for DeepSeek assistant messages left empty by reasoning extraction", + () => + Effect.gen(function* () { + const fixture = loadFixture(deepseekFixture.providerID, deepseekFixture.modelID) + const request = waitRequest( + "/chat/completions", + new Response(createChatStream("Done"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + + const resolved = yield* Provider.use.getModel( + ProviderV2.ID.make(deepseekFixture.providerID), + ModelV2.ID.make(fixture.model.id), + ) + const sessionID = SessionID.make("session-test-deepseek-content") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + yield* drain({ + user: { + id: MessageID.make("msg_user-deepseek-content"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderV2.ID.make(deepseekFixture.providerID), modelID: resolved.id }, + }, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [ + { role: "user", content: "Run date" }, + // DeepSeek returns reasoning_content with empty content, so this + // turn has nothing left once reasoning moves to reasoning_content. + { role: "assistant", content: [{ type: "reasoning", text: "Just thinking." }] }, + { role: "user", content: "Continue" }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "I should call the date tool." }, + { type: "tool-call", toolCallId: "call-1", toolName: "bash", input: { command: "date" } }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { type: "text", value: "12:00" }, + }, + ], + }, + { role: "user", content: "Continue" }, + ] satisfies ModelMessage[], + tools: {}, + }) + + const capture = yield* Effect.promise(() => request) + const messages = capture.body.messages as Array> + const assistants = messages.filter((message) => message.role === "assistant") + + expect(assistants[0]).toMatchObject({ + role: "assistant", + content: " ", + reasoning_content: "Just thinking.", + }) + // A tool call is already non-empty content; no filler text is added, + // because on the Anthropic wire format a tool_use block must come last. + expect(assistants[1]).toMatchObject({ + role: "assistant", + content: "", + reasoning_content: "I should call the date tool.", + tool_calls: [ + { + id: "call-1", + type: "function", + function: { + name: "bash", + arguments: '{"command":"date"}', + }, + }, + ], + }) + }), + { + config: () => ({ + enabled_providers: [deepseekFixture.providerID], + provider: { + [deepseekFixture.providerID]: { + options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` }, + }, + }, + }), + }, + ) + const cerebrasFixture = { providerID: "cerebras", modelID: "gpt-oss-120b" } it.instance( "replays Cerebras assistant reasoning using the provider-supported field",