Responses support (#32689)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Harmya Bhatt <harmyacs@gmail.com>
Co-authored-by: harmya <harmya@modal.com>
Co-authored-by: Xinyuan <xinyuan@radixark.com>
Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
Co-authored-by: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com>
This commit is contained in:
Liangsheng Yin
2026-08-07 13:21:46 -07:00
committed by GitHub
co-authored by github-actions[bot] Harmya Bhatt harmya Xinyuan Xinyuan Tong Xinyuan Tong
parent 6c7498113f
commit 3c51e29deb
9 changed files with 1135 additions and 263 deletions
@@ -786,20 +786,24 @@ class TestOpenAIServerv1Responses(CustomTestCase):
assert isinstance(resp.output, list)
assert resp.status in (
"completed",
"incomplete",
"in_progress",
"queued",
"failed",
"cancelled",
)
if resp.status == "completed":
if resp.status in ("completed", "incomplete"):
assert resp.usage is not None
assert resp.usage.prompt_tokens >= 0
assert resp.usage.completion_tokens >= 0
assert resp.usage.input_tokens >= 0
assert resp.usage.output_tokens >= 0
assert resp.usage.total_tokens >= 0
if hasattr(resp, "error"):
assert resp.error is None
if hasattr(resp, "incomplete_details"):
assert resp.incomplete_details is None
if resp.status == "incomplete":
assert resp.incomplete_details.reason == "max_output_tokens"
else:
assert resp.incomplete_details is None
if getattr(resp, "text", None):
fmt = resp.text.get("format") if isinstance(resp.text, dict) else None
if fmt:
@@ -818,8 +822,8 @@ class TestOpenAIServerv1Responses(CustomTestCase):
def test_response_completion(self):
resp = self.run_response(temperature=0, max_output_tokens=16)
assert resp.status in ("completed", "in_progress", "queued")
if resp.status == "completed":
assert resp.status in ("completed", "incomplete", "in_progress", "queued")
if resp.status in ("completed", "incomplete"):
assert resp.usage is not None
assert resp.usage.total_tokens >= 0
@@ -900,9 +904,10 @@ class TestOpenAIServerv1Responses(CustomTestCase):
self.assertEqual(body.get("object"), "response")
self.assertIn("output", body)
self.assertIn("status", body)
if "usage" in body:
self.assertIn("prompt_tokens", body["usage"])
self.assertIn("total_tokens", body["usage"])
self.assertIn("usage", body)
self.assertIn("input_tokens", body["usage"])
self.assertIn("output_tokens", body["usage"])
self.assertIn("total_tokens", body["usage"])
def test_response_prefill(self):
client = openai.Client(api_key=self.api_key, base_url=self.base_url)
@@ -1,14 +1,33 @@
import json
import unittest
from utils import make_serving # noqa: F401 — bootstrap import
from sglang.srt.entrypoints.openai.protocol import ResponsesRequest, UsageInfo
from sglang.srt.entrypoints.openai.protocol import (
PromptTokensDetails,
ResponsesRequest,
ResponsesResponse,
UsageInfo,
)
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=4, suite="base-a-test-cpu")
register_cpu_ci(est_time=7, suite="base-a-test-cpu")
class ResponsesRequestTestCase(unittest.TestCase):
def _in_progress_response(request: ResponsesRequest) -> ResponsesResponse:
return ResponsesResponse.from_request(
request,
sampling_params={},
model_name="x",
created_time=0,
output=[],
status="in_progress",
usage=None,
)
class ResponsesRequestTestCase(CustomTestCase):
def test_function_tool_accepted(self):
request = ResponsesRequest(
model="x",
@@ -88,7 +107,7 @@ class ResponsesRequestTestCase(unittest.TestCase):
self.assertEqual(request.tools[0].tools[0]["name"], "apply_patch")
class ResponsesSamplingParamsTestCase(unittest.TestCase):
class ResponsesSamplingParamsTestCase(CustomTestCase):
def test_processed_stop_and_tool_constraint_propagate(self):
request = ResponsesRequest(model="x", input="call the tool", store=False)
params = request.to_sampling_params(
@@ -122,11 +141,118 @@ class ResponsesSamplingParamsTestCase(unittest.TestCase):
)
self.assertEqual(params["structural_tag"], '{"type": "structural_tag"}')
def test_text_format_maps_to_json_schema_constraint(self):
schema = {"type": "object", "properties": {"age": {"type": "integer"}}}
for fmt, expected in [
({"type": "json_schema", "name": "p", "schema": schema}, schema),
({"type": "json_object"}, {"type": "object"}),
]:
req = ResponsesRequest(
model="x", input="hi", store=False, text={"format": fmt}
)
params = req.to_sampling_params(default_max_tokens=128, default_params={})
self.assertEqual(json.loads(params["json_schema"]), expected, fmt)
plain = ResponsesRequest(
model="x", input="hi", store=False, text={"format": {"type": "text"}}
).to_sampling_params(default_max_tokens=128, default_params={})
self.assertNotIn("json_schema", plain)
def test_text_format_conflicts_with_tool_constraint(self):
request = ResponsesRequest(
model="x",
input="hi",
store=False,
text={
"format": {
"type": "json_schema",
"name": "p",
"schema": {"type": "object"},
}
},
)
# The message must name text.format: it is the only source of the
# conflict a /v1/responses caller can actually set.
with self.assertRaisesRegex(ValueError, r"text\.format"):
request.to_sampling_params(
default_max_tokens=128,
default_params={},
tool_call_constraint=("json_schema", {"type": "object"}),
)
class IncludeOutputLogprobsTestCase(CustomTestCase):
def test_detected_only_for_logprobs_include(self):
def has(include):
return ResponsesRequest(
model="x", input="hi", store=False, include=include
).is_include_output_logprobs()
self.assertTrue(has(["message.output_text.logprobs"]))
self.assertFalse(has(None))
self.assertFalse(has(["reasoning.encrypted_content"]))
class ThinkingControlTestCase(CustomTestCase):
def test_effort_none_disables_thinking(self):
ctk = ResponsesRequest(
model="x", input="hi", store=False, reasoning={"effort": "none"}
).chat_template_kwargs
self.assertEqual((ctk["enable_thinking"], ctk["thinking"]), (False, False))
def test_thinking_untouched_otherwise(self):
# grammar-constrained requests keep thinking on; ReasonerGrammarBackend
# defers the grammar past </think> so the two coexist.
for kw in (
{"reasoning": {"effort": "medium"}},
{"text": {"format": {"type": "text"}}},
{
"text": {
"format": {
"type": "json_schema",
"name": "p",
"schema": {"type": "object"},
}
}
},
{"text": {"format": {"type": "json_object"}}},
{"tool_choice": "required"},
{"tool_choice": {"type": "function", "name": "f"}},
{},
):
req = ResponsesRequest(model="x", input="hi", store=False, **kw)
self.assertIsNone(req.chat_template_kwargs, kw)
def test_explicit_chat_template_kwargs_preserved(self):
req = ResponsesRequest(
model="x",
input="hi",
store=False,
chat_template_kwargs={"enable_thinking": True},
)
self.assertTrue(req.chat_template_kwargs["enable_thinking"])
class ResponsesResponseFromRequestTestCase(CustomTestCase):
def test_requested_text_format_is_echoed(self):
schema = {"type": "object", "properties": {"x": {"type": "integer"}}}
request = ResponsesRequest(
model="x",
input="hi",
store=False,
text={"format": {"type": "json_schema", "name": "p", "schema": schema}},
)
response = ResponsesResponse.from_request(
request,
sampling_params={},
model_name="x",
created_time=0,
output=[],
status="completed",
usage=UsageInfo(prompt_tokens=1, completion_tokens=1, total_tokens=2),
)
self.assertEqual(response.text["format"]["type"], "json_schema")
class ResponsesResponseFromRequestTestCase(unittest.TestCase):
def test_parallel_tool_calls_false_preserved(self):
from sglang.srt.entrypoints.openai.protocol import ResponsesResponse
request = ResponsesRequest(
model="x", input="hi", parallel_tool_calls=False, store=False
)
@@ -141,6 +267,144 @@ class ResponsesResponseFromRequestTestCase(unittest.TestCase):
)
self.assertFalse(response.parallel_tool_calls)
def test_incomplete_status_sets_incomplete_details(self):
request = ResponsesRequest(model="x", input="hi", store=False)
incomplete = ResponsesResponse.from_request(
request,
sampling_params={},
model_name="x",
created_time=0,
output=[],
status="incomplete",
usage=UsageInfo(prompt_tokens=1, completion_tokens=1, total_tokens=2),
)
self.assertEqual(incomplete.status, "incomplete")
self.assertEqual(incomplete.incomplete_details, {"reason": "max_output_tokens"})
completed = ResponsesResponse.from_request(
request,
sampling_params={},
model_name="x",
created_time=0,
output=[],
status="completed",
usage=UsageInfo(prompt_tokens=1, completion_tokens=1, total_tokens=2),
)
self.assertIsNone(completed.incomplete_details)
def test_usage_serialized_in_responses_shape(self):
request = ResponsesRequest(model="x", input="hi", store=False)
resp = ResponsesResponse.from_request(
request,
sampling_params={},
model_name="x",
created_time=0,
output=[],
status="completed",
usage=UsageInfo(
prompt_tokens=11,
completion_tokens=102,
total_tokens=113,
reasoning_tokens=7,
prompt_tokens_details=PromptTokensDetails(cached_tokens=3),
),
)
usage = resp.model_dump()["usage"]
self.assertEqual(usage["input_tokens"], 11)
self.assertEqual(usage["output_tokens"], 102)
self.assertEqual(usage["total_tokens"], 113)
self.assertEqual(usage["output_tokens_details"]["reasoning_tokens"], 7)
self.assertEqual(usage["input_tokens_details"]["cached_tokens"], 3)
# Chat-style keys must be gone.
self.assertNotIn("prompt_tokens", usage)
self.assertNotIn("completion_tokens", usage)
def test_only_sdk_known_efforts_echoed_so_streaming_event_validates(self):
import openai.types.responses as ort
# OpenAI's Reasoning.effort literal is narrower than our tier list:
# "none" is a request-side extension and xhigh/max postdate it. Echoing
# one of those raises a ValidationError in the typed event below, which
# the stream generator builds before its try block -- the connection
# then dies without a single SSE byte.
for effort in ("none", "minimal", "low", "medium", "high", "xhigh", "max"):
resp = _in_progress_response(
ResponsesRequest(
model="x", input="hi", store=False, reasoning={"effort": effort}
)
)
expected = (
effort if effort in ("minimal", "low", "medium", "high") else None
)
self.assertEqual(resp.reasoning["effort"], expected, effort)
ort.ResponseCreatedEvent(
type="response.created", sequence_number=0, response=resp.model_dump()
)
class InputItemStringIdTestCase(CustomTestCase):
"""A response.output item replayed into input (string id + content) must
keep its content rather than collapse to an item-reference."""
def test_string_id_dropped_only_for_content_items(self):
norm = ResponsesRequest._normalize_input_item_for_validation
kept = norm(
{
"id": "msg_x",
"role": "assistant",
"content": [{"type": "output_text", "text": "hi"}],
}
)
self.assertNotIn("id", kept)
self.assertTrue(kept["content"])
# int ids and bare item-references are left alone
self.assertEqual(norm({"id": 123, "content": [{"type": "text"}]})["id"], 123)
ref = {"type": "item_reference", "id": "msg_ref"}
self.assertEqual(norm(ref), ref)
def test_request_accepts_replayed_output_item(self):
# Construction must not raise (the bug returned 400).
ResponsesRequest(
model="x",
store=False,
input=[
{
"id": "msg_x",
"role": "assistant",
"content": [{"type": "output_text", "text": "hi"}],
}
],
)
class ToolChoiceObjectFormTestCase(CustomTestCase):
def test_echoed_choice_is_what_the_server_honors(self):
import openai.types.responses as ort
named = {"type": "function", "name": "get_weather"}
# Object forms other than a named function cannot be forced through the
# tool-call parser, so the response must echo the "auto" we actually
# run -- which also keeps it inside the SDK's ToolChoice union that the
# typed event below validates against.
for tool_choice, expected in (
("auto", "auto"),
("required", "required"),
("none", "none"),
(named, named),
({"type": "function", "function": {"name": "get_weather"}}, named),
({"type": "web_search"}, "auto"),
({"type": "mcp", "server_label": "s"}, "auto"),
):
req = ResponsesRequest(
model="x", input="hi", store=False, tool_choice=tool_choice
)
self.assertEqual(req.effective_tool_choice(), expected, tool_choice)
resp = _in_progress_response(req)
self.assertEqual(resp.tool_choice, expected, tool_choice)
ort.ResponseCreatedEvent(
type="response.created", sequence_number=0, response=resp.model_dump()
)
if __name__ == "__main__":
unittest.main()
@@ -16,15 +16,20 @@ from sglang.srt.entrypoints.openai.protocol import (
RequestResponseMetadata,
ResponsesRequest,
)
from sglang.srt.entrypoints.openai.serving_responses import OpenAIServingResponses
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.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=8, suite="base-a-test-cpu")
register_cpu_ci(est_time=7, suite="base-a-test-cpu")
class InputMessageConstructionTestCase(unittest.TestCase):
class InputMessageConstructionTestCase(CustomTestCase):
def test_previous_response_replays_assistant_text_not_instructions(self):
serving = make_serving()
prev_response = Mock(id="resp_prev")
@@ -146,7 +151,7 @@ class InputMessageConstructionTestCase(unittest.TestCase):
pass
class ChatToolForwardingTestCase(unittest.TestCase):
class ChatToolForwardingTestCase(CustomTestCase):
def test_make_request_passes_function_tools_to_chat_processing(self):
serving = make_serving()
seen = {}
@@ -306,7 +311,7 @@ class ReasoningRequestForwardingTestCase(unittest.TestCase):
self.assertFalse(parser_cls.call_args.kwargs["force_reasoning"])
class InputItemNormalizationTestCase(unittest.TestCase):
class InputItemNormalizationTestCase(CustomTestCase):
def test_function_call_becomes_assistant_tool_call(self):
normalized = OpenAIServingResponses._normalize_response_message_for_chat(
{
@@ -361,7 +366,7 @@ class InputItemNormalizationTestCase(unittest.TestCase):
)
class FullResponseUsageTestCase(unittest.TestCase):
class FullResponseUsageTestCase(CustomTestCase):
def test_full_response_uses_dict_meta_info_for_usage(self):
serving = make_serving()
context = SimpleContext()
@@ -403,7 +408,7 @@ class FullResponseUsageTestCase(unittest.TestCase):
self.assertEqual(metadata.final_usage_info, response.usage)
class MultimodalRequestTestCase(unittest.TestCase):
class MultimodalRequestTestCase(CustomTestCase):
def test_text_only_create_responses_rejects_media_before_generation(self):
serving = make_serving()
serving._process_messages = Mock()
@@ -500,7 +505,13 @@ class MultimodalRequestTestCase(unittest.TestCase):
self.assertEqual(captured["adapted_request"].modalities, ["image"])
class OutputItemsTestCase(unittest.TestCase):
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",
@@ -517,8 +528,7 @@ class OutputItemsTestCase(unittest.TestCase):
)
def test_function_tool_call_extracted_via_parser(self):
serving = make_serving()
serving.tool_call_parser = "qwen3_coder"
serving = self.serving
fake_call = ToolCallItem(
tool_index=0, name="get_weather", parameters='{"city": "Beijing"}'
)
@@ -550,8 +560,7 @@ class OutputItemsTestCase(unittest.TestCase):
self.assertEqual(message_items[0].content[0].text, "trailing text")
def test_prose_emitted_before_tool_call_item(self):
serving = make_serving()
serving.tool_call_parser = "qwen3_coder"
serving = self.serving
fake_call = ToolCallItem(
tool_index=0, name="get_weather", parameters='{"city": "Beijing"}'
)
@@ -576,7 +585,7 @@ class OutputItemsTestCase(unittest.TestCase):
self.assertEqual(types, ["ResponseOutputMessage", "ResponseFunctionToolCall"])
def test_required_tool_choice_parses_json_array_without_native_parser(self):
serving = make_serving()
serving = self.serving
serving.tool_call_parser = None
request = ResponsesRequest(
model="x",
@@ -609,8 +618,7 @@ class OutputItemsTestCase(unittest.TestCase):
)
def test_no_tool_call_extraction_when_tool_choice_none(self):
serving = make_serving()
serving.tool_call_parser = "qwen3_coder"
serving = self.serving
request = ResponsesRequest(
model="x",
input="hi",
@@ -640,7 +648,7 @@ class OutputItemsTestCase(unittest.TestCase):
self.assertIsInstance(output_items[0], ResponseOutputMessage)
class HarmonyResponsesTestCase(unittest.TestCase):
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
@@ -660,5 +668,214 @@ class HarmonyResponsesTestCase(unittest.TestCase):
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()
@@ -1,67 +1,23 @@
import asyncio
import unittest
from unittest.mock import Mock, patch
from unittest.mock import patch
from utils import (
collect_stream_events,
StreamFixture,
engine_chunk,
event_payloads,
event_types,
find_completed_event,
make_serving,
)
from sglang.srt.entrypoints.openai.protocol import (
RequestResponseMetadata,
ResponsesRequest,
)
from sglang.srt.entrypoints.openai.protocol import ResponsesRequest
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=4, suite="base-a-test-cpu")
register_cpu_ci(est_time=7, suite="base-a-test-cpu")
class _StreamFixture:
def __init__(self, serving, request, *, require_reasoning=False):
self.serving = serving
self.request = request
self.require_reasoning = require_reasoning
self.request_metadata = RequestResponseMetadata(request_id=request.request_id)
def run(self, chunks):
async def gen():
for ch in chunks:
yield ch
async def collect():
return await collect_stream_events(
self.serving.responses_stream_generator_non_harmony(
self.request,
sampling_params={},
result_generator=gen(),
model_name="x",
tokenizer=Mock(),
request_metadata=self.request_metadata,
require_reasoning=self.require_reasoning,
)
)
return asyncio.run(collect())
def _engine_chunk(text, completion_tokens, *, finish=False):
return {
"text": text,
"meta_info": {
"id": "rid",
"prompt_tokens": 5,
"completion_tokens": completion_tokens,
"cached_tokens": 0,
"reasoning_tokens": 0,
"finish_reason": {"type": "stop"} if finish else None,
},
}
class NonHarmonyStreamTestCase(unittest.TestCase):
class NonHarmonyStreamTestCase(CustomTestCase):
def test_reasoning_parser_uses_processed_reasoning_state(self):
serving = make_serving()
serving.reasoning_parser = "deepseek-r1"
@@ -71,8 +27,8 @@ class NonHarmonyStreamTestCase(unittest.TestCase):
"sglang.srt.entrypoints.openai.serving_responses.ReasoningParser"
) as parser_cls:
parser_cls.return_value.parse_stream_chunk.return_value = (None, "done")
fixture = _StreamFixture(serving, request, require_reasoning=True)
fixture.run([_engine_chunk("done", 1, finish=True)])
fixture = StreamFixture(serving, request, require_reasoning=True)
fixture.run([engine_chunk("done", 1, finish=True)])
self.assertTrue(parser_cls.call_args.kwargs["force_reasoning"])
@@ -82,12 +38,12 @@ class NonHarmonyStreamTestCase(unittest.TestCase):
serving.tool_call_parser = None
request = ResponsesRequest(model="x", input="hi", stream=True, store=False)
fixture = _StreamFixture(serving, request)
fixture = StreamFixture(serving, request)
events = fixture.run(
[
_engine_chunk("Hel", 1),
_engine_chunk("Hello", 2),
_engine_chunk("Hello world", 4, finish=True),
engine_chunk("Hel", 1),
engine_chunk("Hello", 2),
engine_chunk("Hello world", 4, finish=True),
]
)
@@ -134,10 +90,10 @@ class NonHarmonyStreamTestCase(unittest.TestCase):
while sent < len(payload):
sent += min(8, len(payload) - sent)
chunks.append(
_engine_chunk(payload[:sent], sent, finish=sent == len(payload))
engine_chunk(payload[:sent], sent, finish=sent == len(payload))
)
fixture = _StreamFixture(serving, request)
fixture = StreamFixture(serving, request)
events = fixture.run(chunks)
types = event_types(events)
@@ -193,9 +149,9 @@ class NonHarmonyStreamTestCase(unittest.TestCase):
StreamingParseResult(normal_text="It's sunny.", calls=[]),
]
chunks = [
_engine_chunk(" " * 3, 3),
_engine_chunk(" " * 10, 10),
_engine_chunk(" " * 14, 14, finish=True),
engine_chunk(" " * 3, 3),
engine_chunk(" " * 10, 10),
engine_chunk(" " * 14, 14, finish=True),
]
script_iter = iter(scripted)
@@ -211,7 +167,7 @@ class NonHarmonyStreamTestCase(unittest.TestCase):
parser_cls.return_value.parse_stream_chunk.side_effect = (
fake_parse_stream_chunk
)
fixture = _StreamFixture(serving, request)
fixture = StreamFixture(serving, request)
events = fixture.run(chunks)
completed = find_completed_event(events)
@@ -223,5 +179,109 @@ class NonHarmonyStreamTestCase(unittest.TestCase):
self.assertEqual(output[2]["content"][0]["text"], "It's sunny.")
class MultiToolCallStreamingOrderTestCase(CustomTestCase):
"""The wire order of message / function_call items across tool-call deltas."""
def setUp(self):
from sglang.srt.function_call.qwen3_coder_detector import Qwen3CoderDetector
self.serving = make_serving()
self.serving.tool_call_parser = "qwen3_coder"
self.serving.reasoning_parser = None
det = Qwen3CoderDetector()
s, e = det.tool_call_start_token, det.tool_call_end_token
fp, fe = det.tool_call_prefix, det.function_end_token
pp, pe = det.parameter_prefix, det.parameter_end_token
self.weather = f"{s}{fp}get_weather>{pp}city>Beijing{pe}{fe}{e}"
self.time = f"{s}{fp}get_time>{pp}tz>UTC{pe}{fe}{e}"
# a prefix of ``weather`` that stops mid-arguments
self.weather_head = f"{s}{fp}get_weather>{pp}city>Beij"
def _seq(self, texts, *names):
"""Stream cumulative ``texts`` (last one final) and return (type, payload)."""
request = ResponsesRequest(
model="x",
input="weather and time",
store=False,
tools=[
{"type": "function", "name": n, "parameters": {"type": "object"}}
for n in names
],
)
chunks = [engine_chunk(t) for t in texts]
chunks.append(engine_chunk(texts[-1], finish=True))
return StreamFixture(self.serving, request).run_seq(chunks)
@staticmethod
def _added(seq):
return [
(p["output_index"], p["item"].get("type"))
for t, p in seq
if t == "response.output_item.added"
]
@staticmethod
def _done_calls(seq):
return [
p["item"]
for t, p in seq
if t == "response.output_item.done"
and p["item"].get("type") == "function_call"
]
def test_prior_tool_call_done_before_next_added(self):
full = self.weather + "\n" + self.time
seq = self._seq(
[self.weather, self.weather + "\n", full], "get_weather", "get_time"
)
def position(pred):
return next(i for i, (t, p) in enumerate(seq) if pred(t, p))
done0 = position(
lambda t, p: t == "response.output_item.done" and p["output_index"] == 0
)
added1 = position(
lambda t, p: t == "response.output_item.added" and p["output_index"] == 1
)
self.assertLess(done0, added1)
items = self._done_calls(seq)
self.assertEqual(sorted(i["name"] for i in items), ["get_time", "get_weather"])
def test_prose_before_tool_call_keeps_message_first(self):
"""Prose and a tool-call start in one delta: the message item must come
first, since the prose preceded the call."""
# One delta spanning prose + the whole call, as spec decoding or
# --stream-interval > 1 produces.
seq = self._seq(["Let me check." + self.weather], "get_weather")
added = self._added(seq)
message_index = next(i for i, kind in added if kind == "message")
call_index = next(i for i, kind in added if kind == "function_call")
self.assertLess(message_index, call_index)
# The call must not be split across two items by the reordering.
self.assertEqual(len([k for _, k in added if k == "function_call"]), 1)
def test_call_tail_prose_and_next_call_in_one_delta(self):
"""One delta closing tool1, carrying prose, and opening tool2 needs both
orders at once: tool1's trailing "}" must be drained before the prose
closes every open item, and tool2 must land after the message."""
seq = self._seq(
[self.weather_head, self.weather + "Here you go." + self.time],
"get_weather",
"get_time",
)
items = self._done_calls(seq)
# No duplicate item invented for the already-closed call, and no call
# left nameless by being reopened from an args-only fragment.
self.assertEqual(len(items), 2)
self.assertTrue(all(i["name"] for i in items))
self.assertEqual(items[0]["arguments"], '{"city": "Beijing"}')
if __name__ == "__main__":
unittest.main()
@@ -20,10 +20,12 @@ from sglang.test.test_utils import maybe_stub_sgl_kernel
maybe_stub_sgl_kernel()
import asyncio
import json
from typing import AsyncIterator
from unittest.mock import Mock
from sglang.srt.entrypoints.openai.protocol import RequestResponseMetadata
from sglang.srt.entrypoints.openai.serving_responses import OpenAIServingResponses
from sglang.test.ci.ci_register import register_cpu_ci
@@ -115,3 +117,52 @@ def find_completed_event(events: list[str]) -> dict:
if lines and lines[0] == "event: response.completed":
return json.loads(lines[1][len("data: ") :])
raise AssertionError("response.completed event missing from stream")
def engine_chunk(text, completion_tokens=1, *, finish=False):
return {
"text": text,
"meta_info": {
"id": "rid",
"prompt_tokens": 5,
"completion_tokens": completion_tokens,
"cached_tokens": 0,
"reasoning_tokens": 0,
"finish_reason": {"type": "stop"} if finish else None,
},
}
class StreamFixture:
"""Drives ``responses_stream_generator_non_harmony`` over a chunk list."""
def __init__(self, serving, request, *, require_reasoning=False):
self.serving = serving
self.request = request
self.require_reasoning = require_reasoning
self.request_metadata = RequestResponseMetadata(request_id=request.request_id)
def run(self, chunks) -> list[str]:
async def gen():
for ch in chunks:
yield ch
async def collect():
return await collect_stream_events(
self.serving.responses_stream_generator_non_harmony(
self.request,
sampling_params={},
result_generator=gen(),
model_name="x",
tokenizer=Mock(),
request_metadata=self.request_metadata,
require_reasoning=self.require_reasoning,
)
)
return asyncio.run(collect())
def run_seq(self, chunks) -> list[tuple]:
"""``run`` plus (event type, payload) pairing, the common assertion shape."""
events = self.run(chunks)
return list(zip(event_types(events), event_payloads(events)))