Overuse may indicate design issues; consider polymorphism
if not isinstance(content, list):
1from __future__ import annotations23import hashlib4import json5import logging6import os7import re8import ssl9import uuid10from collections.abc import Callable, Sequence # noqa: TC00311from operator import itemgetter12from typing import (13 TYPE_CHECKING,14 Any,15 Literal,16 cast,17)1819import certifi20import httpx21from httpx_sse import EventSource, aconnect_sse, connect_sse22from langchain_core.callbacks import (23 AsyncCallbackManagerForLLMRun,24 CallbackManagerForLLMRun,25)26from langchain_core.language_models import (27 LanguageModelInput,28 ModelProfile,29 ModelProfileRegistry,30)31from langchain_core.language_models.chat_models import BaseChatModel, LangSmithParams32from langchain_core.language_models.llms import create_base_retry_decorator33from langchain_core.messages import (34 AIMessage,35 AIMessageChunk,36 BaseMessage,37 BaseMessageChunk,38 ChatMessage,39 ChatMessageChunk,40 HumanMessage,41 HumanMessageChunk,42 InvalidToolCall,43 SystemMessage,44 SystemMessageChunk,45 ToolCall,46 ToolMessage,47 is_data_content_block,48)49from langchain_core.messages.block_translators.openai import (50 convert_to_openai_data_block,51)52from langchain_core.messages.tool import tool_call_chunk53from langchain_core.output_parsers import (54 JsonOutputParser,55 PydanticOutputParser,56)57from langchain_core.output_parsers.base import OutputParserLike58from langchain_core.output_parsers.openai_tools import (59 JsonOutputKeyToolsParser,60 PydanticToolsParser,61 make_invalid_tool_call,62 parse_tool_call,63)64from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult65from langchain_core.runnables import Runnable, RunnableMap, RunnablePassthrough66from langchain_core.tools import BaseTool67from langchain_core.utils import get_pydantic_field_names, secret_from_env68from langchain_core.utils.function_calling import convert_to_openai_tool69from langchain_core.utils.pydantic import is_basemodel_subclass70from langchain_core.utils.utils import _build_model_kwargs71from pydantic import (72 BaseModel,73 ConfigDict,74 Field,75 SecretStr,76 model_validator,77)78from typing_extensions import Self7980from langchain_mistralai._compat import _convert_from_v1_to_mistral81from langchain_mistralai._version import __version__82from langchain_mistralai.data._profiles import _PROFILES8384if TYPE_CHECKING:85 from collections.abc import AsyncIterator, Iterator86 from contextlib import AbstractAsyncContextManager8788logger = logging.getLogger(__name__)8990# Mistral enforces a specific pattern for tool call IDs91TOOL_CALL_ID_PATTERN = re.compile(r"^[a-zA-Z0-9]{9}$")929394# This SSL context is equivalent to the default `verify=True`.95# https://www.python-httpx.org/advanced/ssl/#configuring-client-instances96global_ssl_context = ssl.create_default_context(cafile=certifi.where())979899_MODEL_PROFILES = cast("ModelProfileRegistry", _PROFILES)100101102def _get_default_model_profile(model_name: str) -> ModelProfile:103 default = _MODEL_PROFILES.get(model_name) or {}104 return default.copy()105106107def _create_retry_decorator(108 llm: ChatMistralAI,109 run_manager: AsyncCallbackManagerForLLMRun | CallbackManagerForLLMRun | None = None,110) -> Callable[[Any], Any]:111 """Return a tenacity retry decorator, preconfigured to handle exceptions."""112 errors = [httpx.RequestError, httpx.StreamError]113 return create_base_retry_decorator(114 error_types=errors, max_retries=llm.max_retries, run_manager=run_manager115 )116117118def _is_valid_mistral_tool_call_id(tool_call_id: str) -> bool:119 """Check if tool call ID is nine character string consisting of a-z, A-Z, 0-9."""120 return bool(TOOL_CALL_ID_PATTERN.match(tool_call_id))121122123def _base62_encode(num: int) -> str:124 """Encode a number in base62 and ensures result is of a specified length."""125 base62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"126 if num == 0:127 return base62[0]128 arr = []129 base = len(base62)130 while num:131 num, rem = divmod(num, base)132 arr.append(base62[rem])133 arr.reverse()134 return "".join(arr)135136137def _convert_tool_call_id_to_mistral_compatible(tool_call_id: str) -> str:138 """Convert a tool call ID to a Mistral-compatible format."""139 if _is_valid_mistral_tool_call_id(tool_call_id):140 return tool_call_id141 hash_bytes = hashlib.sha256(tool_call_id.encode()).digest()142 hash_int = int.from_bytes(hash_bytes, byteorder="big")143 base62_str = _base62_encode(hash_int)144 if len(base62_str) >= 9:145 return base62_str[:9]146 return base62_str.rjust(9, "0")147148149def _normalize_mistral_content(content: Any) -> str | list[str | dict]:150 """Normalize Mistral content so reference blocks are visible to .text.151152 Mistral citation responses return content as a list of typed chunks where153 `reference` blocks carry visible answer text alongside citation metadata.154 The core `.text` accessor only concatenates blocks whose type is155 `"text"`, so preserving `reference` as-is would drop cited answer spans156 from `message.text` and `ChatGeneration.text`.157158 To keep the answer text visible while preserving citation metadata, rewrite159 each `reference` block to `type: "text"` and move the original block160 (including `reference_ids`) under a `"reference"` key. The `_compat.py`161 translator reads that key to produce standard `Citation` annotations.162 """163 if not isinstance(content, list):164 return content or ""165 has_reference = False166 new_blocks: list[str | dict] = []167 for block in content:168 if isinstance(block, dict) and block.get("type") == "reference":169 has_reference = True170 new_block = {171 "type": "text",172 "text": block.get("text", ""),173 "reference": {174 k: v for k, v in block.items() if k not in ("type", "text")175 },176 }177 if "index" in block:178 new_block["index"] = block["index"]179 new_blocks.append(new_block)180 else:181 new_blocks.append(block)182 return new_blocks if has_reference else content183184185def _convert_mistral_chat_message_to_message(186 _message: dict,187) -> BaseMessage:188 role = _message["role"]189 if role != "assistant":190 msg = f"Expected role to be 'assistant', got {role}"191 raise ValueError(msg)192 # Mistral returns None for tool invocations. When citations are enabled,193 # content is a list of typed chunks (text and reference). Normalize194 # reference blocks so their answer text is visible via .text while195 # citation metadata is preserved for _compat.py to translate.196 content = _normalize_mistral_content(_message.get("content", ""))197198 additional_kwargs: dict = {}199 tool_calls = []200 invalid_tool_calls = []201 if raw_tool_calls := _message.get("tool_calls"):202 additional_kwargs["tool_calls"] = raw_tool_calls203 for raw_tool_call in raw_tool_calls:204 try:205 parsed: dict = cast(206 "dict", parse_tool_call(raw_tool_call, return_id=True)207 )208 if not parsed["id"]:209 parsed["id"] = uuid.uuid4().hex[:]210 tool_calls.append(parsed)211 except Exception as e:212 invalid_tool_calls.append(make_invalid_tool_call(raw_tool_call, str(e)))213 return AIMessage(214 content=content,215 additional_kwargs=additional_kwargs,216 tool_calls=tool_calls,217 invalid_tool_calls=invalid_tool_calls,218 response_metadata={"model_provider": "mistralai"},219 )220221222def _raise_on_error(response: httpx.Response) -> None:223 """Raise an error if the response is an error."""224 if httpx.codes.is_error(response.status_code):225 error_message = response.read().decode("utf-8")226 msg = (227 f"Error response {response.status_code} "228 f"while fetching {response.url}: {error_message}"229 )230 raise httpx.HTTPStatusError(231 msg,232 request=response.request,233 response=response,234 )235236237async def _araise_on_error(response: httpx.Response) -> None:238 """Raise an error if the response is an error."""239 if httpx.codes.is_error(response.status_code):240 error_message = (await response.aread()).decode("utf-8")241 msg = (242 f"Error response {response.status_code} "243 f"while fetching {response.url}: {error_message}"244 )245 raise httpx.HTTPStatusError(246 msg,247 request=response.request,248 response=response,249 )250251252async def _aiter_sse(253 event_source_mgr: AbstractAsyncContextManager[EventSource],254) -> AsyncIterator[dict]:255 """Iterate over the server-sent events."""256 async with event_source_mgr as event_source:257 await _araise_on_error(event_source.response)258 async for event in event_source.aiter_sse():259 if event.data == "[DONE]":260 return261 yield event.json()262263264async def acompletion_with_retry(265 llm: ChatMistralAI,266 run_manager: AsyncCallbackManagerForLLMRun | None = None,267 **kwargs: Any,268) -> Any:269 """Use tenacity to retry the async completion call."""270 retry_decorator = _create_retry_decorator(llm, run_manager=run_manager)271272 @retry_decorator273 async def _completion_with_retry(**kwargs: Any) -> Any:274 if "stream" not in kwargs:275 kwargs["stream"] = False276 stream = kwargs["stream"]277 if stream:278 event_source = aconnect_sse(279 llm.async_client, "POST", "/chat/completions", json=kwargs280 )281 return _aiter_sse(event_source)282 response = await llm.async_client.post(url="/chat/completions", json=kwargs)283 await _araise_on_error(response)284 return response.json()285286 return await _completion_with_retry(**kwargs)287288289def _convert_chunk_to_message_chunk(290 chunk: dict,291 default_class: type[BaseMessageChunk],292 index: int,293 index_type: str,294 output_version: str | None,295) -> tuple[BaseMessageChunk, int, str]:296 _choice = chunk["choices"][0]297 _delta = _choice["delta"]298 role = _delta.get("role")299 content = _delta.get("content") or ""300 if output_version == "v1" and isinstance(content, str):301 content = [{"type": "text", "text": content}]302 content = _normalize_mistral_content(content)303 if isinstance(content, list):304 for block in content:305 if isinstance(block, dict):306 block_type = "reference" if "reference" in block else block.get("type")307 if block_type is not None and block_type != index_type:308 index_type = block_type309 index = index + 1310 if "index" not in block:311 block["index"] = index312 if block.get("type") == "thinking" and isinstance(313 block.get("thinking"), list314 ):315 for sub_block in block["thinking"]:316 if isinstance(sub_block, dict) and "index" not in sub_block:317 sub_block["index"] = 0318 if role == "user" or default_class == HumanMessageChunk:319 return HumanMessageChunk(content=content), index, index_type320 if role == "assistant" or default_class == AIMessageChunk:321 additional_kwargs: dict = {}322 response_metadata: dict[str, Any] = {}323 if raw_tool_calls := _delta.get("tool_calls"):324 additional_kwargs["tool_calls"] = raw_tool_calls325 try:326 tool_call_chunks = []327 for raw_tool_call in raw_tool_calls:328 if not raw_tool_call.get("index") and not raw_tool_call.get("id"):329 tool_call_id = uuid.uuid4().hex[:]330 else:331 tool_call_id = raw_tool_call.get("id")332 tool_call_chunks.append(333 tool_call_chunk(334 name=raw_tool_call["function"].get("name"),335 args=raw_tool_call["function"].get("arguments"),336 id=tool_call_id,337 index=raw_tool_call.get("index"),338 )339 )340 except KeyError:341 pass342 else:343 tool_call_chunks = []344 if token_usage := chunk.get("usage"):345 usage_metadata = {346 "input_tokens": token_usage.get("prompt_tokens", 0),347 "output_tokens": token_usage.get("completion_tokens", 0),348 "total_tokens": token_usage.get("total_tokens", 0),349 }350 else:351 usage_metadata = None352 if _choice.get("finish_reason") is not None and isinstance(353 chunk.get("model"), str354 ):355 response_metadata["model_name"] = chunk["model"]356 response_metadata["finish_reason"] = _choice["finish_reason"]357 return (358 AIMessageChunk(359 content=content,360 additional_kwargs=additional_kwargs,361 tool_call_chunks=tool_call_chunks, # type: ignore[arg-type]362 usage_metadata=usage_metadata, # type: ignore[arg-type]363 response_metadata={"model_provider": "mistralai", **response_metadata},364 ),365 index,366 index_type,367 )368 if role == "system" or default_class == SystemMessageChunk:369 return SystemMessageChunk(content=content), index, index_type370 if role or default_class == ChatMessageChunk:371 return ChatMessageChunk(content=content, role=role), index, index_type372 return default_class(content=content), index, index_type # type: ignore[call-arg]373374375def _format_tool_call_for_mistral(tool_call: ToolCall) -> dict:376 """Format LangChain ToolCall to dict expected by Mistral."""377 result: dict[str, Any] = {378 "function": {379 "name": tool_call["name"],380 "arguments": json.dumps(tool_call["args"], ensure_ascii=False),381 }382 }383 if _id := tool_call.get("id"):384 result["id"] = _convert_tool_call_id_to_mistral_compatible(_id)385386 return result387388389def _format_invalid_tool_call_for_mistral(invalid_tool_call: InvalidToolCall) -> dict:390 """Format LangChain InvalidToolCall to dict expected by Mistral."""391 result: dict[str, Any] = {392 "function": {393 "name": invalid_tool_call["name"],394 "arguments": invalid_tool_call["args"],395 }396 }397 if _id := invalid_tool_call.get("id"):398 result["id"] = _convert_tool_call_id_to_mistral_compatible(_id)399400 return result401402403def _clean_block(block: dict) -> dict:404 # Remove internal keys added by LangChain or by provider response normalization.405 if block.get("type") == "text" and "text" in block:406 return {"type": "text", "text": block["text"]}407408 new_block = {k: v for k, v in block.items() if k != "index"}409 if block.get("type") == "thinking" and isinstance(block.get("thinking"), list):410 new_block["thinking"] = [411 (412 {k: v for k, v in sb.items() if k != "index"}413 if isinstance(sb, dict) and "index" in sb414 else sb415 )416 for sb in block["thinking"]417 ]418 return new_block419420421def _sanitize_chat_completions_content(content: Any) -> Any:422 """Strip non-wire keys from text content blocks.423424 Mistral's chat completions endpoint rejects unknown fields on tool425 message content blocks (e.g. the `id` that LangChain auto-generates on426 `TextContentBlock`). For list content, keep only `type` and `text` on427 text blocks; pass other blocks and non-list content through unchanged.428 """429 if not isinstance(content, list):430 return content431 sanitized: list[Any] = []432 for block in content:433 if isinstance(block, dict) and block.get("type") == "text" and "text" in block:434 sanitized.append({"type": "text", "text": block["text"]})435 else:436 sanitized.append(block)437 return sanitized438439440def _format_message_content(content: Any) -> Any:441 """Format message content for the Mistral chat completions wire format.442443 Walks list content and translates LangChain canonical v0/v1 multimodal444 data blocks (e.g. `ImageContentBlock` with `url`, `base64`, or445 `file_id`) into the OpenAI-compatible shape that Mistral accepts:446 `{"type": "image_url", "image_url": {"url": "..."}}`. Strings and any447 other dict blocks are returned unchanged so that already-translated wire448 blocks (e.g. `text`, `image_url`) and Mistral-specific blocks449 (`document_url`, `input_audio`) pass through; the API surfaces an error450 for anything it doesn't understand.451452 Args:453 content: The message content. Strings and non-list values pass454 through unchanged; lists are walked block by block.455456 Returns:457 The formatted content. List inputs return a new list with canonical458 data-block translations applied; other inputs are returned as-is.459 """460 if not isinstance(content, list):461 return content462 formatted: list[Any] = []463 for block in content:464 if isinstance(block, dict) and is_data_content_block(block):465 formatted.append(466 convert_to_openai_data_block(block, api="chat/completions")467 )468 continue469 formatted.append(block)470 return formatted471472473def _convert_message_to_mistral_chat_message(474 message: BaseMessage,475) -> dict:476 if isinstance(message, ChatMessage):477 return {"role": message.role, "content": message.content}478 if isinstance(message, HumanMessage):479 return {"role": "user", "content": _format_message_content(message.content)}480 if isinstance(message, AIMessage):481 message_dict: dict[str, Any] = {"role": "assistant"}482 tool_calls: list = []483 if message.tool_calls or message.invalid_tool_calls:484 if message.tool_calls:485 tool_calls.extend(486 _format_tool_call_for_mistral(tool_call)487 for tool_call in message.tool_calls488 )489 if message.invalid_tool_calls:490 tool_calls.extend(491 _format_invalid_tool_call_for_mistral(invalid_tool_call)492 for invalid_tool_call in message.invalid_tool_calls493 )494 elif "tool_calls" in message.additional_kwargs:495 for tc in message.additional_kwargs["tool_calls"]:496 chunk = {497 "function": {498 "name": tc["function"]["name"],499 "arguments": tc["function"]["arguments"],500 }501 }502 if _id := tc.get("id"):503 chunk["id"] = _id504 tool_calls.append(chunk)505 else:506 pass507 if tool_calls: # do not populate empty list tool_calls508 message_dict["tool_calls"] = tool_calls509510 # Message content511 # Translate v1 content512 if message.response_metadata.get("output_version") == "v1":513 content = _convert_from_v1_to_mistral(514 message.content_blocks, message.response_metadata.get("model_provider")515 )516 else:517 content = message.content518519 if tool_calls and content:520 # Assistant message must have either content or tool_calls, but not both.521 # Some providers may not support tool_calls in the same message as content.522 # This is done to ensure compatibility with messages from other providers.523 content = ""524525 elif isinstance(content, list):526 content = [527 _clean_block(block) if isinstance(block, dict) else block528 for block in content529 ]530 else:531 content = message.content532533 # if any blocks are dicts, cast strings to text blocks534 if any(isinstance(block, dict) for block in content):535 content = [536 block if isinstance(block, dict) else {"type": "text", "text": block}537 for block in content538 ]539 message_dict["content"] = content540541 if "prefix" in message.additional_kwargs:542 message_dict["prefix"] = message.additional_kwargs["prefix"]543 return message_dict544 if isinstance(message, SystemMessage):545 return {"role": "system", "content": message.content}546 if isinstance(message, ToolMessage):547 return {548 "role": "tool",549 "content": _sanitize_chat_completions_content(message.content),550 "name": message.name,551 "tool_call_id": _convert_tool_call_id_to_mistral_compatible(552 message.tool_call_id553 ),554 }555 msg = f"Got unknown type {message}"556 raise ValueError(msg)557558559class ChatMistralAI(BaseChatModel):560 """A chat model that uses the Mistral AI API."""561562 # The type for client and async_client is ignored because the type is not563 # an Optional after the model is initialized and the model_validator564 # is run.565 client: httpx.Client = Field( # type: ignore[assignment] # : meta private:566 default=None, exclude=True567 )568569 async_client: httpx.AsyncClient = Field( # type: ignore[assignment] # : meta private:570 default=None, exclude=True571 )572573 mistral_api_key: SecretStr | None = Field(574 alias="api_key",575 default_factory=secret_from_env("MISTRAL_API_KEY", default=None),576 )577578 endpoint: str | None = Field(default=None, alias="base_url")579580 max_retries: int = 5581582 timeout: int = 120583584 max_concurrent_requests: int = 64585586 model: str = Field(default="mistral-small", alias="model_name")587588 temperature: float = 0.7589590 max_tokens: int | None = None591592 stop: list[str] | None = None593 """Default stop sequences.594595 Generation stops when any of these strings is produced; the stop sequence itself596 is not included in the output. Can be overridden per call via the `stop` argument.597 Mistral accepts up to 4 stop sequences.598 """599600 top_p: float = 1601 """Decode using nucleus sampling: consider the smallest set of tokens whose602 probability sum is at least `top_p`. Must be in the closed interval603 `[0.0, 1.0]`."""604605 random_seed: int | None = None606607 safe_mode: bool | None = None608609 streaming: bool = False610611 model_kwargs: dict[str, Any] = Field(default_factory=dict)612 """Holds any invocation parameters not explicitly specified."""613614 model_config = ConfigDict(615 populate_by_name=True,616 arbitrary_types_allowed=True,617 )618619 @model_validator(mode="before")620 @classmethod621 def build_extra(cls, values: dict[str, Any]) -> Any:622 """Build extra kwargs from additional params that were passed in."""623 all_required_field_names = get_pydantic_field_names(cls)624 return _build_model_kwargs(values, all_required_field_names)625626 @property627 def _default_params(self) -> dict[str, Any]:628 """Get the default parameters for calling the API."""629 defaults = {630 "model": self.model,631 "temperature": self.temperature,632 "max_tokens": self.max_tokens,633 "top_p": self.top_p,634 "random_seed": self.random_seed,635 "safe_prompt": self.safe_mode,636 **self.model_kwargs,637 }638 return {k: v for k, v in defaults.items() if v is not None}639640 def _get_ls_params(641 self, stop: list[str] | None = None, **kwargs: Any642 ) -> LangSmithParams:643 """Get standard params for tracing."""644 params = self._get_invocation_params(stop=stop, **kwargs)645 ls_params = LangSmithParams(646 ls_provider="mistral",647 ls_model_name=params.get("model", self.model),648 ls_model_type="chat",649 ls_temperature=params.get("temperature", self.temperature),650 )651 if ls_max_tokens := params.get("max_tokens", self.max_tokens):652 ls_params["ls_max_tokens"] = ls_max_tokens653 if ls_stop := stop or self.stop or params.get("stop", None):654 ls_params["ls_stop"] = ls_stop655 return ls_params656657 @property658 def _client_params(self) -> dict[str, Any]:659 """Get the parameters used for the client."""660 return self._default_params661662 def completion_with_retry(663 self, run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any664 ) -> Any:665 """Use tenacity to retry the completion call."""666 retry_decorator = _create_retry_decorator(self, run_manager=run_manager)667668 @retry_decorator669 def _completion_with_retry(**kwargs: Any) -> Any:670 if "stream" not in kwargs:671 kwargs["stream"] = False672 stream = kwargs["stream"]673 if stream:674675 def iter_sse() -> Iterator[dict]:676 with connect_sse(677 self.client, "POST", "/chat/completions", json=kwargs678 ) as event_source:679 _raise_on_error(event_source.response)680 for event in event_source.iter_sse():681 if event.data == "[DONE]":682 return683 yield event.json()684685 return iter_sse()686 response = self.client.post(url="/chat/completions", json=kwargs)687 _raise_on_error(response)688 return response.json()689690 return _completion_with_retry(**kwargs)691692 def _combine_llm_outputs(self, llm_outputs: list[dict | None]) -> dict:693 overall_token_usage: dict = {}694 for output in llm_outputs:695 if output is None:696 # Happens in streaming697 continue698 token_usage = output["token_usage"]699 if token_usage is not None:700 for k, v in token_usage.items():701 if k in overall_token_usage:702 overall_token_usage[k] += v703 else:704 overall_token_usage[k] = v705 return {"token_usage": overall_token_usage, "model_name": self.model}706707 @model_validator(mode="after")708 def _set_mistralai_version(self) -> Self:709 """Set package version in metadata."""710 self._add_version("langchain-mistralai", __version__)711 return self712713 @model_validator(mode="after")714 def validate_environment(self) -> Self:715 """Validate api key, python package exists, temperature, and top_p."""716 if isinstance(self.mistral_api_key, SecretStr):717 api_key_str: str | None = self.mistral_api_key.get_secret_value()718 else:719 api_key_str = self.mistral_api_key720721 # TODO: handle retries722 base_url_str = (723 self.endpoint724 or os.environ.get("MISTRAL_BASE_URL")725 or "https://api.mistral.ai/v1"726 )727 self.endpoint = base_url_str728 if not self.client:729 self.client = httpx.Client(730 base_url=base_url_str,731 headers={732 "Content-Type": "application/json",733 "Accept": "application/json",734 "Authorization": f"Bearer {api_key_str}",735 },736 timeout=self.timeout,737 verify=global_ssl_context,738 )739 # TODO: handle retries and max_concurrency740 if not self.async_client:741 self.async_client = httpx.AsyncClient(742 base_url=base_url_str,743 headers={744 "Content-Type": "application/json",745 "Accept": "application/json",746 "Authorization": f"Bearer {api_key_str}",747 },748 timeout=self.timeout,749 verify=global_ssl_context,750 )751752 if self.temperature is not None and not 0 <= self.temperature <= 1:753 msg = "temperature must be in the range [0.0, 1.0]"754 raise ValueError(msg)755756 if self.top_p is not None and not 0 <= self.top_p <= 1:757 msg = "top_p must be in the range [0.0, 1.0]"758 raise ValueError(msg)759760 return self761762 def _resolve_model_profile(self) -> ModelProfile | None:763 return _get_default_model_profile(self.model) or None764765 def _generate(766 self,767 messages: list[BaseMessage],768 stop: list[str] | None = None,769 run_manager: CallbackManagerForLLMRun | None = None,770 stream: bool | None = None, # noqa: FBT001771 **kwargs: Any,772 ) -> ChatResult:773 message_dicts, params = self._create_message_dicts(messages, stop)774 params = {**params, **kwargs}775 response = self.completion_with_retry(776 messages=message_dicts, run_manager=run_manager, **params777 )778 return self._create_chat_result(response)779780 def _create_chat_result(self, response: dict) -> ChatResult:781 generations = []782 token_usage = response.get("usage", {})783 for res in response["choices"]:784 finish_reason = res.get("finish_reason")785 message = _convert_mistral_chat_message_to_message(res["message"])786 if token_usage and isinstance(message, AIMessage):787 message.usage_metadata = {788 "input_tokens": token_usage.get("prompt_tokens", 0),789 "output_tokens": token_usage.get("completion_tokens", 0),790 "total_tokens": token_usage.get("total_tokens", 0),791 }792 gen = ChatGeneration(793 message=message,794 generation_info={"finish_reason": finish_reason},795 )796 generations.append(gen)797798 llm_output = {799 "token_usage": token_usage,800 "model_name": self.model,801 "model": self.model, # Backwards compatibility802 }803 return ChatResult(generations=generations, llm_output=llm_output)804805 def _create_message_dicts(806 self, messages: list[BaseMessage], stop: list[str] | None807 ) -> tuple[list[dict], dict[str, Any]]:808 params = self._client_params809 stop = stop if stop is not None else self.stop810 if stop:811 params["stop"] = stop812 message_dicts = [_convert_message_to_mistral_chat_message(m) for m in messages]813 return message_dicts, params814815 def _stream(816 self,817 messages: list[BaseMessage],818 stop: list[str] | None = None,819 run_manager: CallbackManagerForLLMRun | None = None,820 **kwargs: Any,821 ) -> Iterator[ChatGenerationChunk]:822 message_dicts, params = self._create_message_dicts(messages, stop)823 params = {**params, **kwargs, "stream": True}824825 default_chunk_class: type[BaseMessageChunk] = AIMessageChunk826 index = -1827 index_type = ""828 for chunk in self.completion_with_retry(829 messages=message_dicts, run_manager=run_manager, **params830 ):831 if len(chunk.get("choices", [])) == 0:832 continue833 new_chunk, index, index_type = _convert_chunk_to_message_chunk(834 chunk, default_chunk_class, index, index_type, self.output_version835 )836 # make future chunks same type as first chunk837 default_chunk_class = new_chunk.__class__838 gen_chunk = ChatGenerationChunk(message=new_chunk)839 if run_manager:840 run_manager.on_llm_new_token(841 token=cast("str", new_chunk.content), chunk=gen_chunk842 )843 yield gen_chunk844845 async def _astream(846 self,847 messages: list[BaseMessage],848 stop: list[str] | None = None,849 run_manager: AsyncCallbackManagerForLLMRun | None = None,850 **kwargs: Any,851 ) -> AsyncIterator[ChatGenerationChunk]:852 message_dicts, params = self._create_message_dicts(messages, stop)853 params = {**params, **kwargs, "stream": True}854855 default_chunk_class: type[BaseMessageChunk] = AIMessageChunk856 index = -1857 index_type = ""858 async for chunk in await acompletion_with_retry(859 self, messages=message_dicts, run_manager=run_manager, **params860 ):861 if len(chunk.get("choices", [])) == 0:862 continue863 new_chunk, index, index_type = _convert_chunk_to_message_chunk(864 chunk, default_chunk_class, index, index_type, self.output_version865 )866 # make future chunks same type as first chunk867 default_chunk_class = new_chunk.__class__868 gen_chunk = ChatGenerationChunk(message=new_chunk)869 if run_manager:870 await run_manager.on_llm_new_token(871 token=cast("str", new_chunk.content), chunk=gen_chunk872 )873 yield gen_chunk874875 async def _agenerate(876 self,877 messages: list[BaseMessage],878 stop: list[str] | None = None,879 run_manager: AsyncCallbackManagerForLLMRun | None = None,880 stream: bool | None = None, # noqa: FBT001881 **kwargs: Any,882 ) -> ChatResult:883 message_dicts, params = self._create_message_dicts(messages, stop)884 params = {**params, **kwargs}885 response = await acompletion_with_retry(886 self, messages=message_dicts, run_manager=run_manager, **params887 )888 return self._create_chat_result(response)889890 def bind_tools(891 self,892 tools: Sequence[dict[str, Any] | type | Callable | BaseTool],893 tool_choice: dict | str | Literal["auto", "any"] | None = None, # noqa: PYI051894 **kwargs: Any,895 ) -> Runnable[LanguageModelInput, AIMessage]:896 """Bind tool-like objects to this chat model.897898 Assumes model is compatible with OpenAI tool-calling API.899900 Args:901 tools: A list of tool definitions to bind to this chat model.902903 Supports any tool definition handled by [`convert_to_openai_tool`][langchain_core.utils.function_calling.convert_to_openai_tool].904 tool_choice: Which tool to require the model to call.905 Must be the name of the single provided function or906 `'auto'` to automatically determine which function to call907 (if any), or a dict of the form:908 {"type": "function", "function": {"name": <<tool_name>>}}.909 kwargs: Any additional parameters are passed directly to910 `self.bind(**kwargs)`.911 """ # noqa: E501912 formatted_tools = [convert_to_openai_tool(tool) for tool in tools]913 if tool_choice:914 tool_names = []915 for tool in formatted_tools:916 if ("function" in tool and (name := tool["function"].get("name"))) or (917 name := tool.get("name")918 ):919 tool_names.append(name)920 else:921 pass922 if tool_choice in tool_names:923 kwargs["tool_choice"] = {924 "type": "function",925 "function": {"name": tool_choice},926 }927 else:928 kwargs["tool_choice"] = tool_choice929 return super().bind(tools=formatted_tools, **kwargs)930931 def with_structured_output(932 self,933 schema: dict | type | None = None,934 *,935 method: Literal[936 "function_calling", "json_mode", "json_schema"937 ] = "function_calling",938 include_raw: bool = False,939 **kwargs: Any,940 ) -> Runnable[LanguageModelInput, dict | BaseModel]:941 r"""Model wrapper that returns outputs formatted to match the given schema.942943 Args:944 schema: The output schema. Can be passed in as:945946 - An OpenAI function/tool schema,947 - A JSON Schema,948 - A `TypedDict` class,949 - Or a Pydantic class.950951 If `schema` is a Pydantic class then the model output will be a952 Pydantic instance of that class, and the model-generated fields will be953 validated by the Pydantic class. Otherwise the model output will be a954 dict and will not be validated.955956 See `langchain_core.utils.function_calling.convert_to_openai_tool` for957 more on how to properly specify types and descriptions of schema fields958 when specifying a Pydantic or `TypedDict` class.959960 method: The method for steering model generation, one of:961962 - `'function_calling'`:963 Uses Mistral's964 [function-calling feature](https://docs.mistral.ai/capabilities/function_calling/).965 - `'json_schema'`:966 Uses Mistral's967 [structured output feature](https://docs.mistral.ai/capabilities/structured-output/custom_structured_output/).968 - `'json_mode'`:969 Uses Mistral's970 [JSON mode](https://docs.mistral.ai/capabilities/structured-output/json_mode/).971 Note that if using JSON mode then you972 must include instructions for formatting the output into the973 desired schema into the model call.974975 !!! warning "Behavior changed in `langchain-mistralai` 0.2.5"976977 Added method="json_schema"978979 include_raw:980 If `False` then only the parsed structured output is returned.981982 If an error occurs during model output parsing it will be raised.983984 If `True` then both the raw model response (a `BaseMessage`) and the985 parsed model response will be returned.986987 If an error occurs during output parsing it will be caught and returned988 as well.989990 The final output is always a `dict` with keys `'raw'`, `'parsed'`, and991 `'parsing_error'`.992993 kwargs: Any additional parameters are passed directly to994 `self.bind(**kwargs)`. This is useful for passing in995 parameters such as `tool_choice` or `tools` to control996 which tool the model should call, or to pass in parameters such as997 `stop` to control when the model should stop generating output.998999 Returns:1000 A `Runnable` that takes same inputs as a1001 `langchain_core.language_models.chat.BaseChatModel`. If `include_raw` is1002 `False` and `schema` is a Pydantic class, `Runnable` outputs an instance1003 of `schema` (i.e., a Pydantic object). Otherwise, if `include_raw` is1004 `False` then `Runnable` outputs a `dict`.10051006 If `include_raw` is `True`, then `Runnable` outputs a `dict` with keys:10071008 - `'raw'`: `BaseMessage`1009 - `'parsed'`: `None` if there was a parsing error, otherwise the type1010 depends on the `schema` as described above.1011 - `'parsing_error'`: `BaseException | None`10121013 Example: schema=Pydantic class, method="function_calling", include_raw=False:10141015 ```python1016 from typing import Optional10171018 from langchain_mistralai import ChatMistralAI1019 from pydantic import BaseModel, Field102010211022 class AnswerWithJustification(BaseModel):1023 '''An answer to the user question along with justification for the answer.'''10241025 answer: str1026 # If we provide default values and/or descriptions for fields, these will be passed1027 # to the model. This is an important part of improving a model's ability to1028 # correctly return structured outputs.1029 justification: str | None = Field(1030 default=None, description="A justification for the answer."1031 )103210331034 model = ChatMistralAI(model="mistral-large-latest", temperature=0)1035 structured_model = model.with_structured_output(AnswerWithJustification)10361037 structured_model.invoke(1038 "What weighs more a pound of bricks or a pound of feathers"1039 )10401041 # -> AnswerWithJustification(1042 # answer='They weigh the same',1043 # justification='Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ.'1044 # )1045 ```10461047 Example: schema=Pydantic class, method="function_calling", include_raw=True:10481049 ```python1050 from langchain_mistralai import ChatMistralAI1051 from pydantic import BaseModel105210531054 class AnswerWithJustification(BaseModel):1055 '''An answer to the user question along with justification for the answer.'''10561057 answer: str1058 justification: str105910601061 model = ChatMistralAI(model="mistral-large-latest", temperature=0)1062 structured_model = model.with_structured_output(1063 AnswerWithJustification, include_raw=True1064 )10651066 structured_model.invoke(1067 "What weighs more a pound of bricks or a pound of feathers"1068 )1069 # -> {1070 # 'raw': AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_Ao02pnFYXD6GN1yzc0uXPsvF', 'function': {'arguments': '{"answer":"They weigh the same.","justification":"Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ."}', 'name': 'AnswerWithJustification'}, 'type': 'function'}]}),1071 # 'parsed': AnswerWithJustification(answer='They weigh the same.', justification='Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume or density of the objects may differ.'),1072 # 'parsing_error': None1073 # }1074 ```10751076 Example: schema=TypedDict class, method="function_calling", include_raw=False:10771078 ```python1079 from typing_extensions import Annotated, TypedDict10801081 from langchain_mistralai import ChatMistralAI108210831084 class AnswerWithJustification(TypedDict):1085 '''An answer to the user question along with justification for the answer.'''10861087 answer: str1088 justification: Annotated[1089 str | None, None, "A justification for the answer."1090 ]109110921093 model = ChatMistralAI(model="mistral-large-latest", temperature=0)1094 structured_model = model.with_structured_output(AnswerWithJustification)10951096 structured_model.invoke(1097 "What weighs more a pound of bricks or a pound of feathers"1098 )1099 # -> {1100 # 'answer': 'They weigh the same',1101 # 'justification': 'Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume and density of the two substances differ.'1102 # }1103 ```11041105 Example: schema=OpenAI function schema, method="function_calling", include_raw=False:11061107 ```python1108 from langchain_mistralai import ChatMistralAI11091110 oai_schema = {1111 'name': 'AnswerWithJustification',1112 'description': 'An answer to the user question along with justification for the answer.',1113 'parameters': {1114 'type': 'object',1115 'properties': {1116 'answer': {'type': 'string'},1117 'justification': {'description': 'A justification for the answer.', 'type': 'string'}1118 },1119 'required': ['answer']1120 }11211122 model = ChatMistralAI(model="mistral-large-latest", temperature=0)1123 structured_model = model.with_structured_output(oai_schema)11241125 structured_model.invoke(1126 "What weighs more a pound of bricks or a pound of feathers"1127 )1128 # -> {1129 # 'answer': 'They weigh the same',1130 # 'justification': 'Both a pound of bricks and a pound of feathers weigh one pound. The weight is the same, but the volume and density of the two substances differ.'1131 # }1132 ```11331134 Example: schema=Pydantic class, method="json_mode", include_raw=True:11351136 ```python1137 from langchain_mistralai import ChatMistralAI1138 from pydantic import BaseModel113911401141 class AnswerWithJustification(BaseModel):1142 answer: str1143 justification: str114411451146 model = ChatMistralAI(model="mistral-large-latest", temperature=0)1147 structured_model = model.with_structured_output(1148 AnswerWithJustification, method="json_mode", include_raw=True1149 )11501151 structured_model.invoke(1152 "Answer the following question. "1153 "Make sure to return a JSON blob with keys 'answer' and 'justification'.\\n\\n"1154 "What's heavier a pound of bricks or a pound of feathers?"1155 )1156 # -> {1157 # 'raw': AIMessage(content='{\\n "answer": "They are both the same weight.",\\n "justification": "Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight." \\n}'),1158 # 'parsed': AnswerWithJustification(answer='They are both the same weight.', justification='Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight.'),1159 # 'parsing_error': None1160 # }1161 ```11621163 Example: schema=None, method="json_mode", include_raw=True:11641165 ```python1166 structured_model = model.with_structured_output(1167 method="json_mode", include_raw=True1168 )11691170 structured_model.invoke(1171 "Answer the following question. "1172 "Make sure to return a JSON blob with keys 'answer' and 'justification'.\\n\\n"1173 "What's heavier a pound of bricks or a pound of feathers?"1174 )1175 # -> {1176 # 'raw': AIMessage(content='{\\n "answer": "They are both the same weight.",\\n "justification": "Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight." \\n}'),1177 # 'parsed': {1178 # 'answer': 'They are both the same weight.',1179 # 'justification': 'Both a pound of bricks and a pound of feathers weigh one pound. The difference lies in the volume and density of the materials, not the weight.'1180 # },1181 # 'parsing_error': None1182 # }1183 ```1184 """ # noqa: E5011185 _ = kwargs.pop("strict", None)1186 if kwargs:1187 msg = f"Received unsupported arguments {kwargs}"1188 raise ValueError(msg)1189 is_pydantic_schema = isinstance(schema, type) and is_basemodel_subclass(schema)1190 if method == "function_calling":1191 if schema is None:1192 msg = (1193 "schema must be specified when method is 'function_calling'. "1194 "Received None."1195 )1196 raise ValueError(msg)1197 # TODO: Update to pass in tool name as tool_choice if/when Mistral supports1198 # specifying a tool.1199 llm = self.bind_tools(1200 [schema],1201 tool_choice="any",1202 ls_structured_output_format={1203 "kwargs": {"method": "function_calling"},1204 "schema": schema,1205 },1206 )1207 if is_pydantic_schema:1208 output_parser: OutputParserLike = PydanticToolsParser(1209 tools=[schema], # type: ignore[list-item]1210 first_tool_only=True, # type: ignore[list-item]1211 )1212 else:1213 key_name = convert_to_openai_tool(schema)["function"]["name"]1214 output_parser = JsonOutputKeyToolsParser(1215 key_name=key_name, first_tool_only=True1216 )1217 elif method == "json_mode":1218 llm = self.bind(1219 response_format={"type": "json_object"},1220 ls_structured_output_format={1221 "kwargs": {1222 # this is correct - name difference with mistral api1223 "method": "json_mode"1224 },1225 "schema": schema,1226 },1227 )1228 output_parser = (1229 PydanticOutputParser(pydantic_object=schema) # type: ignore[type-var, arg-type]1230 if is_pydantic_schema1231 else JsonOutputParser()1232 )1233 elif method == "json_schema":1234 if schema is None:1235 msg = (1236 "schema must be specified when method is 'json_schema'. "1237 "Received None."1238 )1239 raise ValueError(msg)1240 response_format = _convert_to_openai_response_format(schema, strict=True)1241 llm = self.bind(1242 response_format=response_format,1243 ls_structured_output_format={1244 "kwargs": {"method": "json_schema"},1245 "schema": schema,1246 },1247 )12481249 output_parser = (1250 PydanticOutputParser(pydantic_object=schema) # type: ignore[arg-type]1251 if is_pydantic_schema1252 else JsonOutputParser()1253 )1254 if include_raw:1255 parser_assign = RunnablePassthrough.assign(1256 parsed=itemgetter("raw") | output_parser, parsing_error=lambda _: None1257 )1258 parser_none = RunnablePassthrough.assign(parsed=lambda _: None)1259 parser_with_fallback = parser_assign.with_fallbacks(1260 [parser_none], exception_key="parsing_error"1261 )1262 return RunnableMap(raw=llm) | parser_with_fallback1263 return llm | output_parser12641265 @property1266 def _identifying_params(self) -> dict[str, Any]:1267 """Get the identifying parameters."""1268 return self._default_params12691270 @property1271 def _llm_type(self) -> str:1272 """Return type of chat model."""1273 return "mistralai-chat"12741275 @property1276 def lc_secrets(self) -> dict[str, str]:1277 return {"mistral_api_key": "MISTRAL_API_KEY"}12781279 @classmethod1280 def is_lc_serializable(cls) -> bool:1281 """Return whether this model can be serialized by LangChain."""1282 return True12831284 @classmethod1285 def get_lc_namespace(cls) -> list[str]:1286 """Get the namespace of the LangChain object.12871288 Returns:1289 `["langchain", "chat_models", "mistralai"]`1290 """1291 return ["langchain", "chat_models", "mistralai"]129212931294def _convert_to_openai_response_format(1295 schema: dict[str, Any] | type, *, strict: bool | None = None1296) -> dict:1297 """Perform same op as in ChatOpenAI, but do not pass through Pydantic BaseModels."""1298 if (1299 isinstance(schema, dict)1300 and "json_schema" in schema1301 and schema.get("type") == "json_schema"1302 ):1303 response_format = schema1304 elif isinstance(schema, dict) and "name" in schema and "schema" in schema:1305 response_format = {"type": "json_schema", "json_schema": schema}1306 else:1307 if strict is None:1308 if isinstance(schema, dict) and isinstance(schema.get("strict"), bool):1309 strict = schema["strict"]1310 else:1311 strict = False1312 function = convert_to_openai_tool(schema, strict=strict)["function"]1313 function["schema"] = function.pop("parameters")1314 response_format = {"type": "json_schema", "json_schema": function}13151316 if (1317 strict is not None1318 and strict is not response_format["json_schema"].get("strict")1319 and isinstance(schema, dict)1320 ):1321 msg = (1322 f"Output schema already has 'strict' value set to "1323 f"{schema['json_schema']['strict']} but 'strict' also passed in to "1324 f"with_structured_output as {strict}. Please make sure that "1325 f"'strict' is only specified in one place."1326 )1327 raise ValueError(msg)1328 return response_format
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.