Move Mistral message fixups into MistralLLMAdapter
Mistral imposes three conversation-history quirks on top of the OpenAI-compatible wire format: tool messages must be followed by an assistant message; non-initial system messages are rejected; trailing assistant messages require `prefix=True`. These rules were applied inline in `MistralLLMService.build_chat_completion_params`, which is the wrong layer — every other provider with OpenAI-compatible-but-quirky shape (Perplexity, etc.) owns its transformations in a `BaseLLMAdapter` subclass that runs during `get_llm_invocation_params`. Create `MistralLLMAdapter(OpenAILLMAdapter)` on the Perplexity template and wire it in via the existing `adapter_class` dispatch. The service now only handles Mistral-specific request-level mapping (`random_seed` in place of `seed`), and the message shape concerns live with other provider format logic. No behavior change. The transform function casts to `list[dict[str, Any]]` internally because mutating `role` and attaching Mistral's non-standard `prefix` field both step outside OpenAI's TypedDict contract; the cast at the return boundary encodes that we're emitting Mistral's extended schema, not OpenAI's.
This commit is contained in:
@@ -2,14 +2,8 @@
|
|||||||
"typeCheckingMode": "basic",
|
"typeCheckingMode": "basic",
|
||||||
"pythonVersion": "3.11",
|
"pythonVersion": "3.11",
|
||||||
"pythonPlatform": "All",
|
"pythonPlatform": "All",
|
||||||
"include": [
|
"include": ["scripts", "src/pipecat"],
|
||||||
"scripts",
|
"exclude": ["**/*_pb2.py", "**/__pycache__"],
|
||||||
"src/pipecat"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"**/*_pb2.py",
|
|
||||||
"**/__pycache__"
|
|
||||||
],
|
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"tests",
|
"tests",
|
||||||
"src/pipecat/adapters/services/anthropic_adapter.py",
|
"src/pipecat/adapters/services/anthropic_adapter.py",
|
||||||
@@ -82,7 +76,6 @@
|
|||||||
"src/pipecat/services/llm_service.py",
|
"src/pipecat/services/llm_service.py",
|
||||||
"src/pipecat/services/lmnt/tts.py",
|
"src/pipecat/services/lmnt/tts.py",
|
||||||
"src/pipecat/services/mem0/memory.py",
|
"src/pipecat/services/mem0/memory.py",
|
||||||
"src/pipecat/services/mistral/llm.py",
|
|
||||||
"src/pipecat/services/mistral/stt.py",
|
"src/pipecat/services/mistral/stt.py",
|
||||||
"src/pipecat/services/mistral/tts.py",
|
"src/pipecat/services/mistral/tts.py",
|
||||||
"src/pipecat/services/moondream/vision.py",
|
"src/pipecat/services/moondream/vision.py",
|
||||||
|
|||||||
135
src/pipecat/adapters/services/mistral_adapter.py
Normal file
135
src/pipecat/adapters/services/mistral_adapter.py
Normal file
@@ -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)
|
||||||
@@ -10,8 +10,8 @@ from collections.abc import Sequence
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from loguru import logger
|
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.adapters.services.open_ai_adapter import OpenAILLMInvocationParams
|
||||||
from pipecat.frames.frames import FunctionCallFromLLM
|
from pipecat.frames.frames import FunctionCallFromLLM
|
||||||
from pipecat.services.openai.base_llm import BaseOpenAILLMService
|
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.
|
# This value is used by BaseOpenAILLMService when calling the adapter.
|
||||||
supports_developer_role = False
|
supports_developer_role = False
|
||||||
|
|
||||||
|
adapter_class = MistralLLMAdapter
|
||||||
|
|
||||||
Settings = MistralLLMSettings
|
Settings = MistralLLMSettings
|
||||||
_settings: Settings
|
_settings: Settings
|
||||||
|
|
||||||
@@ -92,60 +94,6 @@ class MistralLLMService(OpenAILLMService):
|
|||||||
logger.debug(f"Creating Mistral client with api {base_url}")
|
logger.debug(f"Creating Mistral client with api {base_url}")
|
||||||
return super().create_client(api_key, base_url, **kwargs)
|
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]):
|
async def run_function_calls(self, function_calls: Sequence[FunctionCallFromLLM]):
|
||||||
"""Execute function calls, filtering out already-completed ones.
|
"""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:
|
def build_chat_completion_params(self, params_from_context: OpenAILLMInvocationParams) -> dict:
|
||||||
"""Build parameters for Mistral chat completion request.
|
"""Build parameters for Mistral chat completion request.
|
||||||
|
|
||||||
Handles Mistral-specific requirements including:
|
Handles Mistral-specific parameter mapping (``random_seed`` in place
|
||||||
- Assistant message prefix requirement for API compatibility
|
of ``seed``). Message-shape fixups required by Mistral are applied
|
||||||
- Parameter mapping (random_seed instead of seed)
|
by :class:`MistralLLMAdapter` upstream.
|
||||||
- Core completion settings
|
|
||||||
"""
|
"""
|
||||||
# Apply Mistral's assistant prefix requirement for API compatibility
|
|
||||||
fixed_messages = self._apply_mistral_fixups(params_from_context["messages"])
|
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"model": self._settings.model,
|
"model": self._settings.model,
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"messages": fixed_messages,
|
"messages": params_from_context["messages"],
|
||||||
"tools": params_from_context["tools"],
|
"tools": params_from_context["tools"],
|
||||||
"tool_choice": params_from_context["tool_choice"],
|
"tool_choice": params_from_context["tool_choice"],
|
||||||
"frequency_penalty": self._settings.frequency_penalty,
|
"frequency_penalty": self._settings.frequency_penalty,
|
||||||
|
|||||||
Reference in New Issue
Block a user