Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 84 additions & 23 deletions python/sglang/srt/entrypoints/harmony_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import datetime
import logging
from collections.abc import Iterable
from typing import Optional, Union
from typing import Optional, Set, Union

import orjson
from openai.types.responses import (
Expand Down Expand Up @@ -46,11 +46,14 @@
from sglang.srt.entrypoints.openai.protocol import (
ReasoningEffortTier,
ResponseInputOutputItem,
ResponseNamespacedFunctionToolCall,
ResponseOutputMessage,
)
from sglang.srt.entrypoints.openai.responses_adapters import (
decode_reasoning_state,
encode_custom_tool_input,
namespace_members_to_chat_tools,
split_namespaced_call,
)
from sglang.srt.utils import random_uuid

Expand Down Expand Up @@ -110,6 +113,7 @@ def get_developer_message(
dev_msg_content = dev_msg_content.with_instructions(instructions)
if tools is not None:
function_tools = []
member_descriptions = []
for tool in tools:
if tool.type in (
"web_search",
Expand All @@ -120,22 +124,33 @@ def get_developer_message(
pass
elif tool.type == "function":
function_tools.append(tool)
elif tool.type == "namespace" and tool.name:
# Members flatten to qualified functions, same as the chat
# path, but as harmony tool descriptions.
for member in namespace_members_to_chat_tools(tool):
member_descriptions.append(
ToolDescription.new(
name=member.function.name,
description=member.function.description or "",
parameters=member.function.parameters,
)
)
else:
# 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(
name=tool.name,
description=tool.description,
parameters=tool.parameters,
)
for tool in function_tools
]
function_tool_descriptions = [
ToolDescription.new(
name=tool.name,
description=tool.description,
parameters=tool.parameters,
)
for tool in function_tools
] + member_descriptions
if function_tool_descriptions:
dev_msg_content = dev_msg_content.with_function_tools(
function_tool_descriptions
)
Expand Down Expand Up @@ -230,9 +245,14 @@ def parse_response_input(
)
if isinstance(arguments, dict):
arguments = orjson.dumps(arguments).decode()
name = response_msg["name"]
namespace = response_msg.get("namespace")
if namespace and name:
# Re-qualify so the model sees the flattened name it was offered.
name = f"{namespace}.{name}"
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_recipient(f"functions.{name}")
msg = msg.with_content_type("json")
else:
raise ValueError(f"Unknown input type: {response_msg['type']}")
Expand Down Expand Up @@ -286,7 +306,11 @@ def get_streamable_parser_for_assistant() -> StreamableParser:
return StreamableParser(get_encoding(), role=Role.ASSISTANT)


def parse_output_message(message: Message):
def parse_output_message(
message: Message,
namespaces: Optional[Set[str]] = None,
declared_names: Optional[Set[str]] = None,
):
if message.author.role != "assistant":
# This is a message from a tool to the assistant (e.g., search result).
# Don't include it in the final output for now. This aligns with
Expand Down Expand Up @@ -351,16 +375,32 @@ def parse_output_message(message: Message):
output_items.append(reasoning_item)
elif message.channel == "commentary" and message.recipient is not None:
if message.recipient.startswith("functions."):
function_name = message.recipient.removeprefix("functions.")
# The recipient keeps the full dotted suffix; declared
# namespaces split it back onto the wire item's fields.
item_name, namespace = split_namespaced_call(
message.recipient.removeprefix("functions."),
namespaces or frozenset(),
declared_names or frozenset(),
)
for content in message.content:
random_id = random_uuid()
response_item = ResponseFunctionToolCall(
arguments=content.text,
call_id=f"call_{random_id}",
type="function_call",
name=function_name,
id=f"ft_{random_id}",
)
if namespace is None:
response_item = ResponseFunctionToolCall(
arguments=content.text,
call_id=f"call_{random_id}",
type="function_call",
name=item_name,
id=f"ft_{random_id}",
)
else:
response_item = ResponseNamespacedFunctionToolCall(
arguments=content.text,
call_id=f"call_{random_id}",
type="function_call",
name=item_name,
namespace=namespace,
id=f"ft_{random_id}",
)
output_items.append(response_item)
elif message.recipient.startswith("python") or message.recipient.startswith(
"browser"
Expand Down Expand Up @@ -406,7 +446,11 @@ def parse_output_message(message: Message):
return output_items


def parse_remaining_state(parser: StreamableParser):
def parse_remaining_state(
parser: StreamableParser,
namespaces: Optional[Set[str]] = None,
declared_names: Optional[Set[str]] = None,
):
if not parser.current_content:
return []
if parser.current_role != Role.ASSISTANT:
Expand Down Expand Up @@ -441,12 +485,29 @@ def parse_remaining_state(parser: StreamableParser):
return [reasoning_item]
elif current_recipient is not None and current_recipient.startswith("functions."):
random_id = random_uuid()
item_name, namespace = split_namespaced_call(
current_recipient.removeprefix("functions."),
namespaces or frozenset(),
declared_names or frozenset(),
)
if namespace is None:
return [
ResponseFunctionToolCall(
id=f"ft_{random_id}",
call_id=f"call_{random_id}",
type="function_call",
name=item_name,
arguments=parser.current_content,
status="in_progress",
)
]
return [
ResponseFunctionToolCall(
ResponseNamespacedFunctionToolCall(
id=f"ft_{random_id}",
call_id=f"call_{random_id}",
type="function_call",
name=current_recipient.removeprefix("functions."),
name=item_name,
namespace=namespace,
arguments=parser.current_content,
status="in_progress",
)
Expand Down
52 changes: 41 additions & 11 deletions python/sglang/srt/entrypoints/openai/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
ResponseFunctionToolCall,
ResponseInputItemParam,
ResponseOutputItem,
ResponseOutputItemAddedEvent,
ResponseOutputItemDoneEvent,
)
from openai.types.responses import ResponseOutputMessage as OpenAIResponseOutputMessage
from openai.types.responses import (
Expand Down Expand Up @@ -1604,6 +1606,37 @@ class ResponseOutputMessage(OpenAIResponseOutputMessage):
phase: Optional[Literal["commentary", "final_answer"]] = None


class ResponseNamespacedFunctionToolCall(ResponseFunctionToolCall):
"""A ``function_call`` emitted for a ``namespace`` tool declaration.

The chat pipeline only sees the qualified ``{namespace}.{name}``
function, so the split pair has to travel back on the item itself.
"""

namespace: str


# Streaming events and response payloads type ``item``/``output`` through the
# SDK unions, whose ``ResponseFunctionToolCall`` arm serializes a subclass
# instance and drops ``namespace``; the subclasses below widen the union with
# the namespaced arm first so the field survives model_dump.
class ResponseNamespacedOutputItemAddedEvent(ResponseOutputItemAddedEvent):
item: Union[ResponseNamespacedFunctionToolCall, ResponseOutputItem]


class ResponseNamespacedOutputItemDoneEvent(ResponseOutputItemDoneEvent):
item: Union[ResponseNamespacedFunctionToolCall, ResponseOutputItem]


ResponsesOutputItem = Union[
ResponseOutputMessage,
ResponseOutputItem,
ResponseReasoningItem,
ResponseNamespacedFunctionToolCall,
ResponseFunctionToolCall,
]


ResponseInputOutputItem: TypeAlias = Union[
ResponseInputMessageParam,
ResponseOutputMessage,
Expand Down Expand Up @@ -1807,15 +1840,19 @@ def has_json_schema_constraint(self) -> bool:

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`` / ``custom`` tool survives, the
rest (web_search, mcp, ...) can't be forced through the tool-call
parser."""
object forms only a named ``function`` / ``custom`` tool survives (a
``namespace`` field re-qualifies the inner name to the flattened form
the model saw), 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"
)
namespace = tool_choice.get("namespace")
if namespace and name:
name = f"{namespace}.{name}"
if tool_choice.get("type") in ("function", "custom") and name:
return {"type": "function", "name": name}
return "auto"
Expand Down Expand Up @@ -1917,14 +1954,7 @@ class ResponsesResponse(BaseModel):
created_at: int = Field(default_factory=lambda: int(time.time()))
model: str

output: List[
Union[
ResponseOutputMessage,
ResponseOutputItem,
ResponseReasoningItem,
ResponseFunctionToolCall,
]
] = Field(default_factory=list)
output: List[ResponsesOutputItem] = Field(default_factory=list)
status: Literal[
"queued", "in_progress", "completed", "incomplete", "failed", "cancelled"
]
Expand Down
58 changes: 56 additions & 2 deletions python/sglang/srt/entrypoints/openai/responses_adapters.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
# 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
Three 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``.
* ``namespace`` tools, which group inner function schemas. Each member is
surfaced as a ``{namespace}.{name}`` function the model can call, and the
emitted call splits the pair back into ``name`` plus ``namespace``.
* ``reasoning.encrypted_content``, the opaque blob a ``store=false`` client
replays to hand a reasoning trace back to the server.
"""
Expand All @@ -17,7 +20,9 @@
import base64
import json
import zlib
from typing import Any, Dict, Optional, Set, Tuple
from typing import Any, Dict, List, Optional, Set, Tuple

from sglang.srt.entrypoints.openai.protocol import Function, Tool

CUSTOM_TOOL_INPUT_KEY = "input"

Expand Down Expand Up @@ -167,6 +172,55 @@ def decode_custom_tool_input_prefix(buffer: str) -> str:
return "".join(out)


def namespace_tool_names(tools: Any) -> Set[str]:
"""Declared ``namespace`` prefixes; only these split on the way out."""
return {tool.name for tool in tools or [] if tool.type == "namespace" and tool.name}


def namespace_members_to_chat_tools(tool: Any) -> List[Any]:
"""Flatten one ``namespace`` declaration into chat function tools named
``{namespace}.{member}``.

Inner description and strict flags survive; the namespace description
fills members that omit one, the way OpenAI documents the grouping.
"""
members = []
for inner in tool.tools or []:
inner_name = inner.get("name") if isinstance(inner, dict) else None
if not isinstance(inner_name, str) or not inner_name:
raise ValueError(
f"namespace tool {tool.name!r} has an inner tool without a name."
)
members.append(
Tool(
type="function",
function=Function(
name=f"{tool.name}.{inner_name}",
description=inner.get("description") or tool.description,
parameters=inner.get("parameters"),
strict=bool(inner.get("strict", False)),
),
)
)
return members


def split_namespaced_call(
name: str, namespaces: Set[str], declared_names: Set[str]
) -> Tuple[str, Optional[str]]:
"""Map one model-facing call name onto its wire-item fields.

Returns ``(name, namespace)``. An exact declaration outranks a prefix
split, so a plain function whose name contains a dot is never
reinterpreted; undeclared dotted names pass through unchanged.
"""
if name not in declared_names:
namespace, _, inner = name.partition(".")
if namespace in namespaces and inner:
return inner, namespace
return name, None


DEVELOPER_BLOCK_LABEL = "Developer instructions:"


Expand Down
Loading
Loading