renderers 0.1.9.dev9__tar.gz → 0.1.10.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/PKG-INFO +2 -2
  2. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/README.md +1 -1
  3. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/docs/renderer-config.md +2 -1
  4. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/__init__.py +4 -0
  5. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/_version.py +2 -2
  6. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/base.py +10 -3
  7. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/client.py +12 -17
  8. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/configs.py +25 -0
  9. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/laguna_xs2.py +74 -19
  10. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/conftest.py +1 -0
  11. renderers-0.1.10.dev1/tests/test_laguna_m1.py +232 -0
  12. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_renderer_config_parity.py +2 -1
  13. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_roundtrip.py +1 -0
  14. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/.github/workflows/publish-dev.yml +0 -0
  15. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/.github/workflows/publish.yml +0 -0
  16. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/.github/workflows/style.yml +0 -0
  17. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/.github/workflows/test.yml +0 -0
  18. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/.gitignore +0 -0
  19. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/.pre-commit-config.yaml +0 -0
  20. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/LICENSE +0 -0
  21. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/examples/README.md +0 -0
  22. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/examples/sglang/multiturn_generate_sglang.py +0 -0
  23. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/examples/sglang/online_multiturn_sglang.py +0 -0
  24. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/examples/tinker/multiturn_generate_tinker.py +0 -0
  25. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/examples/transformers/multiturn_generate_transformers.py +0 -0
  26. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/examples/vllm/multiturn_generate_vllm.py +0 -0
  27. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/pyproject.toml +0 -0
  28. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/deepseek_r1.py +0 -0
  29. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/deepseek_v3.py +0 -0
  30. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/default.py +0 -0
  31. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/glm45.py +0 -0
  32. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/glm5.py +0 -0
  33. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/gpt_oss.py +0 -0
  34. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/hy3.py +0 -0
  35. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/kimi_k2.py +0 -0
  36. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/kimi_k25.py +0 -0
  37. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/llama_3.py +0 -0
  38. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/minimax_m2.py +0 -0
  39. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/nemotron3.py +0 -0
  40. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/parsers.py +0 -0
  41. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/parsing.py +0 -0
  42. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/prime_qwen3.py +0 -0
  43. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/qwen3.py +0 -0
  44. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/qwen35.py +0 -0
  45. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/qwen36.py +0 -0
  46. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/renderers/qwen3_vl.py +0 -0
  47. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_bridge.py +0 -0
  48. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_build_helpers.py +0 -0
  49. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_client.py +0 -0
  50. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_deepseek_r1.py +0 -0
  51. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_disabled_thinking_stability.py +0 -0
  52. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_glm_tool_name_validation.py +0 -0
  53. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_gpt_oss_harmony_parity.py +0 -0
  54. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_hy3.py +0 -0
  55. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_incremental.py +0 -0
  56. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_is_content.py +0 -0
  57. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_kimi_k25_tool_schema.py +0 -0
  58. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_laguna_xs21.py +0 -0
  59. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_llama_3.py +0 -0
  60. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_load_tokenizer.py +0 -0
  61. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_message_indices.py +0 -0
  62. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_message_tool_names.py +0 -0
  63. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_multimodal.py +0 -0
  64. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_nemotron3_parity.py +0 -0
  65. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_nemotron3_ultra.py +0 -0
  66. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_parse_response.py +0 -0
  67. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_parse_response_robustness.py +0 -0
  68. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_parsers.py +0 -0
  69. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_preserve_thinking.py +0 -0
  70. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_prime_qwen3_parity.py +0 -0
  71. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_qwen35_size_coverage.py +0 -0
  72. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_render_ids.py +0 -0
  73. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_renderer_config.py +0 -0
  74. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_sampled_mask.py +0 -0
  75. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_tokens_per_message.py +0 -0
  76. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/tests/test_tool_arg_type_preservation.py +0 -0
  77. {renderers-0.1.9.dev9 → renderers-0.1.10.dev1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: renderers
3
- Version: 0.1.9.dev9
3
+ Version: 0.1.10.dev1
4
4
  Summary: Chat template renderers — deterministic message-to-token conversion for LLM training
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -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`, `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`, `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,6 +37,7 @@ 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` | - |
@@ -136,7 +137,7 @@ the knobs its template actually exposes:
136
137
  | Nemotron-3 | `truncate_history_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
137
138
  | DeepSeek R1 | `template` |
138
139
  | MiniMax M2 | `tool_cycle` |
139
- | DeepSeek V3, Qwen3-VL, Kimi K2, Laguna XS.2 / XS-2.1, Llama 3 | `all` |
140
+ | DeepSeek V3, Qwen3-VL, Kimi K2, Laguna XS.2 / M.1 / XS-2.1, Llama 3 | `all` |
140
141
  | PrimeIntellect Qwen3 | `all` |
141
142
 
142
143
  Config construction raises when an explicit template knob directly contradicts
@@ -54,6 +54,7 @@ from renderers.configs import (
54
54
  Hy3RendererConfig,
55
55
  KimiK25RendererConfig,
56
56
  KimiK2RendererConfig,
57
+ LagunaM1RendererConfig,
57
58
  LagunaXS2RendererConfig,
58
59
  LagunaXS21RendererConfig,
59
60
  Llama3RendererConfig,
@@ -90,6 +91,7 @@ _LAZY_RENDERERS: dict[str, str] = {
90
91
  "Hy3Renderer": "renderers.hy3",
91
92
  "KimiK25Renderer": "renderers.kimi_k25",
92
93
  "KimiK2Renderer": "renderers.kimi_k2",
94
+ "LagunaM1Renderer": "renderers.laguna_xs2",
93
95
  "LagunaXS21Renderer": "renderers.laguna_xs2",
94
96
  "LagunaXS2Renderer": "renderers.laguna_xs2",
95
97
  "Llama3Renderer": "renderers.llama_3",
@@ -145,6 +147,8 @@ __all__ = [
145
147
  "KimiK25RendererConfig",
146
148
  "KimiK2Renderer",
147
149
  "KimiK2RendererConfig",
150
+ "LagunaM1Renderer",
151
+ "LagunaM1RendererConfig",
148
152
  "LagunaXS2Renderer",
149
153
  "LagunaXS2RendererConfig",
150
154
  "LagunaXS21Renderer",
@@ -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.dev9'
22
- __version_tuple__ = version_tuple = (0, 1, 9, 'dev9')
21
+ __version__ = version = '0.1.10.dev1'
22
+ __version_tuple__ = version_tuple = (0, 1, 10, 'dev1')
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
 
@@ -1072,9 +1073,10 @@ MODEL_RENDERER_MAP: dict[str, str] = {
1072
1073
  # construction to pin a different date.
1073
1074
  "meta-llama/Llama-3.2-1B-Instruct": "llama-3",
1074
1075
  "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.
1076
+ # Poolside Laguna. These checkpoints ship distinct chat templates,
1077
+ # each mirrored by its own renderer class/config discriminator.
1077
1078
  "poolside/Laguna-XS.2": "laguna-xs.2",
1079
+ "poolside/Laguna-M.1": "laguna-m.1",
1078
1080
  "poolside/Laguna-XS-2.1": "laguna-xs-2.1",
1079
1081
  # GPT-OSS.
1080
1082
  "openai/gpt-oss-20b": "gpt-oss",
@@ -1323,7 +1325,11 @@ def _populate_registry():
1323
1325
  from renderers.hy3 import Hy3Renderer
1324
1326
  from renderers.kimi_k2 import KimiK2Renderer
1325
1327
  from renderers.kimi_k25 import KimiK25Renderer
1326
- from renderers.laguna_xs2 import LagunaXS2Renderer, LagunaXS21Renderer
1328
+ from renderers.laguna_xs2 import (
1329
+ LagunaM1Renderer,
1330
+ LagunaXS2Renderer,
1331
+ LagunaXS21Renderer,
1332
+ )
1327
1333
  from renderers.llama_3 import Llama3Renderer
1328
1334
  from renderers.minimax_m2 import MiniMaxM2Renderer
1329
1335
  from renderers.nemotron3 import Nemotron3Renderer, Nemotron3UltraRenderer
@@ -1351,6 +1357,7 @@ def _populate_registry():
1351
1357
  "kimi-k2": KimiK2Renderer,
1352
1358
  "kimi-k2.5": KimiK25Renderer,
1353
1359
  "laguna-xs.2": LagunaXS2Renderer,
1360
+ "laguna-m.1": LagunaM1Renderer,
1354
1361
  "laguna-xs-2.1": LagunaXS21Renderer,
1355
1362
  "llama-3": Llama3Renderer,
1356
1363
  "nemotron-3": Nemotron3Renderer,
@@ -1,4 +1,4 @@
1
- """Renderer-based generate client for vLLM 0.20's /inference/v1/generate.
1
+ """Renderer-based generate client for vLLM's /inference/v1/generate.
2
2
 
3
3
  messages → Renderer.render_ids() → token IDs → POST /inference/v1/generate
4
4
  → completion tokens → Renderer.parse_response() → structured message
@@ -58,8 +58,7 @@ class OverlongPromptError(Exception):
58
58
  self.prompt_len = prompt_len
59
59
  self.max_prompt_len = max_prompt_len
60
60
  super().__init__(
61
- f"Prompt length ({prompt_len}) exceeds maximum "
62
- f"context length ({max_prompt_len})."
61
+ f"Prompt length ({prompt_len}) exceeds maximum context length ({max_prompt_len})."
63
62
  )
64
63
 
65
64
 
@@ -187,32 +186,26 @@ def _parse_completion_logprobs(
187
186
  expected_token = f"token_id:{completion_ids[index]}"
188
187
  if entry.get("token") != expected_token:
189
188
  raise MalformedGenerateResponseError(
190
- "Engine response "
191
- f"choice.logprobs.content[{index}].token must be {expected_token!r}."
189
+ f"Engine response choice.logprobs.content[{index}].token must be {expected_token!r}."
192
190
  )
193
191
  raw_logprob = entry.get("logprob")
194
192
  if isinstance(raw_logprob, bool) or not isinstance(raw_logprob, (int, float)):
195
193
  raise MalformedGenerateResponseError(
196
- "Engine response "
197
- f"choice.logprobs.content[{index}].logprob must be a number."
194
+ f"Engine response choice.logprobs.content[{index}].logprob must be a number."
198
195
  )
199
196
  try:
200
197
  logprob = float(raw_logprob)
201
198
  except OverflowError as exc:
202
199
  raise MalformedGenerateResponseError(
203
- "Engine response "
204
- f"choice.logprobs.content[{index}].logprob must be finite."
200
+ f"Engine response choice.logprobs.content[{index}].logprob must be finite."
205
201
  ) from exc
206
202
  if not math.isfinite(logprob):
207
203
  raise MalformedGenerateResponseError(
208
- "Engine response "
209
- f"choice.logprobs.content[{index}].logprob must be finite."
204
+ f"Engine response choice.logprobs.content[{index}].logprob must be finite."
210
205
  )
211
206
  if logprob == VLLM_LOGPROB_SENTINEL:
212
207
  raise MalformedGenerateResponseError(
213
- "Engine response "
214
- f"choice.logprobs.content[{index}].logprob does not contain "
215
- "sampling evidence."
208
+ f"Engine response choice.logprobs.content[{index}].logprob does not contain sampling evidence."
216
209
  )
217
210
  completion_logprobs.append(logprob)
218
211
  return completion_logprobs
@@ -424,9 +417,9 @@ def _build_mm_features(
424
417
  model-family specific. For now we dispatch on the renderer class;
425
418
  extend the dispatch table as more multimodal renderers land.
426
419
 
427
- NOTE — future engine pluggability: this encoder is vLLM 0.20-specific
420
+ NOTE — future engine pluggability: this encoder is vLLM-specific
428
421
  (uses ``vllm.multimodal.inputs.MultiModalKwargsItems``,
429
- ``vllm.entrypoints.serve.disagg.mm_serde.encode_mm_kwargs_item``, and
422
+ ``vllm.entrypoints.scale_out.token_in_token_out.mm_serde.encode_mm_kwargs_item``, and
430
423
  ``_create_qwen2vl_field_factory``). When a second inference engine
431
424
  arrives (SGLang, MAX, ...) the renderer client should be parameterized
432
425
  on engine: either (a) move the encoder onto the renderer as
@@ -473,7 +466,9 @@ def _build_qwen_vl_features(
473
466
  try:
474
467
  import torch
475
468
  from transformers.feature_extraction_utils import BatchFeature
476
- from vllm.entrypoints.serve.disagg.mm_serde import encode_mm_kwargs_item
469
+ from vllm.entrypoints.scale_out.token_in_token_out.mm_serde import (
470
+ encode_mm_kwargs_item,
471
+ )
477
472
  from vllm.model_executor.models.qwen2_vl import _create_qwen2vl_field_factory
478
473
  from vllm.multimodal.inputs import MultiModalKwargsItems
479
474
  except ImportError as exc:
@@ -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
 
@@ -680,6 +702,7 @@ RendererConfig = Annotated[
680
702
  KimiK2RendererConfig,
681
703
  KimiK25RendererConfig,
682
704
  LagunaXS2RendererConfig,
705
+ LagunaM1RendererConfig,
683
706
  LagunaXS21RendererConfig,
684
707
  Llama3RendererConfig,
685
708
  MiniMaxM2RendererConfig,
@@ -720,6 +743,7 @@ _CONFIG_BY_NAME: dict[str, type[BaseRendererConfig]] = {
720
743
  "kimi-k2": KimiK2RendererConfig,
721
744
  "kimi-k2.5": KimiK25RendererConfig,
722
745
  "laguna-xs.2": LagunaXS2RendererConfig,
746
+ "laguna-m.1": LagunaM1RendererConfig,
723
747
  "laguna-xs-2.1": LagunaXS21RendererConfig,
724
748
  "llama-3": Llama3RendererConfig,
725
749
  "minimax-m2": MiniMaxM2RendererConfig,
@@ -767,6 +791,7 @@ __all__ = [
767
791
  "Hy3RendererConfig",
768
792
  "KimiK25RendererConfig",
769
793
  "KimiK2RendererConfig",
794
+ "LagunaM1RendererConfig",
770
795
  "LagunaXS2RendererConfig",
771
796
  "LagunaXS21RendererConfig",
772
797
  "Llama3RendererConfig",
@@ -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
@@ -37,6 +37,7 @@ RENDERER_MODELS = [
37
37
  # name (auto → MODEL_RENDERER_MAP → nemotron-3-ultra).
38
38
  ("nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B-BF16", "auto"),
39
39
  ("poolside/Laguna-XS.2", "auto"),
40
+ ("poolside/Laguna-M.1", "auto"),
40
41
  # XS-2.1 resolves to `LagunaXS21Renderer` via the model name — its
41
42
  # chat template differs from XS.2's (see renderers/laguna_xs2.py).
42
43
  ("poolside/Laguna-XS-2.1", "auto"),
@@ -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
+ )
@@ -64,6 +64,7 @@ _RENDERER_MODELS = [
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
66
  ("poolside/Laguna-XS.2", "auto"),
67
+ ("poolside/Laguna-M.1", "auto"),
67
68
  ("poolside/Laguna-XS-2.1", "auto"),
68
69
  ("tencent/Hy3", "auto"),
69
70
  ("openai/gpt-oss-20b", "gpt-oss"),
@@ -119,7 +120,7 @@ _KWARG_VALUES: dict[str, list[Any]] = {
119
120
  "You are a helpful assistant. Your name is MiniMax-M2.5 and is built by MiniMax.",
120
121
  "You are CustomBot, a research assistant.",
121
122
  ],
122
- # Laguna-XS.2 — switches assistant rendering to a verbatim
123
+ # Laguna-XS.2 / Laguna-M.1 — switches assistant rendering to a verbatim
123
124
  # passthrough mode. The renderer paths diverge significantly under
124
125
  # this flag, so both values are exercised.
125
126
  "render_assistant_messages_raw": [True, False],
@@ -47,6 +47,7 @@ _ROUNDTRIP_MODELS = [
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
49
  ("poolside/Laguna-XS.2", "auto"),
50
+ ("poolside/Laguna-M.1", "auto"),
50
51
  # Laguna-XS-2.1 is deliberately absent: under the default
51
52
  # ``enable_thinking=False`` its template drops assistant reasoning at
52
53
  # render time, so the reasoning round-trip can't hold by design.
File without changes
File without changes