interp-engine 1.2.2__tar.gz → 1.2.4__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 (172) hide show
  1. {interp_engine-1.2.2 → interp_engine-1.2.4}/PKG-INFO +3 -2
  2. {interp_engine-1.2.2 → interp_engine-1.2.4}/README.md +1 -0
  3. {interp_engine-1.2.2 → interp_engine-1.2.4}/docs/PERFORMANCE.md +9 -5
  4. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/__init__.py +12 -0
  5. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/chat_conventions.py +3 -0
  6. interp_engine-1.2.4/interp_engine/chat_formatters.py +451 -0
  7. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/model.py +9 -0
  8. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/tokenize.py +110 -20
  9. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_backend.py +15 -1
  10. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/mhc.py +39 -2
  11. {interp_engine-1.2.2 → interp_engine-1.2.4}/pyproject.toml +11 -5
  12. interp_engine-1.2.4/tests/test_chat_formatters.py +510 -0
  13. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_vllm_hyper_connections.py +19 -1
  14. {interp_engine-1.2.2 → interp_engine-1.2.4}/.gitignore +0 -0
  15. {interp_engine-1.2.2 → interp_engine-1.2.4}/LICENSE +0 -0
  16. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/README.md +0 -0
  17. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/__init__.py +0 -0
  18. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/bench_spec.py +0 -0
  19. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/cells.py +0 -0
  20. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/probe.py +0 -0
  21. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/publish.py +0 -0
  22. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/report_bench.py +0 -0
  23. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/deepseek-v4-flash-0731__eager.json +0 -0
  24. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/deepseek-v4-flash-0731__vllm-cudagraph.json +0 -0
  25. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/deepseek-v4-flash-0731__vllm-dspark-cudagraph.json +0 -0
  26. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/deepseek-v4-flash-0731__vllm-dspark.json +0 -0
  27. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/deepseek-v4-flash-0731__vllm-freeze.json +0 -0
  28. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/deepseek-v4-flash-0731__vllm.json +0 -0
  29. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/gemma-2-2b__eager.json +0 -0
  30. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/gemma-2-2b__vllm-cudagraph.json +0 -0
  31. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/gemma-2-2b__vllm-freeze.json +0 -0
  32. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/gemma-2-2b__vllm.json +0 -0
  33. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/llama-3.1-8b__eager.json +0 -0
  34. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/llama-3.1-8b__vllm-cudagraph.json +0 -0
  35. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/llama-3.1-8b__vllm-freeze.json +0 -0
  36. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/llama-3.1-8b__vllm.json +0 -0
  37. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/qwen3-4b__eager.json +0 -0
  38. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/qwen3-4b__vllm-cudagraph.json +0 -0
  39. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/qwen3-4b__vllm-freeze.json +0 -0
  40. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/qwen3-4b__vllm.json +0 -0
  41. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/qwen3.8-27b__eager.json +0 -0
  42. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/qwen3.8-27b__vllm-cudagraph.json +0 -0
  43. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/qwen3.8-27b__vllm-freeze.json +0 -0
  44. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results/qwen3.8-27b__vllm.json +0 -0
  45. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/results-latest.md +0 -0
  46. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/run_all.sh +0 -0
  47. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/run_bench.py +0 -0
  48. {interp_engine-1.2.2 → interp_engine-1.2.4}/benchmarks/workloads.py +0 -0
  49. {interp_engine-1.2.2 → interp_engine-1.2.4}/docs/AGENT_INTEGRATION.md +0 -0
  50. {interp_engine-1.2.2 → interp_engine-1.2.4}/docs/ARCHITECTURE_QUIRKS.md +0 -0
  51. {interp_engine-1.2.2 → interp_engine-1.2.4}/docs/COMPATIBILITY.md +0 -0
  52. {interp_engine-1.2.2 → interp_engine-1.2.4}/docs/ENGINE_HOOK_MAPPINGS.md +0 -0
  53. {interp_engine-1.2.2 → interp_engine-1.2.4}/docs/GRADIENTS.md +0 -0
  54. {interp_engine-1.2.2 → interp_engine-1.2.4}/docs/INTERNALS.md +0 -0
  55. {interp_engine-1.2.2 → interp_engine-1.2.4}/docs/PORTING.md +0 -0
  56. {interp_engine-1.2.2 → interp_engine-1.2.4}/docs/README.md +0 -0
  57. {interp_engine-1.2.2 → interp_engine-1.2.4}/docs/SUPPORTED_POINTS.md +0 -0
  58. {interp_engine-1.2.2 → interp_engine-1.2.4}/docs/USAGE.md +0 -0
  59. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/_loop.py +0 -0
  60. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/address.py +0 -0
  61. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/arch.py +0 -0
  62. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/attn_config.py +0 -0
  63. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/attn_scores.py +0 -0
  64. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/autograd_support.py +0 -0
  65. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/capture.py +0 -0
  66. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/chat_compose.py +0 -0
  67. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/cuda_preflight.py +0 -0
  68. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/dispatch.py +0 -0
  69. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/facts.py +0 -0
  70. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/hooks.py +0 -0
  71. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/lens.py +0 -0
  72. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/load.py +0 -0
  73. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/mappers.py +0 -0
  74. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/moe_routing.py +0 -0
  75. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/points.py +0 -0
  76. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/protocol.py +0 -0
  77. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/residual_basis.py +0 -0
  78. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/select.py +0 -0
  79. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/steer.py +0 -0
  80. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/steer_specs.py +0 -0
  81. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/sync.py +0 -0
  82. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/__init__.py +0 -0
  83. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/_demux.py +0 -0
  84. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/_hooks.py +0 -0
  85. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/_payload.py +0 -0
  86. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/_tree.py +0 -0
  87. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/attn.py +0 -0
  88. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/capture.py +0 -0
  89. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/freeze.py +0 -0
  90. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/graphs.py +0 -0
  91. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/lens/__init__.py +0 -0
  92. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/lens/intervene.py +0 -0
  93. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/lens/readout.py +0 -0
  94. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/lens/unembed.py +0 -0
  95. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/native.py +0 -0
  96. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/requests.py +0 -0
  97. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_capture/steering.py +0 -0
  98. {interp_engine-1.2.2 → interp_engine-1.2.4}/interp_engine/vllm_plugin.py +0 -0
  99. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/conftest.py +0 -0
  100. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/harness.py +0 -0
  101. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/model_expectations.yaml +0 -0
  102. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/synthetic_families.py +0 -0
  103. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_address.py +0 -0
  104. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_attn_config_tripwire.py +0 -0
  105. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_attn_probs_indexing.py +0 -0
  106. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_attn_scores.py +0 -0
  107. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_attn_z_gqa.py +0 -0
  108. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_autograd_support.py +0 -0
  109. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_bench_workloads.py +0 -0
  110. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_capability_refusals.py +0 -0
  111. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_capture_addressing.py +0 -0
  112. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_chat_compose.py +0 -0
  113. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_chat_templates.py +0 -0
  114. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_core.py +0 -0
  115. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_cuda_preflight.py +0 -0
  116. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_doc_code_fences.py +0 -0
  117. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_eager_autograd.py +0 -0
  118. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_facts.py +0 -0
  119. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_family_points.py +0 -0
  120. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_freeze_dsv4_gpu.py +0 -0
  121. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_freeze_parity_gpu.py +0 -0
  122. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_freeze_set.py +0 -0
  123. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_freeze_warmup.py +0 -0
  124. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_gated_attn_out.py +0 -0
  125. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_head_contributions.py +0 -0
  126. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_hook_call_conventions.py +0 -0
  127. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_layer_kinds.py +0 -0
  128. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_load.py +0 -0
  129. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_logit_transform.py +0 -0
  130. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_mappers.py +0 -0
  131. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_mlp_internals.py +0 -0
  132. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_model_expectations.py +0 -0
  133. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_moe.py +0 -0
  134. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_multimodal_arch.py +0 -0
  135. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_new_models_gpu.py +0 -0
  136. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_no_chat_template.py +0 -0
  137. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_normalized_hook.py +0 -0
  138. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_packaging.py +0 -0
  139. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_parity_gpt2.py +0 -0
  140. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_per_layer_attn_dims.py +0 -0
  141. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_points_registry.py +0 -0
  142. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_protocol.py +0 -0
  143. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_published_benchmarks.py +0 -0
  144. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_qk_norm.py +0 -0
  145. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_qkv_layout.py +0 -0
  146. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_reasoning_spans.py +0 -0
  147. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_release.py +0 -0
  148. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_resid_mid.py +0 -0
  149. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_residual_basis.py +0 -0
  150. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_sandwich_norms.py +0 -0
  151. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_select.py +0 -0
  152. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_sliding_window_attn.py +0 -0
  153. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_small_models_gpu.py +0 -0
  154. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_steer_context.py +0 -0
  155. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_steer_math_parity.py +0 -0
  156. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_sync_loop.py +0 -0
  157. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_sync_parity.py +0 -0
  158. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_unified_free_functions.py +0 -0
  159. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_unresolved_families.py +0 -0
  160. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_vllm_capture_gpu.py +0 -0
  161. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_vllm_capture_scales.py +0 -0
  162. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_vllm_graph_path.py +0 -0
  163. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_vllm_graphs_on_gpu.py +0 -0
  164. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_vllm_hook_availability.py +0 -0
  165. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_vllm_kv_isolation.py +0 -0
  166. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_vllm_new_points.py +0 -0
  167. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_vllm_only_families.py +0 -0
  168. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_vllm_plugin.py +0 -0
  169. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_vllm_wire_grammar.py +0 -0
  170. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_vocabulary_boundary.py +0 -0
  171. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_worker_lens_capture_readout.py +0 -0
  172. {interp_engine-1.2.2 → interp_engine-1.2.4}/tests/test_worker_lens_readout.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: interp-engine
3
- Version: 1.2.2
3
+ Version: 1.2.4
4
4
  Summary: A fast, standardized interpretability engine that supports most modern models and architectures. Powers Neuronpedia.
5
5
  Project-URL: Homepage, https://github.com/decoderesearch/interp-engine
6
6
  Project-URL: Repository, https://github.com/decoderesearch/interp-engine
@@ -26,7 +26,7 @@ Provides-Extra: quant
26
26
  Requires-Dist: accelerate>=1.0; extra == 'quant'
27
27
  Requires-Dist: kernels<0.17.0,>=0.15.2; extra == 'quant'
28
28
  Provides-Extra: vllm
29
- Requires-Dist: vllm>=0.25.1; (sys_platform == 'linux') and extra == 'vllm'
29
+ Requires-Dist: vllm>=0.27.1; (sys_platform == 'linux') and extra == 'vllm'
30
30
  Description-Content-Type: text/markdown
31
31
 
32
32
  # interp-engine
@@ -38,6 +38,7 @@ Description-Content-Type: text/markdown
38
38
  🔗 <a href="https://interp-engine.org"><strong>interp-engine.org</strong></a>
39
39
  </p>
40
40
 
41
+
41
42
  `interp-engine` is an interpretability engine that is fast (>40x tok/s vs HF eager), standardized (34 'points'/addresses across architectures), and easy to use and debug. It powers all of [Neuronpedia](https://neuronpedia.org)'s inference and is checked for accuracy against HF Transformers and other engines.
42
43
 
43
44
  <p align="center">
@@ -7,6 +7,7 @@
7
7
  🔗 <a href="https://interp-engine.org"><strong>interp-engine.org</strong></a>
8
8
  </p>
9
9
 
10
+
10
11
  `interp-engine` is an interpretability engine that is fast (>40x tok/s vs HF eager), standardized (34 'points'/addresses across architectures), and easy to use and debug. It powers all of [Neuronpedia](https://neuronpedia.org)'s inference and is checked for accuracy against HF Transformers and other engines.
11
12
 
12
13
  <p align="center">
@@ -42,13 +42,17 @@ every new upstream flag into an engine change.
42
42
 
43
43
  ### The vLLM version is a floor, not a ceiling
44
44
 
45
- `interp-engine[vllm]` declares `vllm>=0.25.1` on Linux and no upper bound. Read that as **the oldest
45
+ `interp-engine[vllm]` declares `vllm>=0.27.1` on Linux and no upper bound. Read that as **the oldest
46
46
  version we have evidence for**, not the version to run:
47
47
 
48
- - **The floor is measured, not guessed.** 0.25.1 is the oldest version the engine has been exercised
49
- on. The current sweep in [`benchmarks/results-latest.md`](../benchmarks/results-latest.md) ran on
50
- **0.26.0**, which is also what `apps/inference` and `apps/nla` resolve to being ahead of the floor
51
- is the normal state, not drift.
48
+ - **The floor is measured, not guessed.** 0.27.1 is the version the DeepSeek-V4-Flash-0731
49
+ cross-engine comparison scored, and the oldest the engine is supported on. The performance sweep in
50
+ [`benchmarks/results-latest.md`](../benchmarks/results-latest.md) ran on **0.26.0**, one below
51
+ being ahead of the floor is the normal state, not drift.
52
+ - **This floor is load-bearing, not hygiene.** It was 0.25.1 until `vllm_capture/mhc.py` needed
53
+ `mhc_pre_broadcast_tilelang`, which arrived in 0.26.0. Below that, every DeepSeek-V4
54
+ hyper-connection point is refused at install, and since `FREEZE_POINTS=auto` selects
55
+ `resid_streams` on such a trunk, the pod does not load at all.
52
56
  - **There is deliberately no cap.** vLLM moves fast and a `<` bound would make every upstream release
53
57
  an engine release; a lock file is the right place to pin a deployment, and both apps have one. The
54
58
  version an app runs is whatever its `uv.lock` resolved, which is normally newer than this floor.
@@ -58,6 +58,13 @@ from interp_engine.chat_conventions import (
58
58
  detect_reasoning_tags,
59
59
  is_harmony,
60
60
  )
61
+ from interp_engine.chat_formatters import (
62
+ CODE_CHAT_FORMATS,
63
+ ChatFormatter,
64
+ ChatFormatterUnavailable,
65
+ RenderedChat,
66
+ resolve_chat_formatter,
67
+ )
61
68
  from interp_engine.cuda_preflight import check_cuda_driver
62
69
  from interp_engine.dispatch import CAPABILITIES, Capability, CapabilityUnsupported, TokensLike
63
70
  from interp_engine.facts import rms_norm_eps_for_model
@@ -265,11 +272,15 @@ __all__ = [
265
272
  "resolve_arch",
266
273
  "unsupported_attn_config",
267
274
  # Tokenization and chat templates.
275
+ "CODE_CHAT_FORMATS",
276
+ "ChatFormatter",
277
+ "ChatFormatterUnavailable",
268
278
  "ChatTurn",
269
279
  "GeneratedTurnSpans",
270
280
  "NoChatTemplateError",
271
281
  "REASONING_TAGS",
272
282
  "ReasoningTags",
283
+ "RenderedChat",
273
284
  "TURN_END_TOKENS",
274
285
  "TokenSpan",
275
286
  "Tokenize",
@@ -277,6 +288,7 @@ __all__ = [
277
288
  "compose_assistant_turns",
278
289
  "detect_reasoning_tags",
279
290
  "is_harmony",
291
+ "resolve_chat_formatter",
280
292
  "special_token_ids",
281
293
  "special_token_positions",
282
294
  "strip_wire_reasoning",
@@ -50,6 +50,9 @@ TURN_END_TOKENS = frozenset(
50
50
  "<|end_of_text|>",
51
51
  "<|endoftext|>",
52
52
  "<|eom_id|>",
53
+ # DeepSeek (V3 and V4 lines). Note the full-width bars and the U+2581 word separators:
54
+ # these are the checkpoint's actual added-token strings, not the ASCII lookalikes.
55
+ "<|end▁of▁sentence|>",
53
56
  }
54
57
  )
55
58
 
@@ -0,0 +1,451 @@
1
+ """Chat formats that live in Python instead of in a Jinja ``chat_template``.
2
+
3
+ Almost every chat model describes its own prompt format in the tokenizer's ``chat_template``,
4
+ which is why :meth:`Tokenize.message_spans` needs no per-model knowledge: it renders the real
5
+ template and diffs. A few checkpoints ship no template at all and define the format in code
6
+ instead, because the thing they need to express is not a render. DeepSeek-V4 is the case this
7
+ module exists for: its ``encoding/`` folder carries an encoder *and* a parser
8
+ (``parse_message_from_completion_text``), and a template can only ever do the first half.
9
+
10
+ **The reference implementation ships inside the checkpoint**, at
11
+ ``encoding/encoding_dsv4.py``, beside the weights and versioned with them. So this module
12
+ downloads and imports it rather than carrying a copy: a vendored fork is a second source of
13
+ truth for a format whose first source of truth we already have on disk. That the risk is real
14
+ rather than theoretical is easy to check -- vLLM's fork of the same file
15
+ (``vllm/tokenizers/deepseek_v4_encoding.py``) renamed the assistant field
16
+ ``reasoning_content`` to ``reasoning``, so feeding it upstream-shaped messages silently drops
17
+ every thinking block. :func:`_reasoning_field` reads the field name back out of whichever copy
18
+ got loaded rather than assuming either spelling.
19
+
20
+ Loading it is remote code execution, so it is gated on the same ``trust_remote_code`` flag the
21
+ weights are. When the file cannot be fetched the engine keeps loading and simply reports no
22
+ chat support, which lands the caller on the existing raw-text refusal rather than on a failed
23
+ load -- chat is one endpoint, not the model.
24
+
25
+ Adding a family means one entry in :data:`CODE_CHAT_FORMATS` and a class satisfying
26
+ :class:`ChatFormatter`. Generation-side structure (reasoning delimiters, harmony channels)
27
+ stays in ``chat_conventions``; this module is only about rendering the prompt.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import importlib.util
33
+ import inspect
34
+ import logging
35
+ import re
36
+ import sys
37
+ from collections.abc import Callable, Mapping, Sequence
38
+ from dataclasses import dataclass
39
+ from types import ModuleType
40
+ from typing import Any, Protocol
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ Message = Mapping[str, Any]
45
+
46
+
47
+ class ChatFormatterUnavailable(RuntimeError):
48
+ """The architecture needs a code formatter and the engine could not load one.
49
+
50
+ Raised by the loaders, caught by :func:`resolve_chat_formatter`, which downgrades it to a
51
+ warning: a model whose chat format is unreachable still captures, steers and completes raw
52
+ text, so this must not gate loading.
53
+ """
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class RenderedChat:
58
+ """A chat render, kept split at the boundaries span metadata is derived from.
59
+
60
+ ``blocks`` is 1:1 with the input messages, in order, so ``blocks[k]`` is exactly what
61
+ message ``k`` contributed to the prompt. ``prefix`` holds what precedes message 0 (a BOS
62
+ token, an injected tools preamble) and ``suffix`` the trailing generation scaffold.
63
+
64
+ The split is the point. A Jinja template can only be rendered whole, so ``message_spans``
65
+ has to recover boundaries by re-rendering growing message prefixes and diffing tokens --
66
+ which assumes that growing the message list only appends, and DeepSeek-V4 breaks that
67
+ assumption (whether a turn is the *last* user turn changes how earlier turns render). A
68
+ formatter that reports its own boundaries sidesteps the diff entirely: ``upto`` returns a
69
+ genuine string prefix of ``text``, so the token prefix is exact.
70
+ """
71
+
72
+ prefix: str
73
+ blocks: tuple[str, ...]
74
+ suffix: str
75
+
76
+ @property
77
+ def text(self) -> str:
78
+ """The whole prompt, exactly as it would be tokenized."""
79
+ return self.prefix + "".join(self.blocks) + self.suffix
80
+
81
+ def upto(self, count: int) -> str:
82
+ """``text`` truncated to the first ``count`` messages, generation scaffold excluded."""
83
+ return self.prefix + "".join(self.blocks[:count])
84
+
85
+
86
+ class ChatFormatter(Protocol):
87
+ """What :class:`~interp_engine.tokenize.Tokenize` needs from a code-defined chat format.
88
+
89
+ ``template_kwargs`` is the set of keyword arguments the format actually reads. Callers use
90
+ it the way they used to grep the Jinja source for a variable name: to pass
91
+ ``enable_thinking`` only where it means something. A formatter refuses an unknown kwarg
92
+ rather than ignoring it, since a silently dropped ``enable_thinking`` renders the wrong
93
+ prompt and returns 200.
94
+ """
95
+
96
+ name: str
97
+ template_kwargs: frozenset[str]
98
+
99
+ def render(
100
+ self,
101
+ messages: Sequence[Message],
102
+ *,
103
+ add_generation_prompt: bool = True,
104
+ continue_final_message: bool = False,
105
+ **template_kwargs: Any,
106
+ ) -> RenderedChat: ...
107
+
108
+
109
+ # --------------------------------------------------------------------------- #
110
+ # DeepSeek-V4
111
+ # --------------------------------------------------------------------------- #
112
+
113
+ # Where the reference encoder sits in every DeepSeek-V4 repo (V4-Flash, V4-Pro). The folder
114
+ # also carries README.md (the format spec) and tests/ (input/expected-output fixtures, which
115
+ # validator/ uses as golden cases).
116
+ DEEPSEEK_V4_ENCODER_FILE = "encoding/encoding_dsv4.py"
117
+
118
+ # vLLM vendors the same file. Only consulted when the checkpoint's own copy cannot be fetched,
119
+ # and it is a fork rather than a mirror -- see the module docstring.
120
+ DEEPSEEK_V4_FALLBACK_MODULE = "vllm.tokenizers.deepseek_v4_encoding"
121
+
122
+
123
+ class DeepseekV4Formatter:
124
+ """Renders DeepSeek-V4 prompts through the encoder shipped with the checkpoint.
125
+
126
+ Three of the encoder's conventions differ from the ``apply_chat_template`` vocabulary the
127
+ rest of the engine speaks, and each is translated here rather than at the call sites:
128
+
129
+ - **There is no ``add_generation_prompt`` flag.** The encoder appends
130
+ ``<|Assistant|>`` plus a thinking delimiter whenever the conversation ends on a user or
131
+ developer turn, unconditionally. So the scaffold is *identified* after the fact and
132
+ reported as :attr:`RenderedChat.suffix`, which callers keep or drop. Note this makes
133
+ ``add_generation_prompt=True`` a no-op after a closed assistant turn, where a Jinja
134
+ template would open a fresh one: the encoder's own answer is that a transcript ending in
135
+ a completed answer is a transcript, not a prompt, and synthesizing the opener anyway
136
+ would put two tokens in front of the model that the checkpoint's own reference encoder
137
+ never emits. The fixtures in ``encoding/tests/`` are exactly such transcripts.
138
+ - **There is no ``continue_final_message`` flag.** A prefill is spelled per message, as
139
+ ``wo_eos``, which suppresses that turn's end-of-sentence token. That is a better fit
140
+ than the flag it replaces: transformers implements ``continue_final_message`` by
141
+ rendering a sentinel into the content and cutting the string, and here nothing is cut.
142
+ - **Historical reasoning is dropped by default** (``drop_thinking``), which is what makes
143
+ the render non-monotonic and is why this class reports message blocks itself.
144
+ """
145
+
146
+ name = "deepseek_v4"
147
+ # `thinking` is the encoder's own spelling and `enable_thinking` the one every other family
148
+ # in this engine uses; both are accepted so a caller need not know which model it has.
149
+ template_kwargs = frozenset({"enable_thinking", "thinking", "reasoning_effort", "drop_thinking", "tools"})
150
+
151
+ def __init__(self, module: ModuleType):
152
+ self._module = module
153
+ self._encode: Callable[..., str] = getattr(module, "encode_messages") # noqa: B009 - module attr
154
+ self._reasoning_key = _reasoning_field(module)
155
+ self._assistant_token = str(getattr(module, "ASSISTANT_SP_TOKEN", "<|Assistant|>"))
156
+ self._think_open = str(getattr(module, "thinking_start_token", "<think>"))
157
+ self._think_close = str(getattr(module, "thinking_end_token", "</think>"))
158
+ self._bos = str(getattr(module, "bos_token", ""))
159
+ self._encode_params = _accepted_parameters(self._encode)
160
+
161
+ # --- rendering ----------------------------------------------------------
162
+ def render(
163
+ self,
164
+ messages: Sequence[Message],
165
+ *,
166
+ add_generation_prompt: bool = True,
167
+ continue_final_message: bool = False,
168
+ **template_kwargs: Any,
169
+ ) -> RenderedChat:
170
+ config, tools = self._encode_config(template_kwargs)
171
+ turns = [self._normalize(m) for m in messages]
172
+
173
+ if continue_final_message:
174
+ if not turns or turns[-1].get("role") != "assistant":
175
+ raise ValueError(
176
+ "continue_final_message=True keeps a trailing ASSISTANT turn open, but the last "
177
+ f"message is {turns[-1].get('role') if turns else 'absent'!r}."
178
+ )
179
+ turns[-1] = {**turns[-1], "wo_eos": True}
180
+
181
+ # Tools ride on a system message in this format. Injecting one shifts every index, so
182
+ # its render is folded into `prefix` and `blocks` stays 1:1 with the input messages.
183
+ lead: list[dict[str, Any]] = [{"role": "system", "tools": list(tools)}] if tools else []
184
+ conversation = lead + turns
185
+
186
+ tails = self._tails(conversation, config)
187
+ blocks = [tails[k][: len(tails[k]) - len(tails[k + 1])] for k in range(len(conversation))]
188
+ prefix = self._bos + "".join(blocks[: len(lead)])
189
+ blocks = blocks[len(lead) :]
190
+
191
+ scaffold = self._split_scaffold(blocks, turns)
192
+ return RenderedChat(prefix=prefix, blocks=tuple(blocks), suffix=scaffold if add_generation_prompt else "")
193
+
194
+ def _tails(self, conversation: Sequence[Message], config: dict[str, Any]) -> list[str]:
195
+ """``tails[k]`` = messages ``k..end`` rendered *in the context of the whole conversation*.
196
+
197
+ The encoder's own ``context`` argument is what makes per-message blocks exact. Passing
198
+ ``messages[k:]`` with ``context=messages[:k]`` renders only the tail, but computes
199
+ ``last_user_index`` -- the fact that makes this format non-monotonic -- over the whole
200
+ list, so ``tails[k] == block_k + tails[k + 1]`` holds by construction and subtracting
201
+ one from the next recovers each block exactly.
202
+ """
203
+ tails = [""] * (len(conversation) + 1)
204
+ for k in range(len(conversation) - 1, -1, -1):
205
+ tails[k] = self._call_encoder(
206
+ list(conversation[k:]),
207
+ context=list(conversation[:k]),
208
+ **config,
209
+ )
210
+ if not tails[k].endswith(tails[k + 1]):
211
+ # Would mean the encoder's `context` no longer decomposes the render this way,
212
+ # which is the assumption every span position rests on.
213
+ raise ChatFormatterUnavailable(
214
+ f"{DEEPSEEK_V4_ENCODER_FILE} did not render message {k} as a prefix of the "
215
+ "messages after it, so message boundaries cannot be located. The encoder's "
216
+ "`context` argument may have changed meaning."
217
+ )
218
+ return tails
219
+
220
+ def _call_encoder(self, messages: list[Any], **kwargs: Any) -> str:
221
+ """Call ``encode_messages``, passing only arguments this copy of it declares.
222
+
223
+ The checkpoint's encoder and vLLM's fork of it do not have identical signatures, and a
224
+ future revision may add or drop a knob. Filtering here means an unknown argument
225
+ degrades to the encoder's own default instead of raising ``TypeError`` at render time.
226
+ """
227
+ kwargs.setdefault("add_default_bos_token", False)
228
+ if self._encode_params is not None:
229
+ kwargs = {k: v for k, v in kwargs.items() if k in self._encode_params}
230
+ return str(self._encode(messages, **kwargs))
231
+
232
+ # --- translation --------------------------------------------------------
233
+ def _encode_config(self, template_kwargs: Mapping[str, Any]) -> tuple[dict[str, Any], Sequence[Any]]:
234
+ """Map the engine's template kwargs onto ``encode_messages`` arguments."""
235
+ unknown = sorted(set(template_kwargs) - self.template_kwargs)
236
+ if unknown:
237
+ raise ValueError(
238
+ f"{self.name} does not read {', '.join(unknown)}. It accepts: "
239
+ f"{', '.join(sorted(self.template_kwargs))}."
240
+ )
241
+ thinking = bool(template_kwargs.get("enable_thinking") or template_kwargs.get("thinking"))
242
+ effort = template_kwargs.get("reasoning_effort")
243
+ effort = effort if isinstance(effort, str) else None
244
+ if effort == "none":
245
+ thinking, effort = False, None
246
+ elif effort in ("max", "xhigh"):
247
+ effort = "max"
248
+ elif effort == "low":
249
+ # The encoder only branches on "max"; it asserts on anything outside
250
+ # {"max", "high", None}, and treats "high" and None identically. So "low" is None
251
+ # rather than "high" -- same render either way, but it does not claim otherwise.
252
+ effort = None
253
+ elif effort is not None:
254
+ effort = "high"
255
+ return (
256
+ {
257
+ "thinking_mode": "thinking" if thinking else "chat",
258
+ "drop_thinking": bool(template_kwargs.get("drop_thinking", True)),
259
+ "reasoning_effort": effort,
260
+ },
261
+ template_kwargs.get("tools") or (),
262
+ )
263
+
264
+ def _normalize(self, message: Message) -> dict[str, Any]:
265
+ """A message dict spelling reasoning the way the loaded encoder reads it."""
266
+ turn = dict(message)
267
+ alias = "reasoning" if self._reasoning_key == "reasoning_content" else "reasoning_content"
268
+ if alias in turn:
269
+ value = turn.pop(alias)
270
+ turn.setdefault(self._reasoning_key, value)
271
+ return turn
272
+
273
+ def _split_scaffold(self, blocks: list[str], turns: Sequence[Message]) -> str:
274
+ """Remove and return the trailing generation scaffold from ``blocks``, or ``""``.
275
+
276
+ The encoder emits it only after a user or developer turn that carries no ``task``, so
277
+ that is the only case checked. Narrowing it matters twice over: assistant content that
278
+ happened to end in these two tokens would otherwise be mistaken for scaffolding, and a
279
+ ``task`` turn puts its own task token *after* the scaffold, so the suffix match would
280
+ miss it and the strip would cut in the wrong place.
281
+ """
282
+ if not blocks or not turns:
283
+ return ""
284
+ last = turns[-1]
285
+ if last.get("role") not in ("user", "developer") or last.get("task") is not None:
286
+ return ""
287
+ for delimiter in (self._think_open, self._think_close):
288
+ scaffold = self._assistant_token + delimiter
289
+ if blocks[-1].endswith(scaffold):
290
+ blocks[-1] = blocks[-1][: -len(scaffold)]
291
+ return scaffold
292
+ return ""
293
+
294
+
295
+ def _reasoning_field(module: ModuleType) -> str:
296
+ """The message key this copy of the encoder reads a thinking block from.
297
+
298
+ Read out of ``thinking_template`` -- ``"{reasoning_content}"`` upstream,
299
+ ``"{reasoning}"`` in vLLM's fork -- rather than assumed, because guessing wrong drops
300
+ every thinking block without raising.
301
+ """
302
+ template = str(getattr(module, "thinking_template", "") or "")
303
+ match = re.fullmatch(r"\s*\{(\w+)\}\s*", template)
304
+ if match is None:
305
+ logger.warning("Could not read the reasoning field name from %r; assuming reasoning_content", template)
306
+ return "reasoning_content"
307
+ return match.group(1)
308
+
309
+
310
+ def _accepted_parameters(func: Callable[..., Any]) -> frozenset[str] | None:
311
+ """The keyword arguments ``func`` declares. ``None`` means "anything" (it takes ``**kwargs``)."""
312
+ try:
313
+ parameters = inspect.signature(func).parameters
314
+ except (TypeError, ValueError): # pragma: no cover - C-implemented callable
315
+ return None
316
+ if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()):
317
+ return None
318
+ return frozenset(parameters)
319
+
320
+
321
+ # --------------------------------------------------------------------------- #
322
+ # Loading the checkpoint's own encoder
323
+ # --------------------------------------------------------------------------- #
324
+
325
+
326
+ def _download_repo_file(hf_model_id: str, filename: str) -> str | None:
327
+ """Local path to ``filename`` from the checkpoint repo, cache first, then the hub."""
328
+ try:
329
+ from huggingface_hub import hf_hub_download
330
+ except ImportError: # pragma: no cover - transformers depends on it, so this is belt-and-braces
331
+ logger.warning("huggingface_hub is not installed, so %s cannot be fetched", filename)
332
+ return None
333
+ last: Exception | None = None
334
+ for local_files_only in (True, False):
335
+ try:
336
+ return hf_hub_download(hf_model_id, filename, local_files_only=local_files_only)
337
+ except Exception as exc: # noqa: BLE001 - offline, gated, or absent: all mean "no file"
338
+ last = exc
339
+ logger.info("Could not fetch %s from %s (%s)", filename, hf_model_id, last)
340
+ return None
341
+
342
+
343
+ def _import_file(path: str, module_name: str) -> ModuleType | None:
344
+ """Import a standalone ``.py`` file under ``module_name``."""
345
+ existing = sys.modules.get(module_name)
346
+ if existing is not None:
347
+ return existing
348
+ spec = importlib.util.spec_from_file_location(module_name, path)
349
+ if spec is None or spec.loader is None:
350
+ return None
351
+ module = importlib.util.module_from_spec(spec)
352
+ # Registered before exec so the module can import itself by name if it ever needs to; also
353
+ # what makes the check above a cache rather than a repeated exec.
354
+ sys.modules[module_name] = module
355
+ try:
356
+ spec.loader.exec_module(module)
357
+ except Exception:
358
+ sys.modules.pop(module_name, None)
359
+ raise
360
+ return module
361
+
362
+
363
+ def _module_alias(hf_model_id: str, suffix: str) -> str:
364
+ """A ``sys.modules`` key unique to this checkpoint, so two revisions cannot collide."""
365
+ return f"interp_engine._chat_encoders.{re.sub(r'[^0-9A-Za-z_]+', '_', hf_model_id)}_{suffix}"
366
+
367
+
368
+ def load_deepseek_v4_formatter(hf_model_id: str, *, trust_remote_code: bool = True) -> DeepseekV4Formatter:
369
+ """Load DeepSeek-V4's reference encoder and wrap it as a :class:`ChatFormatter`.
370
+
371
+ Prefers the copy inside the checkpoint, which is the format's source of truth and travels
372
+ with the weights. Falls back to vLLM's vendored fork only when that file cannot be
373
+ fetched -- it is a fork, and the module docstring lists how it has diverged.
374
+ """
375
+ if not trust_remote_code:
376
+ raise ChatFormatterUnavailable(
377
+ f"{hf_model_id} defines its chat format in {DEEPSEEK_V4_ENCODER_FILE} rather than in a "
378
+ "chat template, and importing it is remote code execution. Load with "
379
+ "trust_remote_code=True to render chat messages for this model."
380
+ )
381
+
382
+ path = _download_repo_file(hf_model_id, DEEPSEEK_V4_ENCODER_FILE)
383
+ if path is not None:
384
+ module = _import_file(path, _module_alias(hf_model_id, "dsv4"))
385
+ if module is not None and hasattr(module, "encode_messages"):
386
+ logger.info("Loaded the DeepSeek-V4 chat encoder from %s", path)
387
+ return DeepseekV4Formatter(module)
388
+
389
+ try:
390
+ module = importlib.import_module(DEEPSEEK_V4_FALLBACK_MODULE)
391
+ except ImportError:
392
+ module = None
393
+ if module is not None:
394
+ logger.warning(
395
+ "Using vLLM's vendored copy of the DeepSeek-V4 encoder: %s could not be fetched from %s. "
396
+ "It is a fork of the checkpoint's own encoder, not a mirror.",
397
+ DEEPSEEK_V4_ENCODER_FILE,
398
+ hf_model_id,
399
+ )
400
+ return DeepseekV4Formatter(module)
401
+
402
+ raise ChatFormatterUnavailable(
403
+ f"{hf_model_id} ships no chat template; its format is defined by "
404
+ f"{DEEPSEEK_V4_ENCODER_FILE}, which could not be fetched from the repo, and "
405
+ f"{DEEPSEEK_V4_FALLBACK_MODULE} is not importable either."
406
+ )
407
+
408
+
409
+ # Architecture *prefixes* (as they appear in ``config.architectures``) whose chat format is
410
+ # code rather than a template, mapped to the loader that builds it. Prefixes and families
411
+ # rather than checkpoints, matching `facts.MANDATORY_KV_CACHE_DTYPES`: this follows from the
412
+ # format the family was post-trained on, so every DeepSeek-V4 checkpoint shares it.
413
+ CODE_CHAT_FORMATS: dict[str, Callable[..., ChatFormatter]] = {
414
+ "DeepseekV4": load_deepseek_v4_formatter,
415
+ }
416
+
417
+
418
+ _CACHE: dict[tuple[str, str, bool], ChatFormatter | None] = {}
419
+
420
+
421
+ def resolve_chat_formatter(
422
+ architectures: Sequence[str] | None,
423
+ hf_model_id: str,
424
+ *,
425
+ trust_remote_code: bool = True,
426
+ ) -> ChatFormatter | None:
427
+ """The code formatter these architectures need, or ``None`` when a template is enough.
428
+
429
+ ``None`` is the answer for the great majority of models, and also the answer when a
430
+ formatter is needed but unreachable -- that case logs a warning naming the file. Loading a
431
+ model must not fail because one endpoint is unavailable, and the caller already has a
432
+ refusal for "this model cannot take chat input" (``NoChatTemplateError``) that says to send
433
+ raw text instead.
434
+
435
+ Takes the config's whole ``architectures`` list for the reason
436
+ :func:`facts.mandatory_kv_cache_dtype` does: it is the shape callers already hold, and a
437
+ composite config can name more than one.
438
+ """
439
+ for name in architectures or ():
440
+ for prefix, loader in CODE_CHAT_FORMATS.items():
441
+ if not str(name).startswith(prefix):
442
+ continue
443
+ key = (str(name), hf_model_id, bool(trust_remote_code))
444
+ if key not in _CACHE:
445
+ try:
446
+ _CACHE[key] = loader(hf_model_id, trust_remote_code=trust_remote_code)
447
+ except ChatFormatterUnavailable as exc:
448
+ logger.warning("No chat formatter for %s: %s", hf_model_id, exc)
449
+ _CACHE[key] = None
450
+ return _CACHE[key]
451
+ return None
@@ -38,6 +38,7 @@ from interp_engine import facts, moe_routing
38
38
  from interp_engine.address import Address, to_address
39
39
  from interp_engine.arch import ArchSpec, resolve_arch
40
40
  from interp_engine.autograd_support import GradSupport, eager_grad_support
41
+ from interp_engine.chat_formatters import resolve_chat_formatter
41
42
  from interp_engine.facts import factored_projection, text_config
42
43
  from interp_engine.points import PointSpec, Scope, known_names, point_spec, points_for
43
44
  from interp_engine.protocol import Completion
@@ -310,6 +311,14 @@ class EagerModel:
310
311
  self.tokenizer,
311
312
  default_prepend_bos=default_prepend_bos,
312
313
  device=str(self.device),
314
+ # None for every family whose tokenizer carries its own chat template, which is
315
+ # nearly all of them. Never raises: a model whose code-defined format cannot be
316
+ # loaded still serves everything except chat input.
317
+ formatter=resolve_chat_formatter(
318
+ getattr(self.config, "architectures", None),
319
+ hf_model_id,
320
+ trust_remote_code=trust_remote,
321
+ ),
313
322
  )
314
323
 
315
324
  # `requires_grad=True` IS a gradient request, so this is its point of use and the same gate