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.
This commit is contained in:
Paul Kompfner
2026-03-12 15:14:56 -04:00
parent e4bf6281c6
commit 7f98cc9921
2 changed files with 59 additions and 65 deletions

View File

@@ -14,8 +14,9 @@ constraints on conversation history structure:
"user" messages in a row) are rejected with: "user" messages in a row) are rejected with:
``"messages must be an alternating sequence of user/tool and assistant messages"`` ``"messages must be an alternating sequence of user/tool and assistant messages"``
2. **No non-initial system messages** — "system" messages are only allowed as 2. **No non-initial system messages** — "system" messages are only allowed at
the very first message. A system message anywhere else causes: the start of the conversation. A system message after a non-system message
causes:
``"only the initial message can have the system role"`` ``"only the initial message can have the system role"``
3. **Last message must be user/tool** — The final message in the conversation 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): class PerplexityLLMAdapter(OpenAILLMAdapter):
"""Adapter that transforms messages to satisfy Perplexity's API constraints. """Adapter that transforms messages to satisfy Perplexity's API constraints.
Perplexity's API is stricter than standard OpenAI about message structure. Perplexity's API is stricter than OpenAI about message structure. This
This adapter extends ``OpenAILLMAdapter`` and applies message transformations adapter extends ``OpenAILLMAdapter`` and applies message transformations
to ensure compliance with Perplexity's three constraints (role alternation, to ensure compliance with Perplexity's constraints (role alternation,
no non-initial system messages, last message must be user/tool). no non-initial system messages, last message must be user/tool).
The transformations are applied in ``get_llm_invocation_params`` after the The transformations are applied in ``get_llm_invocation_params`` after the
@@ -67,31 +68,24 @@ class PerplexityLLMAdapter(OpenAILLMAdapter):
) -> List[ChatCompletionMessageParam]: ) -> List[ChatCompletionMessageParam]:
"""Transform messages to satisfy Perplexity's API constraints. """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 1. **Convert non-initial system messages to user** — Any system message
starts with multiple system messages, merge them into a single system after the initial system message block is converted to role "user",
message using list-of-dicts content format. This addresses since Perplexity rejects system messages after a non-system message.
Perplexity's constraint that only the initial message can be system.
2. **Convert non-initial system messages to user** — Any system message 2. **Merge consecutive same-role messages** — After the above
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 conversions, adjacent messages with the same role are merged using
list-of-dicts content format. This ensures strict role alternation list-of-dicts content format. This ensures strict role alternation
(e.g. a converted system→user message adjacent to an existing user (e.g. a converted system→user message adjacent to an existing user
message gets merged). 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", remove it. OpenAI appears to silently ignore trailing
assistant messages server-side, so removing them preserves equivalent assistant messages server-side, so removing them preserves equivalent
behavior while satisfying Perplexity's "last message must be behavior while satisfying Perplexity's "last message must be
user/tool" constraint. If the only remaining message is "system" user/tool" constraint. If the last message is "system" (e.g. the
(possible when the context contains just a single system message), context only contains system messages), convert it to "user".
convert it to "user" since Perplexity requires the last message to
be "user" or "tool".
Args: Args:
messages: List of message dicts with "role" and "content" keys. messages: List of message dicts with "role" and "content" keys.
@@ -104,40 +98,29 @@ class PerplexityLLMAdapter(OpenAILLMAdapter):
messages = copy.deepcopy(messages) messages = copy.deepcopy(messages)
# Step 1: Merge consecutive system messages at the start into one. # Step 1: Convert non-initial system messages to "user".
# Perplexity only allows a single initial system message, so if there # Perplexity allows system messages at the start, but rejects them
# are multiple consecutive system messages at the start, we merge them. # after any non-system message.
if messages[0].get("role") == "system": in_initial_system_block = True
system_end = 1 for i in range(len(messages)):
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": 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 # After system→user conversions above, we may have adjacent same-role
# messages that violate Perplexity's strict alternation requirement. # messages that violate Perplexity's strict alternation requirement.
# Skip consecutive system messages at the start — Perplexity allows those.
i = 0 i = 0
while i < len(messages) - 1: while i < len(messages) - 1:
current = messages[i] current = messages[i]
next_msg = messages[i + 1] 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 # Convert string content to list-of-dicts format for merging
if isinstance(current.get("content"), str): if isinstance(current.get("content"), str):
current["content"] = [{"type": "text", "text": current["content"]}] current["content"] = [{"type": "text", "text": current["content"]}]
@@ -152,7 +135,7 @@ class PerplexityLLMAdapter(OpenAILLMAdapter):
else: else:
i += 1 i += 1
# Step 4: Handle trailing messages. # Step 3: Handle trailing messages.
# Perplexity requires the last message to be "user" or "tool". # Perplexity requires the last message to be "user" or "tool".
if messages: if messages:
# Remove trailing assistant messages. OpenAI appears to silently # Remove trailing assistant messages. OpenAI appears to silently
@@ -161,9 +144,9 @@ class PerplexityLLMAdapter(OpenAILLMAdapter):
while messages and messages[-1].get("role") == "assistant": while messages and messages[-1].get("role") == "assistant":
messages.pop() messages.pop()
# If the only remaining message is "system" (single system message # If the last message is "system" (e.g. the context only contains
# in the context), convert it to "user". # system messages), convert it to "user".
if messages and len(messages) == 1 and messages[0].get("role") == "system": if messages and messages[-1].get("role") == "system":
messages[0]["role"] = "user" messages[-1]["role"] = "user"
return messages return messages

View File

@@ -1090,8 +1090,8 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase):
self.assertEqual(merged["content"][0]["text"], "Be concise.") self.assertEqual(merged["content"][0]["text"], "Be concise.")
self.assertEqual(merged["content"][1]["text"], "Tell me about Python.") self.assertEqual(merged["content"][1]["text"], "Tell me about Python.")
def test_multiple_system_messages_at_start_merged(self): def test_multiple_system_messages_at_start_preserved(self):
"""Test that multiple consecutive system messages at start are merged into one.""" """Test that multiple consecutive system messages at start pass through unchanged."""
messages: list[LLMStandardMessage] = [ messages: list[LLMStandardMessage] = [
{"role": "system", "content": "You are a helpful assistant."}, {"role": "system", "content": "You are a helpful assistant."},
{"role": "system", "content": "Always be polite."}, {"role": "system", "content": "Always be polite."},
@@ -1101,18 +1101,13 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase):
context = LLMContext(messages=messages) context = LLMContext(messages=messages)
params = self.adapter.get_llm_invocation_params(context) params = self.adapter.get_llm_invocation_params(context)
self.assertEqual(len(params["messages"]), 2) self.assertEqual(len(params["messages"]), 3)
self.assertEqual(params["messages"][0]["role"], "system")
# First message should be merged system self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.")
system_msg = params["messages"][0] self.assertEqual(params["messages"][1]["role"], "system")
self.assertEqual(system_msg["role"], "system") self.assertEqual(params["messages"][1]["content"], "Always be polite.")
self.assertIsInstance(system_msg["content"], list) self.assertEqual(params["messages"][2]["role"], "user")
self.assertEqual(len(system_msg["content"]), 2) self.assertEqual(params["messages"][2]["content"], "Hello")
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): def test_trailing_assistant_removed(self):
"""Test that a trailing assistant message is removed.""" """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]["role"], "user")
self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.") 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): def test_consecutive_assistants_merged_then_trailing_removed(self):
"""Test that consecutive assistant messages are merged, then trailing assistant is removed.""" """Test that consecutive assistant messages are merged, then trailing assistant is removed."""
messages: list[LLMStandardMessage] = [ messages: list[LLMStandardMessage] = [