renderers 0.1.9.dev10__tar.gz → 0.1.10.dev4__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 (77) hide show
  1. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/PKG-INFO +2 -2
  2. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/README.md +1 -1
  3. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/docs/renderer-config.md +4 -2
  4. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/__init__.py +8 -0
  5. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/_version.py +2 -2
  6. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/base.py +19 -4
  7. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/configs.py +59 -0
  8. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/laguna_xs2.py +74 -19
  9. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/nemotron3.py +22 -3
  10. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/conftest.py +4 -0
  11. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_bridge.py +1 -0
  12. renderers-0.1.10.dev4/tests/test_laguna_m1.py +232 -0
  13. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_nemotron3_parity.py +16 -9
  14. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_nemotron3_ultra.py +32 -7
  15. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_renderer_config_parity.py +5 -1
  16. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_roundtrip.py +3 -0
  17. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/.github/workflows/publish-dev.yml +0 -0
  18. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/.github/workflows/publish.yml +0 -0
  19. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/.github/workflows/style.yml +0 -0
  20. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/.github/workflows/test.yml +0 -0
  21. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/.gitignore +0 -0
  22. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/.pre-commit-config.yaml +0 -0
  23. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/LICENSE +0 -0
  24. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/examples/README.md +0 -0
  25. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/examples/sglang/multiturn_generate_sglang.py +0 -0
  26. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/examples/sglang/online_multiturn_sglang.py +0 -0
  27. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/examples/tinker/multiturn_generate_tinker.py +0 -0
  28. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/examples/transformers/multiturn_generate_transformers.py +0 -0
  29. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/examples/vllm/multiturn_generate_vllm.py +0 -0
  30. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/pyproject.toml +0 -0
  31. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/client.py +0 -0
  32. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/deepseek_r1.py +0 -0
  33. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/deepseek_v3.py +0 -0
  34. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/default.py +0 -0
  35. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/glm45.py +0 -0
  36. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/glm5.py +0 -0
  37. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/gpt_oss.py +0 -0
  38. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/hy3.py +0 -0
  39. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/kimi_k2.py +0 -0
  40. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/kimi_k25.py +0 -0
  41. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/llama_3.py +0 -0
  42. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/minimax_m2.py +0 -0
  43. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/parsers.py +0 -0
  44. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/parsing.py +0 -0
  45. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/prime_qwen3.py +0 -0
  46. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/qwen3.py +0 -0
  47. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/qwen35.py +0 -0
  48. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/qwen36.py +0 -0
  49. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/renderers/qwen3_vl.py +0 -0
  50. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_build_helpers.py +0 -0
  51. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_client.py +0 -0
  52. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_deepseek_r1.py +0 -0
  53. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_disabled_thinking_stability.py +0 -0
  54. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_glm_tool_name_validation.py +0 -0
  55. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_gpt_oss_harmony_parity.py +0 -0
  56. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_hy3.py +0 -0
  57. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_incremental.py +0 -0
  58. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_is_content.py +0 -0
  59. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_kimi_k25_tool_schema.py +0 -0
  60. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_laguna_xs21.py +0 -0
  61. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_llama_3.py +0 -0
  62. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_load_tokenizer.py +0 -0
  63. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_message_indices.py +0 -0
  64. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_message_tool_names.py +0 -0
  65. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_multimodal.py +0 -0
  66. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_parse_response.py +0 -0
  67. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_parse_response_robustness.py +0 -0
  68. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_parsers.py +0 -0
  69. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_preserve_thinking.py +0 -0
  70. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_prime_qwen3_parity.py +0 -0
  71. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_qwen35_size_coverage.py +0 -0
  72. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_render_ids.py +0 -0
  73. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_renderer_config.py +0 -0
  74. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_sampled_mask.py +0 -0
  75. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_tokens_per_message.py +0 -0
  76. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/tests/test_tool_arg_type_preservation.py +0 -0
  77. {renderers-0.1.9.dev10 → renderers-0.1.10.dev4}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: renderers
3
- Version: 0.1.9.dev10
3
+ Version: 0.1.10.dev4
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
@@ -56,7 +56,7 @@ next_prompt_ids = r.bridge_to_next_turn(
56
56
  )
57
57
  ```
58
58
 
59
- Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `nemotron-3`, `nemotron-3-ultra`, `llama-3`, `gpt-oss`, `hy3`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper.
59
+ Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `laguna-xs.2`, `laguna-xs-2.1`, `laguna-m.1`, `nemotron-3`, `nemotron-3-ultra`, `nemotron-3.5`, `llama-3`, `gpt-oss`, `hy3`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper.
60
60
 
61
61
  ## API
62
62
 
@@ -40,7 +40,7 @@ next_prompt_ids = r.bridge_to_next_turn(
40
40
  )
41
41
  ```
42
42
 
43
- Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `nemotron-3`, `nemotron-3-ultra`, `llama-3`, `gpt-oss`, `hy3`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper.
43
+ Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `glm-5`, `glm-5.1`, `glm-4.5`, `minimax-m2`, `deepseek-v3`, `deepseek-r1`, `kimi-k2`, `kimi-k2.5` / `kimi-k2.6`, `laguna-xs.2`, `laguna-xs-2.1`, `laguna-m.1`, `nemotron-3`, `nemotron-3-ultra`, `nemotron-3.5`, `llama-3`, `gpt-oss`, `hy3`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper.
44
44
 
45
45
  ## API
46
46
 
@@ -37,11 +37,13 @@ chat-template kwargs. Those fields are covered by parity tests against
37
37
  | Kimi K2 | `KimiK2RendererConfig` | - | `enable_thinking` |
38
38
  | Kimi K2.5 / 2.6 | `KimiK25RendererConfig` | `thinking` | `image_cache_max` |
39
39
  | Laguna XS.2 | `LagunaXS2RendererConfig` | `enable_thinking`, `render_assistant_messages_raw` | - |
40
+ | Laguna M.1 | `LagunaM1RendererConfig` | `enable_thinking`, `render_assistant_messages_raw` | - |
40
41
  | Laguna XS-2.1 | `LagunaXS21RendererConfig` | `enable_thinking` | - |
41
42
  | Llama 3 | `Llama3RendererConfig` | `date_string`, `tools_in_user_message` | - |
42
43
  | MiniMax M2 | `MiniMaxM2RendererConfig` | `model_identity` | - |
43
44
  | Nemotron-3 Nano / Super | `Nemotron3RendererConfig` | `enable_thinking`, `truncate_history_thinking`, `low_effort` | - |
44
45
  | Nemotron-3 Ultra | `Nemotron3UltraRendererConfig` | `enable_thinking`, `truncate_history_thinking`, `medium_effort` | - |
46
+ | Nemotron-3.5 Lightning | `Nemotron35RendererConfig` | `enable_thinking`, `truncate_history_thinking` | - |
45
47
  | DeepSeek V3 | `DeepSeekV3RendererConfig` | - | - |
46
48
  | DeepSeek R1 | `DeepSeekR1RendererConfig` | - | - |
47
49
 
@@ -133,10 +135,10 @@ the knobs its template actually exposes:
133
135
  | gpt-oss | `auto_drop_analysis=False -> all`, else `tool_cycle` |
134
136
  | Hy3 | `preserved_thinking=True -> all`, else `tool_cycle` |
135
137
  | Kimi K2.5 / 2.6 | `thinking=False -> all`, else `tool_cycle` |
136
- | Nemotron-3 | `truncate_history_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
138
+ | Nemotron-3 / 3.5 | `truncate_history_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
137
139
  | DeepSeek R1 | `template` |
138
140
  | MiniMax M2 | `tool_cycle` |
139
- | DeepSeek V3, Qwen3-VL, Kimi K2, Laguna XS.2 / XS-2.1, Llama 3 | `all` |
141
+ | DeepSeek V3, Qwen3-VL, Kimi K2, Laguna XS.2 / M.1 / XS-2.1, Llama 3 | `all` |
140
142
  | PrimeIntellect Qwen3 | `all` |
141
143
 
142
144
  Config construction raises when an explicit template knob directly contradicts
@@ -54,10 +54,12 @@ from renderers.configs import (
54
54
  Hy3RendererConfig,
55
55
  KimiK25RendererConfig,
56
56
  KimiK2RendererConfig,
57
+ LagunaM1RendererConfig,
57
58
  LagunaXS2RendererConfig,
58
59
  LagunaXS21RendererConfig,
59
60
  Llama3RendererConfig,
60
61
  MiniMaxM2RendererConfig,
62
+ Nemotron35RendererConfig,
61
63
  Nemotron3RendererConfig,
62
64
  Nemotron3UltraRendererConfig,
63
65
  PrimeQwen3RendererConfig,
@@ -90,10 +92,12 @@ _LAZY_RENDERERS: dict[str, str] = {
90
92
  "Hy3Renderer": "renderers.hy3",
91
93
  "KimiK25Renderer": "renderers.kimi_k25",
92
94
  "KimiK2Renderer": "renderers.kimi_k2",
95
+ "LagunaM1Renderer": "renderers.laguna_xs2",
93
96
  "LagunaXS21Renderer": "renderers.laguna_xs2",
94
97
  "LagunaXS2Renderer": "renderers.laguna_xs2",
95
98
  "Llama3Renderer": "renderers.llama_3",
96
99
  "MiniMaxM2Renderer": "renderers.minimax_m2",
100
+ "Nemotron35Renderer": "renderers.nemotron3",
97
101
  "Nemotron3Renderer": "renderers.nemotron3",
98
102
  "Nemotron3UltraRenderer": "renderers.nemotron3",
99
103
  "PrimeQwen3Renderer": "renderers.prime_qwen3",
@@ -145,6 +149,8 @@ __all__ = [
145
149
  "KimiK25RendererConfig",
146
150
  "KimiK2Renderer",
147
151
  "KimiK2RendererConfig",
152
+ "LagunaM1Renderer",
153
+ "LagunaM1RendererConfig",
148
154
  "LagunaXS2Renderer",
149
155
  "LagunaXS2RendererConfig",
150
156
  "LagunaXS21Renderer",
@@ -158,6 +164,8 @@ __all__ = [
158
164
  "MiniMaxM2RendererConfig",
159
165
  "MultiModalData",
160
166
  "MultimodalRenderer",
167
+ "Nemotron35Renderer",
168
+ "Nemotron35RendererConfig",
161
169
  "Nemotron3Renderer",
162
170
  "Nemotron3RendererConfig",
163
171
  "Nemotron3UltraRenderer",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.9.dev10'
22
- __version_tuple__ = version_tuple = (0, 1, 9, 'dev10')
21
+ __version__ = version = '0.1.10.dev4'
22
+ __version_tuple__ = version_tuple = (0, 1, 10, 'dev4')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -117,6 +117,7 @@ class Message(TypedDict, total=False):
117
117
  tool_calls: list[ToolCall]
118
118
  tool_call_id: str
119
119
  name: str
120
+ reasoning: str
120
121
  reasoning_content: str
121
122
 
122
123
 
@@ -1064,6 +1065,9 @@ MODEL_RENDERER_MAP: dict[str, str] = {
1064
1065
  "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-BF16": "nemotron-3",
1065
1066
  "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16": "nemotron-3-ultra",
1066
1067
  "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-FP8": "nemotron-3-ultra",
1068
+ # Nemotron 3.5 (Lightning). Its template is the Ultra variant's minus the
1069
+ # effort kwarg (``nemotron-3.5``).
1070
+ "nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16": "nemotron-3.5",
1067
1071
  # Llama 3.2 (Instruct). Tested against the gated meta-llama repos and
1068
1072
  # the unrestricted unsloth/... mirror, which ships a byte-identical
1069
1073
  # chat template. ``Llama3Renderer`` defaults ``date_string`` to
@@ -1072,9 +1076,10 @@ MODEL_RENDERER_MAP: dict[str, str] = {
1072
1076
  # construction to pin a different date.
1073
1077
  "meta-llama/Llama-3.2-1B-Instruct": "llama-3",
1074
1078
  "meta-llama/Llama-3.2-3B-Instruct": "llama-3",
1075
- # Poolside Laguna. The two checkpoints ship different chat templates,
1076
- # each mirrored by its own renderer class.
1079
+ # Poolside Laguna. These checkpoints ship distinct chat templates,
1080
+ # each mirrored by its own renderer class/config discriminator.
1077
1081
  "poolside/Laguna-XS.2": "laguna-xs.2",
1082
+ "poolside/Laguna-M.1": "laguna-m.1",
1078
1083
  "poolside/Laguna-XS-2.1": "laguna-xs-2.1",
1079
1084
  # GPT-OSS.
1080
1085
  "openai/gpt-oss-20b": "gpt-oss",
@@ -1323,10 +1328,18 @@ def _populate_registry():
1323
1328
  from renderers.hy3 import Hy3Renderer
1324
1329
  from renderers.kimi_k2 import KimiK2Renderer
1325
1330
  from renderers.kimi_k25 import KimiK25Renderer
1326
- from renderers.laguna_xs2 import LagunaXS2Renderer, LagunaXS21Renderer
1331
+ from renderers.laguna_xs2 import (
1332
+ LagunaM1Renderer,
1333
+ LagunaXS2Renderer,
1334
+ LagunaXS21Renderer,
1335
+ )
1327
1336
  from renderers.llama_3 import Llama3Renderer
1328
1337
  from renderers.minimax_m2 import MiniMaxM2Renderer
1329
- from renderers.nemotron3 import Nemotron3Renderer, Nemotron3UltraRenderer
1338
+ from renderers.nemotron3 import (
1339
+ Nemotron3Renderer,
1340
+ Nemotron3UltraRenderer,
1341
+ Nemotron35Renderer,
1342
+ )
1330
1343
  from renderers.prime_qwen3 import PrimeQwen3Renderer
1331
1344
  from renderers.qwen3 import Qwen3Renderer
1332
1345
  from renderers.qwen3_vl import Qwen3VLRenderer
@@ -1351,10 +1364,12 @@ def _populate_registry():
1351
1364
  "kimi-k2": KimiK2Renderer,
1352
1365
  "kimi-k2.5": KimiK25Renderer,
1353
1366
  "laguna-xs.2": LagunaXS2Renderer,
1367
+ "laguna-m.1": LagunaM1Renderer,
1354
1368
  "laguna-xs-2.1": LagunaXS21Renderer,
1355
1369
  "llama-3": Llama3Renderer,
1356
1370
  "nemotron-3": Nemotron3Renderer,
1357
1371
  "nemotron-3-ultra": Nemotron3UltraRenderer,
1372
+ "nemotron-3.5": Nemotron35Renderer,
1358
1373
  "gpt-oss": GptOssRenderer,
1359
1374
  }
1360
1375
  )
@@ -505,6 +505,28 @@ class LagunaXS2RendererConfig(BaseRendererConfig):
505
505
  chat template's ``render_assistant_messages_raw`` gate."""
506
506
 
507
507
 
508
+ class LagunaM1RendererConfig(BaseRendererConfig):
509
+ """Laguna M.1 renderer config.
510
+
511
+ Laguna M.1 shares Laguna XS.2's role and tool-call format, but its
512
+ official checkpoint has a distinct chat template: it does not inject
513
+ XS.2's fallback system message and it gives ``message.reasoning``
514
+ precedence over ``message.reasoning_content``. Served by
515
+ :class:`renderers.laguna_xs2.LagunaM1Renderer`.
516
+ """
517
+
518
+ name: Literal["laguna-m.1"] = "laguna-m.1"
519
+
520
+ enable_thinking: bool = False
521
+ """When ``True``, the generation prompt includes ``<think>``. Mirrors
522
+ the official template's ``enable_thinking`` kwarg and default."""
523
+
524
+ render_assistant_messages_raw: bool = False
525
+ """When ``True``, assistant messages use the official template's
526
+ verbatim passthrough branch. See
527
+ :class:`LagunaXS2RendererConfig.render_assistant_messages_raw`."""
528
+
529
+
508
530
  class LagunaXS21RendererConfig(BaseRendererConfig):
509
531
  """Laguna XS-2.1 renderer config.
510
532
 
@@ -635,6 +657,37 @@ class Nemotron3UltraRendererConfig(BaseRendererConfig):
635
657
  user message. Mirrors the Ultra chat template's ``medium_effort`` kwarg."""
636
658
 
637
659
 
660
+ class Nemotron35RendererConfig(BaseRendererConfig):
661
+ """Nemotron-3.5 (Lightning) renderer config.
662
+
663
+ Nemotron 3.5's chat template is the Ultra variant's minus the effort
664
+ kwarg: same reasoning-block glue (no ``\\n`` around ``</think>``, no
665
+ trim on truncated tool-call history), but its Jinja defines no
666
+ reasoning-effort variable at all. It shares the
667
+ :class:`renderers.nemotron3.Nemotron3Renderer` implementation via the
668
+ :class:`renderers.nemotron3.Nemotron35Renderer` subclass and is reached
669
+ via the ``nemotron-3.5`` discriminator.
670
+ """
671
+
672
+ name: Literal["nemotron-3.5"] = "nemotron-3.5"
673
+
674
+ enable_thinking: bool = True
675
+ """See :class:`Nemotron3RendererConfig.enable_thinking`."""
676
+
677
+ truncate_history_thinking: bool = True
678
+ """See :class:`Nemotron3RendererConfig.truncate_history_thinking`."""
679
+
680
+ @model_validator(mode="after")
681
+ def _check_thinking_retention(self):
682
+ _reject_thinking_retention_conflict(
683
+ self,
684
+ "truncate_history_thinking",
685
+ true_implies="tool_cycle",
686
+ false_implies="all",
687
+ )
688
+ return self
689
+
690
+
638
691
  class DeepSeekV3RendererConfig(BaseRendererConfig):
639
692
  """DeepSeek-V3 renderer config (non-reasoning).
640
693
 
@@ -680,11 +733,13 @@ RendererConfig = Annotated[
680
733
  KimiK2RendererConfig,
681
734
  KimiK25RendererConfig,
682
735
  LagunaXS2RendererConfig,
736
+ LagunaM1RendererConfig,
683
737
  LagunaXS21RendererConfig,
684
738
  Llama3RendererConfig,
685
739
  MiniMaxM2RendererConfig,
686
740
  Nemotron3RendererConfig,
687
741
  Nemotron3UltraRendererConfig,
742
+ Nemotron35RendererConfig,
688
743
  DeepSeekV3RendererConfig,
689
744
  DeepSeekR1RendererConfig,
690
745
  ],
@@ -720,11 +775,13 @@ _CONFIG_BY_NAME: dict[str, type[BaseRendererConfig]] = {
720
775
  "kimi-k2": KimiK2RendererConfig,
721
776
  "kimi-k2.5": KimiK25RendererConfig,
722
777
  "laguna-xs.2": LagunaXS2RendererConfig,
778
+ "laguna-m.1": LagunaM1RendererConfig,
723
779
  "laguna-xs-2.1": LagunaXS21RendererConfig,
724
780
  "llama-3": Llama3RendererConfig,
725
781
  "minimax-m2": MiniMaxM2RendererConfig,
726
782
  "nemotron-3": Nemotron3RendererConfig,
727
783
  "nemotron-3-ultra": Nemotron3UltraRendererConfig,
784
+ "nemotron-3.5": Nemotron35RendererConfig,
728
785
  "deepseek-v3": DeepSeekV3RendererConfig,
729
786
  "deepseek-r1": DeepSeekR1RendererConfig,
730
787
  }
@@ -767,10 +824,12 @@ __all__ = [
767
824
  "Hy3RendererConfig",
768
825
  "KimiK25RendererConfig",
769
826
  "KimiK2RendererConfig",
827
+ "LagunaM1RendererConfig",
770
828
  "LagunaXS2RendererConfig",
771
829
  "LagunaXS21RendererConfig",
772
830
  "Llama3RendererConfig",
773
831
  "MiniMaxM2RendererConfig",
832
+ "Nemotron35RendererConfig",
774
833
  "Nemotron3RendererConfig",
775
834
  "Nemotron3UltraRendererConfig",
776
835
  "PrimeQwen3RendererConfig",
@@ -1,4 +1,4 @@
1
- """Laguna-XS.2 / XS-2.1 Renderer.
1
+ """Poolside Laguna renderer family.
2
2
 
3
3
  Main properties:
4
4
  - Prefix is the single token ``〈|EOS|〉`` (also the EOS / stop token).
@@ -36,6 +36,11 @@ XS-2.1's template (upstream rev ``575f0f28``) is served by the
36
36
  section ends at ``</available_tools>``.
37
37
  - The ``<system>`` block is emitted whenever there is system content,
38
38
  tools, or ``enable_thinking`` — even if that leaves it empty.
39
+
40
+ Laguna M.1's official template (revision ``2bf8a4ab``) is served by
41
+ :class:`LagunaM1Renderer`. It shares XS.2's byte layout, tool syntax, and
42
+ generation prompt, but has no fallback system prompt and reads assistant
43
+ reasoning from ``reasoning`` before falling back to ``reasoning_content``.
39
44
  """
40
45
 
41
46
  from __future__ import annotations
@@ -56,7 +61,11 @@ from renderers.base import (
56
61
  resolve_thinking_retention,
57
62
  should_rerender_for_thinking_retention,
58
63
  )
59
- from renderers.configs import LagunaXS2RendererConfig, LagunaXS21RendererConfig
64
+ from renderers.configs import (
65
+ LagunaM1RendererConfig,
66
+ LagunaXS2RendererConfig,
67
+ LagunaXS21RendererConfig,
68
+ )
60
69
  from renderers.parsing import parse_laguna_xs2
61
70
 
62
71
  _DEFAULT_SYSTEM_MESSAGE = (
@@ -107,7 +116,12 @@ class LagunaXS2Renderer:
107
116
  def __init__(
108
117
  self,
109
118
  tokenizer: PreTrainedTokenizer,
110
- config: LagunaXS2RendererConfig | LagunaXS21RendererConfig | None = None,
119
+ config: (
120
+ LagunaXS2RendererConfig
121
+ | LagunaM1RendererConfig
122
+ | LagunaXS21RendererConfig
123
+ | None
124
+ ) = None,
111
125
  ):
112
126
  self._tokenizer = tokenizer
113
127
  self.config = config or LagunaXS2RendererConfig()
@@ -481,12 +495,9 @@ class LagunaXS2Renderer:
481
495
  emit_text,
482
496
  emit_text_segments,
483
497
  ) -> None:
484
- # Raw passthrough is an XS.2-only template gate; the XS-2.1
485
- # config doesn't define it.
486
- if (
487
- isinstance(self.config, LagunaXS2RendererConfig)
488
- and self.config.render_assistant_messages_raw
489
- ):
498
+ # Raw passthrough is shared by XS.2 and M.1; XS-2.1's config does
499
+ # not expose this template gate.
500
+ if getattr(self.config, "render_assistant_messages_raw", False):
490
501
  self._render_assistant_raw(
491
502
  msg_idx,
492
503
  content,
@@ -495,16 +506,7 @@ class LagunaXS2Renderer:
495
506
  )
496
507
  return
497
508
 
498
- reasoning_content = ""
499
- if isinstance(msg.get("reasoning_content"), str):
500
- reasoning_content = msg["reasoning_content"]
501
- else:
502
- # When the caller stores reasoning as a ``ThinkingPart`` inside
503
- # a list-form ``content`` (e.g. after parse_response →
504
- # reserialize), pull it out here so it survives the re-render.
505
- part_thinking = self._thinking_text(msg.get("content"))
506
- if part_thinking:
507
- reasoning_content = part_thinking
509
+ reasoning_content, content = self._assistant_reasoning_and_content(msg, content)
508
510
 
509
511
  # ``<assistant>\n`` is template-injected scaffolding — the chat
510
512
  # template emits these as the generation prompt at inference and
@@ -567,6 +569,22 @@ class LagunaXS2Renderer:
567
569
  emit_special(self._assistant_end, msg_idx, is_sampled=True, is_content=True)
568
570
  emit_text("\n", msg_idx, is_sampled=False, is_content=False)
569
571
 
572
+ def _assistant_reasoning_and_content(
573
+ self, msg: Message, content: str
574
+ ) -> tuple[str, str]:
575
+ """Return the reasoning/body pair used by the XS.2 template."""
576
+ reasoning_content = ""
577
+ if isinstance(msg.get("reasoning_content"), str):
578
+ reasoning_content = msg["reasoning_content"]
579
+ else:
580
+ # When the caller stores reasoning as a ``ThinkingPart`` inside
581
+ # a list-form ``content`` (e.g. after parse_response →
582
+ # reserialize), pull it out here so it survives the re-render.
583
+ part_thinking = self._thinking_text(msg.get("content"))
584
+ if part_thinking:
585
+ reasoning_content = part_thinking
586
+ return reasoning_content, content
587
+
570
588
  def _render_assistant_raw(
571
589
  self,
572
590
  msg_idx: int,
@@ -620,6 +638,43 @@ class LagunaXS2Renderer:
620
638
  emit_text("\n", msg_idx, is_sampled=False, is_content=False)
621
639
 
622
640
 
641
+ class LagunaM1Renderer(LagunaXS2Renderer):
642
+ """Renderer for the official ``poolside/Laguna-M.1`` checkpoint.
643
+
644
+ The token protocol is shared with XS.2, while the absent fallback
645
+ system prompt and assistant-reasoning precedence are M.1-specific.
646
+ """
647
+
648
+ def __init__(
649
+ self,
650
+ tokenizer: PreTrainedTokenizer,
651
+ config: LagunaM1RendererConfig | None = None,
652
+ ):
653
+ super().__init__(tokenizer, config or LagunaM1RendererConfig())
654
+ self._default_system_message = ""
655
+
656
+ def _assistant_reasoning_and_content(
657
+ self, msg: Message, content: str
658
+ ) -> tuple[str, str]:
659
+ # Match the official Jinja exactly: ``reasoning`` wins whenever it
660
+ # is a string (including the empty string), then
661
+ # ``reasoning_content`` is considered. An inline </think> block is
662
+ # removed from content and becomes the fallback reasoning source.
663
+ reasoning_content = ""
664
+ if isinstance(msg.get("reasoning"), str):
665
+ reasoning_content = msg["reasoning"]
666
+ elif isinstance(msg.get("reasoning_content"), str):
667
+ reasoning_content = msg["reasoning_content"]
668
+
669
+ if "</think>" in content:
670
+ if not reasoning_content:
671
+ before_close = content.split("</think>", 1)[0].rstrip("\n")
672
+ reasoning_content = before_close.split("<think>")[-1].lstrip("\n")
673
+ content = content.rsplit("</think>", 1)[-1].lstrip("\n")
674
+
675
+ return reasoning_content, content
676
+
677
+
623
678
  class LagunaXS21Renderer(LagunaXS2Renderer):
624
679
  """Laguna-XS-2.1 — mirrors the ``poolside/Laguna-XS-2.1`` chat
625
680
  template (upstream rev ``575f0f28``); see the module docstring for
@@ -31,7 +31,11 @@ from renderers.base import (
31
31
  should_rerender_for_thinking_retention,
32
32
  trim_to_turn_close,
33
33
  )
34
- from renderers.configs import Nemotron3RendererConfig, Nemotron3UltraRendererConfig
34
+ from renderers.configs import (
35
+ Nemotron3RendererConfig,
36
+ Nemotron3UltraRendererConfig,
37
+ Nemotron35RendererConfig,
38
+ )
35
39
  from renderers.parsing import parse_qwen35
36
40
 
37
41
  # ---------------------------------------------------------------------------
@@ -76,10 +80,12 @@ def _render_extra_keys(obj: dict[str, Any], handled_keys: set[str]) -> list[str]
76
80
  return lines
77
81
 
78
82
 
79
- # The Nemotron-3 family ships two chat-template variants. Nano / Super share
83
+ # The Nemotron family ships three chat-template variants. Nano / Super share
80
84
  # one (renderer ``Nemotron3Renderer`` / config ``name="nemotron-3"``); Ultra
81
85
  # differs in the reasoning-block glue — no ``\n`` around ``</think>`` — and is
82
- # the ``Nemotron3UltraRenderer`` subclass (``name="nemotron-3-ultra"``). Which
86
+ # the ``Nemotron3UltraRenderer`` subclass (``name="nemotron-3-ultra"``);
87
+ # Nemotron 3.5 (Lightning) uses the Ultra glue but defines no effort kwarg and
88
+ # is the ``Nemotron35Renderer`` subclass (``name="nemotron-3.5"``). Which
83
89
  # variant a checkpoint uses is carried by ``MODEL_RENDERER_MAP``, so the right
84
90
  # renderer class is constructed and the variant is encoded by the class itself.
85
91
 
@@ -852,3 +858,16 @@ class Nemotron3UltraRenderer(Nemotron3Renderer):
852
858
 
853
859
  _config_cls = Nemotron3UltraRendererConfig
854
860
  _ultra = True
861
+
862
+
863
+ class Nemotron35Renderer(Nemotron3Renderer):
864
+ """Renderer for Nemotron **3.5** (Lightning).
865
+
866
+ The 3.5 chat template is the Ultra variant's minus the effort kwarg: same
867
+ reasoning-block glue (carried by the ``_ultra`` class hook), but its Jinja
868
+ defines no reasoning-effort variable, so the config exposes none and the
869
+ effort hint is always empty.
870
+ """
871
+
872
+ _config_cls = Nemotron35RendererConfig
873
+ _ultra = True
@@ -36,7 +36,11 @@ RENDERER_MODELS = [
36
36
  # Ultra resolves to the `nemotron-3-ultra` config variant via the model
37
37
  # name (auto → MODEL_RENDERER_MAP → nemotron-3-ultra).
38
38
  ("nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16", "auto"),
39
+ # Nemotron 3.5 (Lightning): Ultra's template variant minus the effort
40
+ # kwarg (auto → MODEL_RENDERER_MAP → nemotron-3.5).
41
+ ("nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16", "auto"),
39
42
  ("poolside/Laguna-XS.2", "auto"),
43
+ ("poolside/Laguna-M.1", "auto"),
40
44
  # XS-2.1 resolves to `LagunaXS21Renderer` via the model name — its
41
45
  # chat template differs from XS.2's (see renderers/laguna_xs2.py).
42
46
  ("poolside/Laguna-XS-2.1", "auto"),
@@ -33,6 +33,7 @@ _BRIDGE_MODELS = [
33
33
  ("moonshotai/Kimi-K2-Instruct", "auto"),
34
34
  ("moonshotai/Kimi-K2.5", "auto"),
35
35
  ("nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16", "auto"),
36
+ ("nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16", "auto"),
36
37
  ("tencent/Hy3", "auto"),
37
38
  ("unsloth/Llama-3.2-1B-Instruct", "llama-3"),
38
39
  ("openai/gpt-oss-20b", "gpt-oss"),
@@ -0,0 +1,232 @@
1
+ """Exact chat-template parity for the official Laguna M.1 checkpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+
7
+ import pytest
8
+ from pydantic import TypeAdapter
9
+
10
+ from renderers import (
11
+ LagunaM1Renderer,
12
+ LagunaM1RendererConfig,
13
+ RendererConfig,
14
+ create_renderer,
15
+ )
16
+ from renderers.base import load_tokenizer
17
+
18
+ _MODEL = "poolside/Laguna-M.1"
19
+
20
+ TOOLS = [
21
+ {
22
+ "type": "function",
23
+ "function": {
24
+ "name": "shell",
25
+ "description": "Run a shell command",
26
+ "parameters": {
27
+ "type": "object",
28
+ "properties": {
29
+ "cmd": {"type": "string"},
30
+ "timeout": {"type": "integer"},
31
+ },
32
+ "required": ["cmd"],
33
+ },
34
+ },
35
+ }
36
+ ]
37
+
38
+
39
+ @lru_cache(maxsize=None)
40
+ def _tok():
41
+ return load_tokenizer(_MODEL)
42
+
43
+
44
+ def _renderer(**kwargs) -> LagunaM1Renderer:
45
+ renderer = create_renderer(_tok(), LagunaM1RendererConfig(**kwargs))
46
+ assert isinstance(renderer, LagunaM1Renderer)
47
+ return renderer
48
+
49
+
50
+ def _expected(messages, *, tools=None, add_generation_prompt=False, **kwargs):
51
+ return list(
52
+ _tok().apply_chat_template(
53
+ messages,
54
+ tools=tools,
55
+ add_generation_prompt=add_generation_prompt,
56
+ tokenize=True,
57
+ return_dict=False,
58
+ **kwargs,
59
+ )
60
+ )
61
+
62
+
63
+ def test_auto_selection_and_typed_config():
64
+ renderer = create_renderer(_tok())
65
+ assert isinstance(renderer, LagunaM1Renderer)
66
+ assert isinstance(renderer.config, LagunaM1RendererConfig)
67
+
68
+ parsed = TypeAdapter(RendererConfig).validate_python(
69
+ {"name": "laguna-m.1", "enable_thinking": True}
70
+ )
71
+ assert isinstance(parsed, LagunaM1RendererConfig)
72
+ assert parsed.enable_thinking is True
73
+
74
+
75
+ @pytest.mark.parametrize("enable_thinking", [False, True])
76
+ def test_no_system_and_generation_prompt_parity(enable_thinking):
77
+ messages = [{"role": "user", "content": " preserve me "}]
78
+ renderer = _renderer(enable_thinking=enable_thinking)
79
+ got = renderer.render_ids(messages, add_generation_prompt=True)
80
+ assert got == _expected(
81
+ messages,
82
+ add_generation_prompt=True,
83
+ enable_thinking=enable_thinking,
84
+ )
85
+
86
+ text = _tok().decode(got)
87
+ assert text.startswith("〈|EOS|〉<user>\n preserve me \n</user>\n<assistant>\n")
88
+ assert "<system>" not in text
89
+ assert text.endswith("<think>" if enable_thinking else "</think>")
90
+
91
+
92
+ @pytest.mark.parametrize("enable_thinking", [False, True])
93
+ def test_reasoning_content_history_parity_in_both_modes(enable_thinking):
94
+ messages = [
95
+ {"role": "user", "content": "Compute."},
96
+ {
97
+ "role": "assistant",
98
+ "reasoning_content": "\n two steps \n",
99
+ "content": "\n result \n",
100
+ },
101
+ ]
102
+ got = _renderer(enable_thinking=enable_thinking).render_ids(messages)
103
+ assert got == _expected(messages, enable_thinking=enable_thinking)
104
+ assert "<think>\ntwo steps\n</think>\nresult\n</assistant>\n" in _tok().decode(got)
105
+
106
+
107
+ def test_reasoning_field_precedence_and_inline_think_extraction():
108
+ precedence = [
109
+ {"role": "user", "content": "Compute."},
110
+ {
111
+ "role": "assistant",
112
+ "reasoning": "preferred",
113
+ "reasoning_content": "ignored",
114
+ "content": "answer",
115
+ },
116
+ ]
117
+ got = _renderer(enable_thinking=True).render_ids(precedence)
118
+ assert got == _expected(precedence, enable_thinking=True)
119
+ text = _tok().decode(got)
120
+ assert "preferred" in text
121
+ assert "ignored" not in text
122
+
123
+ empty_reasoning_wins = [
124
+ {"role": "user", "content": "Compute."},
125
+ {
126
+ "role": "assistant",
127
+ "reasoning": "",
128
+ "reasoning_content": "also ignored",
129
+ "content": "answer",
130
+ },
131
+ ]
132
+ got = _renderer(enable_thinking=True).render_ids(empty_reasoning_wins)
133
+ assert got == _expected(empty_reasoning_wins, enable_thinking=True)
134
+ assert "also ignored" not in _tok().decode(got)
135
+
136
+ inline = [
137
+ {"role": "user", "content": "Compute."},
138
+ {
139
+ "role": "assistant",
140
+ "content": "<think>\ninline reason\n</think>\nvisible answer",
141
+ },
142
+ ]
143
+ assert _renderer().render_ids(inline) == _expected(inline)
144
+
145
+
146
+ def test_tools_calls_responses_and_generation_prompt_parity():
147
+ messages = [
148
+ {"role": "system", "content": "Use tools carefully."},
149
+ {"role": "user", "content": "Inspect the machine."},
150
+ {
151
+ "role": "assistant",
152
+ "reasoning": "Need a command.",
153
+ "content": "Running it.",
154
+ "tool_calls": [
155
+ {
156
+ "id": "call_1",
157
+ "type": "function",
158
+ "function": {
159
+ "name": "shell",
160
+ "arguments": {"cmd": "uname -a", "timeout": 5},
161
+ },
162
+ }
163
+ ],
164
+ },
165
+ {
166
+ "role": "tool",
167
+ "tool_call_id": "call_1",
168
+ "content": '{"stdout":"Linux"}',
169
+ },
170
+ ]
171
+ renderer = _renderer(enable_thinking=True)
172
+ got = renderer.render_ids(messages, tools=TOOLS, add_generation_prompt=True)
173
+ assert got == _expected(
174
+ messages,
175
+ tools=TOOLS,
176
+ add_generation_prompt=True,
177
+ enable_thinking=True,
178
+ )
179
+
180
+ text = _tok().decode(got)
181
+ assert "<available_tools>\n" in text
182
+ assert (
183
+ "<tool_call>shell\n<arg_key>cmd</arg_key>\n"
184
+ "<arg_value>uname -a</arg_value>\n<arg_key>timeout</arg_key>\n"
185
+ "<arg_value>5</arg_value>\n</tool_call>\n"
186
+ ) in text
187
+ assert '<tool_response>\n{"stdout":"Linux"}\n</tool_response>\n' in text
188
+ assert text.endswith("<assistant>\n<think>")
189
+
190
+
191
+ def test_reasoning_content_and_tool_call_round_trip():
192
+ renderer = _renderer(enable_thinking=True)
193
+ prompt = [{"role": "user", "content": "Inspect the machine."}]
194
+ assistant = {
195
+ "role": "assistant",
196
+ "reasoning": "Need a command.",
197
+ "content": "Running it.",
198
+ "tool_calls": [
199
+ {
200
+ "function": {
201
+ "name": "shell",
202
+ "arguments": {"cmd": "uname -a", "timeout": 5},
203
+ }
204
+ }
205
+ ],
206
+ }
207
+ prompt_ids = renderer.render_ids(prompt, add_generation_prompt=True)
208
+ full_ids = renderer.render_ids([*prompt, assistant])
209
+ completion_ids = full_ids[len(prompt_ids) :]
210
+ parsed = renderer.parse_response(completion_ids, tools=TOOLS)
211
+
212
+ assert parsed.reasoning_content == "Need a command."
213
+ assert parsed.content == "Running it."
214
+ assert len(parsed.tool_calls) == 1
215
+ assert parsed.tool_calls[0].name == "shell"
216
+ assert parsed.tool_calls[0].arguments == {"cmd": "uname -a", "timeout": 5}
217
+
218
+
219
+ def test_raw_assistant_round_trip_parity():
220
+ messages = [
221
+ {"role": "user", "content": "Continue."},
222
+ {
223
+ "role": "assistant",
224
+ "content": "<think>raw reason</think>raw body</assistant>",
225
+ },
226
+ ]
227
+ renderer = _renderer(enable_thinking=True, render_assistant_messages_raw=True)
228
+ assert renderer.render_ids(messages) == _expected(
229
+ messages,
230
+ enable_thinking=True,
231
+ render_assistant_messages_raw=True,
232
+ )
@@ -25,9 +25,10 @@ Every assertion compares ``renderer.render_ids(...)`` to
25
25
  renderer is byte-for-byte faithful for that case. Tokenizers are loaded from
26
26
  the local HF cache (offline); no network.
27
27
 
28
- The variants split across two configs: ``nemotron-3`` (Nano / Super, with
29
- ``low_effort``) and ``nemotron-3-ultra`` (Ultra, with ``medium_effort``). The
30
- helper resolves the right config class per model from ``MODEL_RENDERER_MAP``.
28
+ The variants split across three configs: ``nemotron-3`` (Nano / Super, with
29
+ ``low_effort``), ``nemotron-3-ultra`` (Ultra, with ``medium_effort``), and
30
+ ``nemotron-3.5`` (Lightning Ultra's glue, no effort kwarg). The helper
31
+ resolves the right config class per model from ``MODEL_RENDERER_MAP``.
31
32
  """
32
33
 
33
34
  from __future__ import annotations
@@ -44,7 +45,8 @@ from renderers.configs import _config_class_for
44
45
  NANO = "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16"
45
46
  SUPER = "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-BF16"
46
47
  ULTRA = "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16"
47
- MODELS = [NANO, SUPER, ULTRA]
48
+ LIGHTNING = "nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16"
49
+ MODELS = [NANO, SUPER, ULTRA, LIGHTNING]
48
50
 
49
51
 
50
52
  @lru_cache
@@ -107,7 +109,9 @@ def _assert_parity(
107
109
  )
108
110
 
109
111
 
110
- pytestmark = pytest.mark.parametrize("model", MODELS, ids=["nano", "super", "ultra"])
112
+ pytestmark = pytest.mark.parametrize(
113
+ "model", MODELS, ids=["nano", "super", "ultra", "lightning"]
114
+ )
111
115
 
112
116
 
113
117
  TOOLS = [
@@ -648,8 +652,8 @@ _EFFORT_SHAPES = [
648
652
  def test_low_effort_kwarg(model, flag, shape, extra):
649
653
  """``low_effort`` appends ``\\n\\n{reasoning effort: low}`` to the last user
650
654
  message on **Super**; it's a no-op on **Nano** (its template never defines
651
- it). Ultra's config has no such field, so it's skipped."""
652
- if model == ULTRA:
655
+ it). The Ultra / Lightning configs have no such field, so they're skipped."""
656
+ if model in (ULTRA, LIGHTNING):
653
657
  pytest.skip("low_effort is a nemotron-3 (Nano/Super) kwarg")
654
658
  _assert_parity(model, shape, low_effort=flag, **extra)
655
659
 
@@ -660,7 +664,7 @@ def test_low_effort_kwarg(model, flag, shape, extra):
660
664
  )
661
665
  def test_medium_effort_kwarg(model, flag, shape, extra):
662
666
  """``medium_effort`` appends ``\\n\\n{reasoning effort: efficient}`` on
663
- **Ultra**. Nano/Super configs have no such field, so they're skipped."""
667
+ **Ultra**. The other configs have no such field, so they're skipped."""
664
668
  if model != ULTRA:
665
669
  pytest.skip("medium_effort is a nemotron-3-ultra kwarg")
666
670
  _assert_parity(model, shape, medium_effort=flag, **extra)
@@ -668,9 +672,12 @@ def test_medium_effort_kwarg(model, flag, shape, extra):
668
672
 
669
673
  def test_effort_kwarg_lives_on_the_right_variant(model):
670
674
  """Each effort kwarg is declared only on the variant whose template defines
671
- it — the discriminated union rejects the wrong combination at config load."""
675
+ it — the discriminated union rejects the wrong combination at config load.
676
+ Lightning's template defines no effort variable, so its config has neither."""
672
677
  fields = _config_cls(model).template_field_names()
673
678
  if model == ULTRA:
674
679
  assert "medium_effort" in fields and "low_effort" not in fields
680
+ elif model == LIGHTNING:
681
+ assert "low_effort" not in fields and "medium_effort" not in fields
675
682
  else:
676
683
  assert "low_effort" in fields and "medium_effort" not in fields
@@ -1,4 +1,4 @@
1
- """Offline wiring tests for the Nemotron-3 variant split.
1
+ """Offline wiring tests for the Nemotron variant split.
2
2
 
3
3
  Assert the model→renderer mapping, the per-variant typed-config surface, and
4
4
  the name-based ``low_effort`` gating WITHOUT loading any tokenizer (no
@@ -6,15 +6,16 @@ network). This pins the wiring the parity matrix can't reach — in particular
6
6
  the FP8 Ultra entry, which no test loads a tokenizer for — so it can't
7
7
  silently rot.
8
8
 
9
- The two variants:
9
+ The three variants:
10
10
 
11
11
  * ``nemotron-3`` — Nano / Super, shared template. Config exposes ``low_effort``
12
12
  (honoured on Super, a no-op on Nano).
13
13
  * ``nemotron-3-ultra`` — Ultra, distinct ``</think>`` glue. Config exposes
14
14
  ``medium_effort``.
15
+ * ``nemotron-3.5`` — Lightning, Ultra's glue but no effort kwarg at all.
15
16
 
16
- Both route to the one ``Nemotron3Renderer`` class, which selects the variant
17
- from ``config.name``.
17
+ All route to the one ``Nemotron3Renderer`` implementation via per-variant
18
+ subclasses.
18
19
  """
19
20
 
20
21
  from types import SimpleNamespace
@@ -23,9 +24,15 @@ from renderers.base import MODEL_RENDERER_MAP, RENDERER_REGISTRY, _populate_regi
23
24
  from renderers.configs import (
24
25
  Nemotron3RendererConfig,
25
26
  Nemotron3UltraRendererConfig,
27
+ Nemotron35RendererConfig,
26
28
  _config_class_for,
27
29
  )
28
- from renderers.nemotron3 import Nemotron3Renderer, Nemotron3UltraRenderer, _is_super
30
+ from renderers.nemotron3 import (
31
+ Nemotron3Renderer,
32
+ Nemotron3UltraRenderer,
33
+ Nemotron35Renderer,
34
+ _is_super,
35
+ )
29
36
 
30
37
  _ULTRA_REPOS = [
31
38
  "nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16",
@@ -35,6 +42,9 @@ _NANO_SUPER_REPOS = [
35
42
  "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16",
36
43
  "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-BF16",
37
44
  ]
45
+ _LIGHTNING_REPOS = [
46
+ "nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16",
47
+ ]
38
48
 
39
49
 
40
50
  def _fake_tok(name):
@@ -46,18 +56,23 @@ def test_models_map_to_their_variant():
46
56
  assert MODEL_RENDERER_MAP.get(repo) == "nemotron-3-ultra", repo
47
57
  for repo in _NANO_SUPER_REPOS:
48
58
  assert MODEL_RENDERER_MAP.get(repo) == "nemotron-3", repo
59
+ for repo in _LIGHTNING_REPOS:
60
+ assert MODEL_RENDERER_MAP.get(repo) == "nemotron-3.5", repo
49
61
 
50
62
 
51
63
  def test_each_discriminator_maps_to_its_config_and_renderer_class():
52
64
  # Config discriminator → config class.
53
65
  assert _config_class_for("nemotron-3") is Nemotron3RendererConfig
54
66
  assert _config_class_for("nemotron-3-ultra") is Nemotron3UltraRendererConfig
55
- # Registry → renderer class (Ultra is a sibling subclass, matching the
56
- # GLM-5/5.1 and Qwen3.5/3.6 house style not one class under two names).
67
+ assert _config_class_for("nemotron-3.5") is Nemotron35RendererConfig
68
+ # Registry renderer class (Ultra / 3.5 are sibling subclasses, matching
69
+ # the GLM-5/5.1 and Qwen3.5/3.6 house style — not one class under N names).
57
70
  _populate_registry()
58
71
  assert RENDERER_REGISTRY["nemotron-3"] is Nemotron3Renderer
59
72
  assert RENDERER_REGISTRY["nemotron-3-ultra"] is Nemotron3UltraRenderer
73
+ assert RENDERER_REGISTRY["nemotron-3.5"] is Nemotron35Renderer
60
74
  assert issubclass(Nemotron3UltraRenderer, Nemotron3Renderer)
75
+ assert issubclass(Nemotron35Renderer, Nemotron3Renderer)
61
76
 
62
77
 
63
78
  def test_variant_is_encoded_by_the_class():
@@ -66,8 +81,10 @@ def test_variant_is_encoded_by_the_class():
66
81
  # config.name → class). Default config also follows the class.
67
82
  assert Nemotron3Renderer._ultra is False
68
83
  assert Nemotron3UltraRenderer._ultra is True
84
+ assert Nemotron35Renderer._ultra is True
69
85
  assert Nemotron3Renderer._config_cls is Nemotron3RendererConfig
70
86
  assert Nemotron3UltraRenderer._config_cls is Nemotron3UltraRendererConfig
87
+ assert Nemotron35Renderer._config_cls is Nemotron35RendererConfig
71
88
 
72
89
 
73
90
  def test_template_fields_per_variant():
@@ -80,6 +97,10 @@ def test_template_fields_per_variant():
80
97
  assert Nemotron3UltraRendererConfig.template_field_names() == frozenset(
81
98
  {"enable_thinking", "truncate_history_thinking", "medium_effort"}
82
99
  )
100
+ # Lightning's template defines no effort variable at all.
101
+ assert Nemotron35RendererConfig.template_field_names() == frozenset(
102
+ {"enable_thinking", "truncate_history_thinking"}
103
+ )
83
104
 
84
105
 
85
106
  def test_configs_reject_the_other_variants_effort_kwarg():
@@ -91,6 +112,10 @@ def test_configs_reject_the_other_variants_effort_kwarg():
91
112
  Nemotron3RendererConfig(medium_effort=True) # type: ignore[call-arg]
92
113
  with pytest.raises(ValidationError):
93
114
  Nemotron3UltraRendererConfig(low_effort=True) # type: ignore[call-arg]
115
+ with pytest.raises(ValidationError):
116
+ Nemotron35RendererConfig(low_effort=True) # type: ignore[call-arg]
117
+ with pytest.raises(ValidationError):
118
+ Nemotron35RendererConfig(medium_effort=True) # type: ignore[call-arg]
94
119
  # And the removed ``ultra`` selector is gone entirely.
95
120
  with pytest.raises(ValidationError):
96
121
  Nemotron3RendererConfig(ultra=True) # type: ignore[call-arg]
@@ -63,7 +63,11 @@ _RENDERER_MODELS = [
63
63
  # Ultra: auto-resolves to the ``nemotron-3-ultra`` config via the model
64
64
  # name; parity asserted against the Ultra apply_chat_template (``medium_effort``).
65
65
  ("nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16", "auto"),
66
+ # Nemotron 3.5 (Lightning): Ultra's template minus the effort kwarg — its
67
+ # config declares only ``enable_thinking`` / ``truncate_history_thinking``.
68
+ ("nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16", "auto"),
66
69
  ("poolside/Laguna-XS.2", "auto"),
70
+ ("poolside/Laguna-M.1", "auto"),
67
71
  ("poolside/Laguna-XS-2.1", "auto"),
68
72
  ("tencent/Hy3", "auto"),
69
73
  ("openai/gpt-oss-20b", "gpt-oss"),
@@ -119,7 +123,7 @@ _KWARG_VALUES: dict[str, list[Any]] = {
119
123
  "You are a helpful assistant. Your name is MiniMax-M2.5 and is built by MiniMax.",
120
124
  "You are CustomBot, a research assistant.",
121
125
  ],
122
- # Laguna-XS.2 — switches assistant rendering to a verbatim
126
+ # Laguna-XS.2 / Laguna-M.1 — switches assistant rendering to a verbatim
123
127
  # passthrough mode. The renderer paths diverge significantly under
124
128
  # this flag, so both values are exercised.
125
129
  "render_assistant_messages_raw": [True, False],
@@ -46,7 +46,10 @@ _ROUNDTRIP_MODELS = [
46
46
  # Ultra: parse must recover content after a </think> glued directly to it
47
47
  # (no separating newline) — the Ultra-specific glue stresses the round-trip.
48
48
  ("nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16", "auto"),
49
+ # Nemotron 3.5 (Lightning): same glue as Ultra, distinct renderer class.
50
+ ("nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16", "auto"),
49
51
  ("poolside/Laguna-XS.2", "auto"),
52
+ ("poolside/Laguna-M.1", "auto"),
50
53
  # Laguna-XS-2.1 is deliberately absent: under the default
51
54
  # ``enable_thinking=False`` its template drops assistant reasoning at
52
55
  # render time, so the reasoning round-trip can't hold by design.
File without changes
File without changes