main
py 191 lines 7.05 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import json
4 from collections.abc import AsyncGenerator
5 from typing import Any
6
7 from vllm.entrypoints.openai.chat_completion.protocol import (
8 ChatCompletionResponse,
9 ChatCompletionResponseChoice,
10 ChatCompletionStreamResponse,
11 ChatMessage,
12 )
13 from vllm.entrypoints.serve.engine.protocol import UsageInfo
14
15
16 async def accumulate_streaming_response(
17 stream_generator: AsyncGenerator[str, None],
18 ) -> ChatCompletionResponse:
19 """Accumulate streaming SSE chunks into a complete ChatCompletionResponse.
20
21 This helper parses the SSE format and builds up the complete response
22 by combining all the delta chunks.
23 """
24 accumulated_content = ""
25 accumulated_reasoning = None
26 accumulated_tool_calls: list[dict[str, Any]] = []
27 role = None
28 finish_reason = None
29 response_id = None
30 created = None
31 model = None
32 index = 0
33
34 async for chunk_str in stream_generator:
35 # Skip empty lines and [DONE] marker
36 if not chunk_str.strip() or chunk_str.strip() == "data: [DONE]":
37 continue
38
39 # Parse SSE format: "data: {json}\n\n"
40 if chunk_str.startswith("data: "):
41 json_str = chunk_str[6:].strip()
42 try:
43 chunk_data = json.loads(json_str)
44 # print(f"DEBUG: Parsed chunk_data: {chunk_data}")
45 chunk = ChatCompletionStreamResponse(**chunk_data)
46
47 # Store metadata from first chunk
48 if response_id is None:
49 response_id = chunk.id
50 created = chunk.created
51 model = chunk.model
52
53 # Process each choice in the chunk
54 for choice in chunk.choices:
55 if choice.delta.role:
56 role = choice.delta.role
57 if choice.delta.content:
58 accumulated_content += choice.delta.content
59 if choice.delta.reasoning:
60 if accumulated_reasoning is None:
61 accumulated_reasoning = ""
62 accumulated_reasoning += choice.delta.reasoning
63 if choice.delta.tool_calls:
64 # Accumulate tool calls
65 for tool_call_delta in choice.delta.tool_calls:
66 # Find or create the tool call at this index
67 while len(accumulated_tool_calls) <= tool_call_delta.index:
68 accumulated_tool_calls.append(
69 {
70 "id": None,
71 "type": "function",
72 "function": {"name": "", "arguments": ""},
73 }
74 )
75
76 if tool_call_delta.id:
77 accumulated_tool_calls[tool_call_delta.index]["id"] = (
78 tool_call_delta.id
79 )
80 if tool_call_delta.function:
81 if tool_call_delta.function.name:
82 accumulated_tool_calls[tool_call_delta.index][
83 "function"
84 ]["name"] += tool_call_delta.function.name
85 if tool_call_delta.function.arguments:
86 accumulated_tool_calls[tool_call_delta.index][
87 "function"
88 ]["arguments"] += tool_call_delta.function.arguments
89
90 if choice.finish_reason:
91 finish_reason = choice.finish_reason
92 if choice.index is not None:
93 index = choice.index
94
95 except json.JSONDecodeError:
96 continue
97
98 # Build the final message
99 message_kwargs = {
100 "role": role or "assistant",
101 "content": accumulated_content if accumulated_content else None,
102 "reasoning": accumulated_reasoning,
103 }
104
105 # Only include tool_calls if there are any
106 if accumulated_tool_calls:
107 message_kwargs["tool_calls"] = [
108 {"id": tc["id"], "type": tc["type"], "function": tc["function"]}
109 for tc in accumulated_tool_calls
110 ]
111
112 message = ChatMessage(**message_kwargs)
113
114 # Build the final response
115 choice = ChatCompletionResponseChoice(
116 index=index,
117 message=message,
118 finish_reason=finish_reason or "stop",
119 )
120
121 # Create usage info (with dummy values for tests)
122 usage = UsageInfo(
123 prompt_tokens=0,
124 completion_tokens=0,
125 total_tokens=0,
126 )
127
128 response = ChatCompletionResponse(
129 id=response_id or "chatcmpl-test",
130 object="chat.completion",
131 created=created or 0,
132 model=model or "test-model",
133 choices=[choice],
134 usage=usage,
135 )
136
137 return response
138
139
140 def verify_harmony_messages(
141 messages: list[Any], expected_messages: list[dict[str, Any]]
142 ):
143 assert len(messages) == len(expected_messages)
144 for msg, expected in zip(messages, expected_messages):
145 if "role" in expected:
146 assert msg.author.role == expected["role"]
147 if "author_name" in expected:
148 assert msg.author.name == expected["author_name"]
149 if "channel" in expected:
150 assert msg.channel == expected["channel"]
151 if "recipient" in expected:
152 assert msg.recipient == expected["recipient"]
153 if "content" in expected:
154 assert msg.content[0].text == expected["content"]
155 if "content_type" in expected:
156 assert msg.content_type == expected["content_type"]
157 if "instructions" in expected:
158 assert msg.content[0].instructions == expected["instructions"]
159 if "tool_definitions" in expected:
160 # Check that the tool definitions match the expected list of tool names
161 actual_tools = [t.name for t in msg.content[0].tools["functions"].tools]
162 assert actual_tools == expected["tool_definitions"]
163
164
165 def verify_chat_response(
166 response: ChatCompletionResponse,
167 content: str | None = None,
168 reasoning: str | None = None,
169 tool_calls: list[tuple[str, str]] | None = None,
170 ):
171 assert len(response.choices) == 1
172 message = response.choices[0].message
173
174 if content is not None:
175 assert message.content == content
176 else:
177 assert not message.content
178
179 if reasoning is not None:
180 assert message.reasoning == reasoning
181 else:
182 assert not message.reasoning
183
184 if tool_calls:
185 assert message.tool_calls is not None
186 assert len(message.tool_calls) == len(tool_calls)
187 for tc, (expected_name, expected_args) in zip(message.tool_calls, tool_calls):
188 assert tc.function.name == expected_name
189 assert tc.function.arguments == expected_args
190 else:
191 assert not message.tool_calls