From 916263396b5b939e9926814bf4a79eac343c80cc Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 22 Jan 2026 22:06:59 +0000 Subject: [PATCH 1/4] fix: apply mergeToolResultText and normalize tool IDs for Mistral/Devstral models in OpenAI provider Addresses issue #10684 where vLLM-hosted Mistral/Devstral models reject "Unexpected role user after role tool" errors. Changes: - Add _isMistralFamily() to detect Mistral/Devstral models (case-insensitive) - Add _getMistralConversionOptions() to return conversion options for Mistral family - Pass mergeToolResultText: true to merge environment_details into tool messages - Pass normalizeToolCallId to normalize tool call IDs to 9-char alphanumeric - Apply these options to all 4 convertToOpenAiMessages() calls - Add tests for Mistral family model detection and handling --- src/api/providers/__tests__/openai.spec.ts | 153 +++++++++++++++++++++ src/api/providers/openai.ts | 47 ++++++- 2 files changed, 195 insertions(+), 5 deletions(-) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index d95860d5739..b46aa3b414c 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -1139,6 +1139,159 @@ describe("OpenAiHandler", () => { ) }) }) + + describe("Mistral/Devstral Family Models", () => { + const systemPrompt = "You are a helpful assistant." + const messagesWithToolResult: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text", text: "Hello!" }], + }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_test_123456789", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_test_123456789", + content: "File content here", + }, + { + type: "text", + text: "Details here", + }, + ], + }, + ] + + it("should detect Mistral models and apply mergeToolResultText", async () => { + const mistralHandler = new OpenAiHandler({ + ...mockOptions, + openAiModelId: "mistral-large-latest", + }) + + const stream = mistralHandler.createMessage(systemPrompt, messagesWithToolResult) + for await (const _chunk of stream) { + // Consume the stream + } + + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + + // Find the messages - should NOT have a user message after tool message + // because mergeToolResultText should merge text into the tool message + const messages = callArgs.messages + const toolMessageIndex = messages.findIndex((m: any) => m.role === "tool") + + if (toolMessageIndex !== -1) { + // The message after tool should be the next user message from a new request, + // not a user message with environment_details (which should be merged) + const nextMessage = messages[toolMessageIndex + 1] + // If there's a next message, it should not be a user message containing environment_details + if (nextMessage && nextMessage.role === "user") { + const content = + typeof nextMessage.content === "string" + ? nextMessage.content + : JSON.stringify(nextMessage.content) + expect(content).not.toContain("environment_details") + } + } + }) + + it("should detect Devstral models and apply mergeToolResultText", async () => { + const devstralHandler = new OpenAiHandler({ + ...mockOptions, + openAiModelId: "devstral-small-2", + }) + + const stream = devstralHandler.createMessage(systemPrompt, messagesWithToolResult) + for await (const _chunk of stream) { + // Consume the stream + } + + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + + // Verify the model ID was passed correctly + expect(callArgs.model).toBe("devstral-small-2") + }) + + it("should normalize tool call IDs to 9-char alphanumeric for Mistral models", async () => { + const mistralHandler = new OpenAiHandler({ + ...mockOptions, + openAiModelId: "mistral-medium", + }) + + const stream = mistralHandler.createMessage(systemPrompt, messagesWithToolResult) + for await (const _chunk of stream) { + // Consume the stream + } + + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + + // Find the tool message and verify the tool_call_id is normalized + const toolMessage = callArgs.messages.find((m: any) => m.role === "tool") + if (toolMessage) { + // The ID should be normalized to 9 alphanumeric characters + expect(toolMessage.tool_call_id).toMatch(/^[a-zA-Z0-9]{9}$/) + } + }) + + it("should NOT apply Mistral-specific handling for non-Mistral models", async () => { + const gpt4Handler = new OpenAiHandler({ + ...mockOptions, + openAiModelId: "gpt-4-turbo", + }) + + const stream = gpt4Handler.createMessage(systemPrompt, messagesWithToolResult) + for await (const _chunk of stream) { + // Consume the stream + } + + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + + // For non-Mistral models, tool_call_id should retain original format + const toolMessage = callArgs.messages.find((m: any) => m.role === "tool") + if (toolMessage) { + // The original ID format should be preserved (not normalized) + expect(toolMessage.tool_call_id).toBe("call_test_123456789") + } + }) + + it("should handle case-insensitive model detection", async () => { + const mixedCaseHandler = new OpenAiHandler({ + ...mockOptions, + openAiModelId: "Mistral-Large-LATEST", + }) + + const stream = mixedCaseHandler.createMessage(systemPrompt, messagesWithToolResult) + for await (const _chunk of stream) { + // Consume the stream + } + + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + + // Verify model detection worked despite mixed case + const toolMessage = callArgs.messages.find((m: any) => m.role === "tool") + if (toolMessage) { + // The ID should be normalized (indicating Mistral detection worked) + expect(toolMessage.tool_call_id).toMatch(/^[a-zA-Z0-9]{9}$/) + } + }) + }) }) describe("getOpenAiModels", () => { diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 74cbb511138..ca6a8a78f64 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -14,7 +14,8 @@ import type { ApiHandlerOptions } from "../../shared/api" import { TagMatcher } from "../../utils/tag-matcher" -import { convertToOpenAiMessages } from "../transform/openai-format" +import { convertToOpenAiMessages, ConvertToOpenAiMessagesOptions } from "../transform/openai-format" +import { normalizeMistralToolCallId } from "../transform/mistral-format" import { convertToR1Format } from "../transform/r1-format" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -91,6 +92,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const isAzureAiInference = this._isAzureAiInference(modelUrl) const deepseekReasoner = modelId.includes("deepseek-reasoner") || enabledR1Format + // Mistral/Devstral models require strict tool message ordering and normalized tool call IDs + const mistralConversionOptions = this._getMistralConversionOptions(modelId) + if (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4")) { yield* this.handleO3FamilyMessage(modelId, systemPrompt, messages, metadata) return @@ -121,7 +125,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } - convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)] + convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages, mistralConversionOptions)] if (modelInfo.supportsPromptCache) { // Note: the following logic is copied from openrouter: @@ -225,7 +229,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl model: modelId, messages: deepseekReasoner ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) - : [systemMessage, ...convertToOpenAiMessages(messages)], + : [systemMessage, ...convertToOpenAiMessages(messages, mistralConversionOptions)], // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS) tools: this.convertToolsForOpenAI(metadata?.tools), tool_choice: metadata?.tool_choice, @@ -329,6 +333,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const modelInfo = this.getModel().info const methodIsAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) + // Mistral/Devstral models require strict tool message ordering and normalized tool call IDs + const mistralConversionOptions = this._getMistralConversionOptions(modelId) + if (this.options.openAiStreamingEnabled ?? true) { const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl) @@ -339,7 +346,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl role: "developer", content: `Formatting re-enabled\n${systemPrompt}`, }, - ...convertToOpenAiMessages(messages), + ...convertToOpenAiMessages(messages, mistralConversionOptions), ], stream: true, ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), @@ -375,7 +382,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl role: "developer", content: `Formatting re-enabled\n${systemPrompt}`, }, - ...convertToOpenAiMessages(messages), + ...convertToOpenAiMessages(messages, mistralConversionOptions), ], reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined, temperature: undefined, @@ -508,6 +515,36 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl return urlHost.endsWith(".services.ai.azure.com") } + /** + * Checks if the model is part of the Mistral/Devstral family. + * Mistral models require strict message ordering (no user message after tool message) + * and have specific tool call ID format requirements (9-char alphanumeric). + * @param modelId - The model identifier to check + * @returns true if the model is a Mistral/Devstral family model + */ + private _isMistralFamily(modelId: string): boolean { + const modelIdLower = modelId.toLowerCase() + return modelIdLower.includes("mistral") || modelIdLower.includes("devstral") + } + + /** + * Gets the conversion options for Mistral/Devstral models. + * When the model is in the Mistral family, returns options to: + * 1. Merge text content after tool results into the last tool message (prevents user-after-tool error) + * 2. Normalize tool call IDs to 9-char alphanumeric format (Mistral's strict requirement) + * @param modelId - The model identifier + * @returns Conversion options for convertToOpenAiMessages, or undefined for non-Mistral models + */ + private _getMistralConversionOptions(modelId: string): ConvertToOpenAiMessagesOptions | undefined { + if (this._isMistralFamily(modelId)) { + return { + mergeToolResultText: true, + normalizeToolCallId: normalizeMistralToolCallId, + } + } + return undefined + } + /** * Adds max_completion_tokens to the request body if needed based on provider configuration * Note: max_tokens is deprecated in favor of max_completion_tokens as per OpenAI documentation From 367ec5f00d9f511fea2bb6b7aeb51eb73fd36b83 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 22 Jan 2026 23:34:27 +0000 Subject: [PATCH 2/4] test: ensure Mistral tests always assert tool message existence --- src/api/providers/__tests__/openai.spec.ts | 47 +++++++++++----------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index b46aa3b414c..48243f2b244 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -1193,18 +1193,17 @@ describe("OpenAiHandler", () => { const messages = callArgs.messages const toolMessageIndex = messages.findIndex((m: any) => m.role === "tool") - if (toolMessageIndex !== -1) { - // The message after tool should be the next user message from a new request, - // not a user message with environment_details (which should be merged) - const nextMessage = messages[toolMessageIndex + 1] - // If there's a next message, it should not be a user message containing environment_details - if (nextMessage && nextMessage.role === "user") { - const content = - typeof nextMessage.content === "string" - ? nextMessage.content - : JSON.stringify(nextMessage.content) - expect(content).not.toContain("environment_details") - } + // Assert tool message exists - test setup should always produce a tool message + expect(toolMessageIndex).not.toBe(-1) + + // The message after tool should be the next user message from a new request, + // not a user message with environment_details (which should be merged) + const nextMessage = messages[toolMessageIndex + 1] + // If there's a next message, it should not be a user message containing environment_details + if (nextMessage && nextMessage.role === "user") { + const content = + typeof nextMessage.content === "string" ? nextMessage.content : JSON.stringify(nextMessage.content) + expect(content).not.toContain("environment_details") } }) @@ -1242,10 +1241,10 @@ describe("OpenAiHandler", () => { // Find the tool message and verify the tool_call_id is normalized const toolMessage = callArgs.messages.find((m: any) => m.role === "tool") - if (toolMessage) { - // The ID should be normalized to 9 alphanumeric characters - expect(toolMessage.tool_call_id).toMatch(/^[a-zA-Z0-9]{9}$/) - } + // Assert tool message exists - test setup should always produce a tool message + expect(toolMessage).toBeDefined() + // The ID should be normalized to 9 alphanumeric characters + expect(toolMessage.tool_call_id).toMatch(/^[a-zA-Z0-9]{9}$/) }) it("should NOT apply Mistral-specific handling for non-Mistral models", async () => { @@ -1264,10 +1263,10 @@ describe("OpenAiHandler", () => { // For non-Mistral models, tool_call_id should retain original format const toolMessage = callArgs.messages.find((m: any) => m.role === "tool") - if (toolMessage) { - // The original ID format should be preserved (not normalized) - expect(toolMessage.tool_call_id).toBe("call_test_123456789") - } + // Assert tool message exists - test setup should always produce a tool message + expect(toolMessage).toBeDefined() + // The original ID format should be preserved (not normalized) + expect(toolMessage.tool_call_id).toBe("call_test_123456789") }) it("should handle case-insensitive model detection", async () => { @@ -1286,10 +1285,10 @@ describe("OpenAiHandler", () => { // Verify model detection worked despite mixed case const toolMessage = callArgs.messages.find((m: any) => m.role === "tool") - if (toolMessage) { - // The ID should be normalized (indicating Mistral detection worked) - expect(toolMessage.tool_call_id).toMatch(/^[a-zA-Z0-9]{9}$/) - } + // Assert tool message exists - test setup should always produce a tool message + expect(toolMessage).toBeDefined() + // The ID should be normalized (indicating Mistral detection worked) + expect(toolMessage.tool_call_id).toMatch(/^[a-zA-Z0-9]{9}$/) }) }) }) From 914cc4e73ebdecb0438f69c077d4c42db29b6393 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 22 Jan 2026 23:47:27 +0000 Subject: [PATCH 3/4] test: strengthen Mistral test to verify tool message contains merged text - Add explicit assertions verifying merged environment_details in tool message - Add assertion confirming no user message follows tool (Mistral constraint) - Reference mistral_common validator constraint in comment --- src/api/providers/__tests__/openai.spec.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 48243f2b244..63ac438b0c7 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -1195,15 +1195,19 @@ describe("OpenAiHandler", () => { // Assert tool message exists - test setup should always produce a tool message expect(toolMessageIndex).not.toBe(-1) + const toolMessage = messages[toolMessageIndex] - // The message after tool should be the next user message from a new request, - // not a user message with environment_details (which should be merged) + // Verify the tool message contains both the original content AND the merged environment_details + // This is the key verification that mergeToolResultText is working correctly + expect(toolMessage.content).toContain("File content here") + expect(toolMessage.content).toContain("environment_details") + + // Verify there is NO user message immediately after the tool message + // This is the Mistral constraint: after tool, only assistant or tool is allowed, never user + // Per mistral_common validator: elif previous_role == Roles.tool: expected_roles = {Roles.assistant, Roles.tool} const nextMessage = messages[toolMessageIndex + 1] - // If there's a next message, it should not be a user message containing environment_details - if (nextMessage && nextMessage.role === "user") { - const content = - typeof nextMessage.content === "string" ? nextMessage.content : JSON.stringify(nextMessage.content) - expect(content).not.toContain("environment_details") + if (nextMessage) { + expect(nextMessage.role).not.toBe("user") } }) From 0d212efdbb01fbf4fc12f76430f403b89c0c7fe7 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 23 Jan 2026 00:06:27 +0000 Subject: [PATCH 4/4] test: remove if conditional and add test to verify Mistral no-user-after-tool constraint --- src/api/providers/__tests__/openai.spec.ts | 74 ++++++++++++++++++++-- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 63ac438b0c7..104117affc3 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -1201,14 +1201,78 @@ describe("OpenAiHandler", () => { // This is the key verification that mergeToolResultText is working correctly expect(toolMessage.content).toContain("File content here") expect(toolMessage.content).toContain("environment_details") + }) + + it("should not have user message after tool message for Mistral models", async () => { + // Create a message sequence that includes a follow-up after the tool result + // to verify the Mistral constraint is enforced + const messagesWithFollowUp: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text", text: "Read test.ts and explain it" }], + }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call_abc123xyz", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_abc123xyz", + content: "export const foo = 'bar'", + }, + { + type: "text", + text: "Current directory: /project", + }, + ], + }, + { + role: "assistant", + content: [{ type: "text", text: "This file exports a constant named foo with value 'bar'." }], + }, + { + role: "user", + content: [{ type: "text", text: "Thanks!" }], + }, + ] + + const mistralHandler = new OpenAiHandler({ + ...mockOptions, + openAiModelId: "mistral-large-latest", + }) + + const stream = mistralHandler.createMessage(systemPrompt, messagesWithFollowUp) + for await (const _chunk of stream) { + // Consume the stream + } - // Verify there is NO user message immediately after the tool message + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + const messages = callArgs.messages + + // Find the tool message + const toolMessageIndex = messages.findIndex((m: any) => m.role === "tool") + expect(toolMessageIndex).not.toBe(-1) + + // Verify there IS a next message (the assistant response) + const nextMessage = messages[toolMessageIndex + 1] + expect(nextMessage).toBeDefined() + + // Verify the next message is NOT a user message // This is the Mistral constraint: after tool, only assistant or tool is allowed, never user // Per mistral_common validator: elif previous_role == Roles.tool: expected_roles = {Roles.assistant, Roles.tool} - const nextMessage = messages[toolMessageIndex + 1] - if (nextMessage) { - expect(nextMessage.role).not.toBe("user") - } + expect(nextMessage.role).not.toBe("user") + expect(nextMessage.role).toBe("assistant") }) it("should detect Devstral models and apply mergeToolResultText", async () => {