@@ -31,7 +31,7 @@ const std::string tokenizerPath = getWindowsRepoRootPath() + "\\src\\test\\llm_t
3131const std::string tokenizerPath = " /ovms/src/test/llm_testing/meta-llama/Llama-3.1-8B-Instruct" ;
3232#endif
3333
34- static const ovms::ToolsSchemas_t EMPTY_TOOL_SCHEMA = {}; // not used for llama3
34+ static const ovms::ToolsSchemas_t EMPTY_TOOLS_SCHEMA = {}; // not used for llama3
3535static std::unique_ptr<ov::genai::Tokenizer> llama3Tokenizer;
3636
3737// Id of the <|python_tag|> which is a special token used to indicate the start of a tool calls
@@ -57,8 +57,8 @@ class Llama3OutputParserTest : public ::testing::Test {
5757 }
5858
5959 void SetUp () override {
60- outputParserWithRegularToolParsing = std::make_unique<OutputParser>(*llama3Tokenizer, " llama3" , " " , EMPTY_TOOL_SCHEMA );
61- outputParserWithImmediateToolParsing = std::make_unique<OutputParser>(*llama3Tokenizer, " llama3" , " " , EMPTY_TOOL_SCHEMA );
60+ outputParserWithRegularToolParsing = std::make_unique<OutputParser>(*llama3Tokenizer, " llama3" , " " , EMPTY_TOOLS_SCHEMA );
61+ outputParserWithImmediateToolParsing = std::make_unique<OutputParser>(*llama3Tokenizer, " llama3" , " " , EMPTY_TOOLS_SCHEMA );
6262 outputParserWithImmediateToolParsing->enableImmediateToolParsing ();
6363 }
6464};
@@ -223,7 +223,7 @@ TEST_F(Llama3OutputParserTest, HolisticStreaming) {
223223
224224 for (auto lastFinishReason : {ov::genai::GenerationFinishReason::NONE, ov::genai::GenerationFinishReason::STOP, ov::genai::GenerationFinishReason::LENGTH}) {
225225 // Need to have new output parser per case to simulate separate request processing
226- outputParserWithRegularToolParsing = std::make_unique<OutputParser>(*llama3Tokenizer, " llama3" , " " , EMPTY_TOOL_SCHEMA );
226+ outputParserWithRegularToolParsing = std::make_unique<OutputParser>(*llama3Tokenizer, " llama3" , " " , EMPTY_TOOLS_SCHEMA );
227227 auto chunkToDeltaVecCopy = chunkToDeltaVec;
228228 if (lastFinishReason == ov::genai::GenerationFinishReason::NONE) {
229229 chunkToDeltaVecCopy.push_back ({" Paris\" }}" , ov::genai::GenerationFinishReason::NONE, R"( {"delta":{"tool_calls":[{"index":1,"function":{"arguments":" \""}}]}})" });
@@ -284,6 +284,126 @@ TEST_F(Llama3OutputParserTest, HolisticStreaming) {
284284 }
285285}
286286
287+ // Positive test for streaming tool calls with complex arguments containing special characters
288+ TEST_F (Llama3OutputParserTest, StreamingToolWithComplexArguments) {
289+ std::vector<std::tuple<std::string, std::optional<std::string>>> chunkToDeltaVec{
290+ {" {\" " , std::nullopt },
291+ {" name" , std::nullopt },
292+ {" \" :" , std::nullopt },
293+ {" \" " , std::nullopt },
294+ {" python_code" , std::nullopt },
295+ {" _" , std::nullopt },
296+ {" execution_tool" , std::nullopt },
297+ {" \" ," , std::nullopt },
298+ {" \" " , std::nullopt },
299+ {" arguments" , std::nullopt },
300+ // As we have 'arguments' key present, we can return first delta
301+ {" \" :" , " {\" delta\" :{\" tool_calls\" :[{\" id\" :\" XXXXXXXXX\" ,\" type\" :\" function\" ,\" index\" :0,\" function\" :{\" name\" :\" python_code_execution_tool\" }}]}}" },
302+ // Consecutive deltas without 'id' and 'type'. In order to find the end of arguments parser has one chunk delay to handle end of tool.
303+ {" {" , std::nullopt },
304+ {" \" " , " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\" {\" }}]}}" },
305+ {" function" , " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\"\\\"\" }}]}}" },
306+ {" \" : " , " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\" function\" }}]}}" },
307+ {" \" " , " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\"\\\" : \" }}]}}" },
308+ /*
309+ Next chunks will simulate sending piece of Python code as argument value.
310+ ```python
311+ def example_function(arg1, arg2):
312+ nested_dict = {"nested_arg1": "nested_value1", "nested_arg2": "nested_value2"}
313+ if arg1 == "value1" and arg2 == "arg2":
314+ return nested_dict
315+ else:
316+ return {}
317+ ```
318+ */
319+ {" def example_function(arg1, arg2):\n " , " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\"\\\"\" }}]}}" },
320+
321+ {" \t nested_dict = {\" nested_arg1\" : \" nested_value1\" , \" nested_arg2\" : \" nested_value2\" }\n " ,
322+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\" def example_function(arg1, arg2):\\ n\" }}]}}" },
323+ {" \t if arg1 == \" value1\" and arg2 == \" arg2\" :\n " ,
324+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\"\\ tnested_dict = {\\\" nested_arg1\\\" : \\\" nested_value1\\\" , \\\" nested_arg2\\\" : \\\" nested_value2\\\" }\\ n\" }}]}}" },
325+ {" \t\t return nested_dict\n " ,
326+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\"\\ tif arg1 == \\\" value1\\\" and arg2 == \\\" arg2\\\" :\\ n\" }}]}}" },
327+ {" \t else:\n " ,
328+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\"\\ t\\ treturn nested_dict\\ n\" }}]}}" },
329+ {" \t\t return {}\n " ,
330+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\"\\ telse:\\ n\" }}]}}" },
331+ {" nested_arg1" ,
332+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\"\\ t\\ treturn {}\\ n\" }}]}}" },
333+ {" \" : " ,
334+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\" nested_arg1\" }}]}}" },
335+ {" \" " ,
336+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\"\\\" : \" }}]}}" },
337+ {" nested_value1" ,
338+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\"\\\"\" }}]}}" },
339+ {" \" , " ,
340+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\" nested_value1\" }}]}}" },
341+ {" \" " ,
342+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\"\\\" , \" }}]}}" },
343+ {" nested_arg2" ,
344+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\"\\\"\" }}]}}" },
345+ {" \" : " ,
346+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\" nested_arg2\" }}]}}" },
347+ {" \" " ,
348+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\"\\\" : \" }}]}}" },
349+ {" nested_value2" ,
350+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\"\\\"\" }}]}}" },
351+ {" \" }}}" ,
352+ " {\" delta\" :{\" tool_calls\" :[{\" index\" :0,\" function\" :{\" arguments\" :\" nested_value2\" }}]}}" },
353+ };
354+
355+ auto outputParser = std::make_unique<OutputParser>(*llama3Tokenizer, " llama3" , " " , EMPTY_TOOLS_SCHEMA);
356+ for (const auto & [chunk, expectedDelta] : chunkToDeltaVec) {
357+ std::optional<rapidjson::Document> doc = outputParser->parseChunk (chunk, true , ov::genai::GenerationFinishReason::NONE);
358+ if (!expectedDelta.has_value () && !doc.has_value ()) {
359+ continue ; // Both are nullopt, OK
360+ }
361+ if (expectedDelta.has_value () && doc.has_value ()) {
362+ rapidjson::StringBuffer buffer;
363+ rapidjson::Writer<rapidjson::StringBuffer> writer (buffer);
364+ doc->Accept (writer);
365+ std::string docStr = buffer.GetString ();
366+ // If both strings contain "id":"...", compare id values by length and alphanumeric, else compare whole strings
367+ std::string expected = expectedDelta.value ();
368+ std::string idKey = " \" id\" :\" " ;
369+ auto docIdPos = docStr.find (idKey);
370+ auto expectedIdPos = expected.find (idKey);
371+ if (docIdPos != std::string::npos && expectedIdPos != std::string::npos) {
372+ auto docIdStart = docIdPos + idKey.size ();
373+ auto docIdEnd = docStr.find (" \" " , docIdStart);
374+ auto expectedIdStart = expectedIdPos + idKey.size ();
375+ auto expectedIdEnd = expected.find (" \" " , expectedIdStart);
376+ ASSERT_NE (docIdEnd, std::string::npos);
377+ ASSERT_NE (expectedIdEnd, std::string::npos);
378+ std::string docId = docStr.substr (docIdStart, docIdEnd - docIdStart);
379+ std::string expectedId = expected.substr (expectedIdStart, expectedIdEnd - expectedIdStart);
380+ EXPECT_EQ (docId.size (), expectedId.size ()) << " ID length mismatch for chunk: " << chunk;
381+ EXPECT_TRUE (std::all_of (docId.begin (), docId.end (), ::isalnum)) << " ID not alphanumeric for chunk: " << chunk;
382+ // Compare everything except the id value
383+ std::string docStrNoId = docStr;
384+ std::string expectedNoId = expected;
385+ docStrNoId.replace (docIdStart, docId.size (), std::string (docId.size (), ' *' ));
386+ expectedNoId.replace (expectedIdStart, expectedId.size (), std::string (expectedId.size (), ' *' ));
387+ EXPECT_EQ (docStrNoId, expectedNoId) << " Mismatch for chunk (ignoring id value): " << chunk;
388+ } else {
389+ EXPECT_EQ (docStr, expected) << " Mismatch for chunk: " << chunk << " Received: " << docStr << " , expected: " << expected;
390+ }
391+ } else {
392+ std::string expectedStr = expectedDelta.has_value () ? expectedDelta.value () : " std::nullopt" ;
393+ std::string docStr = doc.has_value () ? [&]() {
394+ rapidjson::StringBuffer buffer;
395+ rapidjson::Writer<rapidjson::StringBuffer> writer (buffer);
396+ doc->Accept (writer);
397+ return std::string (buffer.GetString ());
398+ }()
399+ : " std::nullopt" ;
400+ FAIL () << " Mismatch between expectedDelta and doc for chunk: " << chunk
401+ << " \n expectedDelta: " << expectedStr
402+ << " \n doc: " << docStr;
403+ }
404+ }
405+ }
406+
287407TEST_F (Llama3OutputParserTest, ToolCallsWithoutToolsInTheRequestStreaming) {
288408 std::vector<std::pair<std::string, std::optional<std::string>>> chunkToDeltaVec{
289409 // Tool parser is available, but tools are not in the request so every chunk is just a regular content
0 commit comments