Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
94 changes: 90 additions & 4 deletions tests/entrypoints/openai/responses/test_harmony.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import pytest
import pytest_asyncio
import requests
from openai import InternalServerError, NotFoundError, OpenAI
from openai import BadRequestError, NotFoundError, OpenAI
from openai_harmony import Message

from tests.utils import RemoteOpenAIServer
Expand Down Expand Up @@ -699,11 +699,97 @@ async def test_function_calling_multi_turn(client: OpenAI, model_name: str):
async def test_function_calling_required(client: OpenAI, model_name: str):
tools = [GET_WEATHER_SCHEMA]

with pytest.raises(InternalServerError):
response = await client.responses.create(
model=model_name,
input="What's the weather like in Paris today?",
tools=tools,
tool_choice="required",
)

assert response.status == "completed"
assert has_output_type(response, "function_call"), (
f"Expected function_call in output, got: {[o.type for o in response.output]}"
)
assert not has_output_type(response, "message"), (
"Required tool-choice JSON should not be returned as output_text."
)
tool_call = next(o for o in response.output if o.type == "function_call")
assert tool_call.name == "get_weather"
args = json.loads(tool_call.arguments)
assert "latitude" in args
assert "longitude" in args


@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_function_calling_required_with_stream(
pairs_of_event_types: dict[str, str],
client: OpenAI,
model_name: str,
):
tools = [GET_WEATHER_SCHEMA]

stream = await client.responses.create(
model=model_name,
input="What's the weather like in Paris today?",
tools=tools,
tool_choice="required",
stream=True,
)

function_call_item = None
completed_event = None
completed_response = None
events = []
text_deltas = []
async for event in stream:
events.append(event)
if (
event.type == "response.output_item.added"
and event.item.type == "function_call"
):
function_call_item = event.item
elif event.type == "response.output_text.delta":
text_deltas.append(event.delta)
elif (
event.type == "response.function_call_arguments.delta"
and function_call_item is not None
):
function_call_item.arguments += event.delta
elif (
event.type == "response.output_item.done"
and event.item.type == "function_call"
):
completed_event = event
elif event.type == "response.completed":
completed_response = event.response

validate_streaming_event_stack(events, pairs_of_event_types)
assert function_call_item is not None
assert function_call_item.type == "function_call"
assert function_call_item.name == "get_weather"
assert completed_event is not None
assert function_call_item.arguments == completed_event.item.arguments
assert completed_response is not None
assert has_output_type(completed_response, "function_call")
assert not has_output_type(completed_response, "message")
args = json.loads(function_call_item.arguments)
assert "latitude" in args
assert "longitude" in args
assert "".join(text_deltas).strip() == ""


@pytest.mark.asyncio
@pytest.mark.parametrize("model_name", [MODEL_NAME])
async def test_function_calling_required_builtin_tools_still_unsupported(
client: OpenAI,
model_name: str,
):
with pytest.raises(BadRequestError):
await client.responses.create(
model=model_name,
input="What's the weather like in Paris today?",
tools=tools,
input="Search for the latest vLLM release.",
tools=[{"type": "web_search_preview"}],
tool_choice="required",
)

Expand Down
253 changes: 252 additions & 1 deletion tests/entrypoints/openai/responses/test_serving_responses.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project

import json
from contextlib import AsyncExitStack
from unittest.mock import MagicMock

import pytest
import pytest_asyncio
from openai.types.responses import (
FunctionTool,
ResponseOutputItemDoneEvent,
ResponseReasoningItem,
ResponseReasoningTextDeltaEvent,
Expand Down Expand Up @@ -51,7 +53,8 @@
)
from vllm.inputs import tokens_input
from vllm.outputs import CompletionOutput, RequestOutput
from vllm.sampling_params import SamplingParams
from vllm.sampling_params import SamplingParams, StructuredOutputsParams
from vllm.tool_parsers.streaming import extract_required_tool_call_streaming


class MockConversationContext(ConversationContext):
Expand Down Expand Up @@ -154,6 +157,254 @@ def test_extract_tool_types(monkeypatch: pytest.MonkeyPatch) -> None:
}


def _function_tool() -> FunctionTool:
return FunctionTool(
type="function",
name="get_weather",
description="Get weather.",
parameters={
"type": "object",
"properties": {
"location": {"type": "string"},
},
"required": ["location"],
"additionalProperties": False,
},
strict=True,
)


def _harmony_required_tool_serving() -> OpenAIServingResponses:
# Bypass __init__ to test internal helpers without engine wiring.
serving = object.__new__(OpenAIServingResponses)
serving.use_harmony = True
serving.enable_log_outputs = False
serving.enable_store = True
serving.request_logger = None
serving.tool_call_id_type = "random"
serving.msg_store = {}
return serving


def test_harmony_required_function_call_output_replays() -> None:
serving = _harmony_required_tool_serving()
request = ResponsesRequest(
input="Call get_weather for Paris.",
tools=[_function_tool()],
tool_choice="required",
store=True,
)
serving.msg_store[request.request_id] = []
output = CompletionOutput(
index=0,
text='[{"name":"get_weather","parameters":{"location":"Paris"}}]',
token_ids=[],
cumulative_logprob=0.0,
logprobs=None,
finish_reason="stop",
stop_reason=None,
)

items = serving._make_harmony_required_function_call_items(request, output)

assert len(items) == 1
item = items[0]
assert item.type == "function_call"
assert item.name == "get_weather"
assert json.loads(item.arguments) == {"location": "Paris"}

stored_msg = serving.msg_store[request.request_id][0]
assert stored_msg.channel == "commentary"
assert stored_msg.recipient == "functions.get_weather"
assert stored_msg.content[0].text == '{"location": "Paris"}'

prev_response = MagicMock()
prev_response.id = request.request_id
next_request = ResponsesRequest(
input="Continue.",
tools=[_function_tool()],
)

messages = serving._construct_input_messages_with_harmony(
next_request,
prev_response,
)

assert messages[0].channel == "commentary"
assert messages[0].recipient == "functions.get_weather"
assert messages[0].content[0].text == '{"location": "Paris"}'
assert messages[-1].author.role == "user"
assert messages[-1].content[0].text == "Continue."


@pytest.mark.parametrize(
("text", "match"),
[
('[{"name":"get_weather"}]', "did not include object parameters"),
('[{"name":"get_time","parameters":{}}]', "unknown tool"),
],
)
def test_harmony_required_function_call_rejects_invalid_output(
text: str,
match: str,
) -> None:
request = ResponsesRequest(
input="Call get_weather for Paris.",
tools=[_function_tool()],
tool_choice="required",
)

with pytest.raises(ValueError, match=match):
OpenAIServingResponses._parse_required_function_tool_output(request, text)


@pytest.mark.parametrize(
("request_kwargs", "param"),
[
({"max_tool_calls": 0}, "max_tool_calls"),
(
{"structured_outputs": StructuredOutputsParams(json={"type": "object"})},
"structured_outputs",
),
],
)
def test_harmony_required_function_call_rejects_conflicting_options(
request_kwargs: dict,
param: str,
) -> None:
serving = _harmony_required_tool_serving()
request = ResponsesRequest(
input="Call get_weather for Paris.",
tools=[_function_tool()],
tool_choice="required",
**request_kwargs,
)

error = serving._validate_create_responses_input(request)

assert error is not None
assert error.error.type == "invalid_request_error"
assert error.error.param == param


def test_harmony_required_function_call_allows_text_format() -> None:
serving = _harmony_required_tool_serving()
text_schema = {
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
"additionalProperties": False,
}
request = ResponsesRequest(
input="Call get_weather for Paris.",
tools=[_function_tool()],
tool_choice="required",
text=ResponseTextConfig(
format=ResponseFormatTextJSONSchemaConfig(
type="json_schema",
name="weather_answer",
schema=text_schema,
)
),
)

assert serving._validate_create_responses_input(request) is None
sampling_params = request.to_sampling_params(default_max_tokens=128)
assert sampling_params.structured_outputs is not None
assert sampling_params.structured_outputs.json == text_schema

serving._apply_harmony_required_function_tool_schema(
request, sampling_params, is_required_function_call=True
)

assert sampling_params.structured_outputs is not None
tool_schema = sampling_params.structured_outputs.json
assert isinstance(tool_schema, dict)
assert tool_schema["type"] == "array"
assert tool_schema["items"]["anyOf"][0]["properties"]["name"]["enum"] == [
"get_weather"
]


def test_harmony_required_function_tool_schema_honors_limits() -> None:
single_tool_schema = OpenAIServingResponses._required_function_tool_json_schema(
ResponsesRequest(
input="Call get_weather for Paris.",
tools=[_function_tool()],
tool_choice="required",
parallel_tool_calls=False,
)
)
limited_schema = OpenAIServingResponses._required_function_tool_json_schema(
ResponsesRequest(
input="Call get_weather for Paris.",
tools=[_function_tool()],
tool_choice="required",
max_tool_calls=3,
)
)

assert isinstance(single_tool_schema, dict)
assert single_tool_schema["maxItems"] == 1
assert isinstance(limited_schema, dict)
assert limited_schema["maxItems"] == 3


def test_harmony_required_function_tool_schema_preserves_tool_parameters() -> None:
tool = _function_tool()
tool.parameters["$defs"] = {"city": {"type": "string"}}
request = ResponsesRequest(
input="Call get_weather for Paris.",
tools=[tool],
tool_choice="required",
)

OpenAIServingResponses._required_function_tool_json_schema(request)

assert "$defs" in tool.parameters


@pytest.mark.parametrize(
"chunks",
[
['[{"name":"get_weather","parameters":{"location":"Paris"}}]'],
[
'[{"name":"get_weather",',
'"parameters":{"location":"Paris"}}]',
],
],
)
def test_harmony_required_function_call_streaming_reconstructs_arguments(
chunks: list[str],
) -> None:
previous_text = ""
function_name_returned = False
name = None
arguments = ""

for chunk in chunks:
current_text = previous_text + chunk
delta_message, function_name_returned = extract_required_tool_call_streaming(
previous_text=previous_text,
current_text=current_text,
delta_text=chunk,
function_name_returned=function_name_returned,
tool_call_idx=0,
tool_call_id_type="random",
)
previous_text = current_text

if delta_message is None:
continue
tool_call = delta_message.tool_calls[0]
if tool_call.function.name:
name = tool_call.function.name
arguments += tool_call.function.arguments or ""

assert name == "get_weather"
assert json.loads(arguments) == {"location": "Paris"}


@pytest.mark.skip_global_cleanup
def test_response_created_event_uses_public_json_schema_alias() -> None:
schema = {
Expand Down
Loading
Loading