Co-authored-by: Xiaoyu Zhang <1182563586@qq.com> Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
1036 lines
36 KiB
Python
1036 lines
36 KiB
Python
import asyncio
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
from openai.types.responses import (
|
|
ResponseOutputMessage,
|
|
ResponseOutputText,
|
|
ResponseReasoningItem,
|
|
)
|
|
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
|
|
from utils import make_serving
|
|
|
|
from sglang.srt.entrypoints.context import SimpleContext
|
|
from sglang.srt.entrypoints.openai.protocol import (
|
|
MessageProcessingResult,
|
|
RequestResponseMetadata,
|
|
ResponsesRequest,
|
|
)
|
|
from sglang.srt.entrypoints.openai.serving_responses import (
|
|
OpenAIServingResponses,
|
|
_build_output_text_logprobs,
|
|
_should_emit_normal_text_as_message,
|
|
)
|
|
from sglang.srt.function_call.core_types import ToolCallItem
|
|
from sglang.srt.parser.template_detection import ReasoningToggleConfig
|
|
from sglang.srt.sampling.sampling_params import (
|
|
REQUEST_REASONING_END_TOKEN_IDS_KEY,
|
|
)
|
|
from sglang.test.ci.ci_register import register_cpu_ci
|
|
from sglang.test.test_utils import CustomTestCase
|
|
|
|
register_cpu_ci(est_time=7, suite="base-a-test-cpu")
|
|
|
|
|
|
class InputMessageConstructionTestCase(CustomTestCase):
|
|
def test_previous_response_replays_assistant_text_not_instructions(self):
|
|
serving = make_serving()
|
|
prev_response = Mock(id="resp_prev")
|
|
prev_response.output = [
|
|
ResponseReasoningItem(
|
|
id="rs_prev", summary=[], type="reasoning", content=None, status=None
|
|
),
|
|
ResponseOutputMessage(
|
|
id="msg_prev",
|
|
content=[
|
|
ResponseOutputText(
|
|
text="first answer part",
|
|
annotations=[],
|
|
type="output_text",
|
|
logprobs=None,
|
|
),
|
|
ResponseOutputText(
|
|
text="second answer part",
|
|
annotations=[],
|
|
type="output_text",
|
|
logprobs=None,
|
|
),
|
|
],
|
|
role="assistant",
|
|
status="completed",
|
|
type="message",
|
|
),
|
|
]
|
|
serving.msg_store["resp_prev"] = [{"role": "user", "content": "old input"}]
|
|
|
|
request = ResponsesRequest(
|
|
model="x",
|
|
instructions="Be brief",
|
|
previous_response_id="resp_prev",
|
|
input="new input",
|
|
store=False,
|
|
)
|
|
|
|
messages = serving._construct_input_messages(request, prev_response)
|
|
|
|
self.assertEqual(
|
|
messages,
|
|
[
|
|
{"role": "system", "content": "Be brief"},
|
|
{"role": "user", "content": "old input"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "first answer part\nsecond answer part",
|
|
},
|
|
{"role": "user", "content": "new input"},
|
|
],
|
|
)
|
|
|
|
def test_input_parts_normalized_for_chat_templates(self):
|
|
serving = make_serving()
|
|
request = ResponsesRequest(
|
|
model="x",
|
|
input=[
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "input_text", "text": "what is this?"},
|
|
{
|
|
"type": "input_image",
|
|
"image_url": "http://example.com/cat.png",
|
|
},
|
|
],
|
|
}
|
|
],
|
|
store=False,
|
|
)
|
|
|
|
messages = serving._construct_input_messages(request)
|
|
|
|
self.assertEqual(
|
|
messages,
|
|
[
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "what is this?"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": "http://example.com/cat.png",
|
|
"detail": "auto",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
],
|
|
)
|
|
|
|
def test_previous_response_id_input_list_does_not_call_copy_module(self):
|
|
serving = make_serving()
|
|
serving.use_harmony = True
|
|
prev = Mock(id="resp_prev")
|
|
prev.output = [
|
|
ResponseFunctionToolCall(
|
|
arguments="{}",
|
|
call_id="call_x",
|
|
name="t",
|
|
type="function_call",
|
|
id="fc_x",
|
|
status="completed",
|
|
)
|
|
]
|
|
request = ResponsesRequest(
|
|
model="x",
|
|
input=[{"role": "user", "content": "hi"}],
|
|
previous_response_id="resp_prev",
|
|
store=False,
|
|
)
|
|
try:
|
|
serving._construct_input_messages_with_harmony(request, prev)
|
|
except TypeError as exc:
|
|
self.fail(f"copy() module-call regression: {exc}")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class ChatToolForwardingTestCase(CustomTestCase):
|
|
def test_make_request_passes_function_tools_to_chat_processing(self):
|
|
serving = make_serving()
|
|
seen = {}
|
|
|
|
def fake_process(chat_request, is_multimodal):
|
|
seen["tools"] = chat_request.tools
|
|
seen["tool_choice"] = chat_request.tool_choice
|
|
seen["parallel_tool_calls"] = chat_request.parallel_tool_calls
|
|
return MessageProcessingResult(
|
|
prompt="prompt",
|
|
prompt_ids=[1, 2, 3],
|
|
image_data=None,
|
|
audio_data=None,
|
|
video_data=None,
|
|
modalities=[],
|
|
stop=["</s>"],
|
|
tool_call_constraint=("json_schema", {"type": "object"}),
|
|
)
|
|
|
|
serving._process_messages = Mock(side_effect=fake_process)
|
|
request = ResponsesRequest(
|
|
model="x",
|
|
input="call the tool",
|
|
tools=[
|
|
{
|
|
"type": "function",
|
|
"name": "lookup",
|
|
"parameters": {"type": "object"},
|
|
}
|
|
],
|
|
tool_choice="required",
|
|
parallel_tool_calls=False,
|
|
store=False,
|
|
)
|
|
|
|
messages, request_prompts, engine_prompts, processed = asyncio.run(
|
|
serving._make_request(request, None, serving.tokenizer_manager.tokenizer)
|
|
)
|
|
|
|
self.assertEqual(messages, [{"role": "user", "content": "call the tool"}])
|
|
self.assertEqual(request_prompts, [[1, 2, 3]])
|
|
self.assertEqual(engine_prompts, [[1, 2, 3]])
|
|
self.assertEqual(seen["tools"][0].function.name, "lookup")
|
|
self.assertEqual(seen["tool_choice"], "required")
|
|
self.assertFalse(seen["parallel_tool_calls"])
|
|
self.assertEqual(processed.tool_call_constraint[0], "json_schema")
|
|
|
|
def test_required_tool_choice_without_function_tool_returns_400(self):
|
|
serving = make_serving()
|
|
request = ResponsesRequest(
|
|
model="x",
|
|
input="hi",
|
|
tool_choice="required",
|
|
tools=[{"type": "web_search"}, {"type": "mcp"}],
|
|
store=False,
|
|
)
|
|
result = asyncio.run(serving.create_responses(request, raw_request=None))
|
|
self.assertEqual(getattr(result, "status_code", None), 400)
|
|
|
|
def test_kimi_k3_request_uses_chat_encoder_fields(self):
|
|
serving = make_serving()
|
|
serving.chat_encoding_spec = "kimi_k3"
|
|
serving.default_chat_template_kwargs = {}
|
|
serving.template_manager.chat_template_name = None
|
|
serving.tokenizer_manager.tokenizer.apply_chat_template.return_value = [4, 5, 6]
|
|
request = ResponsesRequest(
|
|
model="x",
|
|
input="Explain <|kimi_image_placeholder|>",
|
|
tools=[
|
|
{
|
|
"type": "function",
|
|
"name": "lookup",
|
|
"parameters": {"type": "object"},
|
|
}
|
|
],
|
|
tool_choice="required",
|
|
reasoning={"effort": "high"},
|
|
store=False,
|
|
)
|
|
|
|
_, request_prompts, engine_prompts, _ = asyncio.run(
|
|
serving._make_request(request, None, serving.tokenizer_manager.tokenizer)
|
|
)
|
|
|
|
call = serving.tokenizer_manager.tokenizer.apply_chat_template.call_args
|
|
self.assertEqual(
|
|
call.args[0][0]["content"], "Explain <| kimi_image_placeholder |>"
|
|
)
|
|
self.assertEqual(call.kwargs["thinking_effort"], "high")
|
|
self.assertEqual(call.kwargs["tool_choice"], "required")
|
|
self.assertEqual(call.kwargs["tools"][0]["function"]["name"], "lookup")
|
|
self.assertEqual(request_prompts, [[4, 5, 6]])
|
|
self.assertEqual(engine_prompts, [[4, 5, 6]])
|
|
|
|
def test_k2_output_parser_reuses_effective_template_default(self):
|
|
serving = make_serving()
|
|
serving.reasoning_parser = "k2_horizon"
|
|
serving.default_chat_template_kwargs = {"reasoning_effort": "low"}
|
|
serving.template_manager.chat_template_name = None
|
|
serving.tokenizer_manager.tokenizer.apply_chat_template.return_value = [4, 5, 6]
|
|
request = ResponsesRequest(
|
|
model="IFM/K2-Horizon-7B",
|
|
input="hi",
|
|
# Template kwargs are the final render inputs, so the server default
|
|
# below takes precedence over this API convenience field.
|
|
reasoning={"effort": "medium"},
|
|
store=False,
|
|
)
|
|
|
|
asyncio.run(
|
|
serving._make_request(request, None, serving.tokenizer_manager.tokenizer)
|
|
)
|
|
|
|
render_call = serving.tokenizer_manager.tokenizer.apply_chat_template.call_args
|
|
self.assertEqual(render_call.kwargs["reasoning_effort"], "low")
|
|
self.assertEqual(request.chat_template_kwargs["reasoning_effort"], "low")
|
|
|
|
output_items = serving._make_response_output_items(
|
|
request,
|
|
"work</ifm|think_faster>\nanswer",
|
|
tokenizer=Mock(),
|
|
require_reasoning=True,
|
|
)
|
|
self.assertEqual(output_items[0].content[0].text, "work")
|
|
self.assertEqual(output_items[1].content[0].text, "\nanswer")
|
|
|
|
|
|
class ReasoningRequestForwardingTestCase(unittest.TestCase):
|
|
def test_create_responses_uses_processed_reasoning_state(self):
|
|
serving = make_serving()
|
|
serving.reasoning_parser = "deepseek-r1"
|
|
serving.default_chat_template_kwargs = {"thinking": False}
|
|
serving.template_manager.reasoning_config = ReasoningToggleConfig(
|
|
toggle_param="thinking", default_enabled=True
|
|
)
|
|
rendered = MessageProcessingResult(
|
|
prompt="prompt",
|
|
prompt_ids=[1, 2, 3],
|
|
image_data=None,
|
|
audio_data=None,
|
|
video_data=None,
|
|
modalities=[],
|
|
stop=[],
|
|
reasoning_end_token_ids=[41, 42],
|
|
)
|
|
captured = {}
|
|
|
|
async def fake_generate(
|
|
request_id,
|
|
request_prompt,
|
|
adapted_request,
|
|
sampling_params,
|
|
context,
|
|
**kwargs,
|
|
):
|
|
captured["adapted_request"] = adapted_request
|
|
context.append_output(
|
|
{
|
|
"text": "done",
|
|
"meta_info": {
|
|
"prompt_tokens": 3,
|
|
"completion_tokens": 1,
|
|
"cached_tokens": 0,
|
|
},
|
|
}
|
|
)
|
|
yield context
|
|
|
|
serving._generate_with_builtin_tools = fake_generate
|
|
request = ResponsesRequest(
|
|
model="x",
|
|
input="answer",
|
|
request_id="resp_reasoning",
|
|
store=False,
|
|
)
|
|
|
|
with (
|
|
patch.object(
|
|
serving, "_apply_conversation_template", return_value=rendered
|
|
),
|
|
patch(
|
|
"sglang.srt.entrypoints.openai.serving_responses.ReasoningParser"
|
|
) as parser_cls,
|
|
):
|
|
parser_cls.return_value.parse_non_stream.return_value = (None, "done")
|
|
response = asyncio.run(serving.create_responses(request))
|
|
|
|
self.assertEqual(response.status, "completed")
|
|
self.assertFalse(captured["adapted_request"].require_reasoning)
|
|
self.assertEqual(
|
|
captured["adapted_request"].sampling_params["custom_params"][
|
|
REQUEST_REASONING_END_TOKEN_IDS_KEY
|
|
],
|
|
[41, 42],
|
|
)
|
|
self.assertFalse(parser_cls.call_args.kwargs["force_reasoning"])
|
|
|
|
|
|
class SkipSpecialTokensForwardingTestCase(CustomTestCase):
|
|
"""The skip_special_tokens override from _process_messages must reach the
|
|
engine sampling params; muse's channel markers die in detok otherwise."""
|
|
|
|
def _create_responses_sampling_params(self, serving):
|
|
serving.default_chat_template_kwargs = None
|
|
rendered = MessageProcessingResult(
|
|
prompt="prompt",
|
|
prompt_ids=[1, 2, 3],
|
|
image_data=None,
|
|
audio_data=None,
|
|
video_data=None,
|
|
modalities=[],
|
|
stop=[],
|
|
)
|
|
captured = {}
|
|
|
|
async def fake_generate(
|
|
request_id,
|
|
request_prompt,
|
|
adapted_request,
|
|
sampling_params,
|
|
context,
|
|
**kwargs,
|
|
):
|
|
captured["sampling_params"] = sampling_params
|
|
context.append_output(
|
|
{
|
|
"text": "done",
|
|
"meta_info": {
|
|
"prompt_tokens": 3,
|
|
"completion_tokens": 1,
|
|
"cached_tokens": 0,
|
|
},
|
|
}
|
|
)
|
|
yield context
|
|
|
|
serving._generate_with_builtin_tools = fake_generate
|
|
request = ResponsesRequest(
|
|
model="x",
|
|
input="answer",
|
|
request_id="resp_skip_special",
|
|
store=False,
|
|
)
|
|
|
|
with (
|
|
patch.object(
|
|
serving, "_apply_conversation_template", return_value=rendered
|
|
),
|
|
patch(
|
|
"sglang.srt.entrypoints.openai.serving_responses.ReasoningParser"
|
|
) as parser_cls,
|
|
):
|
|
parser_cls.return_value.parse_non_stream.return_value = (None, "done")
|
|
response = asyncio.run(serving.create_responses(request))
|
|
|
|
self.assertEqual(response.status, "completed")
|
|
return captured["sampling_params"]
|
|
|
|
def test_marker_preserving_parser_disables_skip_special_tokens(self):
|
|
serving = make_serving()
|
|
serving.reasoning_parser = "muse"
|
|
params = self._create_responses_sampling_params(serving)
|
|
self.assertFalse(params["skip_special_tokens"])
|
|
|
|
def test_default_parser_keeps_skip_special_tokens(self):
|
|
serving = make_serving()
|
|
params = self._create_responses_sampling_params(serving)
|
|
# The chat request's True is a synthesized default (ResponsesRequest has
|
|
# no such field), so leave it unset for --preferred-sampling-params.
|
|
self.assertNotIn("skip_special_tokens", params)
|
|
|
|
|
|
class InputItemNormalizationTestCase(CustomTestCase):
|
|
def test_function_call_becomes_assistant_tool_call(self):
|
|
normalized = OpenAIServingResponses._normalize_response_message_for_chat(
|
|
{
|
|
"type": "function_call",
|
|
"id": "fc_1",
|
|
"call_id": "call_abc",
|
|
"name": "lookup",
|
|
"arguments": '{"key": "val"}',
|
|
"status": "completed",
|
|
}
|
|
)
|
|
self.assertEqual(
|
|
normalized,
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_abc",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "lookup",
|
|
"arguments": '{"key": "val"}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
def test_developer_role_becomes_system(self):
|
|
normalized = OpenAIServingResponses._normalize_response_message_for_chat(
|
|
{"role": "developer", "content": "Be terse."}
|
|
)
|
|
self.assertEqual(normalized, {"role": "system", "content": "Be terse."})
|
|
|
|
def test_function_call_output_becomes_tool_message(self):
|
|
normalized = OpenAIServingResponses._normalize_response_message_for_chat(
|
|
{
|
|
"type": "function_call_output",
|
|
"call_id": "call_abc",
|
|
"output": "42",
|
|
}
|
|
)
|
|
self.assertEqual(
|
|
normalized,
|
|
{"role": "tool", "tool_call_id": "call_abc", "content": "42"},
|
|
)
|
|
|
|
def test_unknown_input_item_type_raises(self):
|
|
with self.assertRaises(ValueError):
|
|
OpenAIServingResponses._normalize_response_message_for_chat(
|
|
{"type": "web_search_call", "id": "ws_1"}
|
|
)
|
|
|
|
|
|
class FullResponseUsageTestCase(CustomTestCase):
|
|
def test_full_response_uses_dict_meta_info_for_usage(self):
|
|
serving = make_serving()
|
|
context = SimpleContext()
|
|
context.last_output = {
|
|
"text": "done",
|
|
"meta_info": {
|
|
"prompt_tokens": 11,
|
|
"completion_tokens": 7,
|
|
"cached_tokens": 3,
|
|
"reasoning_tokens": 2,
|
|
},
|
|
}
|
|
request = ResponsesRequest(
|
|
model="x", input="hello", request_id="resp_usage", store=False
|
|
)
|
|
metadata = RequestResponseMetadata(request_id=request.request_id)
|
|
|
|
async def empty_generator():
|
|
for _ in ():
|
|
yield None
|
|
|
|
response = asyncio.run(
|
|
serving.responses_full_generator(
|
|
request,
|
|
sampling_params={},
|
|
result_generator=empty_generator(),
|
|
context=context,
|
|
model_name="x",
|
|
tokenizer=serving.tokenizer_manager.tokenizer,
|
|
request_metadata=metadata,
|
|
created_time=123,
|
|
require_reasoning=False,
|
|
)
|
|
)
|
|
|
|
self.assertEqual(response.usage.prompt_tokens, 11)
|
|
self.assertEqual(response.usage.completion_tokens, 7)
|
|
self.assertEqual(response.usage.reasoning_tokens, 2)
|
|
self.assertEqual(metadata.final_usage_info, response.usage)
|
|
|
|
|
|
class MultimodalRequestTestCase(CustomTestCase):
|
|
def test_text_only_create_responses_rejects_media_before_generation(self):
|
|
serving = make_serving()
|
|
serving._process_messages = Mock()
|
|
request = ResponsesRequest(
|
|
model="x",
|
|
input=[
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "input_text", "text": "describe it"},
|
|
{
|
|
"type": "input_image",
|
|
"image_url": "http://example.com/cat.png",
|
|
},
|
|
],
|
|
}
|
|
],
|
|
store=False,
|
|
)
|
|
|
|
response = asyncio.run(serving.create_responses(request))
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertIn(b"received unsupported content type 'image_url'", response.body)
|
|
serving._process_messages.assert_not_called()
|
|
serving.tokenizer_manager.generate_request.assert_not_called()
|
|
|
|
def test_multimodal_create_responses_sends_text_and_media_to_engine(self):
|
|
serving = make_serving(is_multimodal=True)
|
|
captured = {}
|
|
|
|
serving._process_messages = Mock(
|
|
return_value=MessageProcessingResult(
|
|
prompt="rendered multimodal prompt",
|
|
prompt_ids=[9, 9, 9],
|
|
image_data=["http://example.com/cat.png"],
|
|
audio_data=None,
|
|
video_data=None,
|
|
modalities=["image"],
|
|
stop=[],
|
|
)
|
|
)
|
|
|
|
async def fake_generate(
|
|
request_id,
|
|
request_prompt,
|
|
adapted_request,
|
|
sampling_params,
|
|
context,
|
|
**kwargs,
|
|
):
|
|
captured["request_prompt"] = request_prompt
|
|
captured["adapted_request"] = adapted_request
|
|
context.append_output(
|
|
{
|
|
"text": "looks like a cat",
|
|
"meta_info": {
|
|
"prompt_tokens": 5,
|
|
"completion_tokens": 4,
|
|
"cached_tokens": 0,
|
|
},
|
|
}
|
|
)
|
|
yield context
|
|
|
|
serving._generate_with_builtin_tools = fake_generate
|
|
request = ResponsesRequest(
|
|
model="x",
|
|
input=[
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "input_text", "text": "describe it"},
|
|
{
|
|
"type": "input_image",
|
|
"image_url": "http://example.com/cat.png",
|
|
},
|
|
],
|
|
}
|
|
],
|
|
request_id="resp_mm",
|
|
store=False,
|
|
)
|
|
|
|
response = asyncio.run(serving.create_responses(request))
|
|
|
|
self.assertEqual(response.status, "completed")
|
|
self.assertEqual(captured["request_prompt"], "rendered multimodal prompt")
|
|
self.assertEqual(captured["adapted_request"].text, "rendered multimodal prompt")
|
|
self.assertIsNone(captured["adapted_request"].input_ids)
|
|
self.assertEqual(
|
|
captured["adapted_request"].image_data, ["http://example.com/cat.png"]
|
|
)
|
|
self.assertEqual(captured["adapted_request"].modalities, ["image"])
|
|
|
|
|
|
class OutputItemsTestCase(CustomTestCase):
|
|
def setUp(self):
|
|
# qwen3_coder is the default for this class; the one no-native-parser
|
|
# case overrides it.
|
|
self.serving = make_serving()
|
|
self.serving.tool_call_parser = "qwen3_coder"
|
|
|
|
def _function_tool_request(self):
|
|
return ResponsesRequest(
|
|
model="x",
|
|
input="weather?",
|
|
store=False,
|
|
tools=[
|
|
{
|
|
"type": "function",
|
|
"name": "get_weather",
|
|
"description": "Get weather",
|
|
"parameters": {"type": "object"},
|
|
}
|
|
],
|
|
)
|
|
|
|
def test_function_tool_call_extracted_via_parser(self):
|
|
serving = self.serving
|
|
fake_call = ToolCallItem(
|
|
tool_index=0, name="get_weather", parameters='{"city": "Beijing"}'
|
|
)
|
|
|
|
with patch(
|
|
"sglang.srt.entrypoints.openai.serving_responses.FunctionCallParser"
|
|
) as parser_cls:
|
|
instance = parser_cls.return_value
|
|
instance.has_tool_call.return_value = True
|
|
instance.parse_non_stream.return_value = ("trailing text", [fake_call])
|
|
output_items = serving._make_response_output_items(
|
|
self._function_tool_request(),
|
|
"raw model output with <tool_call>",
|
|
tokenizer=Mock(),
|
|
require_reasoning=False,
|
|
)
|
|
|
|
tool_calls = [
|
|
item for item in output_items if isinstance(item, ResponseFunctionToolCall)
|
|
]
|
|
self.assertEqual(len(tool_calls), 1)
|
|
self.assertEqual(tool_calls[0].name, "get_weather")
|
|
self.assertEqual(tool_calls[0].arguments, '{"city": "Beijing"}')
|
|
|
|
message_items = [
|
|
item for item in output_items if isinstance(item, ResponseOutputMessage)
|
|
]
|
|
self.assertEqual(len(message_items), 1)
|
|
self.assertEqual(message_items[0].content[0].text, "trailing text")
|
|
|
|
def test_prose_emitted_before_tool_call_item(self):
|
|
serving = self.serving
|
|
fake_call = ToolCallItem(
|
|
tool_index=0, name="get_weather", parameters='{"city": "Beijing"}'
|
|
)
|
|
|
|
with patch(
|
|
"sglang.srt.entrypoints.openai.serving_responses.FunctionCallParser"
|
|
) as parser_cls:
|
|
instance = parser_cls.return_value
|
|
instance.has_tool_call.return_value = True
|
|
instance.parse_non_stream.return_value = (
|
|
"I'll check the weather.",
|
|
[fake_call],
|
|
)
|
|
output_items = serving._make_response_output_items(
|
|
self._function_tool_request(),
|
|
"raw model output",
|
|
tokenizer=Mock(),
|
|
require_reasoning=False,
|
|
)
|
|
|
|
types = [type(item).__name__ for item in output_items]
|
|
self.assertEqual(types, ["ResponseOutputMessage", "ResponseFunctionToolCall"])
|
|
|
|
def test_required_tool_choice_parses_json_array_without_native_parser(self):
|
|
serving = self.serving
|
|
serving.tool_call_parser = None
|
|
request = ResponsesRequest(
|
|
model="x",
|
|
input="hi",
|
|
tool_choice="required",
|
|
tools=[
|
|
{
|
|
"type": "function",
|
|
"name": "get_weather",
|
|
"parameters": {"type": "object"},
|
|
}
|
|
],
|
|
store=False,
|
|
)
|
|
raw = '[{"name": "get_weather", "parameters": {"city": "Beijing"}}]'
|
|
|
|
output_items = serving._make_response_output_items(
|
|
request, raw, tokenizer=Mock(), require_reasoning=False
|
|
)
|
|
|
|
tool_calls = [
|
|
item for item in output_items if isinstance(item, ResponseFunctionToolCall)
|
|
]
|
|
self.assertEqual(len(tool_calls), 1)
|
|
self.assertEqual(tool_calls[0].name, "get_weather")
|
|
self.assertEqual(tool_calls[0].arguments, '{"city": "Beijing"}')
|
|
self.assertEqual(
|
|
[item for item in output_items if isinstance(item, ResponseOutputMessage)],
|
|
[],
|
|
)
|
|
|
|
def test_required_tool_choice_skips_json_fallback_for_native_parser(self):
|
|
"""muse reports parses_required_natively, so required output must not
|
|
be pushed through the orjson JSON-array fallback (mirrors chat)."""
|
|
serving = self.serving
|
|
serving.tool_call_parser = "muse"
|
|
request = ResponsesRequest(
|
|
model="x",
|
|
input="hi",
|
|
tool_choice="required",
|
|
tools=[
|
|
{
|
|
"type": "function",
|
|
"name": "get_weather",
|
|
"parameters": {"type": "object"},
|
|
}
|
|
],
|
|
store=False,
|
|
)
|
|
raw = '[{"name": "get_weather", "parameters": {"city": "Beijing"}}]'
|
|
|
|
output_items = serving._make_response_output_items(
|
|
request, raw, tokenizer=Mock(), require_reasoning=False
|
|
)
|
|
|
|
self.assertEqual(
|
|
[
|
|
item
|
|
for item in output_items
|
|
if isinstance(item, ResponseFunctionToolCall)
|
|
],
|
|
[],
|
|
)
|
|
message_items = [
|
|
item for item in output_items if isinstance(item, ResponseOutputMessage)
|
|
]
|
|
self.assertEqual(len(message_items), 1)
|
|
self.assertEqual(message_items[0].content[0].text, raw)
|
|
|
|
def test_no_tool_call_extraction_when_tool_choice_none(self):
|
|
serving = self.serving
|
|
request = ResponsesRequest(
|
|
model="x",
|
|
input="hi",
|
|
store=False,
|
|
tool_choice="none",
|
|
tools=[
|
|
{
|
|
"type": "function",
|
|
"name": "get_weather",
|
|
"parameters": {"type": "object"},
|
|
}
|
|
],
|
|
)
|
|
|
|
with patch(
|
|
"sglang.srt.entrypoints.openai.serving_responses.FunctionCallParser"
|
|
) as parser_cls:
|
|
output_items = serving._make_response_output_items(
|
|
request,
|
|
"just a plain answer",
|
|
tokenizer=Mock(),
|
|
require_reasoning=False,
|
|
)
|
|
parser_cls.assert_not_called()
|
|
|
|
self.assertEqual(len(output_items), 1)
|
|
self.assertIsInstance(output_items[0], ResponseOutputMessage)
|
|
|
|
|
|
class HarmonyResponsesTestCase(CustomTestCase):
|
|
def test_developer_message_skips_unsupported_tool_types(self):
|
|
from sglang.srt.entrypoints.harmony_utils import get_developer_message
|
|
from sglang.srt.entrypoints.openai.protocol import ResponseTool
|
|
|
|
tools = [
|
|
ResponseTool(
|
|
type="function",
|
|
name="get_weather",
|
|
description="Look up weather.",
|
|
parameters={"type": "object"},
|
|
),
|
|
ResponseTool(type="web_search"),
|
|
ResponseTool(type="namespace", name="codex"),
|
|
ResponseTool(type="mcp"),
|
|
]
|
|
msg = get_developer_message(instructions="be helpful", tools=tools)
|
|
self.assertIsNotNone(msg)
|
|
|
|
|
|
class StatusFromFinishReasonTestCase(CustomTestCase):
|
|
def test_only_length_maps_to_incomplete(self):
|
|
fn = OpenAIServingResponses._status_from_finish_reason
|
|
self.assertEqual(fn({"type": "length"}), "incomplete")
|
|
self.assertEqual(fn("length"), "incomplete")
|
|
for other in ({"type": "stop"}, {"type": "tool_calls"}, "stop", None):
|
|
self.assertEqual(fn(other), "completed", other)
|
|
|
|
|
|
class BuildOutputTextLogprobsTestCase(CustomTestCase):
|
|
def test_tokens_and_top_logprobs_are_converted(self):
|
|
meta_info = {
|
|
"output_token_logprobs": [(-0.1, 10, "Hello"), (-0.2, 11, " world")],
|
|
"output_top_logprobs": [
|
|
[(-0.1, 10, "Hello"), (-2.0, 12, "Hi")],
|
|
[(-0.2, 11, " world"), (-3.0, 13, " earth")],
|
|
],
|
|
}
|
|
out = _build_output_text_logprobs(meta_info)
|
|
self.assertEqual(len(out), 2)
|
|
self.assertEqual(out[0].token, "Hello")
|
|
self.assertEqual(out[0].logprob, -0.1)
|
|
self.assertEqual(out[0].bytes, list("Hello".encode("utf-8")))
|
|
self.assertEqual(len(out[0].top_logprobs), 2)
|
|
self.assertEqual(out[0].top_logprobs[0].token, "Hello")
|
|
self.assertEqual(out[1].token, " world")
|
|
|
|
def test_no_top_logprobs_yields_empty_lists(self):
|
|
meta_info = {
|
|
"output_token_logprobs": [(-0.5, 7, "hi")],
|
|
"output_top_logprobs": None,
|
|
}
|
|
out = _build_output_text_logprobs(meta_info)
|
|
self.assertEqual(len(out), 1)
|
|
self.assertEqual(out[0].top_logprobs, [])
|
|
|
|
|
|
class ChatToolChoiceConversionTestCase(CustomTestCase):
|
|
def test_conversion(self):
|
|
fn = OpenAIServingResponses._chat_tool_choice
|
|
for s in ("auto", "required", "none"):
|
|
self.assertEqual(fn(s), s)
|
|
# Input is an effective_tool_choice() result, so the only object form
|
|
# reaching here is a named function; degrading the rest to "auto"
|
|
# happens there, once, so the echoed and the honored value agree.
|
|
self.assertEqual(
|
|
fn({"type": "function", "name": "get_weather"}),
|
|
{"type": "function", "function": {"name": "get_weather"}},
|
|
)
|
|
|
|
|
|
class ShouldEmitNormalTextTestCase(CustomTestCase):
|
|
def test_whitespace_suppressed_only_while_a_tool_is_open(self):
|
|
emit = _should_emit_normal_text_as_message
|
|
self.assertFalse(emit("", any_tool_call_in_progress=False))
|
|
# whitespace between tool blocks is an inter-call separator, not content
|
|
self.assertFalse(emit("\n", any_tool_call_in_progress=True))
|
|
self.assertTrue(emit("\n", any_tool_call_in_progress=False))
|
|
self.assertTrue(emit("hello", any_tool_call_in_progress=True))
|
|
|
|
|
|
class EnginePassthroughTestCase(CustomTestCase):
|
|
"""Both flags cross hops with no type contract, and dropping either fails
|
|
silently."""
|
|
|
|
def _capture(self, serving, request):
|
|
# Let the real _process_messages run: it is the hop that turns
|
|
# skip_special_tokens off, so mocking it would make that assertion vacuous.
|
|
# chat_template_name=None routes it through the tokenizer's template
|
|
# (mocked) instead of the conversation registry, which has no fixture entry.
|
|
serving.default_chat_template_kwargs = {}
|
|
serving.template_manager.chat_template_name = None
|
|
captured = {}
|
|
|
|
async def fake_generate(
|
|
request_id,
|
|
request_prompt,
|
|
adapted_request,
|
|
sampling_params,
|
|
context,
|
|
**kwargs,
|
|
):
|
|
captured["adapted_request"] = adapted_request
|
|
captured["sampling_params"] = sampling_params
|
|
context.append_output(
|
|
{
|
|
"text": "ok",
|
|
"meta_info": {
|
|
"prompt_tokens": 1,
|
|
"completion_tokens": 1,
|
|
"cached_tokens": 0,
|
|
},
|
|
}
|
|
)
|
|
yield context
|
|
|
|
serving._generate_with_builtin_tools = fake_generate
|
|
asyncio.run(serving.create_responses(request))
|
|
return captured
|
|
|
|
def test_require_reasoning_forwarded_when_reasoning_parser_configured(self):
|
|
serving = make_serving()
|
|
serving.reasoning_parser = "deepseek-r1"
|
|
serving.template_manager.reasoning_config = ReasoningToggleConfig(
|
|
toggle_param="thinking", default_enabled=True
|
|
)
|
|
|
|
captured = self._capture(
|
|
serving, ResponsesRequest(model="x", input="hi", store=False)
|
|
)
|
|
|
|
self.assertTrue(captured["adapted_request"].require_reasoning)
|
|
|
|
def test_prefilled_think_template_opens_the_parser(self):
|
|
"""``force_reasoning`` is a template property, not a request one, so it
|
|
drives the parser but never the engine flag -- as on the chat path."""
|
|
serving = make_serving()
|
|
serving.reasoning_parser = "deepseek-r1"
|
|
serving.template_manager.force_reasoning = True
|
|
|
|
with patch(
|
|
"sglang.srt.entrypoints.openai.serving_responses.ReasoningParser"
|
|
) as parser_cls:
|
|
parser_cls.return_value.parse_non_stream.return_value = (None, "hi")
|
|
serving._make_response_output_items(
|
|
ResponsesRequest(model="x", input="hi", store=False),
|
|
"hi",
|
|
tokenizer=Mock(),
|
|
require_reasoning=False,
|
|
)
|
|
|
|
self.assertTrue(parser_cls.call_args.kwargs["force_reasoning"])
|
|
|
|
def test_require_reasoning_false_without_reasoning_parser(self):
|
|
serving = make_serving()
|
|
serving.reasoning_parser = None
|
|
|
|
captured = self._capture(
|
|
serving, ResponsesRequest(model="x", input="hi", store=False)
|
|
)
|
|
|
|
self.assertFalse(captured["adapted_request"].require_reasoning)
|
|
|
|
def test_skip_special_tokens_disabled_for_tool_requests(self):
|
|
# _process_messages turns it off so tool-call markers survive detokenize;
|
|
# create_responses must re-apply it to the engine sampling dict.
|
|
serving = make_serving()
|
|
serving.tool_call_parser = "qwen25"
|
|
|
|
captured = self._capture(
|
|
serving,
|
|
ResponsesRequest(
|
|
model="x",
|
|
input="weather",
|
|
store=False,
|
|
tools=[
|
|
{
|
|
"type": "function",
|
|
"name": "get_weather",
|
|
"parameters": {"type": "object"},
|
|
}
|
|
],
|
|
),
|
|
)
|
|
|
|
self.assertFalse(captured["sampling_params"]["skip_special_tokens"])
|
|
|
|
|
|
class CancelIdempotencyTestCase(CustomTestCase):
|
|
def test_cancelling_a_terminal_response_returns_it_not_an_error(self):
|
|
from sglang.srt.entrypoints.openai.protocol import ResponsesResponse
|
|
|
|
for status in ("cancelled", "completed"):
|
|
serving = make_serving()
|
|
resp = ResponsesResponse.from_request(
|
|
ResponsesRequest(model="x", input="hi", store=False),
|
|
sampling_params={},
|
|
model_name="x",
|
|
created_time=0,
|
|
output=[],
|
|
status=status,
|
|
usage=None,
|
|
)
|
|
serving.response_store[resp.id] = resp
|
|
|
|
out = asyncio.run(serving.cancel_responses(resp.id))
|
|
|
|
self.assertIs(out, resp, status)
|
|
self.assertEqual(out.status, status)
|
|
|
|
|
|
class StreamingLogprobsRejectionTestCase(CustomTestCase):
|
|
def test_stream_with_logprobs_include_rejected(self):
|
|
import orjson
|
|
|
|
serving = make_serving()
|
|
request = ResponsesRequest(
|
|
model="x",
|
|
input="hi",
|
|
store=False,
|
|
stream=True,
|
|
include=["message.output_text.logprobs"],
|
|
)
|
|
result = asyncio.run(serving.create_responses(request))
|
|
self.assertEqual(result.status_code, 400)
|
|
body = orjson.loads(result.body)
|
|
self.assertIn("streaming mode", body["error"]["message"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|