diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 5aee7267b3..5adf56b001 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -90,7 +90,13 @@ export abstract class BaseOpenAiCompatibleProvider model, max_tokens, temperature, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + // Enable mergeToolResultText to merge environment_details and other text content + // after tool_results into the last tool message. This prevents reasoning/thinking + // models from dropping reasoning_content when they see a user message after tool results. + messages: [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), + ], stream: true, stream_options: { include_usage: true }, ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), diff --git a/src/api/providers/cerebras.ts b/src/api/providers/cerebras.ts index 99e7c4cc3d..d0ff747688 100644 --- a/src/api/providers/cerebras.ts +++ b/src/api/providers/cerebras.ts @@ -106,7 +106,7 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan supportsNativeTools && metadata?.tools && metadata.tools.length > 0 && metadata?.toolProtocol !== "xml" // Convert Anthropic messages to OpenAI format (Cerebras is OpenAI-compatible) - const openaiMessages = convertToOpenAiMessages(messages) + const openaiMessages = convertToOpenAiMessages(messages, { mergeToolResultText: true }) // Prepare request body following Cerebras API specification exactly const requestBody: Record = { diff --git a/src/api/providers/chutes.ts b/src/api/providers/chutes.ts index 78ac7e591f..ac2281190e 100644 --- a/src/api/providers/chutes.ts +++ b/src/api/providers/chutes.ts @@ -44,7 +44,10 @@ export class ChutesHandler extends RouterProvider implements SingleCompletionHan const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model, max_tokens, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + messages: [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), + ], stream: true, stream_options: { include_usage: true }, ...(metadata?.tools && { tools: metadata.tools }), diff --git a/src/api/providers/deepinfra.ts b/src/api/providers/deepinfra.ts index 4dfad2689a..a221927c2b 100644 --- a/src/api/providers/deepinfra.ts +++ b/src/api/providers/deepinfra.ts @@ -72,7 +72,10 @@ export class DeepInfraHandler extends RouterProvider implements SingleCompletion const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { model: modelId, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + messages: [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), + ], stream: true, stream_options: { include_usage: true }, reasoning_effort, diff --git a/src/api/providers/featherless.ts b/src/api/providers/featherless.ts index 3dcd0821b8..64df12bc97 100644 --- a/src/api/providers/featherless.ts +++ b/src/api/providers/featherless.ts @@ -44,7 +44,10 @@ export class FeatherlessHandler extends BaseOpenAiCompatibleProvider { model, max_tokens, temperature, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + // Enable mergeToolResultText to merge environment_details and other text content + // after tool_results into the last tool message. This prevents reasoning/thinking + // models from dropping reasoning_content when they see a user message after tool results. + messages: [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), + ], stream: true, stream_options: { include_usage: true }, ...(reasoning && { reasoning }), diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index 667dcc6083..2038d67193 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -86,7 +86,7 @@ export class UnboundHandler extends RouterProvider implements SingleCompletionHa const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), ] if (info.supportsPromptCache) { diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts index 96863ac1ea..f1c9224238 100644 --- a/src/api/providers/vercel-ai-gateway.ts +++ b/src/api/providers/vercel-ai-gateway.ts @@ -45,7 +45,7 @@ export class VercelAiGatewayHandler extends RouterProvider implements SingleComp const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), ] if (VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS.has(modelId) && info.supportsPromptCache) { diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index a1377a1317..29548e8cf3 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -66,7 +66,7 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler temperature: this.options.modelTemperature ?? XAI_DEFAULT_TEMPERATURE, messages: [ { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), + ...convertToOpenAiMessages(messages, { mergeToolResultText: true }), ] as OpenAI.Chat.ChatCompletionMessageParam[], stream: true as const, stream_options: { include_usage: true }, diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index da9329fa32..29fd712c84 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -224,4 +224,181 @@ describe("convertToOpenAiMessages", () => { const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam expect(assistantMessage.tool_calls![0].id).toBe("custom_toolu_123") }) + + describe("mergeToolResultText option", () => { + it("should merge text content into last tool message when mergeToolResultText is true", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "Tool result content", + }, + { + type: "text", + text: "\nSome context\n", + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) + + // Should produce only one tool message with merged content + expect(openAiMessages).toHaveLength(1) + const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam + expect(toolMessage.role).toBe("tool") + expect(toolMessage.tool_call_id).toBe("tool-123") + expect(toolMessage.content).toBe( + "Tool result content\n\n\nSome context\n", + ) + }) + + it("should merge text into last tool message when multiple tool results exist", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_1", + content: "First result", + }, + { + type: "tool_result", + tool_use_id: "call_2", + content: "Second result", + }, + { + type: "text", + text: "Context", + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) + + // Should produce two tool messages, with text merged into the last one + expect(openAiMessages).toHaveLength(2) + expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe("First result") + expect((openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe( + "Second result\n\nContext", + ) + }) + + it("should NOT merge text when images are present (fall back to user message)", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "Tool result content", + }, + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "base64data", + }, + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) + + // Should produce a tool message AND a user message (because image is present) + expect(openAiMessages).toHaveLength(2) + expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).role).toBe("tool") + expect(openAiMessages[1].role).toBe("user") + }) + + it("should create separate user message when mergeToolResultText is false", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "Tool result content", + }, + { + type: "text", + text: "\nSome context\n", + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: false }) + + // Should produce a tool message AND a separate user message (default behavior) + expect(openAiMessages).toHaveLength(2) + expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).role).toBe("tool") + expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe( + "Tool result content", + ) + expect(openAiMessages[1].role).toBe("user") + }) + + it("should work with normalizeToolCallId when mergeToolResultText is true", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_5019f900a247472bacde0b82", + content: "Tool result content", + }, + { + type: "text", + text: "Context", + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { + mergeToolResultText: true, + normalizeToolCallId: normalizeMistralToolCallId, + }) + + // Should merge AND normalize the ID + expect(openAiMessages).toHaveLength(1) + const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam + expect(toolMessage.role).toBe("tool") + expect(toolMessage.tool_call_id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")) + expect(toolMessage.content).toBe( + "Tool result content\n\nContext", + ) + }) + + it("should handle user messages with only text content (no tool results)", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text", + text: "Hello, how are you?", + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) + + // Should produce a normal user message + expect(openAiMessages).toHaveLength(1) + expect(openAiMessages[0].role).toBe("user") + }) + }) }) diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 7ca4ddb993..e481864034 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -11,6 +11,14 @@ export interface ConvertToOpenAiMessagesOptions { * This allows callers to declare provider-specific ID format requirements. */ normalizeToolCallId?: (id: string) => string + /** + * If true, merge text content after tool_results into the last tool message + * instead of creating a separate user message. This is critical for providers + * with reasoning/thinking models (like DeepSeek-reasoner, GLM-4.7, etc.) where + * a user message after tool results causes the model to drop all previous + * reasoning_content. Default is false for backward compatibility. + */ + mergeToolResultText?: boolean } export function convertToOpenAiMessages( @@ -95,18 +103,40 @@ export function convertToOpenAiMessages( // Process non-tool messages if (nonToolMessages.length > 0) { - openAiMessages.push({ - role: "user", - content: nonToolMessages.map((part) => { - if (part.type === "image") { - return { - type: "image_url", - image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, + // Check if we should merge text into the last tool message + // This is critical for reasoning/thinking models where a user message + // after tool results causes the model to drop all previous reasoning_content + const hasOnlyTextContent = nonToolMessages.every((part) => part.type === "text") + const hasToolMessages = toolMessages.length > 0 + const shouldMergeIntoToolMessage = + options?.mergeToolResultText && hasToolMessages && hasOnlyTextContent + + if (shouldMergeIntoToolMessage) { + // Merge text content into the last tool message + const lastToolMessage = openAiMessages[ + openAiMessages.length - 1 + ] as OpenAI.Chat.ChatCompletionToolMessageParam + if (lastToolMessage?.role === "tool") { + const additionalText = nonToolMessages + .map((part) => (part as Anthropic.TextBlockParam).text) + .join("\n") + lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` + } + } else { + // Standard behavior: add user message with text/image content + openAiMessages.push({ + role: "user", + content: nonToolMessages.map((part) => { + if (part.type === "image") { + return { + type: "image_url", + image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, + } } - } - return { type: "text", text: part.text } - }), - }) + return { type: "text", text: part.text } + }), + }) + } } } else if (anthropicMessage.role === "assistant") { const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{