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:
Mark Backman
2026-04-22 12:15:47 -04:00
parent 10b86b4bbe
commit ec7c35fe98
3 changed files with 144 additions and 72 deletions

View File

@@ -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",

View 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)

View File

@@ -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,