From 85712fa5b034a826d989ff858e8f6556d9a7164d Mon Sep 17 00:00:00 2001 From: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:47:55 +0100 Subject: [PATCH] Fix Responses API request handling (#25881) Co-authored-by: Kai-Hsun Chen Co-authored-by: Kristin Cowalcijk Co-authored-by: aerosta <63026763+aerosta@users.noreply.github.com> Co-authored-by: glaziermag Co-authored-by: Blake Ledden Co-authored-by: PanJason Co-authored-by: Leoyzen Co-authored-by: kennyu <966806+kennyu@users.noreply.github.com> --- .../sglang/srt/entrypoints/harmony_utils.py | 27 +- python/sglang/srt/entrypoints/http_server.py | 5 +- .../sglang/srt/entrypoints/openai/protocol.py | 148 +- .../entrypoints/openai/serving_responses.py | 1186 ++++++++++++++++- .../sglang/srt/parser/jinja_template_utils.py | 14 +- python/sglang/srt/parser/reasoning_parser.py | 3 + .../openai/test_responses_protocol.py | 146 ++ .../openai/test_serving_responses.py | 529 ++++++++ .../openai/test_serving_responses_stream.py | 211 +++ .../unit/entrypoints/openai/utils.py | 109 ++ .../unit/parser/test_jinja_template_utils.py | 31 + 11 files changed, 2312 insertions(+), 97 deletions(-) create mode 100644 test/registered/unit/entrypoints/openai/test_responses_protocol.py create mode 100644 test/registered/unit/entrypoints/openai/test_serving_responses.py create mode 100644 test/registered/unit/entrypoints/openai/test_serving_responses_stream.py create mode 100644 test/registered/unit/entrypoints/openai/utils.py diff --git a/python/sglang/srt/entrypoints/harmony_utils.py b/python/sglang/srt/entrypoints/harmony_utils.py index 86d4356dc..33bd88783 100644 --- a/python/sglang/srt/entrypoints/harmony_utils.py +++ b/python/sglang/srt/entrypoints/harmony_utils.py @@ -3,6 +3,7 @@ # Adapted from vLLM: https://github.com/vllm-project/vllm/blob/1b9902806915040ac9b3029f2ab7522ec505afc3/vllm/entrypoints/harmony_utils.py # Slight differences in processing chat messages import datetime +import logging from collections.abc import Iterable from typing import Literal, Optional, Union @@ -42,6 +43,8 @@ from openai_harmony import ( from sglang.srt.entrypoints.openai.protocol import ResponseInputOutputItem from sglang.srt.utils import random_uuid +logger = logging.getLogger(__name__) + REASONING_EFFORT = { "high": ReasoningEffort.HIGH, "medium": ReasoningEffort.MEDIUM, @@ -92,13 +95,22 @@ def get_developer_message( if tools is not None: function_tools = [] for tool in tools: - if tool.type in ("web_search_preview", "code_interpreter"): + if tool.type in ( + "web_search", + "web_search_preview", + "code_interpreter", + ): # These are built-in tools that are added to the system message. pass elif tool.type == "function": function_tools.append(tool) else: - raise ValueError(f"tool type {tool.type} not supported") + # No harmony prompt template for the remaining built-ins; + # drop them so the request still runs. + logger.debug( + "harmony: ignoring unsupported response tool type %r", + tool.type, + ) if function_tools: function_tool_descriptions = [ ToolDescription.new( @@ -139,7 +151,16 @@ def parse_response_input( if isinstance(content, str): msg = Message.from_role_and_content(role, text_prefix + content) else: - contents = [TextContent(text=text_prefix + c["text"]) for c in content] + # Filter to text parts first, then enumerate, so the surviving first + # text chunk always carries the system→developer text_prefix even if + # earlier parts were non-text (image/audio) and got dropped. + text_chunks = [ + c for c in content if c.get("type") in ("text", "input_text") + ] + contents = [ + TextContent(text=(text_prefix if i == 0 else "") + c.get("text", "")) + for i, c in enumerate(text_chunks) + ] msg = Message.from_role_and_contents(role, contents) elif response_msg["type"] == "function_call_output": call_id = response_msg["call_id"] diff --git a/python/sglang/srt/entrypoints/http_server.py b/python/sglang/srt/entrypoints/http_server.py index cc1002084..86efbd085 100644 --- a/python/sglang/srt/entrypoints/http_server.py +++ b/python/sglang/srt/entrypoints/http_server.py @@ -1799,12 +1799,11 @@ async def v1_score_request(request: ScoringRequest, raw_request: Request): @app.post("/v1/responses", dependencies=[Depends(validate_json_request)]) -async def v1_responses_request(request: dict, raw_request: Request): +async def v1_responses_request(request: ResponsesRequest, raw_request: Request): """Endpoint for the responses API with reasoning support.""" - request_obj = ResponsesRequest(**request) result = await raw_request.app.state.openai_serving_responses.create_responses( - request_obj, raw_request + request, raw_request ) # Handle streaming responses diff --git a/python/sglang/srt/entrypoints/openai/protocol.py b/python/sglang/srt/entrypoints/openai/protocol.py index eae4a312f..d23366372 100644 --- a/python/sglang/srt/entrypoints/openai/protocol.py +++ b/python/sglang/srt/entrypoints/openai/protocol.py @@ -1284,14 +1284,46 @@ class ResponseReasoningParam(BaseModel): default="medium", description="Constrains effort on reasoning for reasoning models.", ) + summary: Optional[Literal["auto", "concise", "detailed"]] = Field( + default=None, + description="Include a summary of the model's reasoning trace on the response.", + ) + + +# Only ``function`` / ``web_search*`` / ``code_interpreter`` are wired to +# execution paths; the rest pass validation so clients aren't rejected. +RESPONSE_TOOL_TYPES = Literal[ + "function", + "web_search", + "web_search_preview", + "code_interpreter", + "file_search", + "image_generation", + "computer_use_preview", + "local_shell", + "mcp", + "custom", + "namespace", + "tool_search", +] class ResponseTool(BaseModel): """Tool definition for responses.""" - type: Literal["web_search_preview", "code_interpreter"] = Field( - description="Type of tool to enable" - ) + type: RESPONSE_TOOL_TYPES = Field(description="Type of tool to enable") + name: Optional[str] = None + description: Optional[str] = None + parameters: Optional[Dict[str, Any]] = None + strict: bool = False + # Inner schemas for ``namespace`` tools. + tools: Optional[List[Dict[str, Any]]] = None + + @model_validator(mode="after") + def validate_function_tool(self) -> "ResponseTool": + if self.type == "function" and not self.name: + raise ValueError("Function tools must include a name.") + return self ResponseInputOutputItem: TypeAlias = Union[ @@ -1318,7 +1350,9 @@ class ResponsesRequest(BaseModel): ] ] ] = None - input: Union[str, List[ResponseInputOutputItem]] + # Accept dict-shaped items as the loose arm; downstream normalization + # handles replayed shapes that don't satisfy every openai TypedDict. + input: Union[str, List[ResponseInputOutputItem], List[Dict[str, Any]]] instructions: Optional[str] = None max_output_tokens: Optional[int] = None max_tool_calls: Optional[int] = None @@ -1352,13 +1386,13 @@ class ResponsesRequest(BaseModel): default=None, description="Cache salt for request caching" ) - # SGLang-specific sampling parameters + # SGLang sampling extras. ``None`` defers to ``--preferred-sampling-params``. frequency_penalty: float = 0.0 presence_penalty: float = 0.0 stop: Optional[Union[str, List[str]]] = None - top_k: int = -1 - min_p: float = 0.0 - repetition_penalty: float = 1.0 + top_k: Optional[int] = None + min_p: Optional[float] = None + repetition_penalty: Optional[float] = None # Default sampling parameters _DEFAULT_SAMPLING_PARAMS = { @@ -1369,8 +1403,57 @@ class ResponsesRequest(BaseModel): "repetition_penalty": 1.0, } + @model_validator(mode="before") + @classmethod + def normalize_responses_input(cls, values): + if not isinstance(values, dict): + return values + + input_value = values.get("input") + if not isinstance(input_value, list): + return values + + values = values.copy() + values["input"] = [ + cls._normalize_input_item_for_validation(item) for item in input_value + ] + return values + + @staticmethod + def _normalize_input_item_for_validation(item): + if not isinstance(item, dict): + return item + + content = item.get("content") + if not isinstance(content, list): + return item + + item = item.copy() + item["content"] = [ + ResponsesRequest._normalize_content_part_for_validation(part) + for part in content + ] + return item + + @staticmethod + def _normalize_content_part_for_validation(part): + if not isinstance(part, dict): + return part + + part_type = part.get("type") + if part_type != "input_image" or part.get("detail") is not None: + return part + + part = part.copy() + part["detail"] = "auto" + return part + def to_sampling_params( - self, default_max_tokens: int, default_params: Optional[Dict] = None + self, + default_max_tokens: int, + default_params: Optional[Dict] = None, + stop: Optional[Union[str, List[str]]] = None, + tool_call_constraint: Optional[ToolCallConstraint] = None, ) -> Dict[str, Any]: """Convert to sampling parameters for generation.""" if default_params is None: @@ -1382,10 +1465,9 @@ class ResponsesRequest(BaseModel): else: max_tokens = default_max_tokens - # Avoid exceed the context length by minus 2 token + # Headroom for BOS/EOS the engine appends on top of prompt+budget. max_tokens -= 2 - # Get parameters with defaults temperature = self.temperature if temperature is None: temperature = default_params.get( @@ -1396,23 +1478,51 @@ class ResponsesRequest(BaseModel): if top_p is None: top_p = default_params.get("top_p", self._DEFAULT_SAMPLING_PARAMS["top_p"]) - params = { + # Omit None entries so they fall through to ``--preferred-sampling-params`` + # rather than overriding it with a literal default. + params: dict[str, Any] = { "max_new_tokens": max_tokens, "temperature": temperature, "top_p": top_p, "frequency_penalty": self.frequency_penalty, "presence_penalty": self.presence_penalty, - "stop": self.stop, - "top_k": self.top_k, - "min_p": self.min_p, - "repetition_penalty": self.repetition_penalty, + "stop": self.stop if stop is None else stop, } + if self.top_k is not None: + params["top_k"] = self.top_k + if self.min_p is not None: + params["min_p"] = self.min_p + if self.repetition_penalty is not None: + params["repetition_penalty"] = self.repetition_penalty # Apply any additional default parameters for key, value in default_params.items(): if key not in params or params[key] is None: params[key] = value + has_existing_constraints = ( + params.get("regex") + or params.get("ebnf") + or params.get("structural_tag") + or params.get("json_schema") + ) + if tool_call_constraint and has_existing_constraints: + # Refuse rather than silently drop the tool-call grammar. + raise ValueError( + "Cannot combine tool calls with constrained decoding " + "(regex / ebnf / structural_tag / json_schema). Remove one." + ) + if tool_call_constraint: + constraint_type, constraint_value = tool_call_constraint + if constraint_type in ("structural_tag", "json_schema"): + params[constraint_type] = convert_json_schema_to_str( + constraint_value.model_dump(by_alias=True) + if hasattr(constraint_value, "model_dump") + else constraint_value + ) + else: + params[constraint_type] = constraint_value + return params @@ -1515,7 +1625,11 @@ class ResponsesResponse(BaseModel): output=output, status=status, usage=usage, - parallel_tool_calls=request.parallel_tool_calls or True, + parallel_tool_calls=( + request.parallel_tool_calls + if request.parallel_tool_calls is not None + else True + ), tool_choice=request.tool_choice, tools=request.tools, # fields for parity with v1/responses diff --git a/python/sglang/srt/entrypoints/openai/serving_responses.py b/python/sglang/srt/entrypoints/openai/serving_responses.py index edda5a471..86ed083fc 100644 --- a/python/sglang/srt/entrypoints/openai/serving_responses.py +++ b/python/sglang/srt/entrypoints/openai/serving_responses.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio -import copy import json import logging import time @@ -27,6 +26,15 @@ from openai.types.responses.response_function_tool_call import ResponseFunctionT from openai.types.responses.response_reasoning_item import ( Content as ResponseReasoningTextContent, ) +from openai.types.responses.response_reasoning_item import ( + Summary as ResponseReasoningSummary, +) +from openai.types.responses.response_reasoning_summary_part_added_event import ( + Part as ResponseReasoningSummaryAddedPart, +) +from openai.types.responses.response_reasoning_summary_part_done_event import ( + Part as ResponseReasoningSummaryDonePart, +) from openai_harmony import Message as OpenAIMessage from sglang.srt.entrypoints.context import ( @@ -48,14 +56,19 @@ from sglang.srt.entrypoints.harmony_utils import ( from sglang.srt.entrypoints.openai.protocol import ( ChatCompletionMessageParam, ChatCompletionRequest, + Function, + MessageProcessingResult, PromptTokenUsageInfo, RequestResponseMetadata, ResponsesRequest, ResponsesResponse, + Tool, UsageInfo, ) from sglang.srt.entrypoints.openai.serving_chat import OpenAIServingChat from sglang.srt.entrypoints.openai.tool_server import MCPToolServer, ToolServer +from sglang.srt.function_call.function_call_parser import FunctionCallParser +from sglang.srt.function_call.json_array_parser import JsonArrayParser from sglang.srt.managers.io_struct import GenerateReqInput from sglang.srt.parser.reasoning_parser import ReasoningParser from sglang.srt.utils import random_uuid @@ -84,8 +97,9 @@ class OpenAIServingResponses(OpenAIServingChat): self.reasoning_parser = self.tokenizer_manager.server_args.reasoning_parser self.enable_prompt_tokens_details = enable_prompt_tokens_details - # Get default sampling params from model config if available - self.default_sampling_params = {} + # Parent OpenAIServingChat.__init__ already populated default_sampling_params. + if not isinstance(self.default_sampling_params, dict): + self.default_sampling_params = {} self.supports_browsing = ( tool_server.has_tool("browser") if tool_server else False @@ -171,6 +185,15 @@ class OpenAIServingResponses(OpenAIServingChat): # FIXME: If the engine is dead, raise an error # This is required for the streaming case + # ``tool_choice="required"`` only works with ``function`` tools. + if request.tool_choice == "required" and not any( + tool.type == "function" for tool in (request.tools or []) + ): + return self.create_error_response( + 'tool_choice="required" requires at least one tool with ' + 'type="function"; other built-in tool types cannot be forced.' + ) + # Handle the previous response ID prev_response_id = request.previous_response_id if prev_response_id is not None: @@ -186,15 +209,19 @@ class OpenAIServingResponses(OpenAIServingChat): try: model_name = request.model tokenizer = self.tokenizer_manager.tokenizer + processed_messages: Optional[MessageProcessingResult] = None if self.use_harmony: messages, request_prompts, engine_prompts = ( self._make_request_with_harmony(request, prev_response) ) else: - messages, request_prompts, engine_prompts = await self._make_request( - request, prev_response, tokenizer - ) + ( + messages, + request_prompts, + engine_prompts, + processed_messages, + ) = await self._make_request(request, prev_response, tokenizer) except (ValueError, TypeError, RuntimeError, jinja2.TemplateError) as e: logger.exception("Error in preprocessing prompt inputs") @@ -210,7 +237,7 @@ class OpenAIServingResponses(OpenAIServingChat): and (request.background or request.stream) and request.tools and any( - tool.type in ["web_search_preview", "code_interpreter"] + tool.type in ("web_search", "web_search_preview", "code_interpreter") for tool in request.tools ) ): @@ -244,10 +271,10 @@ class OpenAIServingResponses(OpenAIServingChat): tool_sessions = {} for i, engine_prompt in enumerate(engine_prompts): # Calculate default max tokens from context length minus prompt length - if hasattr(engine_prompt, "__len__"): - prompt_length = len(engine_prompt) - elif isinstance(engine_prompt, list): + if isinstance(engine_prompt, list): prompt_length = len(engine_prompt) + elif isinstance(engine_prompt, str): + prompt_length = len(tokenizer.encode(engine_prompt)) else: prompt_length = 0 @@ -263,7 +290,18 @@ class OpenAIServingResponses(OpenAIServingChat): context_len - prompt_length - num_reserved_tokens, 512 ) # Ensure minimum 512 tokens sampling_params = request.to_sampling_params( - default_max_tokens, self.default_sampling_params + default_max_tokens, + self.default_sampling_params, + stop=( + processed_messages.stop + if processed_messages + else request.stop + ), + tool_call_constraint=( + processed_messages.tool_call_constraint + if processed_messages + else None + ), ) context: ConversationContext @@ -276,8 +314,33 @@ class OpenAIServingResponses(OpenAIServingChat): context = SimpleContext() # Create GenerateReqInput for SGLang + if isinstance(engine_prompt, str): + prompt_kwargs = {"text": engine_prompt} + else: + prompt_kwargs = {"input_ids": engine_prompt} + adapted_request = GenerateReqInput( - input_ids=engine_prompt, + **prompt_kwargs, + image_data=( + processed_messages.image_data + if processed_messages + else None + ), + video_data=( + processed_messages.video_data + if processed_messages + else None + ), + audio_data=( + processed_messages.audio_data + if processed_messages + else None + ), + modalities=( + processed_messages.modalities + if processed_messages + else None + ), sampling_params=sampling_params, stream=request.stream, rid=request.request_id, @@ -342,11 +405,20 @@ class OpenAIServingResponses(OpenAIServingChat): return response if request.stream: - return self.responses_stream_generator( + if self.use_harmony: + return self.responses_stream_generator( + request, + sampling_params, + result_generator, + context, + model_name, + tokenizer, + request_metadata, + ) + return self.responses_stream_generator_non_harmony( request, sampling_params, result_generator, - context, model_name, tokenizer, request_metadata, @@ -374,43 +446,34 @@ class OpenAIServingResponses(OpenAIServingChat): prev_response: Optional[ResponsesResponse], tokenizer: Any, ): - # Construct the input messages messages = self._construct_input_messages(request, prev_response) - # Follow SGLang's pattern: create a ChatCompletionRequest and process messages - try: - # Convert ResponsesRequest to ChatCompletionRequest for processing - chat_request = ChatCompletionRequest( - model=request.model, - messages=messages, - stream=request.stream, - ) + chat_tools = self._response_tools_to_chat_tools(request) + chat_request = ChatCompletionRequest( + model=request.model, + messages=messages, + stream=request.stream, + tools=chat_tools or None, + tool_choice=request.tool_choice if chat_tools else "none", + parallel_tool_calls=( + request.parallel_tool_calls + if request.parallel_tool_calls is not None + else True + ), + stop=request.stop, + ) - # Follow SGLang's _process_messages pattern - is_multimodal = self.tokenizer_manager.model_config.is_multimodal - processed_messages = self._process_messages(chat_request, is_multimodal) + is_multimodal = self.tokenizer_manager.model_config.is_multimodal + processed_messages = self._process_messages(chat_request, is_multimodal) - # Extract the results - if is_multimodal: - request_prompts = [processed_messages.prompt] - engine_prompts = [processed_messages.prompt] - else: - request_prompts = [processed_messages.prompt_ids] - engine_prompts = [processed_messages.prompt_ids] + if is_multimodal: + request_prompts = [processed_messages.prompt] + engine_prompts = [processed_messages.prompt] + else: + request_prompts = [processed_messages.prompt_ids] + engine_prompts = [processed_messages.prompt_ids] - except Exception as e: - logger.warning(f"Chat processing failed, using fallback: {e}") - # Fallback to simple encoding - prompt_text = "" - for msg in messages: - role = msg.get("role", "user") - content = msg.get("content", "") - prompt_text += f"{role}: {content}\n" - prompt_ids = tokenizer.encode(prompt_text) - request_prompts = [prompt_ids] - engine_prompts = [prompt_ids] - - return messages, request_prompts, engine_prompts + return messages, request_prompts, engine_prompts, processed_messages def _make_request_with_harmony( self, @@ -451,7 +514,7 @@ class OpenAIServingResponses(OpenAIServingChat): if self.use_harmony: assert isinstance(context, HarmonyContext) output = self._make_response_output_items_with_harmony(context) - # TODO: these are all 0 for now! + # num_reasoning_tokens isn't wired through HarmonyContext yet; stays 0. num_prompt_tokens = context.num_prompt_tokens num_generated_tokens = context.num_output_tokens num_cached_tokens = context.num_cached_tokens @@ -466,10 +529,29 @@ class OpenAIServingResponses(OpenAIServingChat): ) # Calculate usage from actual output - if hasattr(final_res, "meta_info"): - num_prompt_tokens = final_res.meta_info.get("prompt_tokens", 0) - num_generated_tokens = final_res.meta_info.get("completion_tokens", 0) - num_cached_tokens = final_res.meta_info.get("cached_tokens", 0) + num_reasoning_tokens = 0 + meta_info = None + if isinstance(final_res, dict) and isinstance( + final_res.get("meta_info"), dict + ): + meta_info = final_res["meta_info"] + elif hasattr(final_res, "meta_info"): + meta_info = final_res.meta_info + + if meta_info is not None: + num_prompt_tokens = meta_info.get("prompt_tokens", 0) + num_generated_tokens = meta_info.get("completion_tokens", 0) + num_cached_tokens = meta_info.get("cached_tokens", 0) + num_reasoning_tokens = meta_info.get("reasoning_tokens", 0) + elif isinstance(final_res, dict) and ( + final_res.get("prompt_token_ids") is not None + or final_res.get("output_ids") is not None + ): + prompt_token_ids = final_res.get("prompt_token_ids") or [] + output_token_ids = final_res.get("output_ids") or [] + num_prompt_tokens = len(prompt_token_ids) + num_generated_tokens = len(output_token_ids) + num_cached_tokens = final_res.get("num_cached_tokens", 0) elif hasattr(final_res, "prompt_token_ids") and hasattr( final_res, "outputs" ): @@ -483,7 +565,6 @@ class OpenAIServingResponses(OpenAIServingChat): else 0 ) num_cached_tokens = getattr(final_res, "num_cached_tokens", 0) - num_reasoning_tokens = 0 else: # Final fallback num_prompt_tokens = 0 @@ -522,18 +603,57 @@ class OpenAIServingResponses(OpenAIServingChat): return response + @staticmethod + def _wants_reasoning_summary(request: ResponsesRequest) -> bool: + return request.reasoning is not None and request.reasoning.summary is not None + + def _is_thinking_enabled_for_request(self, request: ResponsesRequest) -> bool: + """Whether to start the reasoning detector in thinking mode.""" + if not self.reasoning_parser: + return False + effort = request.reasoning.effort if request.reasoning is not None else None + if self.reasoning_parser == "hunyuan": + return effort not in (None, "none", "no_think") + if self.template_manager.force_reasoning: + return True + config = self.template_manager.reasoning_config + if config is None: + # Parser-only models (DeepSeek-R1, …) carry the thinking default in + # the detector itself. + detector = getattr(self, "_reasoning_detector", None) + mode = getattr(detector, "reasoning_default", None) if detector else None + if mode is None or mode == "always": + return mode == "always" + if mode == "mistral": + return effort is not None and effort != "none" + if mode in ("thinking", "enable_thinking"): + return effort != "none" + if mode in ("explicit_thinking", "explicit_enable_thinking"): + return False + return False + if config.special_case == "always": + return True + if config.special_case == "mistral": + return effort is not None and effort != "none" + if config.toggle_param is None or config.default_enabled is None: + return False + if effort == "none": + return False + return bool(config.default_enabled) + def _make_response_output_items( self, request: ResponsesRequest, final_output: Any, tokenizer: Any, ): - # Handle reasoning parsing if enabled if self.reasoning_parser: - # Use standard reasoning parser (openai maps to T4Detector internally) + # Templates that prefill ```` only emit the close tag, so + # start the detector in thinking mode. reasoning_parser = ReasoningParser( model_type=self.reasoning_parser, stream_reasoning=False, + force_reasoning=self._is_thinking_enabled_for_request(request), request=request, ) reasoning_content, content = reasoning_parser.parse_non_stream(final_output) @@ -543,10 +663,21 @@ class OpenAIServingResponses(OpenAIServingChat): output_items = [] if reasoning_content: + # Mirror the single parsed blob into ``summary`` when the caller opts + # in via ``reasoning.summary``; full trace stays in ``content``. + wants_summary = self._wants_reasoning_summary(request) reasoning_item = ResponseReasoningItem( id=f"rs_{random_uuid()}", type="reasoning", - summary=[], + summary=( + [ + ResponseReasoningSummary( + type="summary_text", text=reasoning_content + ) + ] + if wants_summary + else [] + ), content=[ ResponseReasoningTextContent( type="reasoning_text", text=reasoning_content @@ -555,6 +686,65 @@ class OpenAIServingResponses(OpenAIServingChat): status=None, ) output_items.append(reasoning_item) + + chat_tools = self._response_tools_to_chat_tools(request) + is_required = request.tool_choice == "required" + tool_call_items: list[ResponseFunctionToolCall] = [] + parsed_via_native = False + if ( + content + and chat_tools + and self.tool_call_parser + and request.tool_choice != "none" + ): + parser = FunctionCallParser(chat_tools, self.tool_call_parser) + should_try_native = ( + not is_required or parser.detector.supports_structural_tag() + ) + if should_try_native and parser.has_tool_call(content): + try: + content, call_info_list = parser.parse_non_stream(content) + for call_info in call_info_list: + tool_call_items.append( + ResponseFunctionToolCall( + arguments=call_info.parameters or "", + call_id=f"call_{random_uuid()[:24]}", + type="function_call", + name=call_info.name, + id=f"fc_{random_uuid()[:8]}", + status="completed", + ) + ) + parsed_via_native = bool(call_info_list) + except Exception as e: + logger.error("Tool call parsing error: %s", e) + + if content and chat_tools and is_required and not parsed_via_native: + try: + tool_call_data = orjson.loads(content) + if isinstance(tool_call_data, dict): + tool_call_data = [tool_call_data] + if isinstance(tool_call_data, list): + for tool in tool_call_data: + if not isinstance(tool, dict) or "name" not in tool: + continue + arguments = json.dumps( + tool.get("parameters", {}), ensure_ascii=False + ) + tool_call_items.append( + ResponseFunctionToolCall( + arguments=arguments, + call_id=f"call_{random_uuid()[:24]}", + type="function_call", + name=tool["name"], + id=f"fc_{random_uuid()[:8]}", + status="completed", + ) + ) + content = "" + except Exception as e: + logger.error("Required tool JSON parse error: %s", e) + if content: output_text = ResponseOutputText( text=content, @@ -570,6 +760,7 @@ class OpenAIServingResponses(OpenAIServingChat): type="message", ) output_items.append(message) + output_items.extend(tool_call_items) return output_items def _make_response_output_items_with_harmony( @@ -586,6 +777,247 @@ class OpenAIServingResponses(OpenAIServingChat): output_items.extend(last_items) return output_items + @staticmethod + def _response_tools_to_chat_tools(request: ResponsesRequest) -> list[Tool]: + # Only ``function`` tools flow to chat; built-ins go through harmony. + chat_tools = [] + for tool in request.tools: + if tool.type != "function": + continue + chat_tools.append( + Tool( + type="function", + function=Function( + name=tool.name, + description=tool.description, + parameters=tool.parameters, + strict=tool.strict, + ), + ) + ) + return chat_tools + + @staticmethod + def _normalize_response_content_part_for_chat(content_part: Any) -> Any: + # Default detail=\"auto\" and lift flat min/max_dynamic_patch onto + # image_url so the image preprocessor sees them. + if hasattr(content_part, "model_dump"): + content_part = content_part.model_dump(exclude_none=True) + if not isinstance(content_part, dict): + return content_part + + part_type = content_part.get("type") + if part_type in ("input_text", "output_text"): + return {"type": "text", "text": content_part.get("text", "")} + + if part_type == "input_image": + image_url = content_part.get("image_url") + if isinstance(image_url, dict): + image_url_obj = image_url.copy() + else: + image_url_obj = {"url": image_url} + if not image_url_obj.get("detail"): + image_url_obj["detail"] = content_part.get("detail") or "auto" + for key in ("min_dynamic_patch", "max_dynamic_patch"): + if key in content_part and key not in image_url_obj: + image_url_obj[key] = content_part[key] + return {"type": "image_url", "image_url": image_url_obj} + + if part_type == "text": + return content_part + + if part_type == "image_url": + image_url = content_part.get("image_url") + if isinstance(image_url, str): + image_url = { + "url": image_url, + "detail": content_part.get("detail", "auto"), + } + elif isinstance(image_url, dict): + image_url = image_url.copy() + if not image_url.get("detail"): + image_url["detail"] = content_part.get("detail") or "auto" + return {**content_part, "image_url": image_url} + + return content_part + + @classmethod + def _normalize_response_message_for_chat(cls, message: Any) -> Any: + """Convert one Responses-API input item to a chat-completions message.""" + if hasattr(message, "model_dump"): + message = message.model_dump(exclude_none=True) + if not isinstance(message, dict): + return message + + # Most chat templates only recognize system/user/assistant/tool; + # collapse ``developer`` to ``system`` at the boundary. + if message.get("role") == "developer": + message = {**message, "role": "system"} + + msg_type = message.get("type") + if msg_type == "function_call": + # Coerce ``arguments`` to a valid JSON-object string so the chat + # template's unconditional ``orjson.loads`` survives truncated or + # dict-shaped echoes. + raw = message.get("arguments") + if isinstance(raw, str): + try: + parsed = orjson.loads(raw) if raw else None + except orjson.JSONDecodeError: + parsed = None + if not isinstance(parsed, dict): + raw = "{}" + elif isinstance(raw, dict): + raw = orjson.dumps(raw).decode("utf-8") + else: + raw = "{}" + return { + "role": "assistant", + "tool_calls": [ + { + "id": message.get("call_id") or message.get("id"), + "type": "function", + "function": { + "name": message.get("name"), + "arguments": raw, + }, + } + ], + } + if msg_type == "function_call_output": + return { + "role": "tool", + "tool_call_id": message.get("call_id"), + "content": message.get("output", ""), + } + # Reasoning items render as {role: assistant, reasoning_content}; + # empty ones drop instead of injecting an empty assistant block. + if msg_type == "reasoning": + # Prefer ``summary``; fall back to ``content`` only when summary + # is empty, since clients often populate both with the same text. + def _collect(parts): + out: list[str] = [] + for entry in parts or []: + if isinstance(entry, dict): + text = entry.get("text") + if text: + out.append(text) + return out + + text_parts = _collect(message.get("summary")) + if not text_parts: + text_parts = _collect(message.get("content")) + if not text_parts: + return None + return { + "role": "assistant", + "reasoning_content": "\n".join(text_parts), + } + if msg_type not in (None, "message"): + raise ValueError(f"Unsupported Responses API input item type: {msg_type!r}") + + content = message.get("content") + if not isinstance(content, list): + return { + k: v + for k, v in message.items() + if v is not None and k not in ("id", "status", "type") + } + + return { + k: v + for k, v in { + **message, + "content": [ + cls._normalize_response_content_part_for_chat(part) + for part in content + ], + }.items() + if v is not None and k not in ("id", "status", "type") + } + + @staticmethod + def _output_message_text(output_item: Any) -> Optional[str]: + """Return assistant text from a ``message`` output item (joining + ``output_text`` parts with newlines), or None for non-message items.""" + if isinstance(output_item, ResponseReasoningItem): + return None + if hasattr(output_item, "model_dump"): + output_item = output_item.model_dump(exclude_none=True) + if not isinstance(output_item, dict): + return None + if output_item.get("type") != "message": + return None + + text_parts = [] + for content in output_item.get("content") or []: + if isinstance(content, ResponseOutputText): + text_parts.append(content.text) + continue + if hasattr(content, "model_dump"): + content = content.model_dump(exclude_none=True) + if isinstance(content, dict) and content.get("type") == "output_text": + text = content.get("text") + if text is not None: + text_parts.append(text) + + return "\n".join(text_parts) if text_parts else None + + @staticmethod + def _merge_consecutive_assistant_messages( + messages: list, + ) -> list: + """Collapse runs of consecutive ``assistant`` dicts into one entry, + joining ``content`` and concatenating ``tool_calls`` and + ``reasoning_content`` so a logical turn renders as a single block.""" + merged: list = [] + for msg in messages: + if ( + isinstance(msg, dict) + and msg.get("role") == "assistant" + and merged + and isinstance(merged[-1], dict) + and merged[-1].get("role") == "assistant" + ): + prev = merged[-1] = dict(merged[-1]) + # Lift mixed str/list content to list parts so non-text parts + # (e.g. image_url) survive when the two sides differ in shape. + new_content = msg.get("content") + if new_content is not None and new_content != "": + prev_content = prev.get("content") + if prev_content is None or prev_content == "": + prev["content"] = new_content + elif isinstance(prev_content, str) and isinstance(new_content, str): + sep = "\n\n" if prev_content and new_content else "" + prev["content"] = prev_content + sep + new_content + else: + + def _as_parts(c): + if isinstance(c, list): + return list(c) + if isinstance(c, str) and c: + return [{"type": "text", "text": c}] + return [] + + prev["content"] = _as_parts(prev_content) + _as_parts( + new_content + ) + new_calls = msg.get("tool_calls") + if new_calls: + prev_calls = prev.get("tool_calls") or [] + prev["tool_calls"] = prev_calls + list(new_calls) + new_reasoning = msg.get("reasoning_content") + if new_reasoning: + prev_reasoning = prev.get("reasoning_content") + prev["reasoning_content"] = ( + f"{prev_reasoning}\n{new_reasoning}" + if prev_reasoning + else new_reasoning + ) + continue + merged.append(msg) + return merged + def _construct_input_messages( self, request: ResponsesRequest, @@ -606,26 +1038,49 @@ class OpenAIServingResponses(OpenAIServingChat): prev_msg = self.msg_store[prev_response.id] messages.extend(prev_msg) - # Add the previous output for output_item in prev_response.output: - # NOTE: We skip the reasoning output of the previous response - if isinstance(output_item, ResponseReasoningItem): + assistant_text = self._output_message_text(output_item) + if assistant_text is None: continue - for content in output_item.content: - messages.append( - { - "role": "system", - "content": request.instructions, - } - ) + messages.append({"role": "assistant", "content": assistant_text}) # Append the new input # Responses API supports simple text inputs without chat format if isinstance(request.input, str): messages.append({"role": "user", "content": request.input}) else: - messages.extend(request.input) # type: ignore - return messages + for input_item in request.input: + normalized = self._normalize_response_message_for_chat(input_item) + if normalized is not None: + messages.append(normalized) # type: ignore + + # One Responses-API assistant turn maps to multiple input items + # (message + function_call(s)); collapse them into one chat message + # so chat templates render a single assistant block per turn. + messages = self._merge_consecutive_assistant_messages(messages) + + # Most chat templates expect a single leading ``system`` message; + # coalesce any ``instructions`` + interleaved ``developer`` entries. + system_chunks: list[str] = [] + other_msgs: list = [] + for m in messages: + if isinstance(m, dict) and m.get("role") == "system": + content = m.get("content") + if isinstance(content, str): + system_chunks.append(content) + elif isinstance(content, list): + for part in content: + if isinstance(part, dict): + text = part.get("text") + if isinstance(text, str): + system_chunks.append(text) + else: + other_msgs.append(m) + if system_chunks: + return [ + {"role": "system", "content": "\n\n".join(system_chunks)} + ] + other_msgs + return other_msgs def _construct_input_messages_with_harmony( self, @@ -638,7 +1093,8 @@ class OpenAIServingResponses(OpenAIServingChat): reasoning_effort = request.reasoning.effort if request.reasoning else None tool_types = [tool.type for tool in request.tools] enable_browser = ( - "web_search_preview" in tool_types and self.tool_server is not None + any(t in tool_types for t in ("web_search", "web_search_preview")) + and self.tool_server is not None ) enable_code_interpreter = ( "code_interpreter" in tool_types and self.tool_server is not None @@ -693,7 +1149,7 @@ class OpenAIServingResponses(OpenAIServingChat): messages.append(get_user_message(request.input)) else: if prev_response is not None: - prev_outputs = copy(prev_response.output) + prev_outputs = list(prev_response.output) else: prev_outputs = [] for response_msg in request.input: @@ -1231,6 +1687,8 @@ class OpenAIServingResponses(OpenAIServingChat): ) # Convert final_response to the format expected by ResponseCompletedEvent response_dict = final_response.model_dump() + # OpenAI SDK's Tool union may not know extended types; drop echo. + response_dict["tools"] = [] # Convert UsageInfo to ResponseUsage format if response_dict.get("usage"): @@ -1255,6 +1713,596 @@ class OpenAIServingResponses(OpenAIServingChat): ) ) + async def responses_stream_generator_non_harmony( + self, + request: ResponsesRequest, + sampling_params: Any, + result_generator: AsyncIterator[Any], + model_name: str, + tokenizer: Any, + request_metadata: RequestResponseMetadata, + created_time: Optional[int] = None, + ) -> AsyncGenerator[str, None]: + """Stream a /v1/responses response as typed OpenAI SSE events for + non-harmony models. Each engine chunk is run through the reasoning + and function-call parsers; leftover text becomes + ``response.output_text.delta``. + """ + + created_time = created_time or int(time.time()) + sequence_number = 0 + + def _send_event(event): + nonlocal sequence_number + if hasattr(event, "sequence_number"): + event.sequence_number = sequence_number + sequence_number += 1 + event_type = getattr(event, "type", "unknown") + return ( + f"event: {event_type}\n" + f"data: {event.model_dump_json(indent=None)}\n\n" + ) + + # The streaming Response* event models echo ``tools`` through a + # narrower OpenAI SDK Tool union; strip it to avoid pydantic + # validation failures on extended tool types. + def _sanitize_response_dict(d: dict) -> dict: + d["tools"] = [] + return d + + initial_response = _sanitize_response_dict( + ResponsesResponse.from_request( + request, + sampling_params, + model_name=model_name, + created_time=created_time, + output=[], + status="in_progress", + usage=None, + ).model_dump() + ) + yield _send_event( + openai_responses_types.ResponseCreatedEvent( + type="response.created", + sequence_number=-1, + response=initial_response, + ) + ) + yield _send_event( + openai_responses_types.ResponseInProgressEvent( + type="response.in_progress", + sequence_number=-1, + response=initial_response, + ) + ) + + chat_tools = self._response_tools_to_chat_tools(request) + is_required = request.tool_choice == "required" + tool_parser: Optional[Union[FunctionCallParser, JsonArrayParser]] = None + if chat_tools and request.tool_choice != "none": + native_supports_structural_tag = False + if self.tool_call_parser: + probe = FunctionCallParser(chat_tools, self.tool_call_parser) + native_supports_structural_tag = ( + probe.detector.supports_structural_tag() + ) + if is_required and not native_supports_structural_tag: + tool_parser = JsonArrayParser() + elif self.tool_call_parser: + tool_parser = FunctionCallParser(chat_tools, self.tool_call_parser) + reasoning_parser_obj: Optional[ReasoningParser] = None + if self.reasoning_parser: + reasoning_parser_obj = ReasoningParser( + model_type=self.reasoning_parser, + stream_reasoning=True, + force_reasoning=self._is_thinking_enabled_for_request(request), + request=request, + ) + + current_output_index = -1 + reasoning_state = { + "open": False, + "item_id": "", + "output_index": -1, + "text": "", + } + message_state = { + "open": False, + "item_id": "", + "output_index": -1, + "text": "", + } + tool_call_states: dict[int, dict[str, Any]] = {} + # Items closed during the stream, in wire order. Feeds the final + # ``response.completed`` snapshot and the stored response. + emitted_items: list = [] + + prompt_tokens = 0 + completion_tokens = 0 + cached_tokens = 0 + total_tokens_meta = 0 + reasoning_tokens_meta = 0 + finish_reason: Optional[dict[str, Any]] = None + stream_offset = 0 + incremental = self.tokenizer_manager.server_args.incremental_streaming_output + + def _open_reasoning_item() -> str: + nonlocal current_output_index + current_output_index += 1 + item_id = f"rs_{random_uuid()}" + reasoning_state.update( + open=True, item_id=item_id, output_index=current_output_index, text="" + ) + return item_id + + wants_summary = self._wants_reasoning_summary(request) + + def _close_reasoning_item(): + if not reasoning_state["open"]: + return [] + text = reasoning_state["text"] + completed_item = ResponseReasoningItem( + id=reasoning_state["item_id"], + type="reasoning", + summary=( + [ResponseReasoningSummary(type="summary_text", text=text)] + if wants_summary + else [] + ), + content=[ + ResponseReasoningTextContent(type="reasoning_text", text=text), + ], + status="completed", + ) + events: list = [] + if wants_summary: + events.append( + _send_event( + openai_responses_types.ResponseReasoningSummaryTextDoneEvent( + type="response.reasoning_summary_text.done", + item_id=reasoning_state["item_id"], + sequence_number=-1, + output_index=reasoning_state["output_index"], + summary_index=0, + text=text, + ) + ) + ) + events.append( + _send_event( + openai_responses_types.ResponseReasoningSummaryPartDoneEvent( + type="response.reasoning_summary_part.done", + item_id=reasoning_state["item_id"], + sequence_number=-1, + output_index=reasoning_state["output_index"], + summary_index=0, + part=ResponseReasoningSummaryDonePart( + type="summary_text", text=text + ), + ) + ) + ) + else: + events.append( + _send_event( + openai_responses_types.ResponseReasoningTextDoneEvent( + type="response.reasoning_text.done", + item_id=reasoning_state["item_id"], + sequence_number=-1, + output_index=reasoning_state["output_index"], + content_index=0, + text=text, + ) + ) + ) + events += [ + _send_event( + openai_responses_types.ResponseOutputItemDoneEvent( + type="response.output_item.done", + sequence_number=-1, + output_index=reasoning_state["output_index"], + item=completed_item, + ) + ), + ] + emitted_items.append(completed_item) + reasoning_state["open"] = False + return events + + def _open_message_item() -> str: + nonlocal current_output_index + current_output_index += 1 + item_id = f"msg_{random_uuid()}" + message_state.update( + open=True, item_id=item_id, output_index=current_output_index, text="" + ) + return item_id + + def _close_message_item(): + if not message_state["open"]: + return [] + text = message_state["text"] + text_content = openai_responses_types.ResponseOutputText( + type="output_text", text=text, annotations=[], logprobs=None + ) + completed_item = ResponseOutputMessage( + id=message_state["item_id"], + type="message", + role="assistant", + content=[text_content], + status="completed", + ) + events = [ + _send_event( + openai_responses_types.ResponseTextDoneEvent( + type="response.output_text.done", + sequence_number=-1, + output_index=message_state["output_index"], + content_index=0, + text=text, + logprobs=[], + item_id=message_state["item_id"], + ) + ), + _send_event( + openai_responses_types.ResponseContentPartDoneEvent( + type="response.content_part.done", + sequence_number=-1, + item_id=message_state["item_id"], + output_index=message_state["output_index"], + content_index=0, + part=text_content, + ) + ), + _send_event( + openai_responses_types.ResponseOutputItemDoneEvent( + type="response.output_item.done", + sequence_number=-1, + output_index=message_state["output_index"], + item=completed_item, + ) + ), + ] + emitted_items.append(completed_item) + message_state["open"] = False + return events + + def _close_tool_call_state(tool_index: int): + state = tool_call_states.get(tool_index) + if state is None or state.get("done"): + return [] + arguments = state["arguments"] + completed_item = ResponseFunctionToolCall( + arguments=arguments, + call_id=state["call_id"], + name=state["name"] or "", + type="function_call", + id=state["item_id"], + status="completed", + ) + events = [ + _send_event( + openai_responses_types.ResponseFunctionCallArgumentsDoneEvent( + type="response.function_call_arguments.done", + sequence_number=-1, + item_id=state["item_id"], + output_index=state["output_index"], + arguments=arguments, + name=state["name"] or "", + ) + ), + _send_event( + openai_responses_types.ResponseOutputItemDoneEvent( + type="response.output_item.done", + sequence_number=-1, + output_index=state["output_index"], + item=completed_item, + ) + ), + ] + emitted_items.append(completed_item) + state["done"] = True + return events + + try: + async for ctx in result_generator: + if isinstance(ctx, dict): + chunk = ctx + else: + chunk = getattr(ctx, "last_output", None) + if not isinstance(chunk, dict): + continue + meta = chunk.get("meta_info") or {} + prompt_tokens = meta.get("prompt_tokens", prompt_tokens) + completion_tokens = meta.get("completion_tokens", completion_tokens) + cached_tokens = meta.get("cached_tokens", cached_tokens) + total_tokens_meta = meta.get("total_tokens", total_tokens_meta) + reasoning_tokens_meta = meta.get( + "reasoning_tokens", reasoning_tokens_meta + ) + finish_reason = meta.get("finish_reason") or finish_reason + + text = chunk.get("text", "") or "" + if incremental: + delta = text + else: + delta = text[stream_offset:] + stream_offset = len(text) + if not delta and finish_reason is None: + continue + + if reasoning_parser_obj is not None: + reasoning_chunk, delta = reasoning_parser_obj.parse_stream_chunk( + delta + ) + else: + reasoning_chunk = None + + if reasoning_chunk: + if message_state["open"]: + for ev in _close_message_item(): + yield ev + if not reasoning_state["open"]: + item_id = _open_reasoning_item() + yield _send_event( + openai_responses_types.ResponseOutputItemAddedEvent( + type="response.output_item.added", + sequence_number=-1, + output_index=reasoning_state["output_index"], + item=ResponseReasoningItem( + id=item_id, + type="reasoning", + summary=[], + content=[], + status="in_progress", + ), + ) + ) + # Clients that opt into ``reasoning.summary`` render + # off the ``reasoning_summary_text.*`` event stream, + # so mirror the trace into a summary part. + if wants_summary: + yield _send_event( + openai_responses_types.ResponseReasoningSummaryPartAddedEvent( + type="response.reasoning_summary_part.added", + item_id=item_id, + output_index=reasoning_state["output_index"], + summary_index=0, + part=ResponseReasoningSummaryAddedPart( + type="summary_text", text="" + ), + sequence_number=-1, + ) + ) + reasoning_state["text"] += reasoning_chunk + if wants_summary: + yield _send_event( + openai_responses_types.ResponseReasoningSummaryTextDeltaEvent( + type="response.reasoning_summary_text.delta", + item_id=reasoning_state["item_id"], + output_index=reasoning_state["output_index"], + summary_index=0, + delta=reasoning_chunk, + sequence_number=-1, + ) + ) + else: + yield _send_event( + openai_responses_types.ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + item_id=reasoning_state["item_id"], + output_index=reasoning_state["output_index"], + content_index=0, + delta=reasoning_chunk, + sequence_number=-1, + ) + ) + + if not delta: + continue + + if isinstance(tool_parser, JsonArrayParser): + sp = tool_parser.parse_streaming_increment(delta, chat_tools) + normal_text, tool_calls = sp.normal_text or "", sp.calls + elif tool_parser is not None: + normal_text, tool_calls = tool_parser.parse_stream_chunk(delta) + else: + normal_text, tool_calls = delta, [] + + # Close any open tool-call item before opening a message so + # ``output_item.done`` lands before the next ``added``. + if normal_text: + if reasoning_state["open"]: + for ev in _close_reasoning_item(): + yield ev + for tool_index in list(tool_call_states): + for ev in _close_tool_call_state(tool_index): + yield ev + if not message_state["open"]: + item_id = _open_message_item() + yield _send_event( + openai_responses_types.ResponseOutputItemAddedEvent( + type="response.output_item.added", + sequence_number=-1, + output_index=message_state["output_index"], + item=ResponseOutputMessage( + id=item_id, + type="message", + role="assistant", + content=[], + status="in_progress", + ), + ) + ) + yield _send_event( + openai_responses_types.ResponseContentPartAddedEvent( + type="response.content_part.added", + sequence_number=-1, + output_index=message_state["output_index"], + item_id=message_state["item_id"], + content_index=0, + part=openai_responses_types.ResponseOutputText( + type="output_text", + text="", + annotations=[], + logprobs=None, + ), + ) + ) + message_state["text"] += normal_text + yield _send_event( + openai_responses_types.ResponseTextDeltaEvent( + type="response.output_text.delta", + sequence_number=-1, + content_index=0, + output_index=message_state["output_index"], + item_id=message_state["item_id"], + delta=normal_text, + logprobs=[], + ) + ) + + if not tool_calls: + continue + + if reasoning_state["open"]: + for ev in _close_reasoning_item(): + yield ev + if message_state["open"]: + for ev in _close_message_item(): + yield ev + + for call in tool_calls: + tool_index = call.tool_index + state = tool_call_states.get(tool_index) + if state is None or state.get("done"): + current_output_index += 1 + item_id = f"fc_{random_uuid()[:8]}" + call_id = f"call_{random_uuid()[:24]}" + state = { + "item_id": item_id, + "call_id": call_id, + "output_index": current_output_index, + "name": call.name or "", + "arguments": "", + "added": False, + "done": False, + } + tool_call_states[tool_index] = state + if not state["added"]: + state["added"] = True + # Capture ``call.name`` before the ``added`` event so + # the name is set on the first emitted item. + if call.name and not state["name"]: + state["name"] = call.name + yield _send_event( + openai_responses_types.ResponseOutputItemAddedEvent( + type="response.output_item.added", + sequence_number=-1, + output_index=state["output_index"], + item=ResponseFunctionToolCall( + arguments="", + call_id=state["call_id"], + name=state["name"], + type="function_call", + id=state["item_id"], + status="in_progress", + ), + ) + ) + if call.parameters: + state["arguments"] += call.parameters + yield _send_event( + openai_responses_types.ResponseFunctionCallArgumentsDeltaEvent( + type="response.function_call_arguments.delta", + sequence_number=-1, + item_id=state["item_id"], + output_index=state["output_index"], + delta=call.parameters, + ) + ) + except Exception: + logger.exception("Error while streaming /v1/responses") + failed = _sanitize_response_dict( + ResponsesResponse.from_request( + request, + sampling_params, + model_name=model_name, + created_time=created_time, + output=[], + status="failed", + usage=None, + ).model_dump() + ) + yield _send_event( + openai_responses_types.ResponseFailedEvent( + type="response.failed", + sequence_number=-1, + response=failed, + ) + ) + return + + for ev in _close_reasoning_item(): + yield ev + for ev in _close_message_item(): + yield ev + for tool_index in list(tool_call_states): + for ev in _close_tool_call_state(tool_index): + yield ev + + final_output_items = list(emitted_items) + + usage = UsageInfo( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens_meta or (prompt_tokens + completion_tokens), + reasoning_tokens=reasoning_tokens_meta, + ) + if self.enable_prompt_tokens_details and cached_tokens: + usage.prompt_tokens_details = PromptTokenUsageInfo( + cached_tokens=cached_tokens + ) + request_metadata.final_usage_info = usage + + final_response = ResponsesResponse.from_request( + request, + sampling_params, + model_name=model_name, + created_time=created_time, + output=final_output_items, + status="completed", + usage=usage, + ) + if request.store: + async with self.response_store_lock: + stored = self.response_store.get(final_response.id) + if stored is None or stored.status != "cancelled": + self.response_store[final_response.id] = final_response + + response_dict = _sanitize_response_dict(final_response.model_dump()) + if response_dict.get("usage"): + usage_info = response_dict["usage"] + response_dict["usage"] = { + "input_tokens": usage_info.get("prompt_tokens", 0), + "input_tokens_details": { + "cached_tokens": cached_tokens, + }, + "output_tokens": usage_info.get("completion_tokens", 0), + "output_tokens_details": { + "reasoning_tokens": reasoning_tokens_meta, + }, + "total_tokens": usage_info.get("total_tokens", 0), + } + + yield _send_event( + openai_responses_types.ResponseCompletedEvent( + type="response.completed", + sequence_number=-1, + response=response_dict, + ) + ) + async def _generate_with_builtin_tools( self, request_id: str, diff --git a/python/sglang/srt/parser/jinja_template_utils.py b/python/sglang/srt/parser/jinja_template_utils.py index dd2b1397f..c187219dc 100644 --- a/python/sglang/srt/parser/jinja_template_utils.py +++ b/python/sglang/srt/parser/jinja_template_utils.py @@ -157,14 +157,16 @@ def process_content_for_template_format( if isinstance(chunk, dict): chunk_type = chunk.get("type") - if chunk_type == "image_url": + if chunk_type in ("image_url", "input_image"): image_obj = chunk.get("image_url") or {} + if isinstance(image_obj, str): + image_obj = {"url": image_obj, "detail": chunk.get("detail")} mdp = image_obj.get("max_dynamic_patch", None) # Also allow flat style: chunk["max_dynamic_patch"] image_data.append( ImageData( url=image_obj["url"], - detail=image_obj.get("detail", "auto"), + detail=image_obj.get("detail") or "auto", max_dynamic_patch=mdp, ) ) @@ -194,13 +196,15 @@ def process_content_for_template_format( audio_data.append(chunk["audio_url"]["url"]) # Normalize to simple 'audio' type processed_content_parts.append({"type": "audio"}) - elif chunk_type == "text": + elif chunk_type in ("text", "input_text"): # For v32 encoding, collect text parts separately if use_dpsk_v32_encoding: text_parts.append(chunk["text"]) else: # Keep text content as-is for openai format - processed_content_parts.append(chunk) + processed_content_parts.append( + {"type": "text", "text": chunk["text"]} + ) elif chunk_type == "tool_reference": # GLM-specific extension: pass through so the chat template # can match tool_reference.name against tools[*].function.name @@ -220,7 +224,7 @@ def process_content_for_template_format( # String format: flatten to text only (for templates like DeepSeek) text_parts = [] for chunk in msg_dict["content"]: - if isinstance(chunk, dict) and chunk.get("type") == "text": + if isinstance(chunk, dict) and chunk.get("type") in ("text", "input_text"): text_parts.append(chunk["text"]) # Note: For string format, we ignore images/audio since the template # doesn't expect structured content - multimodal placeholders would diff --git a/python/sglang/srt/parser/reasoning_parser.py b/python/sglang/srt/parser/reasoning_parser.py index b36a8fa8f..d4fe2eacf 100644 --- a/python/sglang/srt/parser/reasoning_parser.py +++ b/python/sglang/srt/parser/reasoning_parser.py @@ -257,6 +257,9 @@ class Qwen3Detector(BaseReasoningFormatDetector): think_excluded_tokens=think_excluded_tokens, force_reasoning=force_reasoning, stream_reasoning=stream_reasoning, + # Qwen3.5 sometimes opens ```` without closing + # ````; treat it as an implicit reasoning close. + tool_start_token="", continue_final_message=continue_final_message, previous_content=previous_content, thinks_internally=True, diff --git a/test/registered/unit/entrypoints/openai/test_responses_protocol.py b/test/registered/unit/entrypoints/openai/test_responses_protocol.py new file mode 100644 index 000000000..19a11ad13 --- /dev/null +++ b/test/registered/unit/entrypoints/openai/test_responses_protocol.py @@ -0,0 +1,146 @@ +import unittest + +from utils import make_serving # noqa: F401 — bootstrap import + +from sglang.srt.entrypoints.openai.protocol import ResponsesRequest, UsageInfo +from sglang.test.ci.ci_register import register_cpu_ci + +register_cpu_ci(est_time=4, suite="base-a-test-cpu") + + +class ResponsesRequestTestCase(unittest.TestCase): + def test_function_tool_accepted(self): + request = ResponsesRequest( + model="x", + input="call the tool", + tools=[ + { + "type": "function", + "name": "lookup", + "description": "Look up a value.", + "parameters": { + "type": "object", + "properties": {"key": {"type": "string"}}, + "required": ["key"], + }, + "strict": True, + } + ], + store=False, + ) + + self.assertEqual(request.tools[0].type, "function") + self.assertEqual(request.tools[0].name, "lookup") + self.assertTrue(request.tools[0].strict) + + def test_function_tool_requires_name(self): + with self.assertRaises(ValueError): + ResponsesRequest( + model="x", input="hi", tools=[{"type": "function"}], store=False + ) + with self.assertRaises(ValueError): + ResponsesRequest( + model="x", + input="hi", + tools=[{"type": "function", "name": ""}], + store=False, + ) + + def test_extended_tool_types_accepted(self): + for tool_type in ( + "web_search", + "web_search_preview", + "code_interpreter", + "file_search", + "image_generation", + "computer_use_preview", + "local_shell", + "mcp", + "custom", + "namespace", + ): + request = ResponsesRequest( + model="x", + input="hi", + tools=[{"type": tool_type}], + store=False, + ) + self.assertEqual(request.tools[0].type, tool_type) + + def test_namespace_tool_carries_inner_tools_list(self): + request = ResponsesRequest( + model="x", + input="hi", + tools=[ + { + "type": "namespace", + "name": "codex", + "tools": [ + {"type": "function", "name": "apply_patch"}, + {"type": "function", "name": "shell"}, + ], + } + ], + store=False, + ) + self.assertEqual(request.tools[0].type, "namespace") + self.assertEqual(len(request.tools[0].tools), 2) + self.assertEqual(request.tools[0].tools[0]["name"], "apply_patch") + + +class ResponsesSamplingParamsTestCase(unittest.TestCase): + def test_processed_stop_and_tool_constraint_propagate(self): + request = ResponsesRequest(model="x", input="call the tool", store=False) + params = request.to_sampling_params( + default_max_tokens=128, + default_params={}, + stop=[""], + tool_call_constraint=("json_schema", {"type": "object"}), + ) + self.assertEqual(params["stop"], [""]) + self.assertEqual(params["json_schema"], '{"type": "object"}') + + def test_constraint_conflict_raises(self): + request = ResponsesRequest(model="x", input="hi", store=False) + with self.assertRaises(ValueError): + request.to_sampling_params( + default_max_tokens=128, + default_params={"json_schema": '{"type": "object"}'}, + tool_call_constraint=("json_schema", {"type": "object"}), + ) + + def test_structural_tag_with_model_dump(self): + class _FakeStructuralTag: + def model_dump(self, by_alias=False): + return {"type": "structural_tag"} + + request = ResponsesRequest(model="x", input="hi", store=False) + params = request.to_sampling_params( + default_max_tokens=128, + default_params={}, + tool_call_constraint=("structural_tag", _FakeStructuralTag()), + ) + self.assertEqual(params["structural_tag"], '{"type": "structural_tag"}') + + +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 + ) + 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.assertFalse(response.parallel_tool_calls) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/unit/entrypoints/openai/test_serving_responses.py b/test/registered/unit/entrypoints/openai/test_serving_responses.py new file mode 100644 index 000000000..8a410cbbf --- /dev/null +++ b/test/registered/unit/entrypoints/openai/test_serving_responses.py @@ -0,0 +1,529 @@ +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 +from sglang.srt.function_call.core_types import ToolCallItem +from sglang.test.ci.ci_register import register_cpu_ci + +register_cpu_ci(est_time=8, suite="base-a-test-cpu") + + +class InputMessageConstructionTestCase(unittest.TestCase): + 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(unittest.TestCase): + 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=[""], + 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) + + +class InputItemNormalizationTestCase(unittest.TestCase): + 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(unittest.TestCase): + 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(): + if False: + 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, + ) + ) + + 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(unittest.TestCase): + 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(unittest.TestCase): + 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 = make_serving() + serving.tool_call_parser = "qwen3_coder" + 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 ", + tokenizer=Mock(), + ) + + 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 = make_serving() + serving.tool_call_parser = "qwen3_coder" + 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() + ) + + 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 = make_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() + ) + + 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_no_tool_call_extraction_when_tool_choice_none(self): + serving = make_serving() + serving.tool_call_parser = "qwen3_coder" + 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() + ) + parser_cls.assert_not_called() + + self.assertEqual(len(output_items), 1) + self.assertIsInstance(output_items[0], ResponseOutputMessage) + + +class HarmonyResponsesTestCase(unittest.TestCase): + 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) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/unit/entrypoints/openai/test_serving_responses_stream.py b/test/registered/unit/entrypoints/openai/test_serving_responses_stream.py new file mode 100644 index 000000000..d62cb1629 --- /dev/null +++ b/test/registered/unit/entrypoints/openai/test_serving_responses_stream.py @@ -0,0 +1,211 @@ +import asyncio +import unittest +from unittest.mock import Mock, patch + +from utils import ( + collect_stream_events, + event_payloads, + event_types, + find_completed_event, + make_serving, +) + +from sglang.srt.entrypoints.openai.protocol import ( + RequestResponseMetadata, + ResponsesRequest, +) +from sglang.test.ci.ci_register import register_cpu_ci + +register_cpu_ci(est_time=4, suite="base-a-test-cpu") + + +class _StreamFixture: + def __init__(self, serving, request): + self.serving = serving + self.request = request + 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, + ) + ) + + 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): + def test_emits_typed_sse_events_in_order(self): + serving = make_serving() + serving.reasoning_parser = None + serving.tool_call_parser = None + + request = ResponsesRequest(model="x", input="hi", stream=True, store=False) + fixture = _StreamFixture(serving, request) + events = fixture.run( + [ + _engine_chunk("Hel", 1), + _engine_chunk("Hello", 2), + _engine_chunk("Hello world", 4, finish=True), + ] + ) + + types = event_types(events) + self.assertEqual(types[0], "response.created") + self.assertEqual(types[1], "response.in_progress") + for ev in ( + "response.output_item.added", + "response.content_part.added", + "response.output_text.delta", + "response.output_text.done", + "response.content_part.done", + "response.output_item.done", + ): + self.assertIn(ev, types) + self.assertEqual(types[-1], "response.completed") + + seqs = [p["sequence_number"] for p in event_payloads(events)] + self.assertEqual(seqs, list(range(len(seqs)))) + + def test_required_tool_choice_emits_function_call_events(self): + serving = make_serving() + serving.reasoning_parser = None + serving.tool_call_parser = None + + request = ResponsesRequest( + model="x", + input="hi", + stream=True, + store=False, + tool_choice="required", + tools=[ + { + "type": "function", + "name": "get_weather", + "parameters": {"type": "object"}, + } + ], + ) + payload = '[{"name": "get_weather", "parameters": {"city": "Beijing"}}]' + + chunks = [] + sent = 0 + while sent < len(payload): + sent += min(8, len(payload) - sent) + chunks.append( + _engine_chunk(payload[:sent], sent, finish=sent == len(payload)) + ) + + fixture = _StreamFixture(serving, request) + events = fixture.run(chunks) + types = event_types(events) + + self.assertIn("response.function_call_arguments.delta", types) + self.assertIn("response.function_call_arguments.done", types) + self.assertIn("response.output_item.added", types) + self.assertIn("response.output_item.done", types) + self.assertNotIn("response.output_text.delta", types) + + added_kinds = [ + payload["item"]["type"] + for payload in event_payloads(events) + if payload.get("type") == "response.output_item.added" + ] + self.assertIn("function_call", added_kinds) + + def test_final_output_preserves_text_tool_text_order(self): + from sglang.srt.function_call.core_types import ( + StreamingParseResult, + ToolCallItem, + ) + + serving = make_serving() + serving.reasoning_parser = None + serving.tool_call_parser = "qwen3_coder" + + request = ResponsesRequest( + model="x", + input="hi", + stream=True, + store=False, + tools=[ + { + "type": "function", + "name": "get_weather", + "parameters": {"type": "object"}, + } + ], + ) + + scripted = [ + StreamingParseResult(normal_text="I'll check.", calls=[]), + StreamingParseResult( + normal_text="", + calls=[ + ToolCallItem( + tool_index=0, + name="get_weather", + parameters='{"city": "Beijing"}', + ) + ], + ), + StreamingParseResult(normal_text="It's sunny.", calls=[]), + ] + chunks = [ + _engine_chunk(" " * 3, 3), + _engine_chunk(" " * 10, 10), + _engine_chunk(" " * 14, 14, finish=True), + ] + + script_iter = iter(scripted) + + def fake_parse_stream_chunk(delta): + sp = next(script_iter) + return sp.normal_text, sp.calls + + with patch( + "sglang.srt.entrypoints.openai.serving_responses.FunctionCallParser" + ) as parser_cls: + parser_cls.return_value.detector.supports_structural_tag.return_value = True + parser_cls.return_value.parse_stream_chunk.side_effect = ( + fake_parse_stream_chunk + ) + fixture = _StreamFixture(serving, request) + events = fixture.run(chunks) + + completed = find_completed_event(events) + output = completed["response"]["output"] + kinds = [item["type"] for item in output] + self.assertEqual(kinds, ["message", "function_call", "message"]) + self.assertEqual(output[0]["content"][0]["text"], "I'll check.") + self.assertEqual(output[1]["name"], "get_weather") + self.assertEqual(output[2]["content"][0]["text"], "It's sunny.") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/unit/entrypoints/openai/utils.py b/test/registered/unit/entrypoints/openai/utils.py new file mode 100644 index 000000000..8f2d8872a --- /dev/null +++ b/test/registered/unit/entrypoints/openai/utils.py @@ -0,0 +1,109 @@ +"""Stub CUDA-only deps before importing sglang.srt serving modules. Must +be imported first by every /v1/responses test that runs on CPU.""" + +try: + import torch + + _ORIGINAL_TORCH_COMPILE = torch.compile + + def _identity_compile(fn=None, **kwargs): + if fn is None: + return lambda inner_fn: inner_fn + return fn + + torch.compile = _identity_compile +except ImportError: + torch = None + _ORIGINAL_TORCH_COMPILE = None + +from sglang.test.test_utils import maybe_stub_sgl_kernel + +maybe_stub_sgl_kernel() + +import json +from typing import AsyncIterator +from unittest.mock import Mock + +from sglang.srt.entrypoints.openai.serving_responses import OpenAIServingResponses +from sglang.test.ci.ci_register import register_cpu_ci + +register_cpu_ci( + est_time=0, + suite="base-a-test-cpu", + disabled="helper module — exported fixtures, not a test", +) + +if torch is not None: + torch.compile = _ORIGINAL_TORCH_COMPILE + + +class MockTokenizerManager: + def __init__(self, *, is_multimodal: bool = False): + self.model_config = Mock(is_multimodal=is_multimodal, context_len=4096) + self.model_config.get_default_sampling_params.return_value = {} + self.model_config.hf_config = Mock( + model_type="llama", architectures=["LlamaForCausalLM"] + ) + self.server_args = Mock( + enable_cache_report=False, + reasoning_parser=None, + stream_response_default_include_usage=False, + tokenizer_metrics_allowed_custom_labels=None, + tool_call_parser=None, + incremental_streaming_output=False, + ) + self.tokenizer = Mock() + self.tokenizer.encode.return_value = [1, 2, 3] + self.tokenizer.chat_template = None + self.tokenizer.bos_token_id = 1 + self.num_reserved_tokens = 0 + self.generate_request = Mock() + self.create_abort_task = Mock() + + +class MockTemplateManager: + def __init__(self): + self.chat_template_name = "llama-3" + self.jinja_template_content_format = None + self.completion_template_name = None + self.reasoning_config = None + self.force_reasoning = False + + +def make_serving(*, is_multimodal: bool = False) -> OpenAIServingResponses: + return OpenAIServingResponses( + MockTokenizerManager(is_multimodal=is_multimodal), MockTemplateManager() + ) + + +async def collect_stream_events(stream: AsyncIterator[str]) -> list[str]: + events = [] + async for chunk in stream: + events.append(chunk) + return events + + +def event_types(events: list[str]) -> list[str]: + return [ + line[len("event: ") :].strip() + for chunk in events + for line in chunk.splitlines() + if line.startswith("event: ") + ] + + +def event_payloads(events: list[str]) -> list[dict]: + return [ + json.loads(line[len("data: ") :]) + for chunk in events + for line in chunk.splitlines() + if line.startswith("data: ") + ] + + +def find_completed_event(events: list[str]) -> dict: + for chunk in events: + lines = chunk.splitlines() + if lines and lines[0] == "event: response.completed": + return json.loads(lines[1][len("data: ") :]) + raise AssertionError("response.completed event missing from stream") diff --git a/test/registered/unit/parser/test_jinja_template_utils.py b/test/registered/unit/parser/test_jinja_template_utils.py index 34b2dfacc..3a25d9aca 100644 --- a/test/registered/unit/parser/test_jinja_template_utils.py +++ b/test/registered/unit/parser/test_jinja_template_utils.py @@ -383,6 +383,37 @@ class TestTemplateContentFormatDetection(CustomTestCase): # Image data is still extracted self.assertEqual(len(image_data), 1) + def test_process_content_v32_encoding_accepts_responses_input_text(self): + msg_dict = { + "role": "user", + "content": [ + {"type": "input_text", "text": "Hello"}, + { + "type": "input_image", + "image_url": "http://example.com/img.jpg", + "detail": "auto", + }, + {"type": "input_text", "text": "World"}, + ], + } + image_data = [] + video_data = [] + audio_data = [] + modalities = [] + result = process_content_for_template_format( + msg_dict, + "openai", + image_data, + video_data, + audio_data, + modalities, + use_dpsk_v32_encoding=True, + ) + + self.assertEqual(result["content"], "Hello World") + self.assertEqual(len(image_data), 1) + self.assertEqual(image_data[0].url, "http://example.com/img.jpg") + def test_process_content_invalid_format_raises(self): """Test that invalid content_format raises ValueError.""" msg_dict = {