From 7f98cc9921fe7fe44f1e64ffb71524e592b16ceb Mon Sep 17 00:00:00 2001 From: Paul Kompfner Date: Thu, 12 Mar 2026 15:14:56 -0400 Subject: [PATCH] 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] = [