main
py 340 lines 11.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 # Adapted from
5 # https://github.com/lm-sys/FastChat/blob/168ccc29d3f7edc50823016105c024fe2282732a/fastchat/protocol/openai_api_protocol.py
6 import json
7 from typing import Annotated, Any, Literal, TypeAlias
8
9 from pydantic import (
10 BaseModel,
11 Field,
12 model_serializer,
13 )
14
15 import vllm.envs as envs
16 from vllm.config.utils import replace
17 from vllm.entrypoints.chat_utils import make_tool_call_id
18 from vllm.entrypoints.serve.engine.protocol import OpenAIBaseModel, UsageInfo
19 from vllm.exceptions import VLLMValidationError
20 from vllm.logger import init_logger
21 from vllm.sampling_params import StructuredOutputsParams
22
23 logger = init_logger(__name__)
24
25 StopParam: TypeAlias = (
26 str | Annotated[list[str], Field(max_length=envs.VLLM_MAX_STOP_STRINGS)] | None
27 )
28
29 _CACHE_SALT_FORBIDDEN_CHARS = frozenset("@/\\\x00")
30 _MAX_CACHE_SALT_LENGTH = 128
31
32
33 def validate_cache_salt(cache_salt: object) -> None:
34 """Validate cache salts before they reach downstream cache backends."""
35 if cache_salt is None:
36 return
37 if not isinstance(cache_salt, str) or not cache_salt:
38 raise VLLMValidationError(
39 "Parameter 'cache_salt' must be a non-empty string if provided.",
40 parameter="cache_salt",
41 )
42 if len(cache_salt) > _MAX_CACHE_SALT_LENGTH or any(
43 char in _CACHE_SALT_FORBIDDEN_CHARS for char in cache_salt
44 ):
45 raise VLLMValidationError(
46 "Parameter 'cache_salt' must be at most 128 characters and must "
47 "not contain '@', '/', '\\\\', or NUL.",
48 parameter="cache_salt",
49 )
50
51
52 def validate_request_mm_kwargs(
53 *,
54 mm_processor_kwargs: dict[str, Any] | None,
55 media_io_kwargs: dict[str, dict[str, Any]] | None,
56 trust_request_mm_kwargs: bool,
57 ) -> None:
58 """Reject untrusted per-request multimodal kwarg overrides."""
59 if trust_request_mm_kwargs:
60 return
61
62 if mm_processor_kwargs:
63 raise VLLMValidationError(
64 "Per-request mm_processor_kwargs are disabled by default because "
65 "they can change multimodal preprocessing resource usage. Start "
66 "the server with --trust-request-mm-kwargs only when clients "
67 "are trusted.",
68 parameter="mm_processor_kwargs",
69 )
70 if media_io_kwargs:
71 raise VLLMValidationError(
72 "Per-request media_io_kwargs are disabled by default because "
73 "they can change multimodal media loading resource usage. Start "
74 "the server with --trust-request-mm-kwargs only when clients "
75 "are trusted.",
76 parameter="media_io_kwargs",
77 )
78
79
80 class SpeculativeDecodingMetrics(OpenAIBaseModel):
81 """Per-request speculative-decoding acceptance metrics.
82
83 Experimental, subject to change. Only populated for single-sequence requests
84 (`n == 1`); `null` for `n > 1`, mirroring the timing metrics.
85 """
86
87 mean_acceptance_length: float
88 draft_acceptance_rate: float
89 # Dense histogram: index j holds the number of verify steps that accepted
90 # exactly j draft tokens (length num_spec_tokens + 1). Excludes the
91 # always-accepted bonus token.
92 acceptance_histogram: list[int]
93 num_spec_steps: int
94 num_accepted_draft_tokens: int
95 num_draft_tokens: int
96 num_spec_tokens: int
97 # Ordered per-verify-step arrays; populated only at the `detailed` level.
98 per_step_accepted: list[int] | None = None
99 per_step_drafted: list[int] | None = None
100
101
102 class PerRequestMetrics(OpenAIBaseModel):
103 time_to_first_token_ms: float | None = None
104 generation_time_ms: float | None = None
105 queue_time_ms: float | None = None
106 mean_itl_ms: float | None = None
107 tokens_per_second: float | None = None
108 # Experimental, subject to change.
109 speculative_decoding: SpeculativeDecodingMetrics | None = None
110
111
112 class RequestResponseMetadata(BaseModel):
113 request_id: str
114 final_usage_info: UsageInfo | None = None
115
116
117 class JsonSchemaResponseFormat(OpenAIBaseModel):
118 name: str
119 description: str | None = None
120 # schema is the field in openai but that causes conflicts with pydantic so
121 # instead use json_schema with an alias
122 json_schema: dict[str, Any] | None = Field(default=None, alias="schema")
123 strict: bool | None = None
124
125
126 class LegacyStructuralTag(OpenAIBaseModel):
127 begin: str
128 # schema is the field, but that causes conflicts with pydantic so
129 # instead use structural_tag_schema with an alias
130 structural_tag_schema: dict[str, Any] | None = Field(default=None, alias="schema")
131 end: str
132
133
134 class LegacyStructuralTagResponseFormat(OpenAIBaseModel):
135 type: Literal["structural_tag"]
136 structures: list[LegacyStructuralTag]
137 triggers: list[str]
138
139
140 class StructuralTagResponseFormat(OpenAIBaseModel):
141 type: Literal["structural_tag"]
142 format: Any
143
144
145 AnyStructuralTagResponseFormat: TypeAlias = (
146 LegacyStructuralTagResponseFormat | StructuralTagResponseFormat
147 )
148
149
150 class ResponseFormat(OpenAIBaseModel):
151 # type must be "json_schema", "json_object", or "text"
152 type: Literal["text", "json_object", "json_schema"]
153 json_schema: JsonSchemaResponseFormat | None = None
154
155
156 AnyResponseFormat: TypeAlias = (
157 ResponseFormat | StructuralTagResponseFormat | LegacyStructuralTagResponseFormat
158 )
159
160
161 def structured_outputs_from_response_format(
162 structured_outputs: StructuredOutputsParams | None,
163 response_format: AnyResponseFormat | None,
164 ) -> StructuredOutputsParams | None:
165 """Apply ``response_format`` overrides to ``structured_outputs``."""
166 if response_format is None or response_format.type == "text":
167 return structured_outputs
168
169 overrides: dict[str, Any]
170 if response_format.type == "json_object":
171 overrides = {"json_object": True}
172 elif response_format.type == "json_schema":
173 json_schema = response_format.json_schema
174 assert json_schema is not None
175 overrides = {"json": json_schema.json_schema}
176 else:
177 assert isinstance(
178 response_format,
179 (
180 LegacyStructuralTagResponseFormat,
181 StructuralTagResponseFormat,
182 ),
183 )
184 overrides = {
185 "structural_tag": json.dumps(response_format.model_dump(by_alias=True))
186 }
187
188 if structured_outputs is None:
189 return StructuredOutputsParams(**overrides)
190
191 return replace(structured_outputs, **overrides)
192
193
194 def validate_structural_tag_response_format(
195 response_format: AnyStructuralTagResponseFormat | dict[str, Any],
196 ) -> None:
197 """Validate structural tags before they are sent to the engine.
198
199 Engine-side validation reports malformed structural tags as generation
200 failures. OpenAI request parsing should classify them as bad requests.
201 """
202 from pydantic import TypeAdapter, ValidationError
203
204 if isinstance(response_format, dict):
205 try:
206 response_format = TypeAdapter(
207 AnyStructuralTagResponseFormat
208 ).validate_python(response_format)
209 except ValidationError as exc:
210 raise VLLMValidationError(
211 "Invalid response_format structural_tag specification.",
212 parameter="response_format",
213 ) from exc
214
215 try:
216 payload = json.dumps(response_format.model_dump(by_alias=True))
217 validate_structural_tag_payload(payload, parameter="response_format")
218 except (TypeError, ValueError) as exc:
219 raise VLLMValidationError(
220 "Invalid response_format structural_tag specification.",
221 parameter="response_format",
222 ) from exc
223
224
225 def validate_structural_tag_payload(payload: Any, *, parameter: str) -> None:
226 from vllm.sampling_params import SamplingParams, StructuredOutputsParams
227 from vllm.v1.structured_output.backend_xgrammar import validate_xgrammar_grammar
228
229 if isinstance(payload, str) and not payload:
230 raise VLLMValidationError(
231 f"Invalid {parameter} structural_tag specification.",
232 parameter=parameter,
233 )
234
235 try:
236 validate_xgrammar_grammar(
237 SamplingParams(
238 structured_outputs=StructuredOutputsParams(structural_tag=payload)
239 )
240 )
241 except (TypeError, ValueError, VLLMValidationError) as exc:
242 raise VLLMValidationError(
243 f"Invalid {parameter} structural_tag specification.",
244 parameter=parameter,
245 ) from exc
246
247
248 def validate_structured_outputs_structural_tag(
249 structured_outputs: Any,
250 ) -> None:
251 from vllm.sampling_params import StructuredOutputsParams
252
253 if isinstance(structured_outputs, StructuredOutputsParams):
254 structural_tag = structured_outputs.structural_tag
255 elif isinstance(structured_outputs, dict):
256 structural_tag = structured_outputs.get("structural_tag")
257 else:
258 return
259 if structural_tag is not None:
260 validate_structural_tag_payload(
261 structural_tag,
262 parameter="structured_outputs",
263 )
264
265
266 class StreamOptions(OpenAIBaseModel):
267 include_usage: bool | None = False
268 continuous_usage_stats: bool | None = False
269
270
271 class FunctionDefinition(OpenAIBaseModel):
272 name: str
273 description: str | None = None
274 parameters: dict[str, Any] | None = None
275 strict: bool | None = None
276 defer_loading: bool | None = None
277
278 @model_serializer(mode="wrap")
279 def _serialize(self, handler):
280 data = handler(self)
281 data = {k: v for k, v in data.items() if k in type(self).model_fields}
282 if self.strict is None:
283 data.pop("strict", None)
284 if self.defer_loading is None:
285 data.pop("defer_loading", None)
286 return data
287
288
289 class FunctionCall(OpenAIBaseModel):
290 # Internal field to preserve native tool call ID from tool parser.
291 # Excluded from serialization to maintain OpenAI API compatibility
292 # (function object should only contain 'name' and 'arguments').
293 id: str | None = Field(default=None, exclude=True)
294 name: str
295 arguments: str
296
297
298 class ToolCall(OpenAIBaseModel):
299 id: str = Field(default_factory=make_tool_call_id)
300 type: Literal["function"] = "function"
301 function: FunctionCall
302
303
304 class DeltaFunctionCall(BaseModel):
305 name: str | None = None
306 arguments: str | None = None
307
308
309 # a tool call delta where everything is optional
310 class DeltaToolCall(OpenAIBaseModel):
311 id: str | None = None
312 type: Literal["function"] | None = None
313 index: int
314 function: DeltaFunctionCall | None = None
315
316
317 class ExtractedToolCallInformation(BaseModel):
318 # indicate if tools were called
319 tools_called: bool
320
321 # extracted tool calls
322 tool_calls: list[ToolCall]
323
324 # content - per OpenAI spec, content AND tool calls can be returned rarely
325 # But some models will do this intentionally
326 content: str | None = None
327
328
329 class DeltaMessage(OpenAIBaseModel):
330 role: str | None = None
331 content: str | None = None
332 reasoning: str | None = None
333 tool_calls: list[DeltaToolCall] = Field(default_factory=list)
334
335 @model_serializer(mode="wrap")
336 def _serialize(self, handler):
337 data = handler(self)
338 if len(data.get("tool_calls", [])) == 0:
339 data.pop("tool_calls", None)
340 return data