Skip to content

Commit 0dbf98d

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). (cherry picked from commit f5d2963)
1 parent 6eb9a41 commit 0dbf98d

3 files changed

Lines changed: 174 additions & 107 deletions

File tree

packages/opencode/src/provider/provider.ts

Lines changed: 25 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,60 +1352,25 @@ export const layer = Layer.effect(
13521352
})),
13531353
}
13541354

1355-
function isProviderAllowed(providerID: ProviderID): boolean {
1356-
if (enabled && !enabled.has(providerID)) return false
1357-
if (disabled.has(providerID)) return false
1358-
return true
1359-
}
1360-
1361-
for (const hook of plugins) {
1362-
const p = hook.provider
1363-
const models = p?.models
1364-
if (!p || !models) continue
1365-
1366-
const providerID = ProviderID.make(p.id)
1367-
if (disabled.has(providerID)) continue
1368-
1369-
const provider = database[providerID]
1370-
if (!provider) continue
1371-
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
1372-
1373-
provider.models = yield* Effect.promise(async () => {
1374-
const next = await models(toPublicInfo(provider), { auth: pluginAuth })
1375-
return Object.fromEntries(
1376-
Object.entries(next).map(([id, model]) => [
1377-
id,
1378-
{
1379-
...model,
1380-
id: ModelID.make(id),
1381-
providerID,
1382-
},
1383-
]),
1384-
)
1385-
})
1386-
}
1387-
1388-
// extend database from config
1389-
for (const [providerID, provider] of configProviders) {
1390-
const existing = database[providerID]
1391-
const parsed: Info = {
1392-
id: ProviderID.make(providerID),
1393-
name: provider.name ?? existing?.name ?? providerID,
1394-
env: provider.env ?? existing?.env ?? [],
1395-
options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
1396-
source: "config",
1397-
models: existing?.models ?? {},
1398-
}
1399-
1400-
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
1401-
const existingModel = parsed.models[model.id ?? modelID]
1402-
const apiID = model.id ?? existingModel?.api.id ?? modelID
1403-
const apiNpm =
1404-
model.provider?.npm ??
1405-
provider.npm ??
1406-
existingModel?.api.npm ??
1407-
modelsDev[providerID]?.npm ??
1408-
"@ai-sdk/openai-compatible",
1355+
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
1356+
const existingModel = parsed.models[model.id ?? modelID]
1357+
const apiID = model.id ?? existingModel?.api.id ?? modelID
1358+
const apiNpm =
1359+
model.provider?.npm ??
1360+
provider.npm ??
1361+
existingModel?.api.npm ??
1362+
modelsDev[providerID]?.npm ??
1363+
"@ai-sdk/openai-compatible"
1364+
const name = iife(() => {
1365+
if (model.name) return model.name
1366+
if (model.id && model.id !== modelID) return modelID
1367+
return existingModel?.name ?? modelID
1368+
})
1369+
const parsedModel: Model = {
1370+
id: ModelID.make(modelID),
1371+
api: {
1372+
id: apiID,
1373+
npm: apiNpm,
14091374
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
14101375
},
14111376
status: model.status ?? existingModel?.status ?? "active",
@@ -1430,7 +1395,12 @@ export const layer = Layer.effect(
14301395
video: model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
14311396
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
14321397
},
1433-
interleaved: model.interleaved ?? false,
1398+
interleaved:
1399+
model.interleaved ??
1400+
existingModel?.capabilities.interleaved ??
1401+
(!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.toLowerCase().includes("deepseek")
1402+
? { field: "reasoning_content" }
1403+
: false),
14341404
},
14351405
cost: {
14361406
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
@@ -159,6 +159,7 @@ function normalizeMessages(
159159

160160
if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) {
161161
const field = model.capabilities.interleaved.field
162+
const sdk = sdkKey(model.api.npm) ?? "openaiCompatible"
162163
return msgs.map((msg) => {
163164
if (msg.role === "assistant" && Array.isArray(msg.content)) {
164165
const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning")
@@ -167,24 +168,26 @@ function normalizeMessages(
167168
// Filter out reasoning parts from content
168169
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
169170

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

185181
return {
186182
...msg,
187183
content: filteredContent,
184+
providerOptions: {
185+
...msg.providerOptions,
186+
[sdk]: {
187+
...(msg.providerOptions as any)?.[sdk],
188+
[field]: resolvedText,
189+
},
190+
},
188191
}
189192
}
190193

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

Lines changed: 132 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,6 +1069,41 @@ describe("ProviderTransform.schema - moonshot $ref siblings", () => {
10691069
})
10701070

10711071
describe("ProviderTransform.message - DeepSeek reasoning content", () => {
1072+
const deepseekModel = {
1073+
id: ModelID.make("deepseek/deepseek-chat"),
1074+
providerID: ProviderID.make("deepseek"),
1075+
api: {
1076+
id: "deepseek-chat",
1077+
url: "https://api.deepseek.com",
1078+
npm: "@ai-sdk/openai-compatible",
1079+
},
1080+
name: "DeepSeek Chat",
1081+
capabilities: {
1082+
temperature: true,
1083+
reasoning: true,
1084+
attachment: false,
1085+
toolcall: true,
1086+
input: { text: true, audio: false, image: false, video: false, pdf: false },
1087+
output: { text: true, audio: false, image: false, video: false, pdf: false },
1088+
interleaved: {
1089+
field: "reasoning_content",
1090+
},
1091+
},
1092+
cost: {
1093+
input: 0.001,
1094+
output: 0.002,
1095+
cache: { read: 0.0001, write: 0.0002 },
1096+
},
1097+
limit: {
1098+
context: 128000,
1099+
output: 8192,
1100+
},
1101+
status: "active",
1102+
options: {},
1103+
headers: {},
1104+
release_date: "2023-04-01",
1105+
} as any
1106+
10721107
test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => {
10731108
const msgs = [
10741109
{
@@ -1085,44 +1120,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
10851120
},
10861121
] as any[]
10871122

1088-
const result = ProviderTransform.message(
1089-
msgs,
1090-
{
1091-
id: ModelID.make("deepseek/deepseek-chat"),
1092-
providerID: ProviderID.make("deepseek"),
1093-
api: {
1094-
id: "deepseek-chat",
1095-
url: "https://api.deepseek.com",
1096-
npm: "@ai-sdk/openai-compatible",
1097-
},
1098-
name: "DeepSeek Chat",
1099-
capabilities: {
1100-
temperature: true,
1101-
reasoning: true,
1102-
attachment: false,
1103-
toolcall: true,
1104-
input: { text: true, audio: false, image: false, video: false, pdf: false },
1105-
output: { text: true, audio: false, image: false, video: false, pdf: false },
1106-
interleaved: {
1107-
field: "reasoning_content",
1108-
},
1109-
},
1110-
cost: {
1111-
input: 0.001,
1112-
output: 0.002,
1113-
cache: { read: 0.0001, write: 0.0002 },
1114-
},
1115-
limit: {
1116-
context: 128000,
1117-
output: 8192,
1118-
},
1119-
status: "active",
1120-
options: {},
1121-
headers: {},
1122-
release_date: "2023-04-01",
1123-
},
1124-
{},
1125-
)
1123+
const result = ProviderTransform.message(msgs, deepseekModel, {})
11261124

11271125
expect(result).toHaveLength(1)
11281126
expect(result[0].content).toEqual([
@@ -1136,6 +1134,102 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
11361134
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Let me think about this...")
11371135
})
11381136

1137+
test("DeepSeek emits empty reasoning_content when the assistant produced no reasoning", () => {
1138+
// Follow-up tool calls regress with HTTP 400 unless the field is echoed back,
1139+
// even as an empty string.
1140+
const result = ProviderTransform.message(
1141+
[
1142+
{
1143+
role: "assistant",
1144+
content: [
1145+
{ type: "reasoning", text: "" },
1146+
{
1147+
type: "tool-call",
1148+
toolCallId: "test",
1149+
toolName: "bash",
1150+
input: { command: "echo hello" },
1151+
},
1152+
],
1153+
},
1154+
] as any[],
1155+
deepseekModel,
1156+
{},
1157+
)
1158+
1159+
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("")
1160+
})
1161+
1162+
test("DeepSeek preserves an existing reasoning_content across repeated transforms", () => {
1163+
// After the first pass the reasoning lives in providerOptions, not in
1164+
// content. Re-running the transform must not blow that field away.
1165+
const result = ProviderTransform.message(
1166+
[
1167+
{
1168+
role: "assistant",
1169+
content: [{ type: "text", text: "Done" }],
1170+
providerOptions: {
1171+
openaiCompatible: {
1172+
reasoning_content: "Stored thinking",
1173+
},
1174+
},
1175+
},
1176+
] as any[],
1177+
deepseekModel,
1178+
{},
1179+
)
1180+
1181+
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Stored thinking")
1182+
})
1183+
1184+
test("Reasoning models without an interleaved.field config do not get reasoning_content injected", () => {
1185+
// Guard rail: only providers that explicitly configure `interleaved.field`
1186+
// (DeepSeek-style openai-compatible) should pick up the reasoning_content
1187+
// echo. OpenAI o1/o3, Claude reasoning, Gemini thinking — all reasoning=true
1188+
// — must stay untouched so we don't pollute their providerOptions.
1189+
const openaiReasoningModel = {
1190+
id: ModelID.make("openai/o1-mini"),
1191+
providerID: ProviderID.make("openai"),
1192+
api: {
1193+
id: "o1-mini",
1194+
url: "https://api.openai.com",
1195+
npm: "@ai-sdk/openai",
1196+
},
1197+
name: "OpenAI o1-mini",
1198+
capabilities: {
1199+
temperature: false,
1200+
reasoning: true,
1201+
attachment: false,
1202+
toolcall: true,
1203+
input: { text: true, audio: false, image: false, video: false, pdf: false },
1204+
output: { text: true, audio: false, image: false, video: false, pdf: false },
1205+
interleaved: false,
1206+
},
1207+
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
1208+
limit: { context: 128000, output: 65536 },
1209+
status: "active",
1210+
options: {},
1211+
headers: {},
1212+
release_date: "2024-09-12",
1213+
} as any
1214+
1215+
const result = ProviderTransform.message(
1216+
[
1217+
{
1218+
role: "assistant",
1219+
content: [
1220+
{ type: "reasoning", text: "internal thought" },
1221+
{ type: "text", text: "Answer" },
1222+
],
1223+
},
1224+
] as any[],
1225+
openaiReasoningModel,
1226+
{},
1227+
)
1228+
1229+
expect((result[0].providerOptions as any)?.openai?.reasoning_content).toBeUndefined()
1230+
expect((result[0].providerOptions as any)?.openaiCompatible?.reasoning_content).toBeUndefined()
1231+
})
1232+
11391233
test("Non-DeepSeek providers leave reasoning content unchanged", () => {
11401234
const msgs = [
11411235
{

0 commit comments

Comments
 (0)