[Feat][Responses API] Support custom tools, encrypted reasoning replay, developer tier and model validation (#38690)

Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
Co-authored-by: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com>
This commit is contained in:
Shijin Zhang
2026-09-12 21:29:12 +08:00
committed by GitHub
co-authored by Xinyuan Tong Xinyuan Tong
parent 6dc7b3421b
commit 925e684a88
8 changed files with 2052 additions and 673 deletions
+102 -20
View File
@@ -9,8 +9,11 @@ from typing import Optional, Union
import orjson
from openai.types.responses import (
ResponseCodeInterpreterToolCall,
ResponseOutputItem,
ResponseOutputMessage,
)
from openai.types.responses import ResponseOutputMessage as OpenAIResponseOutputMessage
from openai.types.responses import (
ResponseOutputText,
ResponseReasoningItem,
)
@@ -43,6 +46,11 @@ from openai_harmony import (
from sglang.srt.entrypoints.openai.protocol import (
ReasoningEffortTier,
ResponseInputOutputItem,
ResponseOutputMessage,
)
from sglang.srt.entrypoints.openai.responses_adapters import (
decode_reasoning_state,
encode_custom_tool_input,
)
from sglang.srt.utils import random_uuid
@@ -163,35 +171,66 @@ def parse_response_input(
# 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")
c
for c in content
if c.get("type") in ("text", "input_text", "output_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":
if role == "assistant" and response_msg.get("phase") is not None:
msg = msg.with_channel(
"final" if response_msg["phase"] == "final_answer" else "commentary"
)
elif response_msg["type"] in ("function_call_output", "custom_tool_call_output"):
call_id = response_msg["call_id"]
call_response: Optional[ResponseFunctionToolCall] = None
for prev_response in reversed(prev_responses):
call_response = None
for previous in reversed(prev_responses):
if not isinstance(previous, dict):
previous = previous.model_dump()
if (
isinstance(prev_response, ResponseFunctionToolCall)
and prev_response.call_id == call_id
previous.get("type") in ("function_call", "custom_tool_call")
and previous.get("call_id") == call_id
):
call_response = prev_response
call_response = previous
break
if call_response is None:
raise ValueError(f"No call message found for {call_id}")
output = response_msg.get("output", "")
if isinstance(output, list):
output = "".join(
part.get("text", "") for part in output if isinstance(part, dict)
)
msg = Message.from_author_and_content(
Author.new(Role.TOOL, f"functions.{call_response.name}"),
response_msg["output"],
Author.new(Role.TOOL, f"functions.{call_response['name']}"),
output,
)
elif response_msg["type"] == "reasoning":
content = response_msg["content"]
assert len(content) == 1
msg = Message.from_role_and_content(Role.ASSISTANT, content[0]["text"])
elif response_msg["type"] == "function_call":
msg = Message.from_role_and_content(Role.ASSISTANT, response_msg["arguments"])
text = ""
for field in ("summary", "content"):
text = "\n".join(
part.get("text", "")
for part in response_msg.get(field) or []
if isinstance(part, dict)
)
if text:
break
if not text:
text = decode_reasoning_state(response_msg.get("encrypted_content")) or ""
msg = Message.from_role_and_content(Role.ASSISTANT, text).with_channel(
"analysis"
)
elif response_msg["type"] in ("function_call", "custom_tool_call"):
arguments = (
encode_custom_tool_input(response_msg.get("input") or "")
if response_msg["type"] == "custom_tool_call"
else response_msg.get("arguments") or "{}"
)
if isinstance(arguments, dict):
arguments = orjson.dumps(arguments).decode()
msg = Message.from_role_and_content(Role.ASSISTANT, arguments)
msg = msg.with_channel("commentary")
msg = msg.with_recipient(f"functions.{response_msg['name']}")
msg = msg.with_content_type("json")
@@ -201,10 +240,13 @@ def parse_response_input(
def parse_response_output(output: ResponseOutputItem) -> Message:
if isinstance(output, ResponseOutputMessage):
if isinstance(output, OpenAIResponseOutputMessage):
role = output.role
contents = [TextContent(text=c.text) for c in output.content]
msg = Message.from_role_and_contents(role, contents)
phase = getattr(output, "phase", None)
if phase is not None:
msg = msg.with_channel("final" if phase == "final_answer" else "commentary")
return msg
elif isinstance(output, ResponseFunctionToolCall):
msg = Message.from_role_and_content(Role.ASSISTANT, output.arguments)
@@ -282,6 +324,17 @@ def parse_output_message(message: Message):
type="web_search_call",
)
output_items.append(web_search_item)
elif recipient is not None and recipient.startswith("python"):
output_items.append(
ResponseCodeInterpreterToolCall(
id=f"ci_{random_uuid()}",
type="code_interpreter_call",
code="".join(content.text for content in message.content),
container_id="auto",
outputs=[],
status="completed",
)
)
elif message.channel == "analysis":
for content in message.content:
reasoning_item = ResponseReasoningItem(
@@ -296,9 +349,9 @@ def parse_output_message(message: Message):
status=None,
)
output_items.append(reasoning_item)
elif message.channel == "commentary":
elif message.channel == "commentary" and message.recipient is not None:
if message.recipient.startswith("functions."):
function_name = message.recipient.split(".")[-1]
function_name = message.recipient.removeprefix("functions.")
for content in message.content:
random_id = random_uuid()
response_item = ResponseFunctionToolCall(
@@ -327,7 +380,9 @@ def parse_output_message(message: Message):
output_items.append(reasoning_item)
else:
raise ValueError(f"Unknown recipient: {message.recipient}")
elif message.channel == "final":
elif message.channel == "final" or (
message.channel == "commentary" and message.recipient is None
):
contents = []
for content in message.content:
output_text = ResponseOutputText(
@@ -338,6 +393,7 @@ def parse_output_message(message: Message):
)
contents.append(output_text)
text_item = ResponseOutputMessage(
phase="final_answer" if message.channel == "final" else "commentary",
id=f"msg_{random_uuid()}",
content=contents,
role=message.author.role,
@@ -359,6 +415,17 @@ def parse_remaining_state(parser: StreamableParser):
if current_recipient is not None and current_recipient.startswith("browser."):
return []
if current_recipient is not None and current_recipient.startswith("python"):
return [
ResponseCodeInterpreterToolCall(
id=f"ci_{random_uuid()}",
type="code_interpreter_call",
code=parser.current_content,
container_id="auto",
outputs=[],
status="in_progress",
)
]
if parser.current_channel == "analysis":
reasoning_item = ResponseReasoningItem(
id=f"rs_{random_uuid()}",
@@ -372,7 +439,21 @@ def parse_remaining_state(parser: StreamableParser):
status=None,
)
return [reasoning_item]
elif parser.current_channel == "final":
elif current_recipient is not None and current_recipient.startswith("functions."):
random_id = random_uuid()
return [
ResponseFunctionToolCall(
id=f"ft_{random_id}",
call_id=f"call_{random_id}",
type="function_call",
name=current_recipient.removeprefix("functions."),
arguments=parser.current_content,
status="in_progress",
)
]
elif parser.current_channel == "final" or (
parser.current_channel == "commentary" and current_recipient is None
):
output_text = ResponseOutputText(
text=parser.current_content,
annotations=[], # TODO
@@ -380,6 +461,7 @@ def parse_remaining_state(parser: StreamableParser):
logprobs=None, # TODO
)
text_item = ResponseOutputMessage(
phase="final_answer" if parser.current_channel == "final" else "commentary",
id=f"msg_{random_uuid()}",
content=[output_text],
role="assistant",
@@ -39,11 +39,14 @@ from openai.types.responses import (
ResponseFunctionToolCall,
ResponseInputItemParam,
ResponseOutputItem,
ResponseOutputMessage,
)
from openai.types.responses import ResponseOutputMessage as OpenAIResponseOutputMessage
from openai.types.responses import (
ResponseOutputText,
ResponseReasoningItem,
ResponseTextConfig,
)
from openai.types.responses.easy_input_message_param import EasyInputMessageParam
from openai.types.responses.response import ToolChoice
from openai.types.responses.response_format_text_json_schema_config import (
ResponseFormatTextJSONSchemaConfig,
@@ -713,6 +716,7 @@ class ChatCompletionMessageGenericParam(BaseModel):
)
tool_call_id: Optional[str] = None
name: Optional[str] = None
phase: Optional[Literal["commentary", "final_answer"]] = None
reasoning_content: Optional[str] = None
tool_calls: Optional[List[ToolCall]] = Field(default=None, examples=[None])
tools: Optional[List[Tool]] = Field(default=None, examples=[None])
@@ -1581,6 +1585,9 @@ class ResponseTool(BaseModel):
strict: bool = False
# Inner schemas for ``namespace`` tools.
tools: Optional[List[Dict[str, Any]]] = None
# Input format of a ``custom`` tool: {"type": "text"} or
# {"type": "grammar", "syntax": ..., "definition": ...}.
format: Optional[Dict[str, Any]] = None
@model_validator(mode="after")
def validate_function_tool(self) -> ResponseTool:
@@ -1589,7 +1596,17 @@ class ResponseTool(BaseModel):
return self
class ResponseInputMessageParam(EasyInputMessageParam, total=False):
phase: Optional[Literal["commentary", "final_answer"]]
class ResponseOutputMessage(OpenAIResponseOutputMessage):
phase: Optional[Literal["commentary", "final_answer"]] = None
ResponseInputOutputItem: TypeAlias = Union[
ResponseInputMessageParam,
ResponseOutputMessage,
ResponseInputItemParam,
"ResponseReasoningItem",
ResponseFunctionToolCall,
@@ -1782,20 +1799,24 @@ class ResponsesRequest(BaseModel):
def is_include_output_logprobs(self) -> bool:
return bool(self.include and "message.output_text.logprobs" in self.include)
def is_include_encrypted_reasoning(self) -> bool:
return bool(self.include and "reasoning.encrypted_content" in self.include)
def has_json_schema_constraint(self) -> bool:
return self._json_schema_from_text_format(self.text) is not None
def effective_tool_choice(self) -> Union[str, Dict[str, Any]]:
"""``tool_choice`` reduced to what the server can actually honor: of the
object forms only a named ``function`` survives, the rest (web_search,
mcp, ...) can't be forced through the tool-call parser."""
object forms only a named ``function`` / ``custom`` tool survives, the
rest (web_search, mcp, ...) can't be forced through the tool-call
parser."""
tool_choice = self.tool_choice
if not isinstance(tool_choice, dict):
return tool_choice
name = tool_choice.get("name") or (tool_choice.get("function") or {}).get(
"name"
)
if tool_choice.get("type") == "function" and name:
if tool_choice.get("type") in ("function", "custom") and name:
return {"type": "function", "name": name}
return "auto"
@@ -1897,7 +1918,12 @@ class ResponsesResponse(BaseModel):
model: str
output: List[
Union[ResponseOutputItem, ResponseReasoningItem, ResponseFunctionToolCall]
Union[
ResponseOutputMessage,
ResponseOutputItem,
ResponseReasoningItem,
ResponseFunctionToolCall,
]
] = Field(default_factory=list)
status: Literal[
"queued", "in_progress", "completed", "incomplete", "failed", "cancelled"
@@ -1957,7 +1983,12 @@ class ResponsesResponse(BaseModel):
model_name: str,
created_time: int,
output: List[
Union[ResponseOutputItem, ResponseReasoningItem, ResponseFunctionToolCall]
Union[
ResponseOutputMessage,
ResponseOutputItem,
ResponseReasoningItem,
ResponseFunctionToolCall,
]
],
status: str,
usage: Optional[UsageInfo],
@@ -1983,7 +2014,7 @@ class ResponsesResponse(BaseModel):
try:
if isinstance(it, ResponseOutputText):
continue
elif isinstance(it, ResponseOutputMessage):
elif isinstance(it, OpenAIResponseOutputMessage):
if not it.content:
continue
for c in it.content:
@@ -2083,7 +2114,11 @@ class ResponseReasoningTextContent(BaseModel):
ResponseInputOutputItem: TypeAlias = Union[
ResponseInputItemParam, "ResponseReasoningItem", ResponseFunctionToolCall
ResponseInputMessageParam,
ResponseOutputMessage,
ResponseInputItemParam,
"ResponseReasoningItem",
ResponseFunctionToolCall,
]
@@ -0,0 +1,215 @@
# SPDX-License-Identifier: Apache-2.0
"""Translations between Responses-API wire shapes and SGLang's chat internals.
Two things the Responses API expresses that the chat-completions path has no
representation for:
* ``custom`` tools, whose payload is freeform text rather than JSON-object
arguments. Each is surfaced to the model as a function tool with a single
string property, and the resulting call is translated back into a
``custom_tool_call``.
* ``reasoning.encrypted_content``, the opaque blob a ``store=false`` client
replays to hand a reasoning trace back to the server.
"""
from __future__ import annotations
import base64
import json
import zlib
from typing import Any, Dict, Optional, Set, Tuple
CUSTOM_TOOL_INPUT_KEY = "input"
_SIMPLE_ESCAPES = {
'"': '"',
"\\": "\\",
"/": "/",
"b": "\b",
"f": "\f",
"n": "\n",
"r": "\r",
"t": "\t",
}
def custom_tool_parameters() -> Dict[str, Any]:
return {
"type": "object",
"properties": {
CUSTOM_TOOL_INPUT_KEY: {
"type": "string",
"description": "The freeform text payload for this tool.",
}
},
"required": [CUSTOM_TOOL_INPUT_KEY],
"additionalProperties": False,
}
def custom_tool_description(
description: Optional[str], tool_format: Optional[Dict[str, Any]]
) -> Optional[str]:
"""Fold a custom tool's description and its input format into one blob.
A ``grammar`` format is described to the model rather than enforced by the
grammar backend: the payload is emitted inside the model's own tool-call
envelope, so constrained decoding cannot be scoped to it.
"""
parts = []
if description:
parts.append(description)
if isinstance(tool_format, dict) and tool_format.get("type") == "grammar":
syntax = tool_format.get("syntax") or "lark"
definition = tool_format.get("definition") or ""
parts.append(
f"The payload must conform to this {syntax} grammar:\n{definition}"
)
return "\n\n".join(parts) or None
def custom_tool_names(tools: Any) -> Set[str]:
return {tool.name for tool in tools or [] if tool.type == "custom" and tool.name}
def encode_custom_tool_input(payload: str) -> str:
return json.dumps({CUSTOM_TOOL_INPUT_KEY: payload}, ensure_ascii=False)
def decode_custom_tool_input(arguments: str) -> str:
"""Recover the payload from a completed shim call's arguments.
Falls back to the raw string so a model that ignored the wrapper and
emitted bare text still produces a usable call.
"""
try:
parsed = json.loads(arguments)
except ValueError:
return arguments
if isinstance(parsed, dict):
value = parsed.get(CUSTOM_TOOL_INPUT_KEY)
if isinstance(value, str):
return value
if len(parsed) == 1:
only = next(iter(parsed.values()))
if isinstance(only, str):
return only
return arguments
def _payload_start(buffer: str) -> Optional[int]:
key = f'"{CUSTOM_TOOL_INPUT_KEY}"'
key_at = buffer.find(key)
if key_at < 0:
return None
colon = buffer.find(":", key_at + len(key))
if colon < 0:
return None
quote = buffer.find('"', colon + 1)
return quote + 1 if quote >= 0 else None
def _unicode_escape(buffer: str, i: int) -> Optional[Tuple[str, int]]:
if i + 6 > len(buffer):
return None
try:
code = int(buffer[i + 2 : i + 6], 16)
except ValueError:
return None
if not 0xD800 <= code <= 0xDBFF:
return chr(code), i + 6
if i + 12 > len(buffer) or buffer[i + 6 : i + 8] != "\\u":
return None
try:
low = int(buffer[i + 8 : i + 12], 16)
except ValueError:
return None
if not 0xDC00 <= low <= 0xDFFF:
return None
return chr(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00)), i + 12
def decode_custom_tool_input_prefix(buffer: str) -> str:
"""Longest fully decodable prefix of the payload in a partial argument buffer.
Streaming tool-call parsers hand out argument JSON in fragments, so the
payload is un-escaped incrementally to keep ``custom_tool_call_input``
deltas in step with the value the item finally reports.
"""
start = _payload_start(buffer)
if start is None:
return ""
out = []
i, n = start, len(buffer)
while i < n:
ch = buffer[i]
if ch == '"':
break
if ch != "\\":
out.append(ch)
i += 1
continue
if i + 1 >= n:
break
simple = _SIMPLE_ESCAPES.get(buffer[i + 1])
if simple is not None:
out.append(simple)
i += 2
continue
if buffer[i + 1] == "u":
decoded = _unicode_escape(buffer, i)
if decoded is None:
break
out.append(decoded[0])
i = decoded[1]
continue
break
return "".join(out)
DEVELOPER_BLOCK_LABEL = "Developer instructions:"
def label_developer_content(content: Any) -> Any:
"""Prefix a ``developer`` message's text with its tier label.
Chat templates recognize system/user/assistant/tool, so a developer message
collapses to ``system``. Labelling the block keeps the tier legible instead
of flattening it into the surrounding instructions, where a model can read
it as one more conversational turn and let a later user message win.
"""
if isinstance(content, str):
return f"{DEVELOPER_BLOCK_LABEL}\n{content}"
if isinstance(content, list):
for index, part in enumerate(content):
if isinstance(part, dict) and isinstance(part.get("text"), str):
labelled = dict(part)
labelled["text"] = f"{DEVELOPER_BLOCK_LABEL}\n{part['text']}"
return [*content[:index], labelled, *content[index + 1 :]]
return [{"type": "input_text", "text": DEVELOPER_BLOCK_LABEL}, *content]
return content
_REASONING_STATE_PREFIX = "sglang-reasoning-v1."
def encode_reasoning_state(text: str) -> str:
"""Pack a reasoning trace into the blob ``reasoning.encrypted_content`` carries.
A ``store=false`` client replays reasoning items verbatim, so the trace has
to survive the round trip without server-side state. SGLang holds no key,
so this is an encoded -- not cryptographically protected -- payload: treat
it as opaque to clients, not as a confidentiality boundary.
"""
packed = base64.urlsafe_b64encode(zlib.compress(text.encode("utf-8")))
return _REASONING_STATE_PREFIX + packed.decode("ascii")
def decode_reasoning_state(blob: Any) -> Optional[str]:
if not isinstance(blob, str) or not blob.startswith(_REASONING_STATE_PREFIX):
return None
try:
raw = base64.urlsafe_b64decode(blob[len(_REASONING_STATE_PREFIX) :])
return zlib.decompress(raw).decode("utf-8")
except (ValueError, zlib.error, UnicodeDecodeError):
return None
File diff suppressed because it is too large Load Diff