Skip to content

Commit 586afa8

Browse files
egavrindevagent
andcommitted
fix(providers): sanitize DeepSeek history
- Normalize DeepSeek request history before sending it to the API - Prune unreplayable legacy tool calls and orphan tool results - Preserve reasoning for complete tool-use turns Co-Authored-By: devagent <devagent@egavrin>
1 parent f175c76 commit 586afa8

2 files changed

Lines changed: 320 additions & 18 deletions

File tree

packages/providers/src/deepseek.ts

Lines changed: 178 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ interface DeepSeekUsage {
3131
readonly completion_tokens?: number | null;
3232
}
3333

34+
interface PreparedDeepSeekHistory {
35+
readonly messages: Array<Record<string, unknown>>;
36+
readonly validToolCallIds: ReadonlySet<string>;
37+
}
38+
39+
interface NormalizedDeepSeekTurn {
40+
readonly messages: Array<Record<string, unknown>>;
41+
readonly validToolCallIds: ReadonlySet<string>;
42+
}
43+
3444
/**
3545
* DeepSeek's thinking-mode Chat Completions protocol has one non-OpenAI quirk:
3646
* assistant tool-call messages must be replayed with reasoning_content.
@@ -93,7 +103,7 @@ function buildRequestBody(
93103
const caps = resolveCapabilities(config.capabilities);
94104
const body: Record<string, unknown> = {
95105
model: config.model,
96-
messages: messages.map(convertDeepSeekMessage),
106+
messages: prepareDeepSeekHistory(messages),
97107
stream: true,
98108
stream_options: { include_usage: true },
99109
max_tokens: config.maxTokens ?? caps.defaultMaxTokens,
@@ -116,13 +126,96 @@ function buildRequestBody(
116126
return body;
117127
}
118128

119-
function convertDeepSeekMessage(message: Message): Record<string, unknown> {
120-
if (message.role === MessageRole.SYSTEM) {
121-
return { role: "system", content: message.content ?? "" };
129+
function prepareDeepSeekHistory(messages: ReadonlyArray<Message>): Array<Record<string, unknown>> {
130+
const prepared = normalizeDeepSeekHistory(messages);
131+
validateDeepSeekHistory(prepared);
132+
return prepared.messages;
133+
}
134+
135+
function normalizeDeepSeekHistory(messages: ReadonlyArray<Message>): PreparedDeepSeekHistory {
136+
const validToolCallIds = new Set<string>();
137+
const converted: Array<Record<string, unknown>> = [];
138+
let turn: Message[] = [];
139+
140+
for (const message of messages) {
141+
if (message.role === MessageRole.SYSTEM) {
142+
flushDeepSeekTurn(turn, converted, validToolCallIds);
143+
turn = [];
144+
converted.push({ role: "system", content: message.content ?? "" });
145+
continue;
146+
}
147+
if (message.role === MessageRole.USER) {
148+
flushDeepSeekTurn(turn, converted, validToolCallIds);
149+
turn = [];
150+
converted.push({ role: "user", content: message.content ?? "" });
151+
continue;
152+
}
153+
turn.push(message);
122154
}
123-
if (message.role === MessageRole.USER) {
124-
return { role: "user", content: message.content ?? "" };
155+
flushDeepSeekTurn(turn, converted, validToolCallIds);
156+
157+
return { messages: converted, validToolCallIds };
158+
}
159+
160+
function flushDeepSeekTurn(
161+
turn: ReadonlyArray<Message>,
162+
converted: Array<Record<string, unknown>>,
163+
validToolCallIds: Set<string>,
164+
): void {
165+
if (turn.length === 0) return;
166+
const normalized = normalizeDeepSeekTurn(turn);
167+
for (const id of normalized.validToolCallIds) validToolCallIds.add(id);
168+
converted.push(...normalized.messages);
169+
}
170+
171+
function normalizeDeepSeekTurn(turn: ReadonlyArray<Message>): NormalizedDeepSeekTurn {
172+
const droppedToolCallIds = collectUnreplayableDeepSeekToolCallIds(turn);
173+
const validToolCallIds = collectReplayableDeepSeekToolCallIds(turn);
174+
const hasToolUse = validToolCallIds.size > 0;
175+
const messages: Array<Record<string, unknown>> = [];
176+
177+
for (const message of turn) {
178+
if (shouldDropDeepSeekToolResult(message, droppedToolCallIds, validToolCallIds)) continue;
179+
const normalized = convertDeepSeekTurnMessage(message, droppedToolCallIds, hasToolUse);
180+
if (normalized) messages.push(normalized);
181+
}
182+
183+
return { messages, validToolCallIds };
184+
}
185+
186+
function collectUnreplayableDeepSeekToolCallIds(messages: ReadonlyArray<Message>): Set<string> {
187+
const dropped = new Set<string>();
188+
for (const message of messages) {
189+
if (message.role !== MessageRole.ASSISTANT || message.thinking || !message.toolCalls?.length) continue;
190+
for (const toolCall of message.toolCalls) dropped.add(toolCall.callId);
191+
}
192+
return dropped;
193+
}
194+
195+
function collectReplayableDeepSeekToolCallIds(messages: ReadonlyArray<Message>): Set<string> {
196+
const valid = new Set<string>();
197+
for (const message of messages) {
198+
if (message.role !== MessageRole.ASSISTANT || !message.thinking || !message.toolCalls?.length) continue;
199+
for (const toolCall of message.toolCalls) valid.add(toolCall.callId);
125200
}
201+
return valid;
202+
}
203+
204+
function shouldDropDeepSeekToolResult(
205+
message: Message,
206+
droppedToolCallIds: ReadonlySet<string>,
207+
validToolCallIds: ReadonlySet<string>,
208+
): boolean {
209+
if (message.role !== MessageRole.TOOL) return false;
210+
if (!message.toolCallId) return true;
211+
return droppedToolCallIds.has(message.toolCallId) || !validToolCallIds.has(message.toolCallId);
212+
}
213+
214+
function convertDeepSeekTurnMessage(
215+
message: Message,
216+
droppedToolCallIds: ReadonlySet<string>,
217+
hasToolUse: boolean,
218+
): Record<string, unknown> | null {
126219
if (message.role === MessageRole.TOOL) {
127220
return {
128221
role: "tool",
@@ -135,22 +228,90 @@ function convertDeepSeekMessage(message: Message): Record<string, unknown> {
135228
role: "assistant",
136229
content: message.content ?? "",
137230
};
138-
if (message.thinking && message.toolCalls?.length) {
139-
converted["reasoning_content"] = message.thinking;
231+
if (!message.toolCalls?.length) {
232+
if (message.thinking && hasToolUse) converted["reasoning_content"] = message.thinking;
233+
return hasToolUse && !message.thinking ? null : converted;
140234
}
141-
if (message.toolCalls?.length) {
142-
converted["tool_calls"] = message.toolCalls.map((toolCall) => ({
143-
id: toolCall.callId,
144-
type: "function",
145-
function: {
146-
name: toolCall.name,
147-
arguments: JSON.stringify(toolCall.arguments),
148-
},
149-
}));
235+
if (!message.thinking) {
236+
return hasToolUse ? null : message.content?.trim() ? converted : null;
150237
}
238+
239+
const toolCalls = message.toolCalls.filter((toolCall) => !droppedToolCallIds.has(toolCall.callId));
240+
if (toolCalls.length === 0) return message.content?.trim() ? converted : null;
241+
242+
converted["reasoning_content"] = message.thinking;
243+
converted["tool_calls"] = toolCalls.map(convertDeepSeekToolCall);
151244
return converted;
152245
}
153246

247+
function convertDeepSeekToolCall(toolCall: NonNullable<Message["toolCalls"]>[number]): Record<string, unknown> {
248+
return {
249+
id: toolCall.callId,
250+
type: "function",
251+
function: {
252+
name: toolCall.name,
253+
arguments: JSON.stringify(toolCall.arguments),
254+
},
255+
};
256+
}
257+
258+
function validateDeepSeekHistory(prepared: PreparedDeepSeekHistory): void {
259+
for (const turn of splitDeepSeekHistoryTurns(prepared.messages)) {
260+
const hasToolUse = turn.some((message) => readDeepSeekToolCalls(message).length > 0);
261+
for (const message of turn) {
262+
validateDeepSeekAssistantMessage(message, hasToolUse);
263+
validateDeepSeekToolMessage(message, prepared.validToolCallIds);
264+
}
265+
}
266+
}
267+
268+
function splitDeepSeekHistoryTurns(messages: ReadonlyArray<Record<string, unknown>>): Array<Array<Record<string, unknown>>> {
269+
const turns: Array<Array<Record<string, unknown>>> = [];
270+
let current: Array<Record<string, unknown>> = [];
271+
for (const message of messages) {
272+
if (message["role"] === "user" || message["role"] === "system") {
273+
if (current.length > 0) turns.push(current);
274+
current = [message];
275+
} else {
276+
current.push(message);
277+
}
278+
}
279+
if (current.length > 0) turns.push(current);
280+
return turns;
281+
}
282+
283+
function validateDeepSeekAssistantMessage(message: Record<string, unknown>, hasToolUse: boolean): void {
284+
if (message["role"] !== "assistant") return;
285+
const toolCalls = readDeepSeekToolCalls(message);
286+
for (const toolCall of toolCalls) {
287+
if (typeof toolCall.id !== "string" || toolCall.id.length === 0) {
288+
throw new ProviderError("DeepSeek history error: assistant tool calls require non-empty ids");
289+
}
290+
}
291+
if (toolCalls.length > 0 && typeof message["reasoning_content"] !== "string") {
292+
throw new ProviderError("DeepSeek history error: assistant tool calls require reasoning_content");
293+
}
294+
if (toolCalls.length === 0 && "reasoning_content" in message && !hasToolUse) {
295+
throw new ProviderError("DeepSeek history error: final assistant messages must not include reasoning_content");
296+
}
297+
}
298+
299+
function validateDeepSeekToolMessage(
300+
message: Record<string, unknown>,
301+
validToolCallIds: ReadonlySet<string>,
302+
): void {
303+
if (message["role"] !== "tool") return;
304+
const toolCallId = message["tool_call_id"];
305+
if (typeof toolCallId !== "string" || !validToolCallIds.has(toolCallId)) {
306+
throw new ProviderError("DeepSeek history error: tool result does not match an assistant tool call");
307+
}
308+
}
309+
310+
function readDeepSeekToolCalls(message: Record<string, unknown>): Array<{ id?: unknown }> {
311+
const toolCalls = message["tool_calls"];
312+
return Array.isArray(toolCalls) ? toolCalls as Array<{ id?: unknown }> : [];
313+
}
314+
154315
function convertDeepSeekTool(tool: ToolSpec): Record<string, unknown> {
155316
return {
156317
type: "function",

packages/providers/src/index.test.ts

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ describe("DeepSeek registry provider", () => {
265265
expect(body["tools"]).toBeDefined();
266266
});
267267

268-
it("only replays DeepSeek reasoning_content for assistant tool calls", async () => {
268+
it("replays DeepSeek reasoning_content for complete tool-use turns", async () => {
269269
const fetchMock = vi.fn().mockResolvedValue(makeChatStreamingResponse());
270270
globalThis.fetch = fetchMock as typeof globalThis.fetch;
271271

@@ -316,9 +316,150 @@ describe("DeepSeek registry provider", () => {
316316
expect(body.messages?.[3]).toEqual({
317317
role: "assistant",
318318
content: "Final answer.",
319+
reasoning_content: "This should stay local after the turn.",
319320
});
320321
});
321322

323+
it("omits DeepSeek reasoning_content for non-tool final answers", async () => {
324+
const fetchMock = vi.fn().mockResolvedValue(makeChatStreamingResponse());
325+
globalThis.fetch = fetchMock as typeof globalThis.fetch;
326+
327+
const registry = createDefaultRegistry();
328+
const provider = registry.get("deepseek", {
329+
model: "deepseek-v4-pro",
330+
apiKey: "test-key",
331+
capabilities: {
332+
useResponsesApi: false,
333+
reasoning: true,
334+
supportsTemperature: false,
335+
},
336+
});
337+
338+
await collectChunks(provider.chat([
339+
{ role: MessageRole.USER, content: "answer directly" },
340+
{
341+
role: MessageRole.ASSISTANT,
342+
content: "Final answer.",
343+
thinking: "No tool use happened.",
344+
},
345+
{ role: MessageRole.USER, content: "what next?" },
346+
]));
347+
348+
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as {
349+
messages?: Array<Record<string, unknown>>;
350+
};
351+
expect(body.messages?.[1]).toEqual({
352+
role: "assistant",
353+
content: "Final answer.",
354+
});
355+
});
356+
357+
it("prunes unreplayable legacy DeepSeek tool-call history", async () => {
358+
const fetchMock = vi.fn().mockResolvedValue(makeChatStreamingResponse());
359+
globalThis.fetch = fetchMock as typeof globalThis.fetch;
360+
361+
const registry = createDefaultRegistry();
362+
const provider = registry.get("deepseek", {
363+
model: "deepseek-v4-pro",
364+
apiKey: "test-key",
365+
capabilities: {
366+
useResponsesApi: false,
367+
reasoning: true,
368+
supportsTemperature: false,
369+
},
370+
});
371+
372+
await collectChunks(provider.chat([
373+
{ role: MessageRole.USER, content: "first task" },
374+
{
375+
role: MessageRole.ASSISTANT,
376+
content: "",
377+
thinking: "Valid tool reasoning.",
378+
toolCalls: [{ name: "run_command", arguments: { cmd: "pwd" }, callId: "call_valid" }],
379+
},
380+
{ role: MessageRole.TOOL, toolCallId: "call_valid", content: "/tmp/project" },
381+
{
382+
role: MessageRole.ASSISTANT,
383+
content: "Legacy visible text.",
384+
toolCalls: [
385+
{ name: "read_file", arguments: { path: "a.ts" }, callId: "call_legacy_a" },
386+
{ name: "read_file", arguments: { path: "b.ts" }, callId: "call_legacy_b" },
387+
],
388+
},
389+
{ role: MessageRole.TOOL, toolCallId: "call_legacy_a", content: "a" },
390+
{ role: MessageRole.TOOL, toolCallId: "call_legacy_b", content: "b" },
391+
{ role: MessageRole.TOOL, toolCallId: "call_orphan", content: "orphan" },
392+
{ role: MessageRole.USER, content: "next task" },
393+
{
394+
role: MessageRole.ASSISTANT,
395+
content: "Legacy visible text without valid tool use.",
396+
toolCalls: [
397+
{ name: "read_file", arguments: { path: "c.ts" }, callId: "call_legacy_c" },
398+
],
399+
},
400+
{ role: MessageRole.TOOL, toolCallId: "call_legacy_c", content: "c" },
401+
{ role: MessageRole.USER, content: "final task" },
402+
]));
403+
404+
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body ?? "{}")) as {
405+
messages?: Array<Record<string, unknown>>;
406+
};
407+
expect(body.messages).toEqual([
408+
{ role: "user", content: "first task" },
409+
{
410+
role: "assistant",
411+
content: "",
412+
reasoning_content: "Valid tool reasoning.",
413+
tool_calls: [{
414+
id: "call_valid",
415+
type: "function",
416+
function: {
417+
name: "run_command",
418+
arguments: "{\"cmd\":\"pwd\"}",
419+
},
420+
}],
421+
},
422+
{ role: "tool", tool_call_id: "call_valid", content: "/tmp/project" },
423+
{ role: "user", content: "next task" },
424+
{ role: "assistant", content: "Legacy visible text without valid tool use." },
425+
{ role: "user", content: "final task" },
426+
]);
427+
});
428+
429+
it("rejects malformed normalized DeepSeek tool-call history locally", async () => {
430+
const fetchMock = vi.fn().mockResolvedValue(makeChatStreamingResponse());
431+
globalThis.fetch = fetchMock as typeof globalThis.fetch;
432+
433+
const registry = createDefaultRegistry();
434+
const provider = registry.get("deepseek", {
435+
model: "deepseek-v4-pro",
436+
apiKey: "test-key",
437+
capabilities: {
438+
useResponsesApi: false,
439+
reasoning: true,
440+
supportsTemperature: false,
441+
},
442+
});
443+
444+
let message = "";
445+
try {
446+
await collectChunks(provider.chat([
447+
{ role: MessageRole.USER, content: "first task" },
448+
{
449+
role: MessageRole.ASSISTANT,
450+
content: "",
451+
thinking: "Reasoning exists but the tool id is broken.",
452+
toolCalls: [{ name: "run_command", arguments: { cmd: "pwd" }, callId: "" }],
453+
},
454+
]));
455+
} catch (err) {
456+
message = err instanceof Error ? err.message : String(err);
457+
}
458+
459+
expect(fetchMock).not.toHaveBeenCalled();
460+
expect(message).toContain("assistant tool calls require non-empty ids");
461+
});
462+
322463
it("classifies DeepSeek JSON errors without leaking credentials", async () => {
323464
const fetchMock = vi.fn().mockResolvedValue(new Response(
324465
JSON.stringify({ error: { message: "The `reasoning_content` in the thinking mode must be passed back to the API." } }),

0 commit comments

Comments
 (0)