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:
``"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

View File

@@ -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] = [