mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
8e4c0ae99d
commit
b433d1f7a2
4 changed files with 186 additions and 20 deletions
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 || ""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue