22#
33# SPDX-License-Identifier: Apache-2.0
44
5+ import json
56from typing import Any , ClassVar
67
78from haystack import component , default_to_dict , logging
89from 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+ )
1018from haystack .tools import ToolsType , serialize_tools_or_toolset
1119from haystack .utils import serialize_callable
1220from haystack .utils .auth import Secret
1624logger = 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
2097class 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