diff --git a/python/sglang/srt/function_call/dots_detector.py b/python/sglang/srt/function_call/dots_detector.py
index 26edddf82..1390bab33 100644
--- a/python/sglang/srt/function_call/dots_detector.py
+++ b/python/sglang/srt/function_call/dots_detector.py
@@ -16,7 +16,10 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
-from sglang.srt.function_call.utils import _is_complete_json
+from sglang.srt.function_call.utils import (
+ _is_complete_json,
+ get_schema_properties,
+)
logger = logging.getLogger(__name__)
@@ -137,10 +140,9 @@ class DotsToolDetector(BaseFormatDetector):
schema = tool.function.parameters
if not isinstance(schema, dict):
break
- properties = schema.get("properties", {})
defs = schema.get("$defs", {})
return (
- properties if isinstance(properties, dict) else {},
+ get_schema_properties(schema),
defs if isinstance(defs, dict) else {},
)
return {}, {}
diff --git a/python/sglang/srt/function_call/glm47_moe_detector.py b/python/sglang/srt/function_call/glm47_moe_detector.py
index 90bdb2aed..d9ddabb65 100644
--- a/python/sglang/srt/function_call/glm47_moe_detector.py
+++ b/python/sglang/srt/function_call/glm47_moe_detector.py
@@ -17,6 +17,7 @@ from sglang.srt.function_call.core_types import (
_GetInfoFunc,
)
from sglang.srt.function_call.utils import (
+ get_schema_properties,
infer_type_from_json_schema,
safe_literal_eval,
)
@@ -79,15 +80,8 @@ def get_argument_type(
# Get parameters safely using getattr
params = getattr(tool.function, "parameters", None)
- if not isinstance(params, dict):
- return None
- # Navigate to the type using dict.get() for safe access
- properties = params.get("properties")
- if not isinstance(properties, dict):
- return None
-
- arg_spec = properties.get(arg_key)
+ arg_spec = get_schema_properties(params).get(arg_key)
if isinstance(arg_spec, dict):
# Use the new type inference function for complex JSON Schema support
return infer_type_from_json_schema(arg_spec)
@@ -613,8 +607,9 @@ class Glm47MoeDetector(BaseFormatDetector):
self._last_arguments += "{}"
self.streamed_args_for_tool[self.current_tool_id] += "{}"
self._sent_empty_object = True
- elif not self._last_arguments.endswith("}") and not self._sent_empty_object:
- # Need to close brace
+ elif not self._is_first_param and not self._sent_empty_object:
+ # The streamed outer `{` is only closed here; a trailing "}" in
+ # _last_arguments may belong to a nested object value.
calls.append(
ToolCallItem(
tool_index=self.current_tool_id,
diff --git a/python/sglang/srt/function_call/glm4_moe_detector.py b/python/sglang/srt/function_call/glm4_moe_detector.py
index 3dc251302..0f065f2a7 100644
--- a/python/sglang/srt/function_call/glm4_moe_detector.py
+++ b/python/sglang/srt/function_call/glm4_moe_detector.py
@@ -12,6 +12,7 @@ from sglang.srt.function_call.core_types import (
_GetInfoFunc,
)
from sglang.srt.function_call.utils import (
+ get_schema_properties,
infer_type_from_json_schema,
safe_literal_eval,
)
@@ -54,9 +55,7 @@ def get_argument_type(
if func_name not in name2tool:
return None
tool = name2tool[func_name]
- properties = (tool.function.parameters or {}).get("properties", {})
- if not isinstance(properties, dict):
- properties = {}
+ properties = get_schema_properties(tool.function.parameters)
if arg_key not in properties:
return None
@@ -572,7 +571,9 @@ class Glm4MoeDetector(BaseFormatDetector):
self.streamed_args_for_tool[
self.current_tool_id
] += empty_object
- elif not self._last_arguments.endswith("}"):
+ else:
+ # The streamed outer `{` is only closed here; a
+ # trailing "}" may belong to a nested object value.
closing_brace = "}"
calls.append(
ToolCallItem(
diff --git a/python/sglang/srt/function_call/hunyuan_detector.py b/python/sglang/srt/function_call/hunyuan_detector.py
index 269142bd6..5e9a794b3 100644
--- a/python/sglang/srt/function_call/hunyuan_detector.py
+++ b/python/sglang/srt/function_call/hunyuan_detector.py
@@ -12,6 +12,7 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
+from sglang.srt.function_call.utils import get_schema_properties
logger = logging.getLogger(__name__)
@@ -176,7 +177,7 @@ class HunyuanDetector(BaseFormatDetector):
if tool.function.name == function_name:
if tool.function.parameters is None:
return {}
- return tool.function.parameters.get("properties", {}).get(arg_key, {})
+ return get_schema_properties(tool.function.parameters).get(arg_key, {})
return {}
@staticmethod
diff --git a/python/sglang/srt/function_call/kimik2_detector.py b/python/sglang/srt/function_call/kimik2_detector.py
index 806ce738c..98e0aca70 100644
--- a/python/sglang/srt/function_call/kimik2_detector.py
+++ b/python/sglang/srt/function_call/kimik2_detector.py
@@ -15,6 +15,7 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
+from sglang.srt.function_call.utils import get_schema_properties
logger = logging.getLogger(__name__)
@@ -144,7 +145,7 @@ class KimiK2Detector(BaseFormatDetector):
best_score = -1
for tool in tools:
params = tool.function.parameters or {}
- props = set(params.get("properties", {}).keys())
+ props = set(get_schema_properties(params).keys())
if not props:
continue
overlap = len(arg_keys & props)
diff --git a/python/sglang/srt/function_call/mimo_detector.py b/python/sglang/srt/function_call/mimo_detector.py
index 08a1a631e..1af745c82 100644
--- a/python/sglang/srt/function_call/mimo_detector.py
+++ b/python/sglang/srt/function_call/mimo_detector.py
@@ -22,7 +22,10 @@ from sglang.srt.entrypoints.openai.protocol import Tool
from sglang.srt.environ import envs
from sglang.srt.function_call.base_format_detector import BaseFormatDetector
from sglang.srt.function_call.core_types import StreamingParseResult, _GetInfoFunc
-from sglang.srt.function_call.utils import safe_literal_eval
+from sglang.srt.function_call.utils import (
+ get_schema_properties,
+ safe_literal_eval,
+)
logger = logging.getLogger(__name__)
@@ -31,7 +34,7 @@ def _get_param_type(func_name: str, param_name: str, tools: List[Tool]) -> str:
"""Get parameter type from tool schema."""
for tool in tools:
if tool.function.name == func_name:
- props = tool.function.parameters.get("properties", {})
+ props = get_schema_properties(tool.function.parameters)
if param_name in props:
return props[param_name].get("type", "string")
return "string"
diff --git a/python/sglang/srt/function_call/minicpm5_detector.py b/python/sglang/srt/function_call/minicpm5_detector.py
index 4aecd1161..6e3e041c3 100644
--- a/python/sglang/srt/function_call/minicpm5_detector.py
+++ b/python/sglang/srt/function_call/minicpm5_detector.py
@@ -9,7 +9,10 @@ from sglang.srt.function_call.core_types import (
StreamingParseResult,
_GetInfoFunc,
)
-from sglang.srt.function_call.utils import safe_literal_eval
+from sglang.srt.function_call.utils import (
+ get_schema_properties,
+ safe_literal_eval,
+)
logger = logging.getLogger(__name__)
@@ -38,7 +41,7 @@ def get_argument_type(
params = tool.function.parameters or {}
if not isinstance(params, dict):
return None
- return params.get("properties", {}).get(arg_key, {}).get("type")
+ return get_schema_properties(params).get(arg_key, {}).get("type")
def parse_arguments(json_value):
@@ -84,10 +87,7 @@ class MiniCPM5Detector(BaseFormatDetector):
name_to_required = {}
for name, t in name_to_tool.items():
params = t.function.parameters or {}
- props = (
- (params.get("properties", {}) or {}) if isinstance(params, dict) else {}
- )
- name_to_allowed_props[name] = set(props.keys())
+ name_to_allowed_props[name] = set(get_schema_properties(params).keys())
req = params.get("required", []) if isinstance(params, dict) else []
try:
name_to_required[name] = set(req)
diff --git a/python/sglang/srt/function_call/minimax_m2.py b/python/sglang/srt/function_call/minimax_m2.py
index 56078aa0d..cd5009c99 100644
--- a/python/sglang/srt/function_call/minimax_m2.py
+++ b/python/sglang/srt/function_call/minimax_m2.py
@@ -10,6 +10,7 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
+from sglang.srt.function_call.utils import get_schema_properties
logger = logging.getLogger(__name__)
@@ -508,8 +509,8 @@ class MinimaxM2Detector(BaseFormatDetector):
for tool in tools:
if tool.function.name == fname and tool.function.parameters is not None:
parameters = tool.function.parameters
- if isinstance(parameters, dict) and "properties" in parameters:
- param_config = parameters["properties"]
+ if isinstance(parameters, dict):
+ param_config = get_schema_properties(parameters)
break
param_type = self._get_param_types_from_config(pname, param_config)
diff --git a/python/sglang/srt/function_call/minimax_m3.py b/python/sglang/srt/function_call/minimax_m3.py
index 062fc3ec6..eabf27b19 100644
--- a/python/sglang/srt/function_call/minimax_m3.py
+++ b/python/sglang/srt/function_call/minimax_m3.py
@@ -10,6 +10,7 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
+from sglang.srt.function_call.utils import get_schema_properties
logger = logging.getLogger(__name__)
@@ -374,8 +375,8 @@ class MinimaxM3Detector(BaseFormatDetector):
if self._schema_has_type(parent_schema, ("array",)) and child_tag == "item":
return self._get_array_item_schema(parent_schema, parent_value)
- properties = parent_schema.get("properties")
- if isinstance(properties, dict) and child_tag in properties:
+ properties = get_schema_properties(parent_schema)
+ if child_tag in properties:
child_schema = properties[child_tag]
return child_schema if isinstance(child_schema, dict) else None
diff --git a/python/sglang/srt/function_call/poolside_v1_detector.py b/python/sglang/srt/function_call/poolside_v1_detector.py
index 980218527..fe1c2fc0a 100644
--- a/python/sglang/srt/function_call/poolside_v1_detector.py
+++ b/python/sglang/srt/function_call/poolside_v1_detector.py
@@ -11,7 +11,10 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
-from sglang.srt.function_call.utils import safe_literal_eval
+from sglang.srt.function_call.utils import (
+ get_schema_properties,
+ safe_literal_eval,
+)
class _ParseState(Enum):
@@ -150,7 +153,7 @@ class PoolsideV1Detector(BaseFormatDetector):
and tool.function.name == func_name
and isinstance(tool.function.parameters, dict)
):
- return tool.function.parameters.get("properties", {})
+ return get_schema_properties(tool.function.parameters)
except AttributeError:
continue
return {}
diff --git a/python/sglang/srt/function_call/qwen3_coder_detector.py b/python/sglang/srt/function_call/qwen3_coder_detector.py
index 55bfbce3b..01c526319 100644
--- a/python/sglang/srt/function_call/qwen3_coder_detector.py
+++ b/python/sglang/srt/function_call/qwen3_coder_detector.py
@@ -11,6 +11,7 @@ from sglang.srt.function_call.core_types import (
_GetInfoFunc,
)
from sglang.srt.function_call.utils import (
+ get_schema_properties,
infer_type_from_json_schema,
safe_literal_eval,
)
@@ -80,9 +81,10 @@ class Qwen3CoderDetector(BaseFormatDetector):
except AttributeError:
return {}
- if isinstance(params, dict) and "properties" in params:
- return params["properties"]
- elif isinstance(params, dict):
+ if isinstance(params, dict):
+ properties = get_schema_properties(params)
+ if properties or "properties" in params:
+ return properties
return params
else:
return {}
diff --git a/python/sglang/srt/function_call/spark25_detector.py b/python/sglang/srt/function_call/spark25_detector.py
index 1c5f43ea2..d495dfa04 100644
--- a/python/sglang/srt/function_call/spark25_detector.py
+++ b/python/sglang/srt/function_call/spark25_detector.py
@@ -10,6 +10,7 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
+from sglang.srt.function_call.utils import get_schema_properties
TOOL_CALL_BEGIN = ""
TOOL_CALL_END = ""
@@ -47,10 +48,7 @@ def _get_param_type(tools: list[Tool], function_name: str, param_name: str) -> s
parameters = getattr(function, "parameters", None)
if not isinstance(parameters, dict):
continue
- properties = parameters.get("properties")
- if not isinstance(properties, dict):
- continue
- definition = properties.get(param_name)
+ definition = get_schema_properties(parameters).get(param_name)
if isinstance(definition, dict) and isinstance(definition.get("type"), str):
return definition["type"]
return "string"
diff --git a/python/sglang/srt/function_call/step3_detector.py b/python/sglang/srt/function_call/step3_detector.py
index 9e9fca080..fa4122ea3 100644
--- a/python/sglang/srt/function_call/step3_detector.py
+++ b/python/sglang/srt/function_call/step3_detector.py
@@ -10,7 +10,10 @@ from sglang.srt.function_call.core_types import (
ToolCallItem,
_GetInfoFunc,
)
-from sglang.srt.function_call.utils import safe_literal_eval
+from sglang.srt.function_call.utils import (
+ get_schema_properties,
+ safe_literal_eval,
+)
logger = logging.getLogger(__name__)
@@ -22,7 +25,7 @@ def get_argument_type(func_name: str, arg_key: str, defined_tools: List[Tool]) -
return None
tool = name2tool[func_name]
parameters = tool.function.parameters or {}
- properties = parameters.get("properties", {})
+ properties = get_schema_properties(parameters)
if arg_key not in properties:
return None
return properties[arg_key].get("type", None)
diff --git a/python/sglang/srt/function_call/utils.py b/python/sglang/srt/function_call/utils.py
index 0bd0bef4a..d1a87a90c 100644
--- a/python/sglang/srt/function_call/utils.py
+++ b/python/sglang/srt/function_call/utils.py
@@ -304,6 +304,25 @@ def _get_tool_schema(tool: Tool) -> dict:
}
+def get_schema_properties(schema: Any) -> Dict[str, Any]:
+ """Top-level ``properties`` of a tool ``parameters`` schema, descending
+ into ``anyOf``/``oneOf``/``allOf`` branches when the top level declares
+ none (legal JSON Schema, e.g. discriminated-union arguments)."""
+ if not isinstance(schema, dict):
+ return {}
+ properties = schema.get("properties")
+ if isinstance(properties, dict):
+ return properties
+ merged: Dict[str, Any] = {}
+ for keyword in ("anyOf", "oneOf", "allOf"):
+ branches = schema.get(keyword)
+ if isinstance(branches, list):
+ for branch in branches:
+ for key, value in get_schema_properties(branch).items():
+ merged.setdefault(key, value)
+ return merged
+
+
def infer_type_from_json_schema(schema: Dict[str, Any]) -> Optional[str]:
"""
Infer the primary type of a parameter from JSON Schema.
diff --git a/test/registered/unit/function_call/test_function_call_parser.py b/test/registered/unit/function_call/test_function_call_parser.py
index 8f4e00fb6..bc965a2ce 100644
--- a/test/registered/unit/function_call/test_function_call_parser.py
+++ b/test/registered/unit/function_call/test_function_call_parser.py
@@ -33,6 +33,7 @@ from sglang.srt.function_call.llama32_detector import Llama32Detector
from sglang.srt.function_call.mistral_detector import MistralDetector
from sglang.srt.function_call.pythonic_detector import PythonicDetector
from sglang.srt.function_call.qwen3_coder_detector import Qwen3CoderDetector
+from sglang.srt.function_call.utils import get_schema_properties
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=20, suite="base-a-test-cpu")
@@ -5636,5 +5637,196 @@ class TestGemma4Detector(unittest.TestCase):
self.assertEqual(params1["timezone"], "UTC")
+class TestGetSchemaProperties(unittest.TestCase):
+ def test_flat_properties(self):
+ schema = {"type": "object", "properties": {"a": {"type": "string"}}}
+ self.assertEqual(get_schema_properties(schema), {"a": {"type": "string"}})
+
+ def test_top_level_combinators(self):
+ schema = {
+ "type": "object",
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "kind": {"const": "acme"},
+ "payload": {"type": "object"},
+ },
+ },
+ {"type": "object", "properties": {"kind": {"const": "other"}}},
+ ],
+ }
+ # duplicate keys resolve to the first branch that declares them
+ self.assertEqual(
+ get_schema_properties(schema),
+ {"kind": {"const": "acme"}, "payload": {"type": "object"}},
+ )
+
+ def test_anyof_allof_and_nesting(self):
+ anyof = {
+ "anyOf": [{"properties": {"x": {"type": "integer"}}}, {"type": "null"}]
+ }
+ self.assertEqual(get_schema_properties(anyof), {"x": {"type": "integer"}})
+ allof = {
+ "allOf": [
+ {"oneOf": [{"properties": {"y": {"type": "boolean"}}}]},
+ {"properties": {"z": {"type": "string"}}},
+ ]
+ }
+ self.assertEqual(
+ get_schema_properties(allof),
+ {"y": {"type": "boolean"}, "z": {"type": "string"}},
+ )
+
+ def test_non_dict_and_missing(self):
+ self.assertEqual(get_schema_properties(None), {})
+ self.assertEqual(
+ get_schema_properties(
+ {"type": "object"},
+ ),
+ {},
+ )
+ self.assertEqual(get_schema_properties({"oneOf": "not-a-list"}), {})
+
+
+class TestTopLevelCompositeToolSchema(unittest.TestCase):
+ """Parsers must resolve argument types when tool ``parameters`` declares
+ its properties under a top-level anyOf/oneOf/allOf instead of directly."""
+
+ def setUp(self):
+ self.oneof_tools = [
+ Tool(
+ type="function",
+ function=Function(
+ name="acme",
+ description="Send a value to Acme.",
+ parameters={
+ "type": "object",
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "kind": {"const": "acme"},
+ "payload": {
+ "type": "object",
+ "properties": {
+ "value": {"type": "string"},
+ },
+ "required": ["value"],
+ },
+ },
+ "required": ["kind", "payload"],
+ },
+ {
+ "type": "object",
+ "properties": {"kind": {"const": "other"}},
+ "required": ["kind"],
+ },
+ ],
+ },
+ ),
+ ),
+ ]
+ self.flat_tools = [
+ Tool(
+ type="function",
+ function=Function(
+ name="acme",
+ description="Send a value to Acme.",
+ parameters={
+ "type": "object",
+ "properties": {
+ "kind": {"type": "string"},
+ "payload": {
+ "type": "object",
+ "properties": {"value": {"type": "string"}},
+ "required": ["value"],
+ },
+ },
+ "required": ["kind", "payload"],
+ },
+ ),
+ ),
+ ]
+ self.glm47_text = (
+ "acme"
+ "kindacme"
+ "payload"
+ '{"value": "hello"}'
+ ""
+ )
+ self.glm4_text = (
+ "acme\n"
+ "kind\nacme\n"
+ "payload\n"
+ '{"value": "hello"}\n'
+ ""
+ )
+ self.qwen_text = (
+ ""
+ "acme"
+ '{"value": "hello"}'
+ ""
+ )
+ self.expected = {"kind": "acme", "payload": {"value": "hello"}}
+
+ def _stream_arguments(self, detector, text, tools, chunk_size=8):
+ name = None
+ arguments = ""
+ for i in range(0, len(text), chunk_size):
+ result = detector.parse_streaming_increment(text[i : i + chunk_size], tools)
+ for call in result.calls:
+ if call.name:
+ name = call.name
+ arguments += call.parameters
+ return name, arguments
+
+ def test_glm47_streaming(self):
+ detector = Glm47MoeDetector()
+ name, arguments = self._stream_arguments(
+ detector, self.glm47_text, self.oneof_tools
+ )
+ self.assertEqual(name, "acme")
+ self.assertEqual(json.loads(arguments), self.expected)
+
+ def test_glm47_streaming_object_argument_closes_outer_brace(self):
+ detector = Glm47MoeDetector()
+ name, arguments = self._stream_arguments(
+ detector, self.glm47_text, self.flat_tools
+ )
+ self.assertEqual(name, "acme")
+ self.assertEqual(json.loads(arguments), self.expected)
+
+ def test_glm4_streaming(self):
+ detector = Glm4MoeDetector()
+ name, arguments = self._stream_arguments(
+ detector, self.glm4_text, self.oneof_tools
+ )
+ self.assertEqual(name, "acme")
+ self.assertEqual(json.loads(arguments), self.expected)
+
+ def test_glm4_streaming_object_argument_closes_outer_brace(self):
+ detector = Glm4MoeDetector()
+ name, arguments = self._stream_arguments(
+ detector, self.glm4_text, self.flat_tools
+ )
+ self.assertEqual(name, "acme")
+ self.assertEqual(json.loads(arguments), self.expected)
+
+ def test_qwen3_coder_detect_and_parse(self):
+ detector = Qwen3CoderDetector()
+ result = detector.detect_and_parse(self.qwen_text, self.oneof_tools)
+ self.assertEqual(len(result.calls), 1)
+ self.assertEqual(json.loads(result.calls[0].parameters), self.expected)
+
+ def test_qwen3_coder_streaming(self):
+ detector = Qwen3CoderDetector()
+ name, arguments = self._stream_arguments(
+ detector, self.qwen_text, self.oneof_tools
+ )
+ self.assertEqual(name, "acme")
+ self.assertEqual(json.loads(arguments), self.expected)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/test/registered/unit/function_call/test_minimax_m3_detector.py b/test/registered/unit/function_call/test_minimax_m3_detector.py
index 8ffe51399..8f6179ea0 100644
--- a/test/registered/unit/function_call/test_minimax_m3_detector.py
+++ b/test/registered/unit/function_call/test_minimax_m3_detector.py
@@ -535,5 +535,52 @@ def _parse_segments_text(text, tools):
], result.normal_text
+class TestMinimaxM3TopLevelOneOf(CustomTestCase):
+ def setUp(self):
+ self.tools = [
+ Tool(
+ type="function",
+ function=Function(
+ name="acme",
+ description="Send a value to Acme.",
+ parameters={
+ "type": "object",
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "count": {"type": "integer"},
+ "verbose": {"type": "boolean"},
+ },
+ "required": ["count", "verbose"],
+ },
+ {
+ "type": "object",
+ "properties": {"kind": {"const": "other"}},
+ "required": ["kind"],
+ },
+ ],
+ },
+ ),
+ ),
+ ]
+ self.segments = (
+ "",
+ '',
+ "7",
+ "",
+ "true",
+ "",
+ "",
+ "",
+ )
+ self.expected = {"count": 7, "verbose": True}
+
+ def test_detect_and_parse(self):
+ calls, _ = _parse_segments(self.segments, self.tools)
+ self.assertEqual(len(calls), 1)
+ self.assertEqual(calls[0]["args"], self.expected)
+
+
if __name__ == "__main__":
unittest.main()