main
py 288 lines 9.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tool-call parsing for the unified Cohere Command parser.
4
5 The ``cohere_command3`` / ``cohere_command4`` tool parsers are registry shims;
6 ``CohereCommandParser`` (resolved through ``ParserManager``) does the parsing.
7 """
8
9 from __future__ import annotations
10
11 import json
12 from dataclasses import dataclass, field
13
14 import pytest
15
16 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
17 from vllm.parser.abstract_parser import Parser
18
19 from .utils import MockCohereTokenizer, drive_parser, make_parser, token_deltas
20
21 SPECIAL_TOKEN_MARKERS = (
22 "<|START_THINKING|>",
23 "<|END_THINKING|>",
24 "<|START_RESPONSE|>",
25 "<|END_RESPONSE|>",
26 "<|START_ACTION|>",
27 "<|END_ACTION|>",
28 "<|START_TEXT|>",
29 "<|END_TEXT|>",
30 )
31
32 DUMMY_TOOLS = [
33 {"type": "function", "function": {"name": name, "parameters": {"type": "object"}}}
34 for name in ("foo", "bar")
35 ]
36
37 # (registry name, answer framing tag)
38 VARIANTS = (("cohere_command3", "RESPONSE"), ("cohere_command4", "TEXT"))
39
40
41 @dataclass
42 class ExpectedToolCall:
43 id: str
44 name: str
45 arguments: dict
46
47
48 @dataclass
49 class ToolCallCase:
50 parser_name: str
51 model_output: str
52 expected_tool_calls: list[ExpectedToolCall] = field(default_factory=list)
53 expected_reasoning: str | None = None
54 expected_content: str | None = None
55
56
57 SINGLE_TOOL_CALL = """\
58 <|START_THINKING|> i will call foo with query1<|END_THINKING|><|START_ACTION|>
59 [
60 {"tool_call_id": "0", "tool_name": "foo", "parameters": {"query": "query1"}}
61 ]
62 <|END_ACTION|>"""
63 MULTI_TOOL_CALL = """\
64 <|START_THINKING|>first I think about foo<|END_THINKING|><|START_ACTION|>
65 [
66 {"tool_call_id": "0", "tool_name": "foo", "parameters": {"query": "query1"}},
67 {"tool_call_id": "1", "tool_name": "bar", "parameters": {"x": 42}}
68 ]
69 <|END_ACTION|>"""
70 CITATIONS_NO_TOOL_CALL = (
71 "<|START_THINKING|>This is a rainbow <co>emoji: 🌈</co: 0:[1]><|END_THINKING|>\n"
72 "<|START_{tag}|>foo <co>bar</co: 0:[1,2],1:[3,4]><|END_{tag}|>"
73 )
74
75 # (case id, model output template, expectations)
76 _CASE_TABLE = (
77 (
78 "single_tool_call",
79 SINGLE_TOOL_CALL,
80 dict(
81 expected_tool_calls=[ExpectedToolCall("0", "foo", {"query": "query1"})],
82 expected_reasoning="i will call foo with query1",
83 ),
84 ),
85 (
86 "citations_no_tool_calls",
87 CITATIONS_NO_TOOL_CALL,
88 dict(
89 expected_reasoning="This is a rainbow emoji: 🌈", expected_content="foo bar"
90 ),
91 ),
92 (
93 "multiple_tool_calls",
94 MULTI_TOOL_CALL,
95 dict(
96 expected_tool_calls=[
97 ExpectedToolCall("0", "foo", {"query": "query1"}),
98 ExpectedToolCall("1", "bar", {"x": 42}),
99 ],
100 expected_reasoning="first I think about foo",
101 ),
102 ),
103 (
104 "reasoning_only",
105 "<|START_THINKING|>just think, no response<|END_THINKING|>",
106 dict(expected_reasoning="just think, no response"),
107 ),
108 )
109
110 TOOL_CALL_CASES = [
111 pytest.param(
112 ToolCallCase(name, output.replace("{tag}", tag), **expected),
113 id=f"{name}-{case_id}",
114 )
115 for name, tag in VARIANTS
116 for case_id, output, expected in _CASE_TABLE
117 ]
118
119 PARSER_NAMES = pytest.mark.parametrize("parser_name", [name for name, _ in VARIANTS])
120
121
122 def _tool_parser(tokenizer: MockCohereTokenizer, parser_name: str) -> Parser:
123 return make_parser(tokenizer, parser_name, DUMMY_TOOLS)
124
125
126 def _tool_request() -> ChatCompletionRequest:
127 return ChatCompletionRequest(
128 messages=[], model="test-model", tools=DUMMY_TOOLS, tool_choice="auto"
129 )
130
131
132 @dataclass
133 class StreamingResult:
134 tool_calls: dict[int, dict]
135 reasoning: str | None
136 content: str | None
137
138
139 def run_streaming(
140 tokenizer: MockCohereTokenizer,
141 parser_name: str,
142 model_output: str,
143 chunk_size: int = 1,
144 ) -> StreamingResult:
145 deltas = drive_parser(
146 _tool_parser(tokenizer, parser_name),
147 _tool_request(),
148 token_deltas(tokenizer, model_output, chunk_size),
149 )
150 accumulated: dict[int, dict] = {}
151 for delta in deltas:
152 for tc in delta.tool_calls or []:
153 slot = accumulated.setdefault(
154 tc.index, {"id": "", "name": "", "arguments": ""}
155 )
156 if tc.id:
157 slot["id"] = tc.id
158 if tc.function and tc.function.name:
159 slot["name"] = tc.function.name
160 if tc.function and tc.function.arguments:
161 slot["arguments"] += tc.function.arguments
162 return StreamingResult(
163 tool_calls=accumulated,
164 reasoning="".join(d.reasoning for d in deltas if d.reasoning) or None,
165 content="".join(d.content for d in deltas if d.content) or None,
166 )
167
168
169 def run_nonstreaming(
170 tokenizer: MockCohereTokenizer, parser_name: str, model_output: str
171 ) -> tuple[str | None, str | None, list]:
172 reasoning, content, tool_calls = _tool_parser(tokenizer, parser_name).parse(
173 model_output, _tool_request(), enable_auto_tools=True
174 )
175 return reasoning, content, tool_calls or []
176
177
178 @pytest.mark.parametrize("case", TOOL_CALL_CASES)
179 class TestExtractToolCalls:
180 def test_streaming(self, tokenizer: MockCohereTokenizer, case: ToolCallCase):
181 streamed = run_streaming(tokenizer, case.parser_name, case.model_output)
182 assert len(streamed.tool_calls) == len(case.expected_tool_calls)
183 for i, expected in enumerate(case.expected_tool_calls):
184 tc = streamed.tool_calls[i]
185 assert tc["id"] == expected.id
186 assert tc["name"] == expected.name
187 assert json.loads(tc["arguments"]) == expected.arguments
188
189 def test_streaming_reasoning(
190 self, tokenizer: MockCohereTokenizer, case: ToolCallCase
191 ):
192 streamed = run_streaming(tokenizer, case.parser_name, case.model_output)
193 assert streamed.reasoning == case.expected_reasoning
194
195 def test_streaming_content(
196 self, tokenizer: MockCohereTokenizer, case: ToolCallCase
197 ):
198 streamed = run_streaming(tokenizer, case.parser_name, case.model_output)
199 assert streamed.content == case.expected_content
200
201 def test_nonstreaming(self, tokenizer: MockCohereTokenizer, case: ToolCallCase):
202 reasoning, content, tool_calls = run_nonstreaming(
203 tokenizer, case.parser_name, case.model_output
204 )
205 assert reasoning == case.expected_reasoning
206 assert content == case.expected_content
207 assert len(tool_calls) == len(case.expected_tool_calls)
208 for actual, expected in zip(tool_calls, case.expected_tool_calls):
209 assert actual.name == expected.name
210 assert json.loads(actual.arguments) == expected.arguments
211
212 def test_streaming_nonstreaming_agree(
213 self, tokenizer: MockCohereTokenizer, case: ToolCallCase
214 ):
215 streamed = run_streaming(tokenizer, case.parser_name, case.model_output)
216 _, _, tool_calls = run_nonstreaming(
217 tokenizer, case.parser_name, case.model_output
218 )
219 assert len(streamed.tool_calls) == len(tool_calls)
220 for i, actual in enumerate(tool_calls):
221 assert streamed.tool_calls[i]["name"] == actual.name
222 assert json.loads(streamed.tool_calls[i]["arguments"]) == json.loads(
223 actual.arguments
224 )
225
226
227 class TestSpeculativeDecodingMultiTokenDelta:
228 @PARSER_NAMES
229 @pytest.mark.parametrize("chunk_size", [2, 3, 4, 6])
230 def test_no_special_token_leak_in_streaming_deltas(
231 self, tokenizer: MockCohereTokenizer, parser_name: str, chunk_size: int
232 ):
233 deltas = drive_parser(
234 _tool_parser(tokenizer, parser_name),
235 _tool_request(),
236 token_deltas(tokenizer, SINGLE_TOOL_CALL, chunk_size),
237 )
238 for delta in deltas:
239 fields: list[tuple[str, str | None]] = [
240 ("reasoning", delta.reasoning),
241 ("content", delta.content),
242 ]
243 for tc in delta.tool_calls or []:
244 if tc.function:
245 fields.append(("tool_call.name", tc.function.name))
246 fields.append(("tool_call.arguments", tc.function.arguments))
247 for marker in SPECIAL_TOKEN_MARKERS:
248 for field_name, value in fields:
249 assert value is None or marker not in value, (
250 f"special token {marker!r} leaked into {field_name} "
251 f"with chunk_size={chunk_size} delta={delta!r}"
252 )
253
254 @PARSER_NAMES
255 @pytest.mark.parametrize("chunk_size", [2, 3, 4, 6])
256 def test_multi_token_chunks_still_produce_correct_tool_call(
257 self, tokenizer: MockCohereTokenizer, parser_name: str, chunk_size: int
258 ):
259 streamed = run_streaming(tokenizer, parser_name, SINGLE_TOOL_CALL, chunk_size)
260 assert len(streamed.tool_calls) == 1
261 tc = streamed.tool_calls[0]
262 assert tc["id"] == "0"
263 assert tc["name"] == "foo"
264 assert json.loads(tc["arguments"]) == {"query": "query1"}
265
266
267 class TestStreamingDeltaShape:
268 @PARSER_NAMES
269 def test_reasoning_and_tool_calls_are_separate_deltas(
270 self, tokenizer: MockCohereTokenizer, parser_name: str
271 ):
272 deltas = drive_parser(
273 _tool_parser(tokenizer, parser_name),
274 _tool_request(),
275 token_deltas(tokenizer, SINGLE_TOOL_CALL),
276 )
277 for delta in deltas:
278 populated = [
279 delta.content is not None,
280 delta.reasoning is not None,
281 bool(delta.tool_calls),
282 ]
283 assert sum(populated) == 1, (
284 "A single streaming delta must carry exactly one of "
285 f"content/reasoning/tool_calls, got {delta!r}"
286 )
287 assert any(d.reasoning is not None for d in deltas)
288 assert any(d.tool_calls for d in deltas)