main
py 740 lines 29.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from typing import Annotated, Any, Literal, TypeAlias
4
5 from pydantic import (
6 BaseModel,
7 Field,
8 NonNegativeInt,
9 PrivateAttr,
10 field_validator,
11 model_validator,
12 )
13
14 from vllm.config import ModelConfig
15 from vllm.entrypoints.generate.base.protocol import (
16 PerRequestMetrics,
17 StreamOptions,
18 validate_cache_salt,
19 )
20 from vllm.entrypoints.openai.chat_completion.protocol import (
21 ChatCompletionLogProbs,
22 ChatCompletionRequest,
23 ChatCompletionStreamResponse,
24 )
25 from vllm.entrypoints.openai.completion.protocol import (
26 CompletionRequest,
27 CompletionStreamResponse,
28 )
29 from vllm.entrypoints.serve.engine.protocol import UsageInfo
30 from vllm.logprobs import Logprob
31 from vllm.renderers import TokenizeParams
32 from vllm.sampling_params import SamplingParams
33 from vllm.utils import random_uuid
34
35 ####### Tokens IN <> Tokens OUT #######
36
37
38 class PlaceholderRangeInfo(BaseModel):
39 """Serializable placeholder location for a single multi-modal item."""
40
41 offset: int = Field(ge=0)
42 """Start index of the placeholder tokens in the prompt."""
43
44 length: int = Field(gt=0)
45 """Number of placeholder tokens."""
46
47 # TODO: add ``is_embed: list[bool] | None`` once the /generate side
48 # consumes features — some models (e.g. Qwen-VL) use sparse
49 # placeholder masks that cannot be recomputed from offset+length alone.
50
51
52 def _has_serialized_mm_items(
53 payload: dict[str, list[str | None]] | None,
54 ) -> bool:
55 if not payload:
56 return False
57 return any(item is not None for items in payload.values() for item in items)
58
59
60 class MultiModalFeatures(BaseModel):
61 """Lightweight multimodal metadata produced by the render step.
62
63 Carries hashes (for cache lookup / identification) and placeholder
64 positions so the downstream `/generate` service knows *where* in
65 the token sequence each multimodal item lives.
66 """
67
68 mm_hashes: dict[str, list[str]]
69 """Per-modality item hashes, e.g. `{"image": ["abc", "def"]}`."""
70
71 mm_placeholders: dict[str, list[PlaceholderRangeInfo]]
72 """Per-modality placeholder ranges in the token sequence."""
73
74 kwargs_data: dict[str, list[str | None]] | None = None
75 """Per-modality serialized tensor data.
76
77 Each value is a list parallel to ``mm_hashes[modality]``. A ``str``
78 entry is a base64-encoded ``MultiModalKwargsItem``; ``None`` means
79 the item should be resolved from cache. The entire field is
80 ``None`` for metadata-only (cache-hit) responses.
81 """
82
83 mm_metadata: dict[str, list[str | None]] | None = None
84 """Per-modality serialized metadata for disaggregated prefill.
85
86 Each value is a list parallel to ``mm_hashes[modality]``. A ``str``
87 entry is a base64-encoded ``MultiModalKwargsItem`` containing only
88 placeholder-metadata and ``keep_on_cpu`` fields. ``None`` means that
89 the metadata is unavailable for that item. Prefill can use this
90 instead of ``kwargs_data`` only when ``ec_transfer_params`` is also
91 set, so embeddings arrive through the EC connector rather than from
92 ``pixel_values``.
93 """
94
95 @model_validator(mode="after")
96 def _validate_parallel_fields(self) -> "MultiModalFeatures":
97 modalities = set(self.mm_hashes)
98 if set(self.mm_placeholders) != modalities:
99 raise ValueError(
100 "mm_hashes and mm_placeholders must use the same modalities"
101 )
102 if self.kwargs_data is not None and set(self.kwargs_data) != modalities:
103 raise ValueError("kwargs_data must use the same modalities as mm_hashes")
104 if self.mm_metadata is not None and set(self.mm_metadata) != modalities:
105 raise ValueError("mm_metadata must use the same modalities as mm_hashes")
106
107 flattened_ranges: list[tuple[int, int]] = []
108 for modality in modalities:
109 num_hashes = len(self.mm_hashes[modality])
110 num_placeholders = len(self.mm_placeholders[modality])
111 if num_hashes != num_placeholders:
112 raise ValueError(
113 f"{modality} mm_hashes and mm_placeholders must have "
114 "the same length"
115 )
116 if (
117 self.kwargs_data is not None
118 and len(self.kwargs_data[modality]) != num_hashes
119 ):
120 raise ValueError(
121 f"{modality} kwargs_data and mm_hashes must have the same length"
122 )
123 if (
124 self.mm_metadata is not None
125 and len(self.mm_metadata[modality]) != num_hashes
126 ):
127 raise ValueError(
128 f"{modality} mm_metadata and mm_hashes must have the same length"
129 )
130 flattened_ranges.extend(
131 (placeholder.offset, placeholder.offset + placeholder.length)
132 for placeholder in self.mm_placeholders[modality]
133 )
134
135 flattened_ranges.sort()
136 for (offset, end), (next_offset, _) in zip(
137 flattened_ranges, flattened_ranges[1:]
138 ):
139 if next_offset < end:
140 raise ValueError(
141 "mm_placeholders must be globally non-overlapping and sorted"
142 )
143 return self
144
145
146 class GenerateRequest(BaseModel):
147 request_id: str = Field(
148 default_factory=lambda: f"{random_uuid()}",
149 description=(
150 "The request_id related to this request. If the caller does "
151 "not set it, a random_uuid will be generated. This id is used "
152 "through out the inference process and return in response."
153 ),
154 )
155 token_ids: list[int] = Field(min_length=1)
156 """The token ids to generate text from."""
157
158 @field_validator("token_ids")
159 @classmethod
160 def validate_token_ids(cls, v: list[int]) -> list[int]:
161 if any(t < 0 for t in v):
162 raise ValueError("token_ids must not contain negative values")
163 return v
164
165 token_offsets: list[tuple[int, int]] | None = None
166 """Char-level (start, end) offsets per token, relative to the
167 tokenized source string. Present only when the request set
168 `return_token_offsets=True` and the renderer was able to compute
169 them (Fast tokenizer, text input, no multimodal data). List length
170 equals `token_ids` length when present. None otherwise."""
171
172 features: MultiModalFeatures | None = None
173 """Multimodal hashes and placeholder positions (populated for MM inputs)."""
174
175 content_parts: list[dict[str, Any]] | None = None
176 """Raw multimodal input; server resolves media. Mutually exclusive
177 with ``features``."""
178
179 @model_validator(mode="after")
180 def _check_mm_fields_exclusive(self) -> "GenerateRequest":
181 if self.content_parts and self.features:
182 raise ValueError("content_parts and features are mutually exclusive")
183 return self
184
185 @model_validator(mode="after")
186 def _require_ec_for_metadata_only(self) -> "GenerateRequest":
187 features = self.features
188 if features is None:
189 return self
190 if not _has_serialized_mm_items(features.mm_metadata):
191 return self
192 if self.ec_transfer_params:
193 return self
194 kwargs_data = features.kwargs_data or {}
195 for modality, metadata_items in (features.mm_metadata or {}).items():
196 kwargs_items = kwargs_data.get(modality, [None] * len(metadata_items))
197 if any(
198 metadata is not None and kwargs is None
199 for metadata, kwargs in zip(metadata_items, kwargs_items, strict=True)
200 ):
201 raise ValueError(
202 "metadata-only multimodal items require ec_transfer_params"
203 )
204 return self
205
206 sampling_params: SamplingParams
207 """The sampling parameters for the model."""
208
209 model: str | None = None
210
211 return_token_ids: bool | None = Field(
212 default=None,
213 description=(
214 "If true, return the final prompt token IDs after multimodal "
215 "placeholder expansion, together with multimodal placeholder ranges. "
216 "In streaming mode, this metadata is included only in the first chunk."
217 ),
218 )
219
220 stream: bool | None = False
221 stream_options: StreamOptions | None = None
222 cache_salt: str | None = Field(
223 default=None,
224 min_length=1,
225 max_length=1024,
226 description=(
227 "If specified, the prefix cache will be salted with the provided "
228 "string to prevent an attacker to guess prompts in multi-user "
229 "environments. The salt should be random, protected from "
230 "access by 3rd parties, and long enough to be "
231 "unpredictable (e.g., 43 characters base64-encoded, corresponding "
232 "to 256 bit)."
233 ),
234 )
235 priority: int = Field(
236 default=0,
237 ge=-(2**63),
238 le=2**63 - 1,
239 description=(
240 "The priority of the request (lower means earlier handling; "
241 "default: 0). Any priority other than 0 will raise an error "
242 "if the served model does not use priority scheduling."
243 ),
244 )
245 kv_transfer_params: dict[str, Any] | None = Field(
246 default=None,
247 description="KVTransfer parameters used for disaggregated serving.",
248 )
249 ec_transfer_params: dict[str, Any] | None = Field(
250 default=None,
251 description=(
252 "ECTransfer parameters used for encoder-cache disaggregated serving."
253 ),
254 )
255
256 # Tracks which keys the caller explicitly set inside ``sampling_params``
257 # when the request was parsed from a JSON body. Lets the server tell
258 # "client said max_tokens=16" from "client said nothing → dataclass
259 # default 16" so it can apply server-side defaulting only in the latter
260 # case. ``None`` means the request was constructed with a pre-built
261 # ``SamplingParams`` instance (e.g. from internal callers that have
262 # already resolved values), in which case all fields are considered set.
263 _sampling_params_provided_keys: set[str] | None = PrivateAttr(default=None)
264 _response_mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = (
265 PrivateAttr(default=None)
266 )
267
268 @model_validator(mode="wrap")
269 @classmethod
270 def _capture_sampling_params_provided_keys(cls, data: Any, handler):
271 provided: set[str] | None = None
272 if isinstance(data, dict):
273 sp = data.get("sampling_params")
274 if isinstance(sp, dict):
275 provided = set(sp.keys())
276 instance = handler(data)
277 instance._sampling_params_provided_keys = provided
278 return instance
279
280 @model_validator(mode="before")
281 @classmethod
282 def _validate_cache_salt(cls, data: Any) -> Any:
283 if isinstance(data, dict):
284 validate_cache_salt(data.get("cache_salt"))
285 return data
286
287 @model_validator(mode="after")
288 def _validate_multimodal_feature_bounds(self) -> "GenerateRequest":
289 if self.features is None:
290 return self
291
292 prompt_len = len(self.token_ids)
293 for ranges in self.features.mm_placeholders.values():
294 for placeholder in ranges:
295 if placeholder.offset + placeholder.length > prompt_len:
296 raise ValueError(
297 "mm_placeholders must remain within the token_ids sequence"
298 )
299 return self
300
301 def is_sampling_param_provided(self, name: str) -> bool:
302 """Whether the caller explicitly set ``sampling_params.<name>``.
303
304 For requests parsed from a JSON body, this reflects the raw input
305 dict. For requests constructed with a pre-built ``SamplingParams``
306 instance, all fields are considered provided so server-side defaults
307 do not clobber values already resolved upstream.
308 """
309 if self._sampling_params_provided_keys is None:
310 return True
311 return name in self._sampling_params_provided_keys
312
313 def build_tok_params(self, model_config: ModelConfig) -> TokenizeParams:
314 return TokenizeParams(
315 max_total_tokens=None,
316 max_output_tokens=0,
317 )
318
319
320 class GenerateResponseChoice(BaseModel):
321 index: int
322 logprobs: ChatCompletionLogProbs | None = None
323 # per OpenAI spec this is the default
324 finish_reason: str | None = "stop"
325 token_ids: list[int] | None = None
326 # Per-token expert routing decisions, base64-encoded ``.npy`` bytes
327 # (numpy serialization). Shape after decode:
328 # (num_tokens - 1, num_layers, num_experts_per_tok) dtype uint8/uint16/int32
329 # ``num_tokens - 1`` because the last sampled token has not been
330 # forwarded yet and therefore has no routing data.
331 # Decode:
332 # np.load(io.BytesIO(base64.b64decode(s)))
333 # ``None`` if (a) the request was aborted before any forward pass,
334 # or (b) ``enable_return_routed_experts`` is off server-side.
335 routed_experts: str | None = None
336 sampling_mask: list[list[int]] | None = None
337
338 @field_validator("token_ids")
339 @classmethod
340 def validate_token_ids(cls, v: list[int] | None) -> list[int] | None:
341 if v is not None and any(t < 0 for t in v):
342 raise ValueError("token_ids must not contain negative values")
343 return v
344
345
346 class GenerateResponseStreamChoice(BaseModel):
347 index: int
348 logprobs: ChatCompletionLogProbs | None = None
349 finish_reason: str | None = None
350 token_ids: list[int] | None = None
351 routed_experts: str | None = None
352 sampling_mask: list[list[int]] | None = None
353
354
355 class GenerateStreamResponse(BaseModel):
356 request_id: str = Field(
357 default_factory=lambda: f"{random_uuid()}",
358 description=(
359 "The request_id related to this request. If the caller does "
360 "not set it, a random_uuid will be generated. This id is used "
361 "through out the inference process and return in response."
362 ),
363 )
364 choices: list[GenerateResponseStreamChoice]
365 usage: UsageInfo | None = Field(default=None)
366 prompt_token_ids: list[NonNegativeInt] | None = None
367 mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None
368 metrics: PerRequestMetrics | None = None
369
370
371 class GenerateResponse(BaseModel):
372 request_id: str = Field(
373 default_factory=lambda: f"{random_uuid()}",
374 description=(
375 "The request_id related to this request. If the caller does "
376 "not set it, a random_uuid will be generated. This id is used "
377 "through out the inference process and return in response."
378 ),
379 )
380 model: str | None = None
381 created: int | None = None
382 choices: list[GenerateResponseChoice]
383 usage: UsageInfo | None = Field(default=None)
384 prompt_logprobs: list[dict[int, Logprob] | None] | None = None
385 prompt_token_id_logprobs: str | None = None
386 prompt_token_ids: list[NonNegativeInt] | None = None
387 mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None
388 metrics: PerRequestMetrics | None = None
389
390 kv_transfer_params: dict[str, Any] | None = Field(
391 default=None,
392 description="KVTransfer parameters used for disaggregated serving.",
393 )
394 ec_transfer_params: dict[str, Any] | None = Field(
395 default=None,
396 description=(
397 "ECTransfer parameters used for encoder-cache disaggregated serving."
398 ),
399 )
400
401
402 class DerenderChatRequest(BaseModel):
403 """Request for the /v1/chat/completions/derender endpoint (non-streaming).
404
405 Wraps a complete GenerateResponse and caller supplied metadata needed to
406 produce a fully formed ChatCompletionResponse without a GPU.
407 """
408
409 # --8<-- [start:derender-chat-request]
410 stream: Literal[False] = False
411
412 model: str | None = None
413 """Served model name. Defaults to the server's served model name."""
414
415 generate_response: GenerateResponse
416 """The complete token-in / token-out engine response to derender."""
417
418 prompt_tokens: int | None = None
419 """Prompt token count for usage; defaults to 0 if omitted.
420
421 GenerateResponse carries only output tokens; the caller already has
422 len(GenerateRequest.token_ids) from the render step.
423 """
424
425 prompt_token_ids: list[NonNegativeInt] | None = None
426 """Prompt token IDs (`GenerateRequest.token_ids` from /render). Seeds
427 detokenization from the prompt tail so the first output token keeps its
428 leading space on SentencePiece tokenizers. Falls back to
429 `generate_response.prompt_token_ids`, then to unseeded decoding.
430
431 Only the last few IDs are read, so a suffix of the prompt is enough.
432 """
433
434 chat_request: ChatCompletionRequest | None = None
435 """The original (post-adjust_request) ChatCompletionRequest from /render.
436
437 Required by the parsing so that tool/reasoning parsers can receive the full
438 request context they expect (request.tools, request.tool_choice,
439 request._grammar_from_parser, etc.).
440 """
441 # --8<-- [end:derender-chat-request]
442
443
444 class DerenderCompletionRequest(BaseModel):
445 """Request for the /v1/completions/derender endpoint (non-streaming).
446
447 Parallel to DerenderChatRequest but handles the multi-prompt completions
448 case: one GenerateResponse per prompt, mirroring the list[GenerateRequest]
449 returned by /v1/completions/render.
450 """
451
452 # --8<-- [start:derender-completion-request]
453 stream: Literal[False] = False
454
455 model: str | None = None
456 """Served model name. Defaults to the server's served model name."""
457
458 generate_responses: list[GenerateResponse]
459 """One response per prompt, parallel to the list[GenerateRequest]
460 returned by /v1/completions/render."""
461
462 prompt_tokens: list[int] | None = None
463 """One prompt token count per response; each defaults to 0 if omitted.
464
465 If provided, len(prompt_tokens) must equal len(generate_responses).
466 """
467
468 prompt_token_ids: list[list[NonNegativeInt] | None] | None = None
469 """One prompt token ID list per response, used to seed detokenization.
470 See `DerenderChatRequest.prompt_token_ids`.
471
472 If provided, len(prompt_token_ids) must equal len(generate_responses).
473 A `None` entry falls back to `generate_responses[i].prompt_token_ids`.
474 """
475
476 completion_request: CompletionRequest | None = None
477 """The original (post-adjust_request) CompletionRequest from /render.
478
479 Mirrors chat_request on DerenderChatRequest. Required by the parsing
480 so parsers receive the full request context.
481 """
482 # --8<-- [end:derender-completion-request]
483
484 @model_validator(mode="after")
485 def _validate_prompt_tokens_length(self) -> "DerenderCompletionRequest":
486 if self.prompt_tokens is not None and len(self.prompt_tokens) != len(
487 self.generate_responses
488 ):
489 raise ValueError(
490 f"prompt_tokens length ({len(self.prompt_tokens)}) must equal "
491 f"generate_responses length ({len(self.generate_responses)})"
492 )
493 if self.prompt_token_ids is not None and len(self.prompt_token_ids) != len(
494 self.generate_responses
495 ):
496 raise ValueError(
497 f"prompt_token_ids length ({len(self.prompt_token_ids)}) must "
498 f"equal generate_responses length ({len(self.generate_responses)})"
499 )
500 return self
501
502
503 class DerenderStreamState(BaseModel):
504 """Per sequence state for stateless streaming derender.
505
506 The client carries this between successive per chunk HTTP calls to the
507 streaming derender endpoint. All fields are plain JSON serializable data.
508 No opaque tokenizer or parser internals are stored here.
509
510 Two separate sets of fields support two different streaming modes:
511
512 - For plain streaming (no parser configured including the completions path):
513 `prev_tokens`, `prefix_offset` and `read_offset` maintain a bounded incremental
514 decoding window. This requires O(window) transport and O(delta) computation
515 per chunk.
516 - For parser enabled chat streaming: `output_token_ids`, `output_chunk_lens`,
517 `tools_streamed` and `last_tool_call_ids` are used to replay `parse_delta()`
518 from scratch on every chunk because parser state cannot be serialized. This
519 incurs O(n) transport per chunk (O(n²) per generation) and O(n²)
520 `parse_delta()` calls per generation. Since many parsers re-scan the
521 entire accumulated text on each invocation, `parse_delta()` itself is
522 O(n) yielding a true worst case compute cost of O(n³) per generation.
523 No caching is performed. Work is bounded by `max_model_len`.
524 See `OnlineDerenderer._derender_chat_stream_parsed`.
525
526 The detokenization strategy carries the incremental decode offsets
527 directly rather than re-sending the whole token history each chunk.
528 `detokenize_incrementally` only ever reads the trailing token window
529 `prev_tokens[prefix_offset:]`, so we carry just that tail plus the two
530 offsets. Each chunk resumes exactly where the last one stopped, including
531 any partially processed multi-byte character (tracked by `read_offset`),
532 then trims and rebases the window so it never grows with generation length.
533
534 Performance:
535 - Compute per chunk is O(delta). One `detokenize_incrementally` call per
536 new token, independent of how many tokens preceded it.
537 - Transport per chunk is O(window). The carried tail is bounded by the
538 incremental detokenization offset, so cumulative bytes over the wire are
539 O(n) rather than the O(n^2) a full history round trip would incur.
540 """
541
542 prev_tokens: list[str] = Field(default_factory=list)
543 """Trailing decode window. Token strings from `prefix_offset` onward.
544
545 Bounded, trimmed and rebased each chunk to the tail
546 `detokenize_incrementally` still reads, so it does not grow with the
547 number of chunks.
548 """
549
550 prefix_offset: int = Field(default=0, ge=0)
551 """Prefix offset into `prev_tokens` for incremental detokenization."""
552
553 read_offset: int = Field(default=0, ge=0)
554 """Read offset into `prev_tokens` for incremental detokenization."""
555
556 @field_validator("prev_tokens")
557 @classmethod
558 def _bound_prev_tokens(cls, v: list[str]) -> list[str]:
559 # INITIAL_INCREMENTAL_DETOKENIZATION_OFFSET is small (5) and the trimmed
560 # window is O(offset). A generous limit rejects unusually large or malformed
561 # payloads without restricting legitimate multi-byte sequences.
562 limit = 1024
563 if len(v) > limit:
564 raise ValueError(f"prev_tokens length ({len(v)}) exceeds maximum ({limit})")
565 return v
566
567 role_sent: bool = False
568 """True once the initial `role: "assistant"` delta has been emitted.
569
570 Prevents re-emitting the role on subsequent chunks even when the detok
571 window is transiently empty (e.g. usage only final chunk).
572 """
573
574 logprob_context_token_ids: list[int] = Field(default_factory=list, max_length=4)
575 """Trailing sampled token IDs carried across chunks so byte-fallback
576 (U+FFFD) correction during logprob placeholder resolution has context
577 at chunk boundaries. Bounded to the 4-token window that
578 ``_correct_decoded_token`` reads."""
579
580 logprob_text_offset: int = Field(default=0, ge=0)
581 """Cumulative emitted text length. Seeds ``text_offset`` for completion
582 streaming logprobs so offsets stay absolute across chunks, mirroring
583 ``initial_text_offset`` in the generate streaming path."""
584
585 output_token_ids: list[int] = Field(default_factory=list)
586 """All output tokens seen so far. Parser path only.
587
588 Replay buffer: each chunk rebuilds a fresh parser and replays every
589 token in here through `parse_delta` (discarding the result) before
590 processing the current chunk's tokens since parser internal state
591 cannot be serialized into this stateless model. Unavoidably O(n)
592 bounded by ``max_model_len`` (enforced server side, not by a field
593 validator here since the bound is model dependent).
594 """
595
596 output_chunk_lens: list[Annotated[int, Field(gt=0)]] = Field(default_factory=list)
597 """Token count of each chunk in `output_token_ids`. Parser path only.
598
599 Replay uses these to reproduce the original `parse_delta` call
600 boundaries. Must sum to `len(output_token_ids)`.
601 """
602
603 @model_validator(mode="after")
604 def _validate_output_chunk_lens(self) -> "DerenderStreamState":
605 total = sum(self.output_chunk_lens)
606 if total != len(self.output_token_ids):
607 raise ValueError(
608 f"output_chunk_lens must sum to len(output_token_ids) "
609 f"(got sum={total}, len(output_token_ids)="
610 f"{len(self.output_token_ids)})"
611 )
612 return self
613
614 tools_streamed: bool = False
615 """True once a tool call delta has been emitted. Parser path only.
616
617 Drives the `finish_reason` -> `"tool_calls"` rewrite on the final
618 chunk mirroring the generate streaming path.
619 """
620
621 last_tool_call_ids: list[str] = Field(default_factory=list)
622 """Stable tool call IDs, assigned once when each call first appears.
623
624 Indexed by tool call index. Parser path only. Prevents ID regeneration
625 when replay reprocesses a tool call that already has a pinned ID.
626 """
627
628
629 class DerenderChatStreamRequest(BaseModel):
630 """One chunk streaming derender request for /v1/chat/completions/derender.
631
632 The client sends one request per SSE chunk received from
633 ``/inference/v1/generate``. Each request carries the generate chunk
634 plus the ``stream_state`` returned by the previous call (``None`` on the
635 first call). The response contains the derendered chunk and the updated
636 state to be passed to the next call.
637
638 This implements stateless no server side session. All mutable state lives in
639 the client carried ``stream_state``.
640 """
641
642 # --8<-- [start:derender-chat-stream-request]
643 stream: Literal[True]
644
645 model: str | None = None
646 generate_chunk: GenerateStreamResponse
647 """One SSE chunk from ``/inference/v1/generate`` (``stream=True``)."""
648
649 stream_state: DerenderStreamState | None = None
650 """Client carried detok state from the previous call. ``None`` on first."""
651
652 prompt_tokens: int | None = None
653 """Prompt token count for usage. Forwarded from the render step."""
654
655 prompt_token_ids: list[NonNegativeInt] | None = None
656 """Prompt token IDs. Required by the parser path's `parse_delta` to
657 settle its initial reasoning state (e.g. chat templates that pre-open
658 ``<think>``). `prompt_tokens` is a usage count and cannot serve this
659 purpose. Sourced from `GenerateRequest.token_ids` at the render step.
660
661 Rejected with a 400 (by `ServingDerender`) when a tool or reasoning
662 parser is configured and this is omitted. Without it, `parse_delta`
663 cannot tell whether the prompt left reasoning open and would silently
664 misclassify reasoning content as plain content. On all paths it also
665 seeds detokenization on the first chunk, falling back to
666 `generate_chunk.prompt_token_ids` when omitted (see
667 `DerenderChatRequest.prompt_token_ids`).
668
669 With a parser configured, send the full list. Parsers look back to the
670 last reasoning marker which can be anywhere in the prompt, so a suffix
671 can flip the initial reasoning state. Without a parser, a suffix is
672 enough.
673 """
674
675 chat_request: ChatCompletionRequest | None = None
676 """The original (post adjust_request) ChatCompletionRequest from /render."""
677 # --8<-- [end:derender-chat-stream-request]
678
679
680 class DerenderCompletionStreamRequest(BaseModel):
681 """One chunk streaming derender request for /v1/completions/derender.
682
683 Parallel to ``DerenderChatStreamRequest`` for the completions endpoint.
684 Each call processes one SSE chunk (one output sequence's delta) and
685 returns the derendered chunk plus updated state.
686 """
687
688 # --8<-- [start:derender-completion-stream-request]
689 stream: Literal[True]
690
691 model: str | None = None
692 generate_chunk: GenerateStreamResponse
693 """One SSE chunk from ``/inference/v1/generate``."""
694
695 stream_state: DerenderStreamState | None = None
696 """Client-carried detok state. ``None`` on the first call."""
697
698 prompt_tokens: int | None = None
699 """Prompt token count for usage."""
700
701 prompt_token_ids: list[NonNegativeInt] | None = None
702 """Prompt token IDs, used on the first chunk to seed detokenization.
703 Falls back to `generate_chunk.prompt_token_ids`. See
704 `DerenderChatRequest.prompt_token_ids`.
705 """
706
707 completion_request: CompletionRequest | None = None
708 """The original (post adjust_request) CompletionRequest from /render."""
709 # --8<-- [end:derender-completion-stream-request]
710
711
712 class DerenderChatStreamResponse(BaseModel):
713 """Response for one streaming chat derender chunk.
714
715 Pairs the derendered SSE chunk with the updated client carried state to
716 pass to the next call.
717 """
718
719 chunk: ChatCompletionStreamResponse
720 stream_state: DerenderStreamState
721
722
723 class DerenderCompletionStreamResponse(BaseModel):
724 """Response for one streaming completions derender chunk.
725
726 Parallel to ``DerenderChatStreamResponse`` for the completions endpoint.
727 """
728
729 chunk: CompletionStreamResponse
730 stream_state: DerenderStreamState
731
732
733 # Determines the type by checking the ``stream`` field's literal value. A body without
734 # ``stream`` validates as the non-streaming member
735 # (``stream`` defaults to ``False`` there), so FastAPI can validate and dispatch both
736 # shapes on a single path.
737 DerenderChatRequestUnion: TypeAlias = DerenderChatRequest | DerenderChatStreamRequest
738 DerenderCompletionRequestUnion: TypeAlias = (
739 DerenderCompletionRequest | DerenderCompletionStreamRequest
740 )