From 0373f85b85c92d31f252971f1c10c899412a6750 Mon Sep 17 00:00:00 2001 From: Paul Kompfner Date: Thu, 12 Mar 2026 14:56:30 -0400 Subject: [PATCH 1/5] Add PerplexityLLMAdapter to enforce Perplexity's message ordering constraints Perplexity's API is stricter than OpenAI about conversation history: - Requires strict alternation between user/tool and assistant messages - Disallows system messages except as the initial message - Requires the last message to be user or tool The new adapter transforms messages before sending to satisfy all three constraints: merging consecutive initial system messages, converting non-initial system to user, merging consecutive same-role messages, and removing trailing assistant messages. Also adds dual-system-instruction warnings to Cerebras, Fireworks, Mistral, Perplexity, and SambaNova services (matching the existing BaseOpenAILLMService pattern), and updates the warning text in BaseOpenAILLMService to be more descriptive. --- .../adapters/services/perplexity_adapter.py | 169 +++++++++++++++ src/pipecat/services/cerebras/llm.py | 4 + src/pipecat/services/fireworks/llm.py | 4 + src/pipecat/services/mistral/llm.py | 4 + src/pipecat/services/openai/base_llm.py | 6 +- src/pipecat/services/perplexity/llm.py | 9 + src/pipecat/services/sambanova/llm.py | 4 + tests/test_get_llm_invocation_params.py | 197 ++++++++++++++++++ 8 files changed, 393 insertions(+), 4 deletions(-) create mode 100644 src/pipecat/adapters/services/perplexity_adapter.py 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() From e4bf6281c6fb136cbed12dfdf7ca6f671caae718 Mon Sep 17 00:00:00 2001 From: Paul Kompfner Date: Thu, 12 Mar 2026 14:56:37 -0400 Subject: [PATCH 2/5] Add changelog for #4009 --- changelog/4009.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/4009.added.md diff --git a/changelog/4009.added.md b/changelog/4009.added.md new file mode 100644 index 000000000..9ebbec7dd --- /dev/null +++ b/changelog/4009.added.md @@ -0,0 +1 @@ +- Added `PerplexityLLMAdapter` that automatically transforms conversation messages to satisfy Perplexity's stricter API constraints (strict role alternation, no non-initial system messages, last message must be user/tool). Previously, certain conversation histories could cause Perplexity API errors that didn't occur with OpenAI (`PerplexityLLMService` subclasses `OpenAILLMService` since Perplexity uses an OpenAI-compatible API). From 7f98cc9921fe7fe44f1e64ffb71524e592b16ceb Mon Sep 17 00:00:00 2001 From: Paul Kompfner Date: Thu, 12 Mar 2026 15:14:56 -0400 Subject: [PATCH 3/5] Remove initial system message merging, handle trailing system messages Perplexity allows multiple initial system messages, so don't merge them. Instead, skip system-system pairs during the consecutive same-role merge step. Broaden the trailing message fix to convert any trailing system message to user (not just a lone system message), so contexts with only system messages don't fail. --- .../adapters/services/perplexity_adapter.py | 85 ++++++++----------- tests/test_get_llm_invocation_params.py | 39 ++++++--- 2 files changed, 59 insertions(+), 65 deletions(-) diff --git a/src/pipecat/adapters/services/perplexity_adapter.py b/src/pipecat/adapters/services/perplexity_adapter.py index 0c5de9a63..754716a19 100644 --- a/src/pipecat/adapters/services/perplexity_adapter.py +++ b/src/pipecat/adapters/services/perplexity_adapter.py @@ -14,8 +14,9 @@ constraints on conversation history structure: "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: +2. **No non-initial system messages** — "system" messages are only allowed at + the start of the conversation. A system message after a non-system message + causes: ``"only the initial message can have the system role"`` 3. **Last message must be user/tool** — The final message in the conversation @@ -38,9 +39,9 @@ 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, + Perplexity's API is stricter than OpenAI about message structure. This + adapter extends ``OpenAILLMAdapter`` and applies message transformations + to ensure compliance with Perplexity's 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 @@ -67,31 +68,24 @@ class PerplexityLLMAdapter(OpenAILLMAdapter): ) -> List[ChatCompletionMessageParam]: """Transform messages to satisfy Perplexity's API constraints. - Applies four transformation steps in order: + Applies three 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. + 1. **Convert non-initial system messages to user** — Any system message + after the initial system message block is converted to role "user", + since Perplexity rejects system messages after a non-system message. - 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 + 2. **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 + 3. **Ensure last message is user/tool** — 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". + user/tool" constraint. If the last message is "system" (e.g. the + context only contains system messages), convert it to "user". Args: messages: List of message dicts with "role" and "content" keys. @@ -104,40 +98,29 @@ class PerplexityLLMAdapter(OpenAILLMAdapter): 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)): + # Step 1: Convert non-initial system messages to "user". + # Perplexity allows system messages at the start, but rejects them + # after any non-system message. + in_initial_system_block = True + for i in range(len(messages)): if messages[i].get("role") == "system": - messages[i]["role"] = "user" + if not in_initial_system_block: + messages[i]["role"] = "user" + else: + in_initial_system_block = False - # Step 3: Merge consecutive same-role messages. + # Step 2: Merge consecutive same-role messages. # After system→user conversions above, we may have adjacent same-role # messages that violate Perplexity's strict alternation requirement. + # Skip consecutive system messages at the start — Perplexity allows those. i = 0 while i < len(messages) - 1: current = messages[i] next_msg = messages[i + 1] - if current["role"] == next_msg["role"]: + if current["role"] == next_msg["role"] == "system": + # Perplexity allows multiple initial system messages, don't merge + i += 1 + elif 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"]}] @@ -152,7 +135,7 @@ class PerplexityLLMAdapter(OpenAILLMAdapter): else: i += 1 - # Step 4: Handle trailing messages. + # Step 3: Handle trailing messages. # Perplexity requires the last message to be "user" or "tool". if messages: # Remove trailing assistant messages. OpenAI appears to silently @@ -161,9 +144,9 @@ class PerplexityLLMAdapter(OpenAILLMAdapter): 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" + # If the last message is "system" (e.g. the context only contains + # system messages), convert it to "user". + if messages and messages[-1].get("role") == "system": + messages[-1]["role"] = "user" return messages diff --git a/tests/test_get_llm_invocation_params.py b/tests/test_get_llm_invocation_params.py index f534d7109..6e37873d4 100644 --- a/tests/test_get_llm_invocation_params.py +++ b/tests/test_get_llm_invocation_params.py @@ -1090,8 +1090,8 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): 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.""" + def test_multiple_system_messages_at_start_preserved(self): + """Test that multiple consecutive system messages at start pass through unchanged.""" messages: list[LLMStandardMessage] = [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "system", "content": "Always be polite."}, @@ -1101,18 +1101,13 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): 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") + self.assertEqual(len(params["messages"]), 3) + self.assertEqual(params["messages"][0]["role"], "system") + self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.") + self.assertEqual(params["messages"][1]["role"], "system") + self.assertEqual(params["messages"][1]["content"], "Always be polite.") + self.assertEqual(params["messages"][2]["role"], "user") + self.assertEqual(params["messages"][2]["content"], "Hello") def test_trailing_assistant_removed(self): """Test that a trailing assistant message is removed.""" @@ -1141,6 +1136,22 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): self.assertEqual(params["messages"][0]["role"], "user") self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.") + def test_only_system_messages_last_converted_to_user(self): + """Test that when only system messages exist, the last one is converted to user.""" + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "system", "content": "Always be polite."}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + self.assertEqual(len(params["messages"]), 2) + 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"][1]["content"], "Always be polite.") + def test_consecutive_assistants_merged_then_trailing_removed(self): """Test that consecutive assistant messages are merged, then trailing assistant is removed.""" messages: list[LLMStandardMessage] = [ From e69f5a76e1e596bb4ebfac7f93bcb0151dab04f0 Mon Sep 17 00:00:00 2001 From: Paul Kompfner Date: Thu, 12 Mar 2026 15:24:17 -0400 Subject: [PATCH 4/5] Add test for trailing assistant+system ordering, improve docstring Add test exercising the step 3 ordering where stripping a trailing assistant exposes a system message that then gets converted to user. Move the reasoning about when a trailing system message can occur into the docstring. --- .../adapters/services/perplexity_adapter.py | 10 ++++++---- tests/test_get_llm_invocation_params.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/pipecat/adapters/services/perplexity_adapter.py b/src/pipecat/adapters/services/perplexity_adapter.py index 754716a19..b55696893 100644 --- a/src/pipecat/adapters/services/perplexity_adapter.py +++ b/src/pipecat/adapters/services/perplexity_adapter.py @@ -84,8 +84,11 @@ class PerplexityLLMAdapter(OpenAILLMAdapter): "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 last message is "system" (e.g. the - context only contains system messages), convert it to "user". + user/tool" constraint. If the last message is "system", convert it + to "user". A trailing system message can only occur when the context + consists entirely of system messages (possibly followed by assistant + messages that were just removed), because step 1 converts any system + message that appears after a non-system message to "user". Args: messages: List of message dicts with "role" and "content" keys. @@ -144,8 +147,7 @@ class PerplexityLLMAdapter(OpenAILLMAdapter): while messages and messages[-1].get("role") == "assistant": messages.pop() - # If the last message is "system" (e.g. the context only contains - # system messages), convert it to "user". + # If the last message is "system", convert it to "user". if messages and messages[-1].get("role") == "system": messages[-1]["role"] = "user" diff --git a/tests/test_get_llm_invocation_params.py b/tests/test_get_llm_invocation_params.py index 6e37873d4..08710d77d 100644 --- a/tests/test_get_llm_invocation_params.py +++ b/tests/test_get_llm_invocation_params.py @@ -1152,6 +1152,25 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): self.assertEqual(params["messages"][1]["role"], "user") self.assertEqual(params["messages"][1]["content"], "Always be polite.") + def test_trailing_assistant_removed_then_system_converted(self): + """Test that trailing assistant is removed, exposing a system message that becomes user. + + This exercises the ordering of step 3: strip trailing assistants first, + then convert a trailing system to user. + """ + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "You are helpful."}, + {"role": "assistant", "content": "Sure thing."}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context) + + # Trailing assistant removed → [system] → system converted to user → [user] + self.assertEqual(len(params["messages"]), 1) + self.assertEqual(params["messages"][0]["role"], "user") + self.assertEqual(params["messages"][0]["content"], "You are helpful.") + def test_consecutive_assistants_merged_then_trailing_removed(self): """Test that consecutive assistant messages are merged, then trailing assistant is removed.""" messages: list[LLMStandardMessage] = [ From 99f28120b70b5a295ad2f7dd659023764e8075f3 Mon Sep 17 00:00:00 2001 From: Paul Kompfner Date: Thu, 12 Mar 2026 16:04:46 -0400 Subject: [PATCH 5/5] =?UTF-8?q?Remove=20trailing=20system=E2=86=92user=20c?= =?UTF-8?q?onversion=20for=20cross-call=20stability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perplexity appears to have statefulness within a conversation, so converting a system message to "user" in one call and then back to "system" in the next (after more messages are appended) causes API errors. Remove the trailing system→user conversion entirely — if the context only has system messages, the API call will fail but the mistake will be caught right away. --- .../adapters/services/perplexity_adapter.py | 32 +++++++-------- tests/test_get_llm_invocation_params.py | 41 ++++++++----------- 2 files changed, 31 insertions(+), 42 deletions(-) diff --git a/src/pipecat/adapters/services/perplexity_adapter.py b/src/pipecat/adapters/services/perplexity_adapter.py index b55696893..a8fbe3c18 100644 --- a/src/pipecat/adapters/services/perplexity_adapter.py +++ b/src/pipecat/adapters/services/perplexity_adapter.py @@ -80,15 +80,19 @@ class PerplexityLLMAdapter(OpenAILLMAdapter): (e.g. a converted system→user message adjacent to an existing user message gets merged). - 3. **Ensure last message is user/tool** — If the last message is + 3. **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 last message is "system", convert it - to "user". A trailing system message can only occur when the context - consists entirely of system messages (possibly followed by assistant - messages that were just removed), because step 1 converts any system - message that appears after a non-system message to "user". + user/tool" constraint. + + Note: we intentionally do *not* convert a trailing system message to + "user". That would make the transformation unstable across calls — + Perplexity appears to have statefulness/caching within a conversation, + so a message that was sent as "user" in one call but becomes "system" + in the next (once more messages are appended) causes errors. If the + context consists entirely of system messages, the Perplexity API call + will fail, but that mistake will be caught right away. Args: messages: List of message dicts with "role" and "content" keys. @@ -138,17 +142,11 @@ class PerplexityLLMAdapter(OpenAILLMAdapter): else: i += 1 - # Step 3: Handle trailing messages. + # Step 3: Remove trailing assistant 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 last message is "system", convert it to "user". - if messages and messages[-1].get("role") == "system": - messages[-1]["role"] = "user" + # 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() return messages diff --git a/tests/test_get_llm_invocation_params.py b/tests/test_get_llm_invocation_params.py index 08710d77d..9cfeb8933 100644 --- a/tests/test_get_llm_invocation_params.py +++ b/tests/test_get_llm_invocation_params.py @@ -1123,8 +1123,14 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): 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.""" + def test_only_system_messages_preserved(self): + """Test that system-only contexts are left unchanged (no system→user conversion). + + We intentionally do not convert trailing system messages to "user" + because that would make the transformation unstable across calls — + Perplexity has statefulness within a conversation, so a message that + was "user" in one call but becomes "system" in the next causes errors. + """ messages: list[LLMStandardMessage] = [ {"role": "system", "content": "You are a helpful assistant."}, ] @@ -1133,30 +1139,15 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): 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_only_system_messages_last_converted_to_user(self): - """Test that when only system messages exist, the last one is converted to user.""" - messages: list[LLMStandardMessage] = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "system", "content": "Always be polite."}, - ] - - context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context) - - self.assertEqual(len(params["messages"]), 2) 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"][1]["content"], "Always be polite.") - def test_trailing_assistant_removed_then_system_converted(self): - """Test that trailing assistant is removed, exposing a system message that becomes user. + def test_system_exposed_after_trailing_assistant_removed(self): + """Test that a system message exposed by trailing assistant removal stays system. - This exercises the ordering of step 3: strip trailing assistants first, - then convert a trailing system to user. + It's important that initial system messages are never converted to + "user", because Perplexity has statefulness within a conversation — if + a message was sent as "system" in one call and then becomes "user" in a + later call (after more messages are appended), the API rejects it. """ messages: list[LLMStandardMessage] = [ {"role": "system", "content": "You are helpful."}, @@ -1166,9 +1157,9 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): context = LLMContext(messages=messages) params = self.adapter.get_llm_invocation_params(context) - # Trailing assistant removed → [system] → system converted to user → [user] + # Trailing assistant removed → [system], system stays as-is self.assertEqual(len(params["messages"]), 1) - self.assertEqual(params["messages"][0]["role"], "user") + self.assertEqual(params["messages"][0]["role"], "system") self.assertEqual(params["messages"][0]["content"], "You are helpful.") def test_consecutive_assistants_merged_then_trailing_removed(self):