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:
co-authored by
Kai-Hsun Chen
Kristin Cowalcijk
aerosta
glaziermag
Blake Ledden
PanJason
Leoyzen
kennyu
parent
b3270264e4
commit
85712fa5b0
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user