main
py 167 lines 6.54 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from collections.abc import Iterable
5
6 from vllm.entrypoints.generate.base.protocol import (
7 DeltaMessage,
8 ExtractedToolCallInformation,
9 FunctionCall,
10 ToolCall,
11 )
12 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
13 from vllm.tokenizers import TokenizerLike
14 from vllm.tool_parsers import ToolParser
15
16
17 class StreamingToolReconstructor:
18 def __init__(self, assert_one_tool_per_delta: bool = True):
19 self.tool_calls: list[ToolCall] = []
20 self.other_content: str = ""
21 self._assert_one_tool_per_delta = assert_one_tool_per_delta
22
23 def append_delta(self, delta: DeltaMessage):
24 if delta.content is not None:
25 self.other_content += delta.content
26 else:
27 assert delta.tool_calls, (
28 "Streaming results should have either content or tool calls (or both)"
29 )
30 if self._assert_one_tool_per_delta:
31 # Note: This isn't strictly required by the API and may not be
32 # possible to adhere to depending on the token space and number of
33 # tokens per streamed response from the model, but it is required
34 # by tool_use tests, so we enforce it here by default also.
35 assert len(delta.tool_calls) < 2, (
36 "Streaming should include only one tool call per update."
37 )
38 for call_delta in delta.tool_calls:
39 assert call_delta.type is None or call_delta.type == "function", (
40 "Streaming tool calls should only emit function calls. Got "
41 f"{call_delta.type}"
42 )
43 current_tool_call = (
44 self.tool_calls[call_delta.index]
45 if call_delta.index < len(self.tool_calls)
46 else None
47 )
48 if current_tool_call:
49 assert not call_delta.function.name, (
50 "Streaming tool calls should emit the full function name "
51 f"exactly once. Got {call_delta.function.name}"
52 )
53 assert not call_delta.id, (
54 "Streaming tool calls must emit function id only once. Got "
55 f"{call_delta.id}"
56 )
57 assert call_delta.index == len(self.tool_calls) - 1, (
58 f"Incorrect index for tool delta. Got {call_delta.index}, "
59 f"expected {len(self.tool_calls) - 1}"
60 )
61 current_tool_call.function.arguments += call_delta.function.arguments
62 else:
63 assert call_delta.id is not None, (
64 "Streaming tool calls must have an id on first appearance"
65 )
66 assert call_delta.function.name is not None, (
67 "Streaming tool calls must have a function name on first appearance"
68 )
69 assert call_delta.index == len(self.tool_calls), (
70 f"Incorrect index for tool delta. Got {call_delta.index}, "
71 f"expected {len(self.tool_calls)}"
72 )
73 self.tool_calls.append(
74 ToolCall(
75 id=call_delta.id,
76 function=FunctionCall(
77 name=call_delta.function.name,
78 arguments=call_delta.function.arguments or "",
79 ),
80 )
81 )
82
83
84 def run_tool_extraction(
85 tool_parser: ToolParser,
86 model_output: str,
87 request: ChatCompletionRequest | None = None,
88 streaming: bool = False,
89 assert_one_tool_per_delta: bool = True,
90 ) -> tuple[str | None, list[ToolCall]]:
91 if streaming:
92 reconstructor = run_tool_extraction_streaming(
93 tool_parser,
94 model_output,
95 request,
96 assert_one_tool_per_delta=assert_one_tool_per_delta,
97 )
98 return reconstructor.other_content or None, reconstructor.tool_calls
99 else:
100 extracted = run_tool_extraction_nonstreaming(tool_parser, model_output, request)
101 assert extracted.tools_called == bool(extracted.tool_calls)
102 return extracted.content, extracted.tool_calls
103
104
105 def run_tool_extraction_nonstreaming(
106 tool_parser: ToolParser,
107 model_output: str,
108 request: ChatCompletionRequest | None = None,
109 ) -> ExtractedToolCallInformation:
110 request = request or ChatCompletionRequest(messages=[], model="test-model")
111 return tool_parser.extract_tool_calls(model_output, request)
112
113
114 def split_string_into_token_deltas(tokenizer: TokenizerLike, text: str) -> list[str]:
115 # Split a string into a series of deltas using the provided tokenizer. Each
116 # delta will be the string equivalent of a single token.
117 token_ids = tokenizer.encode(text, add_special_tokens=False)
118 previously_decoded_text = ""
119 deltas = []
120 for i in range(1, len(token_ids) + 1):
121 current_tokens = token_ids[:i]
122 current_text = tokenizer.decode(current_tokens)
123 new_text = current_text[len(previously_decoded_text) :]
124 previously_decoded_text = current_text
125 deltas.append(new_text)
126 return deltas
127
128
129 def run_tool_extraction_streaming(
130 tool_parser: ToolParser,
131 model_deltas: Iterable[str],
132 request: ChatCompletionRequest | None = None,
133 assert_one_tool_per_delta: bool = True,
134 ) -> StreamingToolReconstructor:
135 if isinstance(model_deltas, str):
136 model_deltas = split_string_into_token_deltas(
137 tool_parser.model_tokenizer, model_deltas
138 )
139
140 request = request or ChatCompletionRequest(messages=[], model="test-model")
141 reconstructor = StreamingToolReconstructor(
142 assert_one_tool_per_delta=assert_one_tool_per_delta
143 )
144 previous_text = ""
145 previous_tokens: list[int] = []
146 for delta in model_deltas:
147 token_delta = [
148 tool_parser.vocab.get(token)
149 for token in tool_parser.model_tokenizer.tokenize(delta)
150 if token in tool_parser.vocab
151 ]
152 current_text = previous_text + delta
153 current_tokens = previous_tokens + token_delta
154 delta_message = tool_parser.extract_tool_calls_streaming(
155 previous_text,
156 current_text,
157 delta,
158 previous_tokens,
159 current_tokens,
160 token_delta,
161 request,
162 )
163 if delta_message is not None:
164 reconstructor.append_delta(delta_message)
165 previous_text = current_text
166 previous_tokens = current_tokens
167 return reconstructor