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",
|
||||
"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",
|
||||
|
||||
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 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,
|
||||
|
||||
Reference in New Issue
Block a user