Same shape of fix we applied to anthropic_adapter.py earlier — these
adapters do dict-style mutation on values typed as
ChatCompletionMessageParam (a union of TypedDicts) or against Optional
fields. Apply boundary casts (`cast(dict[str, Any], ...)` for the
mutation block, cast back to the TypedDict at return sites). Most
changes are pure typing (rename + cast); a handful in gemini and
openai_realtime are small defensive bug fixes for code paths that
were latently broken by Optional fields slipping through:
perplexity_adapter.py — pure typing. Cast the deepcopied messages to
`list[dict[str, Any]]` for the role-merging / system-conversion /
trailing-assistant-removal transformations and cast back to
ChatCompletionMessageParam at the return.
bedrock_adapter.py — pure typing. Cast the message to
`dict[str, Any]` at the top of `_from_standard_message` for the
tool-result / tool-use / image-content transformations. Cast the
constructed dict at the return site of `get_llm_invocation_params`.
gemini_adapter.py — typing + several None guards on Content.parts and
related Optional fields. Each guard turns a latent
`TypeError`/`AttributeError` (when the type-system-allowed None
showed up at runtime) into a defensive skip — the type annotations
say these can be None and we now handle that.
open_ai_realtime_adapter.py:
- Typing: cast the deepcopied messages, cast back where needed.
- LLMSpecificMessage handling: previously the function would crash on
the first `.get()` call if any LLMSpecificMessage was in the list.
Filter them out and document the limitation — this adapter's
pack-into-single-text-message strategy doesn't compose with opaque
per-provider payloads.
- Real bug fix: `events.ConversationItem` is a Pydantic BaseModel,
not a TypedDict. The bulk-packing path was constructing a raw dict
where a ConversationItem was expected. Replaced with proper
constructor calls (matches what the single-user-message path
already does).
- Real bug fix: `_from_universal_context_message` was declared
`-> events.ConversationItem` but on the unhandled-message
fallthrough it logged and returned None implicitly. Raise
ValueError so the violation is loud, not silent.
Removes 4 newly-clean files from the pyright ignore list:
adapters/services/{perplexity,bedrock,gemini,open_ai_realtime}_adapter.py.
Net: -95 pyright errors (full-config: 775 -> 680).
174 lines
7.7 KiB
Python
174 lines
7.7 KiB
Python
#
|
|
# 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 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
|
|
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 Any, cast
|
|
|
|
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 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
|
|
parent adapter extracts messages from the LLM context (including any
|
|
``system_instruction`` prepend).
|
|
"""
|
|
|
|
def get_llm_invocation_params(
|
|
self,
|
|
context: LLMContext,
|
|
*,
|
|
system_instruction: str | None = None,
|
|
convert_developer_to_user: bool,
|
|
) -> OpenAILLMInvocationParams:
|
|
"""Get OpenAI-compatible invocation parameters with Perplexity message fixes applied.
|
|
|
|
Args:
|
|
context: The LLM context containing messages, tools, etc.
|
|
system_instruction: Optional system instruction from service settings
|
|
or ``run_inference``. Forwarded to the parent adapter.
|
|
convert_developer_to_user: If True, convert "developer"-role messages
|
|
to "user"-role messages. Forwarded to the parent adapter.
|
|
|
|
Returns:
|
|
Dictionary of parameters for Perplexity's ChatCompletion API, with
|
|
messages transformed to satisfy Perplexity's constraints.
|
|
"""
|
|
params = super().get_llm_invocation_params(
|
|
context,
|
|
system_instruction=system_instruction,
|
|
convert_developer_to_user=convert_developer_to_user,
|
|
)
|
|
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 three transformation steps in order:
|
|
|
|
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. **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).
|
|
|
|
3. **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.
|
|
|
|
Note: we intentionally do *not* convert a trailing system message to
|
|
"user". That would make the transformation unstable across calls —
|
|
Perplexity appears to have statefulness/caching within a conversation,
|
|
so a message that was sent as "user" in one call but becomes "system"
|
|
in the next (once more messages are appended) causes errors. If the
|
|
context consists entirely of system messages, the Perplexity API call
|
|
will fail, but that mistake will be caught right away.
|
|
|
|
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
|
|
|
|
# ChatCompletionMessageParam is a union of TypedDicts; the
|
|
# transformations below mutate by key/index in ways those TypedDicts
|
|
# don't permit. Work against a plain-dict view for the duration of
|
|
# the transformation and cast back at the return site.
|
|
msgs: list[dict[str, Any]] = cast(list[dict[str, Any]], copy.deepcopy(messages))
|
|
|
|
# Note: "developer" → "user" conversion is handled by the parent adapter
|
|
# via the convert_developer_to_user parameter.
|
|
|
|
# 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(msgs)):
|
|
if msgs[i].get("role") == "system":
|
|
if not in_initial_system_block:
|
|
msgs[i]["role"] = "user"
|
|
else:
|
|
in_initial_system_block = False
|
|
|
|
# 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(msgs) - 1:
|
|
current = msgs[i]
|
|
next_msg = msgs[i + 1]
|
|
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"]}]
|
|
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"])
|
|
msgs.pop(i + 1)
|
|
else:
|
|
i += 1
|
|
|
|
# Step 3: Remove trailing assistant messages.
|
|
# Perplexity requires the last message to be "user" or "tool".
|
|
# OpenAI appears to silently ignore trailing assistant messages
|
|
# server-side, so removing them preserves equivalent behavior.
|
|
while msgs and msgs[-1].get("role") == "assistant":
|
|
msgs.pop()
|
|
|
|
return cast(list[ChatCompletionMessageParam], msgs)
|