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:
@@ -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
|
||||
|
||||
@@ -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] = [
|
||||
|
||||
Reference in New Issue
Block a user