From 24b9354afffde86e4d566d85e20575cdee0c5905 Mon Sep 17 00:00:00 2001 From: Xinyuan Tong Date: Thu, 27 Aug 2026 05:24:20 +0000 Subject: [PATCH 1/3] [Fix] Resolve tool argument types through top-level anyOf/oneOf/allOf Tool schemas may legally compose subschemas with a top-level anyOf/oneOf/allOf instead of declaring properties directly (e.g. discriminated-union arguments). Detectors navigated parameters["properties"] only at the top level, so argument types could not be inferred and nested object/array/scalar values came back as raw strings (qwen3_coder in both modes) or JSON-quoted strings (glm45/glm47 streaming). - add shared get_schema_properties() that descends into anyOf/oneOf/allOf branches (first branch wins on duplicate keys) - adopt it in all detectors with parse-path property lookups: glm45, glm47, qwen3_coder, step3, minicpm5, hunyuan, spark25, minimax_m2, dots, poolside_v1, mimo, kimik2 - glm45/glm47 streaming: always close the outer arguments object at finalize; the endswith("}") shortcut misfires when the last value is a nested object, leaving streamed arguments as invalid JSON --- .../sglang/srt/function_call/dots_detector.py | 8 +- .../srt/function_call/glm47_moe_detector.py | 15 +- .../srt/function_call/glm4_moe_detector.py | 9 +- .../srt/function_call/hunyuan_detector.py | 3 +- .../srt/function_call/kimik2_detector.py | 3 +- .../sglang/srt/function_call/mimo_detector.py | 7 +- .../srt/function_call/minicpm5_detector.py | 12 +- python/sglang/srt/function_call/minimax_m2.py | 5 +- .../srt/function_call/poolside_v1_detector.py | 7 +- .../srt/function_call/qwen3_coder_detector.py | 8 +- .../srt/function_call/spark25_detector.py | 6 +- .../srt/function_call/step3_detector.py | 7 +- python/sglang/srt/function_call/utils.py | 19 ++ .../test_function_call_parser.py | 196 ++++++++++++++++++ 14 files changed, 265 insertions(+), 40 deletions(-) diff --git a/python/sglang/srt/function_call/dots_detector.py b/python/sglang/srt/function_call/dots_detector.py index 26edddf822a2..1390bab33f65 100644 --- a/python/sglang/srt/function_call/dots_detector.py +++ b/python/sglang/srt/function_call/dots_detector.py @@ -16,7 +16,10 @@ 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 @@ def _tool_schema(name: str, tools: list[Tool]) -> tuple[dict, dict]: 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 90bdb2aedcd5..d9ddabb651e4 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 @@ _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 @@ def _finalize_tool_call( 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 3dc2513020a6..0f065f2a7c03 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 @@ _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 @@ def parse_streaming_increment( 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 269142bd60f7..5e9a794b3766 100644 --- a/python/sglang/srt/function_call/hunyuan_detector.py +++ b/python/sglang/srt/function_call/hunyuan_detector.py @@ -12,6 +12,7 @@ ToolCallItem, _GetInfoFunc, ) +from sglang.srt.function_call.utils import get_schema_properties logger = logging.getLogger(__name__) @@ -176,7 +177,7 @@ def _get_arg_schema( 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 806ce738c12c..98e0aca70344 100644 --- a/python/sglang/srt/function_call/kimik2_detector.py +++ b/python/sglang/srt/function_call/kimik2_detector.py @@ -15,6 +15,7 @@ ToolCallItem, _GetInfoFunc, ) +from sglang.srt.function_call.utils import get_schema_properties logger = logging.getLogger(__name__) @@ -144,7 +145,7 @@ def _infer_tool_name(self, tools: List[Tool], function_args: str = None): 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 08a1a631e85d..1af745c82d88 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.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 4aecd1161116..6e3e041c3b5c 100644 --- a/python/sglang/srt/function_call/minicpm5_detector.py +++ b/python/sglang/srt/function_call/minicpm5_detector.py @@ -9,7 +9,10 @@ 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 @@ def detect_and_parse(self, text: str, tools: List[Tool]) -> StreamingParseResult 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 56078aa0d2dd..cd5009c995b1 100644 --- a/python/sglang/srt/function_call/minimax_m2.py +++ b/python/sglang/srt/function_call/minimax_m2.py @@ -10,6 +10,7 @@ ToolCallItem, _GetInfoFunc, ) +from sglang.srt.function_call.utils import get_schema_properties logger = logging.getLogger(__name__) @@ -508,8 +509,8 @@ def _parse_parameter( 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/poolside_v1_detector.py b/python/sglang/srt/function_call/poolside_v1_detector.py index 980218527885..fe1c2fc0a569 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 @@ 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 @@ def _get_param_schema( 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 55bfbce3bca9..99b3fa9b67ba 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 @@ _GetInfoFunc, ) from sglang.srt.function_call.utils import ( + get_schema_properties, infer_type_from_json_schema, safe_literal_eval, ) @@ -80,9 +81,10 @@ def _get_arguments_config( 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: + 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 1c5f43ea2b12..d495dfa04474 100644 --- a/python/sglang/srt/function_call/spark25_detector.py +++ b/python/sglang/srt/function_call/spark25_detector.py @@ -10,6 +10,7 @@ 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 9e9fca080c7e..fa4122ea3def 100644 --- a/python/sglang/srt/function_call/step3_detector.py +++ b/python/sglang/srt/function_call/step3_detector.py @@ -10,7 +10,10 @@ 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 0bd0bef4af79..d1a87a90c419 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 8f4e00fb6dc7..e0728057f018 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.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,200 @@ def test_streaming_text_between_tool_calls(self): 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"}}}, + ], + } + # first branch wins on duplicate keys, matching oneOf preference order + 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_detect_and_parse(self): + detector = Glm47MoeDetector() + result = detector.detect_and_parse(self.glm47_text, self.oneof_tools) + self.assertEqual(len(result.calls), 1) + self.assertEqual(json.loads(result.calls[0].parameters), self.expected) + + 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_detect_and_parse(self): + detector = Glm4MoeDetector() + result = detector.detect_and_parse(self.glm4_text, self.oneof_tools) + self.assertEqual(len(result.calls), 1) + self.assertEqual(json.loads(result.calls[0].parameters), 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_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() From a7ba5d5079c91a60761af74c357320b8b0ddefc5 Mon Sep 17 00:00:00 2001 From: Xinyuan Tong Date: Thu, 27 Aug 2026 17:27:21 +0000 Subject: [PATCH 2/3] [Fix] Apply top-level combinator schema resolution to minimax_m3 + review follow-ups - minimax_m3: _get_child_schema navigated top-level properties only; with a top-level anyOf/oneOf/allOf the param schema resolved to None and non-streaming parsing corrupted nested object args - qwen3_coder: restore exact semantics for a declared but empty properties: {} (avoid spurious 'param not defined' warnings) - tests: minimax_m3 oneOf detect/stream cases, glm4 flat-schema streaming brace case, comment wording --- python/sglang/srt/function_call/minimax_m3.py | 5 +- .../srt/function_call/qwen3_coder_detector.py | 2 +- .../test_function_call_parser.py | 10 +++- .../function_call/test_minimax_m3_detector.py | 58 +++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/python/sglang/srt/function_call/minimax_m3.py b/python/sglang/srt/function_call/minimax_m3.py index 062fc3ec687d..eabf27b191b7 100644 --- a/python/sglang/srt/function_call/minimax_m3.py +++ b/python/sglang/srt/function_call/minimax_m3.py @@ -10,6 +10,7 @@ ToolCallItem, _GetInfoFunc, ) +from sglang.srt.function_call.utils import get_schema_properties logger = logging.getLogger(__name__) @@ -374,8 +375,8 @@ def _get_child_schema( 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/qwen3_coder_detector.py b/python/sglang/srt/function_call/qwen3_coder_detector.py index 99b3fa9b67ba..01c52631921b 100644 --- a/python/sglang/srt/function_call/qwen3_coder_detector.py +++ b/python/sglang/srt/function_call/qwen3_coder_detector.py @@ -83,7 +83,7 @@ def _get_arguments_config( if isinstance(params, dict): properties = get_schema_properties(params) - if properties: + if properties or "properties" in params: return properties return params else: 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 e0728057f018..756f93aec6c4 100644 --- a/test/registered/unit/function_call/test_function_call_parser.py +++ b/test/registered/unit/function_call/test_function_call_parser.py @@ -5656,7 +5656,7 @@ def test_top_level_combinators(self): {"type": "object", "properties": {"kind": {"const": "other"}}}, ], } - # first branch wins on duplicate keys, matching oneOf preference order + # duplicate keys resolve to the first branch that declares them self.assertEqual( get_schema_properties(schema), {"kind": {"const": "acme"}, "payload": {"type": "object"}}, @@ -5817,6 +5817,14 @@ def test_glm4_streaming(self): 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) 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 8ffe513996df..a2ffa2674efc 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,63 @@ 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": { + "kind": {"const": "acme"}, + "payload": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + }, + "required": ["kind", "payload"], + }, + { + "type": "object", + "properties": {"kind": {"const": "other"}}, + "required": ["kind"], + }, + ], + }, + ), + ), + ] + self.segments = ( + "", + '', + "acme", + "", + "", + "hello", + "", + "", + "", + "", + ) + self.expected = {"kind": "acme", "payload": {"value": "hello"}} + + 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) + + def test_streaming(self): + calls = _stream_segments(self.segments, self.tools) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["args"], self.expected) + + if __name__ == "__main__": unittest.main() From 25856285eeabfcf48405d369227bb4277df92d11 Mon Sep 17 00:00:00 2001 From: Xinyuan Tong Date: Thu, 27 Aug 2026 17:55:06 +0000 Subject: [PATCH 3/3] [Test] Prune weak top-level-combinator parser tests Drop cases that also pass on unfixed code (glm non-streaming has a json.loads fallback; minimax nested-tag parsing is structure-driven) and rewrite the minimax_m3 case around scalar coercion, which is the path that actually breaks without schema resolution --- .../test_function_call_parser.py | 12 --------- .../function_call/test_minimax_m3_detector.py | 27 ++++++------------- 2 files changed, 8 insertions(+), 31 deletions(-) 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 756f93aec6c4..bc965a2cedbf 100644 --- a/test/registered/unit/function_call/test_function_call_parser.py +++ b/test/registered/unit/function_call/test_function_call_parser.py @@ -5781,12 +5781,6 @@ def _stream_arguments(self, detector, text, tools, chunk_size=8): arguments += call.parameters return name, arguments - def test_glm47_detect_and_parse(self): - detector = Glm47MoeDetector() - result = detector.detect_and_parse(self.glm47_text, self.oneof_tools) - self.assertEqual(len(result.calls), 1) - self.assertEqual(json.loads(result.calls[0].parameters), self.expected) - def test_glm47_streaming(self): detector = Glm47MoeDetector() name, arguments = self._stream_arguments( @@ -5803,12 +5797,6 @@ def test_glm47_streaming_object_argument_closes_outer_brace(self): self.assertEqual(name, "acme") self.assertEqual(json.loads(arguments), self.expected) - def test_glm4_detect_and_parse(self): - detector = Glm4MoeDetector() - result = detector.detect_and_parse(self.glm4_text, self.oneof_tools) - self.assertEqual(len(result.calls), 1) - self.assertEqual(json.loads(result.calls[0].parameters), self.expected) - def test_glm4_streaming(self): detector = Glm4MoeDetector() name, arguments = self._stream_arguments( 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 a2ffa2674efc..8f6179ea0b2f 100644 --- a/test/registered/unit/function_call/test_minimax_m3_detector.py +++ b/test/registered/unit/function_call/test_minimax_m3_detector.py @@ -549,14 +549,10 @@ def setUp(self): { "type": "object", "properties": { - "kind": {"const": "acme"}, - "payload": { - "type": "object", - "properties": {"value": {"type": "string"}}, - "required": ["value"], - }, + "count": {"type": "integer"}, + "verbose": {"type": "boolean"}, }, - "required": ["kind", "payload"], + "required": ["count", "verbose"], }, { "type": "object", @@ -571,27 +567,20 @@ def setUp(self): self.segments = ( "", '', - "acme", - "", - "", - "hello", - "", - "", + "7", + "", + "true", + "", "", "", ) - self.expected = {"kind": "acme", "payload": {"value": "hello"}} + 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) - def test_streaming(self): - calls = _stream_segments(self.segments, self.tools) - self.assertEqual(len(calls), 1) - self.assertEqual(calls[0]["args"], self.expected) - if __name__ == "__main__": unittest.main()