Fix Responses API request handling (#25881)

Co-authored-by: Kai-Hsun Chen <kaihsun@apache.org>
Co-authored-by: Kristin Cowalcijk <kristincowalcijk@gmail.com>
Co-authored-by: aerosta <63026763+aerosta@users.noreply.github.com>
Co-authored-by: glaziermag <glaziermag@users.noreply.github.com>
Co-authored-by: Blake Ledden <blake.ledden@gmail.com>
Co-authored-by: PanJason <pyyjason@gmail.com>
Co-authored-by: Leoyzen <leoyzen@gmail.com>
Co-authored-by: kennyu <966806+kennyu@users.noreply.github.com>
This commit is contained in:
Xinyuan Tong
2026-06-12 14:47:55 -07:00
committed by GitHub
co-authored by Kai-Hsun Chen Kristin Cowalcijk aerosta glaziermag Blake Ledden PanJason Leoyzen kennyu
parent b3270264e4
commit 85712fa5b0
11 changed files with 2312 additions and 97 deletions
+24 -3
View File
@@ -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"]
+2 -3
View File
@@ -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
+131 -17
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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 ``<tool_call>`` without closing
# ``</think>``; treat it as an implicit reasoning close.
tool_start_token="<tool_call>",
continue_final_message=continue_final_message,
previous_content=previous_content,
thinks_internally=True,