Skip to content

Commit 678ed5d

Browse files
committed
fix(deepseek): normalize V4 tool-call replay
1 parent c81b3ab commit 678ed5d

4 files changed

Lines changed: 116 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai
7070
### Fixes
7171

7272
- Agents/Gemini: retry reasoning-only, empty, and planning-only Gemini turns instead of letting sessions silently stall. Fixes #71074. (#71362) Thanks @neeravmakwana.
73+
- Providers/DeepSeek: add missing `reasoning_content` placeholders for replayed assistant tool-call turns when DeepSeek V4 thinking is enabled, so switching an existing session to `deepseek-v4-flash` or `deepseek-v4-pro` no longer trips the provider's 400 replay check. Fixes #71372. Thanks @yangyang1719.
7374
- Exec approvals: allow bare command-name allowlist patterns to match PATH-resolved executable basenames without trusting `./tool` or absolute path-selected binaries. Fixes #71315. Thanks @chen-zhang-cs-code and @dengluozhang.
7475
- Config/recovery: skip whole-file last-known-good rollback when invalidity is scoped to `plugins.entries.*`, preserving unrelated user settings during plugin schema or host-version skew. Fixes #71289. Thanks @jalehman.
7576
- Agents/tools: keep resolved reply-run configs from being overwritten by stale runtime snapshots, and let empty web runtime metadata fall back to configured provider auto-detection so standard and queued turns expose the same tool set. Fixes #71355. Thanks @c-g14.

docs/providers/deepseek.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ back on the follow-up request. OpenClaw handles this inside the DeepSeek plugin,
9090
so normal multi-turn tool use works with `deepseek/deepseek-v4-flash` and
9191
`deepseek/deepseek-v4-pro`.
9292

93+
If you switch an existing session from another OpenAI-compatible provider to a
94+
DeepSeek V4 model, older assistant tool-call turns may not have native
95+
DeepSeek `reasoning_content`. OpenClaw fills that missing field for DeepSeek V4
96+
thinking requests so the provider can accept the replayed tool-call history
97+
without requiring `/new`.
98+
9399
When thinking is disabled in OpenClaw (including the UI **None** selection),
94100
OpenClaw sends DeepSeek `thinking: { type: "disabled" }` and strips replayed
95101
`reasoning_content` from the outgoing history. This keeps disabled-thinking

extensions/deepseek/index.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,96 @@ describe("deepseek provider plugin", () => {
217217
});
218218
});
219219

220+
it("adds blank reasoning_content for replayed tool calls from non-DeepSeek turns", async () => {
221+
let capturedPayload: Record<string, unknown> | undefined;
222+
const model = {
223+
provider: "deepseek",
224+
id: "deepseek-v4-pro",
225+
name: "DeepSeek V4 Pro",
226+
api: "openai-completions",
227+
baseUrl: "https://api.deepseek.com",
228+
reasoning: true,
229+
input: ["text"],
230+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
231+
contextWindow: 1_000_000,
232+
maxTokens: 384_000,
233+
compat: {
234+
supportsUsageInStreaming: true,
235+
supportsReasoningEffort: true,
236+
maxTokensField: "max_tokens",
237+
},
238+
} as Model<"openai-completions">;
239+
const context = {
240+
messages: [
241+
{ role: "user", content: "hi", timestamp: 1 },
242+
{
243+
role: "assistant",
244+
api: "openai-completions",
245+
provider: "openai",
246+
model: "gpt-5.4",
247+
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
248+
usage: {
249+
input: 0,
250+
output: 0,
251+
cacheRead: 0,
252+
cacheWrite: 0,
253+
totalTokens: 0,
254+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
255+
},
256+
stopReason: "toolUse",
257+
timestamp: 2,
258+
},
259+
{
260+
role: "toolResult",
261+
toolCallId: "call_1",
262+
toolName: "read",
263+
content: [{ type: "text", text: "ok" }],
264+
isError: false,
265+
timestamp: 3,
266+
},
267+
],
268+
tools: [
269+
{
270+
name: "read",
271+
description: "Read data",
272+
parameters: { type: "object", properties: {}, required: [], additionalProperties: false },
273+
},
274+
],
275+
} as Context;
276+
const baseStreamFn = (
277+
streamModel: Model<"openai-completions">,
278+
streamContext: Context,
279+
options?: { onPayload?: (payload: unknown, model: unknown) => unknown },
280+
) => {
281+
capturedPayload = buildOpenAICompletionsParams(streamModel, streamContext, {
282+
reasoning: "high",
283+
} as never);
284+
options?.onPayload?.(capturedPayload, streamModel);
285+
const stream = createAssistantMessageEventStream();
286+
queueMicrotask(() => stream.end());
287+
return stream;
288+
};
289+
290+
const wrapThinkingHigh = createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "high");
291+
expect(wrapThinkingHigh).toBeDefined();
292+
wrapThinkingHigh?.(model, context, {});
293+
294+
expect((capturedPayload?.messages as Array<Record<string, unknown>>)[1]).toMatchObject({
295+
role: "assistant",
296+
reasoning_content: "",
297+
tool_calls: [
298+
{
299+
id: "call_1",
300+
type: "function",
301+
function: {
302+
name: "read",
303+
arguments: "{}",
304+
},
305+
},
306+
],
307+
});
308+
});
309+
220310
it("strips replayed reasoning_content when DeepSeek V4 thinking is disabled", async () => {
221311
let capturedPayload: Record<string, unknown> | undefined;
222312
const model = {

extensions/deepseek/stream.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,24 @@ function stripDeepSeekReasoningContent(payload: Record<string, unknown>): void {
2828
}
2929
}
3030

31+
function ensureDeepSeekToolCallReasoningContent(payload: Record<string, unknown>): void {
32+
if (!Array.isArray(payload.messages)) {
33+
return;
34+
}
35+
for (const message of payload.messages) {
36+
if (!message || typeof message !== "object") {
37+
continue;
38+
}
39+
const record = message as Record<string, unknown>;
40+
if (record.role !== "assistant" || !Array.isArray(record.tool_calls)) {
41+
continue;
42+
}
43+
if (!("reasoning_content" in record)) {
44+
record.reasoning_content = "";
45+
}
46+
}
47+
}
48+
3149
export function createDeepSeekV4ThinkingWrapper(
3250
baseStreamFn: ProviderWrapStreamFnContext["streamFn"],
3351
thinkingLevel: DeepSeekThinkingLevel,
@@ -52,6 +70,7 @@ export function createDeepSeekV4ThinkingWrapper(
5270

5371
payload.thinking = { type: "enabled" };
5472
payload.reasoning_effort = resolveDeepSeekReasoningEffort(thinkingLevel);
73+
ensureDeepSeekToolCallReasoningContent(payload);
5574
});
5675
};
5776
}

0 commit comments

Comments
 (0)