Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 32 additions & 15 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)) {
Expand All @@ -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: {
Expand All @@ -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[] {
Expand Down
151 changes: 150 additions & 1 deletion packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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", () => {
Expand Down
107 changes: 107 additions & 0 deletions packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>
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",
Expand Down
Loading