diff --git a/src/pipecat/adapters/services/perplexity_adapter.py b/src/pipecat/adapters/services/perplexity_adapter.py new file mode 100644 index 000000000..0c5de9a63 --- /dev/null +++ b/src/pipecat/adapters/services/perplexity_adapter.py @@ -0,0 +1,169 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Perplexity LLM adapter for Pipecat. + +Perplexity's API uses an OpenAI-compatible interface but enforces stricter +constraints on conversation history structure: + +1. **Strict role alternation** — Messages must alternate between "user"/"tool" + and "assistant" roles. Consecutive messages with the same role (e.g. two + "user" messages in a row) are rejected with: + ``"messages must be an alternating sequence of user/tool and assistant messages"`` + +2. **No non-initial system messages** — "system" messages are only allowed as + the very first message. A system message anywhere else causes: + ``"only the initial message can have the system role"`` + +3. **Last message must be user/tool** — The final message in the conversation + must have role "user" or "tool". A trailing "assistant" message causes: + ``"the last message must have the user or tool role"`` + +This adapter transforms the message list to satisfy all three constraints before +the messages are sent to Perplexity's API. +""" + +import copy +from typing import List + +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 PerplexityLLMAdapter(OpenAILLMAdapter): + """Adapter that transforms messages to satisfy Perplexity's API constraints. + + Perplexity's API is stricter than standard OpenAI about message structure. + This adapter extends ``OpenAILLMAdapter`` and applies message transformations + to ensure compliance with Perplexity's three constraints (role alternation, + no non-initial system messages, last message must be user/tool). + + The transformations are applied in ``get_llm_invocation_params`` after the + parent adapter extracts messages from the LLM context, and before + ``build_chat_completion_params`` prepends ``system_instruction``. + """ + + def get_llm_invocation_params(self, context: LLMContext) -> OpenAILLMInvocationParams: + """Get OpenAI-compatible invocation parameters with Perplexity message fixes applied. + + Args: + context: The LLM context containing messages, tools, etc. + + Returns: + Dictionary of parameters for Perplexity's ChatCompletion API, with + messages transformed to satisfy Perplexity's constraints. + """ + params = super().get_llm_invocation_params(context) + params["messages"] = self._transform_messages(list(params["messages"])) + return params + + def _transform_messages( + self, messages: List[ChatCompletionMessageParam] + ) -> List[ChatCompletionMessageParam]: + """Transform messages to satisfy Perplexity's API constraints. + + Applies four transformation steps in order: + + 1. **Merge consecutive initial system messages** — If the conversation + starts with multiple system messages, merge them into a single system + message using list-of-dicts content format. This addresses + Perplexity's constraint that only the initial message can be system. + + 2. **Convert non-initial system messages to user** — Any system message + after the initial position is converted to role "user", since + Perplexity rejects non-initial system messages. + + 3. **Merge consecutive same-role messages** — After the above + conversions, adjacent messages with the same role are merged using + list-of-dicts content format. This ensures strict role alternation + (e.g. a converted system→user message adjacent to an existing user + message gets merged). + + 4. **Remove trailing assistant messages** — If the last message is + "assistant", remove it. OpenAI appears to silently ignore trailing + assistant messages server-side, so removing them preserves equivalent + behavior while satisfying Perplexity's "last message must be + user/tool" constraint. If the only remaining message is "system" + (possible when the context contains just a single system message), + convert it to "user" since Perplexity requires the last message to + be "user" or "tool". + + Args: + messages: List of message dicts with "role" and "content" keys. + + Returns: + Transformed list of message dicts satisfying Perplexity's constraints. + """ + if not messages: + return messages + + messages = copy.deepcopy(messages) + + # Step 1: Merge consecutive system messages at the start into one. + # Perplexity only allows a single initial system message, so if there + # are multiple consecutive system messages at the start, we merge them. + if messages[0].get("role") == "system": + system_end = 1 + while system_end < len(messages) and messages[system_end].get("role") == "system": + system_end += 1 + + if system_end > 1: + # Merge all initial system messages into a single message using + # list-of-dicts content format (same approach as Anthropic adapter). + merged_content = [] + for msg in messages[:system_end]: + content = msg.get("content", "") + if isinstance(content, str): + merged_content.append({"type": "text", "text": content}) + elif isinstance(content, list): + merged_content.extend(content) + messages = [{"role": "system", "content": merged_content}] + messages[system_end:] + + # Step 2: Convert non-initial system messages to "user". + # Perplexity only allows system role for the very first message. + for i in range(1, len(messages)): + if messages[i].get("role") == "system": + messages[i]["role"] = "user" + + # Step 3: Merge consecutive same-role messages. + # After system→user conversions above, we may have adjacent same-role + # messages that violate Perplexity's strict alternation requirement. + i = 0 + while i < len(messages) - 1: + current = messages[i] + next_msg = messages[i + 1] + if current["role"] == next_msg["role"]: + # Convert string content to list-of-dicts format for merging + if isinstance(current.get("content"), str): + current["content"] = [{"type": "text", "text": current["content"]}] + if isinstance(next_msg.get("content"), str): + next_msg["content"] = [{"type": "text", "text": next_msg["content"]}] + # Merge content from next message into current + if isinstance(current.get("content"), list) and isinstance( + next_msg.get("content"), list + ): + current["content"].extend(next_msg["content"]) + messages.pop(i + 1) + else: + i += 1 + + # Step 4: Handle trailing messages. + # Perplexity requires the last message to be "user" or "tool". + if messages: + # Remove trailing assistant messages. OpenAI appears to silently + # ignore trailing assistant messages server-side, so removing them + # preserves equivalent behavior. + while messages and messages[-1].get("role") == "assistant": + messages.pop() + + # If the only remaining message is "system" (single system message + # in the context), convert it to "user". + if messages and len(messages) == 1 and messages[0].get("role") == "system": + messages[0]["role"] = "user" + + return messages diff --git a/src/pipecat/services/cerebras/llm.py b/src/pipecat/services/cerebras/llm.py index 7c31a6857..dfb62baf8 100644 --- a/src/pipecat/services/cerebras/llm.py +++ b/src/pipecat/services/cerebras/llm.py @@ -117,6 +117,10 @@ class CerebrasLLMService(OpenAILLMService): # Prepend system instruction if set if self._settings.system_instruction: messages = params.get("messages", []) + if messages and messages[0].get("role") == "system": + logger.warning( + f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended." + ) params["messages"] = [ {"role": "system", "content": self._settings.system_instruction} ] + messages diff --git a/src/pipecat/services/fireworks/llm.py b/src/pipecat/services/fireworks/llm.py index bf141fac1..5efa60793 100644 --- a/src/pipecat/services/fireworks/llm.py +++ b/src/pipecat/services/fireworks/llm.py @@ -118,6 +118,10 @@ class FireworksLLMService(OpenAILLMService): # Prepend system instruction if set if self._settings.system_instruction: messages = params.get("messages", []) + if messages and messages[0].get("role") == "system": + logger.warning( + f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended." + ) params["messages"] = [ {"role": "system", "content": self._settings.system_instruction} ] + messages diff --git a/src/pipecat/services/mistral/llm.py b/src/pipecat/services/mistral/llm.py index 3ee1b2623..063dac3aa 100644 --- a/src/pipecat/services/mistral/llm.py +++ b/src/pipecat/services/mistral/llm.py @@ -236,6 +236,10 @@ class MistralLLMService(OpenAILLMService): # Prepend system instruction if set if self._settings.system_instruction: messages = params.get("messages", []) + if messages and messages[0].get("role") == "system": + logger.warning( + f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended." + ) params["messages"] = [ {"role": "system", "content": self._settings.system_instruction} ] + messages diff --git a/src/pipecat/services/openai/base_llm.py b/src/pipecat/services/openai/base_llm.py index 41b26bd20..eb8ce3cc6 100644 --- a/src/pipecat/services/openai/base_llm.py +++ b/src/pipecat/services/openai/base_llm.py @@ -332,8 +332,7 @@ class BaseOpenAILLMService(LLMService): messages = params.get("messages", []) if messages and messages[0].get("role") == "system": logger.warning( - f"{self}: Both system_instruction and a system message in context are set." - " Using system_instruction." + f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended." ) params["messages"] = [ {"role": "system", "content": self._settings.system_instruction} @@ -381,8 +380,7 @@ class BaseOpenAILLMService(LLMService): messages = params.get("messages", []) if messages and messages[0].get("role") == "system": logger.warning( - f"{self}: Both system_instruction and a system message in context are set." - " Using system_instruction." + f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended." ) params["messages"] = [{"role": "system", "content": system_instruction}] + messages diff --git a/src/pipecat/services/perplexity/llm.py b/src/pipecat/services/perplexity/llm.py index 6c2ceba35..9ea323c5d 100644 --- a/src/pipecat/services/perplexity/llm.py +++ b/src/pipecat/services/perplexity/llm.py @@ -14,7 +14,10 @@ reporting patterns while maintaining compatibility with the Pipecat framework. from dataclasses import dataclass from typing import Optional +from loguru import logger + from pipecat.adapters.services.open_ai_adapter import OpenAILLMInvocationParams +from pipecat.adapters.services.perplexity_adapter import PerplexityLLMAdapter from pipecat.metrics.metrics import LLMTokenUsage from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext @@ -37,6 +40,8 @@ class PerplexityLLMService(OpenAILLMService): in token usage reporting between Perplexity (incremental) and OpenAI (final summary). """ + adapter_class = PerplexityLLMAdapter + Settings = PerplexityLLMSettings _settings: Settings @@ -119,6 +124,10 @@ class PerplexityLLMService(OpenAILLMService): # Prepend system instruction if set if self._settings.system_instruction: messages = params.get("messages", []) + if messages and messages[0].get("role") == "system": + logger.warning( + f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended." + ) params["messages"] = [ {"role": "system", "content": self._settings.system_instruction} ] + messages diff --git a/src/pipecat/services/sambanova/llm.py b/src/pipecat/services/sambanova/llm.py index 3c7d76737..710a22db2 100644 --- a/src/pipecat/services/sambanova/llm.py +++ b/src/pipecat/services/sambanova/llm.py @@ -134,6 +134,10 @@ class SambaNovaLLMService(OpenAILLMService): # type: ignore # Prepend system instruction if set if self._settings.system_instruction: messages = params.get("messages", []) + if messages and messages[0].get("role") == "system": + logger.warning( + f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended." + ) params["messages"] = [ {"role": "system", "content": self._settings.system_instruction} ] + messages diff --git a/tests/test_get_llm_invocation_params.py b/tests/test_get_llm_invocation_params.py index c93275b67..f534d7109 100644 --- a/tests/test_get_llm_invocation_params.py +++ b/tests/test_get_llm_invocation_params.py @@ -48,6 +48,7 @@ from pipecat.adapters.services.anthropic_adapter import AnthropicLLMAdapter from pipecat.adapters.services.bedrock_adapter import AWSBedrockLLMAdapter from pipecat.adapters.services.gemini_adapter import GeminiLLMAdapter from pipecat.adapters.services.open_ai_adapter import OpenAILLMAdapter +from pipecat.adapters.services.perplexity_adapter import PerplexityLLMAdapter from pipecat.processors.aggregators.llm_context import ( LLMContext, LLMStandardMessage, @@ -992,5 +993,201 @@ class TestAWSBedrockGetLLMInvocationParams(unittest.TestCase): self.assertEqual(len(params["messages"]), 0) +class TestPerplexityGetLLMInvocationParams(unittest.TestCase): + def setUp(self) -> None: + """Sets up a common adapter instance for all tests.""" + self.adapter = PerplexityLLMAdapter() + + def test_standard_messages_pass_through(self): + """Test that a valid [user, assistant, user] sequence passes through unchanged.""" + messages: list[LLMStandardMessage] = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "user", "content": "How are you?"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 3) + self.assertEqual(params["messages"][0]["role"], "user") + self.assertEqual(params["messages"][0]["content"], "Hello") + self.assertEqual(params["messages"][1]["role"], "assistant") + self.assertEqual(params["messages"][1]["content"], "Hi there!") + self.assertEqual(params["messages"][2]["role"], "user") + self.assertEqual(params["messages"][2]["content"], "How are you?") + + def test_initial_system_message_preserved(self): + """Test that a valid [system, user, assistant, user] sequence passes through unchanged.""" + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "user", "content": "Bye"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 4) + self.assertEqual(params["messages"][0]["role"], "system") + self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.") + self.assertEqual(params["messages"][1]["role"], "user") + self.assertEqual(params["messages"][2]["role"], "assistant") + self.assertEqual(params["messages"][3]["role"], "user") + + def test_consecutive_same_role_messages_merged(self): + """Test that consecutive user messages are merged into list-of-dicts content.""" + messages: list[LLMStandardMessage] = [ + {"role": "user", "content": "First message"}, + {"role": "user", "content": "Second message"}, + {"role": "assistant", "content": "Response"}, + {"role": "user", "content": "Third message"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 3) + + # First message should be merged users + merged = params["messages"][0] + self.assertEqual(merged["role"], "user") + self.assertIsInstance(merged["content"], list) + self.assertEqual(len(merged["content"]), 2) + self.assertEqual(merged["content"][0]["type"], "text") + self.assertEqual(merged["content"][0]["text"], "First message") + self.assertEqual(merged["content"][1]["type"], "text") + self.assertEqual(merged["content"][1]["text"], "Second message") + + self.assertEqual(params["messages"][1]["role"], "assistant") + self.assertEqual(params["messages"][2]["role"], "user") + + def test_non_initial_system_converted_to_user(self): + """Test that non-initial system messages are converted to user and merged with adjacent user.""" + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "system", "content": "Be concise."}, + {"role": "user", "content": "Tell me about Python."}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + # system(initial), user, assistant, merged(system→user + user) + self.assertEqual(len(params["messages"]), 4) + self.assertEqual(params["messages"][0]["role"], "system") + self.assertEqual(params["messages"][1]["role"], "user") + self.assertEqual(params["messages"][2]["role"], "assistant") + + # The converted system→user and the following user should be merged + merged = params["messages"][3] + self.assertEqual(merged["role"], "user") + self.assertIsInstance(merged["content"], list) + self.assertEqual(len(merged["content"]), 2) + self.assertEqual(merged["content"][0]["text"], "Be concise.") + self.assertEqual(merged["content"][1]["text"], "Tell me about Python.") + + def test_multiple_system_messages_at_start_merged(self): + """Test that multiple consecutive system messages at start are merged into one.""" + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "Always be polite."}, + {"role": "user", "content": "Hello"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 2) + + # First message should be merged system + system_msg = params["messages"][0] + self.assertEqual(system_msg["role"], "system") + self.assertIsInstance(system_msg["content"], list) + self.assertEqual(len(system_msg["content"]), 2) + self.assertEqual(system_msg["content"][0]["text"], "You are a helpful assistant.") + self.assertEqual(system_msg["content"][1]["text"], "Always be polite.") + + self.assertEqual(params["messages"][1]["role"], "user") + self.assertEqual(params["messages"][1]["content"], "Hello") + + def test_trailing_assistant_removed(self): + """Test that a trailing assistant message is removed.""" + messages: list[LLMStandardMessage] = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 1) + self.assertEqual(params["messages"][0]["role"], "user") + self.assertEqual(params["messages"][0]["content"], "Hello") + + def test_only_system_message_converted_to_user(self): + """Test that a single system message is converted to user role.""" + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "You are a helpful assistant."}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 1) + self.assertEqual(params["messages"][0]["role"], "user") + self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.") + + def test_consecutive_assistants_merged_then_trailing_removed(self): + """Test that consecutive assistant messages are merged, then trailing assistant is removed.""" + messages: list[LLMStandardMessage] = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "First response"}, + {"role": "assistant", "content": "Second response"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + # After merging assistants we get [user, assistant(merged)], then trailing + # assistant is removed, leaving just [user] + self.assertEqual(len(params["messages"]), 1) + self.assertEqual(params["messages"][0]["role"], "user") + self.assertEqual(params["messages"][0]["content"], "Hello") + + def test_tool_messages_preserved(self): + """Test that tool messages pass through without modification.""" + messages: list[LLMStandardMessage] = [ + {"role": "user", "content": "What's the weather?"}, + { + "role": "assistant", + "content": "Let me check.", + "tool_calls": [{"id": "1", "function": {"name": "get_weather", "arguments": "{}"}}], + }, + {"role": "tool", "content": "Sunny, 72F", "tool_call_id": "1"}, + {"role": "user", "content": "Thanks!"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 4) + self.assertEqual(params["messages"][0]["role"], "user") + self.assertEqual(params["messages"][1]["role"], "assistant") + self.assertEqual(params["messages"][2]["role"], "tool") + self.assertEqual(params["messages"][2]["content"], "Sunny, 72F") + self.assertEqual(params["messages"][3]["role"], "user") + + def test_empty_messages(self): + """Test that empty messages list returns empty.""" + context = LLMContext(messages=[]) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(params["messages"], []) + + if __name__ == "__main__": unittest.main()