@@ -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