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
|
||||
|
||||
Reference in New Issue
Block a user