main
py 1,036 lines 35.6 KB
Raw
1 # ruff: noqa: E501
2 # SPDX-License-Identifier: Apache-2.0
3 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4
5 import json
6 from enum import Enum
7 from typing import Any
8
9 import jsonschema
10 import pytest
11 import regex as re
12 from pydantic import BaseModel
13
14 from tests.reasoning.utils import run_reasoning_extraction
15 from vllm.config import StructuredOutputsConfig
16 from vllm.exceptions import VLLMValidationError
17 from vllm.outputs import RequestOutput
18 from vllm.platforms import current_platform
19 from vllm.reasoning.abs_reasoning_parsers import ReasoningParserManager
20 from vllm.sampling_params import (
21 SamplingParams,
22 StructuredOutputsParams,
23 )
24
25 SAMPLE_REGEX = (
26 r"((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.){3}"
27 r"(25[0-5]|(2[0-4]|1\d|[1-9]|)\d)"
28 )
29
30 # Note: Ensure this only uses attributes compatible with xgrammar
31 SAMPLE_JSON_SCHEMA = {
32 "type": "object",
33 "properties": {
34 "name": {"type": "string"},
35 "age": {"type": "integer"},
36 "skills": {
37 "type": "array",
38 "items": {
39 "type": "string",
40 },
41 },
42 "grade": {
43 "type": "string",
44 "pattern": "^[A-D]$", # Regex pattern
45 },
46 "email": {
47 "type": "string",
48 "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
49 },
50 "work_history": {
51 "type": "array",
52 "items": {
53 "type": "object",
54 "properties": {
55 "company": {"type": "string"},
56 "duration": {
57 "type": "number",
58 "minimum": 0.0,
59 "maximum": 100.0, # Numeric range
60 },
61 "position": {"type": "string"},
62 },
63 "required": ["company", "duration", "position"],
64 "additionalProperties": False,
65 },
66 "minItems": 0,
67 "maxItems": 3,
68 },
69 },
70 "required": ["name", "age", "skills", "grade", "email", "work_history"],
71 "additionalProperties": False,
72 "minProperties": 1,
73 "maxProperties": 10,
74 }
75
76 # A schema unsupported by xgrammar
77 UNSUPPORTED_JSON_SCHEMA = {
78 "type": "object",
79 "properties": {
80 "score": {
81 "type": "integer",
82 "multipleOf": 5, # Numeric multiple
83 },
84 "tags": {
85 "type": "array",
86 "items": {"type": "string", "minLength": 10, "maxLength": 20},
87 },
88 },
89 "required": ["score", "tags"],
90 "additionalProperties": False,
91 "patternProperties": {
92 "^score$": {"type": "integer"},
93 },
94 }
95
96 SAMPLE_STRUCTURED_OUTPUTS_CHOICES = [
97 "Python",
98 "Java",
99 "JavaScript",
100 "C++",
101 "C#",
102 "PHP",
103 "TypeScript",
104 "Ruby",
105 "Swift",
106 "Kotlin",
107 ]
108
109 SAMPLE_SQL_EBNF = """
110 root ::= select_statement
111 select_statement ::= "SELECT" column "from" table "where" condition
112 column ::= "col_1" | "col_2"
113 table ::= "table_1" | "table_2"
114 condition ::= column "=" number
115 number ::= "1" | "2"
116 """
117
118 SAMPLE_SQL_LARK = """
119 start: select_statement
120 select_statement: "SELECT" column "from" table "where" condition
121 column: "col_1" | "col_2"
122 table: "table_1" | "table_2"
123 condition: column "=" number
124 number: "1" | "2"
125 """
126
127 NGRAM_SPEC_CONFIG = {
128 "model": "[ngram]",
129 "num_speculative_tokens": 5,
130 "prompt_lookup_max": 5,
131 "prompt_lookup_min": 1,
132 }
133
134 EAGLE_SPEC_CONFIG = {
135 "method": "eagle",
136 "model": "yuhuili/EAGLE-LLaMA3.1-Instruct-8B",
137 "num_speculative_tokens": 5,
138 }
139
140 PARAMS_MODELS_BACKENDS_TOKENIZER_MODE = [
141 ("mistralai/Ministral-8B-Instruct-2410", "xgrammar", "auto", None),
142 # FIXME: Since "auto" will use Mistral tokenizer and these backends do not support
143 # it, we skip these tests for now.
144 # ("mistralai/Ministral-8B-Instruct-2410", "guidance", "auto", None),
145 # ("mistralai/Ministral-8B-Instruct-2410", "lm-format-enforcer", "auto", None),
146 ("mistralai/Ministral-8B-Instruct-2410", "guidance", "hf", None),
147 pytest.param(
148 "mistralai/Ministral-8B-Instruct-2410",
149 "lm-format-enforcer",
150 "hf",
151 None,
152 marks=pytest.mark.skip(
153 reason=(
154 "Flaky: lm-format-enforcer intermittently returns"
155 "incomplete JSON."
156 "See https://github.com/noamgat/lm-format-enforcer/issues/169"
157 )
158 ),
159 ),
160 ("mistralai/Ministral-8B-Instruct-2410", "xgrammar", "mistral", None),
161 ("Qwen/Qwen2.5-1.5B-Instruct", "xgrammar", "auto", None),
162 pytest.param(
163 "Qwen/Qwen2.5-1.5B-Instruct",
164 "lm-format-enforcer",
165 "auto",
166 None,
167 marks=pytest.mark.skip(
168 reason=(
169 "Flaky: lm-format-enforcer intermittently returns"
170 "incomplete JSON."
171 "See https://github.com/noamgat/lm-format-enforcer/issues/169"
172 )
173 ),
174 ),
175 # FIXME: This tests are flaky on CI thus disabled. Tracking in Issue #24402
176 # ("mistralai/Ministral-8B-Instruct-2410", "outlines", "auto", None),
177 # ("mistralai/Ministral-8B-Instruct-2410", "outlines", "mistral", None),
178 # ("Qwen/Qwen2.5-1.5B-Instruct", "guidance", "auto"),
179 ("mistralai/Ministral-8B-Instruct-2410", "outlines", "auto", NGRAM_SPEC_CONFIG),
180 ("mistralai/Ministral-8B-Instruct-2410", "guidance", "hf", NGRAM_SPEC_CONFIG),
181 ("Qwen/Qwen2.5-1.5B-Instruct", "xgrammar", "auto", NGRAM_SPEC_CONFIG),
182 ("meta-llama/Meta-Llama-3.1-8B-Instruct", "xgrammar", "auto", EAGLE_SPEC_CONFIG),
183 ]
184
185 PARAMS_MODELS_TOKENIZER_MODE = [
186 ("mistralai/Ministral-8B-Instruct-2410", "auto"),
187 ("Qwen/Qwen2.5-1.5B-Instruct", "auto"),
188 ]
189
190 platform_args = {}
191 if current_platform.is_rocm():
192 platform_args["async_scheduling"] = False
193
194
195 class CarType(str, Enum):
196 sedan = "sedan"
197 suv = "SUV"
198 truck = "Truck"
199 coupe = "Coupe"
200
201
202 class CarDescription(BaseModel):
203 brand: str
204 model: str
205 car_type: CarType
206
207
208 @pytest.mark.parametrize(
209 "model_name, backend, tokenizer_mode, speculative_config",
210 PARAMS_MODELS_BACKENDS_TOKENIZER_MODE,
211 )
212 def test_structured_output(
213 backend: str,
214 tokenizer_mode: str,
215 model_name: str,
216 speculative_config: dict[str, Any],
217 vllm_runner,
218 ):
219 sample_json_schema = SAMPLE_JSON_SCHEMA
220 unsupported_json_schema = UNSUPPORTED_JSON_SCHEMA
221 sample_sql_ebnf = SAMPLE_SQL_EBNF
222 sample_sql_lark = SAMPLE_SQL_LARK
223 sample_regex = SAMPLE_REGEX
224 sample_structured_outputs_choices = SAMPLE_STRUCTURED_OUTPUTS_CHOICES
225 if current_platform.is_tpu() and speculative_config:
226 pytest.skip("TPU does not support speculative decoding")
227
228 # Use a single LLM instance for several scenarios to
229 # speed up the test suite.
230 with vllm_runner(
231 model_name,
232 enforce_eager=True,
233 max_model_len=1024,
234 structured_outputs_config=dict(
235 backend=backend, disable_any_whitespace=backend in {"xgrammar", "guidance"}
236 ),
237 seed=120,
238 tokenizer_mode=tokenizer_mode,
239 load_format="auto" if not model_name.startswith("mistralai/") else "hf",
240 config_format="auto" if not model_name.startswith("mistralai/") else "hf",
241 speculative_config=speculative_config,
242 **platform_args,
243 ) as runner:
244 #
245 # Test 1: Generate JSON output based on a provided schema
246 #
247 sampling_params = SamplingParams(
248 temperature=1.0,
249 max_tokens=4096,
250 structured_outputs=StructuredOutputsParams(json=sample_json_schema),
251 )
252
253 prompt = (
254 "Give an example JSON for an employee profile that fits this "
255 "schema. Make the response as short as possible. Schema: "
256 f"{sample_json_schema}"
257 )
258 outputs = runner.llm.generate(
259 [prompt] * 2,
260 sampling_params=sampling_params,
261 use_tqdm=True,
262 )
263
264 assert outputs is not None
265
266 for output in outputs:
267 assert output is not None
268 assert isinstance(output, RequestOutput)
269 prompt = output.prompt
270
271 generated_text = output.outputs[0].text
272 assert generated_text is not None
273 if backend != "lm-format-enforcer":
274 assert "\n" not in generated_text
275 print(f"Prompt: {prompt!r}, Generated text: {generated_text!r}")
276 try:
277 output_json = json.loads(generated_text)
278 except json.JSONDecodeError as e:
279 pytest.fail(
280 f"Invalid JSON from backend={backend}: {generated_text!r}\n"
281 f"Schema: {sample_json_schema}\nError: {e}"
282 )
283 jsonschema.validate(instance=output_json, schema=sample_json_schema)
284
285 #
286 # Test 2: Generate JSON object without a schema
287 #
288 if backend != "outlines":
289 sampling_params = SamplingParams(
290 temperature=1.0,
291 max_tokens=4096,
292 n=2,
293 structured_outputs=StructuredOutputsParams(json_object=True),
294 )
295
296 outputs = runner.llm.generate(
297 prompts=(
298 "Generate a JSON object with curly braces for a person with "
299 "name and age fields for John Smith who is 31 years old. "
300 "Make the response as short as possible."
301 ),
302 sampling_params=sampling_params,
303 use_tqdm=True,
304 )
305
306 assert outputs is not None
307 for output in outputs:
308 assert output is not None
309 assert isinstance(output, RequestOutput)
310
311 for i in range(2):
312 generated_text = output.outputs[i].text
313 print(generated_text)
314 assert generated_text is not None
315
316 # Parse to verify it is a valid JSON object
317 parsed_json = json.loads(generated_text)
318 assert isinstance(parsed_json, dict)
319
320 #
321 # Test 3: test a jsonschema incompatible with xgrammar
322 #
323 sampling_params = SamplingParams(
324 temperature=1.0,
325 max_tokens=4096,
326 structured_outputs=StructuredOutputsParams(json=unsupported_json_schema),
327 )
328 if backend.startswith("xgrammar"):
329 with pytest.raises(
330 VLLMValidationError,
331 match="The provided JSON schema contains features "
332 "not supported by xgrammar.",
333 ):
334 prompt = (
335 f"Give an example JSON for an employee profile that "
336 f"fits this schema: {unsupported_json_schema}. "
337 f"Make the response as short as possible."
338 )
339 runner.llm.generate(
340 [prompt] * 2,
341 sampling_params=sampling_params,
342 use_tqdm=True,
343 )
344 else:
345 prompt = (
346 f"Give an example JSON object for a grade that "
347 f"fits this schema: {unsupported_json_schema}. "
348 f"Make the response as short as possible."
349 )
350 outputs = runner.llm.generate(
351 prompt,
352 sampling_params=sampling_params,
353 use_tqdm=True,
354 )
355 assert outputs is not None
356 for output in outputs:
357 assert output is not None
358 assert isinstance(output, RequestOutput)
359 generated_text = output.outputs[0].text
360 assert generated_text is not None
361 print(generated_text)
362
363 # Parse to verify it is valid JSON
364 parsed_json = json.loads(generated_text)
365 assert isinstance(parsed_json, dict)
366
367 if backend not in ["outlines", "lm-format-enforcer"]:
368 #
369 # Test 4: Generate SQL statement using EBNF grammar
370 #
371 sampling_params = SamplingParams(
372 temperature=0.8,
373 top_p=0.95,
374 max_tokens=1000,
375 structured_outputs=StructuredOutputsParams(grammar=sample_sql_ebnf),
376 )
377 outputs = runner.llm.generate(
378 (
379 "Generate a sql statement that selects col_1 from "
380 "table_1 where it is equal to 1. Make the response as short as "
381 "possible."
382 ),
383 sampling_params=sampling_params,
384 use_tqdm=True,
385 )
386
387 assert outputs is not None
388 for output in outputs:
389 assert output is not None
390 assert isinstance(output, RequestOutput)
391 prompt = output.prompt
392
393 generated_text = output.outputs[0].text
394 assert generated_text is not None
395
396 # remove spaces for comparison b/c we removed them in the grammar
397 ground_truth = "SELECT col_1 from table_1 where col_1 = 1".replace(
398 " ", ""
399 )
400
401 assert generated_text.strip() == ground_truth
402
403 print(f"Prompt: {prompt!r}, Generated text: {generated_text!r}")
404
405 #
406 # Test 5: Generate SQL statement using Lark grammar
407 #
408 sampling_params = SamplingParams(
409 temperature=0.8,
410 top_p=0.95,
411 max_tokens=1000,
412 structured_outputs=StructuredOutputsParams(grammar=sample_sql_lark),
413 )
414 outputs = runner.llm.generate(
415 (
416 "Generate a sql statement that selects col_1 from "
417 "table_1 where it is equal to 1. Make the response as short as "
418 "possible."
419 ),
420 sampling_params=sampling_params,
421 use_tqdm=True,
422 )
423
424 assert outputs is not None
425 for output in outputs:
426 assert output is not None
427 assert isinstance(output, RequestOutput)
428 prompt = output.prompt
429
430 generated_text = output.outputs[0].text
431 assert generated_text is not None
432
433 # use Lark to parse the output, and make sure it's a valid parse tree
434 from lark import Lark
435
436 parser = Lark(sample_sql_lark)
437 parser.parse(generated_text)
438
439 # remove spaces for comparison b/c we removed them in the grammar
440 ground_truth = "SELECT col_1 from table_1 where col_1 = 1".replace(
441 " ", ""
442 )
443
444 assert generated_text.strip() == ground_truth
445
446 print(f"Prompt: {prompt!r}, Generated text: {generated_text!r}")
447
448 #
449 # Test 6: Test invalid grammar input
450 #
451 sampling_params = SamplingParams(
452 temperature=0.8,
453 top_p=0.95,
454 max_tokens=1000,
455 structured_outputs=StructuredOutputsParams(grammar="not a grammar"),
456 )
457 with pytest.raises(
458 VLLMValidationError, match="Invalid grammar specification"
459 ):
460 runner.llm.generate(
461 (
462 "Generate a sql statement that selects col_1 from "
463 "table_1 where it is equal to 1. Make the response as short "
464 "as possible."
465 ),
466 sampling_params=sampling_params,
467 use_tqdm=True,
468 )
469
470 #
471 # Test 7: Generate text based on a regex pattern
472 #
473 sampling_params = SamplingParams(
474 temperature=0.8,
475 top_p=0.95,
476 structured_outputs=StructuredOutputsParams(regex=sample_regex),
477 )
478
479 prompt = (
480 f"Give an example IPv4 address with this regex: {sample_regex}. "
481 f"Make the response as short as possible."
482 )
483 outputs = runner.llm.generate(
484 [prompt] * 2,
485 sampling_params=sampling_params,
486 use_tqdm=True,
487 )
488
489 assert outputs is not None
490 for output in outputs:
491 assert output is not None
492 assert isinstance(output, RequestOutput)
493 prompt = output.prompt
494 generated_text = output.outputs[0].text
495 print(generated_text)
496 assert generated_text is not None
497 assert re.fullmatch(sample_regex, generated_text) is not None
498 print(f"Prompt: {prompt!r}, Generated text: {generated_text!r}")
499
500 #
501 # Test 8: Generate text based on a choices
502 #
503 sampling_params = SamplingParams(
504 temperature=0.8,
505 top_p=0.95,
506 structured_outputs=StructuredOutputsParams(
507 choice=sample_structured_outputs_choices
508 ),
509 )
510
511 outputs = runner.llm.generate(
512 (
513 "The best language for type-safe systems programming is "
514 "(Make the response as short as possible.) "
515 ),
516 sampling_params=sampling_params,
517 use_tqdm=True,
518 )
519 assert outputs is not None
520 for output in outputs:
521 assert output is not None
522 assert isinstance(output, RequestOutput)
523 prompt = output.prompt
524 generated_text = output.outputs[0].text
525 print(generated_text)
526 assert generated_text is not None
527 assert generated_text in sample_structured_outputs_choices
528 print(f"Prompt: {prompt!r}, Generated text: {generated_text!r}")
529
530 #
531 # Test 9: Generate structured output using a Pydantic model with an enum
532 #
533 json_schema = CarDescription.model_json_schema()
534 sampling_params = SamplingParams(
535 temperature=1.0,
536 max_tokens=1000,
537 structured_outputs=StructuredOutputsParams(json=json_schema),
538 )
539
540 outputs = runner.llm.generate(
541 (
542 "Generate a JSON with the brand, model and car_type of the most "
543 "iconic car from the 90's. Make the response as short as "
544 "possible."
545 ),
546 sampling_params=sampling_params,
547 use_tqdm=True,
548 )
549
550 assert outputs is not None
551
552 for output in outputs:
553 assert output is not None
554 assert isinstance(output, RequestOutput)
555 prompt = output.prompt
556
557 generated_text = output.outputs[0].text
558 assert generated_text is not None
559 print(f"Prompt: {prompt!r}, Generated text: {generated_text!r}")
560 try:
561 output_json = json.loads(generated_text)
562 except json.JSONDecodeError as e:
563 pytest.fail(
564 f"Invalid JSON from backend={backend}: {generated_text!r}\n"
565 f"Schema: {json_schema}\nError: {e}"
566 )
567 jsonschema.validate(instance=output_json, schema=json_schema)
568
569 #
570 # Test 10: Generate structured with minLength and maxLength
571 #
572 min_length = 50
573 max_length = 50
574 json_schema = {
575 "type": "object",
576 "properties": {
577 "description": {
578 "type": "string",
579 "maxLength": max_length,
580 "minLength": min_length,
581 }
582 },
583 "required": ["description"],
584 "additionalProperties": False,
585 }
586
587 sampling_params = SamplingParams(
588 temperature=1.0,
589 max_tokens=4096,
590 structured_outputs=StructuredOutputsParams(json=json_schema),
591 )
592
593 outputs = runner.llm.generate(
594 (
595 "Generate a description of a frog using 50 characters. "
596 "Make the response as short as possible."
597 ),
598 sampling_params=sampling_params,
599 use_tqdm=True,
600 )
601
602 assert outputs is not None
603
604 for output in outputs:
605 assert output is not None
606 assert isinstance(output, RequestOutput)
607 prompt = output.prompt
608
609 generated_text = output.outputs[0].text
610 assert generated_text is not None
611 print(f"Prompt: {prompt!r}, Generated text: {generated_text!r}")
612 try:
613 output_json = json.loads(generated_text)
614 except json.JSONDecodeError as e:
615 pytest.fail(
616 f"Invalid JSON from backend={backend}: {generated_text!r}\n"
617 f"Schema: {json_schema}\nError: {e}"
618 )
619 jsonschema.validate(instance=output_json, schema=json_schema)
620
621 if backend not in ["outlines", "lm-format-enforcer"]:
622 #
623 # Test 11: Generate structured output using structural_tag format
624 #
625 structural_tag_config = {
626 "type": "structural_tag",
627 "structures": [
628 {
629 "begin": "<function=get_weather>",
630 "schema": {
631 "type": "object",
632 "properties": {"city": {"type": "string"}},
633 "additionalProperties": False,
634 },
635 "end": "</function>",
636 }
637 ],
638 "triggers": ["<function="],
639 }
640
641 sampling_params = SamplingParams(
642 temperature=0.0,
643 max_tokens=4096,
644 structured_outputs=StructuredOutputsParams(
645 structural_tag=json.dumps(structural_tag_config)
646 ),
647 )
648
649 prompt = """
650 You have access to the following function to retrieve the weather in a city:
651
652 {
653 "name": "get_weather",
654 "parameters": {
655 "city": {
656 "param_type": "string",
657 "description": "The city to get the weather for",
658 "required": True
659 }
660 }
661 }
662
663 If a you choose to call a function ONLY reply in the following format:
664 <{start_tag}={function_name}>{parameters}{end_tag}
665 where
666
667 start_tag => `<function`
668 parameters => a JSON dict with the function argument name
669 as key and function argument value as value.
670 end_tag => `</function>`
671
672 Here is an example,
673 <function=example_function_name>{"example_name": "example_value"}</function>
674
675 Reminder:
676 - Function calls MUST follow the specified format
677 - Required parameters MUST be specified
678 - Only call one function at a time
679 - Put the entire function call reply on one line
680 - Always add your sources when using search results to answer the user query
681
682 You are a helpful assistant.
683
684 Given the previous instructions, what is the weather in New York City? \
685 Make the response as short as possible.
686 """
687
688 # Change this once other backends support structural_tag
689 outputs = runner.llm.generate(
690 prompt, sampling_params=sampling_params, use_tqdm=True
691 )
692 assert outputs is not None
693
694 for output in outputs:
695 assert output is not None
696 assert isinstance(output, RequestOutput)
697 generated_text = output.outputs[0].text
698 assert generated_text is not None
699
700 # Search for function call pattern in the response
701 function_call_pattern = r"<function=get_weather>(.*?)</function>"
702 matches = re.findall(function_call_pattern, generated_text)
703
704 if not matches:
705 print(
706 f"Warning: No function calls found in response: {generated_text!r}"
707 )
708 continue
709
710 # Take the first function call if multiple are found
711 json_str = matches[0]
712 try:
713 json_content = json.loads(json_str)
714 assert "city" in json_content
715 assert isinstance(json_content["city"], str)
716 print(f"Found valid function call: {generated_text!r}")
717 except (json.JSONDecodeError, AssertionError) as e:
718 pytest.fail(
719 f"Invalid function call format: {generated_text!r}\nError: {str(e)}"
720 )
721
722
723 @pytest.mark.parametrize(
724 "model_name, backend, tokenizer_mode, reasoning_parser, speculative_config, async_scheduling", # noqa: E501
725 [
726 (
727 "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B",
728 "xgrammar",
729 "auto",
730 "deepseek_r1",
731 NGRAM_SPEC_CONFIG,
732 False,
733 ),
734 ("Qwen/Qwen3-1.7B", "xgrammar", "auto", "deepseek_r1", None, False),
735 ("Qwen/Qwen3-1.7B", "xgrammar", "auto", "deepseek_r1", None, True),
736 ],
737 )
738 def test_structured_output_with_reasoning_matrices(
739 backend: str,
740 tokenizer_mode: str,
741 reasoning_parser: str,
742 model_name: str,
743 speculative_config: dict[str, Any] | None,
744 async_scheduling: bool,
745 vllm_runner,
746 ):
747 if current_platform.is_tpu() and speculative_config:
748 pytest.skip("TPU does not support speculative decoding")
749
750 # Use a single LLM instance for several scenarios to
751 # speed up the test suite.
752 with vllm_runner(
753 model_name,
754 # Don't use eager execution on TPUs because we want to test for no
755 # recompilation at runtime
756 enforce_eager=bool(not current_platform.is_tpu()),
757 max_model_len=1024,
758 max_num_seqs=16,
759 structured_outputs_config=dict(
760 backend=backend,
761 disable_any_whitespace=backend in {"xgrammar", "guidance"},
762 reasoning_parser=reasoning_parser,
763 ),
764 tokenizer_mode=tokenizer_mode,
765 speculative_config=speculative_config,
766 async_scheduling=async_scheduling,
767 ) as runner:
768 tokenizer = runner.llm.get_tokenizer()
769 reasoner = ReasoningParserManager.get_reasoning_parser(reasoning_parser)(
770 tokenizer=tokenizer
771 )
772
773 reasoning_prompt = "Solve the following math problem step-by-step, then provide the final answer as JSON object with a single key 'result'. Make sure to correct your reasoning if there are any issue should it arise.\nProblem: What is 5 * 8 + 2?" # noqa: E501
774 reasoning_schema = {
775 "type": "object",
776 "properties": {"result": {"type": "integer"}},
777 "required": ["result"],
778 "additionalProperties": False,
779 }
780 if "Qwen3" in model_name:
781 reasoning_prompt += "<think>\n"
782
783 sampling_params = SamplingParams(
784 temperature=0.1,
785 max_tokens=8192,
786 structured_outputs=StructuredOutputsParams(json=reasoning_schema),
787 )
788 outputs = runner.llm.generate(
789 [reasoning_prompt],
790 sampling_params=sampling_params,
791 use_tqdm=True,
792 )
793
794 assert outputs is not None
795 output = outputs[0]
796 assert output is not None and isinstance(output, RequestOutput)
797 prompt = output.prompt
798 generated_text = output.outputs[0].text
799 reasoning, content = run_reasoning_extraction(reasoner, [generated_text])
800 print(f"Prompt: {prompt!r}\nReasoning: {reasoning!r}\nContent: {content!r}")
801
802 if "Qwen3" in model_name:
803 assert content is not None
804
805 assert reasoning is not None
806
807 if content is not None:
808 output_json = json.loads(content)
809 jsonschema.validate(instance=output_json, schema=reasoning_schema)
810
811
812 @pytest.mark.parametrize("model_name, tokenizer_mode", PARAMS_MODELS_TOKENIZER_MODE)
813 def test_structured_output_auto_mode(
814 model_name: str,
815 tokenizer_mode: str,
816 vllm_runner,
817 ):
818 unsupported_json_schema = UNSUPPORTED_JSON_SCHEMA
819 with vllm_runner(
820 model_name,
821 max_model_len=1024,
822 structured_outputs_config=dict(backend="auto"),
823 tokenizer_mode=tokenizer_mode,
824 load_format="auto",
825 config_format="auto",
826 ) as runner:
827 sampling_params = SamplingParams(
828 temperature=1.0,
829 max_tokens=1000,
830 structured_outputs=StructuredOutputsParams(json=unsupported_json_schema),
831 )
832
833 prompts = (
834 "Give an example JSON object for a grade "
835 "that fits this schema: "
836 f"{unsupported_json_schema}. Make the response as short as possible."
837 )
838 # This would fail with the default of "xgrammar", but in "auto"
839 # we will handle fallback automatically.
840 outputs = runner.llm.generate(
841 prompts, sampling_params=sampling_params, use_tqdm=True
842 )
843 # Make sure `auto` backend handling doesn't mess up sampling_params
844 # and that we can reuse it without error.
845 outputs.extend(
846 runner.llm.generate(prompts, sampling_params=sampling_params, use_tqdm=True)
847 )
848
849 assert outputs is not None
850 for output in outputs:
851 assert output is not None
852 assert isinstance(output, RequestOutput)
853 generated_text = output.outputs[0].text
854 assert generated_text is not None
855 print(generated_text)
856
857 # Parse to verify it is valid JSON
858 parsed_json = json.loads(generated_text)
859 assert isinstance(parsed_json, dict)
860
861
862 def test_guidance_no_additional_properties(vllm_runner):
863 with vllm_runner(
864 "Qwen/Qwen2.5-1.5B-Instruct",
865 max_model_len=1024,
866 structured_outputs_config=dict(
867 backend="guidance",
868 disable_any_whitespace=True,
869 disable_additional_properties=True,
870 ),
871 ) as runner:
872 schema = {
873 "type": "object",
874 "properties": {
875 "a1": {"type": "string"},
876 "a2": {"type": "string"},
877 "a3": {"type": "string"},
878 },
879 "required": ["a1", "a2", "a3"],
880 }
881
882 prompt = (
883 "<|im_start|>system\nYou are Qwen, created by Alibaba Cloud. You are a "
884 "helpful assistant.<|im_end|>\n<|im_start|>user\nPlease generate a "
885 "large JSON object with key-value pairs a1=b1, a2=b2, ..., a20=b20. "
886 "Make the response as short as possible."
887 "<|im_end|>\n<|im_start|>assistant\n"
888 )
889
890 def generate_with_backend(backend):
891 structured_outputs_params = StructuredOutputsParams(
892 json=schema,
893 backend=backend,
894 disable_any_whitespace=True,
895 disable_additional_properties=True,
896 )
897 sampling_params = SamplingParams(
898 temperature=0,
899 max_tokens=256,
900 structured_outputs=structured_outputs_params,
901 )
902
903 outputs = runner.llm.generate(prompt, sampling_params=sampling_params)
904 assert outputs is not None
905 generated_text = outputs[0].outputs[0].text
906 assert generated_text is not None
907 parsed_json = json.loads(generated_text)
908 assert isinstance(parsed_json, dict)
909 jsonschema.validate(instance=parsed_json, schema=schema)
910 return parsed_json
911
912 generated = generate_with_backend("guidance")
913 assert "a1" in generated
914 assert "a2" in generated
915 assert "a3" in generated
916 assert "a4" not in generated
917 assert "a5" not in generated
918 assert "a6" not in generated
919
920
921 @pytest.mark.parametrize("backend", ["guidance", "xgrammar", "outlines"])
922 def test_structured_output_batched_with_non_structured_outputs_requests(
923 backend: str,
924 vllm_runner,
925 ):
926 sample_json_schema = SAMPLE_JSON_SCHEMA
927 # Don't use eager execution on TPUs because we want to test for no
928 # recompilation at runtime
929 enforce_eager = bool(not current_platform.is_tpu())
930
931 with vllm_runner(
932 "meta-llama/Meta-Llama-3.1-8B-Instruct",
933 enforce_eager=enforce_eager,
934 max_model_len=1024,
935 structured_outputs_config=StructuredOutputsConfig(
936 backend=backend,
937 disable_any_whitespace=backend in {"xgrammar", "guidance"},
938 ),
939 ) as runner:
940 structured_outputs_prompt = (
941 "Give an example JSON for an employee profile that fits this "
942 "schema. Make the response as short as possible. Schema: "
943 f"{sample_json_schema}"
944 )
945
946 non_structured_outputs_prompt = "The diameter of the Earth in kilometers is "
947
948 prompts = [structured_outputs_prompt, non_structured_outputs_prompt]
949 sampling_params = [
950 SamplingParams(
951 temperature=0,
952 max_tokens=400,
953 structured_outputs=StructuredOutputsParams(json=sample_json_schema),
954 ),
955 # No max tokens, temp=0 to assert on contents
956 SamplingParams(
957 seed=42,
958 temperature=0,
959 top_p=1.0,
960 ),
961 ]
962
963 outputs = runner.llm.generate(
964 prompts=prompts, sampling_params=sampling_params, use_tqdm=True
965 )
966
967 assert outputs is not None
968
969 for index, output in enumerate(outputs):
970 assert output is not None
971 assert isinstance(output, RequestOutput)
972 prompt = output.prompt
973
974 generated_text = output.outputs[0].text
975 assert generated_text is not None
976 print(f"Prompt:\n{prompt!r}\nGenerated text:\n{generated_text!r}")
977
978 if index == 0:
979 # First prompt is structured outputs, expect valid JSON
980 assert "\n" not in generated_text
981 output_json = json.loads(generated_text)
982 jsonschema.validate(instance=output_json, schema=sample_json_schema)
983 else:
984 # Second prompt is not structured outputs, expect valid output
985 # Cannot assert on exact output, but we can expect it to be factual
986 assert "12,742" in generated_text
987
988 # non-structured outputs requests should not return a valid JSON here
989 with pytest.raises(ValueError):
990 output_json = json.loads(generated_text)
991
992
993 @pytest.mark.parametrize("backend", ["xgrammar"])
994 def test_structured_output_with_structural_tag(backend: str, vllm_runner):
995 with vllm_runner(
996 "Qwen/Qwen2.5-1.5B-Instruct",
997 structured_outputs_config=StructuredOutputsConfig(backend=backend),
998 ) as runner:
999 structural_tag_config = {
1000 "type": "structural_tag",
1001 "format": {
1002 "type": "triggered_tags",
1003 "tags": [
1004 {
1005 "begin": "hello_flag",
1006 "content": {"type": "any_text"},
1007 "end": "hello",
1008 }
1009 ],
1010 "triggers": ["hello"],
1011 "stop_after_first": False,
1012 },
1013 }
1014
1015 sampling_params = SamplingParams(
1016 temperature=0.0,
1017 max_tokens=500,
1018 structured_outputs=StructuredOutputsParams(
1019 structural_tag=json.dumps(structural_tag_config)
1020 ),
1021 )
1022
1023 prompt = "Hello and repeat hello 10 times, do not say anything else. Only say hello hello hello, now start"
1024 outputs = runner.llm.generate(
1025 prompt, sampling_params=sampling_params, use_tqdm=True
1026 )
1027 assert outputs is not None
1028 for output in outputs:
1029 assert output is not None
1030 assert isinstance(output, RequestOutput)
1031 prompt = output.prompt
1032 generated_text = output.outputs[0].text
1033 assert generated_text is not None
1034 assert "hello_flag" in generated_text, (
1035 f"Expected 'hello_flag' to be in generated text, but got: {generated_text}"
1036 )