Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions xllm/api_service/utils.h
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,22 @@ inline ToolCallResult process_tool_calls(
return result;
}

if (finish_reason == "stop") {
result.finish_reason = "tool_calls";
} else {
result.finish_reason = std::move(finish_reason);
}

try {
auto [parsed_text, call_info_list] = parser.parse_non_stream(text);
result.text = std::move(parsed_text);

if (call_info_list.empty()) {
result.text = std::move(text);
result.finish_reason = std::move(finish_reason);
return result;
}
Comment thread
yq33victor marked this conversation as resolved.

if (finish_reason == "stop") {
result.finish_reason = "tool_calls";
} else {
result.finish_reason = std::move(finish_reason);
}

google::protobuf::RepeatedPtrField<proto::ToolCall> tool_calls;

for (const auto& call_info : call_info_list) {
Expand Down Expand Up @@ -154,4 +160,4 @@ inline nlohmann::json struct_to_json(
}

} // namespace api_service
} // namespace xllm
} // namespace xllm
4 changes: 3 additions & 1 deletion xllm/function_call/qwen25_detector.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ StreamingParseResult Qwen25Detector::detect_and_parse(

try {
std::string json_content(trimmed_content);
auto json_obj = nlohmann::json::parse(json_content);
auto [json_obj, consumed_len] =
partial_json_loads(json_content, Allow::ALL);
(void)consumed_len;
auto parsed_calls = parse_base_json(json_obj, tools);

calls.insert(calls.end(),
Expand Down
82 changes: 81 additions & 1 deletion xllm/function_call/qwen25_detector_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,39 @@ TEST_F(Qwen25DetectorTest, PerformanceWithManyToolCalls) {
}
}

// Regression test: malformed qwen3 payload in message.content should still be
// recovered into structured tool_calls for non-stream output.
TEST_F(Qwen25StreamingTest, NonStreamMalformedToolPayloadRecoversToolCall) {
nlohmann::json search_recall_params = {
{"type", "object"},
{"properties",
{{"attribute_selection", {{"type", "object"}}},
{"query_list", {{"type", "array"}}},
{"search_mode", {{"type", "string"}}}}},
{"required", {"attribute_selection", "query_list", "search_mode"}}};
JsonTool search_recall_tool(
"function",
JsonFunction("SearchRecall", "SearchRecall tool", search_recall_params));

FunctionCallParser parser({search_recall_tool}, "qwen25");

// Real user sample: malformed JSON inside <tool_call> content.
std::string malformed_content =
"<tool_call>\n"
"{\"name\": \"SearchRecall\", \"arguments\": {\"attribute_selection\": "
"{\"color: [\"}, \"query_list\": [{\"query\": \"kids wiggle car\"}], "
"\"search_mode\": \"single\"}}\n"
"</tool_call>";

auto [normal_text, calls] = parser.parse_non_stream(malformed_content);

EXPECT_TRUE(normal_text.empty());
ASSERT_EQ(calls.size(), 1);
ASSERT_TRUE(calls[0].name.has_value());
EXPECT_EQ(calls[0].name.value(), "SearchRecall");
EXPECT_FALSE(calls[0].parameters.empty());
}

// Test basic streaming functionality
TEST_F(Qwen25StreamingTest, BasicStreamingParsing) {
FunctionCallParser parser(tools_, "qwen25");
Expand Down Expand Up @@ -541,5 +574,52 @@ TEST_F(Qwen25StreamingTest, PartialTokenHandling) {
EXPECT_GT(accumulated_calls.size(), 0);
}

// Regression test: even with malformed payload from message.content, streaming
// parser may still emit intermediate tool-call events (e.g. tool name token).
TEST_F(Qwen25StreamingTest,
StreamingMalformedToolPayloadStillEmitsToolNameEvent) {
nlohmann::json search_recall_params = {
{"type", "object"},
{"properties",
{{"attribute_selection", {{"type", "object"}}},
{"query_list", {{"type", "array"}}},
{"search_mode", {{"type", "string"}}}}},
{"required", {"attribute_selection", "query_list", "search_mode"}}};
JsonTool search_recall_tool(
"function",
JsonFunction("SearchRecall", "SearchRecall tool", search_recall_params));

FunctionCallParser parser({search_recall_tool}, "qwen25");

std::vector<std::string> chunks = {
"<tool_call>\n",
"{\"name\": \"SearchRecall\", \"arguments\": {\"attribute_selection\": "
"{\"color: [\"}, ",
"\"query_list\": [{\"query\": \"kids wiggle car\"}], \"search_mode\": "
"\"single\"}}\n",
"</tool_call>"};

std::string streamed_normal_text;
std::vector<ToolCallItem> streamed_calls;
for (const auto& chunk : chunks) {
auto result = parser.parse_streaming_increment(chunk);
streamed_normal_text += result.normal_text;
streamed_calls.insert(
streamed_calls.end(), result.calls.begin(), result.calls.end());
}

bool found_search_recall_name = false;
for (const auto& call : streamed_calls) {
if (call.name.has_value() && call.name.value() == "SearchRecall") {
found_search_recall_name = true;
break;
}
}

EXPECT_FALSE(streamed_calls.empty());
EXPECT_TRUE(found_search_recall_name);
EXPECT_TRUE(streamed_normal_text.empty());
}

} // namespace function_call
} // namespace xllm
} // namespace xllm
Loading