| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | |
| 5 | import json |
| 6 | |
| 7 | import pytest |
| 8 | |
| 9 | from tests.tool_parsers.common_tests import ( |
| 10 | ToolParserTestConfig, |
| 11 | ToolParserTests, |
| 12 | ) |
| 13 | from tests.tool_parsers.utils import ( |
| 14 | run_tool_extraction, |
| 15 | run_tool_extraction_streaming, |
| 16 | split_string_into_token_deltas, |
| 17 | ) |
| 18 | from vllm.tokenizers import get_tokenizer |
| 19 | from vllm.tool_parsers.granite_engine_tool_parser import GraniteEngineToolParser |
| 20 | |
| 21 | |
| 22 | class TestGraniteToolParser(ToolParserTests): |
| 23 | @pytest.fixture |
| 24 | def test_config(self) -> ToolParserTestConfig: |
| 25 | return ToolParserTestConfig( |
| 26 | parser_name="granite", |
| 27 | # Test data |
| 28 | no_tool_calls_output="This is a regular response without any tool calls.", |
| 29 | single_tool_call_output=( |
| 30 | '<|tool_call|> [{"name": "get_weather", ' |
| 31 | '"arguments": {"city": "Tokyo"}}]' |
| 32 | ), |
| 33 | parallel_tool_calls_output="""<|tool_call|> [ |
| 34 | {"name": "get_weather", "arguments": {"city": "Tokyo"}}, |
| 35 | {"name": "get_time", "arguments": {"timezone": "Asia/Tokyo"}} |
| 36 | ]""", |
| 37 | various_data_types_output="""<tool_call> [{ |
| 38 | "name": "test_function", |
| 39 | "arguments": { |
| 40 | "string_field": "hello", |
| 41 | "int_field": 42, |
| 42 | "float_field": 3.14, |
| 43 | "bool_field": true, |
| 44 | "null_field": null, |
| 45 | "array_field": ["a", "b", "c"], |
| 46 | "object_field": {"nested": "value"}, |
| 47 | "empty_array": [], |
| 48 | "empty_object": {} |
| 49 | } |
| 50 | }]""", |
| 51 | empty_arguments_output=( |
| 52 | '<|tool_call|> [{"name": "refresh", "arguments": {}}]' |
| 53 | ), |
| 54 | surrounding_text_output="""Let me check the weather for you. |
| 55 | <|tool_call|> [{"name": "get_weather", "arguments": {"city": "Tokyo"}}] |
| 56 | I'll get that information.""", |
| 57 | escaped_strings_output="""<tool_call> [{ |
| 58 | "name": "test_function", |
| 59 | "arguments": { |
| 60 | "quoted": "He said \\"hello\\"", |
| 61 | "path": "C:\\\\Users\\\\file.txt", |
| 62 | "newline": "line1\\nline2", |
| 63 | "unicode": "emoji: 🎉" |
| 64 | } |
| 65 | }]""", |
| 66 | malformed_input_outputs=[ |
| 67 | '<|tool_call|> [{"name": "func", "arguments": {', |
| 68 | '<|tool_call|> {"name": "func", "arguments": {}}', # Not an array |
| 69 | '[{"name": "func", "arguments": "not a dict"}]', |
| 70 | 'Some text [{"name": "func"}]', # JSON but not tool call format |
| 71 | ], |
| 72 | # Expected results |
| 73 | single_tool_call_expected_name="get_weather", |
| 74 | single_tool_call_expected_args={"city": "Tokyo"}, |
| 75 | # Granite strips content when tool calls present |
| 76 | single_tool_call_expected_content=None, |
| 77 | parallel_tool_calls_count=2, |
| 78 | parallel_tool_calls_names=["get_weather", "get_time"], |
| 79 | ) |
| 80 | |
| 81 | # Granite-Specific Tests |
| 82 | |
| 83 | @pytest.mark.parametrize("streaming", [True, False]) |
| 84 | def test_granite_token_prefix_format(self, tool_parser, streaming): |
| 85 | """Verify parser handles Granite 3.0 <|tool_call|> token format.""" |
| 86 | single_tool_call_token = ( |
| 87 | '<|tool_call|> [{"name": "get_weather", "arguments": {"city": "Tokyo"}}]' |
| 88 | ) |
| 89 | content, tool_calls = run_tool_extraction( |
| 90 | tool_parser, single_tool_call_token, streaming=streaming |
| 91 | ) |
| 92 | assert len(tool_calls) == 1, ( |
| 93 | f"Expected 1 tool call from token format, got {len(tool_calls)}" |
| 94 | ) |
| 95 | assert tool_calls[0].function.name == "get_weather" |
| 96 | |
| 97 | @pytest.mark.parametrize("streaming", [True, False]) |
| 98 | def test_granite_string_prefix_format(self, tool_parser, streaming): |
| 99 | """Verify parser handles Granite 3.1 <tool_call> string format.""" |
| 100 | single_tool_call_string = ( |
| 101 | '<tool_call> [{"name": "get_weather", "arguments": {"city": "Tokyo"}}]' |
| 102 | ) |
| 103 | content, tool_calls = run_tool_extraction( |
| 104 | tool_parser, single_tool_call_string, streaming=streaming |
| 105 | ) |
| 106 | assert len(tool_calls) == 1, ( |
| 107 | f"Expected 1 tool call from string format, got {len(tool_calls)}" |
| 108 | ) |
| 109 | assert tool_calls[0].function.name == "get_weather" |
| 110 | |
| 111 | |
| 112 | # granite emits arguments before name and its own tokenizer (not gpt2) is used |
| 113 | # here so the token boundaries match production; get_tokenizer only fetches the |
| 114 | # small tokenizer files, not the model weights. |
| 115 | @pytest.fixture(scope="module") |
| 116 | def granite_tokenizer(): |
| 117 | return get_tokenizer(tokenizer_name="ibm-granite/granite-3.1-8b-instruct") |
| 118 | |
| 119 | |
| 120 | @pytest.mark.parametrize("chunk_size", [2, 3, 4, 5]) |
| 121 | def test_streaming_parallel_calls_batched_deltas(granite_tokenizer, chunk_size): |
| 122 | """A batched delta (multiple tokens) spanning the boundary between two |
| 123 | parallel calls must not drop the first call's name. granite streams |
| 124 | arguments before name, so the name only completes as the next call appears. |
| 125 | """ |
| 126 | parser = GraniteEngineToolParser(granite_tokenizer) |
| 127 | model_output = ( |
| 128 | '<|tool_call|> [{"arguments": {"city": "Tokyo"}, "name": "get_weather"}, ' |
| 129 | '{"arguments": {"timezone": "Asia/Tokyo"}, "name": "get_time"}]' |
| 130 | ) |
| 131 | token_deltas = split_string_into_token_deltas(granite_tokenizer, model_output) |
| 132 | batched = [ |
| 133 | "".join(token_deltas[i : i + chunk_size]) |
| 134 | for i in range(0, len(token_deltas), chunk_size) |
| 135 | ] |
| 136 | reconstructor = run_tool_extraction_streaming( |
| 137 | parser, batched, assert_one_tool_per_delta=False |
| 138 | ) |
| 139 | names = [tc.function.name for tc in reconstructor.tool_calls] |
| 140 | assert names == ["get_weather", "get_time"] |
| 141 | # trailing args of the final call are flushed by the serving layer |
| 142 | assert json.loads(reconstructor.tool_calls[0].function.arguments) == { |
| 143 | "city": "Tokyo" |
| 144 | } |