Add MistralLLMService

This commit is contained in:
Mark Backman
2025-08-10 09:23:26 -04:00
parent 5ca33a2b00
commit 2b2b0f8121
2 changed files with 188 additions and 0 deletions

View File

View File

@@ -0,0 +1,188 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Mistral LLM service implementation using OpenAI-compatible interface."""
from typing import List, Sequence
from loguru import logger
from openai import AsyncStream
from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
from pipecat.frames.frames import FunctionCallFromLLM
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
from pipecat.services.openai.llm import OpenAILLMService
class MistralLLMService(OpenAILLMService):
"""A service for interacting with Mistral's API using the OpenAI-compatible interface.
This service extends OpenAILLMService to connect to Mistral's API endpoint while
maintaining full compatibility with OpenAI's interface and functionality.
"""
def __init__(
self,
*,
api_key: str,
base_url: str = "https://api.mistral.ai/v1",
model: str = "mistral-small-latest",
**kwargs,
):
"""Initialize the Mistral LLM service.
Args:
api_key: The API key for accessing Mistral's API.
base_url: The base URL for Mistral API. Defaults to "https://api.mistral.ai/v1".
model: The model identifier to use. Defaults to "mistral-small-latest".
**kwargs: Additional keyword arguments passed to OpenAILLMService.
"""
super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs)
def create_client(self, api_key=None, base_url=None, **kwargs):
"""Create OpenAI-compatible client for Mistral API endpoint.
Args:
api_key: The API key for authentication. If None, uses instance key.
base_url: The base URL for the API. If None, uses instance URL.
**kwargs: Additional arguments passed to the client constructor.
Returns:
An OpenAI-compatible client configured for Mistral API.
"""
logger.debug(f"Creating Mistral client with api {base_url}")
return super().create_client(api_key, base_url, **kwargs)
def _apply_mistral_assistant_prefix(
self, messages: List[ChatCompletionMessageParam]
) -> List[ChatCompletionMessageParam]:
"""Apply Mistral's assistant message prefix requirement.
Mistral requires assistant messages to have prefix=True when they
are the final message in a conversation. According to Mistral's API:
- Assistant messages with prefix=True MUST be the last message
- Only add prefix=True to the final assistant message when needed
- This allows assistant messages to be accepted as the last message
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]
# 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.
Mistral and OpenAI have different function call detection patterns:
OpenAI (Stream-based detection):
- Detects function calls only from streaming chunks as the LLM generates them
- Second LLM completion doesn't re-detect existing tool_calls in message history
- Function calls execute exactly once
Mistral (Message-based detection):
- Detects function calls from the complete message history on each completion
- Second LLM completion with the response re-detects the same tool_calls from
previous messages
- Without filtering, function calls would execute twice
This method prevents duplicate execution by:
1. Checking message history for existing tool result messages
2. Filtering out function calls that already have corresponding results
3. Only executing function calls that haven't been completed yet
Note: This filtering prevents duplicate function execution, but the
on_function_calls_started event may still fire twice due to the detection
pattern difference. This is expected behavior.
Args:
function_calls: The function calls to potentially execute.
"""
if not function_calls:
return
# Filter out function calls that already have results
calls_to_execute = []
# Get messages from the first function call's context (they should all have the same context)
messages = function_calls[0].context.get_messages() if function_calls else []
# Get all tool_call_ids that already have results
executed_call_ids = set()
for msg in messages:
if msg.get("role") == "tool" and msg.get("tool_call_id"):
executed_call_ids.add(msg.get("tool_call_id"))
# Only include function calls that haven't been executed yet
for call in function_calls:
if call.tool_call_id not in executed_call_ids:
calls_to_execute.append(call)
else:
logger.trace(
f"Skipping already-executed function call: {call.function_name}:{call.tool_call_id}"
)
# Call parent method with filtered list
if calls_to_execute:
await super().run_function_calls(calls_to_execute)
async def get_chat_completions(
self, context: OpenAILLMContext, messages: List[ChatCompletionMessageParam]
) -> AsyncStream[ChatCompletionChunk]:
"""Create a streaming chat completion using Mistral's API.
Args:
context: The context object containing tools configuration
and other settings for the chat completion.
messages: The list of messages comprising
the conversation history and current request.
Returns:
A streaming response of chat completion
chunks that can be processed asynchronously.
"""
# Apply Mistral's assistant prefix requirement for API compatibility
fixed_messages = self._apply_mistral_assistant_prefix(messages)
params = {
"model": self.model_name,
"stream": True,
"messages": fixed_messages,
"tools": context.tools,
"tool_choice": context.tool_choice,
"frequency_penalty": self._settings["frequency_penalty"],
"presence_penalty": self._settings["presence_penalty"],
"temperature": self._settings["temperature"],
"top_p": self._settings["top_p"],
"max_tokens": self._settings["max_tokens"],
}
# Handle Mistral-specific parameter mapping
# Mistral uses "random_seed" instead of "seed"
if self._settings["seed"]:
params["random_seed"] = self._settings["seed"]
# Add any extra parameters
params.update(self._settings["extra"])
chunks = await self._client.chat.completions.create(**params)
return chunks