[Refactor] Extract StructuredOutputsParams creation logic from Request.to_sampling_params (#49003)

Signed-off-by: Yifan Zong <yzong@redhat.com>

yzong-rh committed Jul 19, 2026 at 08:18 UTC ac5f38a0f7af29cb5bc15a1e73623922d9832500
5 files changed +95 -98
tests/entrypoints/openai/responses/test_sampling_params.py
+15
@@ -132,6 +132,21 @@ class TestResponsesRequestSamplingParams:
132 assert sampling_params.structured_outputs is not None
133 assert sampling_params.structured_outputs.grammar == "root ::= 'hello'"
134
135 + def test_text_format_json_object_enables_structured_outputs(self):
136 + """text.format json_object enables structured outputs for sampling."""
137 + request = ResponsesRequest(
138 + model="test-model",
139 + input="test input",
140 + text=ResponseTextConfig.model_validate({"format": {"type": "json_object"}}),
141 + )
142 +
143 + sampling_params = request.to_sampling_params(default_max_tokens=1000)
144 +
145 + assert sampling_params.structured_outputs is not None
146 + assert sampling_params.structured_outputs.json_object is True
147 + assert sampling_params.structured_outputs.json is None
148 + assert request.structured_outputs is None
149 +
150 def test_structured_outputs_and_json_schema_conflict(self):
151 """Test that specifying both structured_outputs and json_schema raises."""
152 structured_outputs = StructuredOutputsParams(grammar="root ::= 'hello'")
vllm/entrypoints/openai/chat_completion/protocol.py
+9 -37
@@ -3,7 +3,6 @@
3
4 # Adapted from
5 # https://github.com/lm-sys/FastChat/blob/168ccc29d3f7edc50823016105c024fe2282732a/fastchat/protocol/openai_api_protocol.py
6 -import json
6 import time
7 from typing import Annotated, Any, ClassVar, Literal
8
@@ -14,7 +13,6 @@ from openai.types.chat.chat_completion_message import Annotation as OpenAIAnnota
13 from pydantic import Field, PrivateAttr, model_serializer, model_validator
14
15 from vllm.config import ModelConfig
17 -from vllm.config.utils import replace
16 from vllm.entrypoints.chat_utils import (
17 ChatCompletionMessageParam,
18 ChatTemplateContentFormatOption,
@@ -24,13 +22,12 @@ from vllm.entrypoints.openai.engine.protocol import (
22 DeltaMessage,
23 FunctionCall,
24 FunctionDefinition,
27 - LegacyStructuralTagResponseFormat,
25 OpenAIBaseModel,
26 PerRequestTimingMetrics,
27 StreamOptions,
31 - StructuralTagResponseFormat,
28 ToolCall,
29 UsageInfo,
30 + structured_outputs_from_response_format,
31 validate_structural_tag_response_format,
32 validate_structured_outputs_structural_tag,
33 )
@@ -607,6 +604,13 @@ class ChatCompletionRequest(OpenAIBaseModel):
604 include_stop_str_in_output=self.include_stop_str_in_output,
605 )
606
607 + def extract_structured_outputs(self) -> StructuredOutputsParams | None:
608 + """Normalize request constraints into ``StructuredOutputsParams``."""
609 + return structured_outputs_from_response_format(
610 + self.structured_outputs,
611 + self.response_format,
612 + )
613 +
614 def to_sampling_params(
615 self,
616 max_tokens: int,
@@ -651,38 +655,6 @@ class ChatCompletionRequest(OpenAIBaseModel):
655 if prompt_logprobs is None and self.echo:
656 prompt_logprobs = self.top_logprobs
657
654 - response_format = self.response_format
655 - if response_format is not None:
656 - structured_outputs_kwargs = dict[str, Any]()
657 -
658 - # Set structured output params for response format
659 - if response_format.type == "json_object":
660 - structured_outputs_kwargs["json_object"] = True
661 - elif response_format.type == "json_schema":
662 - json_schema = response_format.json_schema
663 - assert json_schema is not None
664 - structured_outputs_kwargs["json"] = json_schema.json_schema
665 - elif response_format.type == "structural_tag":
666 - structural_tag = response_format
667 - assert structural_tag is not None and isinstance(
668 - structural_tag,
669 - (
670 - LegacyStructuralTagResponseFormat,
671 - StructuralTagResponseFormat,
672 - ),
673 - )
674 - s_tag_obj = structural_tag.model_dump(by_alias=True)
675 - structured_outputs_kwargs["structural_tag"] = json.dumps(s_tag_obj)
676 -
677 - # If structured outputs wasn't already enabled,
678 - # we must enable it for these features to work
679 - if len(structured_outputs_kwargs) > 0:
680 - self.structured_outputs = (
681 - StructuredOutputsParams(**structured_outputs_kwargs)
682 - if self.structured_outputs is None
683 - else replace(self.structured_outputs, **structured_outputs_kwargs)
684 - )
685 -
658 extra_args: dict[str, Any] = self.vllm_xargs if self.vllm_xargs else {}
659 if self.kv_transfer_params:
660 # Pass in kv_transfer_params via extra_args
@@ -718,7 +690,7 @@ class ChatCompletionRequest(OpenAIBaseModel):
690 output_kind=RequestOutputKind.DELTA
691 if self.stream
692 else RequestOutputKind.FINAL_ONLY,
721 - structured_outputs=self.structured_outputs,
693 + structured_outputs=self.extract_structured_outputs(),
694 logit_bias=self.logit_bias,
695 bad_words=self.bad_words,
696 thinking_token_budget=self.thinking_token_budget,
vllm/entrypoints/openai/completion/protocol.py
+9 -37
@@ -3,7 +3,6 @@
3
4 # Adapted from
5 # https://github.com/lm-sys/FastChat/blob/168ccc29d3f7edc50823016105c024fe2282732a/fastchat/protocol/openai_api_protocol.py
6 -import json
6 import time
7 from typing import Annotated, Any, Literal
8
@@ -11,15 +10,13 @@ from pydantic import Field, model_validator
10
11 import vllm.envs as envs
12 from vllm.config import ModelConfig
14 -from vllm.config.utils import replace
13 from vllm.entrypoints.openai.engine.protocol import (
14 AnyResponseFormat,
17 - LegacyStructuralTagResponseFormat,
15 OpenAIBaseModel,
16 PerRequestTimingMetrics,
17 StreamOptions,
21 - StructuralTagResponseFormat,
18 UsageInfo,
19 + structured_outputs_from_response_format,
20 validate_structural_tag_response_format,
21 validate_structured_outputs_structural_tag,
22 )
@@ -281,6 +278,13 @@ class CompletionRequest(OpenAIBaseModel):
278 include_stop_str_in_output=self.include_stop_str_in_output,
279 )
280
281 + def extract_structured_outputs(self) -> StructuredOutputsParams | None:
282 + """Normalize request constraints into ``StructuredOutputsParams``."""
283 + return structured_outputs_from_response_format(
284 + self.structured_outputs,
285 + self.response_format,
286 + )
287 +
288 def to_sampling_params(
289 self,
290 max_tokens: int,
@@ -330,38 +334,6 @@ class CompletionRequest(OpenAIBaseModel):
334
335 echo_without_generation = self.echo and self.max_tokens == 0
336
333 - response_format = self.response_format
334 - if response_format is not None:
335 - structured_outputs_kwargs = dict[str, Any]()
336 -
337 - # Set structured output params for response format
338 - if response_format.type == "json_object":
339 - structured_outputs_kwargs["json_object"] = True
340 - elif response_format.type == "json_schema":
341 - json_schema = response_format.json_schema
342 - assert json_schema is not None
343 - structured_outputs_kwargs["json"] = json_schema.json_schema
344 - elif response_format.type == "structural_tag":
345 - structural_tag = response_format
346 - assert isinstance(
347 - structural_tag,
348 - (
349 - LegacyStructuralTagResponseFormat,
350 - StructuralTagResponseFormat,
351 - ),
352 - )
353 - s_tag_obj = structural_tag.model_dump(by_alias=True)
354 - structured_outputs_kwargs["structural_tag"] = json.dumps(s_tag_obj)
355 -
356 - # If structured outputs wasn't already enabled,
357 - # we must enable it for these features to work
358 - if len(structured_outputs_kwargs) > 0:
359 - self.structured_outputs = (
360 - StructuredOutputsParams(**structured_outputs_kwargs)
361 - if self.structured_outputs is None
362 - else replace(self.structured_outputs, **structured_outputs_kwargs)
363 - )
364 -
337 extra_args: dict[str, Any] = self.vllm_xargs if self.vllm_xargs else {}
338 if self.kv_transfer_params:
339 # Pass in kv_transfer_params via extra_args
@@ -393,7 +365,7 @@ class CompletionRequest(OpenAIBaseModel):
365 output_kind=RequestOutputKind.DELTA
366 if self.stream
367 else RequestOutputKind.FINAL_ONLY,
396 - structured_outputs=self.structured_outputs,
368 + structured_outputs=self.extract_structured_outputs(),
369 logit_bias=self.logit_bias,
370 allowed_token_ids=self.allowed_token_ids,
371 bad_words=self.bad_words,
vllm/entrypoints/openai/engine/protocol.py
+36 -2
@@ -3,6 +3,7 @@
3
4 # Adapted from
5 # https://github.com/lm-sys/FastChat/blob/168ccc29d3f7edc50823016105c024fe2282732a/fastchat/protocol/openai_api_protocol.py
6 +import json
7 import time
8 from http import HTTPStatus
9 from typing import Any, ClassVar, Literal, TypeAlias
@@ -16,9 +17,11 @@ from pydantic import (
17 model_validator,
18 )
19
20 +from vllm.config.utils import replace
21 from vllm.entrypoints.chat_utils import make_tool_call_id
22 from vllm.exceptions import VLLMValidationError
23 from vllm.logger import init_logger
24 +from vllm.sampling_params import StructuredOutputsParams
25 from vllm.utils import random_uuid
26 from vllm.utils.import_utils import resolve_obj_by_qualname
27
@@ -173,6 +176,39 @@ AnyResponseFormat: TypeAlias = (
176 )
177
178
179 +def structured_outputs_from_response_format(
180 + structured_outputs: StructuredOutputsParams | None,
181 + response_format: AnyResponseFormat | None,
182 +) -> StructuredOutputsParams | None:
183 + """Apply ``response_format`` overrides to ``structured_outputs``."""
184 + if response_format is None or response_format.type == "text":
185 + return structured_outputs
186 +
187 + overrides: dict[str, Any]
188 + if response_format.type == "json_object":
189 + overrides = {"json_object": True}
190 + elif response_format.type == "json_schema":
191 + json_schema = response_format.json_schema
192 + assert json_schema is not None
193 + overrides = {"json": json_schema.json_schema}
194 + else:
195 + assert isinstance(
196 + response_format,
197 + (
198 + LegacyStructuralTagResponseFormat,
199 + StructuralTagResponseFormat,
200 + ),
201 + )
202 + overrides = {
203 + "structural_tag": json.dumps(response_format.model_dump(by_alias=True))
204 + }
205 +
206 + if structured_outputs is None:
207 + return StructuredOutputsParams(**overrides)
208 +
209 + return replace(structured_outputs, **overrides)
210 +
211 +
212 def validate_structural_tag_response_format(
213 response_format: AnyStructuralTagResponseFormat | dict[str, Any],
214 ) -> None:
@@ -181,8 +217,6 @@ def validate_structural_tag_response_format(
217 Engine-side validation reports malformed structural tags as generation
218 failures. OpenAI request parsing should classify them as bad requests.
219 """
184 - import json
185 -
220 from pydantic import TypeAdapter, ValidationError
221
222 if isinstance(response_format, dict):
vllm/entrypoints/openai/responses/protocol.py
+26 -22
@@ -354,6 +354,31 @@ class ResponsesRequest(OpenAIBaseModel):
354 "top_k": 0,
355 }
356
357 + def extract_structured_outputs(self) -> StructuredOutputsParams | None:
358 + """Normalize request constraints into ``StructuredOutputsParams``."""
359 + if self.text is None or self.text.format is None:
360 + return self.structured_outputs
361 +
362 + if self.structured_outputs is not None:
363 + raise VLLMValidationError(
364 + "Cannot specify both structured_outputs and text.format",
365 + parameter="structured_outputs",
366 + )
367 +
368 + response_format = self.text.format
369 + if response_format.type == "json_object":
370 + return StructuredOutputsParams(json_object=True)
371 + if (
372 + response_format.type == "json_schema"
373 + and response_format.schema_ is not None
374 + ):
375 + return StructuredOutputsParams(
376 + json=response_format.schema_ # type: ignore[call-arg]
377 + # --follow-imports skip hides the class definition but also hides
378 + # multiple third party conflicts, so best of both evils
379 + )
380 + return None
381 +
382 def to_sampling_params(
383 self,
384 default_max_tokens: int,
@@ -387,27 +412,6 @@ class ResponsesRequest(OpenAIBaseModel):
412 if (frequency_penalty := self.frequency_penalty) is None:
413 frequency_penalty = default_sampling_params.get("frequency_penalty", 0.0)
414
390 - # Structured output
391 - structured_outputs = self.structured_outputs
392 -
393 - # Also check text.format for OpenAI-style json_schema
394 - if self.text is not None and self.text.format is not None:
395 - if structured_outputs is not None:
396 - raise VLLMValidationError(
397 - "Cannot specify both structured_outputs and text.format",
398 - parameter="structured_outputs",
399 - )
400 - response_format = self.text.format
401 - if (
402 - response_format.type == "json_schema"
403 - and response_format.schema_ is not None
404 - ):
405 - structured_outputs = StructuredOutputsParams(
406 - json=response_format.schema_ # type: ignore[call-arg]
407 - # --follow-imports skip hides the class definition but also hides
408 - # multiple third party conflicts, so best of both evils
409 - )
410 -
415 stop = self.stop if self.stop else []
416 if isinstance(stop, str):
417 stop = [stop]
@@ -433,7 +437,7 @@ class ResponsesRequest(OpenAIBaseModel):
437 output_kind=(
438 RequestOutputKind.DELTA if self.stream else RequestOutputKind.FINAL_ONLY
439 ),
436 - structured_outputs=structured_outputs,
440 + structured_outputs=self.extract_structured_outputs(),
441 logit_bias=self.logit_bias,
442 extra_args=extra_args,
443 skip_clone=True, # Created fresh per request, safe to skip clone