Add PerplexityLLMAdapter to enforce Perplexity's message ordering constraints
Perplexity's API is stricter than OpenAI about conversation history: - Requires strict alternation between user/tool and assistant messages - Disallows system messages except as the initial message - Requires the last message to be user or tool The new adapter transforms messages before sending to satisfy all three constraints: merging consecutive initial system messages, converting non-initial system to user, merging consecutive same-role messages, and removing trailing assistant messages. Also adds dual-system-instruction warnings to Cerebras, Fireworks, Mistral, Perplexity, and SambaNova services (matching the existing BaseOpenAILLMService pattern), and updates the warning text in BaseOpenAILLMService to be more descriptive.
This commit is contained in:
169
src/pipecat/adapters/services/perplexity_adapter.py
Normal file
169
src/pipecat/adapters/services/perplexity_adapter.py
Normal file
@@ -0,0 +1,169 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Perplexity LLM adapter for Pipecat.
|
||||
|
||||
Perplexity's API uses an OpenAI-compatible interface but enforces stricter
|
||||
constraints on conversation history structure:
|
||||
|
||||
1. **Strict role alternation** — Messages must alternate between "user"/"tool"
|
||||
and "assistant" roles. Consecutive messages with the same role (e.g. two
|
||||
"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:
|
||||
``"only the initial message can have the system role"``
|
||||
|
||||
3. **Last message must be user/tool** — The final message in the conversation
|
||||
must have role "user" or "tool". A trailing "assistant" message causes:
|
||||
``"the last message must have the user or tool role"``
|
||||
|
||||
This adapter transforms the message list to satisfy all three constraints before
|
||||
the messages are sent to Perplexity's API.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import List
|
||||
|
||||
from openai.types.chat import ChatCompletionMessageParam
|
||||
|
||||
from pipecat.adapters.services.open_ai_adapter import OpenAILLMAdapter, OpenAILLMInvocationParams
|
||||
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,
|
||||
no non-initial system messages, last message must be user/tool).
|
||||
|
||||
The transformations are applied in ``get_llm_invocation_params`` after the
|
||||
parent adapter extracts messages from the LLM context, and before
|
||||
``build_chat_completion_params`` prepends ``system_instruction``.
|
||||
"""
|
||||
|
||||
def get_llm_invocation_params(self, context: LLMContext) -> OpenAILLMInvocationParams:
|
||||
"""Get OpenAI-compatible invocation parameters with Perplexity message fixes applied.
|
||||
|
||||
Args:
|
||||
context: The LLM context containing messages, tools, etc.
|
||||
|
||||
Returns:
|
||||
Dictionary of parameters for Perplexity's ChatCompletion API, with
|
||||
messages transformed to satisfy Perplexity's constraints.
|
||||
"""
|
||||
params = super().get_llm_invocation_params(context)
|
||||
params["messages"] = self._transform_messages(list(params["messages"]))
|
||||
return params
|
||||
|
||||
def _transform_messages(
|
||||
self, messages: List[ChatCompletionMessageParam]
|
||||
) -> List[ChatCompletionMessageParam]:
|
||||
"""Transform messages to satisfy Perplexity's API constraints.
|
||||
|
||||
Applies four 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.
|
||||
|
||||
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
|
||||
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
|
||||
"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".
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with "role" and "content" keys.
|
||||
|
||||
Returns:
|
||||
Transformed list of message dicts satisfying Perplexity's constraints.
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
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)):
|
||||
if messages[i].get("role") == "system":
|
||||
messages[i]["role"] = "user"
|
||||
|
||||
# Step 3: Merge consecutive same-role messages.
|
||||
# After system→user conversions above, we may have adjacent same-role
|
||||
# messages that violate Perplexity's strict alternation requirement.
|
||||
i = 0
|
||||
while i < len(messages) - 1:
|
||||
current = messages[i]
|
||||
next_msg = messages[i + 1]
|
||||
if 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"]}]
|
||||
if isinstance(next_msg.get("content"), str):
|
||||
next_msg["content"] = [{"type": "text", "text": next_msg["content"]}]
|
||||
# Merge content from next message into current
|
||||
if isinstance(current.get("content"), list) and isinstance(
|
||||
next_msg.get("content"), list
|
||||
):
|
||||
current["content"].extend(next_msg["content"])
|
||||
messages.pop(i + 1)
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Step 4: Handle trailing messages.
|
||||
# Perplexity requires the last message to be "user" or "tool".
|
||||
if messages:
|
||||
# Remove trailing assistant messages. OpenAI appears to silently
|
||||
# ignore trailing assistant messages server-side, so removing them
|
||||
# preserves equivalent behavior.
|
||||
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"
|
||||
|
||||
return messages
|
||||
@@ -117,6 +117,10 @@ class CerebrasLLMService(OpenAILLMService):
|
||||
# Prepend system instruction if set
|
||||
if self._settings.system_instruction:
|
||||
messages = params.get("messages", [])
|
||||
if messages and messages[0].get("role") == "system":
|
||||
logger.warning(
|
||||
f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended."
|
||||
)
|
||||
params["messages"] = [
|
||||
{"role": "system", "content": self._settings.system_instruction}
|
||||
] + messages
|
||||
|
||||
@@ -118,6 +118,10 @@ class FireworksLLMService(OpenAILLMService):
|
||||
# Prepend system instruction if set
|
||||
if self._settings.system_instruction:
|
||||
messages = params.get("messages", [])
|
||||
if messages and messages[0].get("role") == "system":
|
||||
logger.warning(
|
||||
f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended."
|
||||
)
|
||||
params["messages"] = [
|
||||
{"role": "system", "content": self._settings.system_instruction}
|
||||
] + messages
|
||||
|
||||
@@ -236,6 +236,10 @@ class MistralLLMService(OpenAILLMService):
|
||||
# Prepend system instruction if set
|
||||
if self._settings.system_instruction:
|
||||
messages = params.get("messages", [])
|
||||
if messages and messages[0].get("role") == "system":
|
||||
logger.warning(
|
||||
f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended."
|
||||
)
|
||||
params["messages"] = [
|
||||
{"role": "system", "content": self._settings.system_instruction}
|
||||
] + messages
|
||||
|
||||
@@ -332,8 +332,7 @@ class BaseOpenAILLMService(LLMService):
|
||||
messages = params.get("messages", [])
|
||||
if messages and messages[0].get("role") == "system":
|
||||
logger.warning(
|
||||
f"{self}: Both system_instruction and a system message in context are set."
|
||||
" Using system_instruction."
|
||||
f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended."
|
||||
)
|
||||
params["messages"] = [
|
||||
{"role": "system", "content": self._settings.system_instruction}
|
||||
@@ -381,8 +380,7 @@ class BaseOpenAILLMService(LLMService):
|
||||
messages = params.get("messages", [])
|
||||
if messages and messages[0].get("role") == "system":
|
||||
logger.warning(
|
||||
f"{self}: Both system_instruction and a system message in context are set."
|
||||
" Using system_instruction."
|
||||
f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended."
|
||||
)
|
||||
params["messages"] = [{"role": "system", "content": system_instruction}] + messages
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ reporting patterns while maintaining compatibility with the Pipecat framework.
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.adapters.services.open_ai_adapter import OpenAILLMInvocationParams
|
||||
from pipecat.adapters.services.perplexity_adapter import PerplexityLLMAdapter
|
||||
from pipecat.metrics.metrics import LLMTokenUsage
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
||||
@@ -37,6 +40,8 @@ class PerplexityLLMService(OpenAILLMService):
|
||||
in token usage reporting between Perplexity (incremental) and OpenAI (final summary).
|
||||
"""
|
||||
|
||||
adapter_class = PerplexityLLMAdapter
|
||||
|
||||
Settings = PerplexityLLMSettings
|
||||
_settings: Settings
|
||||
|
||||
@@ -119,6 +124,10 @@ class PerplexityLLMService(OpenAILLMService):
|
||||
# Prepend system instruction if set
|
||||
if self._settings.system_instruction:
|
||||
messages = params.get("messages", [])
|
||||
if messages and messages[0].get("role") == "system":
|
||||
logger.warning(
|
||||
f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended."
|
||||
)
|
||||
params["messages"] = [
|
||||
{"role": "system", "content": self._settings.system_instruction}
|
||||
] + messages
|
||||
|
||||
@@ -134,6 +134,10 @@ class SambaNovaLLMService(OpenAILLMService): # type: ignore
|
||||
# Prepend system instruction if set
|
||||
if self._settings.system_instruction:
|
||||
messages = params.get("messages", [])
|
||||
if messages and messages[0].get("role") == "system":
|
||||
logger.warning(
|
||||
f"{self}: Both system_instruction and an initial system message in context are set. This may be unintended."
|
||||
)
|
||||
params["messages"] = [
|
||||
{"role": "system", "content": self._settings.system_instruction}
|
||||
] + messages
|
||||
|
||||
@@ -48,6 +48,7 @@ from pipecat.adapters.services.anthropic_adapter import AnthropicLLMAdapter
|
||||
from pipecat.adapters.services.bedrock_adapter import AWSBedrockLLMAdapter
|
||||
from pipecat.adapters.services.gemini_adapter import GeminiLLMAdapter
|
||||
from pipecat.adapters.services.open_ai_adapter import OpenAILLMAdapter
|
||||
from pipecat.adapters.services.perplexity_adapter import PerplexityLLMAdapter
|
||||
from pipecat.processors.aggregators.llm_context import (
|
||||
LLMContext,
|
||||
LLMStandardMessage,
|
||||
@@ -992,5 +993,201 @@ class TestAWSBedrockGetLLMInvocationParams(unittest.TestCase):
|
||||
self.assertEqual(len(params["messages"]), 0)
|
||||
|
||||
|
||||
class TestPerplexityGetLLMInvocationParams(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
"""Sets up a common adapter instance for all tests."""
|
||||
self.adapter = PerplexityLLMAdapter()
|
||||
|
||||
def test_standard_messages_pass_through(self):
|
||||
"""Test that a valid [user, assistant, user] sequence passes through unchanged."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
]
|
||||
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
self.assertEqual(len(params["messages"]), 3)
|
||||
self.assertEqual(params["messages"][0]["role"], "user")
|
||||
self.assertEqual(params["messages"][0]["content"], "Hello")
|
||||
self.assertEqual(params["messages"][1]["role"], "assistant")
|
||||
self.assertEqual(params["messages"][1]["content"], "Hi there!")
|
||||
self.assertEqual(params["messages"][2]["role"], "user")
|
||||
self.assertEqual(params["messages"][2]["content"], "How are you?")
|
||||
|
||||
def test_initial_system_message_preserved(self):
|
||||
"""Test that a valid [system, user, assistant, user] sequence passes through unchanged."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
{"role": "user", "content": "Bye"},
|
||||
]
|
||||
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
self.assertEqual(len(params["messages"]), 4)
|
||||
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"][2]["role"], "assistant")
|
||||
self.assertEqual(params["messages"][3]["role"], "user")
|
||||
|
||||
def test_consecutive_same_role_messages_merged(self):
|
||||
"""Test that consecutive user messages are merged into list-of-dicts content."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "user", "content": "First message"},
|
||||
{"role": "user", "content": "Second message"},
|
||||
{"role": "assistant", "content": "Response"},
|
||||
{"role": "user", "content": "Third message"},
|
||||
]
|
||||
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
self.assertEqual(len(params["messages"]), 3)
|
||||
|
||||
# First message should be merged users
|
||||
merged = params["messages"][0]
|
||||
self.assertEqual(merged["role"], "user")
|
||||
self.assertIsInstance(merged["content"], list)
|
||||
self.assertEqual(len(merged["content"]), 2)
|
||||
self.assertEqual(merged["content"][0]["type"], "text")
|
||||
self.assertEqual(merged["content"][0]["text"], "First message")
|
||||
self.assertEqual(merged["content"][1]["type"], "text")
|
||||
self.assertEqual(merged["content"][1]["text"], "Second message")
|
||||
|
||||
self.assertEqual(params["messages"][1]["role"], "assistant")
|
||||
self.assertEqual(params["messages"][2]["role"], "user")
|
||||
|
||||
def test_non_initial_system_converted_to_user(self):
|
||||
"""Test that non-initial system messages are converted to user and merged with adjacent user."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi!"},
|
||||
{"role": "system", "content": "Be concise."},
|
||||
{"role": "user", "content": "Tell me about Python."},
|
||||
]
|
||||
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
# system(initial), user, assistant, merged(system→user + user)
|
||||
self.assertEqual(len(params["messages"]), 4)
|
||||
self.assertEqual(params["messages"][0]["role"], "system")
|
||||
self.assertEqual(params["messages"][1]["role"], "user")
|
||||
self.assertEqual(params["messages"][2]["role"], "assistant")
|
||||
|
||||
# The converted system→user and the following user should be merged
|
||||
merged = params["messages"][3]
|
||||
self.assertEqual(merged["role"], "user")
|
||||
self.assertIsInstance(merged["content"], list)
|
||||
self.assertEqual(len(merged["content"]), 2)
|
||||
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."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "system", "content": "Always be polite."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
|
||||
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")
|
||||
|
||||
def test_trailing_assistant_removed(self):
|
||||
"""Test that a trailing assistant message is removed."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
]
|
||||
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
self.assertEqual(len(params["messages"]), 1)
|
||||
self.assertEqual(params["messages"][0]["role"], "user")
|
||||
self.assertEqual(params["messages"][0]["content"], "Hello")
|
||||
|
||||
def test_only_system_message_converted_to_user(self):
|
||||
"""Test that a single system message is converted to user role."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
]
|
||||
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
self.assertEqual(len(params["messages"]), 1)
|
||||
self.assertEqual(params["messages"][0]["role"], "user")
|
||||
self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.")
|
||||
|
||||
def test_consecutive_assistants_merged_then_trailing_removed(self):
|
||||
"""Test that consecutive assistant messages are merged, then trailing assistant is removed."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "First response"},
|
||||
{"role": "assistant", "content": "Second response"},
|
||||
]
|
||||
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
# After merging assistants we get [user, assistant(merged)], then trailing
|
||||
# assistant is removed, leaving just [user]
|
||||
self.assertEqual(len(params["messages"]), 1)
|
||||
self.assertEqual(params["messages"][0]["role"], "user")
|
||||
self.assertEqual(params["messages"][0]["content"], "Hello")
|
||||
|
||||
def test_tool_messages_preserved(self):
|
||||
"""Test that tool messages pass through without modification."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "user", "content": "What's the weather?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Let me check.",
|
||||
"tool_calls": [{"id": "1", "function": {"name": "get_weather", "arguments": "{}"}}],
|
||||
},
|
||||
{"role": "tool", "content": "Sunny, 72F", "tool_call_id": "1"},
|
||||
{"role": "user", "content": "Thanks!"},
|
||||
]
|
||||
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
self.assertEqual(len(params["messages"]), 4)
|
||||
self.assertEqual(params["messages"][0]["role"], "user")
|
||||
self.assertEqual(params["messages"][1]["role"], "assistant")
|
||||
self.assertEqual(params["messages"][2]["role"], "tool")
|
||||
self.assertEqual(params["messages"][2]["content"], "Sunny, 72F")
|
||||
self.assertEqual(params["messages"][3]["role"], "user")
|
||||
|
||||
def test_empty_messages(self):
|
||||
"""Test that empty messages list returns empty."""
|
||||
context = LLMContext(messages=[])
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
self.assertEqual(params["messages"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user