renderers 0.1.11.dev5__tar.gz → 0.1.12.dev2__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 (85) hide show
  1. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/PKG-INFO +1 -1
  2. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/_version.py +2 -2
  3. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/base.py +1 -1
  4. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/client.py +92 -18
  5. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/kimi_k25.py +21 -5
  6. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/qwen35.py +44 -26
  7. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/qwen3_vl.py +45 -26
  8. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_client.py +46 -0
  9. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/.github/workflows/publish-dev.yml +0 -0
  10. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/.github/workflows/publish.yml +0 -0
  11. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/.github/workflows/style.yml +0 -0
  12. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/.github/workflows/test.yml +0 -0
  13. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/.gitignore +0 -0
  14. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/.pre-commit-config.yaml +0 -0
  15. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/LICENSE +0 -0
  16. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/README.md +0 -0
  17. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/docs/renderer-config.md +0 -0
  18. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/examples/README.md +0 -0
  19. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/examples/sglang/multiturn_generate_sglang.py +0 -0
  20. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/examples/sglang/online_multiturn_sglang.py +0 -0
  21. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/examples/tinker/multiturn_generate_tinker.py +0 -0
  22. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/examples/transformers/multiturn_generate_transformers.py +0 -0
  23. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/examples/vllm/multiturn_generate_vllm.py +0 -0
  24. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/pyproject.toml +0 -0
  25. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/__init__.py +0 -0
  26. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/configs.py +0 -0
  27. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/deepseek_r1.py +0 -0
  28. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/deepseek_v3.py +0 -0
  29. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/deepseek_v4.py +0 -0
  30. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/default.py +0 -0
  31. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/gemma4.py +0 -0
  32. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/glm45.py +0 -0
  33. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/glm5.py +0 -0
  34. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/gpt_oss.py +0 -0
  35. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/hy3.py +0 -0
  36. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/inkling.py +0 -0
  37. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/kimi_k2.py +0 -0
  38. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/laguna_s21.py +0 -0
  39. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/laguna_xs2.py +0 -0
  40. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/llama_3.py +0 -0
  41. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/minimax_m2.py +0 -0
  42. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/nemotron3.py +0 -0
  43. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/parsers.py +0 -0
  44. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/parsing.py +0 -0
  45. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/prime_qwen3.py +0 -0
  46. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/qwen3.py +0 -0
  47. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/qwen36.py +0 -0
  48. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/renderers/qwen38.py +0 -0
  49. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/conftest.py +0 -0
  50. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/parity.py +0 -0
  51. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/reference_rendering.py +0 -0
  52. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_bridge.py +0 -0
  53. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_build_helpers.py +0 -0
  54. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_deepseek_r1.py +0 -0
  55. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_deepseek_v4.py +0 -0
  56. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_disabled_thinking_stability.py +0 -0
  57. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_gemma4.py +0 -0
  58. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_glm_tool_name_validation.py +0 -0
  59. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_hy3.py +0 -0
  60. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_incremental.py +0 -0
  61. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_inkling.py +0 -0
  62. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_is_content.py +0 -0
  63. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_kimi_k25_tool_schema.py +0 -0
  64. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_laguna_m1.py +0 -0
  65. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_laguna_xs21.py +0 -0
  66. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_llama_3.py +0 -0
  67. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_load_tokenizer.py +0 -0
  68. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_message_indices.py +0 -0
  69. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_message_tool_names.py +0 -0
  70. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_multimodal.py +0 -0
  71. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_nemotron3_ultra.py +0 -0
  72. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_offsetless_tokenizers.py +0 -0
  73. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_parity.py +0 -0
  74. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_parse_response.py +0 -0
  75. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_parse_response_robustness.py +0 -0
  76. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_parsers.py +0 -0
  77. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_preserve_thinking.py +0 -0
  78. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_qwen35_size_coverage.py +0 -0
  79. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_qwen38.py +0 -0
  80. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_renderer_config.py +0 -0
  81. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_roundtrip.py +0 -0
  82. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_sampled_mask.py +0 -0
  83. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_tokens_per_message.py +0 -0
  84. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/tests/test_tool_arg_type_preservation.py +0 -0
  85. {renderers-0.1.11.dev5 → renderers-0.1.12.dev2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: renderers
3
- Version: 0.1.11.dev5
3
+ Version: 0.1.12.dev2
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.11.dev5'
22
- __version_tuple__ = version_tuple = (0, 1, 11, 'dev5')
21
+ __version__ = version = '0.1.12.dev2'
22
+ __version_tuple__ = version_tuple = (0, 1, 12, 'dev2')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -2095,7 +2095,7 @@ def build_trajectory_step(
2095
2095
  "completion_mask": [True] * len(completion_ids),
2096
2096
  "completion_logprobs": [0.0] * len(completion_ids),
2097
2097
  "routed_experts": None,
2098
- "kept_tokens": None,
2098
+ "sampling_mask": None,
2099
2099
  }
2100
2100
  if (
2101
2101
  full_rendered.multi_modal_data is not None
@@ -28,7 +28,6 @@ from renderers.base import (
28
28
 
29
29
  _request_logger = logging.getLogger("renderers.client")
30
30
  ROUTED_EXPERTS_DATA_PREFIX = b'"routed_experts":{"data":"'
31
- KEPT_TOKENS_IDS_PREFIX = b'"kept_tokens":{"ids":"'
32
31
  # vLLM uses this value both when sampled-token evidence is missing and as a
33
32
  # lower-bound clamp, so receiving it cannot prove the real logprob was returned.
34
33
  VLLM_LOGPROB_SENTINEL = -9999.0
@@ -129,12 +128,9 @@ def _strip_base64_field(raw: bytes, prefix: bytes) -> tuple[bytes, memoryview |
129
128
 
130
129
  def parse_generate_response(raw: bytes) -> dict[str, Any]:
131
130
  stripped, routed_data = _strip_base64_field(raw, ROUTED_EXPERTS_DATA_PREFIX)
132
- stripped, kept_ids_data = _strip_base64_field(stripped, KEPT_TOKENS_IDS_PREFIX)
133
131
  payload: dict[str, Any] = json.loads(stripped)
134
132
  if routed_data is not None:
135
133
  payload["choices"][0]["routed_experts"]["data"] = routed_data
136
- if kept_ids_data is not None:
137
- payload["choices"][0]["kept_tokens"]["ids"] = kept_ids_data
138
134
  return payload
139
135
 
140
136
 
@@ -207,6 +203,7 @@ async def generate(
207
203
  priority: int | None = None,
208
204
  extra_headers: dict[str, str] | None = None,
209
205
  max_prompt_len: int | None = None,
206
+ process_multimodal: bool = True,
210
207
  ) -> dict[str, Any]:
211
208
  """Tokenize messages, call vLLM /inference/v1/generate, parse the response.
212
209
 
@@ -226,6 +223,9 @@ async def generate(
226
223
  sidecar, then serializes it to vLLM's ``features`` schema (mm_hashes,
227
224
  mm_placeholders, kwargs_data) before POSTing. The serializer imports
228
225
  ``vllm.*`` lazily so text-only consumers never pay for the import.
226
+ With ``process_multimodal=False``, rendering skips image processing and
227
+ the request carries ``content_parts`` instead; vLLM must return the
228
+ expanded prompt as ``prompt_token_ids``.
229
229
 
230
230
  ``max_prompt_len`` controls the pre-flight overflow check. When the
231
231
  rendered prompt is strictly longer than the cap, the request is never
@@ -236,10 +236,15 @@ async def generate(
236
236
  cache a ``None`` cap and the pre-flight silently disables. Engine 4xx
237
237
  that still slip through propagate raw — converting them into a domain
238
238
  error is the calling client's job (its error shape is engine-specific).
239
+ Calls with ``process_multimodal=False`` skip this pre-flight because only
240
+ vLLM knows the expanded prompt length.
239
241
 
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.
242
+ Returns a dict with: request_id, prompt_ids, renderer_prompt_ids,
243
+ mm_placeholders, completion_ids, completion_logprobs, content,
244
+ reasoning_content, tool_calls, finish_reason, routed_experts,
245
+ multi_modal_data, prompt_attribution. ``renderer_prompt_ids`` is the
246
+ unexpanded logical prompt when ``process_multimodal=False`` and ``None``
247
+ otherwise.
243
248
 
244
249
  ``prompt_attribution`` is the renderer's :class:`RenderedTokens` for
245
250
  the prompt — either the one this call computed via
@@ -258,6 +263,12 @@ async def generate(
258
263
  f"{type(renderer).__name__} does not support tools. "
259
264
  "Choose a model-specific renderer instead of the default fallback."
260
265
  )
266
+ if not process_multimodal and not getattr(
267
+ renderer, "supports_process_multimodal", False
268
+ ):
269
+ raise NotImplementedError(
270
+ f"{type(renderer).__name__} does not support process_multimodal=False"
271
+ )
261
272
 
262
273
  def _prepare():
263
274
  if prompt_ids is not None:
@@ -270,7 +281,15 @@ async def generate(
270
281
  multi_modal_data,
271
282
  prompt_attribution,
272
283
  )
273
- rendered = renderer.render(messages, tools=tools, add_generation_prompt=True)
284
+ render_kwargs: dict[str, Any] = {}
285
+ if not process_multimodal:
286
+ render_kwargs["process_multimodal"] = False
287
+ rendered = renderer.render(
288
+ messages,
289
+ tools=tools,
290
+ add_generation_prompt=True,
291
+ **render_kwargs,
292
+ )
274
293
  return (
275
294
  rendered.token_ids,
276
295
  renderer.get_stop_token_ids(),
@@ -280,12 +299,13 @@ async def generate(
280
299
 
281
300
  prompt_ids, stop_token_ids, mm_data, prompt_attr = _prepare()
282
301
 
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
- )
302
+ if process_multimodal:
303
+ if max_prompt_len is None:
304
+ max_prompt_len = await _resolve_max_prompt_len(client, model)
305
+ if max_prompt_len is not None and len(prompt_ids) > max_prompt_len:
306
+ raise OverlongPromptError(
307
+ prompt_len=len(prompt_ids), max_prompt_len=max_prompt_len
308
+ )
289
309
 
290
310
  sp: dict[str, Any] = dict(sampling_params or {})
291
311
  sp["stop_token_ids"] = stop_token_ids
@@ -297,11 +317,14 @@ async def generate(
297
317
  "token_ids": prompt_ids,
298
318
  "sampling_params": sp,
299
319
  }
320
+ content_parts = _content_parts(messages) if not process_multimodal else None
300
321
  features = (
301
322
  _build_mm_features(renderer, mm_data)
302
- if mm_data and not mm_data.is_empty()
323
+ if process_multimodal and mm_data and not mm_data.is_empty()
303
324
  else None
304
325
  )
326
+ if content_parts:
327
+ body["content_parts"] = content_parts
305
328
  if features is not None:
306
329
  body["features"] = features
307
330
  if cache_salt is not None:
@@ -331,13 +354,25 @@ async def generate(
331
354
 
332
355
  choice = (data.get("choices") or [{}])[0]
333
356
  completion_ids = choice.get("token_ids") or []
357
+ effective_prompt_ids = data.get("prompt_token_ids")
358
+ if content_parts and not isinstance(effective_prompt_ids, list):
359
+ raise MalformedGenerateResponseError(
360
+ "Engine response must include prompt_token_ids when process_multimodal=False."
361
+ )
362
+ mm_placeholders = data.get("mm_placeholders")
363
+ if content_parts and not isinstance(mm_placeholders, dict):
364
+ raise MalformedGenerateResponseError(
365
+ "Engine response must include mm_placeholders when process_multimodal=False."
366
+ )
334
367
 
335
368
  completion_logprobs = _parse_completion_logprobs(choice, completion_ids)
336
369
 
337
370
  parsed = renderer.parse_response(completion_ids, tools=tools)
338
371
 
339
372
  routed_experts = choice.get("routed_experts")
340
- kept_tokens = choice.get("kept_tokens")
373
+ # vLLM's native kept-set sampling masks (``--return-sampling-mask``):
374
+ # one list of surviving vocab ids per completion token.
375
+ sampling_mask = choice.get("sampling_mask")
341
376
 
342
377
  # /inference/v1/generate returns finish_reason in {"stop","length",...} —
343
378
  # never "tool_calls" (a chat-completions concept). Promote stop→tool_calls
@@ -356,7 +391,9 @@ async def generate(
356
391
 
357
392
  return {
358
393
  "request_id": data.get("request_id") or "",
359
- "prompt_ids": list(prompt_ids),
394
+ "prompt_ids": list(effective_prompt_ids or prompt_ids),
395
+ "renderer_prompt_ids": list(prompt_ids) if content_parts else None,
396
+ "mm_placeholders": mm_placeholders,
360
397
  "completion_ids": list(completion_ids),
361
398
  "completion_logprobs": completion_logprobs,
362
399
  "content": parsed.content,
@@ -364,7 +401,7 @@ async def generate(
364
401
  "tool_calls": parsed.tool_calls,
365
402
  "finish_reason": finish_reason,
366
403
  "routed_experts": routed_experts,
367
- "kept_tokens": kept_tokens,
404
+ "sampling_mask": sampling_mask,
368
405
  # The mm sidecar consumed on the request side, surfaced back so
369
406
  # callers can persist it on the trajectory step for downstream
370
407
  # multi-turn bridging and training-sample construction.
@@ -380,6 +417,43 @@ async def generate(
380
417
  }
381
418
 
382
419
 
420
+ _MEDIA_URL_TYPES = {
421
+ "image": "image_url",
422
+ "image_url": "image_url",
423
+ "audio": "audio_url",
424
+ "audio_url": "audio_url",
425
+ "video": "video_url",
426
+ "video_url": "video_url",
427
+ }
428
+
429
+
430
+ def _content_parts(messages: list[Message]) -> list[dict[str, Any]]:
431
+ """Flatten raw media in prompt order for vLLM's token generate endpoint."""
432
+ parts: list[dict[str, Any]] = []
433
+ for message in messages:
434
+ content = message.get("content")
435
+ if not isinstance(content, list):
436
+ continue
437
+ for part in content:
438
+ if not isinstance(part, Mapping):
439
+ continue
440
+ part_type = part.get("type")
441
+ if part_type is None:
442
+ part_type = next(
443
+ (key for key in _MEDIA_URL_TYPES if part.get(key)), None
444
+ )
445
+ if not isinstance(part_type, str) or part_type not in _MEDIA_URL_TYPES:
446
+ continue
447
+ source = part.get(part_type)
448
+ if source is None:
449
+ source = part.get(_MEDIA_URL_TYPES[part_type]) or part.get("url")
450
+ url = source.get("url") if isinstance(source, Mapping) else source
451
+ if not isinstance(url, str) or not url:
452
+ raise ValueError(f"{part_type} content part is missing a URL")
453
+ parts.append({"type": _MEDIA_URL_TYPES[part_type], "url": url})
454
+ return parts
455
+
456
+
383
457
  def _build_mm_features(
384
458
  renderer: Renderer,
385
459
  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():
@@ -93,6 +93,9 @@ class _FakeClient:
93
93
  "request_id": "gen-test",
94
94
  "choices": [self.choice],
95
95
  }
96
+ if body and body.get("content_parts"):
97
+ payload["prompt_token_ids"] = [1, 2, 2, 3]
98
+ payload["mm_placeholders"] = {"image": [{"offset": 1, "length": 2}]}
96
99
  return httpx.Response(
97
100
  200,
98
101
  content=json.dumps(payload, separators=(",", ":")).encode("utf-8"),
@@ -182,6 +185,49 @@ def test_generate_builds_request_body_and_parses_response():
182
185
  assert tc.status == ToolCallParseStatus.OK
183
186
 
184
187
 
188
+ def test_generate_process_multimodal_false_sends_content_parts():
189
+ class DeferredMultimodalRenderer(_FakeRenderer):
190
+ supports_process_multimodal = True
191
+
192
+ def render(
193
+ self,
194
+ messages,
195
+ *,
196
+ tools=None,
197
+ add_generation_prompt=False,
198
+ process_multimodal=True,
199
+ ):
200
+ assert process_multimodal is False
201
+ return RenderedTokens(token_ids=[1, 2, 3])
202
+
203
+ client = _FakeClient()
204
+ image_url = "data:image/png;base64,aW1hZ2U="
205
+ result = asyncio.run(
206
+ generate(
207
+ client=client,
208
+ renderer=DeferredMultimodalRenderer(),
209
+ messages=[
210
+ {
211
+ "role": "user",
212
+ "content": [
213
+ {"type": "text", "text": "look"},
214
+ {"type": "image_url", "image_url": {"url": image_url}},
215
+ ],
216
+ }
217
+ ],
218
+ model="test-model",
219
+ process_multimodal=False,
220
+ )
221
+ )
222
+
223
+ body = client.calls[0]["body"]
224
+ assert body["content_parts"] == [{"type": "image_url", "url": image_url}]
225
+ assert "features" not in body
226
+ assert result["renderer_prompt_ids"] == [1, 2, 3]
227
+ assert result["prompt_ids"] == [1, 2, 2, 3]
228
+ assert result["mm_placeholders"] == {"image": [{"offset": 1, "length": 2}]}
229
+
230
+
185
231
  def test_generate_rejects_missing_completion_logprobs_before_parsing():
186
232
  client = _FakeClient()
187
233
  client.choice.pop("logprobs")
File without changes
File without changes