Skip to content

Render a static prefix when default_system_message is None - #8117

Merged
wasimysaid merged 3 commits into
unslothai:mainfrom
vineethsaivs:fix/static-preamble-dropped-without-default-system
Aug 8, 2026
Merged

wasimysaid merged 3 commits into
unslothai:mainfrom
vineethsaivs:fix/static-preamble-dropped-without-default-system

Conversation

@vineethsaivs

Copy link
Copy Markdown
Contributor

Summary

A chat template can begin with a static preamble that has no {SYSTEM} placeholder, for example Unsloth's own Alpaca-style header. When default_system_message is None, that preamble never appears in any rendered conversation, while the Ollama modelfile returned by the same call still contains it.

Repro

T = ("Below are some instructions that describe some tasks.\n\n"
     "### User: {INPUT}\n### Assistant: {OUTPUT}</s>"
     "### User: {INPUT}\n### Assistant: {OUTPUT}</s>")

modelfile, jinja, _, _ = construct_chat_template(
    tokenizer=tok, chat_template=T, default_system_message=None,
    extra_eos_tokens=["</s>"])
default_system_message=None
   trained on (HF jinja) : '### User: Hi\n### Assistant: Yo</s>'
   served    (modelfile) : 'Below are some instructions that describe some tasks.\n\n{{ if .Prompt }}### User: '

default_system_message='Be helpful.'
   trained on (HF jinja) : 'Below are some instructions that describe some tasks.\n\n### User: Hi\n### Assistant: Yo</s>'
   served    (modelfile) : 'Below are some instructions that describe some tasks.\n\n{{ if .Prompt }}### User: '

The preamble has no {SYSTEM} slot, so whether the caller passed a default system message should not decide whether it is part of the prompt. Today it does.

Cause

The generated template puts the prefix inside the system arm only:

{% if messages[0]['role'] == 'system' %}{{ 'PREAMBLE\n\n' }}{% set loop_messages = messages[1:] %}{% endif %}
{% for message in messages %}...

With no {SYSTEM} slot that arm cannot be reached. A caller-supplied system message is not consumed by the system part, so it reaches the loop and hits raise_exception('Only user and assistant roles are supported!'), which test_static_prefix_without_system_still_rejects_system_message already pins. So the only conversations that render successfully are the ones that take the {% endif %} path, and those drop the prefix.

The effect is train/serve skew rather than an error: apply_chat_template builds training text from the Jinja template through dataset.map, while save.py:create_ollama_modelfile serves the modelfile, so the model is finetuned without the text it is later prompted with. Nothing warns.

Fix

Give that branch the same {% else %} arm the default_system_message is not None path already gets. With no {SYSTEM} in the system part the two arms are identical literals, so the existing "Check if system part is the same!" regex a few lines down folds them into a single unconditional emit:

{{ 'PREAMBLE\n\n' }}{% for message in messages %}...

This deliberately keeps #7199's behaviour: a caller-supplied system message still reaches raise_exception, because the collapsed template loops over messages and the template genuinely cannot render one. The change is only about what a plain user/assistant conversation renders.

Blast radius

Generated jinja_template and modelfile compared before and after across 21 combinations (7 system-part shapes, including none, {SYSTEM}-bearing, apostrophe-bearing and BOS-prefixed, times 3 default_system_message values):

  • 18 byte-identical
  • 3 changed, all of them a static prefix with default_system_message=None, which is the bug
  • every modelfile unchanged

Test

test_static_prefix_without_system_renders_in_every_conversation in tests/python/test_construct_chat_template_validation.py, parametrized over default_system_message, asserting the prefix is in the rendered conversation and in the modelfile from the same call. It uses the file's existing _NO_SYSTEM_CHAT_TEMPLATE and CPU-only _SuccessFakeTokenizer, so it needs no GPU or download.

Fails before on the None case, passes after. The whole file goes 20 passed / 1 failed to 21 passed, and tests/python/test_get_chat_template_escaping.py alongside it stays green (193 passed together). ruff check clean on both changed files.

A chat template can start with a preamble that has no {SYSTEM} placeholder.
construct_chat_template emitted it only from the
{% if messages[0]['role'] == 'system' %} arm, and with no {SYSTEM} slot that
arm is unreachable: a caller system message hits the loop's raise_exception
instead. So the preamble rendered in no successful conversation at all.

The Ollama modelfile returned by the same call still contains it, so training
text built from the Jinja template lacked the prefix the served model was
later prompted with:

    trained on : '### User: Hi\n### Assistant: Yo</s>'
    served     : 'Below are some instructions that describe some tasks.\n\n...'

Give that branch the same {% else %} arm the default_system_message path
gets. Both arms are then identical literals, so the existing 'system part is
the same' regex folds them into one unconditional emit, and a caller-supplied
system message still reaches raise_exception, which is what unslothai#7199 intended.

Only templates with a static prefix and default_system_message=None change:
across 21 combinations of system part and default message, the other 18
produce byte-identical Jinja and every modelfile is unchanged.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@wasimysaid

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c5b5ae480

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread unsloth/chat_templates.py
@wasimysaid

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: ca3aaad430

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@wasimysaid
wasimysaid merged commit 3177523 into unslothai:main Aug 8, 2026
46 of 50 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants