Add unit tests for OpenAILLMAdapter.get_llm_invocation_params(), focusing on messages specifically. Also, fix a bug in OpenAILLMAdapter (found thanks to the unit tests) where it wasn't "unwrapping" LLMSpecificMessages.

This commit is contained in:
Paul Kompfner
2025-09-15 11:16:36 -04:00
parent d8cd28bb8b
commit 42886d7105
2 changed files with 194 additions and 2 deletions

View File

@@ -24,6 +24,7 @@ from pipecat.processors.aggregators.llm_context import (
LLMContext,
LLMContextMessage,
LLMContextToolChoice,
LLMSpecificMessage,
NotGiven,
)
@@ -110,8 +111,15 @@ class OpenAILLMAdapter(BaseLLMAdapter[OpenAILLMInvocationParams]):
def _from_universal_context_messages(
self, messages: List[LLMContextMessage]
) -> List[ChatCompletionMessageParam]:
# Just a pass-through: messages are already the right type
return messages
result = []
for message in messages:
if isinstance(message, LLMSpecificMessage):
# Extract the actual message content from LLMSpecificMessage
result.append(message.message)
else:
# Standard message, pass through unchanged
result.append(message)
return result
def _from_standard_tool_choice(
self, tool_choice: LLMContextToolChoice | NotGiven

View File

@@ -0,0 +1,184 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""
Unit tests for OpenAI adapter's get_llm_invocation_params() method.
These tests focus specifically on the "messages" field generation, ensuring:
1. LLMStandardMessage objects are passed through unchanged
2. LLMSpecificMessage objects with llm='openai' are included and their content extracted
3. LLMSpecificMessage objects with llm != 'openai' are filtered out
4. Complex message structures (like multi-part content) are preserved
5. Edge cases like empty message lists are handled correctly
"""
import unittest
from openai.types.chat import ChatCompletionMessage
from pipecat.adapters.services.open_ai_adapter import OpenAILLMAdapter
from pipecat.processors.aggregators.llm_context import (
LLMContext,
LLMSpecificMessage,
LLMStandardMessage,
)
class TestOpenAIGetLLMInvocationParams(unittest.TestCase):
def setUp(self) -> None:
"""Sets up a common adapter instance for all tests."""
self.adapter = OpenAILLMAdapter()
def test_standard_messages_passed_through_unchanged(self):
"""Test that LLMStandardMessage objects are passed through unchanged to OpenAI params."""
# Create standard messages (OpenAI format)
standard_messages: list[LLMStandardMessage] = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello, how are you?"},
{"role": "assistant", "content": "I'm doing well, thank you for asking!"},
]
# Create context with these messages
context = LLMContext(messages=standard_messages)
# Get invocation params
params = self.adapter.get_llm_invocation_params(context)
# Verify messages are passed through unchanged
self.assertEqual(params["messages"], standard_messages)
self.assertEqual(len(params["messages"]), 3)
# Verify content matches exactly
self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.")
self.assertEqual(params["messages"][1]["content"], "Hello, how are you?")
self.assertEqual(params["messages"][2]["content"], "I'm doing well, thank you for asking!")
def test_openai_specific_messages_included(self):
"""Test that LLMSpecificMessage objects with llm='openai' are included."""
# Create a mix of standard and OpenAI-specific messages
messages = [
{"role": "system", "content": "You are a helpful assistant."},
LLMSpecificMessage(
llm="openai", message={"role": "user", "content": "OpenAI specific message"}
),
{"role": "assistant", "content": "Standard response"},
]
# Create context with these messages
context = LLMContext(messages=messages)
# Get invocation params
params = self.adapter.get_llm_invocation_params(context)
# Verify all messages are included (OpenAI-specific should be included)
self.assertEqual(len(params["messages"]), 3)
# First message should be standard
self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.")
# Second message should be the OpenAI-specific one
self.assertEqual(
params["messages"][1], {"role": "user", "content": "OpenAI specific message"}
)
# Third message should be standard
self.assertEqual(params["messages"][2]["content"], "Standard response")
def test_non_openai_specific_messages_filtered_out(self):
"""Test that LLMSpecificMessage objects with llm != 'openai' are filtered out."""
# Create messages with different LLM-specific ones
messages = [
{"role": "system", "content": "You are a helpful assistant."},
LLMSpecificMessage(
llm="anthropic", message={"role": "user", "content": "Anthropic specific message"}
),
LLMSpecificMessage(
llm="gemini", message={"role": "user", "content": "Gemini specific message"}
),
{"role": "user", "content": "Standard user message"},
LLMSpecificMessage(
llm="openai", message={"role": "assistant", "content": "OpenAI specific response"}
),
]
# Create context with these messages
context = LLMContext(messages=messages)
# Get invocation params
params = self.adapter.get_llm_invocation_params(context)
# Should only include standard messages and OpenAI-specific ones
# (3 total: system, standard user, openai assistant)
self.assertEqual(len(params["messages"]), 3)
# Verify the correct messages are included
self.assertEqual(params["messages"][0]["content"], "You are a helpful assistant.")
self.assertEqual(params["messages"][1]["content"], "Standard user message")
self.assertEqual(
params["messages"][2], {"role": "assistant", "content": "OpenAI specific response"}
)
def test_complex_message_content_preserved(self):
"""Test that complex message content (like multi-part messages) is preserved."""
# Create a message with complex content structure (text + image)
complex_image_message = {
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD..."},
},
],
}
# Create a message with multiple text blocks
multi_text_message = {
"role": "assistant",
"content": [
{"type": "text", "text": "Let me analyze this step by step:"},
{"type": "text", "text": "1. First, I'll examine the visual elements"},
{"type": "text", "text": "2. Then I'll provide my conclusions"},
],
}
messages = [
{"role": "system", "content": "You are a helpful assistant that can analyze images."},
complex_image_message,
multi_text_message,
]
# Create context with these messages
context = LLMContext(messages=messages)
# Get invocation params
params = self.adapter.get_llm_invocation_params(context)
# Verify complex content is preserved
self.assertEqual(len(params["messages"]), 3)
self.assertEqual(params["messages"][1], complex_image_message)
self.assertEqual(params["messages"][2], multi_text_message)
# Verify the image message structure is maintained
image_content = params["messages"][1]["content"]
self.assertIsInstance(image_content, list)
self.assertEqual(len(image_content), 2)
self.assertEqual(image_content[0]["type"], "text")
self.assertEqual(image_content[1]["type"], "image_url")
# Verify the multi-text message structure is maintained
text_content = params["messages"][2]["content"]
self.assertIsInstance(text_content, list)
self.assertEqual(len(text_content), 3)
for i, text_block in enumerate(text_content):
self.assertEqual(text_block["type"], "text")
self.assertEqual(text_content[0]["text"], "Let me analyze this step by step:")
self.assertEqual(text_content[1]["text"], "1. First, I'll examine the visual elements")
self.assertEqual(text_content[2]["text"], "2. Then I'll provide my conclusions")
if __name__ == "__main__":
unittest.main()