Skip to content

Commit f5d2963

Browse files
committed
fix(opencode): port upstream deepseek reasoning_content fix
Cherry-pick the targeted DeepSeek interleaved-default tweak from upstream opencode (sst#24630, commit 738b306) and apply a properly scoped version of the empty-content preserving fix from #1. provider.ts (custom-provider parser only): - Default `interleaved` to { field: "reasoning_content" } only for brand-new openai-compatible providers whose api id contains "deepseek". Existing user/source config still wins via the ?? chain. fromModelsDevModel is intentionally left alone — models.dev already carries the explicit interleaved config for DeepSeek there. transform.ts (the existing interleaved branch): - Switch the providerOptions namespace from hard-coded "openaiCompatible" to sdkKey(model.api.npm) ?? "openaiCompatible" so providers wired through other AI SDKs land in the right slot. - Always emit the field (even when reasoningText is empty) so DeepSeek's follow-up tool-call requests stop 400-ing, and preserve any existing reasoning_content across re-transforms instead of overwriting with "". Deliberately does NOT add a fallback `if (model.capabilities.reasoning)` branch — that would inject reasoning_content into providerOptions for OpenAI o1/o3, Claude reasoning, Gemini thinking etc., which is not a valid key for those SDKs. tests: - Share a deepseekModel fixture across the DeepSeek tests. - Add empty-reasoning preservation regression. - Add re-transform existing-reasoning preservation regression. - Add reverse guard: OpenAI o1 (reasoning=true, interleaved=false) must NOT pick up reasoning_content in providerOptions. Supersedes #1 (that PR's broader scope would affect non-DeepSeek reasoning providers).
1 parent 0c9197b commit f5d2963

3 files changed

Lines changed: 164 additions & 60 deletions

File tree

packages/opencode/src/provider/provider.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,13 @@ export namespace Provider {
10491049

10501050
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
10511051
const existingModel = parsed.models[model.id ?? modelID]
1052+
const apiID = model.id ?? existingModel?.api.id ?? modelID
1053+
const apiNpm =
1054+
model.provider?.npm ??
1055+
provider.npm ??
1056+
existingModel?.api.npm ??
1057+
modelsDev[providerID]?.npm ??
1058+
"@ai-sdk/openai-compatible"
10521059
const name = iife(() => {
10531060
if (model.name) return model.name
10541061
if (model.id && model.id !== modelID) return modelID
@@ -1057,13 +1064,8 @@ export namespace Provider {
10571064
const parsedModel: Model = {
10581065
id: ModelID.make(modelID),
10591066
api: {
1060-
id: model.id ?? existingModel?.api.id ?? modelID,
1061-
npm:
1062-
model.provider?.npm ??
1063-
provider.npm ??
1064-
existingModel?.api.npm ??
1065-
modelsDev[providerID]?.npm ??
1066-
"@ai-sdk/openai-compatible",
1067+
id: apiID,
1068+
npm: apiNpm,
10671069
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
10681070
},
10691071
status: model.status ?? existingModel?.status ?? "active",
@@ -1088,7 +1090,12 @@ export namespace Provider {
10881090
video: model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
10891091
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
10901092
},
1091-
interleaved: model.interleaved ?? false,
1093+
interleaved:
1094+
model.interleaved ??
1095+
existingModel?.capabilities.interleaved ??
1096+
(!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.toLowerCase().includes("deepseek")
1097+
? { field: "reasoning_content" }
1098+
: false),
10921099
},
10931100
cost: {
10941101
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,

packages/opencode/src/provider/transform.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ export namespace ProviderTransform {
154154

155155
if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) {
156156
const field = model.capabilities.interleaved.field
157+
const sdk = sdkKey(model.api.npm) ?? "openaiCompatible"
157158
return msgs.map((msg) => {
158159
if (msg.role === "assistant" && Array.isArray(msg.content)) {
159160
const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning")
@@ -162,24 +163,26 @@ export namespace ProviderTransform {
162163
// Filter out reasoning parts from content
163164
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
164165

165-
// Include reasoning_content | reasoning_details directly on the message for all assistant messages
166-
if (reasoningText) {
167-
return {
168-
...msg,
169-
content: filteredContent,
170-
providerOptions: {
171-
...msg.providerOptions,
172-
openaiCompatible: {
173-
...(msg.providerOptions as any)?.openaiCompatible,
174-
[field]: reasoningText,
175-
},
176-
},
177-
}
178-
}
166+
// DeepSeek-style openai-compatible providers require the reasoning field
167+
// to be echoed back on every assistant message — even an empty string —
168+
// so follow-up tool-call requests don't 400. The DeepSeek normalize block
169+
// above also synthesizes an empty reasoning part on every assistant
170+
// message, so on a re-transform `reasoningText` is "" and we have to fall
171+
// back to whatever a previous pass already attached to providerOptions.
172+
const existing = (msg.providerOptions as any)?.[sdk]?.[field]
173+
const existingText = typeof existing === "string" ? existing : ""
174+
const resolvedText = reasoningText || existingText
179175

180176
return {
181177
...msg,
182178
content: filteredContent,
179+
providerOptions: {
180+
...msg.providerOptions,
181+
[sdk]: {
182+
...(msg.providerOptions as any)?.[sdk],
183+
[field]: resolvedText,
184+
},
185+
},
183186
}
184187
}
185188

packages/opencode/test/provider/transform.test.ts

Lines changed: 132 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,41 @@ describe("ProviderTransform.schema - gemini non-object properties removal", () =
788788
})
789789

790790
describe("ProviderTransform.message - DeepSeek reasoning content", () => {
791+
const deepseekModel = {
792+
id: ModelID.make("deepseek/deepseek-chat"),
793+
providerID: ProviderID.make("deepseek"),
794+
api: {
795+
id: "deepseek-chat",
796+
url: "https://api.deepseek.com",
797+
npm: "@ai-sdk/openai-compatible",
798+
},
799+
name: "DeepSeek Chat",
800+
capabilities: {
801+
temperature: true,
802+
reasoning: true,
803+
attachment: false,
804+
toolcall: true,
805+
input: { text: true, audio: false, image: false, video: false, pdf: false },
806+
output: { text: true, audio: false, image: false, video: false, pdf: false },
807+
interleaved: {
808+
field: "reasoning_content",
809+
},
810+
},
811+
cost: {
812+
input: 0.001,
813+
output: 0.002,
814+
cache: { read: 0.0001, write: 0.0002 },
815+
},
816+
limit: {
817+
context: 128000,
818+
output: 8192,
819+
},
820+
status: "active",
821+
options: {},
822+
headers: {},
823+
release_date: "2023-04-01",
824+
} as any
825+
791826
test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => {
792827
const msgs = [
793828
{
@@ -804,44 +839,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
804839
},
805840
] as any[]
806841

807-
const result = ProviderTransform.message(
808-
msgs,
809-
{
810-
id: ModelID.make("deepseek/deepseek-chat"),
811-
providerID: ProviderID.make("deepseek"),
812-
api: {
813-
id: "deepseek-chat",
814-
url: "https://api.deepseek.com",
815-
npm: "@ai-sdk/openai-compatible",
816-
},
817-
name: "DeepSeek Chat",
818-
capabilities: {
819-
temperature: true,
820-
reasoning: true,
821-
attachment: false,
822-
toolcall: true,
823-
input: { text: true, audio: false, image: false, video: false, pdf: false },
824-
output: { text: true, audio: false, image: false, video: false, pdf: false },
825-
interleaved: {
826-
field: "reasoning_content",
827-
},
828-
},
829-
cost: {
830-
input: 0.001,
831-
output: 0.002,
832-
cache: { read: 0.0001, write: 0.0002 },
833-
},
834-
limit: {
835-
context: 128000,
836-
output: 8192,
837-
},
838-
status: "active",
839-
options: {},
840-
headers: {},
841-
release_date: "2023-04-01",
842-
},
843-
{},
844-
)
842+
const result = ProviderTransform.message(msgs, deepseekModel, {})
845843

846844
expect(result).toHaveLength(1)
847845
expect(result[0].content).toEqual([
@@ -855,6 +853,102 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
855853
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Let me think about this...")
856854
})
857855

856+
test("DeepSeek emits empty reasoning_content when the assistant produced no reasoning", () => {
857+
// Follow-up tool calls regress with HTTP 400 unless the field is echoed back,
858+
// even as an empty string.
859+
const result = ProviderTransform.message(
860+
[
861+
{
862+
role: "assistant",
863+
content: [
864+
{ type: "reasoning", text: "" },
865+
{
866+
type: "tool-call",
867+
toolCallId: "test",
868+
toolName: "bash",
869+
input: { command: "echo hello" },
870+
},
871+
],
872+
},
873+
] as any[],
874+
deepseekModel,
875+
{},
876+
)
877+
878+
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("")
879+
})
880+
881+
test("DeepSeek preserves an existing reasoning_content across repeated transforms", () => {
882+
// After the first pass the reasoning lives in providerOptions, not in
883+
// content. Re-running the transform must not blow that field away.
884+
const result = ProviderTransform.message(
885+
[
886+
{
887+
role: "assistant",
888+
content: [{ type: "text", text: "Done" }],
889+
providerOptions: {
890+
openaiCompatible: {
891+
reasoning_content: "Stored thinking",
892+
},
893+
},
894+
},
895+
] as any[],
896+
deepseekModel,
897+
{},
898+
)
899+
900+
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Stored thinking")
901+
})
902+
903+
test("Reasoning models without an interleaved.field config do not get reasoning_content injected", () => {
904+
// Guard rail: only providers that explicitly configure `interleaved.field`
905+
// (DeepSeek-style openai-compatible) should pick up the reasoning_content
906+
// echo. OpenAI o1/o3, Claude reasoning, Gemini thinking — all reasoning=true
907+
// — must stay untouched so we don't pollute their providerOptions.
908+
const openaiReasoningModel = {
909+
id: ModelID.make("openai/o1-mini"),
910+
providerID: ProviderID.make("openai"),
911+
api: {
912+
id: "o1-mini",
913+
url: "https://api.openai.com",
914+
npm: "@ai-sdk/openai",
915+
},
916+
name: "OpenAI o1-mini",
917+
capabilities: {
918+
temperature: false,
919+
reasoning: true,
920+
attachment: false,
921+
toolcall: true,
922+
input: { text: true, audio: false, image: false, video: false, pdf: false },
923+
output: { text: true, audio: false, image: false, video: false, pdf: false },
924+
interleaved: false,
925+
},
926+
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
927+
limit: { context: 128000, output: 65536 },
928+
status: "active",
929+
options: {},
930+
headers: {},
931+
release_date: "2024-09-12",
932+
} as any
933+
934+
const result = ProviderTransform.message(
935+
[
936+
{
937+
role: "assistant",
938+
content: [
939+
{ type: "reasoning", text: "internal thought" },
940+
{ type: "text", text: "Answer" },
941+
],
942+
},
943+
] as any[],
944+
openaiReasoningModel,
945+
{},
946+
)
947+
948+
expect((result[0].providerOptions as any)?.openai?.reasoning_content).toBeUndefined()
949+
expect((result[0].providerOptions as any)?.openaiCompatible?.reasoning_content).toBeUndefined()
950+
})
951+
858952
test("Non-DeepSeek providers leave reasoning content unchanged", () => {
859953
const msgs = [
860954
{

0 commit comments

Comments
 (0)