renderers 0.1.12.dev11__tar.gz → 0.1.12.dev12__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 (90) hide show
  1. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/PKG-INFO +37 -1
  2. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/README.md +36 -0
  3. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/__init__.py +10 -0
  4. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/_version.py +2 -2
  5. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/base.py +36 -7
  6. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/configs.py +35 -0
  7. renderers-0.1.12.dev12/renderers/custom.py +99 -0
  8. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/deepseek_v4.py +5 -4
  9. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/glm5.py +12 -10
  10. renderers-0.1.12.dev12/tests/test_custom.py +194 -0
  11. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/.github/workflows/publish-dev.yml +0 -0
  12. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/.github/workflows/publish.yml +0 -0
  13. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/.github/workflows/style.yml +0 -0
  14. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/.github/workflows/test.yml +0 -0
  15. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/.gitignore +0 -0
  16. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/.pre-commit-config.yaml +0 -0
  17. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/LICENSE +0 -0
  18. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/docs/renderer-config.md +0 -0
  19. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/examples/README.md +0 -0
  20. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/examples/sglang/multiturn_generate_sglang.py +0 -0
  21. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/examples/sglang/online_multiturn_sglang.py +0 -0
  22. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/examples/tinker/multiturn_generate_tinker.py +0 -0
  23. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/examples/transformers/multiturn_generate_transformers.py +0 -0
  24. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/examples/vllm/multiturn_generate_vllm.py +0 -0
  25. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/pyproject.toml +0 -0
  26. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/client.py +0 -0
  27. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/deepseek_r1.py +0 -0
  28. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/deepseek_v3.py +0 -0
  29. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/default.py +0 -0
  30. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/gemma4.py +0 -0
  31. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/glm45.py +0 -0
  32. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/gpt_oss.py +0 -0
  33. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/hy3.py +0 -0
  34. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/inkling.py +0 -0
  35. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/kimi_k2.py +0 -0
  36. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/kimi_k25.py +0 -0
  37. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/laguna_s21.py +0 -0
  38. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/laguna_xs2.py +0 -0
  39. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/llama_3.py +0 -0
  40. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/minimax_m2.py +0 -0
  41. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/nemotron3.py +0 -0
  42. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/parsers.py +0 -0
  43. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/parsing.py +0 -0
  44. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/prime_qwen3.py +0 -0
  45. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/qwen3.py +0 -0
  46. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/qwen35.py +0 -0
  47. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/qwen36.py +0 -0
  48. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/qwen38.py +0 -0
  49. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/qwen3_vl.py +0 -0
  50. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/renderers/reasoning.py +0 -0
  51. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/conftest.py +0 -0
  52. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/parity.py +0 -0
  53. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/reference_rendering.py +0 -0
  54. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_bridge.py +0 -0
  55. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_build_helpers.py +0 -0
  56. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_client.py +0 -0
  57. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_deepseek_r1.py +0 -0
  58. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_deepseek_v4.py +0 -0
  59. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_disabled_thinking_stability.py +0 -0
  60. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_gemma4.py +0 -0
  61. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_glm_tool_name_validation.py +0 -0
  62. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_hy3.py +0 -0
  63. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_incremental.py +0 -0
  64. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_inkling.py +0 -0
  65. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_is_content.py +0 -0
  66. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_kimi_k25_tool_schema.py +0 -0
  67. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_laguna_m1.py +0 -0
  68. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_laguna_xs21.py +0 -0
  69. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_llama_3.py +0 -0
  70. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_load_tokenizer.py +0 -0
  71. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_message_indices.py +0 -0
  72. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_message_tool_names.py +0 -0
  73. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_multimodal.py +0 -0
  74. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_nemotron35_vlm.py +0 -0
  75. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_nemotron3_ultra.py +0 -0
  76. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_offsetless_tokenizers.py +0 -0
  77. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_parity.py +0 -0
  78. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_parse_response.py +0 -0
  79. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_parse_response_robustness.py +0 -0
  80. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_parsers.py +0 -0
  81. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_preserve_thinking.py +0 -0
  82. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_qwen35_size_coverage.py +0 -0
  83. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_qwen38.py +0 -0
  84. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_reasoning_boundaries.py +0 -0
  85. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_renderer_config.py +0 -0
  86. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_roundtrip.py +0 -0
  87. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_sampled_mask.py +0 -0
  88. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_tokens_per_message.py +0 -0
  89. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/tests/test_tool_arg_type_preservation.py +0 -0
  90. {renderers-0.1.12.dev11 → renderers-0.1.12.dev12}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: renderers
3
- Version: 0.1.12.dev11
3
+ Version: 0.1.12.dev12
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
@@ -221,6 +221,42 @@ One shared behaviour flag lives on typed renderer configs: `thinking_retention`,
221
221
 
222
222
  Generic `thinking_retention` does **not** change full `render()` output: a full re-render always follows the Python chat-template implementation. Only real template knobs can change full-render thinking behaviour. GLM-5 `clear_thinking=False`, Nemotron-3 `truncate_history_thinking=False`, Qwen3.6 `preserve_thinking=True`, and GPT-OSS `auto_drop_analysis=False` all imply bridge policy `"all"`; no-thinking generation knobs also imply `"all"` when `thinking_retention` is unset. Setting a direct keep/drop template knob and a contradictory `thinking_retention` raises at config-load. The full per-renderer mapping lives in [`docs/renderer-config.md`](docs/renderer-config.md).
223
223
 
224
+ ## Custom renderers
225
+
226
+ A renderer can live outside this package. Point a `custom` config at the class with `import_path`, as `my_module.Class` or `path/to/file.py:Class`; relative file paths resolve against the working directory. The class sets `config_class` to its own `BaseRendererConfig` subclass, which validates every other field of the config and supplies its `chat_template_kwargs` allowlist. For example, a GLM-5.3 renderer whose generation prompt prefills an empty think block, so the model starts at the answer:
227
+
228
+ ```python
229
+ from typing import Literal
230
+
231
+ from renderers.configs import GLM53RendererConfig
232
+ from renderers.glm5 import GLM53Renderer
233
+
234
+
235
+ class NoThinkingGLM53Config(GLM53RendererConfig):
236
+ name: Literal["glm-5.3-no-thinking"] = "glm-5.3-no-thinking"
237
+
238
+
239
+ class NoThinkingGLM53Renderer(GLM53Renderer):
240
+ config_class = NoThinkingGLM53Config
241
+
242
+ def _emit_generation_prompt(self, emit_special) -> None:
243
+ emit_special(self._assistant, -1, is_sampled=False, is_content=False)
244
+ emit_special(self._think, -1, is_sampled=False, is_content=False)
245
+ emit_special(self._think_end, -1, is_sampled=False, is_content=False)
246
+ ```
247
+
248
+ ```python
249
+ from renderers import CustomRendererConfig, create_renderer
250
+
251
+ config = CustomRendererConfig(
252
+ import_path="my_renderers.glm53_no_thinking.NoThinkingGLM53Renderer",
253
+ clear_thinking=True,
254
+ )
255
+ renderer = create_renderer(tokenizer, config)
256
+ ```
257
+
258
+ Downstream TOML configs select it the same way, with `name = "custom"` and an `import_path`.
259
+
224
260
  ## `DefaultRenderer`
225
261
 
226
262
  Fallback for unsupported text-only models. Wraps `apply_chat_template` and accepts `tool_parser` / `reasoning_parser` (vLLM convention) plus arbitrary Jinja kwargs via `DefaultRendererConfig`'s `extra="allow"`. Explicit `thinking_retention` is rejected: `bridge_to_next_turn` returns `None` because the template's close is unknown, so multi-turn rollouts fall back to full re-render. Implementing a hand-coded renderer is a few hundred lines of Python (`render_ids` + `parse_response` + `bridge_to_next_turn`) and is the only path that closes the failure modes above by construction.
@@ -201,6 +201,42 @@ One shared behaviour flag lives on typed renderer configs: `thinking_retention`,
201
201
 
202
202
  Generic `thinking_retention` does **not** change full `render()` output: a full re-render always follows the Python chat-template implementation. Only real template knobs can change full-render thinking behaviour. GLM-5 `clear_thinking=False`, Nemotron-3 `truncate_history_thinking=False`, Qwen3.6 `preserve_thinking=True`, and GPT-OSS `auto_drop_analysis=False` all imply bridge policy `"all"`; no-thinking generation knobs also imply `"all"` when `thinking_retention` is unset. Setting a direct keep/drop template knob and a contradictory `thinking_retention` raises at config-load. The full per-renderer mapping lives in [`docs/renderer-config.md`](docs/renderer-config.md).
203
203
 
204
+ ## Custom renderers
205
+
206
+ A renderer can live outside this package. Point a `custom` config at the class with `import_path`, as `my_module.Class` or `path/to/file.py:Class`; relative file paths resolve against the working directory. The class sets `config_class` to its own `BaseRendererConfig` subclass, which validates every other field of the config and supplies its `chat_template_kwargs` allowlist. For example, a GLM-5.3 renderer whose generation prompt prefills an empty think block, so the model starts at the answer:
207
+
208
+ ```python
209
+ from typing import Literal
210
+
211
+ from renderers.configs import GLM53RendererConfig
212
+ from renderers.glm5 import GLM53Renderer
213
+
214
+
215
+ class NoThinkingGLM53Config(GLM53RendererConfig):
216
+ name: Literal["glm-5.3-no-thinking"] = "glm-5.3-no-thinking"
217
+
218
+
219
+ class NoThinkingGLM53Renderer(GLM53Renderer):
220
+ config_class = NoThinkingGLM53Config
221
+
222
+ def _emit_generation_prompt(self, emit_special) -> None:
223
+ emit_special(self._assistant, -1, is_sampled=False, is_content=False)
224
+ emit_special(self._think, -1, is_sampled=False, is_content=False)
225
+ emit_special(self._think_end, -1, is_sampled=False, is_content=False)
226
+ ```
227
+
228
+ ```python
229
+ from renderers import CustomRendererConfig, create_renderer
230
+
231
+ config = CustomRendererConfig(
232
+ import_path="my_renderers.glm53_no_thinking.NoThinkingGLM53Renderer",
233
+ clear_thinking=True,
234
+ )
235
+ renderer = create_renderer(tokenizer, config)
236
+ ```
237
+
238
+ Downstream TOML configs select it the same way, with `name = "custom"` and an `import_path`.
239
+
204
240
  ## `DefaultRenderer`
205
241
 
206
242
  Fallback for unsupported text-only models. Wraps `apply_chat_template` and accepts `tool_parser` / `reasoning_parser` (vLLM convention) plus arbitrary Jinja kwargs via `DefaultRendererConfig`'s `extra="allow"`. Explicit `thinking_retention` is rejected: `bridge_to_next_turn` returns `None` because the template's close is unknown, so multi-turn rollouts fall back to full re-render. Implementing a hand-coded renderer is a few hundred lines of Python (`render_ids` + `parse_response` + `bridge_to_next_turn`) and is the only path that closes the failure modes above by construction.
@@ -37,13 +37,16 @@ from renderers.base import (
37
37
  create_renderer,
38
38
  extract_message_tool_names,
39
39
  is_multimodal,
40
+ merge_chat_template_kwargs,
40
41
  reject_assistant_in_extension,
42
+ template_field_names,
41
43
  trim_to_turn_close,
42
44
  )
43
45
  from renderers.client import MalformedGenerateResponseError, OverlongPromptError
44
46
  from renderers.configs import (
45
47
  AutoRendererConfig,
46
48
  BaseRendererConfig,
49
+ CustomRendererConfig,
47
50
  DeepSeekR1RendererConfig,
48
51
  DeepSeekV3RendererConfig,
49
52
  DeepSeekV4RendererConfig,
@@ -76,6 +79,7 @@ from renderers.configs import (
76
79
  RendererConfig,
77
80
  config_from_name,
78
81
  )
82
+ from renderers.custom import custom_renderer_config, load_custom_renderer, load_target
79
83
 
80
84
  # Concrete renderer classes are lazy-loaded so that consumers needing only the
81
85
  # config layer (``RendererConfig`` discriminated union) don't import every
@@ -138,6 +142,7 @@ __all__ = [
138
142
  "ChatTemplateTokenizer",
139
143
  "Content",
140
144
  "ContentPart",
145
+ "CustomRendererConfig",
141
146
  "DeepSeekR1Renderer",
142
147
  "DeepSeekR1RendererConfig",
143
148
  "DeepSeekV3Renderer",
@@ -226,8 +231,13 @@ __all__ = [
226
231
  "build_trajectory_step",
227
232
  "config_from_name",
228
233
  "create_renderer",
234
+ "custom_renderer_config",
229
235
  "extract_message_tool_names",
230
236
  "is_multimodal",
237
+ "load_custom_renderer",
238
+ "load_target",
239
+ "merge_chat_template_kwargs",
231
240
  "reject_assistant_in_extension",
241
+ "template_field_names",
232
242
  "trim_to_turn_close",
233
243
  ]
@@ -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.dev11'
22
- __version_tuple__ = version_tuple = (0, 1, 12, 'dev11')
21
+ __version__ = version = '0.1.12.dev12'
22
+ __version_tuple__ = version_tuple = (0, 1, 12, 'dev12')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -1424,6 +1424,12 @@ def create_renderer(
1424
1424
  config,
1425
1425
  chat_template_kwargs=chat_template_kwargs,
1426
1426
  )
1427
+ from renderers.configs import CustomRendererConfig
1428
+ from renderers.custom import custom_renderer_config, load_custom_renderer
1429
+
1430
+ if isinstance(config, CustomRendererConfig):
1431
+ renderer_cls = load_custom_renderer(config.import_path)
1432
+ return renderer_cls(tokenizer, custom_renderer_config(config))
1427
1433
  cls = RENDERER_REGISTRY.get(config.name)
1428
1434
  if cls is None:
1429
1435
  raise ValueError(
@@ -1432,26 +1438,49 @@ def create_renderer(
1432
1438
  return cls(tokenizer, config)
1433
1439
 
1434
1440
 
1435
- def _merge_chat_template_kwargs(
1441
+ def template_field_names(config: RendererConfig) -> frozenset[str]:
1442
+ """Chat-template kwargs that ``config``'s renderer accepts, custom renderers included."""
1443
+ from renderers.configs import CustomRendererConfig
1444
+ from renderers.custom import load_custom_renderer
1445
+
1446
+ if isinstance(config, CustomRendererConfig):
1447
+ return load_custom_renderer(
1448
+ config.import_path
1449
+ ).config_class.template_field_names()
1450
+ return type(config).template_field_names()
1451
+
1452
+
1453
+ def merge_chat_template_kwargs(
1436
1454
  config: RendererConfig,
1437
1455
  chat_template_kwargs: Mapping[str, Any] | None,
1438
1456
  ) -> RendererConfig:
1457
+ """Return ``config`` with the template kwargs applied, validated against its allowlist."""
1439
1458
  if not chat_template_kwargs:
1440
1459
  return config
1441
1460
  if not isinstance(chat_template_kwargs, Mapping):
1442
1461
  raise TypeError("chat_template_kwargs must be a mapping.")
1462
+ from renderers.configs import CustomRendererConfig
1463
+ from renderers.custom import load_custom_renderer
1464
+
1443
1465
  kwargs = dict(chat_template_kwargs)
1444
1466
  config_cls = type(config)
1445
- allowed = config_cls.template_field_names()
1446
- if config_cls._allow_opaque_template_kwargs:
1447
- reserved = frozenset(config_cls.model_fields) - allowed - {"name"}
1467
+ # A custom config carries its renderer's fields as extras, so the renderer's
1468
+ # own config class decides which kwargs are template controls.
1469
+ fields_cls = (
1470
+ load_custom_renderer(config.import_path).config_class
1471
+ if isinstance(config, CustomRendererConfig)
1472
+ else config_cls
1473
+ )
1474
+ allowed = fields_cls.template_field_names()
1475
+ if fields_cls._allow_opaque_template_kwargs:
1476
+ reserved = frozenset(fields_cls.model_fields) - allowed - {"name"}
1448
1477
  unsupported = frozenset(kwargs) & reserved
1449
1478
  else:
1450
1479
  unsupported = frozenset(kwargs) - allowed
1451
1480
  if unsupported:
1452
1481
  allowed_text = (
1453
1482
  "opaque Jinja kwargs"
1454
- if config_cls._allow_opaque_template_kwargs
1483
+ if fields_cls._allow_opaque_template_kwargs
1455
1484
  else ", ".join(sorted(allowed)) or "(none)"
1456
1485
  )
1457
1486
  raise ValueError(
@@ -1486,7 +1515,7 @@ def _resolve_renderer_config(
1486
1515
  chat_template_kwargs=chat_template_kwargs,
1487
1516
  )
1488
1517
 
1489
- return _merge_chat_template_kwargs(config, chat_template_kwargs)
1518
+ return merge_chat_template_kwargs(config, chat_template_kwargs)
1490
1519
 
1491
1520
 
1492
1521
  def _resolve_auto_config(
@@ -1514,7 +1543,7 @@ def _resolve_auto_config(
1514
1543
 
1515
1544
  if renderer_name is not None:
1516
1545
  cfg_cls = _config_class_for(renderer_name)
1517
- return _merge_chat_template_kwargs(
1546
+ return merge_chat_template_kwargs(
1518
1547
  cfg_cls(**preserve_carry),
1519
1548
  chat_template_kwargs,
1520
1549
  )
@@ -994,6 +994,40 @@ class DeepSeekV4RendererConfig(BaseRendererConfig):
994
994
  return self
995
995
 
996
996
 
997
+ class CustomRendererConfig(BaseRendererConfig):
998
+ """Config for a renderer that lives outside this package.
999
+
1000
+ ``import_path`` names the renderer class as ``my_module.Class`` or
1001
+ ``path/to/file.py:Class``; relative file paths resolve against the
1002
+ working directory. The class sets ``config_class`` to its own
1003
+ :class:`BaseRendererConfig` subclass, and that class validates every other
1004
+ field of this config. A model-specific renderer can therefore ship without
1005
+ a change to this package::
1006
+
1007
+ [renderer]
1008
+ name = "custom"
1009
+ import_path = "my_renderers.glm53_no_thinking.NoThinkingGLM53Renderer"
1010
+ clear_thinking = true
1011
+ """
1012
+
1013
+ model_config = ConfigDict(frozen=True, extra="allow")
1014
+
1015
+ name: Literal["custom"] = "custom"
1016
+ import_path: str
1017
+ """Renderer class as ``my_module.Class`` or ``path/to/file.py:Class``."""
1018
+
1019
+ _internal_fields = frozenset({"import_path"})
1020
+
1021
+ @model_validator(mode="after")
1022
+ def _validate_renderer_fields(self):
1023
+ # Fail at config load, not at the first render: the import path must
1024
+ # resolve and the renderer's config must accept the other fields.
1025
+ from renderers.custom import custom_renderer_config
1026
+
1027
+ custom_renderer_config(self)
1028
+ return self
1029
+
1030
+
997
1031
  RendererConfig = Annotated[
998
1032
  Union[
999
1033
  AutoRendererConfig,
@@ -1026,6 +1060,7 @@ RendererConfig = Annotated[
1026
1060
  DeepSeekV3RendererConfig,
1027
1061
  DeepSeekR1RendererConfig,
1028
1062
  DeepSeekV4RendererConfig,
1063
+ CustomRendererConfig,
1029
1064
  ],
1030
1065
  Field(discriminator="name"),
1031
1066
  ]
@@ -0,0 +1,99 @@
1
+ """Load custom renderers from importable modules or local Python files.
2
+
3
+ An import path is ``my_module.Name`` (dotted, like any ``import``) or
4
+ ``path/to/file.py:Name``. File paths are resolved against the working
5
+ directory and imported once per process under a stable module name, so every
6
+ caller gets the same class objects.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import importlib
13
+ import importlib.util
14
+ import sys
15
+ from functools import lru_cache
16
+ from pathlib import Path
17
+ from types import ModuleType
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ if TYPE_CHECKING:
21
+ from renderers.configs import BaseRendererConfig, CustomRendererConfig
22
+
23
+
24
+ def load_target(import_path: str) -> Any:
25
+ """Return the object that ``import_path`` names."""
26
+ module_ref, sep, attr = import_path.rpartition(":")
27
+ if not sep and not _is_file_ref(import_path):
28
+ module_ref, sep, attr = import_path.rpartition(".")
29
+ if not sep or not module_ref or not attr:
30
+ raise ValueError(
31
+ f"Import path {import_path!r} must look like 'my_module.Name' or 'path/to/file.py:Name'"
32
+ )
33
+ module = load_module(module_ref)
34
+ try:
35
+ return getattr(module, attr)
36
+ except AttributeError:
37
+ raise ValueError(f"Module {module_ref!r} has no attribute {attr!r}") from None
38
+
39
+
40
+ def load_module(module_ref: str) -> ModuleType:
41
+ """Import a module by dotted name or by file path."""
42
+ if _is_file_ref(module_ref):
43
+ return _load_file_module(str(Path(module_ref).expanduser().resolve()))
44
+ return importlib.import_module(module_ref)
45
+
46
+
47
+ def _is_file_ref(ref: str) -> bool:
48
+ return ref.endswith(".py") or "/" in ref
49
+
50
+
51
+ @lru_cache(maxsize=None)
52
+ def _load_file_module(path: str) -> ModuleType:
53
+ file = Path(path)
54
+ if not file.is_file():
55
+ raise FileNotFoundError(f"Custom renderer file {path!r} does not exist")
56
+ # A path-derived name keeps two files with the same name apart.
57
+ digest = hashlib.sha1(path.encode()).hexdigest()[:8]
58
+ name = f"renderers_custom_{file.stem}_{digest}"
59
+ if name in sys.modules:
60
+ return sys.modules[name]
61
+ spec = importlib.util.spec_from_file_location(name, file)
62
+ if spec is None or spec.loader is None:
63
+ raise ImportError(f"Cannot import custom renderer file {path!r}")
64
+ module = importlib.util.module_from_spec(spec)
65
+ sys.modules[name] = module
66
+ try:
67
+ spec.loader.exec_module(module)
68
+ except BaseException:
69
+ sys.modules.pop(name, None)
70
+ raise
71
+ return module
72
+
73
+
74
+ def load_custom_renderer(import_path: str) -> Any:
75
+ """Return the renderer class that ``import_path`` names.
76
+
77
+ The class must set ``config_class`` to its own
78
+ :class:`~renderers.configs.BaseRendererConfig` subclass.
79
+ """
80
+ from renderers.configs import BaseRendererConfig
81
+
82
+ renderer_cls = load_target(import_path)
83
+ config_cls = getattr(renderer_cls, "config_class", None)
84
+ if not (
85
+ isinstance(config_cls, type) and issubclass(config_cls, BaseRendererConfig)
86
+ ):
87
+ raise TypeError(
88
+ f"Custom renderer {import_path!r} must set config_class to a BaseRendererConfig subclass"
89
+ )
90
+ return renderer_cls
91
+
92
+
93
+ def custom_renderer_config(config: "CustomRendererConfig") -> "BaseRendererConfig":
94
+ """The renderer's own typed config, built from the fields next to ``import_path``."""
95
+ config_cls = load_custom_renderer(config.import_path).config_class
96
+ data = dict(config.model_extra or {})
97
+ for field_name in config.model_fields_set - {"name", "import_path"}:
98
+ data[field_name] = getattr(config, field_name)
99
+ return config_cls.model_validate(data)
@@ -331,6 +331,10 @@ class DeepSeekV4Renderer:
331
331
  raise ValueError(f"Expected one token for {token!r}, got {ids}")
332
332
  return ids[0]
333
333
 
334
+ def _reasoning_effort_prompt(self) -> str:
335
+ """Text emitted after BOS in thinking mode; subclasses can replace it."""
336
+ return _REASONING_EFFORT_PROMPTS[self.config.reasoning_effort]
337
+
334
338
  @staticmethod
335
339
  def _render_tools(tools: list[ToolSpec]) -> str:
336
340
  schemas = [_json(dict(_tool_function(tool))) for tool in tools]
@@ -573,10 +577,7 @@ class DeepSeekV4Renderer:
573
577
  if add_bos:
574
578
  emit_special(self._bos, -1)
575
579
  if add_effort_prompt and self.config.enable_thinking:
576
- emit_text(
577
- _REASONING_EFFORT_PROMPTS[self.config.reasoning_effort],
578
- -1,
579
- )
580
+ emit_text(self._reasoning_effort_prompt(), -1)
580
581
 
581
582
  last_query_index = -1
582
583
  for index, message in enumerate(logical_messages):
@@ -286,11 +286,7 @@ class GLM5Renderer:
286
286
  # sampling starts — the model continues from these, never emits
287
287
  # them. Always is_sampled=False / is_content=False.
288
288
  if add_generation_prompt:
289
- emit_special(self._assistant, -1, is_sampled=False, is_content=False)
290
- if getattr(self.config, "enable_thinking", True):
291
- emit_special(self._think, -1, is_sampled=False, is_content=False)
292
- else:
293
- emit_special(self._think_end, -1, is_sampled=False, is_content=False)
289
+ self._emit_generation_prompt(emit_special)
294
290
 
295
291
  return RenderedTokens(
296
292
  token_ids=tokens,
@@ -301,6 +297,16 @@ class GLM5Renderer:
301
297
  message_tool_names=extract_message_tool_names(messages),
302
298
  )
303
299
 
300
+ def _emit_generation_prompt(self, emit_special) -> None:
301
+ """Generation prompt: ``<|assistant|><think>`` when thinking is on, or
302
+ ``<|assistant|></think>`` when ``enable_thinking=False``. A subclass can
303
+ override this to prefill a different prompt."""
304
+ emit_special(self._assistant, -1, is_sampled=False, is_content=False)
305
+ if getattr(self.config, "enable_thinking", True):
306
+ emit_special(self._think, -1, is_sampled=False, is_content=False)
307
+ else:
308
+ emit_special(self._think_end, -1, is_sampled=False, is_content=False)
309
+
304
310
  @staticmethod
305
311
  def _ordered_tool_indices(messages: list[Message], block_start: int) -> list[int]:
306
312
  """Return one contiguous tool block in its source order."""
@@ -499,11 +505,7 @@ class GLM5Renderer:
499
505
  return None
500
506
 
501
507
  # Generation prompt — match the gen-prompt branch of ``render()``.
502
- emit_special(self._assistant, -1)
503
- if getattr(self.config, "enable_thinking", True):
504
- emit_special(self._think, -1)
505
- else:
506
- emit_special(self._think_end, -1)
508
+ self._emit_generation_prompt(emit_special)
507
509
 
508
510
  total_len = len(previous_ids) + len(ext)
509
511
  return RenderedTokens(
@@ -0,0 +1,194 @@
1
+ """Custom renderers loaded from local files or importable modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pickle
6
+ import textwrap
7
+
8
+ import pytest
9
+ from pydantic import TypeAdapter, ValidationError
10
+
11
+ from renderers import (
12
+ CustomRendererConfig,
13
+ GLM53RendererConfig,
14
+ RendererConfig,
15
+ create_renderer,
16
+ custom_renderer_config,
17
+ load_custom_renderer,
18
+ merge_chat_template_kwargs,
19
+ template_field_names,
20
+ )
21
+
22
+ TOY_SOURCE = textwrap.dedent(
23
+ """
24
+ from typing import Literal
25
+
26
+ from renderers.configs import BaseRendererConfig
27
+
28
+
29
+ class ToyRendererConfig(BaseRendererConfig):
30
+ name: Literal["toy"] = "toy"
31
+ effort: int = 1
32
+ cache_size: int = 8
33
+ _template_fields = frozenset({"effort"})
34
+ _internal_fields = frozenset({"cache_size"})
35
+
36
+
37
+ class ToyRenderer:
38
+ config_class = ToyRendererConfig
39
+
40
+ def __init__(self, tokenizer, config):
41
+ self.tokenizer = tokenizer
42
+ self.config = config
43
+
44
+
45
+ class NoConfigRenderer:
46
+ pass
47
+ """
48
+ )
49
+
50
+
51
+ @pytest.fixture
52
+ def toy_file(tmp_path):
53
+ path = tmp_path / "toy_renderer.py"
54
+ path.write_text(TOY_SOURCE)
55
+ return path
56
+
57
+
58
+ def import_path(path, name="ToyRenderer"):
59
+ return f"{path}:{name}"
60
+
61
+
62
+ def test_custom_fields_validate_through_the_renderer_config(toy_file):
63
+ config = CustomRendererConfig(import_path=import_path(toy_file), effort=3)
64
+ assert custom_renderer_config(config).effort == 3
65
+ assert custom_renderer_config(config).name == "toy"
66
+ with pytest.raises(ValidationError):
67
+ CustomRendererConfig(import_path=import_path(toy_file), unknown=1)
68
+
69
+
70
+ def test_shared_renderer_fields_reach_the_renderer_config(toy_file):
71
+ config = CustomRendererConfig(
72
+ import_path=import_path(toy_file), thinking_retention="all"
73
+ )
74
+ assert custom_renderer_config(config).thinking_retention == "all"
75
+
76
+
77
+ def test_discriminated_union_parses_renderer_configs(toy_file):
78
+ config = TypeAdapter(RendererConfig).validate_python(
79
+ {"name": "custom", "import_path": import_path(toy_file), "effort": 2}
80
+ )
81
+ assert isinstance(config, CustomRendererConfig)
82
+ assert custom_renderer_config(config).effort == 2
83
+
84
+
85
+ def test_create_renderer_builds_the_custom_renderer_with_its_own_config(toy_file):
86
+ tokenizer = object()
87
+ renderer = create_renderer(
88
+ tokenizer, CustomRendererConfig(import_path=import_path(toy_file), effort=4)
89
+ )
90
+ assert type(renderer).__name__ == "ToyRenderer"
91
+ assert renderer.tokenizer is tokenizer
92
+ assert type(renderer.config).__name__ == "ToyRendererConfig"
93
+ assert renderer.config.effort == 4
94
+
95
+
96
+ def test_chat_template_kwargs_use_the_custom_renderer_allowlist(toy_file):
97
+ config = CustomRendererConfig(import_path=import_path(toy_file))
98
+ renderer = create_renderer(object(), config, chat_template_kwargs={"effort": 5})
99
+ assert renderer.config.effort == 5
100
+ with pytest.raises(ValueError, match="cache_size"):
101
+ create_renderer(object(), config, chat_template_kwargs={"cache_size": 2})
102
+
103
+
104
+ def test_module_import_paths_import_by_name(toy_file, monkeypatch):
105
+ monkeypatch.syspath_prepend(str(toy_file.parent))
106
+ config = CustomRendererConfig(import_path="toy_renderer.ToyRenderer", effort=6)
107
+ assert create_renderer(object(), config).config.effort == 6
108
+
109
+
110
+ def test_file_import_paths_load_once_per_process(toy_file):
111
+ assert load_custom_renderer(import_path(toy_file)) is load_custom_renderer(
112
+ import_path(toy_file)
113
+ )
114
+
115
+
116
+ def test_invalid_import_paths_fail_clearly(toy_file, tmp_path):
117
+ with pytest.raises(ValueError, match="must look like"):
118
+ CustomRendererConfig(import_path=str(toy_file))
119
+ with pytest.raises(ValueError, match="has no attribute"):
120
+ CustomRendererConfig(import_path=import_path(toy_file, "Missing"))
121
+ with pytest.raises(TypeError, match="config_class"):
122
+ CustomRendererConfig(import_path=import_path(toy_file, "NoConfigRenderer"))
123
+ with pytest.raises(FileNotFoundError):
124
+ CustomRendererConfig(import_path=f"{tmp_path / 'missing.py'}:ToyRenderer")
125
+
126
+
127
+ def test_renderer_configs_pickle_and_key_caches(toy_file):
128
+ low = CustomRendererConfig(import_path=import_path(toy_file), effort=1)
129
+ high = CustomRendererConfig(import_path=import_path(toy_file), effort=9)
130
+ assert pickle.loads(pickle.dumps(high)) == high
131
+ cache = {low: "low", high: "high"}
132
+ assert (
133
+ cache[CustomRendererConfig(import_path=import_path(toy_file), effort=9)]
134
+ == "high"
135
+ )
136
+ assert low != high
137
+
138
+
139
+ def test_public_helpers_resolve_custom_template_fields(toy_file):
140
+ config = CustomRendererConfig(import_path=import_path(toy_file))
141
+ assert template_field_names(config) == frozenset({"effort"})
142
+ merged = merge_chat_template_kwargs(config, {"effort": 7})
143
+ assert isinstance(merged, CustomRendererConfig)
144
+ assert custom_renderer_config(merged).effort == 7
145
+ with pytest.raises(ValueError, match="cache_size"):
146
+ merge_chat_template_kwargs(config, {"cache_size": 1})
147
+
148
+
149
+ GLM53_NO_THINKING_SOURCE = textwrap.dedent(
150
+ """
151
+ from typing import Literal
152
+
153
+ from renderers.configs import GLM53RendererConfig
154
+ from renderers.glm5 import GLM53Renderer
155
+
156
+
157
+ class NoThinkingGLM53Config(GLM53RendererConfig):
158
+ name: Literal["glm-5.3-no-thinking"] = "glm-5.3-no-thinking"
159
+
160
+
161
+ class NoThinkingGLM53Renderer(GLM53Renderer):
162
+ config_class = NoThinkingGLM53Config
163
+
164
+ def _emit_generation_prompt(self, emit_special) -> None:
165
+ emit_special(self._assistant, -1, is_sampled=False, is_content=False)
166
+ emit_special(self._think, -1, is_sampled=False, is_content=False)
167
+ emit_special(self._think_end, -1, is_sampled=False, is_content=False)
168
+ """
169
+ )
170
+
171
+
172
+ def test_readme_example_prefills_the_think_block(tmp_path):
173
+ from renderers.base import load_tokenizer
174
+
175
+ path = tmp_path / "glm53_no_thinking.py"
176
+ path.write_text(GLM53_NO_THINKING_SOURCE)
177
+ tokenizer = load_tokenizer("zai-org/GLM-5.3-BF16")
178
+ messages = [{"role": "user", "content": "What is 2 + 2?"}]
179
+
180
+ custom = create_renderer(
181
+ tokenizer,
182
+ CustomRendererConfig(
183
+ import_path=import_path(path, "NoThinkingGLM53Renderer"),
184
+ clear_thinking=True,
185
+ ),
186
+ )
187
+ builtin = create_renderer(tokenizer, GLM53RendererConfig(clear_thinking=True))
188
+
189
+ ids = custom.render_ids(messages, add_generation_prompt=True)
190
+ builtin_ids = builtin.render_ids(messages, add_generation_prompt=True)
191
+ assert tokenizer.decode(ids[-3:]) == "<|assistant|><think></think>"
192
+ assert tokenizer.decode(builtin_ids[-2:]) == "<|assistant|><think>"
193
+ assert ids[:-1] == builtin_ids
194
+ assert custom.config.clear_thinking is True