renderers 0.1.11.dev4__tar.gz → 0.1.12.dev1__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 (92) hide show
  1. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/PKG-INFO +2 -2
  2. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/README.md +1 -1
  3. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/docs/renderer-config.md +2 -2
  4. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/pyproject.toml +1 -1
  5. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/_version.py +2 -2
  6. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/client.py +88 -12
  7. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/kimi_k25.py +21 -5
  8. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/qwen35.py +44 -26
  9. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/qwen3_vl.py +45 -26
  10. renderers-0.1.12.dev1/tests/conftest.py +59 -0
  11. renderers-0.1.12.dev1/tests/parity.py +782 -0
  12. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/reference_rendering.py +283 -29
  13. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_bridge.py +2 -24
  14. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_client.py +46 -0
  15. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_deepseek_r1.py +3 -70
  16. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_disabled_thinking_stability.py +17 -33
  17. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_gemma4.py +2 -6
  18. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_glm_tool_name_validation.py +2 -4
  19. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_incremental.py +1 -1
  20. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_laguna_m1.py +0 -78
  21. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_llama_3.py +4 -147
  22. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_message_indices.py +1 -1
  23. renderers-0.1.12.dev1/tests/test_parity.py +222 -0
  24. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_parse_response.py +64 -0
  25. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_qwen35_size_coverage.py +4 -91
  26. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_roundtrip.py +12 -50
  27. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_tool_arg_type_preservation.py +2 -10
  28. renderers-0.1.11.dev4/tests/conftest.py +0 -171
  29. renderers-0.1.11.dev4/tests/test_gpt_oss_harmony_parity.py +0 -271
  30. renderers-0.1.11.dev4/tests/test_laguna_s21.py +0 -128
  31. renderers-0.1.11.dev4/tests/test_nemotron3_parity.py +0 -683
  32. renderers-0.1.11.dev4/tests/test_prime_qwen3_parity.py +0 -254
  33. renderers-0.1.11.dev4/tests/test_render_ids.py +0 -402
  34. renderers-0.1.11.dev4/tests/test_renderer_config_parity.py +0 -602
  35. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/.github/workflows/publish-dev.yml +0 -0
  36. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/.github/workflows/publish.yml +0 -0
  37. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/.github/workflows/style.yml +0 -0
  38. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/.github/workflows/test.yml +0 -0
  39. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/.gitignore +0 -0
  40. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/.pre-commit-config.yaml +0 -0
  41. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/LICENSE +0 -0
  42. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/examples/README.md +0 -0
  43. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/examples/sglang/multiturn_generate_sglang.py +0 -0
  44. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/examples/sglang/online_multiturn_sglang.py +0 -0
  45. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/examples/tinker/multiturn_generate_tinker.py +0 -0
  46. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/examples/transformers/multiturn_generate_transformers.py +0 -0
  47. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/examples/vllm/multiturn_generate_vllm.py +0 -0
  48. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/__init__.py +0 -0
  49. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/base.py +0 -0
  50. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/configs.py +0 -0
  51. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/deepseek_r1.py +0 -0
  52. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/deepseek_v3.py +0 -0
  53. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/deepseek_v4.py +0 -0
  54. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/default.py +0 -0
  55. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/gemma4.py +0 -0
  56. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/glm45.py +0 -0
  57. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/glm5.py +0 -0
  58. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/gpt_oss.py +0 -0
  59. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/hy3.py +0 -0
  60. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/inkling.py +0 -0
  61. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/kimi_k2.py +0 -0
  62. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/laguna_s21.py +0 -0
  63. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/laguna_xs2.py +0 -0
  64. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/llama_3.py +0 -0
  65. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/minimax_m2.py +0 -0
  66. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/nemotron3.py +0 -0
  67. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/parsers.py +0 -0
  68. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/parsing.py +0 -0
  69. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/prime_qwen3.py +0 -0
  70. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/qwen3.py +0 -0
  71. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/qwen36.py +0 -0
  72. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/renderers/qwen38.py +0 -0
  73. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_build_helpers.py +0 -0
  74. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_deepseek_v4.py +0 -0
  75. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_hy3.py +0 -0
  76. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_inkling.py +0 -0
  77. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_is_content.py +0 -0
  78. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_kimi_k25_tool_schema.py +0 -0
  79. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_laguna_xs21.py +0 -0
  80. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_load_tokenizer.py +0 -0
  81. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_message_tool_names.py +0 -0
  82. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_multimodal.py +0 -0
  83. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_nemotron3_ultra.py +0 -0
  84. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_offsetless_tokenizers.py +0 -0
  85. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_parse_response_robustness.py +0 -0
  86. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_parsers.py +0 -0
  87. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_preserve_thinking.py +0 -0
  88. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_qwen38.py +0 -0
  89. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_renderer_config.py +0 -0
  90. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_sampled_mask.py +0 -0
  91. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/tests/test_tokens_per_message.py +0 -0
  92. {renderers-0.1.11.dev4 → renderers-0.1.12.dev1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: renderers
3
- Version: 0.1.11.dev4
3
+ Version: 0.1.12.dev1
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
@@ -199,7 +199,7 @@ uv sync --group dev
199
199
  uv run pytest
200
200
  ```
201
201
 
202
- Round-trip parity (render → parse → original) and token-level parity against `apply_chat_template` are tested per renderer. End-to-end validation runs against Reverse-Text, Wordle, OpenCode-Math, and RLM-SWE environments.
202
+ Round-trip parity (render → parse → original) and token-level parity against each model's independent reference encoder are tested per renderer. Most references use `apply_chat_template`; DeepSeek V4 uses its shipped Python encoder, and GPT-OSS uses Harmony.
203
203
 
204
204
  ## License
205
205
 
@@ -179,7 +179,7 @@ uv sync --group dev
179
179
  uv run pytest
180
180
  ```
181
181
 
182
- Round-trip parity (render → parse → original) and token-level parity against `apply_chat_template` are tested per renderer. End-to-end validation runs against Reverse-Text, Wordle, OpenCode-Math, and RLM-SWE environments.
182
+ Round-trip parity (render → parse → original) and token-level parity against each model's independent reference encoder are tested per renderer. Most references use `apply_chat_template`; DeepSeek V4 uses its shipped Python encoder, and GPT-OSS uses Harmony.
183
183
 
184
184
  ## License
185
185
 
@@ -21,8 +21,8 @@ construction.
21
21
  Use `type(config).template_field_names()` to inspect the explicit allowlist of
22
22
  fields accepted through `chat_template_kwargs`. Every renderer-specific field
23
23
  is classified as either a template field or a renderer-only field at class
24
- definition time. Template fields are covered by parity tests against
25
- `apply_chat_template` in `tests/test_renderer_config_parity.py`.
24
+ definition time. Template fields are covered by the model/scenario/kwarg
25
+ product in `tests/test_parity.py`.
26
26
 
27
27
  | Renderer | Config class | Template fields | Renderer-only fields |
28
28
  | --- | --- | --- | --- |
@@ -27,7 +27,7 @@ dependencies = [
27
27
  # release through 0.5.12.post1 hard-pins ``openai-harmony==0.0.4``, so a
28
28
  # higher floor makes ``renderers`` uninstallable alongside SGLang. The
29
29
  # GptOssRenderer renders byte-identically on 0.0.4 (verified token-for-token
30
- # against 0.0.8) and ``tests/test_gpt_oss_harmony_parity.py`` passes on it,
30
+ # against 0.0.8) and the Harmony cells in ``tests/test_parity.py`` pass,
31
31
  # so the older harmony is safe.
32
32
  "openai-harmony>=0.0.4",
33
33
  # ``BaseRendererConfig`` inherits from ``pydantic_config.BaseConfig`` so
@@ -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.11.dev4'
22
- __version_tuple__ = version_tuple = (0, 1, 11, 'dev4')
21
+ __version__ = version = '0.1.12.dev1'
22
+ __version_tuple__ = version_tuple = (0, 1, 12, 'dev1')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -207,6 +207,7 @@ async def generate(
207
207
  priority: int | None = None,
208
208
  extra_headers: dict[str, str] | None = None,
209
209
  max_prompt_len: int | None = None,
210
+ process_multimodal: bool = True,
210
211
  ) -> dict[str, Any]:
211
212
  """Tokenize messages, call vLLM /inference/v1/generate, parse the response.
212
213
 
@@ -226,6 +227,9 @@ async def generate(
226
227
  sidecar, then serializes it to vLLM's ``features`` schema (mm_hashes,
227
228
  mm_placeholders, kwargs_data) before POSTing. The serializer imports
228
229
  ``vllm.*`` lazily so text-only consumers never pay for the import.
230
+ With ``process_multimodal=False``, rendering skips image processing and
231
+ the request carries ``content_parts`` instead; vLLM must return the
232
+ expanded prompt as ``prompt_token_ids``.
229
233
 
230
234
  ``max_prompt_len`` controls the pre-flight overflow check. When the
231
235
  rendered prompt is strictly longer than the cap, the request is never
@@ -236,10 +240,15 @@ async def generate(
236
240
  cache a ``None`` cap and the pre-flight silently disables. Engine 4xx
237
241
  that still slip through propagate raw — converting them into a domain
238
242
  error is the calling client's job (its error shape is engine-specific).
243
+ Calls with ``process_multimodal=False`` skip this pre-flight because only
244
+ vLLM knows the expanded prompt length.
239
245
 
240
- Returns a dict with: request_id, prompt_ids, completion_ids,
241
- completion_logprobs, content, reasoning_content, tool_calls,
242
- finish_reason, routed_experts, multi_modal_data, prompt_attribution.
246
+ Returns a dict with: request_id, prompt_ids, renderer_prompt_ids,
247
+ mm_placeholders, completion_ids, completion_logprobs, content,
248
+ reasoning_content, tool_calls, finish_reason, routed_experts,
249
+ multi_modal_data, prompt_attribution. ``renderer_prompt_ids`` is the
250
+ unexpanded logical prompt when ``process_multimodal=False`` and ``None``
251
+ otherwise.
243
252
 
244
253
  ``prompt_attribution`` is the renderer's :class:`RenderedTokens` for
245
254
  the prompt — either the one this call computed via
@@ -258,6 +267,12 @@ async def generate(
258
267
  f"{type(renderer).__name__} does not support tools. "
259
268
  "Choose a model-specific renderer instead of the default fallback."
260
269
  )
270
+ if not process_multimodal and not getattr(
271
+ renderer, "supports_process_multimodal", False
272
+ ):
273
+ raise NotImplementedError(
274
+ f"{type(renderer).__name__} does not support process_multimodal=False"
275
+ )
261
276
 
262
277
  def _prepare():
263
278
  if prompt_ids is not None:
@@ -270,7 +285,15 @@ async def generate(
270
285
  multi_modal_data,
271
286
  prompt_attribution,
272
287
  )
273
- rendered = renderer.render(messages, tools=tools, add_generation_prompt=True)
288
+ render_kwargs: dict[str, Any] = {}
289
+ if not process_multimodal:
290
+ render_kwargs["process_multimodal"] = False
291
+ rendered = renderer.render(
292
+ messages,
293
+ tools=tools,
294
+ add_generation_prompt=True,
295
+ **render_kwargs,
296
+ )
274
297
  return (
275
298
  rendered.token_ids,
276
299
  renderer.get_stop_token_ids(),
@@ -280,12 +303,13 @@ async def generate(
280
303
 
281
304
  prompt_ids, stop_token_ids, mm_data, prompt_attr = _prepare()
282
305
 
283
- if max_prompt_len is None:
284
- max_prompt_len = await _resolve_max_prompt_len(client, model)
285
- if max_prompt_len is not None and len(prompt_ids) > max_prompt_len:
286
- raise OverlongPromptError(
287
- prompt_len=len(prompt_ids), max_prompt_len=max_prompt_len
288
- )
306
+ if process_multimodal:
307
+ if max_prompt_len is None:
308
+ max_prompt_len = await _resolve_max_prompt_len(client, model)
309
+ if max_prompt_len is not None and len(prompt_ids) > max_prompt_len:
310
+ raise OverlongPromptError(
311
+ prompt_len=len(prompt_ids), max_prompt_len=max_prompt_len
312
+ )
289
313
 
290
314
  sp: dict[str, Any] = dict(sampling_params or {})
291
315
  sp["stop_token_ids"] = stop_token_ids
@@ -297,11 +321,14 @@ async def generate(
297
321
  "token_ids": prompt_ids,
298
322
  "sampling_params": sp,
299
323
  }
324
+ content_parts = _content_parts(messages) if not process_multimodal else None
300
325
  features = (
301
326
  _build_mm_features(renderer, mm_data)
302
- if mm_data and not mm_data.is_empty()
327
+ if process_multimodal and mm_data and not mm_data.is_empty()
303
328
  else None
304
329
  )
330
+ if content_parts:
331
+ body["content_parts"] = content_parts
305
332
  if features is not None:
306
333
  body["features"] = features
307
334
  if cache_salt is not None:
@@ -331,6 +358,16 @@ async def generate(
331
358
 
332
359
  choice = (data.get("choices") or [{}])[0]
333
360
  completion_ids = choice.get("token_ids") or []
361
+ effective_prompt_ids = data.get("prompt_token_ids")
362
+ if content_parts and not isinstance(effective_prompt_ids, list):
363
+ raise MalformedGenerateResponseError(
364
+ "Engine response must include prompt_token_ids when process_multimodal=False."
365
+ )
366
+ mm_placeholders = data.get("mm_placeholders")
367
+ if content_parts and not isinstance(mm_placeholders, dict):
368
+ raise MalformedGenerateResponseError(
369
+ "Engine response must include mm_placeholders when process_multimodal=False."
370
+ )
334
371
 
335
372
  completion_logprobs = _parse_completion_logprobs(choice, completion_ids)
336
373
 
@@ -356,7 +393,9 @@ async def generate(
356
393
 
357
394
  return {
358
395
  "request_id": data.get("request_id") or "",
359
- "prompt_ids": list(prompt_ids),
396
+ "prompt_ids": list(effective_prompt_ids or prompt_ids),
397
+ "renderer_prompt_ids": list(prompt_ids) if content_parts else None,
398
+ "mm_placeholders": mm_placeholders,
360
399
  "completion_ids": list(completion_ids),
361
400
  "completion_logprobs": completion_logprobs,
362
401
  "content": parsed.content,
@@ -380,6 +419,43 @@ async def generate(
380
419
  }
381
420
 
382
421
 
422
+ _MEDIA_URL_TYPES = {
423
+ "image": "image_url",
424
+ "image_url": "image_url",
425
+ "audio": "audio_url",
426
+ "audio_url": "audio_url",
427
+ "video": "video_url",
428
+ "video_url": "video_url",
429
+ }
430
+
431
+
432
+ def _content_parts(messages: list[Message]) -> list[dict[str, Any]]:
433
+ """Flatten raw media in prompt order for vLLM's token generate endpoint."""
434
+ parts: list[dict[str, Any]] = []
435
+ for message in messages:
436
+ content = message.get("content")
437
+ if not isinstance(content, list):
438
+ continue
439
+ for part in content:
440
+ if not isinstance(part, Mapping):
441
+ continue
442
+ part_type = part.get("type")
443
+ if part_type is None:
444
+ part_type = next(
445
+ (key for key in _MEDIA_URL_TYPES if part.get(key)), None
446
+ )
447
+ if not isinstance(part_type, str) or part_type not in _MEDIA_URL_TYPES:
448
+ continue
449
+ source = part.get(part_type)
450
+ if source is None:
451
+ source = part.get(_MEDIA_URL_TYPES[part_type]) or part.get("url")
452
+ url = source.get("url") if isinstance(source, Mapping) else source
453
+ if not isinstance(url, str) or not url:
454
+ raise ValueError(f"{part_type} content part is missing a URL")
455
+ parts.append({"type": _MEDIA_URL_TYPES[part_type], "url": url})
456
+ return parts
457
+
458
+
383
459
  def _build_mm_features(
384
460
  renderer: Renderer,
385
461
  mm_data: MultiModalData,
@@ -591,6 +591,8 @@ class KimiK25Renderer:
591
591
  The tokenizer should be ``moonshotai/Kimi-K2-Instruct`` (same as K2).
592
592
  """
593
593
 
594
+ supports_process_multimodal = True
595
+
594
596
  def __init__(
595
597
  self,
596
598
  tokenizer: Tokenizer,
@@ -734,6 +736,7 @@ class KimiK25Renderer:
734
736
  *,
735
737
  tools: list[ToolSpec] | None = None,
736
738
  add_generation_prompt: bool = False,
739
+ process_multimodal: bool = True,
737
740
  ) -> RenderedTokens:
738
741
  """Render messages to tokens, matching the K2.5 chat template.
739
742
 
@@ -821,7 +824,10 @@ class KimiK25Renderer:
821
824
  ``<|media_content|>``, ``<|media_end|>``, the trailing
822
825
  ``\\n``) are template-injected scaffold.
823
826
  """
824
- _, out, _num_patches, h = self._process_image(part)
827
+ if process_multimodal:
828
+ _, out, _num_patches, h = self._process_image(part)
829
+ else:
830
+ out = h = None
825
831
  emit_special(
826
832
  self._media_begin, msg_idx, is_sampled=is_sampled, is_content=False
827
833
  )
@@ -840,6 +846,9 @@ class KimiK25Renderer:
840
846
  self._media_end, msg_idx, is_sampled=is_sampled, is_content=False
841
847
  )
842
848
  emit_text("\n", msg_idx, is_sampled=is_sampled, is_content=False)
849
+ if not process_multimodal:
850
+ return
851
+ assert out is not None and h is not None
843
852
  mm_hashes.setdefault("image", []).append(h)
844
853
  mm_placeholders.setdefault("image", []).append(
845
854
  PlaceholderRange(offset=offset, length=1)
@@ -1027,6 +1036,7 @@ class KimiK25Renderer:
1027
1036
  *,
1028
1037
  tools: list[ToolSpec] | None = None,
1029
1038
  previous_multi_modal_data: MultiModalData | None = None,
1039
+ process_multimodal: bool = True,
1030
1040
  ) -> "RenderedTokens | None":
1031
1041
  if (
1032
1042
  not previous_prompt_ids
@@ -1115,7 +1125,10 @@ class KimiK25Renderer:
1115
1125
  is_sampled: bool = False,
1116
1126
  is_content: bool = False,
1117
1127
  ) -> None:
1118
- _, out, _num_patches, h = self._process_image(part)
1128
+ if process_multimodal:
1129
+ _, out, _num_patches, h = self._process_image(part)
1130
+ else:
1131
+ out = h = None
1119
1132
  emit_special(self._media_begin, msg_idx)
1120
1133
  emit_text("image", msg_idx)
1121
1134
  emit_special(self._media_content, msg_idx)
@@ -1125,6 +1138,9 @@ class KimiK25Renderer:
1125
1138
  emit_special(self._media_pad, msg_idx, is_content=is_content)
1126
1139
  emit_special(self._media_end, msg_idx)
1127
1140
  emit_text("\n", msg_idx)
1141
+ if not process_multimodal:
1142
+ return
1143
+ assert out is not None and h is not None
1128
1144
  new_hashes.setdefault("image", []).append(h)
1129
1145
  new_placeholders.setdefault("image", []).append(
1130
1146
  PlaceholderRange(offset=offset, length=1)
@@ -1188,17 +1204,17 @@ class KimiK25Renderer:
1188
1204
  # below never mutates the caller's previous_multi_modal_data.
1189
1205
  merged_hashes: dict[str, list[str]] = (
1190
1206
  {k: list(v) for k, v in previous_multi_modal_data.mm_hashes.items()}
1191
- if previous_multi_modal_data
1207
+ if process_multimodal and previous_multi_modal_data
1192
1208
  else {}
1193
1209
  )
1194
1210
  merged_placeholders: dict[str, list[PlaceholderRange]] = (
1195
1211
  {k: list(v) for k, v in previous_multi_modal_data.mm_placeholders.items()}
1196
- if previous_multi_modal_data
1212
+ if process_multimodal and previous_multi_modal_data
1197
1213
  else {}
1198
1214
  )
1199
1215
  merged_items: dict[str, list[dict[str, Any]]] = (
1200
1216
  {k: list(v) for k, v in previous_multi_modal_data.mm_items.items()}
1201
- if previous_multi_modal_data
1217
+ if process_multimodal and previous_multi_modal_data
1202
1218
  else {}
1203
1219
  )
1204
1220
  for modality, vals in new_hashes.items():
@@ -123,6 +123,7 @@ def _default_enable_thinking(tokenizer) -> bool:
123
123
  class Qwen35Renderer:
124
124
  """Deterministic message → token renderer for Qwen3.5 models."""
125
125
 
126
+ supports_process_multimodal = True
126
127
  _config_cls: type = Qwen35RendererConfig
127
128
 
128
129
  def __init__(
@@ -353,6 +354,7 @@ class Qwen35Renderer:
353
354
  *,
354
355
  tools: list[ToolSpec] | None = None,
355
356
  add_generation_prompt: bool = False,
357
+ process_multimodal: bool = True,
356
358
  ) -> RenderedTokens:
357
359
  if not messages:
358
360
  raise ValueError("No messages provided.")
@@ -424,7 +426,11 @@ class Qwen35Renderer:
424
426
  # image data, so they ARE body content (is_content=True);
425
427
  # the surrounding ``<|vision_start|>`` / ``<|vision_end|>``
426
428
  # specials are template scaffold.
427
- _, out, n, h = self._process_image(part)
429
+ if process_multimodal:
430
+ _, out, n, h = self._process_image(part)
431
+ else:
432
+ out = h = None
433
+ n = 1
428
434
  vision_counts["image"] += 1
429
435
  if self.config.add_vision_id:
430
436
  emit_text(
@@ -442,16 +448,18 @@ class Qwen35Renderer:
442
448
  self._image_pad, msg_idx, is_sampled=False, is_content=True
443
449
  )
444
450
  emit_special(self._vision_end, msg_idx, is_sampled=False, is_content=False)
445
- mm_hashes.setdefault("image", []).append(h)
446
- mm_placeholders.setdefault("image", []).append(
447
- PlaceholderRange(offset=offset, length=n)
448
- )
449
- mm_items.setdefault("image", []).append(
450
- {
451
- "pixel_values": out["pixel_values"],
452
- "image_grid_thw": out["image_grid_thw"],
453
- }
454
- )
451
+ if process_multimodal:
452
+ assert out is not None and h is not None
453
+ mm_hashes.setdefault("image", []).append(h)
454
+ mm_placeholders.setdefault("image", []).append(
455
+ PlaceholderRange(offset=offset, length=n)
456
+ )
457
+ mm_items.setdefault("image", []).append(
458
+ {
459
+ "pixel_values": out["pixel_values"],
460
+ "image_grid_thw": out["image_grid_thw"],
461
+ }
462
+ )
455
463
 
456
464
  def emit_user_with_media(content_list: list[Any], msg_idx: int) -> None:
457
465
  """Emit a user message whose content list contains image parts.
@@ -685,6 +693,7 @@ class Qwen35Renderer:
685
693
  *,
686
694
  tools: list[ToolSpec] | None = None,
687
695
  previous_multi_modal_data: MultiModalData | None = None,
696
+ process_multimodal: bool = True,
688
697
  ) -> "RenderedTokens | None":
689
698
  if (
690
699
  not previous_prompt_ids
@@ -721,6 +730,7 @@ class Qwen35Renderer:
721
730
  # scratch correctly.
722
731
  if (
723
732
  self.config.add_vision_id
733
+ and process_multimodal
724
734
  and previous_multi_modal_data is None
725
735
  and self._vision_start in previous_ids
726
736
  ):
@@ -751,9 +761,11 @@ class Qwen35Renderer:
751
761
  # ``add_vision_id`` parity across turns.
752
762
  prev_image_count = 0
753
763
  prev_video_count = 0
754
- if previous_multi_modal_data is not None:
764
+ if process_multimodal and previous_multi_modal_data is not None:
755
765
  prev_image_count = len(previous_multi_modal_data.mm_items.get("image", []))
756
766
  prev_video_count = len(previous_multi_modal_data.mm_items.get("video", []))
767
+ elif not process_multimodal:
768
+ prev_image_count = previous_ids.count(self._image_pad)
757
769
  vision_counts = {"image": prev_image_count, "video": prev_video_count}
758
770
 
759
771
  def emit_special(
@@ -796,7 +808,11 @@ class Qwen35Renderer:
796
808
  content_mask.append(is_content)
797
809
 
798
810
  def emit_image(part: dict[str, Any], msg_idx: int = -1) -> None:
799
- _, out, n, h = self._process_image(part)
811
+ if process_multimodal:
812
+ _, out, n, h = self._process_image(part)
813
+ else:
814
+ out = h = None
815
+ n = 1
800
816
  vision_counts["image"] += 1
801
817
  if self.config.add_vision_id:
802
818
  emit_text(f"Picture {vision_counts['image']}: ", msg_idx)
@@ -805,16 +821,18 @@ class Qwen35Renderer:
805
821
  for _ in range(n):
806
822
  emit_special(self._image_pad, msg_idx, is_content=True)
807
823
  emit_special(self._vision_end, msg_idx)
808
- new_hashes.setdefault("image", []).append(h)
809
- new_placeholders.setdefault("image", []).append(
810
- PlaceholderRange(offset=offset, length=n)
811
- )
812
- new_items.setdefault("image", []).append(
813
- {
814
- "pixel_values": out["pixel_values"],
815
- "image_grid_thw": out["image_grid_thw"],
816
- }
817
- )
824
+ if process_multimodal:
825
+ assert out is not None and h is not None
826
+ new_hashes.setdefault("image", []).append(h)
827
+ new_placeholders.setdefault("image", []).append(
828
+ PlaceholderRange(offset=offset, length=n)
829
+ )
830
+ new_items.setdefault("image", []).append(
831
+ {
832
+ "pixel_values": out["pixel_values"],
833
+ "image_grid_thw": out["image_grid_thw"],
834
+ }
835
+ )
818
836
 
819
837
  def emit_user_with_media(content_list: list[Any], msg_idx: int) -> None:
820
838
  emit_special(self._im_start, msg_idx)
@@ -905,17 +923,17 @@ class Qwen35Renderer:
905
923
  # below never mutates the caller's previous_multi_modal_data.
906
924
  merged_hashes: dict[str, list[str]] = (
907
925
  {k: list(v) for k, v in previous_multi_modal_data.mm_hashes.items()}
908
- if previous_multi_modal_data
926
+ if process_multimodal and previous_multi_modal_data
909
927
  else {}
910
928
  )
911
929
  merged_placeholders: dict[str, list[PlaceholderRange]] = (
912
930
  {k: list(v) for k, v in previous_multi_modal_data.mm_placeholders.items()}
913
- if previous_multi_modal_data
931
+ if process_multimodal and previous_multi_modal_data
914
932
  else {}
915
933
  )
916
934
  merged_items: dict[str, list[dict[str, Any]]] = (
917
935
  {k: list(v) for k, v in previous_multi_modal_data.mm_items.items()}
918
- if previous_multi_modal_data
936
+ if process_multimodal and previous_multi_modal_data
919
937
  else {}
920
938
  )
921
939
  for modality, vals in new_hashes.items():
@@ -310,6 +310,8 @@ class Qwen3VLRenderer:
310
310
  ``thinking_retention`` still controls whether the bridge is attempted.
311
311
  """
312
312
 
313
+ supports_process_multimodal = True
314
+
313
315
  def __init__(
314
316
  self,
315
317
  tokenizer: Tokenizer,
@@ -457,6 +459,7 @@ class Qwen3VLRenderer:
457
459
  *,
458
460
  tools: list[ToolSpec] | None = None,
459
461
  add_generation_prompt: bool = False,
462
+ process_multimodal: bool = True,
460
463
  ) -> RenderedTokens:
461
464
  if not messages:
462
465
  raise ValueError("No messages provided.")
@@ -480,7 +483,11 @@ class Qwen3VLRenderer:
480
483
  # image data, so they ARE body content (is_content=True);
481
484
  # the surrounding ``<|vision_start|>`` / ``<|vision_end|>``
482
485
  # markers are renderer-emitted scaffold.
483
- _, out, n, h = self._process_image(part)
486
+ if process_multimodal:
487
+ _, out, n, h = self._process_image(part)
488
+ else:
489
+ out = h = None
490
+ n = 1
484
491
  vision_counts["image"] += 1
485
492
  if self.config.add_vision_id:
486
493
  em.text(
@@ -493,16 +500,18 @@ class Qwen3VLRenderer:
493
500
  for _ in range(n):
494
501
  em.special(self._image_pad, is_sampled=False, is_content=True)
495
502
  em.special(self._vision_end, is_sampled=False, is_content=False)
496
- mm_hashes.setdefault("image", []).append(h)
497
- mm_placeholders.setdefault("image", []).append(
498
- PlaceholderRange(offset=offset, length=n)
499
- )
500
- mm_items.setdefault("image", []).append(
501
- {
502
- "pixel_values": out["pixel_values"],
503
- "image_grid_thw": out["image_grid_thw"],
504
- }
505
- )
503
+ if process_multimodal:
504
+ assert out is not None and h is not None
505
+ mm_hashes.setdefault("image", []).append(h)
506
+ mm_placeholders.setdefault("image", []).append(
507
+ PlaceholderRange(offset=offset, length=n)
508
+ )
509
+ mm_items.setdefault("image", []).append(
510
+ {
511
+ "pixel_values": out["pixel_values"],
512
+ "image_grid_thw": out["image_grid_thw"],
513
+ }
514
+ )
506
515
 
507
516
  def render_media_content(content: Any) -> None:
508
517
  """Emit a user/tool content list with media handled inline.
@@ -665,6 +674,7 @@ class Qwen3VLRenderer:
665
674
  *,
666
675
  tools: list[ToolSpec] | None = None,
667
676
  previous_multi_modal_data: MultiModalData | None = None,
677
+ process_multimodal: bool = True,
668
678
  ) -> "RenderedTokens | None":
669
679
  """Extend the prior turn with ``new_messages``.
670
680
 
@@ -709,6 +719,7 @@ class Qwen3VLRenderer:
709
719
  # correctly.
710
720
  if (
711
721
  self.config.add_vision_id
722
+ and process_multimodal
712
723
  and previous_multi_modal_data is None
713
724
  and self._vision_start in previous_ids
714
725
  ):
@@ -746,13 +757,19 @@ class Qwen3VLRenderer:
746
757
  # ``add_vision_id`` parity across turns.
747
758
  prev_image_count = 0
748
759
  prev_video_count = 0
749
- if previous_multi_modal_data is not None:
760
+ if process_multimodal and previous_multi_modal_data is not None:
750
761
  prev_image_count = len(previous_multi_modal_data.mm_items.get("image", []))
751
762
  prev_video_count = len(previous_multi_modal_data.mm_items.get("video", []))
763
+ elif not process_multimodal:
764
+ prev_image_count = previous_ids.count(self._image_pad)
752
765
  vision_counts = {"image": prev_image_count, "video": prev_video_count}
753
766
 
754
767
  def emit_image(part: dict[str, Any]) -> None:
755
- _, out, n, h = self._process_image(part)
768
+ if process_multimodal:
769
+ _, out, n, h = self._process_image(part)
770
+ else:
771
+ out = h = None
772
+ n = 1
756
773
  vision_counts["image"] += 1
757
774
  if self.config.add_vision_id:
758
775
  em.text(
@@ -765,16 +782,18 @@ class Qwen3VLRenderer:
765
782
  for _ in range(n):
766
783
  em.special(self._image_pad, is_sampled=False, is_content=True)
767
784
  em.special(self._vision_end, is_sampled=False, is_content=False)
768
- new_hashes.setdefault("image", []).append(h)
769
- new_placeholders.setdefault("image", []).append(
770
- PlaceholderRange(offset=offset, length=n)
771
- )
772
- new_items.setdefault("image", []).append(
773
- {
774
- "pixel_values": out["pixel_values"],
775
- "image_grid_thw": out["image_grid_thw"],
776
- }
777
- )
785
+ if process_multimodal:
786
+ assert out is not None and h is not None
787
+ new_hashes.setdefault("image", []).append(h)
788
+ new_placeholders.setdefault("image", []).append(
789
+ PlaceholderRange(offset=offset, length=n)
790
+ )
791
+ new_items.setdefault("image", []).append(
792
+ {
793
+ "pixel_values": out["pixel_values"],
794
+ "image_grid_thw": out["image_grid_thw"],
795
+ }
796
+ )
778
797
 
779
798
  def render_media_content(content: Any) -> None:
780
799
  if isinstance(content, str):
@@ -833,17 +852,17 @@ class Qwen3VLRenderer:
833
852
  # caller's previous_multi_modal_data.
834
853
  merged_hashes = (
835
854
  {k: list(v) for k, v in previous_multi_modal_data.mm_hashes.items()}
836
- if previous_multi_modal_data
855
+ if process_multimodal and previous_multi_modal_data
837
856
  else {}
838
857
  )
839
858
  merged_placeholders = (
840
859
  {k: list(v) for k, v in previous_multi_modal_data.mm_placeholders.items()}
841
- if previous_multi_modal_data
860
+ if process_multimodal and previous_multi_modal_data
842
861
  else {}
843
862
  )
844
863
  merged_items = (
845
864
  {k: list(v) for k, v in previous_multi_modal_data.mm_items.items()}
846
- if previous_multi_modal_data
865
+ if process_multimodal and previous_multi_modal_data
847
866
  else {}
848
867
  )
849
868
  for modality, vals in new_hashes.items():