Merge pull request #4272 from pipecat-ai/pk/llm-context-get-messages-elide-large-values
Add truncate_large_values to LLMContext.get_messages()
This commit is contained in:
@@ -125,16 +125,22 @@ class BaseLLMAdapter(ABC, Generic[TLLMInvocationParams]):
|
||||
"""
|
||||
return LLMSpecificMessage(llm=self.id_for_llm_specific_messages, message=message)
|
||||
|
||||
def get_messages(self, context: LLMContext) -> List[LLMContextMessage]:
|
||||
def get_messages(
|
||||
self, context: LLMContext, *, truncate_large_values: bool = False
|
||||
) -> List[LLMContextMessage]:
|
||||
"""Get messages from the LLM context, including standard and LLM-specific messages.
|
||||
|
||||
Args:
|
||||
context: The LLM context containing messages.
|
||||
truncate_large_values: If True, return deep copies of messages with
|
||||
large values replaced by short placeholders.
|
||||
|
||||
Returns:
|
||||
List of messages including standard and LLM-specific messages.
|
||||
"""
|
||||
return context.get_messages(self.id_for_llm_specific_messages)
|
||||
return context.get_messages(
|
||||
self.id_for_llm_specific_messages, truncate_large_values=truncate_large_values
|
||||
)
|
||||
|
||||
def from_standard_tools(self, tools: Any) -> List[Any] | NotGiven:
|
||||
"""Convert tools from standard format to provider format.
|
||||
|
||||
@@ -77,7 +77,7 @@ class GrokRealtimeLLMAdapter(BaseLLMAdapter):
|
||||
def get_messages_for_logging(self, context) -> List[Dict[str, Any]]:
|
||||
"""Get messages from context in a format safe for logging.
|
||||
|
||||
Removes or truncates sensitive data like audio content.
|
||||
Binary data (images, audio) is replaced with short placeholders.
|
||||
|
||||
Args:
|
||||
context: The LLM context containing messages.
|
||||
@@ -85,18 +85,7 @@ class GrokRealtimeLLMAdapter(BaseLLMAdapter):
|
||||
Returns:
|
||||
List of messages with sensitive data redacted.
|
||||
"""
|
||||
msgs = []
|
||||
for message in self.get_messages(context):
|
||||
msg = copy.deepcopy(message)
|
||||
if "content" in msg:
|
||||
if isinstance(msg["content"], list):
|
||||
for item in msg["content"]:
|
||||
if item.get("type") == "input_audio":
|
||||
item["audio"] = "..."
|
||||
if item.get("type") == "audio":
|
||||
item["audio"] = "..."
|
||||
msgs.append(msg)
|
||||
return msgs
|
||||
return self.get_messages(context, truncate_large_values=True)
|
||||
|
||||
@dataclass
|
||||
class ConvertedMessages:
|
||||
|
||||
@@ -77,7 +77,7 @@ class InworldRealtimeLLMAdapter(BaseLLMAdapter):
|
||||
def get_messages_for_logging(self, context) -> List[Dict[str, Any]]:
|
||||
"""Get messages from context in a format safe for logging.
|
||||
|
||||
Removes or truncates sensitive data like audio content.
|
||||
Binary data (images, audio) is replaced with short placeholders.
|
||||
|
||||
Args:
|
||||
context: The LLM context containing messages.
|
||||
@@ -85,18 +85,7 @@ class InworldRealtimeLLMAdapter(BaseLLMAdapter):
|
||||
Returns:
|
||||
List of messages with sensitive data redacted.
|
||||
"""
|
||||
msgs = []
|
||||
for message in self.get_messages(context):
|
||||
msg = copy.deepcopy(message)
|
||||
if "content" in msg:
|
||||
if isinstance(msg["content"], list):
|
||||
for item in msg["content"]:
|
||||
if item.get("type") == "input_audio":
|
||||
item["audio"] = "..."
|
||||
if item.get("type") == "audio":
|
||||
item["audio"] = "..."
|
||||
msgs.append(msg)
|
||||
return msgs
|
||||
return self.get_messages(context, truncate_large_values=True)
|
||||
|
||||
@dataclass
|
||||
class ConvertedMessages:
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
"""OpenAI LLM adapter for Pipecat."""
|
||||
|
||||
import copy
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
|
||||
from openai._types import NotGiven as OpenAINotGiven
|
||||
@@ -119,7 +118,7 @@ class OpenAILLMAdapter(BaseLLMAdapter[OpenAILLMInvocationParams]):
|
||||
def get_messages_for_logging(self, context: LLMContext) -> List[Dict[str, Any]]:
|
||||
"""Get messages from a universal LLM context in a format ready for logging about OpenAI.
|
||||
|
||||
Removes or truncates sensitive data like image content for safe logging.
|
||||
Binary data (images, audio) is replaced with short placeholders.
|
||||
|
||||
Args:
|
||||
context: The LLM context containing messages.
|
||||
@@ -127,21 +126,7 @@ class OpenAILLMAdapter(BaseLLMAdapter[OpenAILLMInvocationParams]):
|
||||
Returns:
|
||||
List of messages in a format ready for logging about OpenAI.
|
||||
"""
|
||||
msgs = []
|
||||
for message in self.get_messages(context):
|
||||
msg = copy.deepcopy(message)
|
||||
if "content" in msg:
|
||||
if isinstance(msg["content"], list):
|
||||
for item in msg["content"]:
|
||||
if item["type"] == "image_url":
|
||||
if item["image_url"]["url"].startswith("data:image/"):
|
||||
item["image_url"]["url"] = "data:image/..."
|
||||
if item["type"] == "input_audio":
|
||||
item["input_audio"]["data"] = "..."
|
||||
if "mime_type" in msg and msg["mime_type"].startswith("image/"):
|
||||
msg["data"] = "..."
|
||||
msgs.append(msg)
|
||||
return msgs
|
||||
return self.get_messages(context, truncate_large_values=True)
|
||||
|
||||
def _from_universal_context_messages(
|
||||
self,
|
||||
|
||||
@@ -71,7 +71,7 @@ class OpenAIRealtimeLLMAdapter(BaseLLMAdapter):
|
||||
def get_messages_for_logging(self, context) -> List[Dict[str, Any]]:
|
||||
"""Get messages from a universal LLM context in a format ready for logging about OpenAI Realtime.
|
||||
|
||||
Removes or truncates sensitive data like image content for safe logging.
|
||||
Binary data (images, audio) is replaced with short placeholders.
|
||||
|
||||
This is a placeholder until support for universal LLMContext machinery is added for OpenAI Realtime.
|
||||
|
||||
@@ -81,25 +81,7 @@ class OpenAIRealtimeLLMAdapter(BaseLLMAdapter):
|
||||
Returns:
|
||||
List of messages in a format ready for logging about OpenAI Realtime.
|
||||
"""
|
||||
# NOTE: this is the same as in OpenAIAdapter, as that's what it was
|
||||
# prior to a refactor. Worth noting that for OpenAI Realtime
|
||||
# specifically, not everything handled here is necessarily supported
|
||||
# (or supported yet).
|
||||
msgs = []
|
||||
for message in self.get_messages(context):
|
||||
msg = copy.deepcopy(message)
|
||||
if "content" in msg:
|
||||
if isinstance(msg["content"], list):
|
||||
for item in msg["content"]:
|
||||
if item["type"] == "image_url":
|
||||
if item["image_url"]["url"].startswith("data:image/"):
|
||||
item["image_url"]["url"] = "data:image/..."
|
||||
if item["type"] == "input_audio":
|
||||
item["input_audio"]["data"] = "..."
|
||||
if "mime_type" in msg and msg["mime_type"].startswith("image/"):
|
||||
msg["data"] = "..."
|
||||
msgs.append(msg)
|
||||
return msgs
|
||||
return self.get_messages(context, truncate_large_values=True)
|
||||
|
||||
@dataclass
|
||||
class ConvertedMessages:
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
"""OpenAI Responses API adapter for Pipecat."""
|
||||
|
||||
import copy
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
|
||||
from openai._types import NotGiven as OpenAINotGiven
|
||||
@@ -136,7 +135,7 @@ class OpenAIResponsesLLMAdapter(BaseLLMAdapter[OpenAIResponsesLLMInvocationParam
|
||||
def get_messages_for_logging(self, context: LLMContext) -> List[Dict[str, Any]]:
|
||||
"""Get messages from context in a format ready for logging.
|
||||
|
||||
Removes or truncates sensitive data like image content for safe logging.
|
||||
Binary data (images, audio) is replaced with short placeholders.
|
||||
|
||||
Args:
|
||||
context: The LLM context containing messages.
|
||||
@@ -144,19 +143,7 @@ class OpenAIResponsesLLMAdapter(BaseLLMAdapter[OpenAIResponsesLLMInvocationParam
|
||||
Returns:
|
||||
List of messages in a format ready for logging.
|
||||
"""
|
||||
msgs = []
|
||||
for message in self.get_messages(context):
|
||||
msg = copy.deepcopy(message)
|
||||
if "content" in msg:
|
||||
if isinstance(msg["content"], list):
|
||||
for item in msg["content"]:
|
||||
if item.get("type") == "image_url":
|
||||
if item["image_url"]["url"].startswith("data:image/"):
|
||||
item["image_url"]["url"] = "data:image/..."
|
||||
if item.get("type") == "input_audio":
|
||||
item["input_audio"]["data"] = "..."
|
||||
msgs.append(msg)
|
||||
return msgs
|
||||
return self.get_messages(context, truncate_large_values=True)
|
||||
|
||||
def _convert_messages_to_input(
|
||||
self, messages: List[LLMContextMessage]
|
||||
|
||||
@@ -16,6 +16,7 @@ service-specific adapter.
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import copy
|
||||
import io
|
||||
import wave
|
||||
from dataclasses import dataclass
|
||||
@@ -198,7 +199,12 @@ class LLMContext:
|
||||
"""
|
||||
return self.get_messages()
|
||||
|
||||
def get_messages(self, llm_specific_filter: Optional[str] = None) -> List[LLMContextMessage]:
|
||||
def get_messages(
|
||||
self,
|
||||
llm_specific_filter: Optional[str] = None,
|
||||
*,
|
||||
truncate_large_values: bool = False,
|
||||
) -> List[LLMContextMessage]:
|
||||
"""Get the current messages list.
|
||||
|
||||
Args:
|
||||
@@ -207,22 +213,110 @@ class LLMContext:
|
||||
messages. If messages end up being filtered, an error will be
|
||||
logged; this is intended to catch accidental use of
|
||||
incompatible LLM-specific messages.
|
||||
truncate_large_values: If True, return deep copies of messages with
|
||||
large values shortened. For standard messages, known binary
|
||||
data (base64-encoded images, audio) is replaced with short
|
||||
placeholders. For LLM-specific messages, long string values
|
||||
are truncated.
|
||||
|
||||
Returns:
|
||||
List of conversation messages.
|
||||
"""
|
||||
if llm_specific_filter is None:
|
||||
return self._messages
|
||||
filtered_messages = [
|
||||
msg
|
||||
for msg in self._messages
|
||||
if not isinstance(msg, LLMSpecificMessage) or msg.llm == llm_specific_filter
|
||||
]
|
||||
if len(filtered_messages) < len(self._messages):
|
||||
logger.error(
|
||||
f"Attempted to use incompatible LLMSpecificMessages with LLM '{llm_specific_filter}'."
|
||||
)
|
||||
return filtered_messages
|
||||
messages = self._messages
|
||||
else:
|
||||
messages = [
|
||||
msg
|
||||
for msg in self._messages
|
||||
if not isinstance(msg, LLMSpecificMessage) or msg.llm == llm_specific_filter
|
||||
]
|
||||
if len(messages) < len(self._messages):
|
||||
logger.error(
|
||||
f"Attempted to use incompatible LLMSpecificMessages with LLM '{llm_specific_filter}'."
|
||||
)
|
||||
|
||||
if truncate_large_values:
|
||||
messages = LLMContext._truncate_large_values_from_messages(messages)
|
||||
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
def _truncate_large_values_from_messages(
|
||||
messages: List[LLMContextMessage],
|
||||
) -> List[LLMContextMessage]:
|
||||
"""Return deep copies of messages with large values replaced by placeholders.
|
||||
|
||||
For standard (universal-format) messages, the following known binary
|
||||
patterns are replaced with short placeholders:
|
||||
|
||||
- ``image_url`` items with ``data:image/...`` base64 URLs
|
||||
- ``input_audio`` items with ``input_audio.data`` or ``audio`` fields
|
||||
- ``audio`` items with an ``audio`` field
|
||||
- Top-level messages with a ``mime_type`` starting with ``image/``
|
||||
|
||||
For ``LLMSpecificMessage`` instances, long string values are truncated
|
||||
since the internal structure is provider-specific.
|
||||
"""
|
||||
result = []
|
||||
for message in messages:
|
||||
if isinstance(message, LLMSpecificMessage):
|
||||
msg_copy = copy.deepcopy(message)
|
||||
msg_copy.message = LLMContext._truncate_long_strings(msg_copy.message)
|
||||
result.append(msg_copy)
|
||||
continue
|
||||
|
||||
msg = copy.deepcopy(message)
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
item_type = item.get("type")
|
||||
if item_type == "image_url":
|
||||
url = item.get("image_url", {}).get("url", "")
|
||||
if url.startswith("data:image/"):
|
||||
item["image_url"]["url"] = "data:image/..."
|
||||
elif item_type == "input_audio":
|
||||
if "input_audio" in item:
|
||||
item["input_audio"]["data"] = "..."
|
||||
if "audio" in item:
|
||||
item["audio"] = "..."
|
||||
elif item_type == "audio":
|
||||
if "audio" in item:
|
||||
item["audio"] = "..."
|
||||
|
||||
if msg.get("mime_type", "").startswith("image/"):
|
||||
msg["data"] = "..."
|
||||
|
||||
result.append(msg)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _truncate_long_strings(value: Any, *, max_length: int = 100) -> Any:
|
||||
"""Recursively truncate long strings in a nested structure.
|
||||
|
||||
Preserves the structure of dicts and lists while truncating any string
|
||||
values that exceed ``max_length``.
|
||||
|
||||
Args:
|
||||
value: The value to process (dict, list, str, or other).
|
||||
max_length: Strings longer than this are truncated.
|
||||
|
||||
Returns:
|
||||
A copy of the structure with long strings truncated.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
if len(value) > max_length:
|
||||
return f"{value[:max_length]}...({len(value)} chars)"
|
||||
return value
|
||||
elif isinstance(value, dict):
|
||||
return {
|
||||
k: LLMContext._truncate_long_strings(v, max_length=max_length)
|
||||
for k, v in value.items()
|
||||
}
|
||||
elif isinstance(value, list):
|
||||
return [
|
||||
LLMContext._truncate_long_strings(item, max_length=max_length) for item in value
|
||||
]
|
||||
return value
|
||||
|
||||
@property
|
||||
def tools(self) -> ToolsSchema | NotGiven:
|
||||
|
||||
Reference in New Issue
Block a user