Centralize system message handling in adapters; add developer message support
Two goals: 1. Centralize system_instruction vs context system message resolution into the LLM adapters. This eliminates duplication between in-pipeline and out-of-band (run_inference) code paths across ~16 locations in service llm.py files. 2. Add support for "developer" role messages in conversation context, which is facilitated by the above centralization. Shared helpers on BaseLLMAdapter: - _extract_initial_system_or_developer: extracts/converts messages[0] based on role and whether system_instruction is provided - _resolve_system_instruction: warns on conflicts between system_instruction and context system messages, returns the effective instruction Developer message handling (new): - Non-OpenAI adapters: an initial "developer" message is promoted to the system instruction when no system_instruction is provided; otherwise it is converted to "user". Subsequent "developer" messages are always converted to "user". No conflict warning is emitted for developer messages (unlike "system" messages). - OpenAI adapter: "developer" messages pass through in conversation history without triggering conflict warnings. - OpenAI Responses adapter: "developer" messages are kept as "developer" role (same as "system", which is also converted to "developer" for the Responses API). Other behavior changes: - Gemini: "initial" system message detection now checks messages[0] only (previously searched anywhere in the list) - Bedrock: a lone system message is now converted to "user" instead of being extracted to an empty message list (matches existing Anthropic behavior)
This commit is contained in:
@@ -11,7 +11,7 @@ adapters that handle tool format conversion and standardization.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Generic, List, TypeVar
|
||||
from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -39,10 +39,16 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]):
|
||||
- Converting standardized tools schema to provider-specific tool formats.
|
||||
- Extracting messages from the LLM context for the purposes of logging
|
||||
about the specific provider.
|
||||
- Resolving conflicts between ``system_instruction`` and initial
|
||||
system/developer messages in the conversation context.
|
||||
|
||||
Subclasses must implement provider-specific conversion logic.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the adapter."""
|
||||
self._warned_system_instruction = False
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def id_for_llm_specific_messages(self) -> str:
|
||||
@@ -129,4 +135,123 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]):
|
||||
# Fallback to return the same tools in case they are not in a standard format
|
||||
return tools
|
||||
|
||||
# TODO: we can move the logic to also handle the Messages here
|
||||
def _extract_initial_system_or_developer(
|
||||
self,
|
||||
messages: list,
|
||||
*,
|
||||
system_instruction: Optional[str],
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Extract an initial system/developer message from messages, if appropriate.
|
||||
|
||||
Checks ``messages[0]``. Behavior:
|
||||
|
||||
- ``"system"`` role: always extract (pop from messages).
|
||||
- ``"developer"`` role **without** ``system_instruction``: extract (pop).
|
||||
- ``"developer"`` role **with** ``system_instruction``: don't extract;
|
||||
convert to ``"user"`` in-place.
|
||||
- Any other role: no-op.
|
||||
|
||||
If extracting would leave the messages list empty (``len(messages) == 1``),
|
||||
the message is converted to ``"user"`` role instead of being extracted.
|
||||
This prevents sending an empty conversation history to providers that
|
||||
require at least one non-system message (e.g. Anthropic, Bedrock).
|
||||
|
||||
Args:
|
||||
messages: Provider-formatted message list (mutated in-place).
|
||||
system_instruction: The system instruction from service settings or
|
||||
``run_inference``, used to decide whether to extract a
|
||||
``"developer"`` message.
|
||||
|
||||
Returns:
|
||||
``(extracted_content, original_role)`` where *original_role* is
|
||||
``"system"`` or ``"developer"``, or ``(None, None)`` if nothing
|
||||
was extracted.
|
||||
"""
|
||||
if not messages:
|
||||
return None, None
|
||||
|
||||
role = messages[0].get("role")
|
||||
if role not in ("system", "developer"):
|
||||
return None, None
|
||||
|
||||
# "developer" + system_instruction present → keep in messages as "user"
|
||||
if role == "developer" and system_instruction:
|
||||
messages[0]["role"] = "user"
|
||||
return None, None
|
||||
|
||||
# Would extracting empty the list? Convert to "user" instead.
|
||||
if len(messages) == 1:
|
||||
messages[0]["role"] = "user"
|
||||
return None, None
|
||||
|
||||
# Extract
|
||||
content = messages[0].get("content", "")
|
||||
if isinstance(content, list):
|
||||
# Join text parts for providers that expect a string system instruction
|
||||
content = " ".join(
|
||||
part.get("text", "") for part in content if part.get("type") == "text"
|
||||
)
|
||||
messages.pop(0)
|
||||
return content, role
|
||||
|
||||
def _resolve_system_instruction(
|
||||
self,
|
||||
initial_context_message: Optional[str],
|
||||
initial_context_message_role: Optional[str],
|
||||
system_instruction: Optional[str],
|
||||
*,
|
||||
discard_context_system: bool,
|
||||
) -> Optional[str]:
|
||||
"""Resolve conflict between ``system_instruction`` and an initial context message.
|
||||
|
||||
Only warns when *initial_context_message_role* is ``"system"`` (not
|
||||
``"developer"``), since a developer message coexisting with
|
||||
``system_instruction`` is expected and handled elsewhere.
|
||||
|
||||
Args:
|
||||
initial_context_message: Content extracted from ``messages[0]``
|
||||
by :meth:`_extract_initial_system_or_developer`, or detected
|
||||
inline (OpenAI adapters).
|
||||
initial_context_message_role: ``"system"`` or ``"developer"`` —
|
||||
the original role before extraction/detection.
|
||||
system_instruction: From service settings or ``run_inference`` param.
|
||||
discard_context_system: If ``True`` (non-OpenAI adapters), the
|
||||
context system message is discarded when ``system_instruction``
|
||||
is also present. If ``False`` (OpenAI adapters), both are kept.
|
||||
|
||||
Returns:
|
||||
The effective system instruction to use, or ``None`` if the system
|
||||
instruction is already represented in the messages (OpenAI path).
|
||||
"""
|
||||
both_present = initial_context_message and system_instruction
|
||||
from_system_role = initial_context_message_role == "system"
|
||||
|
||||
if both_present and from_system_role:
|
||||
if not self._warned_system_instruction:
|
||||
self._warned_system_instruction = True
|
||||
if discard_context_system:
|
||||
logger.warning(
|
||||
"Both system_instruction and a system message in context are set."
|
||||
" Using system_instruction."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Both system_instruction and an initial system message in context"
|
||||
" are set, which may be unintended. Prefer system_instruction."
|
||||
)
|
||||
|
||||
if system_instruction:
|
||||
if discard_context_system:
|
||||
return system_instruction
|
||||
else:
|
||||
# OpenAI path: caller prepends; return the instruction for prepending
|
||||
return system_instruction
|
||||
|
||||
if initial_context_message:
|
||||
if discard_context_system:
|
||||
return initial_context_message
|
||||
else:
|
||||
# Content is already in messages; nothing to prepend
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import copy
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, TypedDict
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
|
||||
from anthropic import NOT_GIVEN, NotGiven
|
||||
from anthropic.types.message_param import MessageParam
|
||||
@@ -48,24 +48,37 @@ class AnthropicLLMAdapter(BaseLLMAdapter[AnthropicLLMInvocationParams]):
|
||||
return "anthropic"
|
||||
|
||||
def get_llm_invocation_params(
|
||||
self, context: LLMContext, enable_prompt_caching: bool
|
||||
self,
|
||||
context: LLMContext,
|
||||
enable_prompt_caching: bool,
|
||||
system_instruction: Optional[str] = None,
|
||||
) -> AnthropicLLMInvocationParams:
|
||||
"""Get Anthropic-specific LLM invocation parameters from a universal LLM context.
|
||||
|
||||
Args:
|
||||
context: The LLM context containing messages, tools, etc.
|
||||
enable_prompt_caching: Whether prompt caching should be enabled.
|
||||
system_instruction: Optional system instruction from service settings
|
||||
or ``run_inference``.
|
||||
|
||||
Returns:
|
||||
Dictionary of parameters for invoking Anthropic's LLM API.
|
||||
"""
|
||||
messages = self._from_universal_context_messages(self.get_messages(context))
|
||||
converted = self._from_universal_context_messages(
|
||||
self.get_messages(context), system_instruction=system_instruction
|
||||
)
|
||||
system = self._resolve_system_instruction(
|
||||
converted.system if converted.system is not NOT_GIVEN else None,
|
||||
converted.system_role,
|
||||
system_instruction,
|
||||
discard_context_system=True,
|
||||
)
|
||||
return {
|
||||
"system": messages.system,
|
||||
"system": system if system is not None else NOT_GIVEN,
|
||||
"messages": (
|
||||
self._with_cache_control_markers(messages.messages)
|
||||
self._with_cache_control_markers(converted.messages)
|
||||
if enable_prompt_caching
|
||||
else messages.messages
|
||||
else converted.messages
|
||||
),
|
||||
# NOTE: LLMContext's tools are guaranteed to be a ToolsSchema (or NOT_GIVEN)
|
||||
"tools": self.from_standard_tools(context.tools) or [],
|
||||
@@ -105,35 +118,39 @@ class AnthropicLLMAdapter(BaseLLMAdapter[AnthropicLLMInvocationParams]):
|
||||
|
||||
messages: List[MessageParam]
|
||||
system: str | NotGiven
|
||||
system_role: Optional[str] = None # "system" or "developer" — origin of extracted system
|
||||
|
||||
def _from_universal_context_messages(
|
||||
self, universal_context_messages: List[LLMContextMessage]
|
||||
self,
|
||||
universal_context_messages: List[LLMContextMessage],
|
||||
*,
|
||||
system_instruction: Optional[str] = None,
|
||||
) -> ConvertedMessages:
|
||||
system = NOT_GIVEN
|
||||
messages = []
|
||||
system_role = None
|
||||
|
||||
# First, map messages using self._from_universal_context_message(m)
|
||||
# Extract initial system/developer from universal messages BEFORE conversion,
|
||||
# so the helper works with standard message format (not provider-specific).
|
||||
remaining = list(universal_context_messages)
|
||||
if remaining and not isinstance(remaining[0], LLMSpecificMessage):
|
||||
extracted_content, extracted_role = self._extract_initial_system_or_developer(
|
||||
remaining, system_instruction=system_instruction
|
||||
)
|
||||
if extracted_content is not None:
|
||||
system = extracted_content
|
||||
system_role = extracted_role
|
||||
|
||||
# Convert remaining messages to Anthropic format
|
||||
messages = []
|
||||
try:
|
||||
messages = [self._from_universal_context_message(m) for m in universal_context_messages]
|
||||
messages = [self._from_universal_context_message(m) for m in remaining]
|
||||
except Exception as e:
|
||||
logger.error(f"Error mapping messages: {e}")
|
||||
|
||||
# See if we should pull the system message out of our messages list.
|
||||
if messages and messages[0]["role"] == "system":
|
||||
if len(messages) == 1:
|
||||
# If we have only have a system message in the list, all we can really do
|
||||
# without introducing too much magic is change the role to "user".
|
||||
messages[0]["role"] = "user"
|
||||
else:
|
||||
# If we have more than one message, we'll pull the system message out of the
|
||||
# list.
|
||||
system = messages[0]["content"]
|
||||
messages.pop(0)
|
||||
|
||||
# Convert any subsequent "system"-role messages to "user"-role
|
||||
# messages, as Anthropic doesn't support system input messages.
|
||||
# Convert any subsequent "system"/"developer"-role messages to "user"-role
|
||||
# messages, as Anthropic doesn't support system or developer input messages.
|
||||
for message in messages:
|
||||
if message["role"] == "system":
|
||||
if message["role"] in ("system", "developer"):
|
||||
message["role"] = "user"
|
||||
|
||||
# Merge consecutive messages with the same role.
|
||||
@@ -163,7 +180,7 @@ class AnthropicLLMAdapter(BaseLLMAdapter[AnthropicLLMInvocationParams]):
|
||||
elif isinstance(message["content"], list) and len(message["content"]) == 0:
|
||||
message["content"] = [{"type": "text", "text": "(empty)"}]
|
||||
|
||||
return self.ConvertedMessages(messages=messages, system=system)
|
||||
return self.ConvertedMessages(messages=messages, system=system, system_role=system_role)
|
||||
|
||||
def _from_universal_context_message(self, message: LLMContextMessage) -> MessageParam:
|
||||
if isinstance(message, LLMSpecificMessage):
|
||||
|
||||
@@ -47,19 +47,31 @@ class AWSBedrockLLMAdapter(BaseLLMAdapter[AWSBedrockLLMInvocationParams]):
|
||||
"""Get the identifier used in LLMSpecificMessage instances for AWS Bedrock."""
|
||||
return "aws"
|
||||
|
||||
def get_llm_invocation_params(self, context: LLMContext) -> AWSBedrockLLMInvocationParams:
|
||||
def get_llm_invocation_params(
|
||||
self, context: LLMContext, *, system_instruction: Optional[str] = None
|
||||
) -> AWSBedrockLLMInvocationParams:
|
||||
"""Get AWS Bedrock-specific LLM invocation parameters from a universal LLM context.
|
||||
|
||||
Args:
|
||||
context: The LLM context containing messages, tools, etc.
|
||||
system_instruction: Optional system instruction from service settings
|
||||
or ``run_inference``.
|
||||
|
||||
Returns:
|
||||
Dictionary of parameters for invoking AWS Bedrock's LLM API.
|
||||
"""
|
||||
messages = self._from_universal_context_messages(self.get_messages(context))
|
||||
converted = self._from_universal_context_messages(
|
||||
self.get_messages(context), system_instruction=system_instruction
|
||||
)
|
||||
effective_system = self._resolve_system_instruction(
|
||||
converted.system,
|
||||
converted.system_role,
|
||||
system_instruction,
|
||||
discard_context_system=True,
|
||||
)
|
||||
return {
|
||||
"system": messages.system,
|
||||
"messages": messages.messages,
|
||||
"system": [{"text": effective_system}] if effective_system else None,
|
||||
"messages": converted.messages,
|
||||
# NOTE: LLMContext's tools are guaranteed to be a ToolsSchema (or NOT_GIVEN)
|
||||
"tools": self.from_standard_tools(context.tools) or [],
|
||||
# To avoid refactoring in AWSBedrockLLMService, we just pass through tool_choice.
|
||||
@@ -96,32 +108,43 @@ class AWSBedrockLLMAdapter(BaseLLMAdapter[AWSBedrockLLMInvocationParams]):
|
||||
|
||||
@dataclass
|
||||
class ConvertedMessages:
|
||||
"""Container for Anthropic-formatted messages converted from universal context."""
|
||||
"""Container for Bedrock-formatted messages converted from universal context."""
|
||||
|
||||
messages: List[dict[str, Any]]
|
||||
system: Optional[str]
|
||||
system_role: Optional[str] = None # "system" or "developer" — origin of extracted system
|
||||
|
||||
def _from_universal_context_messages(
|
||||
self, universal_context_messages: List[LLMContextMessage]
|
||||
self,
|
||||
universal_context_messages: List[LLMContextMessage],
|
||||
*,
|
||||
system_instruction: Optional[str] = None,
|
||||
) -> ConvertedMessages:
|
||||
system = None
|
||||
messages = []
|
||||
system_role = None
|
||||
|
||||
# First, map messages using self._from_universal_context_message(m)
|
||||
# Extract initial system/developer from universal messages BEFORE conversion,
|
||||
# so the helper works with standard message format (not provider-specific).
|
||||
remaining = list(universal_context_messages)
|
||||
if remaining and not isinstance(remaining[0], LLMSpecificMessage):
|
||||
extracted_content, extracted_role = self._extract_initial_system_or_developer(
|
||||
remaining, system_instruction=system_instruction
|
||||
)
|
||||
if extracted_content is not None:
|
||||
system = extracted_content
|
||||
system_role = extracted_role
|
||||
|
||||
# Convert remaining messages to Bedrock format
|
||||
messages = []
|
||||
try:
|
||||
messages = [self._from_universal_context_message(m) for m in universal_context_messages]
|
||||
messages = [self._from_universal_context_message(m) for m in remaining]
|
||||
except Exception as e:
|
||||
logger.error(f"Error mapping messages: {e}")
|
||||
|
||||
# See if we should pull the system message out of our messages list
|
||||
if messages and messages[0]["role"] == "system":
|
||||
system = messages[0]["content"]
|
||||
messages.pop(0)
|
||||
|
||||
# Convert any subsequent "system"-role messages to "user"-role
|
||||
# messages, as AWS Bedrock doesn't support system input messages.
|
||||
# Convert any subsequent "system"/"developer"-role messages to "user"-role
|
||||
# messages, as AWS Bedrock doesn't support system or developer input messages.
|
||||
for message in messages:
|
||||
if message["role"] == "system":
|
||||
if message["role"] in ("system", "developer"):
|
||||
message["role"] = "user"
|
||||
|
||||
# Merge consecutive messages with the same role.
|
||||
@@ -151,7 +174,7 @@ class AWSBedrockLLMAdapter(BaseLLMAdapter[AWSBedrockLLMInvocationParams]):
|
||||
elif isinstance(message["content"], list) and len(message["content"]) == 0:
|
||||
message["content"] = [{"type": "text", "text": "(empty)"}]
|
||||
|
||||
return self.ConvertedMessages(messages=messages, system=system)
|
||||
return self.ConvertedMessages(messages=messages, system=system, system_role=system_role)
|
||||
|
||||
def _from_universal_context_message(self, message: LLMContextMessage) -> dict[str, Any]:
|
||||
if isinstance(message, LLMSpecificMessage):
|
||||
|
||||
@@ -53,19 +53,31 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
|
||||
"""Get the identifier used in LLMSpecificMessage instances for Google."""
|
||||
return "google"
|
||||
|
||||
def get_llm_invocation_params(self, context: LLMContext) -> GeminiLLMInvocationParams:
|
||||
def get_llm_invocation_params(
|
||||
self, context: LLMContext, *, system_instruction: Optional[str] = None
|
||||
) -> GeminiLLMInvocationParams:
|
||||
"""Get Gemini-specific LLM invocation parameters from a universal LLM context.
|
||||
|
||||
Args:
|
||||
context: The LLM context containing messages, tools, etc.
|
||||
system_instruction: Optional system instruction from service settings
|
||||
or ``run_inference``.
|
||||
|
||||
Returns:
|
||||
Dictionary of parameters for Gemini's API.
|
||||
"""
|
||||
messages = self._from_universal_context_messages(self.get_messages(context))
|
||||
converted = self._from_universal_context_messages(
|
||||
self.get_messages(context), system_instruction=system_instruction
|
||||
)
|
||||
effective_system = self._resolve_system_instruction(
|
||||
converted.system_instruction,
|
||||
converted.system_instruction_role,
|
||||
system_instruction,
|
||||
discard_context_system=True,
|
||||
)
|
||||
return {
|
||||
"system_instruction": messages.system_instruction,
|
||||
"messages": messages.messages,
|
||||
"system_instruction": effective_system,
|
||||
"messages": converted.messages,
|
||||
# NOTE: LLMContext's tools are guaranteed to be a ToolsSchema (or NOT_GIVEN)
|
||||
"tools": self.from_standard_tools(context.tools),
|
||||
}
|
||||
@@ -164,57 +176,65 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
|
||||
|
||||
messages: List[Content]
|
||||
system_instruction: Optional[str] = None
|
||||
system_instruction_role: Optional[str] = None # "system" or "developer"
|
||||
|
||||
@dataclass
|
||||
class MessageConversionResult:
|
||||
"""Result of converting a single universal context message to Google format.
|
||||
|
||||
Either content (a Google Content object) or a system instruction string
|
||||
is guaranteed to be set.
|
||||
|
||||
Also returns a tool call ID to name mapping for any tool calls
|
||||
discovered in the message.
|
||||
Contains a Google Content object and a tool call ID to name mapping
|
||||
for any tool calls discovered in the message.
|
||||
"""
|
||||
|
||||
content: Optional[Content] = None
|
||||
system_instruction: Optional[str] = None
|
||||
tool_call_id_to_name_mapping: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class MessageConversionParams:
|
||||
"""Parameters for converting a single universal context message to Google format."""
|
||||
|
||||
already_have_system_instruction: bool
|
||||
tool_call_id_to_name_mapping: Dict[str, str]
|
||||
|
||||
def _from_universal_context_messages(
|
||||
self, universal_context_messages: List[LLMContextMessage]
|
||||
self,
|
||||
universal_context_messages: List[LLMContextMessage],
|
||||
*,
|
||||
system_instruction: Optional[str] = None,
|
||||
) -> ConvertedMessages:
|
||||
"""Restructures messages to ensure proper Google format and message ordering.
|
||||
|
||||
This method handles conversion of OpenAI-formatted messages to Google format,
|
||||
with special handling for function calls, function responses, and system messages.
|
||||
System messages are added back to the context as user messages when needed.
|
||||
with special handling for function calls, function responses, and system/developer
|
||||
messages.
|
||||
|
||||
The final message order is preserved as:
|
||||
Initial system/developer messages are extracted as the system instruction
|
||||
(only from ``messages[0]``). Subsequent system/developer messages are
|
||||
converted to user role.
|
||||
|
||||
1. Function calls (from model)
|
||||
2. Function responses (from user)
|
||||
3. Text messages (converted from system messages)
|
||||
|
||||
Note::
|
||||
|
||||
System messages are only added back when there are no regular text
|
||||
messages in the context, ensuring proper conversation continuity
|
||||
after function calls.
|
||||
Args:
|
||||
universal_context_messages: Messages from the LLM context.
|
||||
system_instruction: Optional system instruction from service settings,
|
||||
used to decide whether to extract an initial "developer" message.
|
||||
"""
|
||||
system_instruction = None
|
||||
# Extract initial system/developer message from universal messages before conversion.
|
||||
# We work on a mutable copy so we can pop messages[0] if needed.
|
||||
remaining_messages = list(universal_context_messages)
|
||||
extracted_system = None
|
||||
extracted_role = None
|
||||
|
||||
# Extract initial system/developer from universal messages BEFORE conversion,
|
||||
# so the helper works with standard message format.
|
||||
if remaining_messages and not isinstance(remaining_messages[0], LLMSpecificMessage):
|
||||
extracted_system, extracted_role = self._extract_initial_system_or_developer(
|
||||
remaining_messages, system_instruction=system_instruction
|
||||
)
|
||||
|
||||
messages = []
|
||||
tool_call_id_to_name_mapping = {}
|
||||
thought_signature_dicts = []
|
||||
|
||||
# Process each message, converting to Google format as needed
|
||||
for message in universal_context_messages:
|
||||
for message in remaining_messages:
|
||||
# We have a Google-specific message; this may either be a
|
||||
# thought-signature-containing message that we need to handle in a
|
||||
# special way, or a message already in Google format that we can
|
||||
@@ -237,16 +257,12 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
|
||||
result = self._from_standard_message(
|
||||
message,
|
||||
params=self.MessageConversionParams(
|
||||
already_have_system_instruction=bool(system_instruction),
|
||||
tool_call_id_to_name_mapping=tool_call_id_to_name_mapping,
|
||||
),
|
||||
)
|
||||
|
||||
# Each result is either a Content or a system instruction
|
||||
if result.content:
|
||||
messages.append(result.content)
|
||||
elif result.system_instruction:
|
||||
system_instruction = result.system_instruction
|
||||
|
||||
# Merge tool call ID to name mapping
|
||||
if result.tool_call_id_to_name_mapping:
|
||||
@@ -259,6 +275,7 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
|
||||
messages = self._merge_parallel_tool_calls_for_thinking(thought_signature_dicts, messages)
|
||||
|
||||
# Check if we only have function-related messages (no regular text)
|
||||
effective_system = extracted_system or system_instruction
|
||||
has_regular_messages = any(
|
||||
len(msg.parts) == 1
|
||||
and getattr(msg.parts[0], "text", None)
|
||||
@@ -268,13 +285,17 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
|
||||
)
|
||||
|
||||
# Add system instruction back as a user message if we only have function messages
|
||||
if system_instruction and not has_regular_messages:
|
||||
messages.append(Content(role="user", parts=[Part(text=system_instruction)]))
|
||||
if effective_system and not has_regular_messages:
|
||||
messages.append(Content(role="user", parts=[Part(text=effective_system)]))
|
||||
|
||||
# Remove any empty messages
|
||||
messages = [m for m in messages if m.parts]
|
||||
|
||||
return self.ConvertedMessages(messages=messages, system_instruction=system_instruction)
|
||||
return self.ConvertedMessages(
|
||||
messages=messages,
|
||||
system_instruction=extracted_system,
|
||||
system_instruction_role=extracted_role,
|
||||
)
|
||||
|
||||
def _from_standard_message(
|
||||
self, message: LLMStandardMessage, *, params: MessageConversionParams
|
||||
@@ -282,17 +303,16 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
|
||||
"""Convert standard universal context message to Google Content object.
|
||||
|
||||
Handles conversion of text, images, and function calls to Google's
|
||||
format.
|
||||
System instructions are returned as a plain string.
|
||||
format. System and developer messages at this stage (i.e. non-initial
|
||||
ones, since the initial one is already extracted) are converted to
|
||||
user role.
|
||||
|
||||
Args:
|
||||
message: Message in standard universal context format.
|
||||
already_have_system_instruction: Whether we already have a system instruction
|
||||
params: Parameters for conversion.
|
||||
|
||||
Returns:
|
||||
MessageConversionResult containing either a Content object or a
|
||||
system instruction string.
|
||||
MessageConversionResult containing a Content object.
|
||||
|
||||
Examples:
|
||||
Standard text message::
|
||||
@@ -333,20 +353,10 @@ class GeminiLLMAdapter(BaseLLMAdapter[GeminiLLMInvocationParams]):
|
||||
role = message["role"]
|
||||
content = message.get("content", [])
|
||||
|
||||
if role == "system":
|
||||
if params.already_have_system_instruction:
|
||||
role = "user" # Convert system message to user role if we already have a system instruction
|
||||
else:
|
||||
system_instruction: str = None
|
||||
if isinstance(content, str):
|
||||
system_instruction = content
|
||||
elif isinstance(content, list):
|
||||
# If content is a list, we assume it's a list of text parts, per the standard
|
||||
system_instruction = " ".join(
|
||||
part["text"] for part in content if part.get("type") == "text"
|
||||
)
|
||||
if system_instruction:
|
||||
return self.MessageConversionResult(system_instruction=system_instruction)
|
||||
# Convert non-initial system/developer messages to user role,
|
||||
# as Gemini doesn't support these as input messages.
|
||||
if role in ("system", "developer"):
|
||||
role = "user"
|
||||
elif role == "assistant":
|
||||
role = "model"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"""OpenAI LLM adapter for Pipecat."""
|
||||
|
||||
import copy
|
||||
from typing import Any, Dict, List, TypedDict
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
|
||||
from openai._types import NotGiven as OpenAINotGiven
|
||||
from openai.types.chat import (
|
||||
@@ -51,17 +51,35 @@ class OpenAILLMAdapter(BaseLLMAdapter[OpenAILLMInvocationParams]):
|
||||
"""Get the identifier used in LLMSpecificMessage instances for OpenAI."""
|
||||
return "openai"
|
||||
|
||||
def get_llm_invocation_params(self, context: LLMContext) -> OpenAILLMInvocationParams:
|
||||
def get_llm_invocation_params(
|
||||
self, context: LLMContext, *, system_instruction: Optional[str] = None
|
||||
) -> OpenAILLMInvocationParams:
|
||||
"""Get OpenAI-specific LLM invocation parameters from a universal LLM context.
|
||||
|
||||
Args:
|
||||
context: The LLM context containing messages, tools, etc.
|
||||
system_instruction: Optional system instruction from service settings
|
||||
or ``run_inference``. If provided, prepended as a system message.
|
||||
|
||||
Returns:
|
||||
Dictionary of parameters for OpenAI's ChatCompletion API.
|
||||
"""
|
||||
messages = self._from_universal_context_messages(self.get_messages(context))
|
||||
|
||||
if system_instruction:
|
||||
# Detect initial system message for warning purposes (don't extract)
|
||||
initial_role = messages[0].get("role") if messages else None
|
||||
initial_content = messages[0].get("content", "") if initial_role == "system" else None
|
||||
self._resolve_system_instruction(
|
||||
initial_content,
|
||||
initial_role if initial_role == "system" else None,
|
||||
system_instruction,
|
||||
discard_context_system=False,
|
||||
)
|
||||
messages = [{"role": "system", "content": system_instruction}] + messages
|
||||
|
||||
return {
|
||||
"messages": self._from_universal_context_messages(self.get_messages(context)),
|
||||
"messages": messages,
|
||||
# NOTE; LLMContext's tools are guaranteed to be a ToolsSchema (or NOT_GIVEN)
|
||||
"tools": self.from_standard_tools(context.tools),
|
||||
"tool_choice": context.tool_choice,
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import copy
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
|
||||
from loguru import logger
|
||||
from openai._types import NotGiven as OpenAINotGiven
|
||||
from openai.types.responses import FunctionToolParam, ResponseInputItemParam
|
||||
|
||||
@@ -41,11 +40,6 @@ class OpenAIResponsesLLMAdapter(BaseLLMAdapter[OpenAIResponsesLLMInvocationParam
|
||||
- Extracting and sanitizing messages from the LLM context for logging
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the adapter."""
|
||||
super().__init__()
|
||||
self._warned_system_instruction = False
|
||||
|
||||
@property
|
||||
def id_for_llm_specific_messages(self) -> str:
|
||||
"""Get the identifier used in LLMSpecificMessage instances."""
|
||||
@@ -67,6 +61,18 @@ class OpenAIResponsesLLMAdapter(BaseLLMAdapter[OpenAIResponsesLLMInvocationParam
|
||||
Dictionary of parameters for the Responses API.
|
||||
"""
|
||||
messages = self.get_messages(context)
|
||||
|
||||
# Check for conflict: system_instruction + initial system message
|
||||
if system_instruction and messages:
|
||||
first_msg = messages[0] if not isinstance(messages[0], LLMSpecificMessage) else None
|
||||
if first_msg and first_msg.get("role") == "system":
|
||||
self._resolve_system_instruction(
|
||||
first_msg.get("content", ""),
|
||||
"system",
|
||||
system_instruction,
|
||||
discard_context_system=False,
|
||||
)
|
||||
|
||||
input_items = self._convert_messages_to_input(messages)
|
||||
|
||||
params: OpenAIResponsesLLMInvocationParams = {
|
||||
@@ -158,24 +164,15 @@ class OpenAIResponsesLLMAdapter(BaseLLMAdapter[OpenAIResponsesLLMInvocationParam
|
||||
List of Responses API input items.
|
||||
"""
|
||||
result: List[ResponseInputItemParam] = []
|
||||
is_first = True
|
||||
|
||||
for message in messages:
|
||||
if isinstance(message, LLMSpecificMessage):
|
||||
result.append(message.message)
|
||||
is_first = False
|
||||
continue
|
||||
|
||||
role = message.get("role")
|
||||
|
||||
if role == "system":
|
||||
if is_first and not self._warned_system_instruction:
|
||||
logger.warning(
|
||||
"System messages in LLMContext are converted to 'developer' role for the "
|
||||
"Responses API. Consider using settings.system_instruction instead, which "
|
||||
"maps to the 'instructions' parameter."
|
||||
)
|
||||
self._warned_system_instruction = True
|
||||
if role in ("system", "developer"):
|
||||
content = message.get("content", "")
|
||||
if isinstance(content, list):
|
||||
content = self._convert_multimodal_content(content)
|
||||
@@ -218,8 +215,6 @@ class OpenAIResponsesLLMAdapter(BaseLLMAdapter[OpenAIResponsesLLMInvocationParam
|
||||
}
|
||||
)
|
||||
|
||||
is_first = False
|
||||
|
||||
return result
|
||||
|
||||
def _convert_multimodal_content(self, content: list) -> list:
|
||||
|
||||
@@ -28,7 +28,7 @@ the messages are sent to Perplexity's API.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from openai.types.chat import ChatCompletionMessageParam
|
||||
|
||||
@@ -49,17 +49,21 @@ class PerplexityLLMAdapter(OpenAILLMAdapter):
|
||||
``build_chat_completion_params`` prepends ``system_instruction``.
|
||||
"""
|
||||
|
||||
def get_llm_invocation_params(self, context: LLMContext) -> OpenAILLMInvocationParams:
|
||||
def get_llm_invocation_params(
|
||||
self, context: LLMContext, *, system_instruction: Optional[str] = None
|
||||
) -> 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.
|
||||
|
||||
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 = super().get_llm_invocation_params(context, system_instruction=system_instruction)
|
||||
params["messages"] = self._transform_messages(list(params["messages"]))
|
||||
return params
|
||||
|
||||
|
||||
@@ -370,10 +370,13 @@ class AnthropicLLMService(LLMService):
|
||||
messages = []
|
||||
system = NOT_GIVEN
|
||||
tools = []
|
||||
effective_instruction = system_instruction or self._settings.system_instruction
|
||||
if isinstance(context, LLMContext):
|
||||
adapter: AnthropicLLMAdapter = self.get_llm_adapter()
|
||||
invocation_params = adapter.get_llm_invocation_params(
|
||||
context, enable_prompt_caching=self._settings.enable_prompt_caching
|
||||
context,
|
||||
enable_prompt_caching=self._settings.enable_prompt_caching,
|
||||
system_instruction=effective_instruction,
|
||||
)
|
||||
messages = invocation_params["messages"]
|
||||
system = invocation_params["system"]
|
||||
@@ -384,15 +387,6 @@ class AnthropicLLMService(LLMService):
|
||||
system = getattr(context, "system", NOT_GIVEN)
|
||||
tools = context.tools or []
|
||||
|
||||
# Override system instruction if provided
|
||||
if system_instruction is not None:
|
||||
if system and system is not NOT_GIVEN:
|
||||
logger.warning(
|
||||
f"{self}: Both system_instruction and a system message in context are set."
|
||||
" Using system_instruction."
|
||||
)
|
||||
system = system_instruction
|
||||
|
||||
# Build params using the same method as streaming completions
|
||||
params = {
|
||||
"model": self._settings.model,
|
||||
@@ -460,15 +454,10 @@ class AnthropicLLMService(LLMService):
|
||||
if isinstance(context, LLMContext):
|
||||
adapter: AnthropicLLMAdapter = self.get_llm_adapter()
|
||||
params: AnthropicLLMInvocationParams = adapter.get_llm_invocation_params(
|
||||
context, enable_prompt_caching=self._settings.enable_prompt_caching
|
||||
context,
|
||||
enable_prompt_caching=self._settings.enable_prompt_caching,
|
||||
system_instruction=self._settings.system_instruction,
|
||||
)
|
||||
if self._settings.system_instruction:
|
||||
if params["system"] is not NOT_GIVEN:
|
||||
logger.warning(
|
||||
f"{self}: Both system_instruction and a system message in context are"
|
||||
" set. Using system_instruction."
|
||||
)
|
||||
params["system"] = self._settings.system_instruction
|
||||
return params
|
||||
|
||||
# Anthropic-specific context
|
||||
|
||||
@@ -942,25 +942,19 @@ class AWSBedrockLLMService(LLMService):
|
||||
"""
|
||||
messages = []
|
||||
system = []
|
||||
effective_instruction = system_instruction or self._settings.system_instruction
|
||||
if isinstance(context, LLMContext):
|
||||
adapter: AWSBedrockLLMAdapter = self.get_llm_adapter()
|
||||
params: AWSBedrockLLMInvocationParams = adapter.get_llm_invocation_params(context)
|
||||
params: AWSBedrockLLMInvocationParams = adapter.get_llm_invocation_params(
|
||||
context, system_instruction=effective_instruction
|
||||
)
|
||||
messages = params["messages"]
|
||||
system = params["system"] # [{"text": "system message"}]
|
||||
system = params["system"] # [{"text": "system message"}] or None
|
||||
else:
|
||||
context = AWSBedrockLLMContext.upgrade_to_bedrock(context)
|
||||
messages = context.messages
|
||||
system = getattr(context, "system", None) # [{"text": "system message"}]
|
||||
|
||||
# Override system instruction if provided
|
||||
if system_instruction is not None:
|
||||
if system:
|
||||
logger.warning(
|
||||
f"{self}: Both system_instruction and a system message in context are set."
|
||||
" Using system_instruction."
|
||||
)
|
||||
system = [{"text": system_instruction}]
|
||||
|
||||
# Prepare request parameters using the same method as streaming
|
||||
inference_config = self._build_inference_config()
|
||||
|
||||
@@ -1086,14 +1080,9 @@ class AWSBedrockLLMService(LLMService):
|
||||
# Universal LLMContext
|
||||
if isinstance(context, LLMContext):
|
||||
adapter: AWSBedrockLLMAdapter = self.get_llm_adapter()
|
||||
params: AWSBedrockLLMInvocationParams = adapter.get_llm_invocation_params(context)
|
||||
if self._settings.system_instruction:
|
||||
if params["system"]:
|
||||
logger.warning(
|
||||
f"{self}: Both system_instruction and a system message in context are"
|
||||
" set. Using system_instruction."
|
||||
)
|
||||
params["system"] = [{"text": self._settings.system_instruction}]
|
||||
params: AWSBedrockLLMInvocationParams = adapter.get_llm_invocation_params(
|
||||
context, system_instruction=self._settings.system_instruction
|
||||
)
|
||||
return params
|
||||
|
||||
# AWS Bedrock-specific context
|
||||
|
||||
@@ -114,15 +114,4 @@ class CerebrasLLMService(OpenAILLMService):
|
||||
|
||||
params.update(self._settings.extra)
|
||||
|
||||
# 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
|
||||
|
||||
return params
|
||||
|
||||
@@ -115,15 +115,4 @@ class FireworksLLMService(OpenAILLMService):
|
||||
|
||||
params.update(self._settings.extra)
|
||||
|
||||
# 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
|
||||
|
||||
return params
|
||||
|
||||
@@ -904,9 +904,12 @@ class GoogleLLMService(LLMService):
|
||||
messages = []
|
||||
system = []
|
||||
tools = []
|
||||
effective_instruction = system_instruction or self._settings.system_instruction
|
||||
if isinstance(context, LLMContext):
|
||||
adapter = self.get_llm_adapter()
|
||||
params: GeminiLLMInvocationParams = adapter.get_llm_invocation_params(context)
|
||||
params: GeminiLLMInvocationParams = adapter.get_llm_invocation_params(
|
||||
context, system_instruction=effective_instruction
|
||||
)
|
||||
messages = params["messages"]
|
||||
system = params["system_instruction"]
|
||||
tools = params["tools"]
|
||||
@@ -916,15 +919,6 @@ class GoogleLLMService(LLMService):
|
||||
system = getattr(context, "system_message", None)
|
||||
tools = context.tools or []
|
||||
|
||||
# Override system instruction if provided
|
||||
if system_instruction is not None:
|
||||
if system:
|
||||
logger.warning(
|
||||
f"{self}: Both system_instruction and a system message in context are set."
|
||||
" Using system_instruction."
|
||||
)
|
||||
system = system_instruction
|
||||
|
||||
# Build generation config using the same method as streaming
|
||||
generation_params = self._build_generation_params(
|
||||
system_instruction=system, tools=tools if tools else None
|
||||
@@ -1015,15 +1009,8 @@ class GoogleLLMService(LLMService):
|
||||
) -> AsyncIterator[GenerateContentResponse]:
|
||||
messages = params_from_context["messages"]
|
||||
|
||||
# Constructor/settings system instruction takes priority over context.
|
||||
if self._settings.system_instruction and params_from_context["system_instruction"]:
|
||||
logger.warning(
|
||||
f"{self}: Both system_instruction and a system message in context are"
|
||||
" set. Using system_instruction."
|
||||
)
|
||||
system_instruction = (
|
||||
self._settings.system_instruction or params_from_context["system_instruction"]
|
||||
)
|
||||
# The adapter already resolved system_instruction vs context system message.
|
||||
system_instruction = params_from_context["system_instruction"]
|
||||
|
||||
tools = []
|
||||
if params_from_context["tools"]:
|
||||
@@ -1072,7 +1059,9 @@ class GoogleLLMService(LLMService):
|
||||
self, context: LLMContext
|
||||
) -> AsyncIterator[GenerateContentResponse]:
|
||||
adapter = self.get_llm_adapter()
|
||||
params: GeminiLLMInvocationParams = adapter.get_llm_invocation_params(context)
|
||||
params: GeminiLLMInvocationParams = adapter.get_llm_invocation_params(
|
||||
context, system_instruction=self._settings.system_instruction
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"{self}: Generating chat from universal context [{params['system_instruction']}] | {adapter.get_messages_for_logging(context)}"
|
||||
|
||||
@@ -233,15 +233,4 @@ class MistralLLMService(OpenAILLMService):
|
||||
# Add any extra parameters
|
||||
params.update(self._settings.extra)
|
||||
|
||||
# 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
|
||||
|
||||
return params
|
||||
|
||||
@@ -327,17 +327,6 @@ class BaseOpenAILLMService(LLMService):
|
||||
|
||||
params.update(self._settings.extra)
|
||||
|
||||
# Prepend system instruction from constructor
|
||||
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
|
||||
|
||||
return params
|
||||
|
||||
async def run_inference(
|
||||
@@ -358,10 +347,11 @@ class BaseOpenAILLMService(LLMService):
|
||||
Returns:
|
||||
The LLM's response as a string, or None if no response is generated.
|
||||
"""
|
||||
effective_instruction = system_instruction or self._settings.system_instruction
|
||||
if isinstance(context, LLMContext):
|
||||
adapter = self.get_llm_adapter()
|
||||
invocation_params: OpenAILLMInvocationParams = adapter.get_llm_invocation_params(
|
||||
context
|
||||
context, system_instruction=effective_instruction
|
||||
)
|
||||
else:
|
||||
invocation_params = OpenAILLMInvocationParams(
|
||||
@@ -375,15 +365,6 @@ class BaseOpenAILLMService(LLMService):
|
||||
params["stream"] = False
|
||||
params.pop("stream_options", None)
|
||||
|
||||
# Prepend system instruction if provided
|
||||
if system_instruction is not None:
|
||||
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": system_instruction}] + messages
|
||||
|
||||
# Override max_tokens if provided
|
||||
if max_tokens is not None:
|
||||
# Use max_completion_tokens for newer models, fallback to max_tokens
|
||||
@@ -439,7 +420,9 @@ class BaseOpenAILLMService(LLMService):
|
||||
f"{self}: Generating chat from universal context {adapter.get_messages_for_logging(context)}"
|
||||
)
|
||||
|
||||
params: OpenAILLMInvocationParams = adapter.get_llm_invocation_params(context)
|
||||
params: OpenAILLMInvocationParams = adapter.get_llm_invocation_params(
|
||||
context, system_instruction=self._settings.system_instruction
|
||||
)
|
||||
chunks = await self.get_chat_completions(params)
|
||||
|
||||
return chunks
|
||||
|
||||
@@ -121,17 +121,6 @@ class PerplexityLLMService(OpenAILLMService):
|
||||
if self._settings.max_tokens is not None:
|
||||
params["max_tokens"] = self._settings.max_tokens
|
||||
|
||||
# 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
|
||||
|
||||
return params
|
||||
|
||||
async def _process_context(self, context: OpenAILLMContext | LLMContext):
|
||||
|
||||
@@ -131,17 +131,6 @@ class SambaNovaLLMService(OpenAILLMService): # type: ignore
|
||||
|
||||
params.update(self._settings.extra)
|
||||
|
||||
# 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
|
||||
|
||||
return params
|
||||
|
||||
@traced_llm # type: ignore
|
||||
|
||||
587
tests/test_adapter_system_instruction.py
Normal file
587
tests/test_adapter_system_instruction.py
Normal file
@@ -0,0 +1,587 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Unit tests for system_instruction and developer message handling in LLM adapters.
|
||||
|
||||
Tests cover:
|
||||
|
||||
1. system_instruction only (no system/developer in context)
|
||||
2. Initial "system" message only (no system_instruction)
|
||||
3. Initial "developer" message only (no system_instruction) -> promoted to system instruction
|
||||
4. Both system_instruction and initial "system" message -> warns
|
||||
5. Both system_instruction and initial "developer" message -> does NOT warn; developer becomes "user"
|
||||
6. Non-OpenAI adapters: subsequent "developer" messages converted to "user"
|
||||
7. Non-OpenAI adapters: initial "system" discarded when system_instruction provided
|
||||
8. Gemini: non-initial "system" message is converted to "user" (not extracted)
|
||||
9. Single system-only message: converted to "user" instead of extracting (empty list prevention)
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pipecat.adapters.services.open_ai_adapter import OpenAILLMAdapter
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext, LLMStandardMessage
|
||||
|
||||
|
||||
class TestOpenAIAdapterSystemInstruction(unittest.TestCase):
|
||||
"""Tests for the OpenAI ChatCompletion adapter."""
|
||||
|
||||
def setUp(self):
|
||||
self.adapter = OpenAILLMAdapter()
|
||||
|
||||
def test_system_instruction_only(self):
|
||||
"""system_instruction alone is prepended as a system message."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context, system_instruction="Be helpful.")
|
||||
|
||||
self.assertEqual(params["messages"][0]["role"], "system")
|
||||
self.assertEqual(params["messages"][0]["content"], "Be helpful.")
|
||||
self.assertEqual(params["messages"][1]["role"], "user")
|
||||
|
||||
def test_initial_system_message_only(self):
|
||||
"""Initial system message without system_instruction passes through."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
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 helpful.")
|
||||
|
||||
def test_both_system_instruction_and_system_message_warns(self):
|
||||
"""system_instruction + initial system message warns but allows both."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
params = self.adapter.get_llm_invocation_params(
|
||||
context, system_instruction="Be concise."
|
||||
)
|
||||
mock_logger.warning.assert_called_once()
|
||||
warning_msg = mock_logger.warning.call_args[0][0]
|
||||
self.assertIn("may be unintended", warning_msg)
|
||||
|
||||
# Both are present: prepended system_instruction + original system message
|
||||
self.assertEqual(params["messages"][0]["content"], "Be concise.")
|
||||
self.assertEqual(params["messages"][1]["content"], "You are helpful.")
|
||||
|
||||
def test_both_system_instruction_and_developer_message_no_warning(self):
|
||||
"""system_instruction + initial developer message does NOT warn."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "developer", "content": "Extra context."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
params = self.adapter.get_llm_invocation_params(
|
||||
context, system_instruction="Be concise."
|
||||
)
|
||||
mock_logger.warning.assert_not_called()
|
||||
|
||||
# system_instruction prepended, developer message stays in messages
|
||||
self.assertEqual(params["messages"][0]["content"], "Be concise.")
|
||||
self.assertEqual(params["messages"][1]["role"], "developer")
|
||||
|
||||
def test_warning_fires_only_once(self):
|
||||
"""Conflict warning fires only once per adapter instance."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
self.adapter.get_llm_invocation_params(context, system_instruction="Be concise.")
|
||||
self.adapter.get_llm_invocation_params(context, system_instruction="Be concise.")
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
|
||||
class TestAnthropicAdapterSystemInstruction(unittest.TestCase):
|
||||
"""Tests for the Anthropic adapter."""
|
||||
|
||||
def setUp(self):
|
||||
from pipecat.adapters.services.anthropic_adapter import AnthropicLLMAdapter
|
||||
|
||||
self.adapter = AnthropicLLMAdapter()
|
||||
|
||||
def test_system_instruction_only(self):
|
||||
"""system_instruction alone becomes the system parameter."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(
|
||||
context, enable_prompt_caching=False, system_instruction="Be helpful."
|
||||
)
|
||||
|
||||
self.assertEqual(params["system"], "Be helpful.")
|
||||
self.assertEqual(len(params["messages"]), 1)
|
||||
self.assertEqual(params["messages"][0]["role"], "user")
|
||||
|
||||
def test_initial_system_message_only(self):
|
||||
"""Initial system message is extracted as the system parameter."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context, enable_prompt_caching=False)
|
||||
|
||||
self.assertEqual(params["system"], "You are helpful.")
|
||||
self.assertEqual(len(params["messages"]), 1)
|
||||
self.assertEqual(params["messages"][0]["role"], "user")
|
||||
|
||||
def test_initial_developer_message_promoted(self):
|
||||
"""Initial developer message without system_instruction is promoted to system."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "developer", "content": "Extra context."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context, enable_prompt_caching=False)
|
||||
|
||||
self.assertEqual(params["system"], "Extra context.")
|
||||
self.assertEqual(len(params["messages"]), 1)
|
||||
|
||||
def test_both_system_instruction_and_system_message_warns(self):
|
||||
"""system_instruction + initial system message warns and uses system_instruction."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
params = self.adapter.get_llm_invocation_params(
|
||||
context,
|
||||
enable_prompt_caching=False,
|
||||
system_instruction="Be concise.",
|
||||
)
|
||||
mock_logger.warning.assert_called_once()
|
||||
warning_msg = mock_logger.warning.call_args[0][0]
|
||||
self.assertIn("Using system_instruction", warning_msg)
|
||||
|
||||
self.assertEqual(params["system"], "Be concise.")
|
||||
|
||||
def test_both_system_instruction_and_developer_message_no_warning(self):
|
||||
"""system_instruction + initial developer message: no warning, developer becomes user."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "developer", "content": "Extra context."},
|
||||
{"role": "assistant", "content": "Hi"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
params = self.adapter.get_llm_invocation_params(
|
||||
context,
|
||||
enable_prompt_caching=False,
|
||||
system_instruction="Be concise.",
|
||||
)
|
||||
mock_logger.warning.assert_not_called()
|
||||
|
||||
self.assertEqual(params["system"], "Be concise.")
|
||||
# Developer message should have been converted to "user"
|
||||
self.assertEqual(params["messages"][0]["role"], "user")
|
||||
self.assertEqual(params["messages"][0]["content"], "Extra context.")
|
||||
|
||||
def test_subsequent_developer_messages_converted_to_user(self):
|
||||
"""Subsequent developer messages are converted to user role."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi"},
|
||||
{"role": "developer", "content": "More instructions"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context, enable_prompt_caching=False)
|
||||
|
||||
# Developer message was converted to "user"
|
||||
self.assertEqual(params["messages"][2]["role"], "user")
|
||||
self.assertEqual(params["messages"][2]["content"], "More instructions")
|
||||
|
||||
def test_initial_system_discarded_when_system_instruction_provided(self):
|
||||
"""Initial system message is discarded when system_instruction is provided."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "Old instruction."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger"):
|
||||
params = self.adapter.get_llm_invocation_params(
|
||||
context,
|
||||
enable_prompt_caching=False,
|
||||
system_instruction="New instruction.",
|
||||
)
|
||||
|
||||
self.assertEqual(params["system"], "New instruction.")
|
||||
# Only the user message should remain
|
||||
self.assertEqual(len(params["messages"]), 1)
|
||||
self.assertEqual(params["messages"][0]["role"], "user")
|
||||
|
||||
def test_single_system_message_becomes_user(self):
|
||||
"""A lone system message is converted to user (not extracted) to prevent empty history."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context, enable_prompt_caching=False)
|
||||
|
||||
from anthropic import NOT_GIVEN
|
||||
|
||||
self.assertEqual(params["system"], NOT_GIVEN)
|
||||
self.assertEqual(len(params["messages"]), 1)
|
||||
self.assertEqual(params["messages"][0]["role"], "user")
|
||||
|
||||
|
||||
class TestBedrockAdapterSystemInstruction(unittest.TestCase):
|
||||
"""Tests for the AWS Bedrock adapter."""
|
||||
|
||||
def setUp(self):
|
||||
from pipecat.adapters.services.bedrock_adapter import AWSBedrockLLMAdapter
|
||||
|
||||
self.adapter = AWSBedrockLLMAdapter()
|
||||
|
||||
def test_system_instruction_only(self):
|
||||
"""system_instruction alone becomes the system parameter."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context, system_instruction="Be helpful.")
|
||||
|
||||
self.assertEqual(params["system"], [{"text": "Be helpful."}])
|
||||
|
||||
def test_initial_system_message_only(self):
|
||||
"""Initial system message is extracted as the system parameter."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
self.assertEqual(params["system"], [{"text": "You are helpful."}])
|
||||
self.assertEqual(len(params["messages"]), 1)
|
||||
|
||||
def test_initial_developer_message_promoted(self):
|
||||
"""Initial developer message without system_instruction is promoted."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "developer", "content": "Extra context."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
self.assertEqual(params["system"], [{"text": "Extra context."}])
|
||||
|
||||
def test_both_system_instruction_and_system_message_warns(self):
|
||||
"""system_instruction + initial system message warns and uses system_instruction."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
params = self.adapter.get_llm_invocation_params(
|
||||
context, system_instruction="Be concise."
|
||||
)
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
self.assertEqual(params["system"], [{"text": "Be concise."}])
|
||||
|
||||
def test_both_system_instruction_and_developer_message_no_warning(self):
|
||||
"""system_instruction + initial developer message: no warning, developer becomes user."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "developer", "content": "Extra context."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
params = self.adapter.get_llm_invocation_params(
|
||||
context, system_instruction="Be concise."
|
||||
)
|
||||
mock_logger.warning.assert_not_called()
|
||||
|
||||
self.assertEqual(params["system"], [{"text": "Be concise."}])
|
||||
self.assertEqual(params["messages"][0]["role"], "user")
|
||||
|
||||
def test_subsequent_developer_messages_converted_to_user(self):
|
||||
"""Subsequent developer messages are converted to user role."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi"},
|
||||
{"role": "developer", "content": "More instructions"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
self.assertEqual(params["messages"][2]["role"], "user")
|
||||
|
||||
def test_single_system_message_becomes_user(self):
|
||||
"""A lone system message is converted to user to prevent empty history."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
self.assertIsNone(params["system"])
|
||||
self.assertEqual(len(params["messages"]), 1)
|
||||
self.assertEqual(params["messages"][0]["role"], "user")
|
||||
|
||||
|
||||
class TestGeminiAdapterSystemInstruction(unittest.TestCase):
|
||||
"""Tests for the Gemini adapter."""
|
||||
|
||||
def setUp(self):
|
||||
from pipecat.adapters.services.gemini_adapter import GeminiLLMAdapter
|
||||
|
||||
self.adapter = GeminiLLMAdapter()
|
||||
|
||||
def test_system_instruction_only(self):
|
||||
"""system_instruction alone becomes the system_instruction parameter."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context, system_instruction="Be helpful.")
|
||||
|
||||
self.assertEqual(params["system_instruction"], "Be helpful.")
|
||||
|
||||
def test_initial_system_message_only(self):
|
||||
"""Initial system message is extracted as system_instruction."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
self.assertEqual(params["system_instruction"], "You are helpful.")
|
||||
self.assertEqual(len(params["messages"]), 1)
|
||||
|
||||
def test_initial_developer_message_promoted(self):
|
||||
"""Initial developer message without system_instruction is promoted."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "developer", "content": "Extra context."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
self.assertEqual(params["system_instruction"], "Extra context.")
|
||||
|
||||
def test_both_system_instruction_and_system_message_warns(self):
|
||||
"""system_instruction + initial system message warns and uses system_instruction."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
params = self.adapter.get_llm_invocation_params(
|
||||
context, system_instruction="Be concise."
|
||||
)
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
self.assertEqual(params["system_instruction"], "Be concise.")
|
||||
|
||||
def test_both_system_instruction_and_developer_message_no_warning(self):
|
||||
"""system_instruction + initial developer message: no warning, developer becomes user."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "developer", "content": "Extra context."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
params = self.adapter.get_llm_invocation_params(
|
||||
context, system_instruction="Be concise."
|
||||
)
|
||||
mock_logger.warning.assert_not_called()
|
||||
|
||||
self.assertEqual(params["system_instruction"], "Be concise.")
|
||||
|
||||
def test_non_initial_system_message_not_extracted(self):
|
||||
"""Non-initial system message is converted to user, not extracted as system instruction."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "system", "content": "Late system message"},
|
||||
{"role": "user", "content": "How are you?"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
# No system instruction should be extracted from non-initial position
|
||||
self.assertIsNone(params["system_instruction"])
|
||||
# The system message should have been converted to user role in the Gemini Content
|
||||
# (we check that 3 messages are present, meaning no extraction happened)
|
||||
self.assertEqual(len(params["messages"]), 3)
|
||||
|
||||
def test_subsequent_developer_messages_converted_to_user(self):
|
||||
"""Subsequent developer messages are converted to user role."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "developer", "content": "More instructions"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
self.assertEqual(len(params["messages"]), 2)
|
||||
# Second message (developer) should be converted to user in Google format
|
||||
self.assertEqual(params["messages"][1].role, "user")
|
||||
|
||||
|
||||
class TestBaseLLMAdapterHelpers(unittest.TestCase):
|
||||
"""Tests for the shared helper methods on BaseLLMAdapter."""
|
||||
|
||||
def setUp(self):
|
||||
# Use OpenAILLMAdapter as a concrete implementation for testing the base helpers
|
||||
self.adapter = OpenAILLMAdapter()
|
||||
|
||||
def test_extract_system_message(self):
|
||||
"""System message is extracted from messages[0]."""
|
||||
messages = [
|
||||
{"role": "system", "content": "Be helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
content, role = self.adapter._extract_initial_system_or_developer(
|
||||
messages, system_instruction=None
|
||||
)
|
||||
|
||||
self.assertEqual(content, "Be helpful.")
|
||||
self.assertEqual(role, "system")
|
||||
self.assertEqual(len(messages), 1) # popped
|
||||
|
||||
def test_extract_developer_without_system_instruction(self):
|
||||
"""Developer message is extracted when no system_instruction."""
|
||||
messages = [
|
||||
{"role": "developer", "content": "Context."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
content, role = self.adapter._extract_initial_system_or_developer(
|
||||
messages, system_instruction=None
|
||||
)
|
||||
|
||||
self.assertEqual(content, "Context.")
|
||||
self.assertEqual(role, "developer")
|
||||
self.assertEqual(len(messages), 1)
|
||||
|
||||
def test_developer_with_system_instruction_converts_to_user(self):
|
||||
"""Developer message with system_instruction is converted to user, not extracted."""
|
||||
messages = [
|
||||
{"role": "developer", "content": "Context."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
content, role = self.adapter._extract_initial_system_or_developer(
|
||||
messages, system_instruction="Be helpful."
|
||||
)
|
||||
|
||||
self.assertIsNone(content)
|
||||
self.assertIsNone(role)
|
||||
self.assertEqual(len(messages), 2) # not popped
|
||||
self.assertEqual(messages[0]["role"], "user") # converted to user
|
||||
|
||||
def test_single_system_message_becomes_user(self):
|
||||
"""Single system message is converted to user instead of extracting (empty prevention)."""
|
||||
messages = [
|
||||
{"role": "system", "content": "Be helpful."},
|
||||
]
|
||||
content, role = self.adapter._extract_initial_system_or_developer(
|
||||
messages, system_instruction=None
|
||||
)
|
||||
|
||||
self.assertIsNone(content)
|
||||
self.assertIsNone(role)
|
||||
self.assertEqual(len(messages), 1) # not popped
|
||||
self.assertEqual(messages[0]["role"], "user")
|
||||
|
||||
def test_non_system_message_ignored(self):
|
||||
"""Non-system/developer first message is ignored."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
content, role = self.adapter._extract_initial_system_or_developer(
|
||||
messages, system_instruction=None
|
||||
)
|
||||
|
||||
self.assertIsNone(content)
|
||||
self.assertIsNone(role)
|
||||
self.assertEqual(len(messages), 1)
|
||||
|
||||
def test_empty_messages(self):
|
||||
"""Empty messages list returns None."""
|
||||
messages = []
|
||||
content, role = self.adapter._extract_initial_system_or_developer(
|
||||
messages, system_instruction=None
|
||||
)
|
||||
|
||||
self.assertIsNone(content)
|
||||
self.assertIsNone(role)
|
||||
|
||||
def test_resolve_both_system_discard(self):
|
||||
"""Resolve with discard=True: system_instruction wins, warns."""
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
result = self.adapter._resolve_system_instruction(
|
||||
"from context", "system", "from settings", discard_context_system=True
|
||||
)
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
self.assertEqual(result, "from settings")
|
||||
|
||||
def test_resolve_both_system_keep(self):
|
||||
"""Resolve with discard=False: warns but returns system_instruction."""
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
result = self.adapter._resolve_system_instruction(
|
||||
"from context", "system", "from settings", discard_context_system=False
|
||||
)
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
self.assertEqual(result, "from settings")
|
||||
|
||||
def test_resolve_only_system_instruction(self):
|
||||
"""Only system_instruction: returns it, no warning."""
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
result = self.adapter._resolve_system_instruction(
|
||||
None, None, "from settings", discard_context_system=True
|
||||
)
|
||||
mock_logger.warning.assert_not_called()
|
||||
|
||||
self.assertEqual(result, "from settings")
|
||||
|
||||
def test_resolve_only_context_system_discard(self):
|
||||
"""Only context system (discard=True): returns it."""
|
||||
result = self.adapter._resolve_system_instruction(
|
||||
"from context", "system", None, discard_context_system=True
|
||||
)
|
||||
|
||||
self.assertEqual(result, "from context")
|
||||
|
||||
def test_resolve_only_context_system_keep(self):
|
||||
"""Only context system (discard=False): returns None (already in messages)."""
|
||||
result = self.adapter._resolve_system_instruction(
|
||||
"from context", "system", None, discard_context_system=False
|
||||
)
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -424,10 +424,11 @@ class TestGeminiGetLLMInvocationParams(unittest.TestCase):
|
||||
context = LLMContext(messages=messages)
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
# System instruction should be extracted
|
||||
self.assertEqual(params["system_instruction"], "You are a helpful assistant.")
|
||||
# When there's only one message, it's converted to user in-place (not extracted)
|
||||
# so system_instruction is None
|
||||
self.assertIsNone(params["system_instruction"])
|
||||
|
||||
# But since there are no other messages, it should also be added back as a user message
|
||||
# The system message should be converted to a user message
|
||||
self.assertEqual(len(params["messages"]), 1)
|
||||
self.assertEqual(params["messages"][0].role, "user")
|
||||
self.assertEqual(params["messages"][0].parts[0].text, "You are a helpful assistant.")
|
||||
@@ -973,7 +974,7 @@ class TestAWSBedrockGetLLMInvocationParams(unittest.TestCase):
|
||||
self.assertEqual(params["messages"][2]["content"][0]["text"], "Remember to be concise.")
|
||||
|
||||
def test_single_system_message_handling(self):
|
||||
"""Test that a single system message is extracted as system parameter and no messages remain."""
|
||||
"""Test that a single system message is converted to user role when no other messages exist."""
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
]
|
||||
@@ -984,13 +985,16 @@ class TestAWSBedrockGetLLMInvocationParams(unittest.TestCase):
|
||||
# Get invocation params
|
||||
params = self.adapter.get_llm_invocation_params(context)
|
||||
|
||||
# System should be extracted (in AWS Bedrock format)
|
||||
self.assertIsInstance(params["system"], list)
|
||||
self.assertEqual(len(params["system"]), 1)
|
||||
self.assertEqual(params["system"][0]["text"], "You are a helpful assistant.")
|
||||
# When there's only one message, it's converted to user in-place (not extracted)
|
||||
# so system is None
|
||||
self.assertIsNone(params["system"])
|
||||
|
||||
# No messages should remain after system extraction
|
||||
self.assertEqual(len(params["messages"]), 0)
|
||||
# Single system message should be converted to user role
|
||||
self.assertEqual(len(params["messages"]), 1)
|
||||
self.assertEqual(params["messages"][0]["role"], "user")
|
||||
self.assertEqual(
|
||||
params["messages"][0]["content"][0]["text"], "You are a helpful assistant."
|
||||
)
|
||||
|
||||
|
||||
class TestPerplexityGetLLMInvocationParams(unittest.TestCase):
|
||||
|
||||
@@ -58,9 +58,8 @@ class TestOpenAIResponsesAdapter(unittest.TestCase):
|
||||
self.assertEqual(params["input"][0]["role"], "developer")
|
||||
self.assertEqual(params["input"][0]["content"], "You are helpful.")
|
||||
|
||||
def test_first_system_message_triggers_warning(self):
|
||||
"""First system message triggers a warning about using system_instruction."""
|
||||
# Use a fresh adapter so the warning hasn't been emitted yet
|
||||
def test_system_message_without_system_instruction_no_warning(self):
|
||||
"""System message without system_instruction does not trigger a warning."""
|
||||
adapter = OpenAIResponsesLLMAdapter()
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
@@ -68,8 +67,21 @@ class TestOpenAIResponsesAdapter(unittest.TestCase):
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
|
||||
with patch("pipecat.adapters.services.open_ai_responses_adapter.logger") as mock_logger:
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
adapter.get_llm_invocation_params(context)
|
||||
mock_logger.warning.assert_not_called()
|
||||
|
||||
def test_system_message_with_system_instruction_triggers_warning(self):
|
||||
"""System message + system_instruction triggers a conflict warning."""
|
||||
adapter = OpenAIResponsesLLMAdapter()
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
context = LLMContext(messages=messages)
|
||||
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
adapter.get_llm_invocation_params(context, system_instruction="Be concise.")
|
||||
mock_logger.warning.assert_called_once()
|
||||
warning_msg = mock_logger.warning.call_args[0][0]
|
||||
self.assertIn("system_instruction", warning_msg)
|
||||
@@ -83,15 +95,15 @@ class TestOpenAIResponsesAdapter(unittest.TestCase):
|
||||
context = LLMContext(messages=messages)
|
||||
|
||||
adapter = OpenAIResponsesLLMAdapter()
|
||||
with patch("pipecat.adapters.services.open_ai_responses_adapter.logger") as mock_logger:
|
||||
params = adapter.get_llm_invocation_params(context)
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
params = adapter.get_llm_invocation_params(context, system_instruction="Be helpful.")
|
||||
mock_logger.warning.assert_not_called()
|
||||
|
||||
self.assertEqual(params["input"][1]["role"], "developer")
|
||||
self.assertEqual(params["input"][1]["content"], "New instruction")
|
||||
|
||||
def test_first_system_message_warning_fires_only_once(self):
|
||||
"""The first-system-message warning fires only once per adapter instance."""
|
||||
def test_conflict_warning_fires_only_once(self):
|
||||
"""The conflict warning fires only once per adapter instance."""
|
||||
messages: list[LLMStandardMessage] = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
@@ -99,9 +111,9 @@ class TestOpenAIResponsesAdapter(unittest.TestCase):
|
||||
context = LLMContext(messages=messages)
|
||||
|
||||
adapter = OpenAIResponsesLLMAdapter()
|
||||
with patch("pipecat.adapters.services.open_ai_responses_adapter.logger") as mock_logger:
|
||||
adapter.get_llm_invocation_params(context)
|
||||
adapter.get_llm_invocation_params(context)
|
||||
with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger:
|
||||
adapter.get_llm_invocation_params(context, system_instruction="Be concise.")
|
||||
adapter.get_llm_invocation_params(context, system_instruction="Be concise.")
|
||||
# Warning should have been emitted exactly once, not twice
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ async def test_openai_run_inference_with_llm_context():
|
||||
# Verify
|
||||
assert result == "Hello! How can I help you today?"
|
||||
service.get_llm_adapter.assert_called_once()
|
||||
mock_adapter.get_llm_invocation_params.assert_called_once_with(mock_context)
|
||||
mock_adapter.get_llm_invocation_params.assert_called_once_with(
|
||||
mock_context, system_instruction=None
|
||||
)
|
||||
service._client.chat.completions.create.assert_called_once_with(
|
||||
model="gpt-4",
|
||||
stream=False,
|
||||
@@ -187,7 +189,7 @@ async def test_anthropic_run_inference_with_llm_context():
|
||||
assert result == "Hello! How can I help you today?"
|
||||
service.get_llm_adapter.assert_called_once()
|
||||
mock_adapter.get_llm_invocation_params.assert_called_once_with(
|
||||
mock_context, enable_prompt_caching=False
|
||||
mock_context, enable_prompt_caching=False, system_instruction=None
|
||||
)
|
||||
service._client.beta.messages.create.assert_called_once_with(
|
||||
model="claude-3-sonnet-20240229",
|
||||
@@ -302,7 +304,9 @@ async def test_google_run_inference_with_llm_context():
|
||||
# Verify
|
||||
assert result == "Hello! How can I help you today?"
|
||||
service.get_llm_adapter.assert_called_once()
|
||||
mock_adapter.get_llm_invocation_params.assert_called_once_with(mock_context)
|
||||
mock_adapter.get_llm_invocation_params.assert_called_once_with(
|
||||
mock_context, system_instruction=None
|
||||
)
|
||||
service._client.aio.models.generate_content.assert_called_once()
|
||||
|
||||
|
||||
@@ -421,7 +425,9 @@ async def test_aws_bedrock_run_inference_with_llm_context():
|
||||
# Verify
|
||||
assert result == "Hello! How can I help you today?"
|
||||
service.get_llm_adapter.assert_called_once()
|
||||
mock_adapter.get_llm_invocation_params.assert_called_once_with(mock_context)
|
||||
mock_adapter.get_llm_invocation_params.assert_called_once_with(
|
||||
mock_context, system_instruction=None
|
||||
)
|
||||
|
||||
# Verify the call includes configured parameters
|
||||
call_kwargs = mock_client.converse.call_args.kwargs
|
||||
@@ -543,15 +549,10 @@ async def test_openai_run_inference_system_instruction_overrides_context():
|
||||
)
|
||||
|
||||
assert result == "Response"
|
||||
call_kwargs = service._client.chat.completions.create.call_args.kwargs
|
||||
messages = call_kwargs["messages"]
|
||||
# system_instruction should be prepended as the first message
|
||||
assert messages[0] == {"role": "system", "content": "New system instruction"}
|
||||
# Original system message should still be present
|
||||
assert messages[1] == {"role": "system", "content": "Original system message"}
|
||||
# User message should still be present
|
||||
assert messages[2] == {"role": "user", "content": "Hello"}
|
||||
assert len(messages) == 3
|
||||
# Verify the adapter was called with the correct system_instruction
|
||||
mock_adapter.get_llm_invocation_params.assert_called_once_with(
|
||||
mock_context, system_instruction="New system instruction"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -608,9 +609,12 @@ async def test_anthropic_run_inference_system_instruction_overrides_context():
|
||||
result = await service.run_inference(mock_context, system_instruction="New system instruction")
|
||||
|
||||
assert result == "Response"
|
||||
call_kwargs = service._client.beta.messages.create.call_args.kwargs
|
||||
assert call_kwargs["system"] == "New system instruction"
|
||||
assert call_kwargs["messages"] == test_messages
|
||||
# Verify the adapter was called with the correct system_instruction
|
||||
mock_adapter.get_llm_invocation_params.assert_called_once_with(
|
||||
mock_context,
|
||||
enable_prompt_caching=False,
|
||||
system_instruction="New system instruction",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -665,9 +669,10 @@ async def test_google_run_inference_system_instruction_overrides_context():
|
||||
result = await service.run_inference(mock_context, system_instruction="New system instruction")
|
||||
|
||||
assert result == "Response"
|
||||
call_kwargs = service._client.aio.models.generate_content.call_args.kwargs
|
||||
config = call_kwargs["config"]
|
||||
assert config.system_instruction == "New system instruction"
|
||||
# Verify the adapter was called with the correct system_instruction
|
||||
mock_adapter.get_llm_invocation_params.assert_called_once_with(
|
||||
mock_context, system_instruction="New system instruction"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -731,9 +736,10 @@ async def test_aws_bedrock_run_inference_system_instruction_overrides_context():
|
||||
)
|
||||
|
||||
assert result == "Response"
|
||||
call_kwargs = mock_client.converse.call_args.kwargs
|
||||
assert call_kwargs["system"] == [{"text": "New system instruction"}]
|
||||
assert call_kwargs["messages"] == test_messages
|
||||
# Verify the adapter was called with the correct system_instruction
|
||||
mock_adapter.get_llm_invocation_params.assert_called_once_with(
|
||||
mock_context, system_instruction="New system instruction"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user