From 6617db1bfba34fce90e56f8876db1490ca530bc5 Mon Sep 17 00:00:00 2001 From: Maxwill Lin <0312fs3@gmail.com> Date: Tue, 23 Jun 2026 13:43:11 -0700 Subject: [PATCH] [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> --- .../test_hunyuan_a13b_tool_parser.py | 12 +++++++++ .../tool_parsers/test_seed_oss_tool_parser.py | 25 +++++++++++++++++++ tests/tool_parsers/test_xlam_tool_parser.py | 23 +++++++++++++++++ vllm/tool_parsers/hunyuan_a13b_tool_parser.py | 2 +- vllm/tool_parsers/seed_oss_tool_parser.py | 16 +++++++----- vllm/tool_parsers/xlam_tool_parser.py | 6 +++-- 6 files changed, 75 insertions(+), 9 deletions(-) diff --git a/tests/tool_parsers/test_hunyuan_a13b_tool_parser.py b/tests/tool_parsers/test_hunyuan_a13b_tool_parser.py index 90f08bb82e..167ad66889 100644 --- a/tests/tool_parsers/test_hunyuan_a13b_tool_parser.py +++ b/tests/tool_parsers/test_hunyuan_a13b_tool_parser.py @@ -177,3 +177,15 @@ def test_hunyuan_a13b_tool_parser_streaming(model_deltas, expected_tool_calls): reconstructor.tool_calls[idx].id = expected_tool_calls[idx].id assert reconstructor.tool_calls == expected_tool_calls + + +def test_hunyuan_a13b_tool_parser_non_ascii(): + mock_tokenizer = MagicMock() + tool_parser: ToolParser = ToolParserManager.get_tool_parser("hunyuan_a13b")( + mock_tokenizer + ) + model_output = '[{"name": "get_weather", "arguments": {"city": "北京"}}]' + _, tool_calls = run_tool_extraction(tool_parser, model_output, streaming=False) + args = tool_calls[0].function.arguments + assert "北京" in args + assert "\\u" not in args diff --git a/tests/tool_parsers/test_seed_oss_tool_parser.py b/tests/tool_parsers/test_seed_oss_tool_parser.py index 9dd13afe01..4ff96fb01b 100644 --- a/tests/tool_parsers/test_seed_oss_tool_parser.py +++ b/tests/tool_parsers/test_seed_oss_tool_parser.py @@ -495,3 +495,28 @@ def test_streaming_tool_calls( actual_args = json.loads(arguments_str) expected_args = json.loads(expected_tool.function.arguments) assert actual_args == expected_args + + +def test_streaming_tool_calls_non_ascii( + seed_oss_tool_parser, seed_oss_tokenizer, sample_tools +): + request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools) + model_output = ( + """\n\n\n""" + """The current thinking budget is 0, so I will directly start answering the question.\n\n""" + """\n\n""" + """北京\n\n""" + ) + + args = "".join( + tool_call.function.arguments + for delta_message in stream_delta_message_generator( + seed_oss_tool_parser, seed_oss_tokenizer, model_output, request + ) + if delta_message.tool_calls + for tool_call in delta_message.tool_calls + if tool_call.function and tool_call.function.arguments is not None + ) + + assert "北京" in args + assert "\\u" not in args diff --git a/tests/tool_parsers/test_xlam_tool_parser.py b/tests/tool_parsers/test_xlam_tool_parser.py index 3853d2039a..5d39f0b575 100644 --- a/tests/tool_parsers/test_xlam_tool_parser.py +++ b/tests/tool_parsers/test_xlam_tool_parser.py @@ -532,3 +532,26 @@ def test_extract_tool_calls_streaming_incremental( parsed_args = json.loads(full_args) expected_args = json.loads(expected_first_tool.function.arguments) assert parsed_args == expected_args + + +@pytest.mark.parametrize("streaming", [False, True]) +def test_extract_tool_calls_non_ascii(xlam_tool_parser, xlam_tokenizer, streaming): + # Use parallel tool calls so the streaming path re-serializes arguments + # (the ensure_ascii fix only runs when tool_count > 1). + model_output = """[{"name": "get_current_weather", "arguments": {"city": "北京"}}, {"name": "get_current_weather", "arguments": {"city": "上海"}}]""" # noqa: E501 + + if streaming: + request = ChatCompletionRequest(model=MODEL, messages=[]) + args = "".join( + delta.tool_calls[0].function.arguments + for delta in stream_delta_message_generator( + xlam_tool_parser, xlam_tokenizer, model_output, request + ) + if delta.tool_calls and delta.tool_calls[0].function.arguments + ) + else: + extracted = xlam_tool_parser.extract_tool_calls(model_output, request=None) # type: ignore[arg-type] + args = "".join(tc.function.arguments for tc in extracted.tool_calls) + + assert "北京" in args + assert "\\u" not in args diff --git a/vllm/tool_parsers/hunyuan_a13b_tool_parser.py b/vllm/tool_parsers/hunyuan_a13b_tool_parser.py index 9723ef45d2..f5cd9f85a0 100644 --- a/vllm/tool_parsers/hunyuan_a13b_tool_parser.py +++ b/vllm/tool_parsers/hunyuan_a13b_tool_parser.py @@ -144,7 +144,7 @@ class HunyuanA13BToolParser(ToolParser): function=FunctionCall( name=call["name"], arguments=( - json.dumps(call["arguments"]) + json.dumps(call["arguments"], ensure_ascii=False) if isinstance(call["arguments"], dict) else call["arguments"] ), diff --git a/vllm/tool_parsers/seed_oss_tool_parser.py b/vllm/tool_parsers/seed_oss_tool_parser.py index a90bdc76d9..42e4ba7769 100644 --- a/vllm/tool_parsers/seed_oss_tool_parser.py +++ b/vllm/tool_parsers/seed_oss_tool_parser.py @@ -528,7 +528,7 @@ class SeedOssToolParser(ToolParser): '"' + self.current_param_name + '": "' - + json.dumps(param_value)[1:-1] + + json.dumps(param_value, ensure_ascii=False)[1:-1] + '"' ) else: @@ -536,7 +536,7 @@ class SeedOssToolParser(ToolParser): ', "' + self.current_param_name + '": "' - + json.dumps(param_value)[1:-1] + + json.dumps(param_value, ensure_ascii=False)[1:-1] + '"' ) @@ -571,11 +571,11 @@ class SeedOssToolParser(ToolParser): # Calculate incremental JSON full_value = self.current_param_value + value_chunk prev_escaped = ( - json.dumps(self.current_param_value)[1:-1] + json.dumps(self.current_param_value, ensure_ascii=False)[1:-1] if self.current_param_value else "" ) - full_escaped = json.dumps(full_value)[1:-1] + full_escaped = json.dumps(full_value, ensure_ascii=False)[1:-1] delta_escaped = full_escaped[len(prev_escaped) :] self.in_param = False @@ -606,12 +606,16 @@ class SeedOssToolParser(ToolParser): if value_chunk: # Stream the escaped delta prev_escaped = ( - json.dumps(self.current_param_value)[1:-1] + json.dumps(self.current_param_value, ensure_ascii=False)[ + 1:-1 + ] if self.current_param_value else "" ) self.current_param_value += value_chunk - full_escaped = json.dumps(self.current_param_value)[1:-1] + full_escaped = json.dumps( + self.current_param_value, ensure_ascii=False + )[1:-1] delta_escaped = full_escaped[len(prev_escaped) :] if delta_escaped: diff --git a/vllm/tool_parsers/xlam_tool_parser.py b/vllm/tool_parsers/xlam_tool_parser.py index 61eaaf952b..d004e83352 100644 --- a/vllm/tool_parsers/xlam_tool_parser.py +++ b/vllm/tool_parsers/xlam_tool_parser.py @@ -165,7 +165,7 @@ class xLAMToolParser(ToolParser): function=FunctionCall( name=call["name"], arguments=( - json.dumps(call["arguments"]) + json.dumps(call["arguments"], ensure_ascii=False) if isinstance(call["arguments"], dict) else call["arguments"] ), @@ -473,7 +473,9 @@ class xLAMToolParser(ToolParser): ): current_tool = parsed_tools[current_idx] if isinstance(current_tool.get("arguments"), dict): - args_text = json.dumps(current_tool["arguments"]) + args_text = json.dumps( + current_tool["arguments"], ensure_ascii=False + ) else: args_text = str(current_tool.get("arguments", "{}")) except (json.JSONDecodeError, KeyError, IndexError):