[Bugfix][Frontend] Emit non-ASCII tool-call arguments without \uXXXX escapes (#46308)

Signed-off-by: EazyReal <8047065+EazyReal@users.noreply.github.com> Co-authored-by: EazyReal <8047065+EazyReal@users.noreply.github.com>

Maxwill Lin committed Jun 23, 2026 at 13:43 UTC 6617db1bfba34fce90e56f8876db1490ca530bc5
6 files changed +75 -9
tests/tool_parsers/test_hunyuan_a13b_tool_parser.py
+12
@@ -177,3 +177,15 @@ def test_hunyuan_a13b_tool_parser_streaming(model_deltas, expected_tool_calls):
177 reconstructor.tool_calls[idx].id = expected_tool_calls[idx].id
178
179 assert reconstructor.tool_calls == expected_tool_calls
180 +
181 +
182 +def test_hunyuan_a13b_tool_parser_non_ascii():
183 + mock_tokenizer = MagicMock()
184 + tool_parser: ToolParser = ToolParserManager.get_tool_parser("hunyuan_a13b")(
185 + mock_tokenizer
186 + )
187 + model_output = '<tool_calls>[{"name": "get_weather", "arguments": {"city": "北京"}}]</tool_calls>'
188 + _, tool_calls = run_tool_extraction(tool_parser, model_output, streaming=False)
189 + args = tool_calls[0].function.arguments
190 + assert "北京" in args
191 + assert "\\u" not in args
tests/tool_parsers/test_seed_oss_tool_parser.py
+25
@@ -495,3 +495,28 @@ def test_streaming_tool_calls(
495 actual_args = json.loads(arguments_str)
496 expected_args = json.loads(expected_tool.function.arguments)
497 assert actual_args == expected_args
498 +
499 +
500 +def test_streaming_tool_calls_non_ascii(
501 + seed_oss_tool_parser, seed_oss_tokenizer, sample_tools
502 +):
503 + request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools)
504 + model_output = (
505 + """<seed:think>\n</seed:cot_budget_reflect>\n</seed:cot_budget_reflect>\n"""
506 + """The current thinking budget is 0, so I will directly start answering the question.\n</seed:think>\n"""
507 + """<seed:tool_call>\n<function=get_weather>\n"""
508 + """<parameter=location>北京</parameter>\n</function>\n</seed:tool_call>"""
509 + )
510 +
511 + args = "".join(
512 + tool_call.function.arguments
513 + for delta_message in stream_delta_message_generator(
514 + seed_oss_tool_parser, seed_oss_tokenizer, model_output, request
515 + )
516 + if delta_message.tool_calls
517 + for tool_call in delta_message.tool_calls
518 + if tool_call.function and tool_call.function.arguments is not None
519 + )
520 +
521 + assert "北京" in args
522 + assert "\\u" not in args
tests/tool_parsers/test_xlam_tool_parser.py
+23
@@ -532,3 +532,26 @@ def test_extract_tool_calls_streaming_incremental(
532 parsed_args = json.loads(full_args)
533 expected_args = json.loads(expected_first_tool.function.arguments)
534 assert parsed_args == expected_args
535 +
536 +
537 +@pytest.mark.parametrize("streaming", [False, True])
538 +def test_extract_tool_calls_non_ascii(xlam_tool_parser, xlam_tokenizer, streaming):
539 + # Use parallel tool calls so the streaming path re-serializes arguments
540 + # (the ensure_ascii fix only runs when tool_count > 1).
541 + model_output = """[{"name": "get_current_weather", "arguments": {"city": "北京"}}, {"name": "get_current_weather", "arguments": {"city": "上海"}}]""" # noqa: E501
542 +
543 + if streaming:
544 + request = ChatCompletionRequest(model=MODEL, messages=[])
545 + args = "".join(
546 + delta.tool_calls[0].function.arguments
547 + for delta in stream_delta_message_generator(
548 + xlam_tool_parser, xlam_tokenizer, model_output, request
549 + )
550 + if delta.tool_calls and delta.tool_calls[0].function.arguments
551 + )
552 + else:
553 + extracted = xlam_tool_parser.extract_tool_calls(model_output, request=None) # type: ignore[arg-type]
554 + args = "".join(tc.function.arguments for tc in extracted.tool_calls)
555 +
556 + assert "北京" in args
557 + assert "\\u" not in args
vllm/tool_parsers/hunyuan_a13b_tool_parser.py
+1 -1
@@ -144,7 +144,7 @@ class HunyuanA13BToolParser(ToolParser):
144 function=FunctionCall(
145 name=call["name"],
146 arguments=(
147 - json.dumps(call["arguments"])
147 + json.dumps(call["arguments"], ensure_ascii=False)
148 if isinstance(call["arguments"], dict)
149 else call["arguments"]
150 ),
vllm/tool_parsers/seed_oss_tool_parser.py
+10 -6
@@ -528,7 +528,7 @@ class SeedOssToolParser(ToolParser):
528 '"'
529 + self.current_param_name
530 + '": "'
531 - + json.dumps(param_value)[1:-1]
531 + + json.dumps(param_value, ensure_ascii=False)[1:-1]
532 + '"'
533 )
534 else:
@@ -536,7 +536,7 @@ class SeedOssToolParser(ToolParser):
536 ', "'
537 + self.current_param_name
538 + '": "'
539 - + json.dumps(param_value)[1:-1]
539 + + json.dumps(param_value, ensure_ascii=False)[1:-1]
540 + '"'
541 )
542
@@ -571,11 +571,11 @@ class SeedOssToolParser(ToolParser):
571 # Calculate incremental JSON
572 full_value = self.current_param_value + value_chunk
573 prev_escaped = (
574 - json.dumps(self.current_param_value)[1:-1]
574 + json.dumps(self.current_param_value, ensure_ascii=False)[1:-1]
575 if self.current_param_value
576 else ""
577 )
578 - full_escaped = json.dumps(full_value)[1:-1]
578 + full_escaped = json.dumps(full_value, ensure_ascii=False)[1:-1]
579 delta_escaped = full_escaped[len(prev_escaped) :]
580
581 self.in_param = False
@@ -606,12 +606,16 @@ class SeedOssToolParser(ToolParser):
606 if value_chunk:
607 # Stream the escaped delta
608 prev_escaped = (
609 - json.dumps(self.current_param_value)[1:-1]
609 + json.dumps(self.current_param_value, ensure_ascii=False)[
610 + 1:-1
611 + ]
612 if self.current_param_value
613 else ""
614 )
615 self.current_param_value += value_chunk
614 - full_escaped = json.dumps(self.current_param_value)[1:-1]
616 + full_escaped = json.dumps(
617 + self.current_param_value, ensure_ascii=False
618 + )[1:-1]
619 delta_escaped = full_escaped[len(prev_escaped) :]
620
621 if delta_escaped:
vllm/tool_parsers/xlam_tool_parser.py
+4 -2
@@ -165,7 +165,7 @@ class xLAMToolParser(ToolParser):
165 function=FunctionCall(
166 name=call["name"],
167 arguments=(
168 - json.dumps(call["arguments"])
168 + json.dumps(call["arguments"], ensure_ascii=False)
169 if isinstance(call["arguments"], dict)
170 else call["arguments"]
171 ),
@@ -473,7 +473,9 @@ class xLAMToolParser(ToolParser):
473 ):
474 current_tool = parsed_tools[current_idx]
475 if isinstance(current_tool.get("arguments"), dict):
476 - args_text = json.dumps(current_tool["arguments"])
476 + args_text = json.dumps(
477 + current_tool["arguments"], ensure_ascii=False
478 + )
479 else:
480 args_text = str(current_tool.get("arguments", "{}"))
481 except (json.JSONDecodeError, KeyError, IndexError):