Skip to content

Commit 8d404f6

Browse files
See USee U
authored andcommitted
feat(opencode): native deepseek reasoning support and 400 recovery hint
1 parent 4afdb14 commit 8d404f6

4 files changed

Lines changed: 168 additions & 2 deletions

File tree

packages/opencode/src/provider/error.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ function message(providerID: ProviderV2.ID, e: APICallError) {
4141
return "Unknown error"
4242
}
4343

44+
// DeepSeek rejects requests that drop a prior turn's
45+
// `reasoning_content` (e.g. after history edits or a model switch);
46+
// the reliable recovery is a fresh session.
47+
if (e.statusCode === 400 && providerID.includes("deepseek")) {
48+
const body = json(e.responseBody)
49+
const errMsg = body?.error?.message ?? body?.message ?? msg
50+
if (typeof errMsg === "string" && errMsg.includes("reasoning_content"))
51+
return `${msg}${msg === errMsg ? "" : `: ${errMsg}`} — DeepSeek requires previous reasoning to be resent verbatim; start a new session if this error persists.`
52+
}
53+
4454
if (!e.responseBody || (e.statusCode && msg !== STATUS_CODES[e.statusCode])) {
4555
return msg
4656
}

packages/opencode/src/session/llm/native-request.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,24 @@ const messages = (input: readonly ModelMessage[]) => {
110110
Message.make({
111111
role: message.role,
112112
content: content(message.content),
113-
native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined,
113+
native: messageNative(message),
114114
}),
115115
]
116116
})
117117
return { system, messages }
118118
}
119119

120+
// The openai-chat protocol reads continuation metadata from
121+
// `message.native.openaiCompatible` (e.g. DeepSeek's mandatory
122+
// `reasoning_content` echo on assistant turns). The AI SDK stores the same
123+
// data on `providerOptions.openaiCompatible`; project it so the echo
124+
// survives native request lowering and tool-turn requests stay valid.
125+
const messageNative = (message: ModelMessage): Message["native"] | undefined => {
126+
const options = isRecord(message.providerOptions) ? message.providerOptions : undefined
127+
if (!isRecord(options?.openaiCompatible)) return undefined
128+
return { openaiCompatible: options.openaiCompatible }
129+
}
130+
120131
const schema = (value: unknown): JsonSchema => {
121132
if (!isRecord(value)) return { type: "object", properties: {} }
122133
if (isRecord(value.jsonSchema)) return value.jsonSchema
@@ -189,8 +200,23 @@ export const request = (input: RequestInput) => {
189200
tools: tools(input.tools),
190201
toolChoice: input.toolChoice,
191202
generation: generation(input),
192-
providerOptions: input.providerOptions,
203+
providerOptions: providerOptions(input),
193204
})
194205
}
195206

207+
// opencode-managed OpenAI-compatible providers key model options under the
208+
// dot-split providerID prefix (e.g. "opencode" for "opencode.deepseek"); the
209+
// openai-chat protocol reads the "openai" key. Translate so reasoning
210+
// variants (effort, thinking toggle) reach the native request body.
211+
function providerOptions(input: RequestInput): LLMRequest["providerOptions"] {
212+
const options = input.providerOptions
213+
if (!isRecord(options) || input.model.api.npm !== "@ai-sdk/openai-compatible") return options
214+
const key = input.model.providerID.split(".")[0]
215+
if (key === "openai") return options
216+
const value = options[key]
217+
if (!isRecord(value)) return options
218+
const openai = isRecord(options.openai) ? { ...value, ...options.openai } : value
219+
return { ...options, openai }
220+
}
221+
196222
export * as LLMNative from "./native-request"
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { APICallError } from "ai"
3+
import { ProviderV2 } from "@opencode-ai/core/provider"
4+
import { ProviderError } from "@/provider/error"
5+
6+
const deepseekReasoningContent = (statusCode: number) =>
7+
new APICallError({
8+
message: `${statusCode} Bad Request`,
9+
url: "https://api.deepseek.com/v1/chat/completions",
10+
statusCode,
11+
responseBody: JSON.stringify({
12+
error: {
13+
message:
14+
"This model version does not support reasoning_content, please resend all assistant messages that contain reasoning_content",
15+
},
16+
}),
17+
})
18+
19+
const parsedApiError = (parsed: ProviderError.ParsedAPICallError) => {
20+
if (parsed.type !== "api_error") throw new Error("expected api_error")
21+
return parsed
22+
}
23+
24+
describe("ProviderError.parseAPICallError", () => {
25+
test("adds a DeepSeek reasoning_content recovery hint on 400", () => {
26+
const parsed = parsedApiError(
27+
ProviderError.parseAPICallError({
28+
providerID: ProviderV2.ID.make("deepseek"),
29+
error: deepseekReasoningContent(400),
30+
}),
31+
)
32+
33+
expect(parsed.message).toContain("DeepSeek requires previous reasoning to be resent verbatim")
34+
})
35+
36+
test("does not hint when the DeepSeek error does not mention reasoning_content", () => {
37+
const error = new APICallError({
38+
message: "400 Bad Request",
39+
url: "https://api.deepseek.com/v1/chat/completions",
40+
statusCode: 400,
41+
responseBody: JSON.stringify({ error: { message: "Invalid model" } }),
42+
})
43+
const parsed = parsedApiError(
44+
ProviderError.parseAPICallError({
45+
providerID: ProviderV2.ID.make("deepseek"),
46+
error,
47+
}),
48+
)
49+
50+
expect(parsed.message).not.toContain("resent verbatim")
51+
})
52+
53+
test("does not hint for non-DeepSeek providers", () => {
54+
const parsed = parsedApiError(
55+
ProviderError.parseAPICallError({
56+
providerID: ProviderV2.ID.make("openai"),
57+
error: deepseekReasoningContent(400),
58+
}),
59+
)
60+
61+
expect(parsed.message).not.toContain("resent verbatim")
62+
})
63+
64+
test("does not crash when the error body message is not a string", () => {
65+
const error = new APICallError({
66+
message: "400 Bad Request",
67+
url: "https://api.deepseek.com/v1/chat/completions",
68+
statusCode: 400,
69+
responseBody: JSON.stringify({ error: { message: { code: "x" } } }),
70+
})
71+
const parsed = parsedApiError(
72+
ProviderError.parseAPICallError({
73+
providerID: ProviderV2.ID.make("deepseek"),
74+
error,
75+
}),
76+
)
77+
78+
expect(parsed.message).toBe("400 Bad Request")
79+
})
80+
})

packages/opencode/test/session/llm-native.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,56 @@ describe("session.llm-native.request", () => {
332332
])
333333
})
334334

335+
test("projects AI SDK openaiCompatible continuation metadata into native message metadata", () => {
336+
const request = LLMNative.request({
337+
model: {
338+
...baseModel,
339+
providerID: ProviderV2.ID.make("opencode"),
340+
api: { ...baseModel.api, url: "https://ai.example.test/v1", npm: "@ai-sdk/openai-compatible" },
341+
},
342+
messages: [
343+
{
344+
role: "assistant",
345+
content: [{ type: "text", text: "Hello" }],
346+
providerOptions: { openaiCompatible: { reasoning_content: "thinking" } },
347+
},
348+
],
349+
})
350+
351+
expect(request.messages).toMatchObject([
352+
{
353+
role: "assistant",
354+
native: { openaiCompatible: { reasoning_content: "thinking" } },
355+
},
356+
])
357+
})
358+
359+
test("remaps opencode-keyed provider options to the openai key for native openai-compatible routes", () => {
360+
const request = LLMNative.request({
361+
model: {
362+
...baseModel,
363+
providerID: ProviderV2.ID.make("opencode.deepseek"),
364+
api: { ...baseModel.api, url: "https://ai.example.test/v1", npm: "@ai-sdk/openai-compatible" },
365+
},
366+
messages: [{ role: "user", content: "hi" }],
367+
providerOptions: { opencode: { reasoningEffort: "max", thinking: { type: "disabled" } } },
368+
})
369+
370+
expect(request.providerOptions).toMatchObject({
371+
openai: { reasoningEffort: "max", thinking: { type: "disabled" } },
372+
})
373+
})
374+
375+
test("leaves openai-keyed provider options untouched for native openai routes", () => {
376+
const request = LLMNative.request({
377+
model: baseModel,
378+
messages: [{ role: "user", content: "hi" }],
379+
providerOptions: { openai: { store: false } },
380+
})
381+
382+
expect(request.providerOptions).toEqual({ openai: { store: false } })
383+
})
384+
335385
test("selects native request routes for provider packages", () => {
336386
const openai = LLMNative.model({
337387
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },

0 commit comments

Comments
 (0)