Three Jinja chat templates live here. Two are verbatim upstream copies. One is a patched fork for agentic coding harnesses.
-
gemma4_upstream.jinja— verbatim copy ofgoogle/gemma-4-31B-it. The baseline. The conformance suite diffs the fork against this file. -
gemma4_e4b_upstream.jinja— verbatim upstream template for the E4B model. This is the default the router serves for E4B. Do not patch it. -
custom_pub_chat_template_gemma4.jinja— patched fork of the 31B template (pinned atfcf2302760ae9c6e528a8dbba9dd636e56848237). Five patches (P1–P5) fix edge cases that hurt multi-turn tool calling. Apache 2.0, same as upstream.
- E4B router:
gemma4_e4b_upstream.jinja. Stock behavior, no patches. - 31B / 26B / 12B dense:
custom_pub_chat_template_gemma4.jinja. These models drive coding harnesses and hit the bug sites the fork fixes.
The fork carries five patches against the 31B upstream template. Full comments
sit next to each patch site in the .jinja file.
-
P1 — JSON
nullinstead of bare"None".format_argumentran PythonNonethroughstr(), emitting the literalNoneinto the DSL (e.g.after:Nonein a search call). Optional fields are everywhere in coding tools. The fork emitsnull. Theis nonebranch must come first, beforeis string/is mapping/is sequence. -
P2 —
enable_thinkingdefaults toTrue. Upstream defaults it toFalse, and most OpenAI-compatible adapters drop unknown request fields, so thinking ends up permanently off with no failure signal. Tool-call accuracy suffers. See anomalyco/opencode#24264 -
P3 — string-typed
argumentsraises instead of corrupting. Whentool_call.argumentsarrives as a JSON string (Vercel AI SDK and several OpenAI-compatible adapters serialize this way), upstream wraps it in extra braces and produces invalid DSL likecall:fn{{"city":"Tokyo"}}— nested braces, JSON colons, quoted keys, none of it trained on. The fork raises so the bug surfaces at the adapter boundary instead of silently degrading. See earendil-works/pi#3325 -
P4 —
preserve_thinkingkwarg, defaults toTrue. Upstream drops prior-turn reasoning from history. For chat that's correct; for multi-step agentic tool calling it's harmful — after 2–3 turns tool calls collapse toarguments: {}. The fork keeps prior<|channel>reasoning visible. Same issue as the Qwen analogue: earendil-works/pi#3325 -
P5 — symmetric turn-tag close for HF discussion #62. Two back-to-back text-only assistant messages rendered with one open and two closes (
<|turn>model\npart 1<turn|>\npart 2<turn|>\n) — malformed. The model reads it as a truncated, re-opened turn, which destabilizes long agentic histories. The fork forward-scans for the next non-tool message and suppresses the redundant close. Conformance test T13 locks this in.
Both servers take the file via --chat-template.
vLLM:
vllm serve google/gemma-4-31B-it \
--chat-template gemma4/chat_templates/custom_pub_chat_template_gemma4.jinja
SGLang:
python -m sglang.launch_server --model-path google/gemma-4-31B-it \
--chat-template gemma4/chat_templates/custom_pub_chat_template_gemma4.jinja
For E4B, point at gemma4_e4b_upstream.jinja instead.
The two new behaviors are kwargs. Pass them through chat_template_kwargs:
{
"extra_body": {
"chat_template_kwargs": {
"enable_thinking": false,
"preserve_thinking": false
}
}
}enable_thinking: falseturns off P2.preserve_thinking: falseturns off P4 and recovers upstream history handling exactly.
For opencode-style providers this maps to the chat_template_args field in the
models config. For pi, set thinkingFormat in the provider compat block and pi
injects these kwargs.
With both enable_thinking=False and preserve_thinking=False passed
explicitly, the fork renders byte-for-byte identical to upstream on every input
that doesn't hit a P1, P3, or P5 bug site. That's the prefix-cache contract: any
drift means a patch leaked into a path it shouldn't touch.
P1, P3, and P5 are strict-where-upstream-is-silent. They cannot match upstream because upstream was producing corrupt or malformed output there — that's the point of the patch.
The suite at gemma4/tests/test_custom_chat_template.py
covers 20 cases: a byte-identity group (kwargs off, asserts the fork equals
upstream) and a strict-contract group (P1 null, P3 raise, P5 merge).
python3 -m pytest tests/test_custom_chat_template.py -v
Run from the gemma4/ directory. Needs jinja2 and pytest.
Full testing notes are in TESTING.md: the symptom, root cause, and fix for each patch; the offline conformance suite and its byte-identity invariant; the live A/B procedure; and the maintenance contract for upstream revision bumps.
-
Offline conformance suite — 20 Jinja tests at
gemma4/tests/test_custom_chat_template.py. No model, no GPU. Run withuv:uv run --with jinja2 --with pytest pytest tests/test_custom_chat_template.py -v -
Live probe — live_agentic_probe.py drives the fork through any OpenAI-compatible endpoint and prints PASS/FAIL per scenario (thinking-on default, preserve_thinking across a tool loop, optional/null argument, two-tool parallel call).
--endpointdefaults to$ENDPOINTorhttp://localhost:8000:uv run --with openai python live_agentic_probe.py --endpoint http://localhost:8000
The test count is reproducible. The probe's behavioral results are not a published benchmark; this fork was never deployed live. Run the A/B in TESTING.md against your own endpoints to get your own numbers.