main
py 690 lines 26.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 time
7 from typing import Annotated, Any, Literal
8
9 from pydantic import Field, model_validator
10
11 import vllm.envs as envs
12 from vllm.config import ModelConfig
13 from vllm.entrypoints.generate.base.protocol import (
14 AnyResponseFormat,
15 PerRequestMetrics,
16 StopParam,
17 StreamOptions,
18 structured_outputs_from_response_format,
19 validate_cache_salt,
20 validate_structural_tag_response_format,
21 validate_structured_outputs_structural_tag,
22 )
23 from vllm.entrypoints.serve.engine.protocol import OpenAIBaseModel, UsageInfo
24 from vllm.exceptions import VLLMValidationError
25 from vllm.logger import init_logger
26 from vllm.logprobs import Logprob
27 from vllm.renderers import TokenizeParams
28 from vllm.sampling_params import (
29 BeamSearchParams,
30 RepetitionDetectionParams,
31 RequestOutputKind,
32 SamplingParams,
33 StructuredOutputsParams,
34 ThinkingTokenBudget,
35 )
36 from vllm.utils import random_uuid
37 from vllm.utils.collection_utils import is_list_of
38
39 logger = init_logger(__name__)
40
41
42 _INT64_MIN = -(2**63)
43 _INT64_MAX = 2**63 - 1
44
45
46 class CompletionRequest(OpenAIBaseModel):
47 # Ordered by official OpenAI API documentation
48 # https://platform.openai.com/docs/api-reference/completions/create
49 model: str | None = None
50 prompt: (
51 list[Annotated[int, Field(ge=0)]]
52 | list[list[Annotated[int, Field(ge=0)]]]
53 | str
54 | list[str]
55 | None
56 ) = None
57 echo: bool | None = False
58 frequency_penalty: float | None = None
59 logit_bias: dict[str, float] | None = None
60 logprobs: int | None = None
61 max_tokens: int | None = 16
62 n: int = 1
63 presence_penalty: float | None = None
64 seed: int | None = Field(None, ge=_INT64_MIN, le=_INT64_MAX)
65 stop: StopParam = []
66 stream: bool | None = False
67 stream_options: StreamOptions | None = None
68 suffix: str | None = None
69 temperature: float | None = None
70 top_p: float | None = None
71 user: str | None = None
72
73 # --8<-- [start:completion-sampling-params]
74 use_beam_search: bool = False
75 top_k: int | None = None
76 min_p: float | None = None
77 repetition_penalty: float | None = None
78 watermarking: bool = True
79 length_penalty: float = 1.0
80 stop_token_ids: list[int] | None = []
81 include_stop_str_in_output: bool = False
82 ignore_eos: bool = False
83 min_tokens: int = 0
84 skip_special_tokens: bool = True
85 spaces_between_special_tokens: bool = True
86 truncate_prompt_tokens: Annotated[int, Field(ge=-1, le=_INT64_MAX)] | None = None
87 truncation_side: Literal["left", "right"] | None = Field(
88 default=None,
89 description=(
90 "Which side to truncate from when truncate_prompt_tokens is active. "
91 "'right' keeps the first N tokens. "
92 "'left' keeps the last N tokens."
93 ),
94 )
95 allowed_token_ids: list[int] | None = None
96 prompt_logprobs: int | None = None
97 logprob_token_ids: list[int] | None = Field(
98 default=None,
99 description=(
100 "Specific vocab token IDs to return logprobs for at each generated "
101 "position, in addition to the sampled token. More efficient than "
102 "requesting the full vocab when only a small fixed label set is "
103 "needed (e.g. multilabel "
104 "scoring where each label corresponds to a known vocab id). When "
105 "set, this explicit token selection takes precedence over the "
106 "natural top-k selected by `logprobs`. Requires `logprobs` to be "
107 "set."
108 ),
109 )
110 bad_words: list[str] = Field(default_factory=list)
111 # --8<-- [end:completion-sampling-params]
112
113 # --8<-- [start:completion-extra-params]
114 prompt_embeds: bytes | list[bytes] | None = None
115 add_special_tokens: bool = Field(
116 default=True,
117 description=(
118 "If true (the default), special tokens (e.g. BOS) will be added to "
119 "the prompt."
120 ),
121 )
122 response_format: AnyResponseFormat | None = Field(
123 default=None,
124 description=(
125 "Similar to chat completion, this parameter specifies the format "
126 "of output. Only {'type': 'json_object'}, {'type': 'json_schema'}"
127 ", {'type': 'structural_tag'}, or {'type': 'text' } is supported."
128 ),
129 )
130 structured_outputs: StructuredOutputsParams | None = Field(
131 default=None,
132 description="Additional kwargs for structured outputs",
133 )
134 priority: int = Field(
135 default=0,
136 ge=_INT64_MIN,
137 le=_INT64_MAX,
138 description=(
139 "The priority of the request (lower means earlier handling; "
140 "default: 0). Any priority other than 0 will raise an error "
141 "if the served model does not use priority scheduling."
142 ),
143 )
144 request_id: str = Field(
145 default_factory=random_uuid,
146 description=(
147 "The request_id related to this request. If the caller does "
148 "not set it, a random_uuid will be generated. This id is used "
149 "through out the inference process and return in response."
150 ),
151 )
152 session_id: str | None = Field(
153 default=None,
154 description=(
155 "Stable session identity shared by related requests. Unlike "
156 "request_id, this value is expected to remain stable across "
157 "multiple requests in the same conversation or agent session."
158 ),
159 )
160
161 return_tokens_as_token_ids: bool | None = Field(
162 default=None,
163 description=(
164 "If specified with 'logprobs', tokens are represented "
165 " as strings of the form 'token_id:{token_id}' so that tokens "
166 "that are not JSON-encodable can be identified."
167 ),
168 )
169 return_token_ids: bool | None = Field(
170 default=None,
171 description=(
172 "If specified, the result will include token IDs alongside the "
173 "generated text. In streaming mode, prompt_token_ids is included "
174 "only in the first chunk, and token_ids contains the delta tokens "
175 "for each chunk. This is useful for debugging or when you "
176 "need to map generated text back to input tokens."
177 ),
178 )
179 routed_experts_prompt_start: int = Field(
180 default=0,
181 ge=0,
182 description="Skip the first N prompt tokens from returned routed-expert data.",
183 )
184 return_token_offsets: bool | None = Field(
185 default=False,
186 description=(
187 "If true, return char-level (start, end) offsets for each "
188 "token relative to the tokenized source string in the "
189 "`token_offsets` field of the rendered response. Only "
190 "supported on the `/v1/completions/render` and "
191 "`/v1/chat/completions/render` endpoints; ignored on regular "
192 "generation endpoints. Honored only for Fast (Rust-backed) "
193 "tokenizers; otherwise `token_offsets` is null. For chat "
194 "requests, offsets are relative to the templated prompt "
195 "string (after applying the chat template). Multimodal "
196 "inputs and pre-tokenized inputs always yield null."
197 ),
198 )
199
200 cache_salt: str | None = Field(
201 default=None,
202 min_length=1,
203 max_length=1024,
204 description=(
205 "If specified, the prefix cache will be salted with the provided "
206 "string to prevent an attacker to guess prompts in multi-user "
207 "environments. The salt should be random, protected from "
208 "access by 3rd parties, and long enough to be "
209 "unpredictable (e.g., 43 characters base64-encoded, corresponding "
210 "to 256 bit)."
211 ),
212 )
213
214 kv_transfer_params: dict[str, Any] | None = Field(
215 default=None,
216 description="KVTransfer parameters used for disaggregated serving.",
217 )
218
219 ec_transfer_params: dict[str, Any] | None = Field(
220 default=None,
221 description=(
222 "ECTransfer parameters used for encoder-cache disaggregated serving."
223 ),
224 )
225
226 vllm_xargs: dict[str, str | int | float | list[str | int | float]] | None = Field(
227 default=None,
228 description=(
229 "Additional request parameters with (list of) string or "
230 "numeric values, used by custom extensions."
231 ),
232 )
233
234 repetition_detection: RepetitionDetectionParams | None = Field(
235 default=None,
236 description="Parameters for detecting repetitive N-gram patterns "
237 "in output tokens. If such repetition is detected, generation will "
238 "be ended early. LLMs can sometimes generate repetitive, unhelpful "
239 "token patterns, stopping only when they hit the maximum output length "
240 "(e.g. 'abcdabcdabcd...' or '\\emoji \\emoji \\emoji ...'). This feature "
241 "can detect such behavior and terminate early, saving time and tokens.",
242 )
243
244 thinking_token_budget: ThinkingTokenBudget = Field(
245 default=None,
246 description=(
247 "Maximum number of tokens allowed for thinking operations "
248 "(reasoning models). Non-negative integer sets the limit; "
249 "-1 means unlimited (treated as unset)."
250 ),
251 )
252
253 stream_interval: Annotated[int, Field(ge=1)] | None = Field(
254 default=None,
255 description=(
256 "Number of tokens to batch into each streamed chunk. Raises the "
257 "server's `--stream-interval` for this request. Values below the "
258 "server setting are clamped up to it. The first and last chunks "
259 "are always sent immediately. Ignored for non-streaming requests."
260 ),
261 )
262
263 # --8<-- [end:completion-extra-params]
264
265 def build_tok_params(self, model_config: ModelConfig) -> TokenizeParams:
266 return TokenizeParams(
267 max_total_tokens=model_config.max_model_len,
268 max_output_tokens=self.max_tokens or 0,
269 truncate_prompt_tokens=self.truncate_prompt_tokens,
270 truncation_side=self.truncation_side,
271 add_special_tokens=self.add_special_tokens,
272 needs_detokenization=bool(self.echo and not self.return_token_ids),
273 max_total_tokens_param="max_model_len",
274 max_output_tokens_param="max_tokens",
275 return_token_offsets=bool(self.return_token_offsets),
276 )
277
278 # Default sampling parameters for completion requests
279 _DEFAULT_SAMPLING_PARAMS: dict = {
280 "repetition_penalty": 1.0,
281 "presence_penalty": 0.0,
282 "frequency_penalty": 0.0,
283 "temperature": 1.0,
284 "top_p": 1.0,
285 "top_k": 0,
286 "min_p": 0.0,
287 }
288
289 def to_beam_search_params(
290 self,
291 max_tokens: int,
292 default_sampling_params: dict | None = None,
293 ) -> BeamSearchParams:
294 if default_sampling_params is None:
295 default_sampling_params = {}
296 n = self.n if self.n is not None else 1
297
298 if (temperature := self.temperature) is None:
299 temperature = default_sampling_params.get("temperature", 1.0)
300
301 return BeamSearchParams(
302 beam_width=n,
303 max_tokens=max_tokens,
304 ignore_eos=self.ignore_eos,
305 temperature=temperature,
306 length_penalty=self.length_penalty,
307 include_stop_str_in_output=self.include_stop_str_in_output,
308 skip_special_tokens=self.skip_special_tokens,
309 )
310
311 def extract_structured_outputs(self) -> StructuredOutputsParams | None:
312 """Normalize request constraints into ``StructuredOutputsParams``."""
313 return structured_outputs_from_response_format(
314 self.structured_outputs,
315 self.response_format,
316 )
317
318 def to_sampling_params(
319 self,
320 max_tokens: int,
321 default_sampling_params: dict | None = None,
322 ) -> SamplingParams:
323 if default_sampling_params is None:
324 default_sampling_params = {}
325
326 # Priority: user -> server default -> OpenAI default
327 sampling_params = {
328 name: (
329 value
330 if (value := getattr(self, name)) is not None
331 else default_sampling_params.get(name, default)
332 )
333 for name, default in self._DEFAULT_SAMPLING_PARAMS.items()
334 }
335
336 # Merge server-default stop_token_ids (e.g., model-specific tokens
337 # like </call> for gpt-oss) with any request-specified ones
338 stop_token_ids = self.stop_token_ids
339 default_stop_ids = default_sampling_params.get("stop_token_ids")
340 if default_stop_ids:
341 if not stop_token_ids:
342 stop_token_ids = list(default_stop_ids)
343 else:
344 stop_token_ids = list(
345 dict.fromkeys([*stop_token_ids, *default_stop_ids])
346 )
347
348 prompt_logprobs = self.prompt_logprobs
349 if prompt_logprobs is None and self.echo:
350 prompt_logprobs = self.logprobs
351
352 echo_without_generation = self.echo and self.max_tokens == 0
353
354 extra_args: dict[str, Any] = self.vllm_xargs if self.vllm_xargs else {}
355 if self.kv_transfer_params:
356 # Pass in kv_transfer_params via extra_args
357 extra_args["kv_transfer_params"] = self.kv_transfer_params
358 if self.ec_transfer_params:
359 # Pass in ec_transfer_params via extra_args
360 extra_args["ec_transfer_params"] = self.ec_transfer_params
361 return SamplingParams.from_optional(
362 n=self.n,
363 **sampling_params,
364 watermarking=self.watermarking,
365 seed=self.seed,
366 stop=self.stop,
367 stop_token_ids=stop_token_ids,
368 logprobs=None if self.logprob_token_ids else self.logprobs,
369 ignore_eos=self.ignore_eos,
370 max_tokens=max_tokens if not echo_without_generation else 1,
371 min_tokens=self.min_tokens,
372 prompt_logprobs=prompt_logprobs,
373 logprob_token_ids=self.logprob_token_ids or None,
374 skip_special_tokens=self.skip_special_tokens,
375 spaces_between_special_tokens=self.spaces_between_special_tokens,
376 include_stop_str_in_output=self.include_stop_str_in_output,
377 output_kind=RequestOutputKind.DELTA
378 if self.stream
379 else RequestOutputKind.FINAL_ONLY,
380 stream_interval=self.stream_interval,
381 structured_outputs=self.extract_structured_outputs(),
382 logit_bias=self.logit_bias,
383 allowed_token_ids=self.allowed_token_ids,
384 bad_words=self.bad_words,
385 extra_args=extra_args or None,
386 skip_clone=True, # Created fresh per request, safe to skip clone
387 repetition_detection=self.repetition_detection,
388 thinking_token_budget=self.thinking_token_budget,
389 routed_experts_prompt_start=self.routed_experts_prompt_start,
390 )
391
392 @model_validator(mode="before")
393 @classmethod
394 def check_cache_salt_support(cls, data: Any) -> Any:
395 if not isinstance(data, dict):
396 return data
397 validate_cache_salt(data.get("cache_salt"))
398 return data
399
400 @model_validator(mode="before")
401 @classmethod
402 def normalize_null_max_tokens(cls, data):
403 if isinstance(data, dict) and data.get("max_tokens") is None:
404 data = data.copy()
405 data["max_tokens"] = cls.model_fields["max_tokens"].default
406 return data
407
408 @model_validator(mode="before")
409 @classmethod
410 def validate_response_format(cls, data):
411 if not isinstance(data, dict):
412 return data
413 response_format = data.get("response_format")
414 if response_format is None:
415 return data
416
417 rf_type = (
418 response_format.get("type")
419 if isinstance(response_format, dict)
420 else getattr(response_format, "type", None)
421 )
422
423 if rf_type == "json_schema":
424 json_schema = (
425 response_format.get("json_schema")
426 if isinstance(response_format, dict)
427 else getattr(response_format, "json_schema", None)
428 )
429 if json_schema is None:
430 raise VLLMValidationError(
431 "When response_format type is 'json_schema', the "
432 "'json_schema' field must be provided.",
433 parameter="response_format",
434 )
435
436 if rf_type == "structural_tag":
437 validate_structural_tag_response_format(response_format)
438
439 return data
440
441 @model_validator(mode="before")
442 @classmethod
443 def check_structured_outputs_count(cls, data):
444 if not isinstance(data, dict):
445 return data
446 if data.get("structured_outputs", None) is None:
447 return data
448
449 structured_outputs_kwargs = data["structured_outputs"]
450 # structured_outputs may arrive as a dict (from JSON/raw kwargs) or
451 # as a StructuredOutputsParams dataclass instance.
452 is_dataclass = isinstance(structured_outputs_kwargs, StructuredOutputsParams)
453 count = sum(
454 (
455 getattr(structured_outputs_kwargs, k, None)
456 if is_dataclass
457 else structured_outputs_kwargs.get(k)
458 )
459 is not None
460 for k in ("json", "regex", "choice")
461 )
462 if count > 1:
463 raise VLLMValidationError(
464 "You can only use one kind of constraints for structured "
465 "outputs ('json', 'regex' or 'choice').",
466 parameter="structured_outputs",
467 )
468 validate_structured_outputs_structural_tag(structured_outputs_kwargs)
469 return data
470
471 @model_validator(mode="before")
472 @classmethod
473 def check_logprobs(cls, data):
474 if not isinstance(data, dict):
475 return data
476 if data.get("logprob_token_ids") and data.get("use_beam_search"):
477 raise VLLMValidationError(
478 "`logprob_token_ids` is not supported with beam search.",
479 parameter="logprob_token_ids",
480 )
481
482 if (
483 data.get("logprob_token_ids")
484 and data.get("echo")
485 and data.get("max_tokens") == 0
486 ):
487 raise VLLMValidationError(
488 "`logprob_token_ids` is not supported when `echo=True` and "
489 "`max_tokens=0` because no output tokens are generated.",
490 parameter="logprob_token_ids",
491 )
492
493 if data.get("logprob_token_ids") and data.get("logprobs") is None:
494 raise VLLMValidationError(
495 "when using `logprob_token_ids`, `logprobs` must be set.",
496 parameter="logprob_token_ids",
497 )
498
499 # These fields are integers, but `mode="before"` runs on the raw
500 # request data, so a non-numeric value (e.g. a JSON string) would
501 # reach the comparisons below and raise TypeError -> HTTP 500. Reject
502 # it here so the client gets a clean 400 instead.
503 for field_name in ("prompt_logprobs", "logprobs"):
504 field_value = data.get(field_name)
505 if field_value is not None and not isinstance(field_value, (int, float)):
506 raise VLLMValidationError(
507 f"`{field_name}` must be an integer.",
508 parameter=field_name,
509 value=field_value,
510 )
511 if (prompt_logprobs := data.get("prompt_logprobs")) is not None:
512 if data.get("stream") and (prompt_logprobs > 0 or prompt_logprobs == -1):
513 raise VLLMValidationError(
514 "`prompt_logprobs` are not available when `stream=True`.",
515 parameter="prompt_logprobs",
516 )
517
518 if prompt_logprobs < 0 and prompt_logprobs != -1:
519 raise VLLMValidationError(
520 "`prompt_logprobs` must be a positive value or -1.",
521 parameter="prompt_logprobs",
522 value=prompt_logprobs,
523 )
524 if (
525 (logprobs := data.get("logprobs")) is not None
526 and logprobs < 0
527 and logprobs != -1
528 ):
529 raise VLLMValidationError(
530 "`logprobs` must be a positive value or -1.",
531 parameter="logprobs",
532 value=logprobs,
533 )
534
535 return data
536
537 @model_validator(mode="before")
538 @classmethod
539 def validate_stream_options(cls, data):
540 if not isinstance(data, dict):
541 return data
542 if data.get("stream_options") and not data.get("stream"):
543 raise VLLMValidationError(
544 "Stream options can only be defined when `stream=True`.",
545 parameter="stream_options",
546 )
547
548 return data
549
550 @model_validator(mode="before")
551 @classmethod
552 def validate_prompt_and_prompt_embeds(cls, data):
553 if not isinstance(data, dict):
554 return data
555 prompt = data.get("prompt")
556 prompt_embeds = data.get("prompt_embeds")
557
558 prompt_is_empty = prompt is None or (isinstance(prompt, str) and prompt == "")
559 embeds_is_empty = prompt_embeds is None or (
560 isinstance(prompt_embeds, list) and len(prompt_embeds) == 0
561 )
562
563 if prompt_is_empty and embeds_is_empty:
564 raise VLLMValidationError(
565 "Either prompt or prompt_embeds must be provided and non-empty.",
566 parameter="prompt",
567 )
568
569 return data
570
571 @model_validator(mode="before")
572 @classmethod
573 def validate_prompt_list_length(cls, data):
574 if not isinstance(data, dict):
575 return data
576 max_prompts = envs.VLLM_MAX_COMPLETION_PROMPTS
577
578 prompt = data.get("prompt")
579 if (
580 isinstance(prompt, list)
581 and len(prompt) > 0
582 and not is_list_of(prompt, int)
583 and len(prompt) > max_prompts
584 ):
585 raise VLLMValidationError(
586 f"prompt list length {len(prompt)} exceeds the maximum "
587 f"allowed count of {max_prompts}. To increase this "
588 "limit, set the VLLM_MAX_COMPLETION_PROMPTS "
589 "environment variable.",
590 parameter="prompt",
591 )
592
593 prompt_embeds = data.get("prompt_embeds")
594 if isinstance(prompt_embeds, list) and len(prompt_embeds) > max_prompts:
595 raise VLLMValidationError(
596 f"prompt_embeds list length {len(prompt_embeds)} exceeds "
597 f"the maximum allowed count of {max_prompts}. To increase "
598 "this limit, set the VLLM_MAX_COMPLETION_PROMPTS "
599 "environment variable.",
600 parameter="prompt_embeds",
601 )
602
603 return data
604
605
606 class CompletionLogProbs(OpenAIBaseModel):
607 text_offset: list[int] = Field(default_factory=list)
608 token_logprobs: list[float | None] = Field(default_factory=list)
609 tokens: list[str] = Field(default_factory=list)
610 top_logprobs: list[dict[str, float] | None] = Field(default_factory=list)
611
612
613 class CompletionResponseChoice(OpenAIBaseModel):
614 index: int
615 text: str
616 logprobs: CompletionLogProbs | None = None
617 finish_reason: str | None = None
618 stop_reason: int | str | None = Field(
619 default=None,
620 description=(
621 "The stop string or token id that caused the completion "
622 "to stop, None if the completion finished for some other reason "
623 "including encountering the EOS token"
624 ),
625 )
626 token_ids: list[int] | None = None # For response
627 prompt_logprobs: list[dict[int, Logprob] | None] | None = None
628 prompt_token_ids: list[int] | None = None # For prompt
629 # Per-token expert routing decisions, base64-encoded ``.npy`` bytes
630 # (numpy serialization). Shape after decode:
631 # (num_tokens - 1, num_layers, num_experts_per_tok) dtype uint8/uint16/int32
632 # ``num_tokens - 1`` because the last sampled token has not been
633 # forwarded yet and therefore has no routing data.
634 # Decode:
635 # np.load(io.BytesIO(base64.b64decode(s)))
636 # ``None`` if (a) the request was aborted before any forward pass,
637 # or (b) ``enable_return_routed_experts`` is off server-side.
638 routed_experts: str | None = None
639
640
641 class CompletionResponse(OpenAIBaseModel):
642 id: str = Field(default_factory=lambda: f"cmpl-{random_uuid()}")
643 object: Literal["text_completion"] = "text_completion"
644 created: int = Field(default_factory=lambda: int(time.time()))
645 model: str
646 choices: list[CompletionResponseChoice]
647 service_tier: Literal["auto", "default", "flex", "scale", "priority"] | None = None
648 system_fingerprint: str | None = None
649 usage: UsageInfo
650
651 # vLLM-specific fields that are not in OpenAI spec
652 kv_transfer_params: dict[str, Any] | None = Field(
653 default=None, description="KVTransfer parameters."
654 )
655 ec_transfer_params: dict[str, Any] | None = Field(
656 default=None, description="ECTransfer parameters."
657 )
658 metrics: PerRequestMetrics | None = None
659
660
661 class CompletionResponseStreamChoice(OpenAIBaseModel):
662 index: int
663 text: str
664 logprobs: CompletionLogProbs | None = None
665 finish_reason: str | None = None
666 stop_reason: int | str | None = Field(
667 default=None,
668 description=(
669 "The stop string or token id that caused the completion "
670 "to stop, None if the completion finished for some other reason "
671 "including encountering the EOS token"
672 ),
673 )
674 # not part of the OpenAI spec but for tracing the tokens
675 # prompt tokens is put into choice to align with CompletionResponseChoice
676 prompt_token_ids: list[int] | None = None
677 token_ids: list[int] | None = None
678
679
680 class CompletionStreamResponse(OpenAIBaseModel):
681 id: str = Field(default_factory=lambda: f"cmpl-{random_uuid()}")
682 object: str = "text_completion"
683 created: int = Field(default_factory=lambda: int(time.time()))
684 model: str
685 choices: list[CompletionResponseStreamChoice]
686 usage: UsageInfo | None = Field(default=None)
687 # Set only on the final chunk of a stream to mirror non-streaming responses
688 # without the per-chunk serialization overhead.
689 system_fingerprint: str | None = None
690 metrics: PerRequestMetrics | None = None