Skip to content

Commit 53d1bab

Browse files
ArkaD171717claudebogdankostic
authored
feat: add reasoning token support to Mistral integration (#3265)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: bogdankostic <bogdankostic@web.de>
1 parent d88b722 commit 53d1bab

3 files changed

Lines changed: 783 additions & 52 deletions

File tree

integrations/mistral/src/haystack_integrations/components/generators/mistral/chat/chat_generator.py

Lines changed: 263 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@
22
#
33
# SPDX-License-Identifier: Apache-2.0
44

5+
import json
56
from typing import Any, ClassVar
67

78
from haystack import component, default_to_dict, logging
89
from haystack.components.generators.chat import OpenAIChatGenerator
9-
from haystack.dataclasses import ChatMessage, StreamingCallbackT
10+
from haystack.components.generators.chat.openai import _check_finish_reason
11+
from haystack.dataclasses import (
12+
ChatMessage,
13+
ReasoningContent,
14+
StreamingCallbackT,
15+
ToolCall,
16+
select_streaming_callback,
17+
)
1018
from haystack.tools import ToolsType, serialize_tools_or_toolset
1119
from haystack.utils import serialize_callable
1220
from haystack.utils.auth import Secret
@@ -16,6 +24,75 @@
1624
logger = logging.getLogger(__name__)
1725

1826

27+
def _parse_mistral_content(content: Any) -> tuple[str | None, ReasoningContent | None]:
28+
"""Parse Mistral message content which can be a string or an array of typed blocks."""
29+
if content is None:
30+
return None, None
31+
if isinstance(content, str):
32+
return content or None, None
33+
if not isinstance(content, list):
34+
return str(content), None
35+
36+
text_parts: list[str] = []
37+
thinking_parts: list[str] = []
38+
39+
for block in content:
40+
if not isinstance(block, dict):
41+
continue
42+
block_type = block.get("type", "")
43+
if block_type == "thinking":
44+
for item in block.get("thinking", []):
45+
if isinstance(item, dict) and item.get("type") == "text":
46+
thinking_parts.append(item.get("text", ""))
47+
elif block_type == "text":
48+
text_parts.append(block.get("text", ""))
49+
50+
text = "".join(text_parts) or None
51+
reasoning = None
52+
if thinking_parts:
53+
reasoning = ReasoningContent(reasoning_text="".join(thinking_parts))
54+
55+
return text, reasoning
56+
57+
58+
def _convert_mistral_response_to_chat_messages(response_data: dict[str, Any] | str) -> list[ChatMessage]:
59+
"""Convert a raw Mistral API JSON response to a list of ChatMessages, handling array content."""
60+
data: dict[str, Any] = json.loads(response_data) if isinstance(response_data, str) else response_data
61+
completions: list[ChatMessage] = []
62+
usage = data.get("usage")
63+
model = data.get("model", "")
64+
65+
for choice in data.get("choices", []):
66+
message = choice.get("message", {})
67+
text, reasoning = _parse_mistral_content(message.get("content"))
68+
69+
tool_calls: list[ToolCall] = []
70+
for tc in message.get("tool_calls") or []:
71+
func = tc.get("function", {})
72+
try:
73+
arguments = json.loads(func.get("arguments", "{}"))
74+
tool_calls.append(ToolCall(id=tc.get("id"), tool_name=func.get("name"), arguments=arguments))
75+
except json.JSONDecodeError:
76+
logger.warning(
77+
"Mistral returned malformed JSON for tool call arguments. "
78+
"Tool call ID: {_id}, Tool name: {_name}, Arguments: {_arguments}",
79+
_id=tc.get("id"),
80+
_name=func.get("name"),
81+
_arguments=func.get("arguments"),
82+
)
83+
84+
meta: dict[str, Any] = {
85+
"model": model,
86+
"index": choice.get("index", 0),
87+
"finish_reason": choice.get("finish_reason"),
88+
"usage": usage,
89+
}
90+
91+
completions.append(ChatMessage.from_assistant(text=text, tool_calls=tool_calls, meta=meta, reasoning=reasoning))
92+
93+
return completions
94+
95+
1996
@component
2097
class MistralChatGenerator(OpenAIChatGenerator):
2198
"""
@@ -28,9 +105,12 @@ class MistralChatGenerator(OpenAIChatGenerator):
28105
parameter in `run` method.
29106
30107
Key Features and Compatibility:
31-
- **Primary Compatibility**: Designed to work seamlessly with the Mistral API Chat Completion endpoint.
108+
- **Primary Compatibility**: Compatible with the Mistral API Chat Completion endpoint.
32109
- **Streaming Support**: Supports streaming responses from the Mistral API Chat Completion endpoint.
33110
- **Customizability**: Supports all parameters supported by the Mistral API Chat Completion endpoint.
111+
- **Reasoning Support**: Extracts reasoning/thinking content from models that support it
112+
(e.g., mistral-small with `reasoning_effort`, magistral models) and stores it in the
113+
`ReasoningContent` field on `ChatMessage`.
34114
35115
This component uses the ChatMessage format for structuring both input and output,
36116
ensuring coherent and contextually relevant responses in chat-based text generation scenarios.
@@ -58,6 +138,22 @@ class MistralChatGenerator(OpenAIChatGenerator):
58138
>> _meta={'model': 'mistral-small-latest', 'index': 0, 'finish_reason': 'stop',
59139
>> 'usage': {'prompt_tokens': 15, 'completion_tokens': 36, 'total_tokens': 51}})]}
60140
```
141+
142+
Reasoning usage example:
143+
```python
144+
from haystack_integrations.components.generators.mistral import MistralChatGenerator
145+
from haystack.dataclasses import ChatMessage
146+
147+
messages = [ChatMessage.from_user("Solve: if x + 3 = 7, what is x?")]
148+
149+
client = MistralChatGenerator(
150+
model="mistral-small-latest",
151+
generation_kwargs={"reasoning_effort": "high"},
152+
)
153+
response = client.run(messages)
154+
print(response["replies"][0].reasoning) # Access reasoning content
155+
print(response["replies"][0].text) # Access final answer
156+
```
61157
"""
62158

63159
SUPPORTED_MODELS: ClassVar[list[str]] = [
@@ -104,8 +200,6 @@ class MistralChatGenerator(OpenAIChatGenerator):
104200
"voxtral-mini-2507",
105201
"voxtral-mini-latest",
106202
"voxtral-mini-2602",
107-
"voxtral-mini-latest",
108-
"voxtral-mini-2507",
109203
]
110204
"""A list of models supported by Mistral AI
111205
see [Mistral AI docs](https://docs.mistral.ai/getting-started/models) for more information
@@ -153,7 +247,12 @@ def __init__(
153247
events as they become available, with the stream terminated by a data: [DONE] message.
154248
- `safe_prompt`: Whether to inject a safety prompt before all conversations.
155249
- `random_seed`: The seed to use for random sampling.
156-
- `response_format`: A JSON schema or a Pydantic model that enforces the structure of the model's response.
250+
- `reasoning_effort`: Controls reasoning/thinking tokens for models that support adjustable reasoning
251+
(e.g., `mistral-small-latest`, `mistral-medium`). Accepted values: `"high"`, `"none"`.
252+
See [Mistral reasoning docs](https://docs.mistral.ai/capabilities/reasoning/).
253+
- `prompt_mode`: For native reasoning models (magistral). Set to `"reasoning"` to use the default
254+
reasoning system prompt, or omit for the model's default behavior.
255+
- `response_format`: A JSON schema or a Pydantic model that enforces the structure of the model's response.
157256
If provided, the output will always be validated against this
158257
format (unless the model returns a tool call).
159258
For details, see the [OpenAI Structured Outputs documentation](https://platform.openai.com/docs/guides/structured-outputs).
@@ -202,12 +301,169 @@ def _prepare_api_call(
202301
tools=tools,
203302
tools_strict=tools_strict,
204303
)
205-
# Mistral does not support response_format and in Haystack 2.18 we always include response_format even if
206-
# it's None
304+
207305
if "response_format" in api_args and api_args["response_format"] is None:
208306
api_args.pop("response_format")
307+
308+
extra_body: dict[str, Any] = {}
309+
for param in ("reasoning_effort", "prompt_mode", "safe_prompt"):
310+
if param in api_args:
311+
extra_body[param] = api_args.pop(param)
312+
if extra_body:
313+
api_args.setdefault("extra_body", {}).update(extra_body)
314+
315+
for i, chat_msg in enumerate(messages):
316+
if chat_msg.reasoning and chat_msg.reasoning.reasoning_text:
317+
formatted = api_args["messages"][i]
318+
text_content = formatted.get("content", "") or ""
319+
formatted["content"] = [
320+
{"type": "thinking", "thinking": [{"type": "text", "text": chat_msg.reasoning.reasoning_text}]},
321+
{"type": "text", "text": text_content},
322+
]
323+
209324
return api_args
210325

326+
@component.output_types(replies=list[ChatMessage])
327+
def run(
328+
self,
329+
messages: list[ChatMessage],
330+
streaming_callback: StreamingCallbackT | None = None,
331+
generation_kwargs: dict[str, Any] | None = None,
332+
*,
333+
tools: ToolsType | None = None,
334+
tools_strict: bool | None = None,
335+
) -> dict[str, list[ChatMessage]]:
336+
"""
337+
Invokes chat completion on the Mistral API.
338+
339+
:param messages:
340+
A list of ChatMessage instances representing the input messages.
341+
:param streaming_callback:
342+
A callback function that is called when a new token is received from the stream.
343+
:param generation_kwargs:
344+
Additional keyword arguments for text generation. These parameters will
345+
override the parameters passed during component initialization.
346+
For details on Mistral API parameters, see
347+
[Mistral docs](https://docs.mistral.ai/api/).
348+
:param tools: A list of Tool and/or Toolset objects, or a single Toolset for which the model can prepare calls.
349+
If set, it will override the `tools` parameter provided during initialization.
350+
:param tools_strict:
351+
Whether to enable strict schema adherence for tool calls.
352+
353+
:returns:
354+
A dictionary with the following key:
355+
- `replies`: A list containing the generated responses as ChatMessage instances.
356+
"""
357+
if not self._is_warmed_up:
358+
self.warm_up()
359+
360+
if len(messages) == 0:
361+
return {"replies": []}
362+
363+
streaming_callback = select_streaming_callback(
364+
init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=False
365+
)
366+
367+
if streaming_callback is not None:
368+
merged_kwargs = {**self.generation_kwargs, **(generation_kwargs or {})}
369+
if merged_kwargs.get("reasoning_effort") or merged_kwargs.get("prompt_mode"):
370+
logger.warning(
371+
"Streaming with reasoning parameters is active. Reasoning content from thinking "
372+
"blocks will not be captured during streaming. Use non-streaming mode to extract "
373+
"reasoning content."
374+
)
375+
376+
api_args = self._prepare_api_call(
377+
messages=messages,
378+
streaming_callback=streaming_callback,
379+
generation_kwargs=generation_kwargs,
380+
tools=tools,
381+
tools_strict=tools_strict,
382+
)
383+
openai_endpoint = api_args.pop("openai_endpoint")
384+
385+
if streaming_callback is not None:
386+
chat_completion = getattr(self.client.chat.completions, openai_endpoint)(**api_args)
387+
completions = self._handle_stream_response(chat_completion, streaming_callback)
388+
else:
389+
raw_response = getattr(self.client.chat.completions.with_raw_response, openai_endpoint)(**api_args)
390+
completions = _convert_mistral_response_to_chat_messages(raw_response.text)
391+
392+
for message in completions:
393+
_check_finish_reason(message.meta)
394+
395+
return {"replies": completions}
396+
397+
@component.output_types(replies=list[ChatMessage])
398+
async def run_async(
399+
self,
400+
messages: list[ChatMessage],
401+
streaming_callback: StreamingCallbackT | None = None,
402+
generation_kwargs: dict[str, Any] | None = None,
403+
*,
404+
tools: ToolsType | None = None,
405+
tools_strict: bool | None = None,
406+
) -> dict[str, list[ChatMessage]]:
407+
"""
408+
Asynchronously invokes chat completion on the Mistral API.
409+
410+
:param messages:
411+
A list of ChatMessage instances representing the input messages.
412+
:param streaming_callback:
413+
A callback function that is called when a new token is received from the stream.
414+
Must be a coroutine.
415+
:param generation_kwargs:
416+
Additional keyword arguments for text generation.
417+
:param tools: A list of Tool and/or Toolset objects, or a single Toolset.
418+
:param tools_strict:
419+
Whether to enable strict schema adherence for tool calls.
420+
421+
:returns:
422+
A dictionary with the following key:
423+
- `replies`: A list containing the generated responses as ChatMessage instances.
424+
"""
425+
if not self._is_warmed_up:
426+
self.warm_up()
427+
428+
if len(messages) == 0:
429+
return {"replies": []}
430+
431+
streaming_callback = select_streaming_callback(
432+
init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=True
433+
)
434+
435+
if streaming_callback is not None:
436+
merged_kwargs = {**self.generation_kwargs, **(generation_kwargs or {})}
437+
if merged_kwargs.get("reasoning_effort") or merged_kwargs.get("prompt_mode"):
438+
logger.warning(
439+
"Streaming with reasoning parameters is active. Reasoning content from thinking "
440+
"blocks will not be captured during streaming. Use non-streaming mode to extract "
441+
"reasoning content."
442+
)
443+
444+
api_args = self._prepare_api_call(
445+
messages=messages,
446+
streaming_callback=streaming_callback,
447+
generation_kwargs=generation_kwargs,
448+
tools=tools,
449+
tools_strict=tools_strict,
450+
)
451+
openai_endpoint = api_args.pop("openai_endpoint")
452+
453+
if streaming_callback is not None:
454+
chat_completion = await getattr(self.async_client.chat.completions, openai_endpoint)(**api_args)
455+
completions = await self._handle_async_stream_response(chat_completion, streaming_callback)
456+
else:
457+
raw_response = await getattr(self.async_client.chat.completions.with_raw_response, openai_endpoint)(
458+
**api_args
459+
)
460+
completions = _convert_mistral_response_to_chat_messages(raw_response.text)
461+
462+
for message in completions:
463+
_check_finish_reason(message.meta)
464+
465+
return {"replies": completions}
466+
211467
def to_dict(self) -> dict[str, Any]:
212468
"""
213469
Serialize this component to a dictionary.

0 commit comments

Comments
 (0)