40633.diff 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. diff --git a/python/sglang/srt/function_call/mimo_detector.py b/python/sglang/srt/function_call/mimo_detector.py
  2. index 1af745c82d88..6a273f6a4bcd 100644
  3. --- a/python/sglang/srt/function_call/mimo_detector.py
  4. +++ b/python/sglang/srt/function_call/mimo_detector.py
  5. @@ -12,7 +12,6 @@
  6. # limitations under the License.
  7. # ==============================================================================
  8. -import html
  9. import json
  10. import logging
  11. import re
  12. @@ -24,6 +23,7 @@
  13. from sglang.srt.function_call.core_types import StreamingParseResult, _GetInfoFunc
  14. from sglang.srt.function_call.utils import (
  15. get_schema_properties,
  16. + infer_type_from_json_schema,
  17. safe_literal_eval,
  18. )
  19. @@ -36,7 +36,7 @@ def _get_param_type(func_name: str, param_name: str, tools: List[Tool]) -> str:
  20. if tool.function.name == func_name:
  21. props = get_schema_properties(tool.function.parameters)
  22. if param_name in props:
  23. - return props[param_name].get("type", "string")
  24. + return infer_type_from_json_schema(props[param_name]) or "string"
  25. return "string"
  26. @@ -47,16 +47,12 @@ def _convert_param_value(
  27. Convert parameter value based on its type in the schema.
  28. Adapted from vllm-project/vllm (vllm/entrypoints/openai/tool_parsers/qwen3coder_tool_parser.py)
  29. """
  30. - param_value = html.unescape(param_value)
  31. -
  32. - # Handle null value for any type
  33. - if param_value.lower() == "null":
  34. - return None
  35. -
  36. param_type = _get_param_type(func_name, param_name, tools)
  37. if param_type in ["string", "str", "text", "varchar", "char", "enum"]:
  38. return param_value
  39. + elif param_value == "null":
  40. + return None
  41. elif (
  42. param_type.startswith("int")
  43. or param_type.startswith("integer")
  44. @@ -278,7 +274,88 @@ def _parse_tool_call(
  45. return {"name": func_name, "parameters": params}
  46. def supports_structural_tag(self) -> bool:
  47. - return False
  48. + return True
  49. def structure_info(self) -> _GetInfoFunc:
  50. raise NotImplementedError
  51. +
  52. + def get_structural_tag_name(self) -> str:
  53. + return "mimo"
  54. +
  55. + def get_structural_tag(
  56. + self,
  57. + tools=None,
  58. + tool_choice="auto",
  59. + thinking_mode=False,
  60. + parallel_tool_calls=True,
  61. + ):
  62. + try:
  63. + return super().get_structural_tag(
  64. + tools, tool_choice, thinking_mode, parallel_tool_calls
  65. + )
  66. + except ValueError as error:
  67. + if not str(error).startswith("Unknown format type: mimo,"):
  68. + raise
  69. +
  70. + # XGrammar 0.2.7 has qwen_xml, but predates the compact MiMo builtin.
  71. + from xgrammar import normalize_tool_choice
  72. + from xgrammar.structural_tag import (
  73. + AnyTextFormat,
  74. + JSONSchemaFormat,
  75. + SequenceFormat,
  76. + StructuralTag,
  77. + TagFormat,
  78. + TriggeredTagsFormat,
  79. + )
  80. +
  81. + from sglang.srt.entrypoints.openai.protocol import ToolChoice
  82. +
  83. + functions, builtins, choice = normalize_tool_choice(
  84. + [tool.model_dump() for tool in tools or []],
  85. + tool_choice.model_dump()
  86. + if isinstance(tool_choice, ToolChoice)
  87. + else tool_choice,
  88. + )
  89. + if builtins:
  90. + raise ValueError("MiMo does not support builtin tools.")
  91. + tags = [
  92. + TagFormat(
  93. + begin=f"<tool_call><function={tool.function.name}>",
  94. + content=JSONSchemaFormat(
  95. + json_schema=(
  96. + tool.function.parameters
  97. + if tool.function.strict is not False
  98. + and tool.function.parameters
  99. + else True
  100. + ),
  101. + style="qwen_xml",
  102. + ),
  103. + end="</function></tool_call>",
  104. + )
  105. + for tool in functions
  106. + ]
  107. + excludes = ["<think>", "</think>", "</tool_call>", "<function="]
  108. + if choice == "forced":
  109. + suffix = tags[0]
  110. + elif tags:
  111. + suffix = TriggeredTagsFormat(
  112. + triggers=["<tool_call>"],
  113. + tags=tags,
  114. + excludes=excludes,
  115. + at_least_one=choice == "required",
  116. + stop_after_first=not parallel_tool_calls,
  117. + )
  118. + else:
  119. + suffix = AnyTextFormat(excludes=["<tool_call>", *excludes])
  120. + if thinking_mode:
  121. + suffix = SequenceFormat(
  122. + elements=[
  123. + TagFormat(
  124. + begin="<think>",
  125. + content=AnyTextFormat(excludes=["<tool_call>", *excludes]),
  126. + end="</think>",
  127. + ),
  128. + suffix,
  129. + ]
  130. + )
  131. + return StructuralTag(format=suffix)
  132. diff --git a/test/registered/unit/function_call/test_function_call_parser.py b/test/registered/unit/function_call/test_function_call_parser.py
  133. index 359ae726dbfc..20aa22a5f5f9 100644
  134. --- a/test/registered/unit/function_call/test_function_call_parser.py
  135. +++ b/test/registered/unit/function_call/test_function_call_parser.py
  136. @@ -33,6 +33,7 @@
  137. from sglang.srt.function_call.lfm2_detector import Lfm2Detector
  138. from sglang.srt.function_call.ling3_detector import Ling3Detector
  139. from sglang.srt.function_call.llama32_detector import Llama32Detector
  140. +from sglang.srt.function_call.mimo_detector import MiMoDetector
  141. from sglang.srt.function_call.mistral_detector import MistralDetector
  142. from sglang.srt.function_call.parser_names import TOOL_CALL_PARSER_NAMES
  143. from sglang.srt.function_call.pythonic_detector import PythonicDetector
  144. @@ -6024,5 +6025,137 @@ def test_matches_registry(self):
  145. )
  146. +class TestMiMoStructuralTag(unittest.TestCase):
  147. + def setUp(self):
  148. + self.tools = [
  149. + Tool(
  150. + type="function",
  151. + function=Function(
  152. + name="run",
  153. + strict=True,
  154. + parameters={
  155. + "type": "object",
  156. + "properties": {
  157. + "text": {"type": "string"},
  158. + "n": {"type": ["integer", "null"]},
  159. + "data": {"type": "object"},
  160. + },
  161. + "required": ["text", "n", "data"],
  162. + "additionalProperties": False,
  163. + },
  164. + ),
  165. + )
  166. + ]
  167. + self.call = (
  168. + "<tool_call><function=run><parameter=text>\n北京 &amp;\n</parameter>"
  169. + '<parameter=n>2</parameter><parameter=data>{"items": [true, null]}</parameter></function></tool_call>'
  170. + )
  171. +
  172. + def test_compact_xml_schema_constraints(self):
  173. + from xgrammar.testing import _is_grammar_accept_string
  174. +
  175. + for policy in [
  176. + "auto",
  177. + "required",
  178. + ToolChoice(type="function", function=ToolChoiceFuncName(name="run")),
  179. + ]:
  180. + for thinking in [False, True]:
  181. + with self.subTest(policy=policy, thinking=thinking):
  182. + detector = MiMoDetector()
  183. + self.assertTrue(detector.supports_structural_tag())
  184. + tag = detector.get_structural_tag(
  185. + self.tools, policy, thinking_mode=thinking
  186. + )
  187. + grammar = xgr.Grammar.from_structural_tag(tag)
  188. + prefix = "<think>plan</think>" if thinking else ""
  189. + self.assertTrue(
  190. + _is_grammar_accept_string(grammar, prefix + self.call)
  191. + )
  192. + self.assertEqual(
  193. + _is_grammar_accept_string(grammar, prefix + "answer"),
  194. + policy == "auto",
  195. + )
  196. + for invalid in [
  197. + self.call.replace("function=run", "function=unknown"),
  198. + self.call.replace("<parameter=n>2</parameter>", ""),
  199. + self.call.replace("<parameter=n>2", "<parameter=n>bad"),
  200. + self.call.replace("<parameter=n>", "<parameter=extra>"),
  201. + self.call.replace(
  202. + "</function>", "<parameter=n>3</parameter></function>"
  203. + ),
  204. + ]:
  205. + self.assertFalse(
  206. + _is_grammar_accept_string(grammar, prefix + invalid)
  207. + )
  208. + self.assertEqual(
  209. + _is_grammar_accept_string(grammar, prefix + self.call * 2),
  210. + isinstance(policy, str),
  211. + )
  212. + single = xgr.Grammar.from_structural_tag(
  213. + detector.get_structural_tag(
  214. + self.tools,
  215. + policy,
  216. + thinking_mode=thinking,
  217. + parallel_tool_calls=False,
  218. + )
  219. + )
  220. + self.assertTrue(
  221. + _is_grammar_accept_string(single, prefix + self.call)
  222. + )
  223. + self.assertFalse(
  224. + _is_grammar_accept_string(single, prefix + self.call * 2)
  225. + )
  226. +
  227. + def test_non_strict_keeps_native_parameter_format(self):
  228. + from xgrammar.testing import _is_grammar_accept_string
  229. +
  230. + self.tools[0].function.strict = False
  231. + grammar = xgr.Grammar.from_structural_tag(
  232. + MiMoDetector().get_structural_tag(self.tools, "required")
  233. + )
  234. + self.assertTrue(
  235. + _is_grammar_accept_string(
  236. + grammar,
  237. + "<tool_call><function=run><parameter=extra>anything</parameter></function></tool_call>",
  238. + )
  239. + )
  240. +
  241. + def test_parameters_round_trip_without_html_or_string_coercion(self):
  242. + for text in ["\n北京 &amp;\n", "null", "42", ""]:
  243. + for number in ["2", "null"]:
  244. + with self.subTest(text=text, number=number):
  245. + call = self.call.replace("\n北京 &amp;\n", text).replace(
  246. + "<parameter=n>2", "<parameter=n>" + number
  247. + )
  248. + result = MiMoDetector().detect_and_parse(call, self.tools)
  249. + self.assertEqual(
  250. + json.loads(result.calls[0].parameters),
  251. + {
  252. + "text": text,
  253. + "n": json.loads(number),
  254. + "data": {"items": [True, None]},
  255. + },
  256. + )
  257. +
  258. + def test_streamed_compact_calls_preserve_arguments(self):
  259. + # MiMo's tool-call markers are atomic tokenizer tokens; parameter tags are not.
  260. + chunks = [
  261. + "<tool_call>",
  262. + *self.call[len("<tool_call>") : -len("</tool_call>")],
  263. + "</tool_call>",
  264. + ]
  265. + detector = MiMoDetector()
  266. + calls = []
  267. + for chunk in chunks * 2:
  268. + calls.extend(detector.parse_streaming_increment(chunk, self.tools).calls)
  269. + self.assertEqual(len(calls), 2)
  270. + self.assertEqual([call.tool_index for call in calls], [0, 1])
  271. + for call in calls:
  272. + self.assertEqual(
  273. + json.loads(call.parameters),
  274. + {"text": "\n北京 &amp;\n", "n": 2, "data": {"items": [True, None]}},
  275. + )
  276. +
  277. +
  278. if __name__ == "__main__":
  279. unittest.main()