renderers 0.1.9.dev8__tar.gz → 0.1.9.dev10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/PKG-INFO +1 -1
  2. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/_version.py +2 -2
  3. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/base.py +10 -2
  4. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/client.py +12 -17
  5. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/parsing.py +44 -1
  6. renderers-0.1.9.dev10/tests/test_glm_tool_name_validation.py +183 -0
  7. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/.github/workflows/publish-dev.yml +0 -0
  8. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/.github/workflows/publish.yml +0 -0
  9. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/.github/workflows/style.yml +0 -0
  10. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/.github/workflows/test.yml +0 -0
  11. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/.gitignore +0 -0
  12. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/.pre-commit-config.yaml +0 -0
  13. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/LICENSE +0 -0
  14. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/README.md +0 -0
  15. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/docs/renderer-config.md +0 -0
  16. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/examples/README.md +0 -0
  17. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/examples/sglang/multiturn_generate_sglang.py +0 -0
  18. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/examples/sglang/online_multiturn_sglang.py +0 -0
  19. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/examples/tinker/multiturn_generate_tinker.py +0 -0
  20. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/examples/transformers/multiturn_generate_transformers.py +0 -0
  21. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/examples/vllm/multiturn_generate_vllm.py +0 -0
  22. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/pyproject.toml +0 -0
  23. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/__init__.py +0 -0
  24. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/configs.py +0 -0
  25. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/deepseek_r1.py +0 -0
  26. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/deepseek_v3.py +0 -0
  27. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/default.py +0 -0
  28. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/glm45.py +0 -0
  29. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/glm5.py +0 -0
  30. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/gpt_oss.py +0 -0
  31. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/hy3.py +0 -0
  32. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/kimi_k2.py +0 -0
  33. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/kimi_k25.py +0 -0
  34. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/laguna_xs2.py +0 -0
  35. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/llama_3.py +0 -0
  36. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/minimax_m2.py +0 -0
  37. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/nemotron3.py +0 -0
  38. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/parsers.py +0 -0
  39. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/prime_qwen3.py +0 -0
  40. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/qwen3.py +0 -0
  41. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/qwen35.py +0 -0
  42. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/qwen36.py +0 -0
  43. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/renderers/qwen3_vl.py +0 -0
  44. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/conftest.py +0 -0
  45. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_bridge.py +0 -0
  46. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_build_helpers.py +0 -0
  47. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_client.py +0 -0
  48. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_deepseek_r1.py +0 -0
  49. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_disabled_thinking_stability.py +0 -0
  50. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_gpt_oss_harmony_parity.py +0 -0
  51. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_hy3.py +0 -0
  52. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_incremental.py +0 -0
  53. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_is_content.py +0 -0
  54. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_kimi_k25_tool_schema.py +0 -0
  55. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_laguna_xs21.py +0 -0
  56. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_llama_3.py +0 -0
  57. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_load_tokenizer.py +0 -0
  58. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_message_indices.py +0 -0
  59. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_message_tool_names.py +0 -0
  60. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_multimodal.py +0 -0
  61. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_nemotron3_parity.py +0 -0
  62. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_nemotron3_ultra.py +0 -0
  63. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_parse_response.py +0 -0
  64. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_parse_response_robustness.py +0 -0
  65. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_parsers.py +0 -0
  66. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_preserve_thinking.py +0 -0
  67. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_prime_qwen3_parity.py +0 -0
  68. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_qwen35_size_coverage.py +0 -0
  69. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_render_ids.py +0 -0
  70. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_renderer_config.py +0 -0
  71. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_renderer_config_parity.py +0 -0
  72. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_roundtrip.py +0 -0
  73. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_sampled_mask.py +0 -0
  74. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_tokens_per_message.py +0 -0
  75. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/tests/test_tool_arg_type_preservation.py +0 -0
  76. {renderers-0.1.9.dev8 → renderers-0.1.9.dev10}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: renderers
3
- Version: 0.1.9.dev8
3
+ Version: 0.1.9.dev10
4
4
  Summary: Chat template renderers — deterministic message-to-token conversion for LLM training
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.9.dev8'
22
- __version_tuple__ = version_tuple = (0, 1, 9, 'dev8')
21
+ __version__ = version = '0.1.9.dev10'
22
+ __version_tuple__ = version_tuple = (0, 1, 9, 'dev10')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -558,8 +558,15 @@ class ToolCallParseStatus(str, enum.Enum):
558
558
  """Per-attempt outcome of parsing a single ``<tool_call>`` block.
559
559
 
560
560
  The renderer parser's job is JSON-syntax → ``dict`` (the parser-level
561
- contract). Schema validation — required fields, argument types, tool
562
- name lookup — is the *tool*'s job and is intentionally not done here.
561
+ contract). Schema validation — required fields, argument types — is
562
+ the *tool*'s job and is intentionally not done here. Tool-*name*
563
+ lookup is the one exception, and only where the reference inference
564
+ parser does it: vLLM ≥ 0.24 aliases ``glm45``/``glm47`` to a parser
565
+ with ``validate_tool_names=True`` that silently drops any call whose
566
+ name isn't in the request's tool list. ``parse_glm`` mirrors that as
567
+ ``UNKNOWN_TOOL`` (when ``tools`` is passed) so train-side parsing
568
+ agrees with what an eval client sees from the engine — but keeps the
569
+ attempt visible instead of swallowing it.
563
570
  See ``ParsedToolCall.status`` for what each value means.
564
571
 
565
572
  Diverges from vLLM/SGLang on purpose. Both engines collapse parse
@@ -576,6 +583,7 @@ class ToolCallParseStatus(str, enum.Enum):
576
583
  UNCLOSED_BLOCK = "unclosed_block" # opening delim hit EOS / stop
577
584
  MISSING_NAME = "missing_name" # parsed structurally, but no function name
578
585
  MALFORMED_STRUCTURE = "malformed_structure" # format-specific shape error
586
+ UNKNOWN_TOOL = "unknown_tool" # name not in the provided tools list
579
587
 
580
588
 
581
589
  @dataclass
@@ -1,4 +1,4 @@
1
- """Renderer-based generate client for vLLM 0.20's /inference/v1/generate.
1
+ """Renderer-based generate client for vLLM's /inference/v1/generate.
2
2
 
3
3
  messages → Renderer.render_ids() → token IDs → POST /inference/v1/generate
4
4
  → completion tokens → Renderer.parse_response() → structured message
@@ -58,8 +58,7 @@ class OverlongPromptError(Exception):
58
58
  self.prompt_len = prompt_len
59
59
  self.max_prompt_len = max_prompt_len
60
60
  super().__init__(
61
- f"Prompt length ({prompt_len}) exceeds maximum "
62
- f"context length ({max_prompt_len})."
61
+ f"Prompt length ({prompt_len}) exceeds maximum context length ({max_prompt_len})."
63
62
  )
64
63
 
65
64
 
@@ -187,32 +186,26 @@ def _parse_completion_logprobs(
187
186
  expected_token = f"token_id:{completion_ids[index]}"
188
187
  if entry.get("token") != expected_token:
189
188
  raise MalformedGenerateResponseError(
190
- "Engine response "
191
- f"choice.logprobs.content[{index}].token must be {expected_token!r}."
189
+ f"Engine response choice.logprobs.content[{index}].token must be {expected_token!r}."
192
190
  )
193
191
  raw_logprob = entry.get("logprob")
194
192
  if isinstance(raw_logprob, bool) or not isinstance(raw_logprob, (int, float)):
195
193
  raise MalformedGenerateResponseError(
196
- "Engine response "
197
- f"choice.logprobs.content[{index}].logprob must be a number."
194
+ f"Engine response choice.logprobs.content[{index}].logprob must be a number."
198
195
  )
199
196
  try:
200
197
  logprob = float(raw_logprob)
201
198
  except OverflowError as exc:
202
199
  raise MalformedGenerateResponseError(
203
- "Engine response "
204
- f"choice.logprobs.content[{index}].logprob must be finite."
200
+ f"Engine response choice.logprobs.content[{index}].logprob must be finite."
205
201
  ) from exc
206
202
  if not math.isfinite(logprob):
207
203
  raise MalformedGenerateResponseError(
208
- "Engine response "
209
- f"choice.logprobs.content[{index}].logprob must be finite."
204
+ f"Engine response choice.logprobs.content[{index}].logprob must be finite."
210
205
  )
211
206
  if logprob == VLLM_LOGPROB_SENTINEL:
212
207
  raise MalformedGenerateResponseError(
213
- "Engine response "
214
- f"choice.logprobs.content[{index}].logprob does not contain "
215
- "sampling evidence."
208
+ f"Engine response choice.logprobs.content[{index}].logprob does not contain sampling evidence."
216
209
  )
217
210
  completion_logprobs.append(logprob)
218
211
  return completion_logprobs
@@ -424,9 +417,9 @@ def _build_mm_features(
424
417
  model-family specific. For now we dispatch on the renderer class;
425
418
  extend the dispatch table as more multimodal renderers land.
426
419
 
427
- NOTE — future engine pluggability: this encoder is vLLM 0.20-specific
420
+ NOTE — future engine pluggability: this encoder is vLLM-specific
428
421
  (uses ``vllm.multimodal.inputs.MultiModalKwargsItems``,
429
- ``vllm.entrypoints.serve.disagg.mm_serde.encode_mm_kwargs_item``, and
422
+ ``vllm.entrypoints.scale_out.token_in_token_out.mm_serde.encode_mm_kwargs_item``, and
430
423
  ``_create_qwen2vl_field_factory``). When a second inference engine
431
424
  arrives (SGLang, MAX, ...) the renderer client should be parameterized
432
425
  on engine: either (a) move the encoder onto the renderer as
@@ -473,7 +466,9 @@ def _build_qwen_vl_features(
473
466
  try:
474
467
  import torch
475
468
  from transformers.feature_extraction_utils import BatchFeature
476
- from vllm.entrypoints.serve.disagg.mm_serde import encode_mm_kwargs_item
469
+ from vllm.entrypoints.scale_out.token_in_token_out.mm_serde import (
470
+ encode_mm_kwargs_item,
471
+ )
477
472
  from vllm.model_executor.models.qwen2_vl import _create_qwen2vl_field_factory
478
473
  from vllm.multimodal.inputs import MultiModalKwargsItems
479
474
  except ImportError as exc:
@@ -59,6 +59,26 @@ def _build_param_type_index(
59
59
  return index
60
60
 
61
61
 
62
+ def _extract_tool_names(tools: list[ToolSpec] | None) -> set[str] | None:
63
+ """Set of declared tool names, or ``None`` when ``tools`` is empty.
64
+
65
+ ``None`` disables name validation — mirroring vLLM's
66
+ ``ParserEngine._is_valid_tool_name``, which returns ``True`` whenever
67
+ the request carries no tools. Accepts both flat ``ToolSpec`` and the
68
+ OpenAI ``{"type": "function", "function": {...}}`` envelope, like
69
+ ``_build_param_type_index`` (but independent of it: a tool with no
70
+ ``parameters.properties`` still counts as a known name).
71
+ """
72
+ if not tools:
73
+ return None
74
+ names: set[str] = set()
75
+ for tool in tools:
76
+ spec = tool.get("function", tool) if isinstance(tool, dict) else None
77
+ if isinstance(spec, dict) and isinstance(spec.get("name"), str):
78
+ names.add(spec["name"])
79
+ return names
80
+
81
+
62
82
  def _coerce_arg_value(
63
83
  text: str, param_schema: dict[str, Any] | None
64
84
  ) -> tuple[Any, bool]:
@@ -434,7 +454,23 @@ def parse_glm(
434
454
  arg_value_end_id: int,
435
455
  tools: list[ToolSpec] | None = None,
436
456
  ) -> ParsedResponse:
437
- """Parse GLM completion tokens. Token-level thinking + arg_key/arg_value tool calls."""
457
+ """Parse GLM completion tokens. Token-level thinking + arg_key/arg_value tool calls.
458
+
459
+ When ``tools`` is passed, tool names are validated against it: a call
460
+ whose name isn't declared gets ``status=UNKNOWN_TOOL`` instead of
461
+ ``OK``. This mirrors vLLM ≥ 0.24, where the ``glm45``/``glm47`` tool
462
+ parsers run with ``validate_tool_names=True`` and silently drop
463
+ unknown-name calls (``vllm/parser/glm47_moe.py``,
464
+ ``ParserEngine._is_valid_tool_name``) — so a completion that yields no
465
+ tool call from the engine also yields no ``OK`` call here, and
466
+ downstream finish-reason promotion (``renderers/client.py``) agrees
467
+ with an OpenAI chat-completions client talking to the same engine.
468
+ Notably this covers the missing-``<arg_key>`` shape
469
+ (``<tool_call>bash\\n<arg_value>...</arg_value></tool_call>``): both
470
+ this parser and vLLM's resolve the whole block as the name, which then
471
+ fails validation. Without ``tools``, no validation happens (vLLM
472
+ behaves the same when the request carries no tools).
473
+ """
438
474
  ids = _strip_stop_tokens(token_ids, stop_ids)
439
475
 
440
476
  reasoning = None
@@ -468,6 +504,7 @@ def parse_glm(
468
504
  arg_value_end_id,
469
505
  section_offset=parse_offset + tc_start,
470
506
  param_index=_build_param_type_index(tools),
507
+ known_names=_extract_tool_names(tools),
471
508
  )
472
509
  else:
473
510
  content_text = _decode(tokenizer, ids).strip()
@@ -491,6 +528,7 @@ def _parse_glm_tool_calls(
491
528
  *,
492
529
  section_offset: int,
493
530
  param_index: dict[str, dict[str, dict[str, Any]]],
531
+ known_names: set[str] | None = None,
494
532
  ) -> list[ParsedToolCall]:
495
533
  """Parse GLM-style tool calls: name + arg_key/arg_value pairs, all by token ID."""
496
534
  tool_calls: list[ParsedToolCall] = []
@@ -548,6 +586,11 @@ def _parse_glm_tool_calls(
548
586
  j += 1
549
587
  if not name:
550
588
  status = ToolCallParseStatus.MISSING_NAME
589
+ elif known_names is not None and name not in known_names:
590
+ # vLLM ≥ 0.24 drops the call entirely here; we keep the
591
+ # attempt visible but deny it OK so consumers agree with
592
+ # the engine on "no tool was called."
593
+ status = ToolCallParseStatus.UNKNOWN_TOOL
551
594
  elif structure_broke:
552
595
  status = ToolCallParseStatus.MALFORMED_STRUCTURE
553
596
  elif any_json_fallback:
@@ -0,0 +1,183 @@
1
+ """GLM tool-name validation against the provided tools list.
2
+
3
+ vLLM 0.24 re-aliased ``glm45`` from the lenient ``Glm4MoeModelToolParser``
4
+ to ``Glm47MoeModelToolParser``, whose engine config sets
5
+ ``validate_tool_names=True`` (``vllm/parser/glm47_moe.py``): a completed
6
+ ``<tool_call>`` block whose name isn't in the request's tool list is
7
+ silently dropped — no tool call is emitted and the response reads as a
8
+ voluntary stop. Before this, unknown-name calls were surfaced to the
9
+ harness, which answered with a recoverable "unknown tool" error.
10
+
11
+ ``parse_glm`` mirrors the ≥ 0.24 behavior when ``tools`` is passed:
12
+ unknown names get ``status=UNKNOWN_TOOL`` — never ``OK`` — so the
13
+ client-side stop→tool_calls finish-reason promotion
14
+ (``renderers/client.py``) agrees with what an OpenAI chat-completions
15
+ client sees from the engine, while the attempt itself stays visible for
16
+ verifier / RL-loss code. Without ``tools`` there is no validation, also
17
+ matching vLLM (``ParserEngine._is_valid_tool_name`` returns ``True``
18
+ when the request has no tools).
19
+
20
+ Cross-validated end-to-end against the real vLLM v0.23.0 and v0.24.0
21
+ parsers (vendored from the release tags) on identical completions.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from functools import lru_cache
27
+
28
+ from renderers.base import ToolCallParseStatus
29
+
30
+ # Both GLM renderers share ``parse_glm`` and are served by the same
31
+ # strict vLLM parser (the ``glm45`` and ``glm47`` aliases both resolve
32
+ # to ``Glm47MoeModelToolParser`` in vLLM ≥ 0.24).
33
+ _MODELS = [
34
+ ("THUDM/GLM-4.5-Air", "auto"),
35
+ ("zai-org/GLM-5", "auto"),
36
+ ]
37
+
38
+ _TOOLS = [
39
+ {
40
+ "name": "bash",
41
+ "description": "Run a shell command.",
42
+ "parameters": {
43
+ "type": "object",
44
+ "properties": {
45
+ "command": {"type": "string"},
46
+ "timeout": {"type": "integer"},
47
+ },
48
+ "required": ["command"],
49
+ },
50
+ },
51
+ # A declared tool with no parameters must still count as a known
52
+ # name (name lookup is independent of the param-type index).
53
+ {"name": "submit", "description": "Finish the episode."},
54
+ ]
55
+
56
+
57
+ @lru_cache(maxsize=None)
58
+ def _load(model: str, renderer_name: str):
59
+ from renderers import config_from_name, create_renderer
60
+ from renderers.base import load_tokenizer
61
+
62
+ tok = load_tokenizer(model)
63
+ return tok, create_renderer(tok, config_from_name(renderer_name))
64
+
65
+
66
+ def pytest_generate_tests(metafunc):
67
+ if "model" in metafunc.fixturenames:
68
+ metafunc.parametrize(
69
+ "model,renderer_name",
70
+ _MODELS,
71
+ ids=[m for m, _ in _MODELS],
72
+ )
73
+
74
+
75
+ def _parse(model: str, renderer_name: str, text: str, tools):
76
+ tok, renderer = _load(model, renderer_name)
77
+ ids = tok.encode(text, add_special_tokens=False)
78
+ return renderer.parse_response(ids, tools=tools)
79
+
80
+
81
+ def _statuses(parsed):
82
+ return [tc.status for tc in parsed.tool_calls]
83
+
84
+
85
+ def test_known_name_is_ok(model, renderer_name):
86
+ parsed = _parse(
87
+ model,
88
+ renderer_name,
89
+ "<tool_call>bash\n"
90
+ "<arg_key>command</arg_key>\n<arg_value>pwd</arg_value>\n"
91
+ "</tool_call>",
92
+ _TOOLS,
93
+ )
94
+ assert _statuses(parsed) == [ToolCallParseStatus.OK]
95
+ assert parsed.tool_calls[0].name == "bash"
96
+ assert parsed.tool_calls[0].arguments == {"command": "pwd"}
97
+
98
+
99
+ def test_known_name_without_parameters_is_ok(model, renderer_name):
100
+ parsed = _parse(model, renderer_name, "<tool_call>submit\n</tool_call>", _TOOLS)
101
+ assert _statuses(parsed) == [ToolCallParseStatus.OK]
102
+ assert parsed.tool_calls[0].name == "submit"
103
+
104
+
105
+ def test_unknown_name_is_flagged(model, renderer_name):
106
+ parsed = _parse(
107
+ model,
108
+ renderer_name,
109
+ "<tool_call>read\n"
110
+ "<arg_key>lines</arg_key>\n<arg_value>10</arg_value>\n"
111
+ "</tool_call>",
112
+ _TOOLS,
113
+ )
114
+ assert _statuses(parsed) == [ToolCallParseStatus.UNKNOWN_TOOL]
115
+ # The attempt stays visible (unlike vLLM's silent drop): name and
116
+ # parsed arguments are preserved for verifier / RL-loss consumers.
117
+ assert parsed.tool_calls[0].name == "read"
118
+ assert parsed.tool_calls[0].arguments == {"lines": 10}
119
+
120
+
121
+ def test_unknown_name_without_args_is_flagged(model, renderer_name):
122
+ parsed = _parse(model, renderer_name, "<tool_call>finish\n</tool_call>", _TOOLS)
123
+ assert _statuses(parsed) == [ToolCallParseStatus.UNKNOWN_TOOL]
124
+
125
+
126
+ def test_missing_arg_key_block_is_flagged(model, renderer_name):
127
+ # No <arg_key> token ⇒ the whole block resolves as the name — both
128
+ # here and in vLLM's engine (unmatched terminals in TOOL_NAME state
129
+ # accumulate into the name) — and then fails validation.
130
+ parsed = _parse(
131
+ model,
132
+ renderer_name,
133
+ "<tool_call>bash\n<arg_value>pwd</arg_value>\n</tool_call>",
134
+ _TOOLS,
135
+ )
136
+ assert _statuses(parsed) == [ToolCallParseStatus.UNKNOWN_TOOL]
137
+ assert "bash" in (parsed.tool_calls[0].name or "")
138
+
139
+
140
+ def test_mixed_calls_flag_only_unknown(model, renderer_name):
141
+ parsed = _parse(
142
+ model,
143
+ renderer_name,
144
+ "<tool_call>bash\n"
145
+ "<arg_key>command</arg_key>\n<arg_value>ls</arg_value>\n"
146
+ "</tool_call>"
147
+ "<tool_call>read\n"
148
+ "<arg_key>path</arg_key>\n<arg_value>x</arg_value>\n"
149
+ "</tool_call>",
150
+ _TOOLS,
151
+ )
152
+ assert _statuses(parsed) == [
153
+ ToolCallParseStatus.OK,
154
+ ToolCallParseStatus.UNKNOWN_TOOL,
155
+ ]
156
+
157
+
158
+ def test_no_tools_means_no_validation(model, renderer_name):
159
+ # vLLM skips name validation when the request carries no tools; so
160
+ # do we — this also keeps tools-less parse_response calls (the
161
+ # common test / SFT path) byte-for-byte backward compatible.
162
+ parsed = _parse(
163
+ model,
164
+ renderer_name,
165
+ "<tool_call>read\n"
166
+ "<arg_key>lines</arg_key>\n<arg_value>10</arg_value>\n"
167
+ "</tool_call>",
168
+ None,
169
+ )
170
+ assert _statuses(parsed) == [ToolCallParseStatus.OK]
171
+
172
+
173
+ def test_openai_envelope_tools_are_recognized(model, renderer_name):
174
+ wrapped = [{"type": "function", "function": t} for t in _TOOLS]
175
+ parsed = _parse(
176
+ model,
177
+ renderer_name,
178
+ "<tool_call>bash\n"
179
+ "<arg_key>command</arg_key>\n<arg_value>pwd</arg_value>\n"
180
+ "</tool_call>",
181
+ wrapped,
182
+ )
183
+ assert _statuses(parsed) == [ToolCallParseStatus.OK]
File without changes
File without changes