main
py 1,296 lines 40.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """On-demand trace builder for parser engine testing and benchmarks.
4
5 Generates token sequences programmatically from model-agnostic scenario
6 definitions. Each model format handler knows how to render scenarios
7 into the model's output format, tokenize them with correct special token
8 IDs, and compute expected parse outputs.
9
10 Every generated sample is self-validated by replaying it through the
11 real parser before being returned.
12 """
13
14 from __future__ import annotations
15
16 import functools
17 import json
18 from collections.abc import Callable
19 from dataclasses import dataclass, replace
20 from typing import Any
21
22 from tests.parser.engine.replay_harness import (
23 MockTokenizer,
24 Sample,
25 assert_parse_output,
26 collect_output,
27 replay_streaming,
28 )
29 from vllm.entrypoints.openai.chat_completion.protocol import (
30 ChatCompletionToolsParam,
31 )
32 from vllm.parser import plamo3
33 from vllm.parser.engine.registered_adapters import (
34 DeepSeekV4Parser,
35 DeepSeekV32Parser,
36 DeepSeekV41Parser,
37 Gemma4Parser,
38 Glm47MoeParser,
39 GraniteParser,
40 GraniteThinkingParser,
41 InklingParser,
42 KimiK2Parser,
43 MiMoParser,
44 MinimaxM2Parser,
45 NemotronV3Parser,
46 Plamo3Parser,
47 Qwen3Parser,
48 SeedOssParser,
49 Step3p5Parser,
50 )
51
52 # ── Data structures ──────────────────────────────────────────────────
53
54
55 @dataclass
56 class ToolCallSpec:
57 name: str
58 arguments: dict[str, Any]
59
60
61 @dataclass
62 class Scenario:
63 id: str
64 description: str
65 reasoning: str | None = None
66 content: str | None = None
67 tool_calls: list[ToolCallSpec] | None = None
68 after_tool_response: bool = False
69
70
71 # ── Scenarios ────────────────────────────────────────────────────────
72
73 _READ_TOOL = ToolCallSpec("read_file", {"path": "/tmp/test.txt"})
74 _BASH_TOOL = ToolCallSpec(
75 "bash", {"command": "hostname", "description": "Get hostname"}
76 )
77 _WEATHER_TOOL = ToolCallSpec(
78 "get_weather",
79 {"city": "Dallas", "state": "TX", "unit": "fahrenheit"},
80 )
81 _COMPLEX_TOOL = ToolCallSpec(
82 "search",
83 {
84 "query": "vllm parser",
85 "filters": {"language": "python", "min_stars": 100},
86 "tags": ["ml", "inference"],
87 "limit": 10,
88 "verbose": True,
89 },
90 )
91
92 SCENARIOS: list[Scenario] = [
93 Scenario(
94 id="think-then-tool",
95 description="Reasoning then single tool call",
96 reasoning="Let me check the file.",
97 tool_calls=[_READ_TOOL],
98 ),
99 Scenario(
100 id="think-then-parallel-tools",
101 description="Reasoning then two parallel tool calls",
102 reasoning="I need to run both commands.",
103 tool_calls=[_BASH_TOOL, _WEATHER_TOOL],
104 ),
105 Scenario(
106 id="think-then-content",
107 description="Reasoning then content response",
108 reasoning="Let me think about this carefully.",
109 content="The answer is 42.",
110 ),
111 Scenario(
112 id="content-only",
113 description="Plain content response without reasoning",
114 content="Hello! How can I help you today?",
115 ),
116 Scenario(
117 id="tool-only",
118 description="Tool call without reasoning",
119 tool_calls=[_READ_TOOL],
120 ),
121 Scenario(
122 id="complex-json-args",
123 description="Tool call with nested objects, arrays, numbers, booleans",
124 reasoning="This needs a complex query.",
125 tool_calls=[_COMPLEX_TOOL],
126 ),
127 Scenario(
128 id="whitespace-before-tool",
129 description="Whitespace-only content before tool call",
130 content="\n\n",
131 tool_calls=[_WEATHER_TOOL],
132 ),
133 Scenario(
134 id="think-content-tool",
135 description="Reasoning, content, then tool call",
136 reasoning="Let me analyze and then fetch data.",
137 content="Checking the weather now.",
138 tool_calls=[_WEATHER_TOOL],
139 ),
140 Scenario(
141 id="think-whitespace-tool",
142 description="Reasoning, whitespace-only gap, then tool call",
143 reasoning="Let me check the file contents.",
144 content="\n\n",
145 tool_calls=[_READ_TOOL],
146 ),
147 Scenario(
148 id="empty-reasoning-content",
149 description="Empty reasoning section followed by content",
150 reasoning="",
151 content="The epoch timestamp is 1779111346.",
152 ),
153 Scenario(
154 id="tool-after-tool-response",
155 description="Tool call immediately after tool response (agentic flow)",
156 tool_calls=[_READ_TOOL],
157 after_tool_response=True,
158 ),
159 Scenario(
160 id="empty-tool-block",
161 description="Empty tool block followed by content (edge case recovery)",
162 content="Content after empty tools.",
163 tool_calls=[],
164 ),
165 ]
166
167
168 # ── Tokenization ─────────────────────────────────────────────────────
169
170
171 def _word_split(text: str) -> list[str]:
172 """Split text into word-like tokens, preserving all characters."""
173 if not text:
174 return []
175 parts: list[str] = []
176 current = ""
177 for ch in text:
178 if ch in " \t\n\r" and current and current[-1] not in " \t\n\r":
179 parts.append(current)
180 current = ch
181 else:
182 current += ch
183 if current:
184 parts.append(current)
185 return parts
186
187
188 def _tokenize(
189 segments: list[tuple[str, bool]],
190 vocab: dict[str, int],
191 start_id: int = 100,
192 ) -> list[tuple[int, str]]:
193 """Build token list from segments.
194
195 Each segment is ``(text, is_special)``. Special segments use vocab
196 IDs; content segments are word-split with sequential IDs.
197 """
198 tokens: list[tuple[int, str]] = []
199 next_id = start_id
200
201 for text, is_special in segments:
202 if not text:
203 continue
204 if is_special:
205 tid = vocab.get(text)
206 if tid is None:
207 raise ValueError(f"Special token {text!r} not in vocab")
208 tokens.append((tid, text))
209 else:
210 for word in _word_split(text):
211 tokens.append((next_id, word))
212 next_id += 1
213
214 return tokens
215
216
217 # ── Tool definitions ─────────────────────────────────────────────────
218
219
220 def _infer_schema(value: object) -> dict:
221 """Infer a JSON Schema from a Python value, recursing into dicts/lists."""
222 if isinstance(value, bool):
223 return {"type": "boolean"}
224 if isinstance(value, int):
225 return {"type": "integer"}
226 if isinstance(value, float):
227 return {"type": "number"}
228 if isinstance(value, str):
229 return {"type": "string"}
230 if isinstance(value, dict):
231 return {
232 "type": "object",
233 "properties": {k: _infer_schema(v) for k, v in value.items()},
234 }
235 if isinstance(value, list) and value:
236 return {"type": "array", "items": _infer_schema(value[0])}
237 if isinstance(value, list):
238 return {"type": "array"}
239 return {}
240
241
242 def _tool_defs(tool_calls: list[ToolCallSpec]) -> list[dict]:
243 """Generate OpenAI-style tool definitions from tool call specs."""
244 seen: set[str] = set()
245 tools: list[dict] = []
246 for tc in tool_calls:
247 if tc.name in seen:
248 continue
249 seen.add(tc.name)
250 properties = {k: _infer_schema(v) for k, v in tc.arguments.items()}
251 tools.append(
252 {
253 "type": "function",
254 "function": {
255 "name": tc.name,
256 "parameters": {
257 "type": "object",
258 "properties": properties,
259 },
260 },
261 }
262 )
263 return tools
264
265
266 # ── Format handlers ──────────────────────────────────────────────────
267
268
269 def _expected_tc(scenario: Scenario) -> list[dict] | None:
270 if not scenario.tool_calls:
271 return None
272 return [{"name": tc.name, "arguments": tc.arguments} for tc in scenario.tool_calls]
273
274
275 def _expected_tools(scenario: Scenario) -> list[dict] | None:
276 return _tool_defs(scenario.tool_calls) if scenario.tool_calls else None
277
278
279 def _validate_sample(sample: Sample, parser_cls: type, **kwargs) -> None:
280 """Replay sample through the real parser and assert correctness."""
281 tokenizer = MockTokenizer(vocab=dict(sample.vocab), tokens=sample.tokens)
282 parser = parser_cls(tokenizer, sample.tools, **kwargs)
283 deltas = replay_streaming(
284 parser,
285 sample.tokens,
286 chunk_size=1,
287 tools=sample.tools,
288 prompt_token_ids=sample.prompt_token_ids,
289 )
290 output = collect_output(deltas)
291 assert_parse_output(output, sample)
292
293
294 def _validate_tools(
295 tools: list[dict] | None,
296 ) -> list[ChatCompletionToolsParam] | None:
297 if not tools:
298 return None
299 return [ChatCompletionToolsParam.model_validate(t) for t in tools]
300
301
302 def _make_sample(
303 sample_id: str,
304 description: str,
305 vocab: dict[str, int],
306 segments: list[tuple[str, bool]],
307 expected_reasoning: str | None,
308 expected_content: str | None,
309 expected_tool_calls: list[dict] | None,
310 tools: list[dict] | None,
311 chat_template_kwargs: dict | None = None,
312 prompt_token_ids: list[int] | None = None,
313 ) -> Sample:
314 tokens = _tokenize(segments, vocab)
315 return Sample(
316 id=sample_id,
317 description=description,
318 source="trace-builder",
319 vocab=dict(vocab),
320 tokens=tokens,
321 expected_reasoning=expected_reasoning,
322 expected_content=expected_content,
323 expected_tool_calls=expected_tool_calls,
324 tools=_validate_tools(tools),
325 chat_template_kwargs=chat_template_kwargs,
326 prompt_token_ids=prompt_token_ids,
327 )
328
329
330 # ── Qwen3 (XML tool format, starts in REASONING) ────────────────────
331
332 _QWEN3_VOCAB: dict[str, int] = {
333 "<think>": 50,
334 "</think>": 51,
335 "<tool_call>": 60,
336 "</tool_call>": 61,
337 }
338
339
340 def _qwen3_arg_value(value: Any) -> str:
341 if isinstance(value, bool):
342 return "true" if value else "false"
343 if isinstance(value, (int, float)):
344 return str(value)
345 if isinstance(value, str):
346 return value
347 return json.dumps(value, ensure_ascii=False)
348
349
350 def _qwen3_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
351 parts = [f"\n<function={tc.name}>"]
352 for key, value in tc.arguments.items():
353 parts.append(f"\n<parameter={key}>{_qwen3_arg_value(value)}</parameter>")
354 parts.append("\n</function>\n")
355 return [
356 ("<tool_call>", True),
357 ("".join(parts), False),
358 ("</tool_call>", True),
359 ]
360
361
362 def _qwen3_segments(
363 scenario: Scenario,
364 tool_segments: Callable[[ToolCallSpec], list[tuple[str, bool]]] = (
365 _qwen3_tool_segments
366 ),
367 ) -> list[tuple[str, bool]]:
368 segs: list[tuple[str, bool]] = []
369 if scenario.reasoning is not None:
370 segs.append((scenario.reasoning, False))
371 if scenario.content is not None or scenario.tool_calls is not None:
372 segs.append(("</think>", True))
373 if scenario.tool_calls is not None and not scenario.tool_calls:
374 segs.append(("<tool_call>", True))
375 segs.append(("</tool_call>", True))
376 if scenario.content is not None:
377 segs.append((scenario.content, False))
378 if scenario.tool_calls:
379 for tc in scenario.tool_calls:
380 segs.extend(tool_segments(tc))
381 return segs
382
383
384 def _qwen3_expected_content(scenario: Scenario) -> str | None:
385 if (
386 scenario.content is not None
387 and scenario.tool_calls
388 and not scenario.content.strip()
389 ):
390 return ""
391 return scenario.content
392
393
394 def _build_qwen3(
395 scenario: Scenario,
396 name: str = "qwen3",
397 parser_cls: type = Qwen3Parser,
398 strip_trailing_ws: bool = False,
399 validate: bool = True,
400 tool_segments: Callable[[ToolCallSpec], list[tuple[str, bool]]] = (
401 _qwen3_tool_segments
402 ),
403 ) -> Sample:
404 expected_reasoning: str | None
405 if scenario.reasoning is not None:
406 r = scenario.reasoning
407 if strip_trailing_ws:
408 r = r.rstrip()
409 expected_reasoning = r
410 else:
411 expected_reasoning = ""
412
413 sample = _make_sample(
414 sample_id=f"{name}-{scenario.id}",
415 description=scenario.description,
416 vocab=_QWEN3_VOCAB,
417 segments=_qwen3_segments(scenario, tool_segments),
418 expected_reasoning=expected_reasoning,
419 expected_content=_qwen3_expected_content(scenario),
420 expected_tool_calls=_expected_tc(scenario),
421 tools=_expected_tools(scenario),
422 )
423 if validate:
424 _validate_sample(sample, parser_cls)
425 return sample
426
427
428 # ── MiMo (compact Qwen3 XML, no newlines between tags) ───────────────
429
430
431 def _mimo_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
432 parts = [f"<function={tc.name}>"]
433 for key, value in tc.arguments.items():
434 parts.append(f"<parameter={key}>{_qwen3_arg_value(value)}</parameter>")
435 parts.append("</function>")
436 return [
437 ("<tool_call>", True),
438 ("".join(parts), False),
439 ("</tool_call>", True),
440 ]
441
442
443 def _build_mimo(scenario: Scenario, validate: bool = True) -> Sample:
444 return _build_qwen3(
445 scenario,
446 name="mimo",
447 parser_cls=MiMoParser,
448 validate=validate,
449 tool_segments=_mimo_tool_segments,
450 )
451
452
453 # ── Step-3.5 (Qwen3 XML, trailing reasoning whitespace stripped) ────
454
455
456 def _build_step3p5(scenario: Scenario, validate: bool = True) -> Sample:
457 return _build_qwen3(
458 scenario,
459 name="step3p5",
460 parser_cls=Step3p5Parser,
461 strip_trailing_ws=True,
462 validate=validate,
463 )
464
465
466 # ── MiniMax M2 (XML invoke format, starts in REASONING) ──────────────
467
468 _MINIMAX_M2_VOCAB: dict[str, int] = {
469 "<think>": 50,
470 "</think>": 51,
471 "<minimax:tool_call>": 60,
472 "</minimax:tool_call>": 61,
473 }
474
475
476 def _minimax_m2_arg_value(value: Any) -> str:
477 if isinstance(value, bool):
478 return "true" if value else "false"
479 if isinstance(value, (int, float)):
480 return str(value)
481 if isinstance(value, str):
482 return value
483 return json.dumps(value, ensure_ascii=False)
484
485
486 def _minimax_m2_tool_segments(tool_calls: list[ToolCallSpec]) -> list[tuple[str, bool]]:
487 segs: list[tuple[str, bool]] = [("<minimax:tool_call>", True)]
488 for tc in tool_calls:
489 segs.append((f'<invoke name="{tc.name}">', False))
490 for key, value in tc.arguments.items():
491 segs.append(
492 (
493 f'<parameter name="{key}">'
494 f"{_minimax_m2_arg_value(value)}"
495 "</parameter>",
496 False,
497 )
498 )
499 segs.append(("</invoke>", False))
500 segs.append(("</minimax:tool_call>", True))
501 return segs
502
503
504 def _minimax_m2_segments(scenario: Scenario) -> list[tuple[str, bool]]:
505 segs: list[tuple[str, bool]] = []
506 if scenario.reasoning is not None:
507 segs.append((scenario.reasoning, False))
508 if scenario.content is not None or scenario.tool_calls is not None:
509 segs.append(("</think>", True))
510 if scenario.tool_calls is not None and not scenario.tool_calls:
511 segs.append(("<minimax:tool_call>", True))
512 segs.append(("</minimax:tool_call>", True))
513 if scenario.content is not None:
514 segs.append((scenario.content, False))
515 if scenario.tool_calls:
516 segs.extend(_minimax_m2_tool_segments(scenario.tool_calls))
517 return segs
518
519
520 def _build_minimax_m2(scenario: Scenario, validate: bool = True) -> Sample:
521 expected_reasoning: str | None
522 if scenario.reasoning is not None:
523 expected_reasoning = scenario.reasoning.rstrip()
524 else:
525 expected_reasoning = ""
526
527 sample = _make_sample(
528 sample_id=f"minimax_m2-{scenario.id}",
529 description=scenario.description,
530 vocab=_MINIMAX_M2_VOCAB,
531 segments=_minimax_m2_segments(scenario),
532 expected_reasoning=expected_reasoning,
533 expected_content=_qwen3_expected_content(scenario),
534 expected_tool_calls=_expected_tc(scenario),
535 tools=_expected_tools(scenario),
536 )
537 if validate:
538 _validate_sample(sample, MinimaxM2Parser)
539 return sample
540
541
542 # ── Gemma4 (channel reasoning, custom arg format) ────────────────────
543
544 _GEMMA4_VOCAB: dict[str, int] = {
545 "<|channel>": 50,
546 "<channel|>": 51,
547 "<|tool_call>": 48,
548 "<tool_call|>": 49,
549 '<|"|>': 52,
550 "<|turn>": 53,
551 "<|tool_response>": 54,
552 }
553 _GEMMA4_THOUGHT_PREFIX = "thought\n"
554 _GEMMA4_QUOTE = '<|"|>'
555
556
557 def _gemma4_value_segments(value: Any) -> list[tuple[str, bool]]:
558 """Render a value in Gemma4 arg format as segments."""
559 if isinstance(value, str):
560 return [(_GEMMA4_QUOTE, True), (value, False), (_GEMMA4_QUOTE, True)]
561 if isinstance(value, bool):
562 return [("true" if value else "false", False)]
563 if isinstance(value, (int, float)):
564 return [(str(value), False)]
565 if isinstance(value, dict):
566 segs: list[tuple[str, bool]] = [("{", False)]
567 for i, (k, v) in enumerate(value.items()):
568 if i > 0:
569 segs.append((",", False))
570 segs.append((f"{k}:", False))
571 segs.extend(_gemma4_value_segments(v))
572 segs.append(("}", False))
573 return segs
574 if isinstance(value, list):
575 segs = [("[", False)]
576 for i, item in enumerate(value):
577 if i > 0:
578 segs.append((",", False))
579 segs.extend(_gemma4_value_segments(item))
580 segs.append(("]", False))
581 return segs
582 return [(json.dumps(value, ensure_ascii=False), False)]
583
584
585 def _gemma4_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
586 segs: list[tuple[str, bool]] = [
587 ("<|tool_call>", True),
588 (f"call:{tc.name}", False),
589 ("{", False),
590 ]
591 for i, (key, value) in enumerate(tc.arguments.items()):
592 if i > 0:
593 segs.append((",", False))
594 segs.append((f"{key}:", False))
595 segs.extend(_gemma4_value_segments(value))
596 segs.append(("}", False))
597 segs.append(("<tool_call|>", True))
598 return segs
599
600
601 def _gemma4_segments(scenario: Scenario) -> list[tuple[str, bool]]:
602 segs: list[tuple[str, bool]] = []
603 if scenario.reasoning is not None:
604 segs.append(("<|channel>", True))
605 segs.append((_GEMMA4_THOUGHT_PREFIX, False))
606 segs.append((scenario.reasoning, False))
607 segs.append(("<channel|>", True))
608 if scenario.tool_calls is not None and not scenario.tool_calls:
609 segs.append(("<|tool_call>", True))
610 segs.append(("<tool_call|>", True))
611 if scenario.content is not None:
612 segs.append((scenario.content, False))
613 if scenario.tool_calls:
614 for tc in scenario.tool_calls:
615 segs.extend(_gemma4_tool_segments(tc))
616 return segs
617
618
619 def _build_gemma4(scenario: Scenario, validate: bool = True) -> Sample:
620 prompt_token_ids = None
621 if scenario.after_tool_response:
622 prompt_token_ids = [_GEMMA4_VOCAB["<|tool_response>"]]
623 sample = _make_sample(
624 sample_id=f"gemma4-{scenario.id}",
625 description=scenario.description,
626 vocab=_GEMMA4_VOCAB,
627 segments=_gemma4_segments(scenario),
628 expected_reasoning=scenario.reasoning,
629 expected_content=_qwen3_expected_content(scenario),
630 expected_tool_calls=_expected_tc(scenario),
631 tools=_expected_tools(scenario),
632 prompt_token_ids=prompt_token_ids,
633 )
634 if validate:
635 _validate_sample(sample, Gemma4Parser)
636 return sample
637
638
639 def _build_nemotron_v3(scenario: Scenario, validate: bool = True) -> Sample:
640 return _build_qwen3(
641 scenario,
642 name="nemotron_v3",
643 parser_cls=NemotronV3Parser,
644 strip_trailing_ws=True,
645 validate=validate,
646 )
647
648
649 def _build_granite_thinking(scenario: Scenario, validate: bool = True) -> Sample:
650 """Granite 4.2: the Nemotron V3 grammar plus leading-newline stripping on
651 content after ``</think>``."""
652 sample = _build_qwen3(
653 scenario,
654 name="granite_thinking_parser",
655 parser_cls=GraniteThinkingParser,
656 strip_trailing_ws=True,
657 validate=False,
658 )
659 sample = replace(
660 sample,
661 expected_content=(
662 sample.expected_content.lstrip("\n")
663 if sample.expected_content
664 else sample.expected_content
665 ),
666 content_lstrip="\n",
667 )
668 if validate:
669 _validate_sample(sample, GraniteThinkingParser)
670 return sample
671
672
673 # ── Seed-OSS (Qwen3 XML grammar with Seed wrapper tokens) ────────────
674
675 _SEED_OSS_VOCAB: dict[str, int] = {
676 "<seed:think>": 50,
677 "</seed:think>": 51,
678 "<seed:tool_call>": 60,
679 "</seed:tool_call>": 61,
680 }
681
682
683 def _seed_oss_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
684 parts = [f"\n<function={tc.name}>"]
685 for key, value in tc.arguments.items():
686 parts.append(f"\n<parameter={key}>{_qwen3_arg_value(value)}</parameter>")
687 parts.append("\n</function>\n")
688 return [
689 ("<seed:tool_call>", True),
690 ("".join(parts), False),
691 ("</seed:tool_call>", True),
692 ]
693
694
695 def _seed_oss_segments(scenario: Scenario) -> list[tuple[str, bool]]:
696 segs: list[tuple[str, bool]] = []
697 if scenario.reasoning is not None:
698 segs.append((scenario.reasoning, False))
699 if scenario.content is not None or scenario.tool_calls is not None:
700 segs.append(("</seed:think>", True))
701 if scenario.tool_calls is not None and not scenario.tool_calls:
702 segs.append(("<seed:tool_call>", True))
703 segs.append(("</seed:tool_call>", True))
704 if scenario.content is not None:
705 segs.append((scenario.content, False))
706 if scenario.tool_calls:
707 for tc in scenario.tool_calls:
708 segs.extend(_seed_oss_tool_segments(tc))
709 return segs
710
711
712 def _build_seed_oss(scenario: Scenario, validate: bool = True) -> Sample:
713 sample = _make_sample(
714 sample_id=f"seed_oss-{scenario.id}",
715 description=scenario.description,
716 vocab=_SEED_OSS_VOCAB,
717 segments=_seed_oss_segments(scenario),
718 expected_reasoning=scenario.reasoning if scenario.reasoning is not None else "",
719 expected_content=_qwen3_expected_content(scenario),
720 expected_tool_calls=_expected_tc(scenario),
721 tools=_expected_tools(scenario),
722 )
723 if validate:
724 _validate_sample(sample, SeedOssParser)
725 return sample
726
727
728 # ── DeepSeek V4 (DSML tool format) ──────────────────────────────────
729
730 _DSML = "|DSML|"
731 _DSV4_VOCAB: dict[str, int] = {
732 "<think>": 128821,
733 "</think>": 128822,
734 f"<{_DSML}tool_calls>": 128823,
735 f"</{_DSML}tool_calls>": 128824,
736 }
737
738
739 def _dsv4_param_text(key: str, value: Any, dsml: str = _DSML) -> str:
740 is_string = isinstance(value, str)
741 if is_string:
742 val_str = value
743 elif isinstance(value, bool):
744 val_str = "true" if value else "false"
745 elif isinstance(value, (int, float)):
746 val_str = str(value)
747 else:
748 val_str = json.dumps(value, ensure_ascii=False)
749 string_attr = "true" if is_string else "false"
750 return (
751 f'<{dsml}parameter name="{key}" string="{string_attr}">'
752 f"{val_str}</{dsml}parameter>\n"
753 )
754
755
756 def _dsv4_tool_text(tc: ToolCallSpec, dsml: str = _DSML) -> str:
757 parts = [f'<{dsml}invoke name="{tc.name}">\n']
758 for key, value in tc.arguments.items():
759 parts.append(_dsv4_param_text(key, value, dsml))
760 parts.append(f"</{dsml}invoke>\n")
761 return "".join(parts)
762
763
764 def _dsml_tool_segs(
765 scenario: Scenario,
766 tag: str,
767 dsml: str = _DSML,
768 ) -> list[tuple[str, bool]]:
769 if not scenario.tool_calls:
770 return []
771 parts = ["\n"]
772 for tc in scenario.tool_calls:
773 parts.append(_dsv4_tool_text(tc, dsml))
774 return [
775 (f"<{dsml}{tag}>", True),
776 ("".join(parts), False),
777 (f"</{dsml}{tag}>", True),
778 ]
779
780
781 def _dsv4_segments(
782 scenario: Scenario, thinking: bool, *, v41: bool = False
783 ) -> list[tuple[str, bool]]:
784 segs: list[tuple[str, bool]] = []
785
786 if thinking:
787 if scenario.reasoning is not None:
788 segs.append((scenario.reasoning, False))
789 if scenario.content is not None or scenario.tool_calls:
790 segs.append(("</think>", True))
791 else:
792 if scenario.reasoning is not None:
793 segs.append(("<think>", True))
794 segs.append((scenario.reasoning, False))
795 segs.append(("</think>", True))
796
797 if scenario.content is not None:
798 segs.append((scenario.content, False))
799
800 segs.extend(
801 _dsml_tool_segs(scenario, "calls", f"{_DSML} ")
802 if v41
803 else _dsml_tool_segs(scenario, "tool_calls")
804 )
805 return segs
806
807
808 def _build_deepseek_v4(
809 scenario: Scenario, validate: bool = True, *, v41: bool = False
810 ) -> Sample:
811 thinking = scenario.reasoning is not None
812 chat_kwargs = {"thinking": thinking}
813 parser_cls = DeepSeekV41Parser if v41 else DeepSeekV4Parser
814 model = "deepseek_v41" if v41 else "deepseek_v4"
815 vocab = (
816 {
817 "<think>": 128821,
818 "</think>": 128822,
819 f"<{_DSML} calls>": 128823,
820 f"</{_DSML} calls>": 128824,
821 }
822 if v41
823 else _DSV4_VOCAB
824 )
825
826 if thinking:
827 expected_reasoning: str | None = scenario.reasoning or ""
828 else:
829 expected_reasoning = None
830
831 sample = _make_sample(
832 sample_id=f"{model}-{scenario.id}",
833 description=scenario.description,
834 vocab=vocab,
835 segments=_dsv4_segments(scenario, thinking, v41=v41),
836 expected_reasoning=expected_reasoning,
837 expected_content=_qwen3_expected_content(scenario),
838 expected_tool_calls=_expected_tc(scenario),
839 tools=_expected_tools(scenario),
840 chat_template_kwargs=chat_kwargs,
841 )
842 if validate:
843 kwargs = {}
844 if chat_kwargs:
845 kwargs["chat_template_kwargs"] = chat_kwargs
846 _validate_sample(sample, parser_cls, **kwargs)
847 return sample
848
849
850 # ── DeepSeek V3.2 (DSML tool format, no reasoning) ──────────────────
851
852 _DSV32_VOCAB: dict[str, int] = {
853 f"<{_DSML}function_calls>": 128830,
854 f"</{_DSML}function_calls>": 128831,
855 }
856
857
858 def _dsv32_segments(scenario: Scenario) -> list[tuple[str, bool]]:
859 segs: list[tuple[str, bool]] = []
860
861 if scenario.content is not None:
862 segs.append((scenario.content, False))
863
864 segs.extend(_dsml_tool_segs(scenario, "function_calls"))
865 return segs
866
867
868 def _build_deepseek_v32(scenario: Scenario, validate: bool = True) -> Sample | None:
869 if scenario.reasoning is not None:
870 return None
871
872 sample = _make_sample(
873 sample_id=f"deepseek_v32-{scenario.id}",
874 description=scenario.description,
875 vocab=_DSV32_VOCAB,
876 segments=_dsv32_segments(scenario),
877 expected_reasoning=None,
878 expected_content=_qwen3_expected_content(scenario),
879 expected_tool_calls=_expected_tc(scenario),
880 tools=_expected_tools(scenario),
881 )
882 if validate:
883 _validate_sample(sample, DeepSeekV32Parser)
884 return sample
885
886
887 # ── GLM-4.7 MoE (XML tool format, starts in REASONING) ──────────────
888
889 _GLM47_MOE_VOCAB: dict[str, int] = {
890 "<think>": 50,
891 "</think>": 51,
892 "<tool_call>": 60,
893 "</tool_call>": 61,
894 "<arg_key>": 62,
895 "</arg_key>": 63,
896 "<arg_value>": 64,
897 "</arg_value>": 65,
898 }
899
900
901 def _glm47_moe_arg_value(value: Any) -> str:
902 if isinstance(value, bool):
903 return "true" if value else "false"
904 if isinstance(value, (int, float)):
905 return str(value)
906 if isinstance(value, str):
907 return value
908 return json.dumps(value, ensure_ascii=False)
909
910
911 def _glm47_moe_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
912 segs: list[tuple[str, bool]] = [
913 ("<tool_call>", True),
914 (tc.name, False),
915 ]
916 for key, value in tc.arguments.items():
917 segs.extend(
918 [
919 ("<arg_key>", True),
920 (key, False),
921 ("</arg_key>", True),
922 ("<arg_value>", True),
923 (_glm47_moe_arg_value(value), False),
924 ("</arg_value>", True),
925 ]
926 )
927 segs.append(("</tool_call>", True))
928 return segs
929
930
931 def _glm47_moe_segments(scenario: Scenario) -> list[tuple[str, bool]]:
932 segs: list[tuple[str, bool]] = []
933 if scenario.reasoning is not None:
934 segs.append((scenario.reasoning, False))
935 if scenario.content is not None or scenario.tool_calls:
936 segs.append(("</think>", True))
937 if scenario.content is not None:
938 segs.append((scenario.content, False))
939 if scenario.tool_calls:
940 for tc in scenario.tool_calls:
941 segs.extend(_glm47_moe_tool_segments(tc))
942 return segs
943
944
945 def _build_glm47_moe(scenario: Scenario, validate: bool = True) -> Sample:
946 sample = _make_sample(
947 sample_id=f"glm47_moe-{scenario.id}",
948 description=scenario.description,
949 vocab=_GLM47_MOE_VOCAB,
950 segments=_glm47_moe_segments(scenario),
951 expected_reasoning=scenario.reasoning if scenario.reasoning is not None else "",
952 expected_content=_qwen3_expected_content(scenario),
953 expected_tool_calls=_expected_tc(scenario),
954 tools=_expected_tools(scenario),
955 )
956 if validate:
957 _validate_sample(sample, Glm47MoeParser)
958 return sample
959
960
961 # ── Kimi K2 (native tool-call section, starts in REASONING) ──────────
962
963 _KIMI_K2_VOCAB: dict[str, int] = {
964 "<think>": 50,
965 "</think>": 51,
966 "<|tool_calls_section_begin|>": 60,
967 "<|tool_calls_section_end|>": 61,
968 "<|tool_call_begin|>": 62,
969 "<|tool_call_end|>": 63,
970 "<|tool_call_argument_begin|>": 64,
971 }
972
973
974 def _kimi_k2_tool_segments(
975 tool_calls: list[ToolCallSpec],
976 ) -> list[tuple[str, bool]]:
977 segs: list[tuple[str, bool]] = [("<|tool_calls_section_begin|>", True)]
978 for index, tc in enumerate(tool_calls):
979 args = json.dumps(tc.arguments, ensure_ascii=False, separators=(",", ":"))
980 segs.extend(
981 [
982 ("<|tool_call_begin|>", True),
983 (f"functions.{tc.name}:{index}\n", False),
984 ("<|tool_call_argument_begin|>", True),
985 (args, False),
986 ("<|tool_call_end|>", True),
987 ]
988 )
989 segs.append(("<|tool_calls_section_end|>", True))
990 return segs
991
992
993 def _kimi_k2_segments(scenario: Scenario) -> list[tuple[str, bool]]:
994 segs: list[tuple[str, bool]] = []
995 if scenario.reasoning is not None:
996 segs.append(("<think>", True))
997 segs.append((scenario.reasoning, False))
998 if scenario.content is not None or scenario.tool_calls is not None:
999 segs.append(("</think>", True))
1000 if scenario.content is not None:
1001 segs.append((scenario.content, False))
1002 if scenario.tool_calls is not None:
1003 segs.extend(_kimi_k2_tool_segments(scenario.tool_calls))
1004 return segs
1005
1006
1007 def _build_kimi_k2(
1008 scenario: Scenario,
1009 validate: bool = True,
1010 thinking: bool = True,
1011 ) -> Sample:
1012 expected_reasoning = (
1013 scenario.reasoning.rstrip()
1014 if (thinking and scenario.reasoning is not None)
1015 else None
1016 )
1017 if thinking and scenario.reasoning is None:
1018 expected_reasoning = ""
1019
1020 sample = _make_sample(
1021 sample_id=f"kimi_k2-{scenario.id}",
1022 description=scenario.description,
1023 vocab=_KIMI_K2_VOCAB,
1024 segments=_kimi_k2_segments(scenario),
1025 expected_reasoning=expected_reasoning,
1026 expected_content=_qwen3_expected_content(scenario),
1027 expected_tool_calls=_expected_tc(scenario),
1028 tools=_expected_tools(scenario),
1029 chat_template_kwargs=None if thinking else {"thinking": False},
1030 )
1031 if validate:
1032 _validate_sample(
1033 sample,
1034 KimiK2Parser,
1035 chat_template_kwargs=sample.chat_template_kwargs,
1036 )
1037 return sample
1038
1039
1040 _KIMI_K2_SCENARIOS = [
1041 *SCENARIOS,
1042 Scenario(
1043 id="trailing-reasoning-whitespace",
1044 description="Reasoning trailing whitespace is stripped",
1045 reasoning="Reasoning with trailing whitespace. \n\t",
1046 content="Done.",
1047 ),
1048 ]
1049
1050
1051 # ── Inkling (typed content blocks, JSON tool payloads) ───────────────────
1052
1053 _TML_VOCAB: dict[str, int] = {
1054 "<|message_model|>": 200001,
1055 "<|content_text|>": 200004,
1056 "<|content_model_end_sampling|>": 200006,
1057 "<|content_thinking|>": 200008,
1058 "<|end_message|>": 200010,
1059 "<|content_tool_error|>": 200022,
1060 "<|content_invoke_tool_json|>": 200049,
1061 "<|content_invoke_tool_text|>": 200057,
1062 }
1063
1064
1065 def _inkling_block(
1066 segs: list[tuple[str, bool]],
1067 kind_token: str,
1068 body: str,
1069 ) -> None:
1070 """Append one Inkling content block; blocks after the first start with
1071 the ``<|message_model|>`` role token (the first block continues the
1072 generation prompt directly)."""
1073 if segs:
1074 segs.append(("<|message_model|>", True))
1075 segs.append((kind_token, True))
1076 if body:
1077 segs.append((body, False))
1078 segs.append(("<|end_message|>", True))
1079
1080
1081 def _inkling_segments(scenario: Scenario) -> list[tuple[str, bool]]:
1082 segs: list[tuple[str, bool]] = []
1083 if scenario.reasoning is not None:
1084 _inkling_block(segs, "<|content_thinking|>", scenario.reasoning)
1085 if scenario.tool_calls is not None and not scenario.tool_calls:
1086 _inkling_block(segs, "<|content_invoke_tool_json|>", "")
1087 if scenario.content is not None:
1088 _inkling_block(segs, "<|content_text|>", scenario.content)
1089 if scenario.tool_calls:
1090 for tc in scenario.tool_calls:
1091 args = json.dumps(tc.arguments, ensure_ascii=False, separators=(",", ":"))
1092 payload = f'{{"name":"{tc.name}","args":{args}}}'
1093 _inkling_block(segs, "<|content_invoke_tool_json|>", payload)
1094 return segs
1095
1096
1097 def _build_inkling(scenario: Scenario, validate: bool = True) -> Sample:
1098 prompt_token_ids = None
1099 if scenario.after_tool_response:
1100 # Prompt ends with a closed tool-response block and the
1101 # generation-prompt role token.
1102 prompt_token_ids = [
1103 _TML_VOCAB["<|end_message|>"],
1104 _TML_VOCAB["<|message_model|>"],
1105 ]
1106 sample = _make_sample(
1107 sample_id=f"inkling-{scenario.id}",
1108 description=scenario.description,
1109 vocab=_TML_VOCAB,
1110 segments=_inkling_segments(scenario),
1111 expected_reasoning=scenario.reasoning,
1112 expected_content=_qwen3_expected_content(scenario),
1113 expected_tool_calls=_expected_tc(scenario),
1114 tools=_expected_tools(scenario),
1115 prompt_token_ids=prompt_token_ids,
1116 )
1117 if validate:
1118 _validate_sample(sample, InklingParser)
1119 return sample
1120
1121
1122 # ── PLaMo3 (literal nested tool-request wrappers) ───────────────────
1123
1124 _PLAMO3_MARKERS = (
1125 plamo3.BEGIN_THINK,
1126 plamo3.END_THINK,
1127 plamo3.BEGIN_TOOL_REQUESTS,
1128 plamo3.END_TOOL_REQUESTS,
1129 plamo3.BEGIN_TOOL_REQUEST,
1130 plamo3.END_TOOL_REQUEST,
1131 plamo3.BEGIN_TOOL_NAME,
1132 plamo3.END_TOOL_NAME,
1133 plamo3.BEGIN_TOOL_ARGUMENTS,
1134 plamo3.END_TOOL_ARGUMENTS,
1135 plamo3.EOT,
1136 )
1137 _PLAMO3_VOCAB = {marker: 300 + i for i, marker in enumerate(_PLAMO3_MARKERS)}
1138
1139
1140 def _plamo3_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
1141 args = json.dumps(tc.arguments, ensure_ascii=False, separators=(",", ":"))
1142 return [
1143 (plamo3.BEGIN_TOOL_REQUEST, True),
1144 (plamo3.BEGIN_TOOL_NAME, True),
1145 (tc.name, False),
1146 (plamo3.END_TOOL_NAME, True),
1147 (plamo3.BEGIN_TOOL_ARGUMENTS, True),
1148 (args, False),
1149 (plamo3.END_TOOL_ARGUMENTS, True),
1150 (plamo3.END_TOOL_REQUEST, True),
1151 ]
1152
1153
1154 def _plamo3_segments(scenario: Scenario) -> list[tuple[str, bool]]:
1155 segs: list[tuple[str, bool]] = [
1156 (plamo3.BEGIN_THINK, True),
1157 (scenario.reasoning or "", False),
1158 (plamo3.END_THINK, True),
1159 ]
1160 if scenario.content is not None:
1161 segs.append((scenario.content, False))
1162 if scenario.tool_calls is not None:
1163 segs.append((plamo3.BEGIN_TOOL_REQUESTS, True))
1164 for tool_call in scenario.tool_calls:
1165 segs.extend(_plamo3_tool_segments(tool_call))
1166 segs.append((plamo3.END_TOOL_REQUESTS, True))
1167 return segs
1168
1169
1170 def _build_plamo3(scenario: Scenario, validate: bool = True) -> Sample:
1171 sample = _make_sample(
1172 sample_id=f"plamo3-{scenario.id}",
1173 description=scenario.description,
1174 vocab=_PLAMO3_VOCAB,
1175 segments=_plamo3_segments(scenario),
1176 expected_reasoning=scenario.reasoning or "",
1177 expected_content=_qwen3_expected_content(scenario),
1178 expected_tool_calls=_expected_tc(scenario),
1179 tools=_expected_tools(scenario),
1180 )
1181 if validate:
1182 _validate_sample(sample, Plamo3Parser)
1183 return sample
1184
1185
1186 # ── Granite (JSON-array tool bodies, no reasoning) ───────────────────────
1187
1188 _GRANITE_VOCAB: dict[str, int] = {
1189 "<|tool_call|>": 49154,
1190 }
1191
1192
1193 def _granite_segments(scenario: Scenario) -> list[tuple[str, bool]]:
1194 segs: list[tuple[str, bool]] = []
1195 if scenario.content:
1196 # Granite has no reasoning; prose is plain content preceding the marker.
1197 segs.append((scenario.content, False))
1198 if scenario.tool_calls:
1199 segs.append(("<|tool_call|>", True))
1200 payload = json.dumps(
1201 [
1202 {"name": tc.name, "arguments": tc.arguments}
1203 for tc in scenario.tool_calls
1204 ],
1205 ensure_ascii=False,
1206 separators=(", ", ": "),
1207 )
1208 segs.append((" " + payload, False))
1209 return segs
1210
1211
1212 def _granite_expected_content(scenario: Scenario) -> str | None:
1213 if scenario.tool_calls:
1214 if not scenario.content:
1215 return None
1216 return scenario.content.strip() or None
1217 return scenario.content
1218
1219
1220 def _build_granite(scenario: Scenario, validate: bool = True) -> Sample:
1221 sample = _make_sample(
1222 sample_id=f"granite-{scenario.id}",
1223 description=scenario.description,
1224 vocab=_GRANITE_VOCAB,
1225 segments=_granite_segments(scenario),
1226 expected_reasoning=None,
1227 expected_content=_granite_expected_content(scenario),
1228 expected_tool_calls=_expected_tc(scenario),
1229 tools=_expected_tools(scenario),
1230 )
1231 if validate:
1232 _validate_sample(sample, GraniteParser)
1233 return sample
1234
1235
1236 # Granite has no reasoning, so the shared reasoning-centric SCENARIOS do not
1237 # apply; these exercise the JSON-array tool body (single, parallel, surrounding
1238 # text) instead.
1239 _GRANITE_SCENARIOS: list[Scenario] = [
1240 Scenario(
1241 id="single-tool",
1242 description="Single tool call",
1243 tool_calls=[_READ_TOOL],
1244 ),
1245 Scenario(
1246 id="parallel-tools",
1247 description="Parallel tool calls in one JSON array",
1248 tool_calls=[_BASH_TOOL, _WEATHER_TOOL],
1249 ),
1250 Scenario(
1251 id="complex-json-args",
1252 description="Tool call with nested objects, arrays, numbers, booleans",
1253 tool_calls=[_COMPLEX_TOOL],
1254 ),
1255 Scenario(
1256 id="content-only",
1257 description="Plain content response without tool calls",
1258 content="Hello! How can I help you today?",
1259 ),
1260 Scenario(
1261 id="surrounding-text",
1262 description="Prose content preceding the tool call",
1263 content="Let me check the weather.",
1264 tool_calls=[_WEATHER_TOOL],
1265 ),
1266 ]
1267
1268
1269 # ── Registry and public API ──────────────────────────────────────────
1270
1271 _BUILDERS: dict[str, Any] = {
1272 "deepseek_v32": _build_deepseek_v32,
1273 "deepseek_v4": _build_deepseek_v4,
1274 "deepseek_v41": functools.partial(_build_deepseek_v4, v41=True),
1275 "gemma4": _build_gemma4,
1276 "granite": _build_granite,
1277 "minimax_m2": _build_minimax_m2,
1278 "nemotron_v3": _build_nemotron_v3,
1279 "granite_thinking_parser": _build_granite_thinking,
1280 "seed_oss": _build_seed_oss,
1281 "glm47_moe": _build_glm47_moe,
1282 "kimi_k2": _build_kimi_k2,
1283 "qwen3": _build_qwen3,
1284 "mimo": _build_mimo,
1285 "step3p5": _build_step3p5,
1286 "inkling": _build_inkling,
1287 "plamo3": _build_plamo3,
1288 }
1289
1290
1291 @functools.cache
1292 def build_samples(model: str) -> tuple[Sample, ...]:
1293 """Build all scenario samples for a model, self-validated."""
1294 builder = _BUILDERS[model]
1295 scenarios = _KIMI_K2_SCENARIOS if model == "kimi_k2" else SCENARIOS
1296 return tuple(s for s in (builder(sc) for sc in scenarios) if s is not None)