diff --git a/pyrightconfig.json b/pyrightconfig.json index 0f42d8550..6dafdcd85 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -2,14 +2,8 @@ "typeCheckingMode": "basic", "pythonVersion": "3.11", "pythonPlatform": "All", - "include": [ - "scripts", - "src/pipecat" - ], - "exclude": [ - "**/*_pb2.py", - "**/__pycache__" - ], + "include": ["scripts", "src/pipecat"], + "exclude": ["**/*_pb2.py", "**/__pycache__"], "ignore": [ "tests", "src/pipecat/adapters/services/anthropic_adapter.py", @@ -82,7 +76,6 @@ "src/pipecat/services/llm_service.py", "src/pipecat/services/lmnt/tts.py", "src/pipecat/services/mem0/memory.py", - "src/pipecat/services/mistral/llm.py", "src/pipecat/services/mistral/stt.py", "src/pipecat/services/mistral/tts.py", "src/pipecat/services/moondream/vision.py", diff --git a/src/pipecat/adapters/services/mistral_adapter.py b/src/pipecat/adapters/services/mistral_adapter.py new file mode 100644 index 000000000..272ea129a --- /dev/null +++ b/src/pipecat/adapters/services/mistral_adapter.py @@ -0,0 +1,135 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Mistral LLM adapter for Pipecat. + +Mistral's API uses an OpenAI-compatible interface but imposes three +conversation-history constraints that OpenAI does not: + +1. **Tool messages must be followed by an assistant message.** A ``"tool"`` + role message that isn't followed by an ``"assistant"`` message is + rejected. + +2. **Only the initial contiguous system block is permitted.** A + ``"system"`` message appearing after any non-system message must be + converted to ``"user"``. + +3. **A trailing assistant message requires ``prefix=True``.** When the + conversation ends on an assistant message, Mistral expects the + ``prefix`` flag set so it can continue from that partial reply. + +This adapter extends ``OpenAILLMAdapter`` and applies those three fixups +before the messages reach ``build_chat_completion_params``. +""" + +import copy +from typing import Any, cast + +from openai.types.chat import ChatCompletionMessageParam + +from pipecat.adapters.services.open_ai_adapter import OpenAILLMAdapter, OpenAILLMInvocationParams +from pipecat.processors.aggregators.llm_context import LLMContext + + +class MistralLLMAdapter(OpenAILLMAdapter): + """Adapter that transforms messages to satisfy Mistral's API constraints. + + Mistral accepts the OpenAI chat-completions schema but enforces extra + rules on conversation history. This adapter extends ``OpenAILLMAdapter`` + and rewrites the messages produced by the parent to comply with those + rules before the request is built. + """ + + def get_llm_invocation_params( + self, + context: LLMContext, + *, + system_instruction: str | None = None, + convert_developer_to_user: bool, + ) -> OpenAILLMInvocationParams: + """Get OpenAI-compatible invocation parameters with Mistral message fixes applied. + + Args: + context: The LLM context containing messages, tools, etc. + system_instruction: Optional system instruction from service settings + or ``run_inference``. Forwarded to the parent adapter. + convert_developer_to_user: If True, convert "developer"-role messages + to "user"-role messages. Forwarded to the parent adapter. + + Returns: + Dictionary of parameters for Mistral's ChatCompletion API, with + messages transformed to satisfy Mistral's constraints. + """ + params = super().get_llm_invocation_params( + context, + system_instruction=system_instruction, + convert_developer_to_user=convert_developer_to_user, + ) + params["messages"] = self._transform_messages(list(params["messages"])) + return params + + def _transform_messages( + self, messages: list[ChatCompletionMessageParam] + ) -> list[ChatCompletionMessageParam]: + """Transform messages to satisfy Mistral's API constraints. + + Applies three transformation steps in order: + + 1. **Insert assistant messages after tool messages** — Any ``"tool"`` + message not followed by an ``"assistant"`` message gets a minimal + ``{"role": "assistant", "content": " "}`` inserted after it. + + 2. **Convert non-initial system messages to user** — System messages + after the initial contiguous system block are converted to + ``"user"``, since Mistral only accepts system messages at the + start of a conversation. + + 3. **Set prefix on trailing assistant message** — If the final message + is an assistant message without a ``prefix`` field, set + ``prefix=True`` so Mistral will continue the partial reply. + + Args: + messages: List of OpenAI-shaped message dicts. + + Returns: + Transformed list of messages satisfying Mistral's constraints. + """ + if not messages: + return messages + + # Work on plain dicts: we need to mutate "role" (which OpenAI TypedDict + # variants tag with fixed Literals) and to attach Mistral's non-standard + # "prefix" field. Cast back on return — the outgoing list is valid for + # Mistral's extended schema even though it doesn't fit OpenAI's. + msgs: list[dict[str, Any]] = copy.deepcopy([dict(m) for m in messages]) + + # Step 1: ensure every "tool" message is followed by an "assistant". + insert_at: list[int] = [] + for i, msg in enumerate(msgs): + if msg.get("role") == "tool": + is_last = i == len(msgs) - 1 + if is_last or msgs[i + 1].get("role") != "assistant": + insert_at.append(i + 1) + for idx in reversed(insert_at): + msgs.insert(idx, {"role": "assistant", "content": " "}) + + # Step 2: convert non-initial system messages to "user". + # Mistral rejects system messages after any non-system message. + first_non_system = next( + (i for i, m in enumerate(msgs) if m.get("role") != "system"), + len(msgs), + ) + for i in range(first_non_system, len(msgs)): + if msgs[i].get("role") == "system": + msgs[i]["role"] = "user" + + # Step 3: set prefix on a trailing assistant message so Mistral will + # continue it rather than rejecting the turn. + last = msgs[-1] + if last.get("role") == "assistant" and "prefix" not in last: + last["prefix"] = True + + return cast(list[ChatCompletionMessageParam], msgs) diff --git a/src/pipecat/services/mistral/llm.py b/src/pipecat/services/mistral/llm.py index c1ac8b652..a85d80ff5 100644 --- a/src/pipecat/services/mistral/llm.py +++ b/src/pipecat/services/mistral/llm.py @@ -10,8 +10,8 @@ from collections.abc import Sequence from dataclasses import dataclass from loguru import logger -from openai.types.chat import ChatCompletionMessageParam +from pipecat.adapters.services.mistral_adapter import MistralLLMAdapter from pipecat.adapters.services.open_ai_adapter import OpenAILLMInvocationParams from pipecat.frames.frames import FunctionCallFromLLM from pipecat.services.openai.base_llm import BaseOpenAILLMService @@ -36,6 +36,8 @@ class MistralLLMService(OpenAILLMService): # This value is used by BaseOpenAILLMService when calling the adapter. supports_developer_role = False + adapter_class = MistralLLMAdapter + Settings = MistralLLMSettings _settings: Settings @@ -92,60 +94,6 @@ class MistralLLMService(OpenAILLMService): logger.debug(f"Creating Mistral client with api {base_url}") return super().create_client(api_key, base_url, **kwargs) - def _apply_mistral_fixups( - self, messages: list[ChatCompletionMessageParam] - ) -> list[ChatCompletionMessageParam]: - """Apply fixups to messages to meet Mistral-specific requirements. - - 1. A "tool"-role message must be followed by an assistant message. - - 2. "system"-role messages must only appear at the start of a - conversation. - - 3. Assistant messages must have prefix=True when they are the final - message in a conversation (but at no other point). - - Args: - messages: The original list of messages. - - Returns: - Messages with Mistral prefix requirement applied to final assistant message. - """ - if not messages: - return messages - - # Create a copy to avoid modifying the original - fixed_messages = [dict(msg) for msg in messages] - - # Ensure all tool responses are followed by an assistant message - assistant_insert_indices = [] - for i, msg in enumerate(fixed_messages): - if msg.get("role") == "tool": - # If this is the last message or the next message is not assistant - if i == len(fixed_messages) - 1 or fixed_messages[i + 1].get("role") != "assistant": - assistant_insert_indices.append(i + 1) - for idx in reversed(assistant_insert_indices): - fixed_messages.insert(idx, {"role": "assistant", "content": " "}) - - # Convert any "system" messages that aren't at the start (i.e., after the initial contiguous block) to "user" - first_non_system_idx = next( - (i for i, msg in enumerate(fixed_messages) if msg.get("role") != "system"), - len(fixed_messages), - ) - for i, msg in enumerate(fixed_messages): - if msg.get("role") == "system" and i >= first_non_system_idx: - msg["role"] = "user" - - # Get the last message - last_message = fixed_messages[-1] - - # Only add prefix=True to the last message if it's an assistant message - # and Mistral would otherwise reject it - if last_message.get("role") == "assistant" and "prefix" not in last_message: - last_message["prefix"] = True - - return fixed_messages - async def run_function_calls(self, function_calls: Sequence[FunctionCallFromLLM]): """Execute function calls, filtering out already-completed ones. @@ -208,18 +156,14 @@ class MistralLLMService(OpenAILLMService): def build_chat_completion_params(self, params_from_context: OpenAILLMInvocationParams) -> dict: """Build parameters for Mistral chat completion request. - Handles Mistral-specific requirements including: - - Assistant message prefix requirement for API compatibility - - Parameter mapping (random_seed instead of seed) - - Core completion settings + Handles Mistral-specific parameter mapping (``random_seed`` in place + of ``seed``). Message-shape fixups required by Mistral are applied + by :class:`MistralLLMAdapter` upstream. """ - # Apply Mistral's assistant prefix requirement for API compatibility - fixed_messages = self._apply_mistral_fixups(params_from_context["messages"]) - params = { "model": self._settings.model, "stream": True, - "messages": fixed_messages, + "messages": params_from_context["messages"], "tools": params_from_context["tools"], "tool_choice": params_from_context["tool_choice"], "frequency_penalty": self._settings.frequency_penalty,