Skip to content

Commit fe7f41a

Browse files
committed
fix(openai-format): update types for deepseek
1 parent 404744c commit fe7f41a

2 files changed

Lines changed: 91 additions & 19 deletions

File tree

‎src/api/transform/__tests__/openai-format.spec.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,6 +1149,35 @@ describe("convertToOpenAiMessages", () => {
11491149
expect(msg.tool_calls[0].id).toBe("call_abc")
11501150
})
11511151

1152+
it("should accumulate multiple reasoning blocks in order, separated by a tool call", () => {
1153+
// DeepSeek / Z.ai interleaved thinking can emit more than one reasoning block per
1154+
// turn. A regression that overwrites (instead of accumulates) would silently drop
1155+
// all but the last block.
1156+
const anthropicMessages = [
1157+
{
1158+
role: "assistant" as const,
1159+
content: [
1160+
{ type: "reasoning", text: "First, I should check the file.", summary: [] },
1161+
{
1162+
type: "tool_use",
1163+
id: "call_abc",
1164+
name: "read_file",
1165+
input: { path: "foo.txt" },
1166+
},
1167+
{ type: "reasoning", text: "Now I know what to do next.", summary: [] },
1168+
],
1169+
},
1170+
] as any as Anthropic.Messages.MessageParam[]
1171+
1172+
const result = convertToOpenAiMessages(anthropicMessages)
1173+
1174+
expect(result).toHaveLength(1)
1175+
const msg = result[0] as any
1176+
expect(msg.reasoning_content).toBe("First, I should check the file.Now I know what to do next.")
1177+
expect(msg.tool_calls).toHaveLength(1)
1178+
expect(msg.tool_calls[0].id).toBe("call_abc")
1179+
})
1180+
11521181
it("should prefer top-level reasoning_content over content block", () => {
11531182
const anthropicMessages = [
11541183
{

‎src/api/transform/openai-format.ts‎

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,52 @@ export function sanitizeGeminiMessages(
253253
return sanitized
254254
}
255255

256+
/**
257+
* A reasoning content block (DeepSeek / Z.ai interleaved thinking) that may
258+
* appear in an assistant message's content array. Not part of Anthropic's
259+
* ContentBlockParam union, so it's declared separately here.
260+
*/
261+
interface ReasoningContentBlock {
262+
type: "reasoning"
263+
text?: string
264+
}
265+
266+
/**
267+
* Extracts the reasoning text if `part` is a {@link ReasoningContentBlock}, or `undefined`
268+
* otherwise. Providers like DeepSeek / Z.ai emit a `"reasoning"` content block that isn't part of
269+
* Anthropic's `ContentBlockParam` union, so `part` is treated as `unknown` and its shape is
270+
* validated at runtime rather than assumed via a cast. A boolean type-guard isn't viable here:
271+
* TypeScript can't narrow `part` to `ReasoningContentBlock` because their `type` literals don't
272+
* overlap with any variant of `ContentBlockParam`, so the narrowed type collapses to `never`.
273+
*/
274+
function getReasoningBlockText(part: unknown): string | undefined {
275+
if (!part || typeof part !== "object") {
276+
return undefined
277+
}
278+
const block = part as { type?: unknown; text?: unknown }
279+
return block.type === "reasoning" && typeof block.text === "string" ? block.text : undefined
280+
}
281+
282+
/**
283+
* Non-standard fields Zoo Code attaches to Anthropic message params to
284+
* round-trip provider-specific reasoning state (e.g. DeepSeek's
285+
* `reasoning_content`, which must be echoed back or the API returns a 400).
286+
*/
287+
type AssistantMessageWithReasoning = Anthropic.Messages.MessageParam & {
288+
reasoning_details?: any[]
289+
reasoning_content?: string
290+
}
291+
292+
/**
293+
* Extra fields layered onto an outgoing OpenAI chat message to round-trip
294+
* provider-specific reasoning state. Shared by both branches of the
295+
* conversion below (string-content and content-block assistant messages).
296+
*/
297+
type ReasoningPassthroughFields = {
298+
reasoning_details?: any[]
299+
reasoning_content?: string
300+
}
301+
256302
/**
257303
* Options for converting Anthropic messages to OpenAI format.
258304
*/
@@ -306,19 +352,16 @@ export function convertToOpenAiMessages(
306352
// will convert a single text block into a string for compactness.
307353
// If a message also contains reasoning_details (Gemini 3 / xAI / o-series, etc.),
308354
// we must preserve it here as well.
309-
const messageWithDetails = anthropicMessage as any
310-
const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & {
311-
reasoning_details?: any[]
312-
reasoning_content?: string
313-
} = {
355+
const messageWithDetails = anthropicMessage as AssistantMessageWithReasoning
356+
const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & ReasoningPassthroughFields = {
314357
role: anthropicMessage.role,
315358
content: anthropicMessage.content,
316359
}
317360

318361
if (anthropicMessage.role === "assistant") {
319362
const mapped = mapReasoningDetails(messageWithDetails.reasoning_details)
320363
if (mapped) {
321-
;(baseMessage as any).reasoning_details = mapped
364+
baseMessage.reasoning_details = mapped
322365
}
323366
// Pass through reasoning_content for DeepSeek / Z.ai thinking mode.
324367
if (typeof messageWithDetails.reasoning_content === "string" && messageWithDetails.reasoning_content) {
@@ -457,7 +500,7 @@ export function convertToOpenAiMessages(
457500
}
458501
}
459502
} else if (anthropicMessage.role === "assistant") {
460-
const messageWithDetails = anthropicMessage as any
503+
const messageWithDetails = anthropicMessage as AssistantMessageWithReasoning
461504

462505
let extractedReasoning: string | undefined
463506
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
@@ -469,14 +512,17 @@ export function convertToOpenAiMessages(
469512
acc.toolMessages.push(part)
470513
} else if (part.type === "text" || part.type === "image") {
471514
acc.nonToolMessages.push(part)
472-
} else if ((part as any).type === "reasoning" && (part as any).text) {
473-
// Extract reasoning stored as a content block (DeepSeek / Z.ai interleaved thinking).
474-
// Must be passed back as top-level reasoning_content so providers like DeepSeek
475-
// don't reject the request with "reasoning_content must be passed back to the API".
476-
// Accumulate all blocks (a turn may have more than one) to preserve order.
477-
extractedReasoning = extractedReasoning
478-
? extractedReasoning + (part as any).text
479-
: (part as any).text
515+
} else {
516+
const reasoningText = getReasoningBlockText(part)
517+
if (reasoningText) {
518+
// Extract reasoning stored as a content block (DeepSeek / Z.ai interleaved thinking).
519+
// Must be passed back as top-level reasoning_content so providers like DeepSeek
520+
// don't reject the request with "reasoning_content must be passed back to the API".
521+
// Accumulate all blocks (a turn may have more than one) to preserve order.
522+
extractedReasoning = extractedReasoning
523+
? extractedReasoning + reasoningText
524+
: reasoningText
525+
}
480526
} // assistant cannot send tool_result messages
481527
return acc
482528
},
@@ -510,10 +556,7 @@ export function convertToOpenAiMessages(
510556
// Build message with reasoning_details BEFORE tool_calls to preserve
511557
// the order expected by providers like Roo. Property order matters
512558
// when sending messages back to some APIs.
513-
const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & {
514-
reasoning_details?: any[]
515-
reasoning_content?: string
516-
} = {
559+
const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & ReasoningPassthroughFields = {
517560
role: "assistant",
518561
// Use empty string instead of undefined for providers like Gemini (via OpenRouter)
519562
// that require every message to have content in the "parts" field

0 commit comments

Comments
 (0)