renderers 0.1.12.dev7__tar.gz → 0.1.12.dev8__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.
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/PKG-INFO +1 -1
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/_version.py +2 -2
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/base.py +2 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/client.py +76 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/nemotron3.py +252 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_client.py +99 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_multimodal.py +23 -4
- renderers-0.1.12.dev8/tests/test_nemotron35_vlm.py +322 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/.github/workflows/publish-dev.yml +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/.github/workflows/publish.yml +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/.github/workflows/style.yml +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/.github/workflows/test.yml +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/.gitignore +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/.pre-commit-config.yaml +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/LICENSE +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/README.md +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/docs/renderer-config.md +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/examples/README.md +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/examples/sglang/multiturn_generate_sglang.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/examples/sglang/online_multiturn_sglang.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/examples/tinker/multiturn_generate_tinker.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/examples/transformers/multiturn_generate_transformers.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/examples/vllm/multiturn_generate_vllm.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/pyproject.toml +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/__init__.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/configs.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/deepseek_r1.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/deepseek_v3.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/deepseek_v4.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/default.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/gemma4.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/glm45.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/glm5.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/gpt_oss.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/hy3.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/inkling.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/kimi_k2.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/kimi_k25.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/laguna_s21.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/laguna_xs2.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/llama_3.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/minimax_m2.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/parsers.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/parsing.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/prime_qwen3.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/qwen3.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/qwen35.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/qwen36.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/qwen38.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/qwen3_vl.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/renderers/reasoning.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/conftest.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/parity.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/reference_rendering.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_bridge.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_build_helpers.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_deepseek_r1.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_deepseek_v4.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_disabled_thinking_stability.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_gemma4.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_glm_tool_name_validation.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_hy3.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_incremental.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_inkling.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_is_content.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_kimi_k25_tool_schema.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_laguna_m1.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_laguna_xs21.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_llama_3.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_load_tokenizer.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_message_indices.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_message_tool_names.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_nemotron3_ultra.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_offsetless_tokenizers.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_parity.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_parse_response.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_parse_response_robustness.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_parsers.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_preserve_thinking.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_qwen35_size_coverage.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_qwen38.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_reasoning_boundaries.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_renderer_config.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_roundtrip.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_sampled_mask.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_tokens_per_message.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/tests/test_tool_arg_type_preservation.py +0 -0
- {renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/uv.lock +0 -0
|
@@ -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.12.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1, 12, '
|
|
21
|
+
__version__ = version = '0.1.12.dev8'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 12, 'dev8')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -1017,6 +1017,7 @@ MODEL_RENDERER_MAP: dict[str, str] = {
|
|
|
1017
1017
|
# Nemotron 3.5 (Lightning). Its template is the Ultra variant's minus the
|
|
1018
1018
|
# effort kwarg (``nemotron-3.5``).
|
|
1019
1019
|
"nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16": "nemotron-3.5",
|
|
1020
|
+
"nvidia/NVIDIA-Nemotron-3.5-Super-EA-09112026": "nemotron-3.5",
|
|
1020
1021
|
# Llama 3.2 (Instruct). Tested against the gated meta-llama repos and
|
|
1021
1022
|
# the unrestricted unsloth/... mirror, which ships a byte-identical
|
|
1022
1023
|
# chat template. ``Llama3Renderer`` defaults ``date_string`` to
|
|
@@ -1057,6 +1058,7 @@ MODEL_RENDERER_MAP: dict[str, str] = {
|
|
|
1057
1058
|
# Modality values: ``"image"``, ``"video"``, ``"audio"``. Text is implicit
|
|
1058
1059
|
# (every model supports it), so it doesn't appear in the set.
|
|
1059
1060
|
MULTIMODAL_MODELS: dict[str, set[str]] = {
|
|
1061
|
+
"nvidia/NVIDIA-Nemotron-3.5-Super-EA-09112026": {"image"},
|
|
1060
1062
|
"Qwen/Qwen3-VL-4B-Instruct": {"image"},
|
|
1061
1063
|
"Qwen/Qwen3-VL-8B-Instruct": {"image"},
|
|
1062
1064
|
"Qwen/Qwen3-VL-30B-A3B-Instruct": {"image"},
|
|
@@ -486,6 +486,7 @@ def _build_mm_features(
|
|
|
486
486
|
to change. Don't pre-build the abstraction with one engine in tree.
|
|
487
487
|
"""
|
|
488
488
|
from renderers.gemma4 import Gemma4Renderer
|
|
489
|
+
from renderers.nemotron3 import Nemotron35Renderer
|
|
489
490
|
from renderers.qwen3_vl import Qwen3VLRenderer
|
|
490
491
|
from renderers.qwen35 import Qwen35Renderer
|
|
491
492
|
|
|
@@ -498,6 +499,8 @@ def _build_mm_features(
|
|
|
498
499
|
return _build_qwen_vl_features(mm_data, spatial_merge_size=2)
|
|
499
500
|
if issubclass(renderer_cls, Gemma4Renderer):
|
|
500
501
|
return _build_gemma4_features(mm_data)
|
|
502
|
+
if issubclass(renderer_cls, Nemotron35Renderer):
|
|
503
|
+
return _build_nemotron35_features(mm_data)
|
|
501
504
|
|
|
502
505
|
raise NotImplementedError(
|
|
503
506
|
f"Multimodal serialization not implemented for {renderer_cls.__name__}. "
|
|
@@ -505,6 +508,79 @@ def _build_mm_features(
|
|
|
505
508
|
)
|
|
506
509
|
|
|
507
510
|
|
|
511
|
+
def _build_nemotron35_features(mm_data: MultiModalData) -> dict[str, Any]:
|
|
512
|
+
"""Encode dynamic-resolution Nemotron image inputs for vLLM."""
|
|
513
|
+
_require_transformers("Encoding Nemotron 3.5 multimodal features for vLLM")
|
|
514
|
+
try:
|
|
515
|
+
import torch
|
|
516
|
+
from transformers.feature_extraction_utils import BatchFeature
|
|
517
|
+
from vllm.entrypoints.scale_out.token_in_token_out.mm_serde import (
|
|
518
|
+
encode_mm_kwargs_item,
|
|
519
|
+
)
|
|
520
|
+
from vllm.multimodal.inputs import (
|
|
521
|
+
MultiModalFieldConfig,
|
|
522
|
+
MultiModalKwargsItems,
|
|
523
|
+
)
|
|
524
|
+
except ImportError as exc:
|
|
525
|
+
raise RuntimeError(
|
|
526
|
+
"Nemotron 3.5 multimodal generate requires vLLM and torch."
|
|
527
|
+
) from exc
|
|
528
|
+
|
|
529
|
+
out: dict[str, Any] = {
|
|
530
|
+
"mm_hashes": {},
|
|
531
|
+
"mm_placeholders": {},
|
|
532
|
+
"kwargs_data": {},
|
|
533
|
+
}
|
|
534
|
+
image_items = mm_data.mm_items.get("image") or []
|
|
535
|
+
if image_items:
|
|
536
|
+
pixel_values_flat = []
|
|
537
|
+
imgs_sizes = []
|
|
538
|
+
num_tokens_per_image = []
|
|
539
|
+
for item in image_items:
|
|
540
|
+
pixel_values = torch.as_tensor(item["pixel_values"])
|
|
541
|
+
if pixel_values.ndim != 4 or pixel_values.shape[0] != 1:
|
|
542
|
+
raise ValueError(
|
|
543
|
+
"Each Nemotron pixel_values item must have shape (1, C, H, W)."
|
|
544
|
+
)
|
|
545
|
+
sizes = item["imgs_sizes"]
|
|
546
|
+
token_counts = item["num_tokens"]
|
|
547
|
+
if not (len(sizes) == len(token_counts) == 1):
|
|
548
|
+
raise ValueError(
|
|
549
|
+
"Each Nemotron image item must contain one size and token count."
|
|
550
|
+
)
|
|
551
|
+
pixel_values_flat.append(pixel_values[0])
|
|
552
|
+
imgs_sizes.append(tuple(int(value) for value in sizes[0]))
|
|
553
|
+
num_tokens_per_image.append(int(token_counts[0]))
|
|
554
|
+
|
|
555
|
+
hf_inputs = BatchFeature(
|
|
556
|
+
data={
|
|
557
|
+
"pixel_values_flat": pixel_values_flat,
|
|
558
|
+
"imgs_sizes": imgs_sizes,
|
|
559
|
+
"num_tokens_per_image": num_tokens_per_image,
|
|
560
|
+
}
|
|
561
|
+
)
|
|
562
|
+
field_config = {
|
|
563
|
+
"pixel_values_flat": MultiModalFieldConfig.batched("image"),
|
|
564
|
+
"num_tokens_per_image": MultiModalFieldConfig.batched(
|
|
565
|
+
"image", keep_on_cpu=True
|
|
566
|
+
),
|
|
567
|
+
"imgs_sizes": MultiModalFieldConfig.batched("image", keep_on_cpu=True),
|
|
568
|
+
}
|
|
569
|
+
kwargs_items = MultiModalKwargsItems.from_hf_inputs(hf_inputs, field_config)
|
|
570
|
+
out["kwargs_data"]["image"] = [
|
|
571
|
+
encode_mm_kwargs_item(item) for item in kwargs_items["image"]
|
|
572
|
+
]
|
|
573
|
+
out["mm_hashes"]["image"] = list(mm_data.mm_hashes.get("image") or [])
|
|
574
|
+
out["mm_placeholders"]["image"] = [
|
|
575
|
+
{"offset": placeholder.offset, "length": placeholder.length}
|
|
576
|
+
for placeholder in mm_data.mm_placeholders.get("image") or []
|
|
577
|
+
]
|
|
578
|
+
|
|
579
|
+
if not any(out["kwargs_data"].values()):
|
|
580
|
+
out["kwargs_data"] = None
|
|
581
|
+
return out
|
|
582
|
+
|
|
583
|
+
|
|
508
584
|
def _build_gemma4_features(mm_data: MultiModalData) -> dict[str, Any]:
|
|
509
585
|
"""vLLM features payload for Gemma 4 image inputs.
|
|
510
586
|
|
|
@@ -14,18 +14,23 @@ Nemotron 3 uses the same <|im_start|>/<|im_end|> format as Qwen3.5 but differs i
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
from dataclasses import replace
|
|
17
18
|
import json
|
|
19
|
+
from pathlib import Path
|
|
18
20
|
from typing import Any
|
|
19
21
|
|
|
20
22
|
from renderers.reasoning import scan_reasoning, prompt_ends_in_reasoning
|
|
21
23
|
|
|
22
24
|
from renderers.base import (
|
|
23
25
|
Message,
|
|
26
|
+
MultiModalData,
|
|
24
27
|
ParsedResponse,
|
|
28
|
+
PlaceholderRange,
|
|
25
29
|
RenderedTokens,
|
|
26
30
|
ToolSpec,
|
|
27
31
|
Tokenizer,
|
|
28
32
|
_content_mask_or_empty,
|
|
33
|
+
_require_transformers,
|
|
29
34
|
attribute_text_segments,
|
|
30
35
|
extract_message_tool_names,
|
|
31
36
|
reject_assistant_in_extension,
|
|
@@ -39,6 +44,7 @@ from renderers.configs import (
|
|
|
39
44
|
Nemotron35RendererConfig,
|
|
40
45
|
)
|
|
41
46
|
from renderers.parsing import parse_qwen35
|
|
47
|
+
from renderers.qwen3_vl import _image_hash, _is_image_part, _load_pil_image
|
|
42
48
|
|
|
43
49
|
# ---------------------------------------------------------------------------
|
|
44
50
|
# Tool system prompt constants
|
|
@@ -894,3 +900,249 @@ class Nemotron35Renderer(Nemotron3Renderer):
|
|
|
894
900
|
|
|
895
901
|
_config_cls = Nemotron35RendererConfig
|
|
896
902
|
_ultra = True
|
|
903
|
+
supports_process_multimodal = True
|
|
904
|
+
supports_multimodal_bridge = False
|
|
905
|
+
|
|
906
|
+
_processor_model_prefix = "nvidia/NVIDIA-Nemotron-3.5"
|
|
907
|
+
|
|
908
|
+
def __init__(
|
|
909
|
+
self,
|
|
910
|
+
tokenizer: Tokenizer,
|
|
911
|
+
config: Nemotron35RendererConfig | None = None,
|
|
912
|
+
*,
|
|
913
|
+
processor: Any = None,
|
|
914
|
+
):
|
|
915
|
+
super().__init__(tokenizer, config)
|
|
916
|
+
self._processor = processor
|
|
917
|
+
self._image = self._token_id("<image>", optional=True)
|
|
918
|
+
|
|
919
|
+
@property
|
|
920
|
+
def mm_token_type_id_map(self) -> dict[int, int]:
|
|
921
|
+
return {self._image: 1} if self._image is not None else {}
|
|
922
|
+
|
|
923
|
+
def _image_token_id(self) -> int:
|
|
924
|
+
if self._image is None:
|
|
925
|
+
raise RuntimeError("This Nemotron 3.5 tokenizer has no <image> token.")
|
|
926
|
+
return self._image
|
|
927
|
+
|
|
928
|
+
def _get_processor(self):
|
|
929
|
+
if self._processor is not None:
|
|
930
|
+
return self._processor
|
|
931
|
+
name = getattr(self._tokenizer, "name_or_path", None)
|
|
932
|
+
if not name:
|
|
933
|
+
raise RuntimeError(
|
|
934
|
+
"Nemotron35Renderer needs a processor for image content."
|
|
935
|
+
)
|
|
936
|
+
if Path(name).expanduser().is_dir():
|
|
937
|
+
raise RuntimeError(
|
|
938
|
+
"Local Nemotron remote-code processor paths require an explicitly "
|
|
939
|
+
"injected reviewed processor."
|
|
940
|
+
)
|
|
941
|
+
if not name.startswith(self._processor_model_prefix):
|
|
942
|
+
raise RuntimeError(
|
|
943
|
+
f"Nemotron processor {name!r} is not approved for remote-code "
|
|
944
|
+
"loading. Inject a reviewed processor."
|
|
945
|
+
)
|
|
946
|
+
transformers = _require_transformers("Auto-loading a Nemotron 3.5 processor")
|
|
947
|
+
self._processor = transformers.AutoProcessor.from_pretrained(
|
|
948
|
+
name, trust_remote_code=True
|
|
949
|
+
)
|
|
950
|
+
return self._processor
|
|
951
|
+
|
|
952
|
+
@staticmethod
|
|
953
|
+
def _is_image_part(item: Any) -> bool:
|
|
954
|
+
return _is_image_part(item) or (
|
|
955
|
+
isinstance(item, dict) and item.get("type") == "input_image"
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
@staticmethod
|
|
959
|
+
def _image_source(part: dict[str, Any]) -> dict[str, Any]:
|
|
960
|
+
if "input_image" not in part:
|
|
961
|
+
return part
|
|
962
|
+
return {**part, "image": part["input_image"]}
|
|
963
|
+
|
|
964
|
+
@classmethod
|
|
965
|
+
def _logical_multimodal_content(
|
|
966
|
+
cls, content: list[Any]
|
|
967
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
968
|
+
text = "".join(
|
|
969
|
+
item if isinstance(item, str) else str(item.get("text") or "")
|
|
970
|
+
for item in content
|
|
971
|
+
if isinstance(item, str)
|
|
972
|
+
or (isinstance(item, dict) and item.get("type") in ("text", "input_text"))
|
|
973
|
+
)
|
|
974
|
+
image_parts = [
|
|
975
|
+
item
|
|
976
|
+
for item in content
|
|
977
|
+
if isinstance(item, dict) and cls._is_image_part(item)
|
|
978
|
+
]
|
|
979
|
+
emit_structured_images = "<image>" not in text
|
|
980
|
+
rendered: list[str] = []
|
|
981
|
+
image_index = 0
|
|
982
|
+
for item in content:
|
|
983
|
+
if isinstance(item, str):
|
|
984
|
+
rendered.append(item)
|
|
985
|
+
elif not isinstance(item, dict):
|
|
986
|
+
raise ValueError(f"Unexpected content item: {item}")
|
|
987
|
+
elif cls._is_image_part(item):
|
|
988
|
+
image_index += 1
|
|
989
|
+
if emit_structured_images:
|
|
990
|
+
if len(image_parts) > 1:
|
|
991
|
+
rendered.append(f"<image {image_index}><image>")
|
|
992
|
+
else:
|
|
993
|
+
rendered.append("<image>")
|
|
994
|
+
elif item.get("type") in ("video", "video_url", "input_video"):
|
|
995
|
+
raise NotImplementedError(
|
|
996
|
+
"Video parts are not yet supported by Nemotron35Renderer."
|
|
997
|
+
)
|
|
998
|
+
elif item.get("type") in ("text", "input_text") or "text" in item:
|
|
999
|
+
rendered.append(str(item.get("text") or ""))
|
|
1000
|
+
return "".join(rendered), image_parts
|
|
1001
|
+
|
|
1002
|
+
@staticmethod
|
|
1003
|
+
def _load_image(part: dict[str, Any]):
|
|
1004
|
+
source = Nemotron35Renderer._image_source(part)
|
|
1005
|
+
if "image" in source:
|
|
1006
|
+
raw = source["image"]
|
|
1007
|
+
elif "image_url" in source:
|
|
1008
|
+
image_url = source.get("image_url")
|
|
1009
|
+
raw = image_url.get("url") if isinstance(image_url, dict) else image_url
|
|
1010
|
+
else:
|
|
1011
|
+
raw = source.get("url") or source.get("path")
|
|
1012
|
+
if isinstance(raw, str) and not raw.startswith("data:image/"):
|
|
1013
|
+
raise ValueError(
|
|
1014
|
+
"Nemotron 3.5 accepts in-memory images or data:image URIs; "
|
|
1015
|
+
"network and filesystem image sources are disabled."
|
|
1016
|
+
)
|
|
1017
|
+
return _load_pil_image(source)
|
|
1018
|
+
|
|
1019
|
+
def _process_image(self, part: dict[str, Any]) -> tuple[dict[str, Any], int, str]:
|
|
1020
|
+
self._image_token_id()
|
|
1021
|
+
image = self._load_image(part)
|
|
1022
|
+
output = self._get_processor().image_processor(
|
|
1023
|
+
images=[image], return_tensors="np"
|
|
1024
|
+
)
|
|
1025
|
+
return output, int(output["num_tokens"][0]), _image_hash(image)
|
|
1026
|
+
|
|
1027
|
+
@staticmethod
|
|
1028
|
+
def _expand_image_tokens(content: str, token_counts: list[int]) -> str:
|
|
1029
|
+
if content.count("<image>") != len(token_counts):
|
|
1030
|
+
raise ValueError(
|
|
1031
|
+
"Nemotron image content must contain one <image> placeholder per image."
|
|
1032
|
+
)
|
|
1033
|
+
segments = content.split("<image>")
|
|
1034
|
+
expanded = [segments[0]]
|
|
1035
|
+
for count, suffix in zip(token_counts, segments[1:]):
|
|
1036
|
+
expanded.append("<img>" + "<image>" * count + "</img>" + suffix)
|
|
1037
|
+
return "".join(expanded)
|
|
1038
|
+
|
|
1039
|
+
def _transform_multimodal_messages(
|
|
1040
|
+
self,
|
|
1041
|
+
messages: list[Message],
|
|
1042
|
+
*,
|
|
1043
|
+
process_multimodal: bool,
|
|
1044
|
+
) -> tuple[list[Message], list[tuple[dict[str, Any], int, str]]]:
|
|
1045
|
+
transformed: list[Message] = []
|
|
1046
|
+
processed_images: list[tuple[dict[str, Any], int, str]] = []
|
|
1047
|
+
for message in messages:
|
|
1048
|
+
copied = dict(message)
|
|
1049
|
+
content = message.get("content")
|
|
1050
|
+
if isinstance(content, list) and any(
|
|
1051
|
+
self._is_image_part(item) for item in content
|
|
1052
|
+
):
|
|
1053
|
+
logical_content, image_parts = self._logical_multimodal_content(content)
|
|
1054
|
+
if process_multimodal:
|
|
1055
|
+
current_images = [self._process_image(part) for part in image_parts]
|
|
1056
|
+
logical_content = self._expand_image_tokens(
|
|
1057
|
+
logical_content, [item[1] for item in current_images]
|
|
1058
|
+
)
|
|
1059
|
+
processed_images.extend(current_images)
|
|
1060
|
+
copied["content"] = logical_content
|
|
1061
|
+
transformed.append(copied)
|
|
1062
|
+
return transformed, processed_images
|
|
1063
|
+
|
|
1064
|
+
def render(
|
|
1065
|
+
self,
|
|
1066
|
+
messages: list[Message],
|
|
1067
|
+
*,
|
|
1068
|
+
tools: list[ToolSpec] | None = None,
|
|
1069
|
+
add_generation_prompt: bool = False,
|
|
1070
|
+
process_multimodal: bool = True,
|
|
1071
|
+
) -> RenderedTokens:
|
|
1072
|
+
transformed, processed_images = self._transform_multimodal_messages(
|
|
1073
|
+
messages, process_multimodal=process_multimodal
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
rendered = super().render(
|
|
1077
|
+
transformed,
|
|
1078
|
+
tools=tools,
|
|
1079
|
+
add_generation_prompt=add_generation_prompt,
|
|
1080
|
+
)
|
|
1081
|
+
if not processed_images:
|
|
1082
|
+
return rendered
|
|
1083
|
+
|
|
1084
|
+
image_token_id = self._image_token_id()
|
|
1085
|
+
placeholder_runs: list[PlaceholderRange] = []
|
|
1086
|
+
cursor = 0
|
|
1087
|
+
for _, length, _ in processed_images:
|
|
1088
|
+
while (
|
|
1089
|
+
cursor < len(rendered.token_ids)
|
|
1090
|
+
and rendered.token_ids[cursor] != image_token_id
|
|
1091
|
+
):
|
|
1092
|
+
cursor += 1
|
|
1093
|
+
if (
|
|
1094
|
+
rendered.token_ids[cursor : cursor + length]
|
|
1095
|
+
!= [image_token_id] * length
|
|
1096
|
+
):
|
|
1097
|
+
raise ValueError(
|
|
1098
|
+
"Rendered Nemotron image placeholders are not contiguous."
|
|
1099
|
+
)
|
|
1100
|
+
placeholder_runs.append(PlaceholderRange(offset=cursor, length=length))
|
|
1101
|
+
cursor += length
|
|
1102
|
+
|
|
1103
|
+
mm_data = MultiModalData(
|
|
1104
|
+
mm_hashes={"image": [item[2] for item in processed_images]},
|
|
1105
|
+
mm_placeholders={"image": placeholder_runs},
|
|
1106
|
+
mm_items={
|
|
1107
|
+
"image": [
|
|
1108
|
+
{
|
|
1109
|
+
key: processor_output[key]
|
|
1110
|
+
for key in (
|
|
1111
|
+
"pixel_values",
|
|
1112
|
+
"imgs_sizes",
|
|
1113
|
+
"num_tokens",
|
|
1114
|
+
"num_patches",
|
|
1115
|
+
)
|
|
1116
|
+
}
|
|
1117
|
+
for processor_output, _, _ in processed_images
|
|
1118
|
+
],
|
|
1119
|
+
},
|
|
1120
|
+
)
|
|
1121
|
+
return replace(rendered, multi_modal_data=mm_data)
|
|
1122
|
+
|
|
1123
|
+
def bridge_to_next_turn(
|
|
1124
|
+
self,
|
|
1125
|
+
previous_prompt_ids: list[int],
|
|
1126
|
+
previous_completion_ids: list[int],
|
|
1127
|
+
new_messages: list[Message],
|
|
1128
|
+
*,
|
|
1129
|
+
tools: list[ToolSpec] | None = None,
|
|
1130
|
+
previous_multi_modal_data: MultiModalData | None = None,
|
|
1131
|
+
process_multimodal: bool = True,
|
|
1132
|
+
) -> RenderedTokens | None:
|
|
1133
|
+
has_new_images = any(
|
|
1134
|
+
isinstance(message.get("content"), list)
|
|
1135
|
+
and any(self._is_image_part(item) for item in message["content"])
|
|
1136
|
+
for message in new_messages
|
|
1137
|
+
)
|
|
1138
|
+
if has_new_images or (
|
|
1139
|
+
previous_multi_modal_data is not None
|
|
1140
|
+
and not previous_multi_modal_data.is_empty()
|
|
1141
|
+
):
|
|
1142
|
+
return None
|
|
1143
|
+
return super().bridge_to_next_turn(
|
|
1144
|
+
previous_prompt_ids,
|
|
1145
|
+
previous_completion_ids,
|
|
1146
|
+
new_messages,
|
|
1147
|
+
tools=tools,
|
|
1148
|
+
)
|
|
@@ -595,6 +595,105 @@ def test_generate_serializes_multimodal_features_for_gemma4():
|
|
|
595
595
|
)
|
|
596
596
|
|
|
597
597
|
|
|
598
|
+
def test_generate_serializes_nemotron_dynamic_image_features():
|
|
599
|
+
pytest.importorskip("torch")
|
|
600
|
+
pytest.importorskip("vllm", reason="vllm needed for features serialization")
|
|
601
|
+
|
|
602
|
+
from vllm.entrypoints.scale_out.token_in_token_out.mm_serde import (
|
|
603
|
+
decode_mm_kwargs_item,
|
|
604
|
+
)
|
|
605
|
+
from vllm.model_executor.models.nano_nemotron_vl import NemotronH_Nano_VL_V2
|
|
606
|
+
from vllm.multimodal.inputs import MultiModalKwargsItems
|
|
607
|
+
|
|
608
|
+
from renderers.base import MultiModalData, PlaceholderRange
|
|
609
|
+
from renderers.nemotron3 import Nemotron35Renderer
|
|
610
|
+
|
|
611
|
+
class _FakeNemotronRenderer(Nemotron35Renderer):
|
|
612
|
+
def get_stop_token_ids(self):
|
|
613
|
+
return [99]
|
|
614
|
+
|
|
615
|
+
def parse_response(self, completion_ids, *, tools=None, prompt_ids=None):
|
|
616
|
+
return ParsedResponse(content="done")
|
|
617
|
+
|
|
618
|
+
renderer = object.__new__(_FakeNemotronRenderer)
|
|
619
|
+
mm_data = MultiModalData(
|
|
620
|
+
mm_hashes={"image": ["wide", "tall"]},
|
|
621
|
+
mm_placeholders={
|
|
622
|
+
"image": [
|
|
623
|
+
PlaceholderRange(offset=2, length=6),
|
|
624
|
+
PlaceholderRange(offset=10, length=6),
|
|
625
|
+
]
|
|
626
|
+
},
|
|
627
|
+
mm_items={
|
|
628
|
+
"image": [
|
|
629
|
+
{
|
|
630
|
+
"pixel_values": np.zeros((1, 3, 32, 48), dtype=np.float32),
|
|
631
|
+
"imgs_sizes": np.array([[32, 48]], dtype=np.int64),
|
|
632
|
+
"num_tokens": [6],
|
|
633
|
+
"num_patches": [1],
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
"pixel_values": np.ones((1, 3, 48, 32), dtype=np.float32),
|
|
637
|
+
"imgs_sizes": np.array([[48, 32]], dtype=np.int64),
|
|
638
|
+
"num_tokens": [6],
|
|
639
|
+
"num_patches": [1],
|
|
640
|
+
},
|
|
641
|
+
]
|
|
642
|
+
},
|
|
643
|
+
)
|
|
644
|
+
client = _FakeClient()
|
|
645
|
+
|
|
646
|
+
asyncio.run(
|
|
647
|
+
generate(
|
|
648
|
+
client=client,
|
|
649
|
+
renderer=renderer,
|
|
650
|
+
messages=[],
|
|
651
|
+
model="nemotron35-super",
|
|
652
|
+
prompt_ids=list(range(20)),
|
|
653
|
+
multi_modal_data=mm_data,
|
|
654
|
+
sampling_params={"max_tokens": 4},
|
|
655
|
+
)
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
features = client.calls[0]["body"]["features"]
|
|
659
|
+
assert features["mm_hashes"] == {"image": ["wide", "tall"]}
|
|
660
|
+
assert features["mm_placeholders"] == {
|
|
661
|
+
"image": [{"offset": 2, "length": 6}, {"offset": 10, "length": 6}]
|
|
662
|
+
}
|
|
663
|
+
decoded_items = [
|
|
664
|
+
decode_mm_kwargs_item(item) for item in features["kwargs_data"]["image"]
|
|
665
|
+
]
|
|
666
|
+
decoded = [item.get_data() for item in decoded_items]
|
|
667
|
+
assert [tuple(item["pixel_values_flat"].shape) for item in decoded] == [
|
|
668
|
+
(3, 32, 48),
|
|
669
|
+
(3, 48, 32),
|
|
670
|
+
]
|
|
671
|
+
assert [tuple(item["imgs_sizes"]) for item in decoded] == [
|
|
672
|
+
(32, 48),
|
|
673
|
+
(48, 32),
|
|
674
|
+
]
|
|
675
|
+
assert [int(item["num_tokens_per_image"]) for item in decoded] == [6, 6]
|
|
676
|
+
assert all("image_num_patches" not in item for item in decoded)
|
|
677
|
+
|
|
678
|
+
kwargs = MultiModalKwargsItems({"image": decoded_items}).get_data()
|
|
679
|
+
model = object.__new__(NemotronH_Nano_VL_V2)
|
|
680
|
+
model.dynamic_resolution = True
|
|
681
|
+
model.patch_size = 16
|
|
682
|
+
image_input = model._parse_and_validate_image_input(**kwargs)
|
|
683
|
+
|
|
684
|
+
assert image_input is not None
|
|
685
|
+
assert tuple(image_input["pixel_values_flat"].shape) == (
|
|
686
|
+
1,
|
|
687
|
+
12,
|
|
688
|
+
3 * 16 * 16,
|
|
689
|
+
)
|
|
690
|
+
assert [tuple(size) for size in image_input["imgs_sizes"]] == [
|
|
691
|
+
(32, 48),
|
|
692
|
+
(48, 32),
|
|
693
|
+
]
|
|
694
|
+
assert image_input["num_tokens_per_image"] == [6, 6]
|
|
695
|
+
|
|
696
|
+
|
|
598
697
|
# ---------------------------------------------------------------------------
|
|
599
698
|
# Prompt overflow handling.
|
|
600
699
|
# ---------------------------------------------------------------------------
|
|
@@ -105,8 +105,8 @@ _loaded: dict[str, tuple] = {}
|
|
|
105
105
|
|
|
106
106
|
|
|
107
107
|
# Models whose processors need ``trust_remote_code=True`` (custom Python
|
|
108
|
-
# in the repo)
|
|
109
|
-
#
|
|
108
|
+
# in the repo) and still use a pinned revision. Nemotron 3.5 is handled
|
|
109
|
+
# separately below so its selected model revision is not overridden.
|
|
110
110
|
_PROCESSOR_TRUSTED_REVISIONS: dict[str, str] = {
|
|
111
111
|
"moonshotai/Kimi-K2.5": "4d01dfe0332d63057c186e0b262165819efb6611",
|
|
112
112
|
"moonshotai/Kimi-K2.6": "2755962d07cb42aa2d988a35bcb65cd4a9c2de82",
|
|
@@ -119,7 +119,12 @@ def _load_processor_and_renderer(model_name: str):
|
|
|
119
119
|
|
|
120
120
|
tokenizer = load_tokenizer(model_name)
|
|
121
121
|
revision = _PROCESSOR_TRUSTED_REVISIONS.get(model_name)
|
|
122
|
-
if
|
|
122
|
+
if model_name.startswith("nvidia/NVIDIA-Nemotron-3.5"):
|
|
123
|
+
processor = AutoProcessor.from_pretrained(
|
|
124
|
+
model_name,
|
|
125
|
+
trust_remote_code=True,
|
|
126
|
+
)
|
|
127
|
+
elif revision is not None:
|
|
123
128
|
processor = AutoProcessor.from_pretrained(
|
|
124
129
|
model_name,
|
|
125
130
|
trust_remote_code=True,
|
|
@@ -173,6 +178,8 @@ def _detect_family(model_name: str) -> str:
|
|
|
173
178
|
- ``gemma4``: canonical Gemma turn grammar plus dynamic
|
|
174
179
|
``<|image>`` + N x ``<|image|>`` + ``<image|>`` expansion.
|
|
175
180
|
"""
|
|
181
|
+
if model_name.startswith("nvidia/NVIDIA-Nemotron-3.5"):
|
|
182
|
+
return "nemotron35"
|
|
176
183
|
if model_name.startswith("moonshotai/Kimi-K2.5") or model_name.startswith(
|
|
177
184
|
"moonshotai/Kimi-K2.6"
|
|
178
185
|
):
|
|
@@ -332,6 +339,12 @@ def _modality_kit(modality: str, model_name: str):
|
|
|
332
339
|
"placeholder_token": "<|unused_200054|>",
|
|
333
340
|
"processor_input_ids": _qwen_vl_processor_input_ids,
|
|
334
341
|
}
|
|
342
|
+
if family == "nemotron35":
|
|
343
|
+
return {
|
|
344
|
+
"make_part": _image_content_part,
|
|
345
|
+
"placeholder_token": "<image>",
|
|
346
|
+
"processor_input_ids": _qwen_vl_processor_input_ids,
|
|
347
|
+
}
|
|
335
348
|
# Default: Qwen-VL family (Qwen3-VL, Qwen3.5, Qwen3.6).
|
|
336
349
|
return {
|
|
337
350
|
"make_part": _image_content_part,
|
|
@@ -594,9 +607,13 @@ def _supports_tool_message_images(renderer) -> bool:
|
|
|
594
607
|
asserting against them."""
|
|
595
608
|
from renderers.gemma4 import Gemma4Renderer
|
|
596
609
|
from renderers.kimi_k25 import KimiK25Renderer
|
|
610
|
+
from renderers.nemotron3 import Nemotron35Renderer
|
|
597
611
|
from renderers.qwen35 import Qwen35Renderer
|
|
598
612
|
|
|
599
|
-
return isinstance(
|
|
613
|
+
return isinstance(
|
|
614
|
+
renderer,
|
|
615
|
+
(Qwen35Renderer, KimiK25Renderer, Gemma4Renderer, Nemotron35Renderer),
|
|
616
|
+
)
|
|
600
617
|
|
|
601
618
|
|
|
602
619
|
@pytest.mark.parametrize(
|
|
@@ -715,6 +732,8 @@ def test_multimodal_bridge_extends_and_carries_mm_data(
|
|
|
715
732
|
tokenizer,
|
|
716
733
|
_config_for_model(mm_model_name, thinking_retention="all"),
|
|
717
734
|
)
|
|
735
|
+
if not getattr(renderer, "supports_multimodal_bridge", True):
|
|
736
|
+
pytest.skip(f"{type(renderer).__name__} uses full multimodal re-rendering")
|
|
718
737
|
if hasattr(renderer, "_processor") and renderer._processor is None:
|
|
719
738
|
renderer._processor = processor
|
|
720
739
|
sample = _sample_for(modality, tiny_image)
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Focused multimodal tests for the private Nemotron 3.5 Super checkpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from renderers.base import MODEL_RENDERER_MAP, MULTIMODAL_MODELS, PlaceholderRange
|
|
13
|
+
from renderers.nemotron3 import Nemotron35Renderer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
MODEL = "nvidia/NVIDIA-Nemotron-3.5-Super-EA-09112026"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Tokenizer:
|
|
20
|
+
name_or_path = MODEL
|
|
21
|
+
unk_token_id = -1
|
|
22
|
+
eos_token_id = 2
|
|
23
|
+
|
|
24
|
+
_specials = {
|
|
25
|
+
"<|im_start|>": 1,
|
|
26
|
+
"<|im_end|>": 2,
|
|
27
|
+
"<|endoftext|>": 3,
|
|
28
|
+
"<think>": 4,
|
|
29
|
+
"</think>": 5,
|
|
30
|
+
"<tool_call>": 6,
|
|
31
|
+
"</tool_call>": 7,
|
|
32
|
+
"<tool_response>": 8,
|
|
33
|
+
"</tool_response>": 9,
|
|
34
|
+
"<img>": 10,
|
|
35
|
+
"</img>": 11,
|
|
36
|
+
"<image>": 18,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def convert_tokens_to_ids(self, token: str) -> int:
|
|
40
|
+
return self._specials.get(token, self.unk_token_id)
|
|
41
|
+
|
|
42
|
+
def encode(self, text: str, *, add_special_tokens: bool = False) -> list[int]:
|
|
43
|
+
assert not add_special_tokens
|
|
44
|
+
token_ids: list[int] = []
|
|
45
|
+
index = 0
|
|
46
|
+
specials = sorted(self._specials, key=len, reverse=True)
|
|
47
|
+
while index < len(text):
|
|
48
|
+
special = next(
|
|
49
|
+
(value for value in specials if text.startswith(value, index)), None
|
|
50
|
+
)
|
|
51
|
+
if special is not None:
|
|
52
|
+
token_ids.append(self._specials[special])
|
|
53
|
+
index += len(special)
|
|
54
|
+
else:
|
|
55
|
+
token_ids.append(1000 + ord(text[index]))
|
|
56
|
+
index += 1
|
|
57
|
+
return token_ids
|
|
58
|
+
|
|
59
|
+
def decode(self, token_ids: list[int], **_: Any) -> str:
|
|
60
|
+
reverse_specials = {value: key for key, value in self._specials.items()}
|
|
61
|
+
return "".join(
|
|
62
|
+
reverse_specials[token_id]
|
|
63
|
+
if token_id in reverse_specials
|
|
64
|
+
else chr(token_id - 1000)
|
|
65
|
+
for token_id in token_ids
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _ImageProcessor:
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
self.calls = 0
|
|
72
|
+
|
|
73
|
+
def __call__(self, *, images: list[Any], return_tensors: str):
|
|
74
|
+
assert len(images) == 1
|
|
75
|
+
assert return_tensors == "np"
|
|
76
|
+
self.calls += 1
|
|
77
|
+
return {
|
|
78
|
+
"pixel_values": [[[[0.0]]]],
|
|
79
|
+
"num_tokens": [3],
|
|
80
|
+
"num_patches": [1],
|
|
81
|
+
"imgs_sizes": [(1, 1)],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_private_super_is_registered_as_image_model():
|
|
86
|
+
assert MODEL_RENDERER_MAP[MODEL] == "nemotron-3.5"
|
|
87
|
+
assert MULTIMODAL_MODELS[MODEL] == {"image"}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_image_content_expands_and_attaches_only_model_inputs():
|
|
91
|
+
Image = pytest.importorskip("PIL.Image")
|
|
92
|
+
image = Image.new("RGB", (4, 3), color=(10, 20, 30))
|
|
93
|
+
image_processor = _ImageProcessor()
|
|
94
|
+
processor = SimpleNamespace(image_processor=image_processor)
|
|
95
|
+
renderer = Nemotron35Renderer(_Tokenizer(), processor=processor)
|
|
96
|
+
messages = [
|
|
97
|
+
{
|
|
98
|
+
"role": "user",
|
|
99
|
+
"content": [
|
|
100
|
+
{"type": "text", "text": "What is shown? "},
|
|
101
|
+
{"type": "image", "image": image},
|
|
102
|
+
{"type": "text", "text": " Be concise."},
|
|
103
|
+
],
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
rendered = renderer.render(messages, add_generation_prompt=True)
|
|
108
|
+
|
|
109
|
+
assert messages[0]["content"][1]["image"] is image
|
|
110
|
+
assert image_processor.calls == 1
|
|
111
|
+
assert renderer.mm_token_type_id_map == {18: 1}
|
|
112
|
+
assert rendered.multi_modal_data is not None
|
|
113
|
+
assert rendered.multi_modal_data.mm_hashes.keys() == {"image"}
|
|
114
|
+
assert len(rendered.multi_modal_data.mm_hashes["image"]) == 1
|
|
115
|
+
placeholder = rendered.multi_modal_data.mm_placeholders["image"]
|
|
116
|
+
assert placeholder == [PlaceholderRange(offset=placeholder[0].offset, length=3)]
|
|
117
|
+
assert rendered.token_ids[placeholder[0].offset : placeholder[0].offset + 3] == [
|
|
118
|
+
18,
|
|
119
|
+
18,
|
|
120
|
+
18,
|
|
121
|
+
]
|
|
122
|
+
assert rendered.multi_modal_data.mm_items == {
|
|
123
|
+
"image": [
|
|
124
|
+
{
|
|
125
|
+
"pixel_values": [[[[0.0]]]],
|
|
126
|
+
"num_tokens": [3],
|
|
127
|
+
"num_patches": [1],
|
|
128
|
+
"imgs_sizes": [(1, 1)],
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_deferred_multimodal_processing_keeps_one_logical_image_token():
|
|
135
|
+
Image = pytest.importorskip("PIL.Image")
|
|
136
|
+
image = Image.new("RGB", (2, 2))
|
|
137
|
+
renderer = Nemotron35Renderer(
|
|
138
|
+
_Tokenizer(), processor=SimpleNamespace(image_processor=_ImageProcessor())
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
rendered = renderer.render(
|
|
142
|
+
[{"role": "user", "content": [{"type": "image", "image": image}]}],
|
|
143
|
+
process_multimodal=False,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
assert rendered.token_ids.count(18) == 1
|
|
147
|
+
assert rendered.multi_modal_data is None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_two_images_produce_two_distinct_placeholder_ranges():
|
|
151
|
+
Image = pytest.importorskip("PIL.Image")
|
|
152
|
+
renderer = Nemotron35Renderer(
|
|
153
|
+
_Tokenizer(), processor=SimpleNamespace(image_processor=_ImageProcessor())
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
rendered = renderer.render(
|
|
157
|
+
[
|
|
158
|
+
{
|
|
159
|
+
"role": "user",
|
|
160
|
+
"content": [
|
|
161
|
+
{"type": "image", "image": Image.new("RGB", (2, 2))},
|
|
162
|
+
{"type": "text", "text": "compare"},
|
|
163
|
+
{"type": "image", "image": Image.new("RGB", (3, 2))},
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
assert rendered.multi_modal_data is not None
|
|
170
|
+
placeholders = rendered.multi_modal_data.mm_placeholders["image"]
|
|
171
|
+
assert [placeholder.length for placeholder in placeholders] == [3, 3]
|
|
172
|
+
assert placeholders[0].offset + placeholders[0].length < placeholders[1].offset
|
|
173
|
+
assert len(rendered.multi_modal_data.mm_items["image"]) == 2
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_multimodal_bridge_uses_full_rerender_fallback():
|
|
177
|
+
renderer = Nemotron35Renderer(_Tokenizer())
|
|
178
|
+
|
|
179
|
+
assert not renderer.supports_multimodal_bridge
|
|
180
|
+
assert (
|
|
181
|
+
renderer.bridge_to_next_turn(
|
|
182
|
+
[1],
|
|
183
|
+
[2],
|
|
184
|
+
[{"role": "user", "content": [{"type": "image", "image": object()}]}],
|
|
185
|
+
)
|
|
186
|
+
is None
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_text_only_nemotron35_tokenizer_does_not_need_image_token():
|
|
191
|
+
class _TextTokenizer(_Tokenizer):
|
|
192
|
+
_specials = {
|
|
193
|
+
key: value
|
|
194
|
+
for key, value in _Tokenizer._specials.items()
|
|
195
|
+
if key != "<image>"
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
renderer = Nemotron35Renderer(_TextTokenizer())
|
|
199
|
+
|
|
200
|
+
assert renderer.mm_token_type_id_map == {}
|
|
201
|
+
assert renderer.render_ids([{"role": "user", "content": "Hello"}])
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@pytest.mark.parametrize(
|
|
205
|
+
"source",
|
|
206
|
+
[
|
|
207
|
+
"https://example.test/image.png",
|
|
208
|
+
"file:///tmp/image.png",
|
|
209
|
+
"/tmp/image.png",
|
|
210
|
+
],
|
|
211
|
+
)
|
|
212
|
+
def test_nemotron35_rejects_network_and_filesystem_image_sources(source):
|
|
213
|
+
renderer = Nemotron35Renderer(
|
|
214
|
+
_Tokenizer(), processor=SimpleNamespace(image_processor=_ImageProcessor())
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
with pytest.raises(ValueError, match="in-memory images or data:image"):
|
|
218
|
+
renderer.render(
|
|
219
|
+
[
|
|
220
|
+
{
|
|
221
|
+
"role": "user",
|
|
222
|
+
"content": [{"type": "image_url", "image_url": {"url": source}}],
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_nemotron35_rejects_unpinned_remote_processor(monkeypatch):
|
|
229
|
+
class _RemoteTokenizer(_Tokenizer):
|
|
230
|
+
name_or_path = "example/unreviewed-model"
|
|
231
|
+
|
|
232
|
+
monkeypatch.setattr(
|
|
233
|
+
"renderers.nemotron3._require_transformers",
|
|
234
|
+
lambda _: pytest.fail(
|
|
235
|
+
"unknown remote model must fail before transformers load"
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
renderer = Nemotron35Renderer(_RemoteTokenizer())
|
|
239
|
+
|
|
240
|
+
with pytest.raises(RuntimeError, match="not approved for remote-code loading"):
|
|
241
|
+
renderer._get_processor()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_nemotron35_rejects_unapproved_local_processor(tmp_path, monkeypatch):
|
|
245
|
+
class _LocalTokenizer(_Tokenizer):
|
|
246
|
+
name_or_path = str(tmp_path)
|
|
247
|
+
|
|
248
|
+
monkeypatch.setattr(
|
|
249
|
+
"renderers.nemotron3._require_transformers",
|
|
250
|
+
lambda _: pytest.fail("unapproved local model must fail before load"),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
with pytest.raises(RuntimeError, match="explicitly injected reviewed processor"):
|
|
254
|
+
Nemotron35Renderer(_LocalTokenizer())._get_processor()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_nemotron35_loads_approved_remote_processor_without_revision(monkeypatch):
|
|
258
|
+
calls = []
|
|
259
|
+
expected = object()
|
|
260
|
+
transformers = SimpleNamespace(
|
|
261
|
+
AutoProcessor=SimpleNamespace(
|
|
262
|
+
from_pretrained=lambda name, **kwargs: (
|
|
263
|
+
calls.append((name, kwargs)) or expected
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
monkeypatch.setattr(
|
|
268
|
+
"renderers.nemotron3._require_transformers", lambda _: transformers
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
assert Nemotron35Renderer(_Tokenizer())._get_processor() is expected
|
|
272
|
+
assert calls == [
|
|
273
|
+
(
|
|
274
|
+
MODEL,
|
|
275
|
+
{"trust_remote_code": True},
|
|
276
|
+
)
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_real_processor_token_parity_when_metadata_is_available():
|
|
281
|
+
metadata = os.environ.get("NEMOTRON35_VLM_METADATA")
|
|
282
|
+
if metadata is None:
|
|
283
|
+
pytest.skip("set NEMOTRON35_VLM_METADATA to the downloaded model metadata")
|
|
284
|
+
metadata_path = Path(metadata)
|
|
285
|
+
if not metadata_path.is_dir():
|
|
286
|
+
pytest.skip(f"Nemotron metadata directory does not exist: {metadata_path}")
|
|
287
|
+
|
|
288
|
+
Image = pytest.importorskip("PIL.Image")
|
|
289
|
+
transformers = pytest.importorskip("transformers")
|
|
290
|
+
tokenizer = transformers.AutoTokenizer.from_pretrained(metadata_path)
|
|
291
|
+
processor = transformers.AutoProcessor.from_pretrained(
|
|
292
|
+
metadata_path, trust_remote_code=True
|
|
293
|
+
)
|
|
294
|
+
renderer = Nemotron35Renderer(tokenizer, processor=processor)
|
|
295
|
+
images = [
|
|
296
|
+
Image.new("RGB", (2048, 256), color=(10, 20, 30)),
|
|
297
|
+
Image.new("RGB", (256, 2048), color=(30, 20, 10)),
|
|
298
|
+
]
|
|
299
|
+
for selected_images in (images[:1], images):
|
|
300
|
+
content = [{"type": "text", "text": "Describe: "}]
|
|
301
|
+
content.extend({"type": "image", "image": image} for image in selected_images)
|
|
302
|
+
messages = [{"role": "user", "content": content}]
|
|
303
|
+
|
|
304
|
+
rendered = renderer.render(messages, add_generation_prompt=True)
|
|
305
|
+
prompt = processor.apply_chat_template(
|
|
306
|
+
messages, tokenize=False, add_generation_prompt=True
|
|
307
|
+
)
|
|
308
|
+
expected = processor(
|
|
309
|
+
images=selected_images,
|
|
310
|
+
text=prompt,
|
|
311
|
+
return_tensors=None,
|
|
312
|
+
)["input_ids"][0]
|
|
313
|
+
|
|
314
|
+
assert rendered.token_ids == expected
|
|
315
|
+
assert rendered.multi_modal_data is not None
|
|
316
|
+
assert len(rendered.multi_modal_data.mm_items["image"]) == len(selected_images)
|
|
317
|
+
if len(selected_images) == 2:
|
|
318
|
+
shapes = [
|
|
319
|
+
tuple(item["pixel_values"].shape)
|
|
320
|
+
for item in rendered.multi_modal_data.mm_items["image"]
|
|
321
|
+
]
|
|
322
|
+
assert shapes[0] != shapes[1]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/examples/sglang/multiturn_generate_sglang.py
RENAMED
|
File without changes
|
|
File without changes
|
{renderers-0.1.12.dev7 → renderers-0.1.12.dev8}/examples/tinker/multiturn_generate_tinker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|