fix: handle Mistral thinking content as reasoning chunks (#7106)

* 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

* fix: resolve TypeScript type issue in completePrompt method

* 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

---------

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
This commit is contained in:
roomote[bot] 2025-08-23 06:33:43 -07:00 committed by GitHub
parent 8e4c0ae99d
commit b433d1f7a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 186 additions and 20 deletions

12
pnpm-lock.yaml generated
View file

@ -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
@ -2116,8 +2116,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'
@ -11409,7 +11409,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)
@ -13573,7 +13573,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:

View file

@ -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,134 @@ 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 matching new SDK structure
mockCreate.mockImplementationOnce(async (_options) => {
const stream = {
[Symbol.asyncIterator]: async function* () {
yield {
data: {
choices: [
{
delta: {
content: [
{
type: "thinking",
thinking: [{ type: "text", 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 matching new SDK structure
mockCreate.mockImplementationOnce(async (_options) => {
const stream = {
[Symbol.asyncIterator]: async function* () {
yield {
data: {
choices: [
{
delta: {
content: [
{ type: "text", text: "First text" },
{
type: "thinking",
thinking: [{ type: "text", 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")
})
})
})

View file

@ -11,6 +11,14 @@ import { ApiStream } from "../transform/stream"
import { BaseProvider } from "./base-provider"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
// 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 }>
}
export class MistralHandler extends BaseProvider implements SingleCompletionHandler {
protected options: ApiHandlerOptions
private client: Mistral
@ -48,26 +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) {
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 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 }
}
}
}
yield { type: "text", text: content }
}
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,
}
}
}
@ -97,7 +117,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 as ContentChunkWithThinking[])
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text || "")
.join("")
}
return content || ""

View file

@ -429,7 +429,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.19.0",