Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions tests/entrypoints/openai/test_response_api_with_harmony.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,11 +535,17 @@ def get_place_to_travel():
return "Paris"


def get_horoscope(sign):
return f"{sign}: Next Tuesday you will befriend a baby otter."


def call_function(name, args):
if name == "get_weather":
return get_weather(**args)
elif name == "get_place_to_travel":
return get_place_to_travel()
elif name == "get_horoscope":
return get_horoscope(**args)
else:
raise ValueError(f"Unknown function: {name}")

Expand Down Expand Up @@ -828,3 +834,103 @@ async def test_output_messages_enabled(client: OpenAI, model_name: str, server):
assert response.status == "completed"
assert len(response.input_messages) > 0
assert len(response.output_messages) > 0


@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_function_call_with_previous_input_messages(
client: OpenAI, model_name: str
):
"""Test function calling using previous_input_messages
for multi-turn conversation with a function call"""

# Define the get_horoscope tool
tools = [
{
"type": "function",
"name": "get_horoscope",
"description": "Get today's horoscope for an astrological sign.",
"parameters": {
"type": "object",
"properties": {
"sign": {"type": "string"},
},
"required": ["sign"],
"additionalProperties": False,
},
"strict": True,
}
]

# Step 1: First call with the function tool
stream_response = await client.responses.create(
model=model_name,
input="What is the horoscope for Aquarius today?",
tools=tools,
extra_body={"enable_response_messages": True},
stream=True,
)

response = None
async for event in stream_response:
if event.type == "response.completed":
response = event.response

assert response is not None
assert response.status == "completed"

# Step 2: Parse the first output to find the function_call type
function_call = None
for item in response.output:
if item.type == "function_call":
function_call = item
break

assert function_call is not None, "Expected a function_call in the output"
assert function_call.name == "get_horoscope"
assert function_call.call_id is not None

# Verify the format matches expectations
args = json.loads(function_call.arguments)
assert "sign" in args

# Step 3: Call the get_horoscope function
result = call_function(function_call.name, args)
assert "Aquarius" in result
assert "baby otter" in result

# Get the input_messages and output_messages from the first response
first_input_messages = response.input_messages
first_output_messages = response.output_messages

# Construct the full conversation history using previous_input_messages
previous_messages = (
first_input_messages
+ first_output_messages
+ [
{
"role": "tool",
"name": "functions.get_horoscope",
"content": [{"type": "text", "text": str(result)}],
"content_type": "<|constrain|>json",
}
]
)

# Step 4: Make another responses.create() call with previous_input_messages
response_2 = await client.responses.create(
model=model_name,
tools=tools,
input="",
extra_body={"previous_input_messages": previous_messages},
)

assert response_2 is not None
assert response_2.status == "completed"
assert response_2.output_text is not None

# Verify the output makes sense - should contain information about the horoscope
output_text = response_2.output_text.lower()
assert (
"aquarius" in output_text or "otter" in output_text or "tuesday" in output_text
)
254 changes: 254 additions & 0 deletions tests/entrypoints/test_harmony_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project

from openai_harmony import Role

from vllm.entrypoints.harmony_utils import parse_input_to_harmony_message


class TestParseInputToHarmonyMessage:
"""Tests for parse_input_to_harmony_message function."""

def test_assistant_message_with_tool_calls(self):
"""Test parsing assistant message with tool calls."""
chat_msg = {
"role": "assistant",
"tool_calls": [
{
"function": {
"name": "get_weather",
"arguments": '{"location": "San Francisco"}',
}
},
{
"function": {
"name": "search_web",
"arguments": '{"query": "latest news"}',
}
},
],
}

messages = parse_input_to_harmony_message(chat_msg)

assert len(messages) == 2

# First tool call
assert messages[0].author.role == Role.ASSISTANT
assert messages[0].content[0].text == '{"location": "San Francisco"}'
assert messages[0].channel == "commentary"
assert messages[0].recipient == "functions.get_weather"
assert messages[0].content_type == "json"

# Second tool call
assert messages[1].author.role == Role.ASSISTANT
assert messages[1].content[0].text == '{"query": "latest news"}'
assert messages[1].channel == "commentary"
assert messages[1].recipient == "functions.search_web"
assert messages[1].content_type == "json"

def test_assistant_message_with_empty_tool_call_arguments(self):
"""Test parsing assistant message with tool call having None arguments."""
chat_msg = {
"role": "assistant",
"tool_calls": [
{
"function": {
"name": "get_current_time",
"arguments": None,
}
}
],
}

messages = parse_input_to_harmony_message(chat_msg)

assert len(messages) == 1
assert messages[0].content[0].text == ""
assert messages[0].recipient == "functions.get_current_time"

def test_tool_message_with_string_content(self):
"""Test parsing tool message with string content."""
chat_msg = {
"role": "tool",
"name": "get_weather",
"content": "The weather in San Francisco is sunny, 72°F",
}

messages = parse_input_to_harmony_message(chat_msg)

assert len(messages) == 1
assert messages[0].author.role == Role.TOOL
assert messages[0].author.name == "functions.get_weather"
assert (
messages[0].content[0].text == "The weather in San Francisco is sunny, 72°F"
)
assert messages[0].channel == "commentary"

def test_tool_message_with_array_content(self):
"""Test parsing tool message with array content."""
chat_msg = {
"role": "tool",
"name": "search_results",
"content": [
{"type": "text", "text": "Result 1: "},
{"type": "text", "text": "Result 2: "},
{
"type": "image",
"url": "http://example.com/img.png",
}, # Should be ignored
{"type": "text", "text": "Result 3"},
],
}

messages = parse_input_to_harmony_message(chat_msg)

assert len(messages) == 1
assert messages[0].author.role == Role.TOOL
assert messages[0].content[0].text == "Result 1: Result 2: Result 3"

def test_tool_message_with_empty_content(self):
"""Test parsing tool message with None content."""
chat_msg = {
"role": "tool",
"name": "empty_tool",
"content": None,
}

messages = parse_input_to_harmony_message(chat_msg)

assert len(messages) == 1
assert messages[0].content[0].text == ""

def test_system_message(self):
"""Test parsing system message."""
chat_msg = {
"role": "system",
"content": "You are a helpful assistant",
}

messages = parse_input_to_harmony_message(chat_msg)

assert len(messages) == 1
# System messages are converted using Message.from_dict
# which should preserve the role
assert messages[0].author.role == Role.SYSTEM

def test_developer_message(self):
"""Test parsing developer message."""
chat_msg = {
"role": "developer",
"content": "Use concise language",
}

messages = parse_input_to_harmony_message(chat_msg)

assert len(messages) == 1
assert messages[0].author.role == Role.DEVELOPER

def test_user_message_with_string_content(self):
"""Test parsing user message with string content."""
chat_msg = {
"role": "user",
"content": "What's the weather in San Francisco?",
}

messages = parse_input_to_harmony_message(chat_msg)

assert len(messages) == 1
assert messages[0].author.role == Role.USER
assert messages[0].content[0].text == "What's the weather in San Francisco?"

def test_user_message_with_array_content(self):
"""Test parsing user message with array content."""
chat_msg = {
"role": "user",
"content": [
{"text": "What's in this image? "},
{"text": "Please describe it."},
],
}

messages = parse_input_to_harmony_message(chat_msg)

assert len(messages) == 1
assert messages[0].author.role == Role.USER
assert len(messages[0].content) == 2
assert messages[0].content[0].text == "What's in this image? "
assert messages[0].content[1].text == "Please describe it."

def test_assistant_message_with_string_content(self):
"""Test parsing assistant message with string content (no tool calls)."""
chat_msg = {
"role": "assistant",
"content": "Hello! How can I help you today?",
}

messages = parse_input_to_harmony_message(chat_msg)

assert len(messages) == 1
assert messages[0].author.role == Role.ASSISTANT
assert messages[0].content[0].text == "Hello! How can I help you today?"

def test_pydantic_model_input(self):
"""Test parsing Pydantic model input (has model_dump method)."""

class MockPydanticModel:
def model_dump(self, exclude_none=True):
return {
"role": "user",
"content": "Test message",
}

chat_msg = MockPydanticModel()
messages = parse_input_to_harmony_message(chat_msg)

assert len(messages) == 1
assert messages[0].author.role == Role.USER
assert messages[0].content[0].text == "Test message"

def test_message_with_empty_content(self):
"""Test parsing message with empty string content."""
chat_msg = {
"role": "user",
"content": "",
}

messages = parse_input_to_harmony_message(chat_msg)

assert len(messages) == 1
assert messages[0].content[0].text == ""

def test_tool_call_with_missing_function_fields(self):
"""Test parsing tool call with missing name or arguments."""
chat_msg = {
"role": "assistant",
"tool_calls": [
{
"function": {} # Missing both name and arguments
}
],
}

messages = parse_input_to_harmony_message(chat_msg)

assert len(messages) == 1
assert messages[0].recipient == "functions."
assert messages[0].content[0].text == ""

def test_array_content_with_missing_text(self):
"""Test parsing array content where text field is missing."""
chat_msg = {
"role": "user",
"content": [
{}, # Missing text field
{"text": "actual text"},
],
}

messages = parse_input_to_harmony_message(chat_msg)

assert len(messages) == 1
assert len(messages[0].content) == 2
assert messages[0].content[0].text == ""
assert messages[0].content[1].text == "actual text"
6 changes: 5 additions & 1 deletion vllm/entrypoints/harmony_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ def parse_response_input(
return msg


def parse_chat_input(chat_msg) -> list[Message]:
def parse_input_to_harmony_message(chat_msg) -> list[Message]:
if not isinstance(chat_msg, dict):
# Handle Pydantic models
chat_msg = chat_msg.model_dump(exclude_none=True)
Expand Down Expand Up @@ -267,6 +267,10 @@ def parse_chat_input(chat_msg) -> list[Message]:
Author.new(Role.TOOL, f"functions.{name}"), content
).with_channel("commentary")
return [msg]
# system or developer message
if role == "system" or role == "developer":
msg = Message.from_dict(chat_msg)
return [msg]

# Default: user/assistant/system messages with content
content = chat_msg.get("content", "")
Expand Down
Loading