From 470b78a49578d0e93ef2db92e7f4e566efac512c Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 14 Aug 2025 17:57:48 +0000 Subject: [PATCH 1/3] fix: handle Mistral thinking content as reasoning chunks - Add TypeScript interfaces for Mistral content types (text and thinking) - Update createMessage to yield reasoning chunks for thinking content - Update completePrompt to filter out thinking content in non-streaming mode - Add comprehensive tests for reasoning content handling - Follow the pattern used by other providers (Anthropic, OpenAI, Gemini, etc.) Fixes #6842 --- src/api/providers/__tests__/mistral.spec.ts | 138 +++++++++++++++++++- src/api/providers/mistral.ts | 39 +++++- 2 files changed, 169 insertions(+), 8 deletions(-) diff --git a/src/api/providers/__tests__/mistral.spec.ts b/src/api/providers/__tests__/mistral.spec.ts index 73861ecdc0..f91491b11f 100644 --- a/src/api/providers/__tests__/mistral.spec.ts +++ b/src/api/providers/__tests__/mistral.spec.ts @@ -1,5 +1,6 @@ // Mock Mistral client - must come before other imports const mockCreate = vi.fn() +const mockComplete = vi.fn() vi.mock("@mistralai/mistralai", () => { return { Mistral: vi.fn().mockImplementation(() => ({ @@ -21,6 +22,17 @@ vi.mock("@mistralai/mistralai", () => { } return stream }), + complete: mockComplete.mockImplementation(async (_options) => { + return { + choices: [ + { + message: { + content: "Test response", + }, + }, + ], + } + }), }, })), } @@ -29,7 +41,7 @@ vi.mock("@mistralai/mistralai", () => { import type { Anthropic } from "@anthropic-ai/sdk" import { MistralHandler } from "../mistral" import type { ApiHandlerOptions } from "../../../shared/api" -import type { ApiStreamTextChunk } from "../../transform/stream" +import type { ApiStreamTextChunk, ApiStreamReasoningChunk } from "../../transform/stream" describe("MistralHandler", () => { let handler: MistralHandler @@ -44,6 +56,7 @@ describe("MistralHandler", () => { } handler = new MistralHandler(mockOptions) mockCreate.mockClear() + mockComplete.mockClear() }) describe("constructor", () => { @@ -122,5 +135,128 @@ describe("MistralHandler", () => { mockCreate.mockRejectedValueOnce(new Error("API Error")) await expect(handler.createMessage(systemPrompt, messages).next()).rejects.toThrow("API Error") }) + + it("should handle thinking content as reasoning chunks", async () => { + // Mock stream with thinking content + mockCreate.mockImplementationOnce(async (_options) => { + const stream = { + [Symbol.asyncIterator]: async function* () { + yield { + data: { + choices: [ + { + delta: { + content: [ + { type: "thinking", text: "Let me think about this..." }, + { type: "text", text: "Here's the answer" }, + ], + }, + index: 0, + }, + ], + }, + } + }, + } + return stream + }) + + const iterator = handler.createMessage(systemPrompt, messages) + const results: (ApiStreamTextChunk | ApiStreamReasoningChunk)[] = [] + + for await (const chunk of iterator) { + if ("text" in chunk) { + results.push(chunk as ApiStreamTextChunk | ApiStreamReasoningChunk) + } + } + + expect(results).toHaveLength(2) + expect(results[0]).toEqual({ type: "reasoning", text: "Let me think about this..." }) + expect(results[1]).toEqual({ type: "text", text: "Here's the answer" }) + }) + + it("should handle mixed content arrays correctly", async () => { + // Mock stream with mixed content + mockCreate.mockImplementationOnce(async (_options) => { + const stream = { + [Symbol.asyncIterator]: async function* () { + yield { + data: { + choices: [ + { + delta: { + content: [ + { type: "text", text: "First text" }, + { type: "thinking", text: "Some reasoning" }, + { type: "text", text: "Second text" }, + ], + }, + index: 0, + }, + ], + }, + } + }, + } + return stream + }) + + const iterator = handler.createMessage(systemPrompt, messages) + const results: (ApiStreamTextChunk | ApiStreamReasoningChunk)[] = [] + + for await (const chunk of iterator) { + if ("text" in chunk) { + results.push(chunk as ApiStreamTextChunk | ApiStreamReasoningChunk) + } + } + + expect(results).toHaveLength(3) + expect(results[0]).toEqual({ type: "text", text: "First text" }) + expect(results[1]).toEqual({ type: "reasoning", text: "Some reasoning" }) + expect(results[2]).toEqual({ type: "text", text: "Second text" }) + }) + }) + + describe("completePrompt", () => { + it("should complete prompt successfully", async () => { + const prompt = "Test prompt" + const result = await handler.completePrompt(prompt) + + expect(mockComplete).toHaveBeenCalledWith({ + model: mockOptions.apiModelId, + messages: [{ role: "user", content: prompt }], + temperature: 0, + }) + + expect(result).toBe("Test response") + }) + + it("should filter out thinking content in completePrompt", async () => { + mockComplete.mockImplementationOnce(async (_options) => { + return { + choices: [ + { + message: { + content: [ + { type: "thinking", text: "Let me think..." }, + { type: "text", text: "Answer part 1" }, + { type: "text", text: "Answer part 2" }, + ], + }, + }, + ], + } + }) + + const prompt = "Test prompt" + const result = await handler.completePrompt(prompt) + + expect(result).toBe("Answer part 1Answer part 2") + }) + + it("should handle errors in completePrompt", async () => { + mockComplete.mockRejectedValueOnce(new Error("API Error")) + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Mistral completion error: API Error") + }) }) }) diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index 7d48b9ef01..c6fe6cbecc 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -11,6 +11,19 @@ import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +// Define TypeScript interfaces for Mistral content types +interface MistralTextContent { + type: "text" + text: string +} + +interface MistralThinkingContent { + type: "thinking" + text: string +} + +type MistralContent = MistralTextContent | MistralThinkingContent | string + export class MistralHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions private client: Mistral @@ -52,15 +65,23 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand const delta = chunk.data.choices[0]?.delta if (delta?.content) { - let content: string = "" - if (typeof delta.content === "string") { - content = delta.content + // Handle string content as text + yield { type: "text", text: delta.content } } else if (Array.isArray(delta.content)) { - content = delta.content.map((c) => (c.type === "text" ? c.text : "")).join("") + // Handle array of content blocks + for (const c of delta.content as MistralContent[]) { + if (typeof c === "object" && c !== null) { + if (c.type === "thinking" && c.text) { + // Handle thinking content as reasoning chunks + yield { type: "reasoning", text: c.text } + } else if (c.type === "text" && c.text) { + // Handle text content normally + yield { type: "text", text: c.text } + } + } + } } - - yield { type: "text", text: content } } if (chunk.data.usage) { @@ -97,7 +118,11 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand const content = response.choices?.[0]?.message.content if (Array.isArray(content)) { - return content.map((c) => (c.type === "text" ? c.text : "")).join("") + // Only return text content, filter out thinking content for non-streaming + return content + .filter((c: MistralContent) => typeof c === "object" && c.type === "text") + .map((c: MistralContent) => (c as MistralTextContent).text || "") + .join("") } return content || "" From a4492097eccb00b7d0cb128667bfb922599c6054 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 14 Aug 2025 17:59:33 +0000 Subject: [PATCH 2/3] fix: resolve TypeScript type issue in completePrompt method --- src/api/providers/mistral.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index c6fe6cbecc..951e59bacc 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -120,8 +120,8 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand if (Array.isArray(content)) { // Only return text content, filter out thinking content for non-streaming return content - .filter((c: MistralContent) => typeof c === "object" && c.type === "text") - .map((c: MistralContent) => (c as MistralTextContent).text || "") + .filter((c: any) => typeof c === "object" && c !== null && c.type === "text") + .map((c: any) => c.text || "") .join("") } From fd96ae899fa8f78ea9a4915429b54f0d7fc65261 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Fri, 22 Aug 2025 18:17:33 -0500 Subject: [PATCH 3/3] fix: handle Mistral thinking content chunks in streaming responses - Added ContentChunkWithThinking type helper to handle thinking chunks - Properly converts thinking content to reasoning chunks in streaming - Filters out thinking content in non-streaming completePrompt responses - Confirmed that Mistral API does send thinking chunks with type 'thinking' - Works with Mistral SDK v1.9.18 --- pnpm-lock.yaml | 12 ++--- src/api/providers/__tests__/mistral.spec.ts | 14 ++++-- src/api/providers/mistral.ts | 55 ++++++++++----------- src/package.json | 2 +- 4 files changed, 44 insertions(+), 39 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bcc01f194..0032dc998b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -575,8 +575,8 @@ importers: specifier: ^1.1.1 version: 1.2.0 '@mistralai/mistralai': - specifier: ^1.3.6 - version: 1.6.1(zod@3.25.61) + specifier: ^1.9.18 + version: 1.9.18(zod@3.25.61) '@modelcontextprotocol/sdk': specifier: ^1.9.0 version: 1.12.0 @@ -2113,8 +2113,8 @@ packages: '@microsoft/fast-web-utilities@5.4.1': resolution: {integrity: sha512-ReWYncndjV3c8D8iq9tp7NcFNc1vbVHvcBFPME2nNFKNbS1XCesYZGlIlf3ot5EmuOXPlrzUHOWzQ2vFpIkqDg==} - '@mistralai/mistralai@1.6.1': - resolution: {integrity: sha512-NFAMamNFSAaLT4YhDrqEjhJALJXSheZdA5jXT6gG5ICCJRk9+WQx7vRQO1sIZNIRP+xpPyROpa7X6ZcufiucIA==} + '@mistralai/mistralai@1.9.18': + resolution: {integrity: sha512-D/vNAGEvWMsg95tzgLTg7pPnW9leOPyH+nh1Os05NwxVPbUykoYgMAwOEX7J46msahWdvZ4NQQuxUXIUV2P6dg==} peerDependencies: zod: '>= 3' @@ -11387,7 +11387,7 @@ snapshots: dependencies: exenv-es6: 1.1.1 - '@mistralai/mistralai@1.6.1(zod@3.25.61)': + '@mistralai/mistralai@1.9.18(zod@3.25.61)': dependencies: zod: 3.25.61 zod-to-json-schema: 3.24.5(zod@3.25.61) @@ -13546,7 +13546,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: diff --git a/src/api/providers/__tests__/mistral.spec.ts b/src/api/providers/__tests__/mistral.spec.ts index f91491b11f..ff3c5d3d8b 100644 --- a/src/api/providers/__tests__/mistral.spec.ts +++ b/src/api/providers/__tests__/mistral.spec.ts @@ -137,7 +137,7 @@ describe("MistralHandler", () => { }) it("should handle thinking content as reasoning chunks", async () => { - // Mock stream with thinking content + // Mock stream with thinking content matching new SDK structure mockCreate.mockImplementationOnce(async (_options) => { const stream = { [Symbol.asyncIterator]: async function* () { @@ -147,7 +147,10 @@ describe("MistralHandler", () => { { delta: { content: [ - { type: "thinking", text: "Let me think about this..." }, + { + type: "thinking", + thinking: [{ type: "text", text: "Let me think about this..." }], + }, { type: "text", text: "Here's the answer" }, ], }, @@ -176,7 +179,7 @@ describe("MistralHandler", () => { }) it("should handle mixed content arrays correctly", async () => { - // Mock stream with mixed content + // Mock stream with mixed content matching new SDK structure mockCreate.mockImplementationOnce(async (_options) => { const stream = { [Symbol.asyncIterator]: async function* () { @@ -187,7 +190,10 @@ describe("MistralHandler", () => { delta: { content: [ { type: "text", text: "First text" }, - { type: "thinking", text: "Some reasoning" }, + { + type: "thinking", + thinking: [{ type: "text", text: "Some reasoning" }], + }, { type: "text", text: "Second text" }, ], }, diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index 951e59bacc..fef215d43f 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -11,19 +11,14 @@ import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -// Define TypeScript interfaces for Mistral content types -interface MistralTextContent { - type: "text" - text: string +// Type helper to handle thinking chunks from Mistral API +// The SDK includes ThinkChunk but TypeScript has trouble with the discriminated union +type ContentChunkWithThinking = { + type: string + text?: string + thinking?: Array<{ type: string; text?: string }> } -interface MistralThinkingContent { - type: "thinking" - text: string -} - -type MistralContent = MistralTextContent | MistralThinkingContent | string - export class MistralHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions private client: Mistral @@ -61,34 +56,38 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand temperature, }) - for await (const chunk of response) { - const delta = chunk.data.choices[0]?.delta + for await (const event of response) { + const delta = event.data.choices[0]?.delta if (delta?.content) { if (typeof delta.content === "string") { // Handle string content as text yield { type: "text", text: delta.content } } else if (Array.isArray(delta.content)) { - // Handle array of content blocks - for (const c of delta.content as MistralContent[]) { - if (typeof c === "object" && c !== null) { - if (c.type === "thinking" && c.text) { - // Handle thinking content as reasoning chunks - yield { type: "reasoning", text: c.text } - } else if (c.type === "text" && c.text) { - // Handle text content normally - yield { type: "text", text: c.text } + // Handle array of content chunks + // The SDK v1.9.18 supports ThinkChunk with type "thinking" + for (const chunk of delta.content as ContentChunkWithThinking[]) { + if (chunk.type === "thinking" && chunk.thinking) { + // Handle thinking content as reasoning chunks + // ThinkChunk has a 'thinking' property that contains an array of text/reference chunks + for (const thinkingPart of chunk.thinking) { + if (thinkingPart.type === "text" && thinkingPart.text) { + yield { type: "reasoning", text: thinkingPart.text } + } } + } else if (chunk.type === "text" && chunk.text) { + // Handle text content normally + yield { type: "text", text: chunk.text } } } } } - if (chunk.data.usage) { + if (event.data.usage) { yield { type: "usage", - inputTokens: chunk.data.usage.promptTokens || 0, - outputTokens: chunk.data.usage.completionTokens || 0, + inputTokens: event.data.usage.promptTokens || 0, + outputTokens: event.data.usage.completionTokens || 0, } } } @@ -119,9 +118,9 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand if (Array.isArray(content)) { // Only return text content, filter out thinking content for non-streaming - return content - .filter((c: any) => typeof c === "object" && c !== null && c.type === "text") - .map((c: any) => c.text || "") + return (content as ContentChunkWithThinking[]) + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text || "") .join("") } diff --git a/src/package.json b/src/package.json index 084e42e08e..d5492ea079 100644 --- a/src/package.json +++ b/src/package.json @@ -424,7 +424,7 @@ "@aws-sdk/credential-providers": "^3.848.0", "@google/genai": "^1.0.0", "@lmstudio/sdk": "^1.1.1", - "@mistralai/mistralai": "^1.3.6", + "@mistralai/mistralai": "^1.9.18", "@modelcontextprotocol/sdk": "^1.9.0", "@qdrant/js-client-rest": "^1.14.0", "@roo-code/cloud": "^0.14.0",