Skip to content

Commit 25cd973

Browse files
committed
bugfix: improve qwen25 tool-call parsing robustness.
1 parent 5b42b40 commit 25cd973

3 files changed

Lines changed: 97 additions & 9 deletions

File tree

xllm/api_service/utils.h

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,22 @@ inline ToolCallResult process_tool_calls(
9898
return result;
9999
}
100100

101-
if (finish_reason == "stop") {
102-
result.finish_reason = "tool_calls";
103-
} else {
104-
result.finish_reason = std::move(finish_reason);
105-
}
106-
107101
try {
108102
auto [parsed_text, call_info_list] = parser.parse_non_stream(text);
109103
result.text = std::move(parsed_text);
110104

105+
if (call_info_list.empty()) {
106+
result.text = std::move(text);
107+
result.finish_reason = std::move(finish_reason);
108+
return result;
109+
}
110+
111+
if (finish_reason == "stop") {
112+
result.finish_reason = "tool_calls";
113+
} else {
114+
result.finish_reason = std::move(finish_reason);
115+
}
116+
111117
google::protobuf::RepeatedPtrField<proto::ToolCall> tool_calls;
112118

113119
for (const auto& call_info : call_info_list) {
@@ -154,4 +160,4 @@ inline nlohmann::json struct_to_json(
154160
}
155161

156162
} // namespace api_service
157-
} // namespace xllm
163+
} // namespace xllm

xllm/function_call/qwen25_detector.cpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ StreamingParseResult Qwen25Detector::detect_and_parse(
110110

111111
try {
112112
std::string json_content(trimmed_content);
113-
auto json_obj = nlohmann::json::parse(json_content);
113+
auto [json_obj, consumed_len] =
114+
partial_json_loads(json_content, Allow::ALL);
115+
(void)consumed_len;
114116
auto parsed_calls = parse_base_json(json_obj, tools);
115117

116118
calls.insert(calls.end(),

xllm/function_call/qwen25_detector_test.cpp

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,39 @@ TEST_F(Qwen25DetectorTest, PerformanceWithManyToolCalls) {
353353
}
354354
}
355355

356+
// Regression test: malformed qwen3 payload in message.content should still be
357+
// recovered into structured tool_calls for non-stream output.
358+
TEST_F(Qwen25StreamingTest, NonStreamMalformedToolPayloadRecoversToolCall) {
359+
nlohmann::json search_recall_params = {
360+
{"type", "object"},
361+
{"properties",
362+
{{"attribute_selection", {{"type", "object"}}},
363+
{"query_list", {{"type", "array"}}},
364+
{"search_mode", {{"type", "string"}}}}},
365+
{"required", {"attribute_selection", "query_list", "search_mode"}}};
366+
JsonTool search_recall_tool(
367+
"function",
368+
JsonFunction("SearchRecall", "SearchRecall tool", search_recall_params));
369+
370+
FunctionCallParser parser({search_recall_tool}, "qwen25");
371+
372+
// Real user sample: malformed JSON inside <tool_call> content.
373+
std::string malformed_content =
374+
"<tool_call>\n"
375+
"{\"name\": \"SearchRecall\", \"arguments\": {\"attribute_selection\": "
376+
"{\"color: [\"}, \"query_list\": [{\"query\": \"kids wiggle car\"}], "
377+
"\"search_mode\": \"single\"}}\n"
378+
"</tool_call>";
379+
380+
auto [normal_text, calls] = parser.parse_non_stream(malformed_content);
381+
382+
EXPECT_TRUE(normal_text.empty());
383+
ASSERT_EQ(calls.size(), 1);
384+
ASSERT_TRUE(calls[0].name.has_value());
385+
EXPECT_EQ(calls[0].name.value(), "SearchRecall");
386+
EXPECT_FALSE(calls[0].parameters.empty());
387+
}
388+
356389
// Test basic streaming functionality
357390
TEST_F(Qwen25StreamingTest, BasicStreamingParsing) {
358391
FunctionCallParser parser(tools_, "qwen25");
@@ -541,5 +574,52 @@ TEST_F(Qwen25StreamingTest, PartialTokenHandling) {
541574
EXPECT_GT(accumulated_calls.size(), 0);
542575
}
543576

577+
// Regression test: even with malformed payload from message.content, streaming
578+
// parser may still emit intermediate tool-call events (e.g. tool name token).
579+
TEST_F(Qwen25StreamingTest,
580+
StreamingMalformedToolPayloadStillEmitsToolNameEvent) {
581+
nlohmann::json search_recall_params = {
582+
{"type", "object"},
583+
{"properties",
584+
{{"attribute_selection", {{"type", "object"}}},
585+
{"query_list", {{"type", "array"}}},
586+
{"search_mode", {{"type", "string"}}}}},
587+
{"required", {"attribute_selection", "query_list", "search_mode"}}};
588+
JsonTool search_recall_tool(
589+
"function",
590+
JsonFunction("SearchRecall", "SearchRecall tool", search_recall_params));
591+
592+
FunctionCallParser parser({search_recall_tool}, "qwen25");
593+
594+
std::vector<std::string> chunks = {
595+
"<tool_call>\n",
596+
"{\"name\": \"SearchRecall\", \"arguments\": {\"attribute_selection\": "
597+
"{\"color: [\"}, ",
598+
"\"query_list\": [{\"query\": \"kids wiggle car\"}], \"search_mode\": "
599+
"\"single\"}}\n",
600+
"</tool_call>"};
601+
602+
std::string streamed_normal_text;
603+
std::vector<ToolCallItem> streamed_calls;
604+
for (const auto& chunk : chunks) {
605+
auto result = parser.parse_streaming_increment(chunk);
606+
streamed_normal_text += result.normal_text;
607+
streamed_calls.insert(
608+
streamed_calls.end(), result.calls.begin(), result.calls.end());
609+
}
610+
611+
bool found_search_recall_name = false;
612+
for (const auto& call : streamed_calls) {
613+
if (call.name.has_value() && call.name.value() == "SearchRecall") {
614+
found_search_recall_name = true;
615+
break;
616+
}
617+
}
618+
619+
EXPECT_FALSE(streamed_calls.empty());
620+
EXPECT_TRUE(found_search_recall_name);
621+
EXPECT_TRUE(streamed_normal_text.empty());
622+
}
623+
544624
} // namespace function_call
545-
} // namespace xllm
625+
} // namespace xllm

0 commit comments

Comments
 (0)