renderers 0.1.10.dev13__tar.gz → 0.1.10.dev15__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/.github/workflows/publish-dev.yml +1 -1
  2. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/.github/workflows/publish.yml +1 -1
  3. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/PKG-INFO +3 -3
  4. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/README.md +1 -1
  5. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/docs/renderer-config.md +2 -0
  6. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/__init__.py +4 -0
  7. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/_version.py +2 -2
  8. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/base.py +10 -4
  9. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/configs.py +49 -0
  10. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/qwen35.py +70 -27
  11. renderers-0.1.10.dev15/renderers/qwen38.py +66 -0
  12. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/conftest.py +1 -0
  13. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_bridge.py +1 -0
  14. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_client.py +2 -1
  15. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_disabled_thinking_stability.py +5 -0
  16. renderers-0.1.10.dev15/tests/test_qwen38.py +172 -0
  17. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_renderer_config_parity.py +6 -4
  18. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_roundtrip.py +1 -0
  19. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/.github/workflows/style.yml +0 -0
  20. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/.github/workflows/test.yml +0 -0
  21. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/.gitignore +0 -0
  22. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/.pre-commit-config.yaml +0 -0
  23. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/LICENSE +0 -0
  24. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/examples/README.md +0 -0
  25. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/examples/sglang/multiturn_generate_sglang.py +0 -0
  26. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/examples/sglang/online_multiturn_sglang.py +0 -0
  27. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/examples/tinker/multiturn_generate_tinker.py +0 -0
  28. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/examples/transformers/multiturn_generate_transformers.py +0 -0
  29. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/examples/vllm/multiturn_generate_vllm.py +0 -0
  30. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/pyproject.toml +0 -0
  31. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/client.py +0 -0
  32. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/deepseek_r1.py +0 -0
  33. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/deepseek_v3.py +0 -0
  34. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/default.py +0 -0
  35. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/gemma4.py +0 -0
  36. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/glm45.py +0 -0
  37. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/glm5.py +0 -0
  38. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/gpt_oss.py +0 -0
  39. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/hy3.py +0 -0
  40. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/inkling.py +0 -0
  41. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/kimi_k2.py +0 -0
  42. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/kimi_k25.py +0 -0
  43. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/laguna_s21.py +0 -0
  44. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/laguna_xs2.py +0 -0
  45. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/llama_3.py +0 -0
  46. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/minimax_m2.py +0 -0
  47. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/nemotron3.py +0 -0
  48. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/parsers.py +0 -0
  49. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/parsing.py +0 -0
  50. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/prime_qwen3.py +0 -0
  51. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/qwen3.py +0 -0
  52. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/qwen36.py +0 -0
  53. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/renderers/qwen3_vl.py +0 -0
  54. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_build_helpers.py +0 -0
  55. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_deepseek_r1.py +0 -0
  56. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_gemma4.py +0 -0
  57. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_glm_tool_name_validation.py +0 -0
  58. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_gpt_oss_harmony_parity.py +0 -0
  59. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_hy3.py +0 -0
  60. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_incremental.py +0 -0
  61. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_inkling.py +0 -0
  62. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_is_content.py +0 -0
  63. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_kimi_k25_tool_schema.py +0 -0
  64. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_laguna_m1.py +0 -0
  65. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_laguna_s21.py +0 -0
  66. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_laguna_xs21.py +0 -0
  67. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_llama_3.py +0 -0
  68. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_load_tokenizer.py +0 -0
  69. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_message_indices.py +0 -0
  70. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_message_tool_names.py +0 -0
  71. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_multimodal.py +0 -0
  72. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_nemotron3_parity.py +0 -0
  73. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_nemotron3_ultra.py +0 -0
  74. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_parse_response.py +0 -0
  75. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_parse_response_robustness.py +0 -0
  76. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_parsers.py +0 -0
  77. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_preserve_thinking.py +0 -0
  78. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_prime_qwen3_parity.py +0 -0
  79. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_qwen35_size_coverage.py +0 -0
  80. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_render_ids.py +0 -0
  81. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_renderer_config.py +0 -0
  82. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_sampled_mask.py +0 -0
  83. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_tokens_per_message.py +0 -0
  84. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/tests/test_tool_arg_type_preservation.py +0 -0
  85. {renderers-0.1.10.dev13 → renderers-0.1.10.dev15}/uv.lock +0 -0
@@ -62,6 +62,6 @@ jobs:
62
62
  path: dist/
63
63
 
64
64
  - name: Publish to PyPI
65
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
65
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
66
66
  with:
67
67
  skip-existing: true
@@ -87,4 +87,4 @@ jobs:
87
87
  path: dist/
88
88
 
89
89
  - name: Publish to PyPI
90
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
90
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: renderers
3
- Version: 0.1.10.dev13
3
+ Version: 0.1.10.dev15
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`, `gemma4`, `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-s-2.1`, `laguna-m.1`, `nemotron-3`, `nemotron-3-ultra`, `nemotron-3.5`, `llama-3`, `gpt-oss`, `hy3`, `inkling` / `inkling-small`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper. `qwen3-vl`, `qwen3.5`, `qwen3.6`, `gemma4`, `kimi-k2.5` / `kimi-k2.6`, and the Inkling checkpoints are multimodal (Inkling handles both image **and** audio).
59
+ Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.8`, `gemma4`, `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-s-2.1`, `laguna-m.1`, `nemotron-3`, `nemotron-3-ultra`, `nemotron-3.5`, `llama-3`, `gpt-oss`, `hy3`, `inkling` / `inkling-small`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper. `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.8`, `gemma4`, `kimi-k2.5` / `kimi-k2.6`, and the Inkling checkpoints are multimodal (Inkling handles both image **and** audio).
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`, `gemma4`, `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-s-2.1`, `laguna-m.1`, `nemotron-3`, `nemotron-3-ultra`, `nemotron-3.5`, `llama-3`, `gpt-oss`, `hy3`, `inkling` / `inkling-small`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper. `qwen3-vl`, `qwen3.5`, `qwen3.6`, `gemma4`, `kimi-k2.5` / `kimi-k2.6`, and the Inkling checkpoints are multimodal (Inkling handles both image **and** audio).
43
+ Hand-coded renderers ship for `qwen3`, `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.8`, `gemma4`, `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-s-2.1`, `laguna-m.1`, `nemotron-3`, `nemotron-3-ultra`, `nemotron-3.5`, `llama-3`, `gpt-oss`, `hy3`, `inkling` / `inkling-small`, and `prime-qwen3`. Anything else falls back to `DefaultRenderer`, a generic `apply_chat_template` wrapper. `qwen3-vl`, `qwen3.5`, `qwen3.6`, `qwen3.8`, `gemma4`, `kimi-k2.5` / `kimi-k2.6`, and the Inkling checkpoints are multimodal (Inkling handles both image **and** audio).
44
44
 
45
45
  ## API
46
46
 
@@ -31,6 +31,7 @@ definition time. Template fields are covered by parity tests against
31
31
  | PrimeIntellect Qwen3 | `PrimeQwen3RendererConfig` | - | - |
32
32
  | Qwen3.5 | `Qwen35RendererConfig` | `enable_thinking`, `add_vision_id` | `image_cache_max` |
33
33
  | Qwen3.6 | `Qwen36RendererConfig` | `enable_thinking`, `add_vision_id`, `preserve_thinking` | `image_cache_max` |
34
+ | Qwen3.8 | `Qwen38RendererConfig` | `enable_thinking`, `add_vision_id`, `preserve_thinking`, `reasoning_effort` | `image_cache_max` |
34
35
  | Qwen3-VL | `Qwen3VLRendererConfig` | `add_vision_id` | `image_cache_max` |
35
36
  | Gemma 4 | `Gemma4RendererConfig` | `enable_thinking`, `preserve_thinking` | `image_cache_max` |
36
37
  | GLM-5 / 5.1 | `GLM5RendererConfig` / `GLM51RendererConfig` | `enable_thinking`, `clear_thinking` | - |
@@ -140,6 +141,7 @@ the knobs its template actually exposes:
140
141
  | Qwen3 | `enable_thinking=False -> all`, else `tool_cycle` |
141
142
  | Qwen3.5 | `enable_thinking=False -> all`, else `tool_cycle` |
142
143
  | Qwen3.6 | `preserve_thinking=True -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
144
+ | Qwen3.8 | `preserve_thinking=True -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
143
145
  | Gemma 4 | `preserve_thinking=True -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
144
146
  | GLM-5 / 5.1 | `clear_thinking=False -> all`; else `enable_thinking=False -> all`; else `tool_cycle` |
145
147
  | GLM-4.5 | `enable_thinking=False -> all`, else `tool_cycle` |
@@ -68,6 +68,7 @@ from renderers.configs import (
68
68
  PrimeQwen3RendererConfig,
69
69
  Qwen35RendererConfig,
70
70
  Qwen36RendererConfig,
71
+ Qwen38RendererConfig,
71
72
  Qwen3RendererConfig,
72
73
  Qwen3VLRendererConfig,
73
74
  RendererConfig,
@@ -109,6 +110,7 @@ _LAZY_RENDERERS: dict[str, str] = {
109
110
  "PrimeQwen3Renderer": "renderers.prime_qwen3",
110
111
  "Qwen35Renderer": "renderers.qwen35",
111
112
  "Qwen36Renderer": "renderers.qwen36",
113
+ "Qwen38Renderer": "renderers.qwen38",
112
114
  "Qwen3Renderer": "renderers.qwen3",
113
115
  "Qwen3VLRenderer": "renderers.qwen3_vl",
114
116
  }
@@ -192,6 +194,8 @@ __all__ = [
192
194
  "Qwen35RendererConfig",
193
195
  "Qwen36Renderer",
194
196
  "Qwen36RendererConfig",
197
+ "Qwen38Renderer",
198
+ "Qwen38RendererConfig",
195
199
  "Qwen3Renderer",
196
200
  "Qwen3RendererConfig",
197
201
  "Qwen3VLRenderer",
@@ -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.10.dev13'
22
- __version_tuple__ = version_tuple = (0, 1, 10, 'dev13')
21
+ __version__ = version = '0.1.10.dev15'
22
+ __version_tuple__ = version_tuple = (0, 1, 10, 'dev15')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -794,10 +794,10 @@ class MultimodalRenderer(Renderer, Protocol):
794
794
  """A :class:`Renderer` that supports multimodal inputs (images, video).
795
795
 
796
796
  Concrete classes (``Qwen3VLRenderer``, ``Qwen35Renderer``,
797
- ``Qwen36Renderer``, ``KimiK25Renderer``) implement this Protocol
798
- structurally — no explicit inheritance required. Callers that need
799
- to drive vLLM's ``multi_modal_data`` features field or carry images
800
- forward across turns should dispatch on ``isinstance(r,
797
+ ``Qwen36Renderer``, ``Qwen38Renderer``, ``KimiK25Renderer``) implement
798
+ this Protocol structurally — no explicit inheritance required.
799
+ Callers that need to drive vLLM's ``multi_modal_data`` features field or
800
+ carry images forward across turns should dispatch on ``isinstance(r,
801
801
  MultimodalRenderer)`` and use the extended ``bridge_to_next_turn``
802
802
  signature below.
803
803
  """
@@ -1031,6 +1031,8 @@ MODEL_RENDERER_MAP: dict[str, str] = {
1031
1031
  "Qwen/Qwen3.5-397B-A17B": "qwen3.5",
1032
1032
  # Qwen3.6.
1033
1033
  "Qwen/Qwen3.6-35B-A3B": "qwen3.6",
1034
+ # Qwen3.8.
1035
+ "Qwen/Qwen3.8-27B": "qwen3.8",
1034
1036
  # Qwen3-VL.
1035
1037
  "Qwen/Qwen3-VL-4B-Instruct": "qwen3-vl",
1036
1038
  "Qwen/Qwen3-VL-8B-Instruct": "qwen3-vl",
@@ -1136,6 +1138,8 @@ MULTIMODAL_MODELS: dict[str, set[str]] = {
1136
1138
  # Qwen3.6 extends Qwen3.5's chat template; same VL bits, only
1137
1139
  # tool-call argument serialization differs.
1138
1140
  "Qwen/Qwen3.6-35B-A3B": {"image"},
1141
+ # Qwen3.8 adds reasoning-effort control and preserves thinking by default.
1142
+ "Qwen/Qwen3.8-27B": {"image"},
1139
1143
  # Kimi K2.5 / K2.6 are unified VLMs (HF tag ``image-text-to-text``)
1140
1144
  # with custom processor (``KimiK25Processor`` + ``KimiK25VisionProcessor``).
1141
1145
  # Vision wrap is different from Qwen-VL:
@@ -1367,6 +1371,7 @@ def _populate_registry():
1367
1371
  from renderers.qwen3_vl import Qwen3VLRenderer
1368
1372
  from renderers.qwen35 import Qwen35Renderer
1369
1373
  from renderers.qwen36 import Qwen36Renderer
1374
+ from renderers.qwen38 import Qwen38Renderer
1370
1375
 
1371
1376
  RENDERER_REGISTRY.update(
1372
1377
  {
@@ -1377,6 +1382,7 @@ def _populate_registry():
1377
1382
  "gemma4": Gemma4Renderer,
1378
1383
  "qwen3.5": Qwen35Renderer,
1379
1384
  "qwen3.6": Qwen36Renderer,
1385
+ "qwen3.8": Qwen38Renderer,
1380
1386
  "glm-5": GLM5Renderer,
1381
1387
  "glm-5.1": GLM51Renderer,
1382
1388
  "glm-4.5": GLM45Renderer,
@@ -271,6 +271,52 @@ class Qwen36RendererConfig(BaseRendererConfig):
271
271
  return self
272
272
 
273
273
 
274
+ class Qwen38RendererConfig(BaseRendererConfig):
275
+ """Qwen3.8 renderer config.
276
+
277
+ Qwen3.8 keeps Qwen3.6's multimodal/tool template and adds native
278
+ reasoning-effort instructions. Historical thinking is preserved by
279
+ default in the upstream template.
280
+ """
281
+
282
+ name: Literal["qwen3.8"] = "qwen3.8"
283
+ _template_fields = frozenset(
284
+ {
285
+ "enable_thinking",
286
+ "add_vision_id",
287
+ "preserve_thinking",
288
+ "reasoning_effort",
289
+ }
290
+ )
291
+
292
+ enable_thinking: bool | None = None
293
+ """See :class:`Qwen35RendererConfig.enable_thinking`."""
294
+
295
+ add_vision_id: bool = False
296
+ """See :class:`Qwen35RendererConfig.add_vision_id`."""
297
+
298
+ preserve_thinking: bool = True
299
+ """Keep historical ``<think>`` blocks. Mirrors Qwen3.8's default."""
300
+
301
+ reasoning_effort: Literal["xhigh", "medium", "low"] = "xhigh"
302
+ """Reasoning depth hint injected into the system prompt when enabled."""
303
+
304
+ image_cache_max: int = 256
305
+ """See :class:`Qwen35RendererConfig.image_cache_max`."""
306
+
307
+ _internal_fields = frozenset({"image_cache_max"})
308
+
309
+ @model_validator(mode="after")
310
+ def _check_thinking_retention(self):
311
+ _reject_thinking_retention_conflict(
312
+ self,
313
+ "preserve_thinking",
314
+ true_implies="all",
315
+ false_implies="tool_cycle",
316
+ )
317
+ return self
318
+
319
+
274
320
  class Qwen3VLRendererConfig(BaseRendererConfig):
275
321
  """Qwen3-VL renderer config."""
276
322
 
@@ -892,6 +938,7 @@ RendererConfig = Annotated[
892
938
  PrimeQwen3RendererConfig,
893
939
  Qwen35RendererConfig,
894
940
  Qwen36RendererConfig,
941
+ Qwen38RendererConfig,
895
942
  Qwen3VLRendererConfig,
896
943
  Gemma4RendererConfig,
897
944
  GLM5RendererConfig,
@@ -937,6 +984,7 @@ _CONFIG_BY_NAME: dict[str, type[BaseRendererConfig]] = {
937
984
  "prime-qwen3": PrimeQwen3RendererConfig,
938
985
  "qwen3.5": Qwen35RendererConfig,
939
986
  "qwen3.6": Qwen36RendererConfig,
987
+ "qwen3.8": Qwen38RendererConfig,
940
988
  "qwen3-vl": Qwen3VLRendererConfig,
941
989
  "gemma4": Gemma4RendererConfig,
942
990
  "glm-5": GLM5RendererConfig,
@@ -1013,6 +1061,7 @@ __all__ = [
1013
1061
  "PrimeQwen3RendererConfig",
1014
1062
  "Qwen35RendererConfig",
1015
1063
  "Qwen36RendererConfig",
1064
+ "Qwen38RendererConfig",
1016
1065
  "Qwen3RendererConfig",
1017
1066
  "Qwen3VLRendererConfig",
1018
1067
  "RendererConfig",
@@ -102,6 +102,8 @@ _ENABLE_THINKING_DEFAULTS: dict[str, bool] = {
102
102
  "Qwen/Qwen3.5-397B-A17B": True,
103
103
  # Qwen3.6 extends the Qwen3.5 template; same big-size polarity.
104
104
  "Qwen/Qwen3.6-35B-A3B": True,
105
+ # Qwen3.8 keeps thinking enabled by default.
106
+ "Qwen/Qwen3.8-27B": True,
105
107
  }
106
108
 
107
109
 
@@ -305,6 +307,41 @@ class Qwen35Renderer:
305
307
  return i
306
308
  return len(messages)
307
309
 
310
+ def _reasoning_instructions(self) -> str:
311
+ """Optional synthetic system instruction for reasoning control.
312
+
313
+ Qwen3.5 and Qwen3.6 do not inject one. Newer family members can
314
+ override this hook without duplicating the full render path.
315
+ """
316
+ return ""
317
+
318
+ def _omit_empty_system_message(self) -> bool:
319
+ """Whether an empty caller-provided system message emits no tokens."""
320
+ return False
321
+
322
+ @staticmethod
323
+ def _extract_assistant_parts(msg: Message, content: str) -> tuple[str, str]:
324
+ """Return ``(reasoning_content, visible_content)`` for an assistant.
325
+
326
+ Qwen3.5 and Qwen3.6 accept the legacy inline
327
+ ``<think>...</think>content`` shape when ``reasoning_content`` is
328
+ absent. Qwen3.8 removes that compatibility branch and overrides this
329
+ hook to keep inline markers in visible content.
330
+ """
331
+ reasoning_content = ""
332
+ if isinstance(msg.get("reasoning_content"), str):
333
+ reasoning_content = msg["reasoning_content"]
334
+ elif "</think>" in content:
335
+ before_think_end, after_think_end = content.split("</think>", 1)
336
+ if "<think>" in before_think_end:
337
+ reasoning_content = before_think_end.split("<think>")[-1].lstrip("\n")
338
+ else:
339
+ reasoning_content = before_think_end.lstrip("\n")
340
+ reasoning_content = reasoning_content.rstrip("\n")
341
+ content = after_think_end.lstrip("\n")
342
+
343
+ return reasoning_content.strip(), content
344
+
308
345
  # ------------------------------------------------------------------
309
346
  # Core render method
310
347
  # ------------------------------------------------------------------
@@ -466,6 +503,7 @@ class Qwen35Renderer:
466
503
 
467
504
  # ── 1. System message + optional tools ──────────────────────
468
505
  first_is_system = messages[0].get("role") == "system"
506
+ reasoning_instructions = self._reasoning_instructions()
469
507
 
470
508
  if tools:
471
509
  # System message index for attribution
@@ -477,10 +515,10 @@ class Qwen35Renderer:
477
515
  # JSON tool specs — is scaffold. The tools dict is
478
516
  # recoverable from the ``tools`` argument; don't re-attribute
479
517
  # its embedded JSON as message body.
480
- segments: list[tuple[str, bool]] = [
481
- ("system\n", False),
482
- (_TOOLS_HEADER, False),
483
- ]
518
+ segments: list[tuple[str, bool]] = [("system\n", False)]
519
+ if reasoning_instructions:
520
+ segments.append((reasoning_instructions + "\n\n", False))
521
+ segments.append((_TOOLS_HEADER, False))
484
522
  for tool in tools:
485
523
  segments.append(("\n" + json.dumps(tool, ensure_ascii=False), False))
486
524
  segments.append((_TOOLS_FOOTER, False))
@@ -495,13 +533,33 @@ class Qwen35Renderer:
495
533
  emit_text("\n", sys_idx, is_sampled=False, is_content=False)
496
534
  elif first_is_system:
497
535
  sys_content = self._render_content(messages[0].get("content")).strip()
498
- emit_special(self._im_start, 0, is_sampled=False, is_content=False)
499
- sys_segments: list[tuple[str, bool]] = [("system\n", False)]
500
- if sys_content:
501
- sys_segments.append((sys_content, True))
502
- emit_text_segments(sys_segments, 0, is_sampled=False)
503
- emit_special(self._im_end, 0, is_sampled=False, is_content=False)
504
- emit_text("\n", 0, is_sampled=False, is_content=False)
536
+ if (
537
+ sys_content
538
+ or reasoning_instructions
539
+ or not self._omit_empty_system_message()
540
+ ):
541
+ emit_special(self._im_start, 0, is_sampled=False, is_content=False)
542
+ sys_segments: list[tuple[str, bool]] = [("system\n", False)]
543
+ if reasoning_instructions:
544
+ separator = "\n\n" if sys_content else ""
545
+ sys_segments.append((reasoning_instructions + separator, False))
546
+ if sys_content:
547
+ sys_segments.append((sys_content, True))
548
+ emit_text_segments(sys_segments, 0, is_sampled=False)
549
+ emit_special(self._im_end, 0, is_sampled=False, is_content=False)
550
+ emit_text("\n", 0, is_sampled=False, is_content=False)
551
+ elif reasoning_instructions:
552
+ # Qwen3.8 injects reasoning guidance even when the caller did
553
+ # not provide a system message. It is renderer scaffold, so use
554
+ # the synthetic ``-1`` attribution bucket.
555
+ emit_special(self._im_start, -1, is_sampled=False, is_content=False)
556
+ emit_text_segments(
557
+ [("system\n", False), (reasoning_instructions, False)],
558
+ -1,
559
+ is_sampled=False,
560
+ )
561
+ emit_special(self._im_end, -1, is_sampled=False, is_content=False)
562
+ emit_text("\n", -1, is_sampled=False, is_content=False)
505
563
 
506
564
  # ── 2. Compute last_query_index ─────────────────────────────
507
565
  last_qi = self._last_query_index(messages)
@@ -932,22 +990,7 @@ class Qwen35Renderer:
932
990
  emit_ids,
933
991
  emit_text_segments,
934
992
  ) -> None:
935
- # Extract reasoning_content
936
- reasoning_content = ""
937
- if isinstance(msg.get("reasoning_content"), str):
938
- reasoning_content = msg["reasoning_content"]
939
- elif "</think>" in content:
940
- # Split on </think> to separate reasoning from content
941
- before_think_end, after_think_end = content.split("</think>", 1)
942
- # Extract text after <think> (if present)
943
- if "<think>" in before_think_end:
944
- reasoning_content = before_think_end.split("<think>")[-1].lstrip("\n")
945
- else:
946
- reasoning_content = before_think_end.lstrip("\n")
947
- reasoning_content = reasoning_content.rstrip("\n")
948
- content = after_think_end.lstrip("\n")
949
-
950
- reasoning_content = reasoning_content.strip()
993
+ reasoning_content, content = self._extract_assistant_parts(msg, content)
951
994
 
952
995
  # ``<|im_start|>assistant\n`` is template-injected scaffolding —
953
996
  # at inference the chat template emits these as the generation
@@ -0,0 +1,66 @@
1
+ """Qwen3.8 Renderer — mirrors the Qwen3.8 Jinja chat template.
2
+
3
+ Qwen3.8 retains Qwen3.6's multimodal message grammar and JSON-safe tool
4
+ argument serialization, with three template changes:
5
+
6
+ - ``reasoning_effort`` can be ``xhigh`` (default), ``medium``, or ``low``;
7
+ xhigh/low inject a matching instruction into the system prompt.
8
+ - ``preserve_thinking`` defaults to true, so historical reasoning blocks are
9
+ retained unless explicitly disabled.
10
+ - Legacy inline ``<think>...</think>`` text is no longer split out of
11
+ assistant ``content``; only ``reasoning_content`` populates the reasoning
12
+ block.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from renderers.base import Message
18
+ from renderers.configs import Qwen38RendererConfig
19
+ from renderers.qwen35 import Qwen35Renderer
20
+ from renderers.qwen36 import Qwen36Renderer
21
+
22
+
23
+ _REASONING_INSTRUCTIONS = {
24
+ "xhigh": (
25
+ "Reasoning effort is set to xhigh. Please think carefully through the "
26
+ "task, validate key assumptions, consider plausible alternatives, and "
27
+ "prioritize correctness, consistency, and clarity in the final answer."
28
+ ),
29
+ "medium": "",
30
+ "low": (
31
+ "Reasoning effort is set to low. Keep your thinking brief and focused, "
32
+ "moving directly to the conclusion without unnecessary elaboration."
33
+ ),
34
+ }
35
+
36
+
37
+ class Qwen38Renderer(Qwen36Renderer):
38
+ """Deterministic message-to-token renderer for Qwen3.8 models."""
39
+
40
+ config: Qwen38RendererConfig
41
+ _config_cls = Qwen38RendererConfig
42
+
43
+ def _reasoning_instructions(self) -> str:
44
+ if not self.config.enable_thinking:
45
+ return ""
46
+ return _REASONING_INSTRUCTIONS[self.config.reasoning_effort]
47
+
48
+ def _omit_empty_system_message(self) -> bool:
49
+ return True
50
+
51
+ @staticmethod
52
+ def _last_query_index(messages: list[Message]) -> int:
53
+ last_query_index = Qwen35Renderer._last_query_index(messages)
54
+ if last_query_index == len(messages):
55
+ raise ValueError("No user query found in messages.")
56
+ return last_query_index
57
+
58
+ @staticmethod
59
+ def _extract_assistant_parts(msg: Message, content: str) -> tuple[str, str]:
60
+ reasoning_content = msg.get("reasoning_content")
61
+ if not isinstance(reasoning_content, str):
62
+ reasoning_content = ""
63
+ return reasoning_content.strip(), content
64
+
65
+
66
+ __all__ = ["Qwen38Renderer"]
@@ -22,6 +22,7 @@ RENDERER_MODELS = [
22
22
  ("PrimeIntellect/Qwen3-0.6B", "auto"),
23
23
  ("Qwen/Qwen3.5-9B", "auto"),
24
24
  ("Qwen/Qwen3.6-35B-A3B", "auto"),
25
+ ("Qwen/Qwen3.8-27B", "auto"),
25
26
  ("Qwen/Qwen3-VL-4B-Instruct", "auto"),
26
27
  ("google/gemma-4-31B-it", "auto"),
27
28
  ("zai-org/GLM-5", "auto"),
@@ -26,6 +26,7 @@ _BRIDGE_MODELS = [
26
26
  ("PrimeIntellect/Qwen3-0.6B", "auto"),
27
27
  ("Qwen/Qwen3.5-9B", "auto"),
28
28
  ("Qwen/Qwen3.6-35B-A3B", "auto"),
29
+ ("Qwen/Qwen3.8-27B", "auto"),
29
30
  ("google/gemma-4-31B-it", "auto"),
30
31
  ("thinkingmachines/Inkling", "auto"),
31
32
  ("thinkingmachines/Inkling-Small", "auto"),
@@ -384,8 +384,9 @@ def test_generate_threads_prompt_attribution_through_prebuilt_prompt_path():
384
384
  [
385
385
  ("Qwen/Qwen3-VL-4B-Instruct", "renderers.qwen3_vl:Qwen3VLRenderer"),
386
386
  ("Qwen/Qwen3.5-2B", "renderers.qwen35:Qwen35Renderer"),
387
+ ("Qwen/Qwen3.8-27B", "renderers.qwen38:Qwen38Renderer"),
387
388
  ],
388
- ids=["qwen3_vl", "qwen35"],
389
+ ids=["qwen3_vl", "qwen35", "qwen38"],
389
390
  )
390
391
  def test_generate_serializes_multimodal_features_for_qwen_vl_family(
391
392
  model_id, renderer_class_path
@@ -32,6 +32,7 @@ from renderers.configs import (
32
32
  Qwen3RendererConfig,
33
33
  Qwen35RendererConfig,
34
34
  Qwen36RendererConfig,
35
+ Qwen38RendererConfig,
35
36
  )
36
37
 
37
38
  # One representative model per affected renderer family, each with
@@ -40,6 +41,10 @@ _MODELS = [
40
41
  ("Qwen/Qwen3-8B", Qwen3RendererConfig(enable_thinking=False)),
41
42
  ("Qwen/Qwen3.5-9B", Qwen35RendererConfig(enable_thinking=False)),
42
43
  ("Qwen/Qwen3.6-35B-A3B", Qwen36RendererConfig(enable_thinking=False)),
44
+ (
45
+ "Qwen/Qwen3.8-27B",
46
+ Qwen38RendererConfig(enable_thinking=False),
47
+ ),
43
48
  (
44
49
  "google/gemma-4-26B-A4B-it",
45
50
  Gemma4RendererConfig(enable_thinking=False),
@@ -0,0 +1,172 @@
1
+ """Focused Qwen3.8 registration and upstream-template parity coverage."""
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
+ Qwen38Renderer,
12
+ Qwen38RendererConfig,
13
+ RendererConfig,
14
+ create_renderer,
15
+ )
16
+ from renderers.base import MODEL_RENDERER_MAP, MULTIMODAL_MODELS, load_tokenizer
17
+
18
+
19
+ TOOLS = [
20
+ {
21
+ "type": "function",
22
+ "function": {
23
+ "name": "get_weather",
24
+ "description": "Get weather for a city",
25
+ "parameters": {
26
+ "type": "object",
27
+ "properties": {"city": {"type": "string"}},
28
+ "required": ["city"],
29
+ },
30
+ },
31
+ }
32
+ ]
33
+
34
+
35
+ @lru_cache(maxsize=1)
36
+ def _qwen38():
37
+ tokenizer = load_tokenizer("Qwen/Qwen3.8-27B")
38
+ return tokenizer, create_renderer(tokenizer)
39
+
40
+
41
+ def _expected(tokenizer, messages, **kwargs):
42
+ result = tokenizer.apply_chat_template(
43
+ messages,
44
+ tokenize=True,
45
+ return_dict=False,
46
+ **kwargs,
47
+ )
48
+ return list(result)
49
+
50
+
51
+ def test_qwen38_is_registered_with_native_defaults():
52
+ tokenizer, renderer = _qwen38()
53
+
54
+ assert tokenizer.name_or_path == "Qwen/Qwen3.8-27B"
55
+ assert MODEL_RENDERER_MAP[tokenizer.name_or_path] == "qwen3.8"
56
+ assert MULTIMODAL_MODELS[tokenizer.name_or_path] == {"image"}
57
+ assert isinstance(renderer, Qwen38Renderer)
58
+ assert renderer.config.enable_thinking is True
59
+ assert renderer.config.reasoning_effort == "xhigh"
60
+ assert renderer.config.preserve_thinking is True
61
+ assert renderer.effective_thinking_retention == "all"
62
+
63
+
64
+ def test_qwen38_config_discriminator():
65
+ parsed = TypeAdapter(RendererConfig).validate_python(
66
+ {
67
+ "name": "qwen3.8",
68
+ "reasoning_effort": "low",
69
+ "preserve_thinking": False,
70
+ }
71
+ )
72
+
73
+ assert isinstance(parsed, Qwen38RendererConfig)
74
+ assert parsed.reasoning_effort == "low"
75
+ assert parsed.preserve_thinking is False
76
+
77
+
78
+ @pytest.mark.parametrize(
79
+ "config_kwargs",
80
+ [
81
+ pytest.param({}, id="defaults"),
82
+ pytest.param({"reasoning_effort": "xhigh"}, id="xhigh"),
83
+ pytest.param({"reasoning_effort": "medium"}, id="medium"),
84
+ pytest.param({"reasoning_effort": "low"}, id="low"),
85
+ pytest.param({"enable_thinking": False}, id="thinking-disabled"),
86
+ pytest.param({"preserve_thinking": False}, id="drop-history-thinking"),
87
+ ],
88
+ )
89
+ def test_qwen38_text_and_tool_parity(config_kwargs):
90
+ tokenizer, _ = _qwen38()
91
+ renderer = Qwen38Renderer(tokenizer, Qwen38RendererConfig(**config_kwargs))
92
+ cases = [
93
+ (
94
+ [
95
+ {"role": "system", "content": "Be concise."},
96
+ {"role": "user", "content": "Hello."},
97
+ ],
98
+ {"add_generation_prompt": True},
99
+ ),
100
+ (
101
+ [
102
+ {"role": "system", "content": ""},
103
+ {"role": "user", "content": "Hello."},
104
+ ],
105
+ {"add_generation_prompt": True},
106
+ ),
107
+ (
108
+ [
109
+ {"role": "user", "content": "First question."},
110
+ {
111
+ "role": "assistant",
112
+ "reasoning_content": "First thought.",
113
+ "content": "First answer.",
114
+ },
115
+ {"role": "user", "content": "Second question."},
116
+ {
117
+ "role": "assistant",
118
+ "reasoning_content": "Second thought.",
119
+ "content": "Second answer.",
120
+ },
121
+ ],
122
+ {},
123
+ ),
124
+ (
125
+ [
126
+ {"role": "user", "content": "Weather in Paris?"},
127
+ {
128
+ "role": "assistant",
129
+ "content": "",
130
+ "tool_calls": [
131
+ {
132
+ "function": {
133
+ "name": "get_weather",
134
+ "arguments": {"city": "Paris", "fresh": True},
135
+ }
136
+ }
137
+ ],
138
+ },
139
+ {"role": "tool", "content": '{"temperature": 20}'},
140
+ ],
141
+ {"tools": TOOLS, "add_generation_prompt": True},
142
+ ),
143
+ ]
144
+
145
+ for messages, render_kwargs in cases:
146
+ expected = _expected(
147
+ tokenizer,
148
+ messages,
149
+ **config_kwargs,
150
+ **render_kwargs,
151
+ )
152
+ assert renderer.render_ids(messages, **render_kwargs) == expected
153
+
154
+
155
+ def test_qwen38_keeps_inline_think_markup_in_visible_content():
156
+ tokenizer, renderer = _qwen38()
157
+ messages = [
158
+ {"role": "user", "content": "Echo this."},
159
+ {
160
+ "role": "assistant",
161
+ "content": "<think>literal markup</think>visible content",
162
+ },
163
+ ]
164
+
165
+ assert renderer.render_ids(messages) == _expected(tokenizer, messages)
166
+
167
+
168
+ def test_qwen38_requires_a_real_user_query():
169
+ _, renderer = _qwen38()
170
+
171
+ with pytest.raises(ValueError, match="No user query found"):
172
+ renderer.render_ids([{"role": "system", "content": "System only."}])
@@ -47,6 +47,7 @@ _RENDERER_MODELS = [
47
47
  ("Qwen/Qwen3-8B", "auto"),
48
48
  ("Qwen/Qwen3.5-9B", "auto"),
49
49
  ("Qwen/Qwen3.6-35B-A3B", "auto"),
50
+ ("Qwen/Qwen3.8-27B", "auto"),
50
51
  ("google/gemma-4-31B-it", "auto"),
51
52
  ("zai-org/GLM-5", "auto"),
52
53
  ("zai-org/GLM-5.1", "auto"),
@@ -93,7 +94,7 @@ _KWARG_VALUES: dict[str, list[Any]] = {
93
94
  # gpt-oss accepts low/medium/high; Hy3 accepts no_think/low/high. The
94
95
  # union is listed here and the matrix builder drops values a given
95
96
  # renderer's typed config rejects (see ``_value_valid_for``).
96
- "reasoning_effort": ["no_think", "low", "medium", "high"],
97
+ "reasoning_effort": ["no_think", "low", "medium", "high", "xhigh"],
97
98
  # Hy3 — keep <think>{reasoning}</think> on historical assistant turns
98
99
  # (True) vs collapse past-cycle reasoning to <think></think> (False).
99
100
  "preserved_thinking": [True, False],
@@ -131,10 +132,11 @@ _KWARG_VALUES: dict[str, list[Any]] = {
131
132
  # passthrough mode. The renderer paths diverge significantly under
132
133
  # this flag, so both values are exercised.
133
134
  "render_assistant_messages_raw": [True, False],
134
- # Qwen3.5 / Qwen3.6 / Qwen3-VL — when True, prefix each image /
135
- # video placeholder with ``Picture N: `` / ``Video N: ``.
135
+ # Qwen3.5 / Qwen3.6 / Qwen3.8 / Qwen3-VL — when True, prefix each
136
+ # image / video placeholder with ``Picture N: `` / ``Video N: ``.
136
137
  "add_vision_id": [True, False],
137
- # Qwen3.6 — keep historical think blocks before the last real user query.
138
+ # Qwen3.6 / Qwen3.8 — keep historical think blocks before the last real
139
+ # user query. Qwen3.8 defaults this to True.
138
140
  "preserve_thinking": [True, False],
139
141
  # gpt-oss — pin to a fixed date so the renderer's preamble matches
140
142
  # the harmony oracle built with the same date. The default
@@ -32,6 +32,7 @@ _ROUNDTRIP_MODELS = [
32
32
  ("PrimeIntellect/Qwen3-0.6B", "auto"),
33
33
  ("Qwen/Qwen3.5-9B", "auto"),
34
34
  ("Qwen/Qwen3.6-35B-A3B", "auto"),
35
+ ("Qwen/Qwen3.8-27B", "auto"),
35
36
  ("Qwen/Qwen3-VL-4B-Instruct", "auto"),
36
37
  ("google/gemma-4-31B-it", "auto"),
37
38
  ("thinkingmachines/Inkling", "auto"),