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