fix(chat_templates): check find() return value before slicing on placeholders#5763
Conversation
…eholders
Two places in `construct_chat_template()` use `str.find()` for sentinel
placeholders (`{INPUT}` / `{OUTPUT}`) without checking the -1 return:
1. The `except:` fallback (around line 2464) computes
`chat_template[chat_template.find("{OUTPUT}") + len("{OUTPUT}"):]`.
If the template has no `{OUTPUT}` marker, `find()` returns -1 and the
slice starts at offset 7 (`-1 + len("{OUTPUT}")`), producing garbage
that's then `re.escape`-d and fed back into the template-recovery
regex. The user sees a confusing `IndexError` on
`response_part = response_part[0]` instead of the real problem.
2. The final trim before returning (`input_part[:input_part.find("{INPUT}")]`
and the matching `{OUTPUT}` line) silently drops the last character
when the placeholder is missing — `find()` returns -1, and `[:-1]`
slices everything except the last character, returning a corrupted
template prefix to the caller.
Replace both with an explicit `-1` check that raises a clear
`RuntimeError` naming the missing placeholder, matching the existing
guard pattern from unslothai#4923 (`try_fix_tokenizer`).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces validation for required placeholders in chat templates to prevent runtime errors during string processing. Specifically, it adds checks for the '{OUTPUT}' placeholder in construct_chat_template and both '{INPUT}' and '{OUTPUT}' in the process function, raising a RuntimeError if they are missing. The reviewer suggests expanding the check in construct_chat_template to include the '{INPUT}' placeholder to prevent a potential IndexError later in the logic. Additionally, the feedback recommends improving the error messages by including the actual template content and using user-friendly descriptions instead of internal variable names.
| output_pos = chat_template.find("{OUTPUT}") | ||
| if output_pos == -1: | ||
| raise RuntimeError( | ||
| "Unsloth: chat_template must contain a '{OUTPUT}' placeholder." | ||
| ) | ||
| ending = chat_template[output_pos + len("{OUTPUT}"):] |
There was a problem hiding this comment.
While checking for {OUTPUT} prevents garbage slicing, the subsequent regex logic at line 2472 still relies on the presence of the {INPUT} placeholder. If {INPUT} is missing, re.findall will return an empty list, leading to an IndexError at line 2474. It is better to check for both placeholders here to provide a clear error message and avoid the confusing IndexError mentioned in the PR description. The error message has been updated to include the template value as per repository rules for dynamic user-facing messages.
| output_pos = chat_template.find("{OUTPUT}") | |
| if output_pos == -1: | |
| raise RuntimeError( | |
| "Unsloth: chat_template must contain a '{OUTPUT}' placeholder." | |
| ) | |
| ending = chat_template[output_pos + len("{OUTPUT}"):] | |
| output_idx = chat_template.find("{OUTPUT}") | |
| input_idx = chat_template.find("{INPUT}") | |
| if output_idx == -1 or input_idx == -1: | |
| raise RuntimeError( | |
| f"Unsloth: chat_template must contain both '{{INPUT}}' and '{{OUTPUT}}' placeholders. " | |
| f"Template: {chat_template}" | |
| ) | |
| ending = chat_template[output_idx + len("{OUTPUT}"):] |
References
- User-facing warning messages should be dynamically generated to include the specific configuration values they refer to, rather than using hardcoded examples, to ensure accuracy and avoid confusion.
| if input_idx == -1: | ||
| raise RuntimeError( | ||
| "Unsloth: input_part must contain '{INPUT}' placeholder." | ||
| ) | ||
| if output_idx == -1: | ||
| raise RuntimeError( | ||
| "Unsloth: output_part must contain '{OUTPUT}' placeholder." | ||
| ) |
There was a problem hiding this comment.
The error messages here use internal variable names (input_part, output_part) which might be confusing for users. Additionally, they are slightly inconsistent with the error message format used earlier in the function. Consider using more descriptive language that refers to the template sections and include the actual content of the sections to help with debugging, as per repository guidelines for dynamic user-facing messages.
| if input_idx == -1: | |
| raise RuntimeError( | |
| "Unsloth: input_part must contain '{INPUT}' placeholder." | |
| ) | |
| if output_idx == -1: | |
| raise RuntimeError( | |
| "Unsloth: output_part must contain '{OUTPUT}' placeholder." | |
| ) | |
| if input_idx == -1: | |
| raise RuntimeError( | |
| f"Unsloth: The instruction section of the template must contain the '{{INPUT}}' placeholder. " | |
| f"Section: {input_part}" | |
| ) | |
| if output_idx == -1: | |
| raise RuntimeError( | |
| f"Unsloth: The response section of the template must contain the '{{OUTPUT}}' placeholder. " | |
| f"Section: {output_part}" | |
| ) |
References
- User-facing warning messages should be dynamically generated to include the specific configuration values they refer to, rather than using hardcoded examples, to ensure accuracy and avoid confusion.
…paths
Builds on the {OUTPUT} / final-trim guards in this branch by closing
the three remaining ways the except-block fallback in
construct_chat_template() can still raise a confusing IndexError or
AttributeError on malformed templates:
1. Validate both {INPUT} and {OUTPUT} before deriving `ending`. The
regex two lines later (`{INPUT} + ending + ...`) still produced an
empty list and crashed on `response_part[0]` if {INPUT} was missing.
2. Guard the regex no-match case. Some templates contain both
placeholders but not in a recoverable two-example shape, in which
case `re.findall` returns an empty list and `[0]` raises.
3. Initialize `found = None` before the separator-search loop and
raise if the loop never sets it. Previously, if the first
iteration's `re.finditer` was empty the loop broke without binding
`found`, and `found.group(1)` raised AttributeError on the stale
int left over from the outer rfind loop.
Rephrase the final-trim error messages from internal variable names
("input_part") to user-facing wording ("instruction section") and
include a bounded (200-char) excerpt of the offending content so the
error is debuggable without being unbounded.
Add tests/python/test_construct_chat_template_validation.py covering
each failure mode with a fake tokenizer (no HF_TOKEN, no model
download, CPU-only).
|
Reviewed and pushed a follow-up commit (93bc533) extending the guards. What it adds
Out of scope, deliberately not in this PR
|
for more information, see https://pre-commit.ci
|
Thanks for the PR! |
Five actionable findings from round 29 reviewer aggregate, plus an origin/main merge that absorbs the chat_templates.py fix landed in PR #5763. Skipped #4 / #5 (studio.txt + constraints.txt hub bump) because CI evidence from round 26 contradicts that suggestion; the real broken combo only happens via the --no-deps no-torch path which is already bumped in no-torch-runtime.txt + pyproject.toml. 1. core/inference/diffusion.py: round 28 reordered _release_chat_backend_for_diffusion BEFORE _release_other_gpu_owners_for_diffusion to surface the helper / advisor busy check early, but that meant the chat unload inside _release_chat_backend_for_diffusion now fired before the training / export conflict check in the second helper. A direct backend caller (tests, scripts) or a route-precheck race with a newly-started training run would then unload the user's chat and then 409 with nothing loaded. Split the helper busy check into _raise_if_helper_advisor_busy_for_diffusion (cheap, no side effects), keep _release_chat_backend_for_diffusion as the actual chat unload with an opt-out flag, and reorder load_model to: (a) helper check, (b) training / export check + idle export shutdown, (c) chat unload. All raises now fire BEFORE any destructive unload. 2. Merge origin/main: absorbs af6504f (PR #5763 chat_templates.py find() guards + the new tests/python/test_construct_chat_template_validation.py regression test). Removes the 101-line stale-rebase silent revert that round 29 reviewer 5 and 8 flagged. 3. frontend/src/features/images/images-page.tsx: supportsNegativePrompt now also honours customFamily when no model is loaded yet, so a Custom HF repo with family flux.2 / flux.2-klein correctly hides the negative prompt field instead of silently sending it. 4. routes/inference.py /images/generate: report the ACTUAL PNG width / height from PIL Image.size instead of echoing back the requested payload values. FLUX-family pipelines round to vae_scale_factor * 2, so a request for 520x520 lands as 512x512 internally; metadata now matches the bytes on the wire. Tests: 98 targeted (diffusion + cached_gguf + inference_validation) and frontend npm run typecheck pass locally.
…eholders (unslothai#5763) * fix(chat_templates): check find() return value before slicing on placeholders Two places in `construct_chat_template()` use `str.find()` for sentinel placeholders (`{INPUT}` / `{OUTPUT}`) without checking the -1 return: 1. The `except:` fallback (around line 2464) computes `chat_template[chat_template.find("{OUTPUT}") + len("{OUTPUT}"):]`. If the template has no `{OUTPUT}` marker, `find()` returns -1 and the slice starts at offset 7 (`-1 + len("{OUTPUT}")`), producing garbage that's then `re.escape`-d and fed back into the template-recovery regex. The user sees a confusing `IndexError` on `response_part = response_part[0]` instead of the real problem. 2. The final trim before returning (`input_part[:input_part.find("{INPUT}")]` and the matching `{OUTPUT}` line) silently drops the last character when the placeholder is missing — `find()` returns -1, and `[:-1]` slices everything except the last character, returning a corrupted template prefix to the caller. Replace both with an explicit `-1` check that raises a clear `RuntimeError` naming the missing placeholder, matching the existing guard pattern from unslothai#4923 (`try_fix_tokenizer`). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(chat_templates): also guard {INPUT} and fallback regex/separator paths Builds on the {OUTPUT} / final-trim guards in this branch by closing the three remaining ways the except-block fallback in construct_chat_template() can still raise a confusing IndexError or AttributeError on malformed templates: 1. Validate both {INPUT} and {OUTPUT} before deriving `ending`. The regex two lines later (`{INPUT} + ending + ...`) still produced an empty list and crashed on `response_part[0]` if {INPUT} was missing. 2. Guard the regex no-match case. Some templates contain both placeholders but not in a recoverable two-example shape, in which case `re.findall` returns an empty list and `[0]` raises. 3. Initialize `found = None` before the separator-search loop and raise if the loop never sets it. Previously, if the first iteration's `re.finditer` was empty the loop broke without binding `found`, and `found.group(1)` raised AttributeError on the stale int left over from the outer rfind loop. Rephrase the final-trim error messages from internal variable names ("input_part") to user-facing wording ("instruction section") and include a bounded (200-char) excerpt of the offending content so the error is debuggable without being unbounded. Add tests/python/test_construct_chat_template_validation.py covering each failure mode with a fake tokenizer (no HF_TOKEN, no model download, CPU-only). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Summary
Two places in
construct_chat_template()usestr.find()for sentinel placeholders ({INPUT}/{OUTPUT}) without checking the-1return. Same shape as thetry_fix_tokenizerfix that landed in #4923.Bug 1 — except-block fallback (around line 2464)
If
chat_templatehas no{OUTPUT}marker,find()returns-1, the slice starts at offset7(-1 + len("{OUTPUT}")), andendingbecomes garbage from the start of the template. That garbage is thenre.escape-d and fed back into the template-recovery regex on the next line, which finds nothing — so the user sees a confusingIndexErroronresponse_part = response_part[0]instead of the actual problem (missing placeholder).Bug 2 — final trim before return (around line 2610-2611)
If either placeholder is missing,
find()returns-1, ands[:-1]silently drops the last character. The function returns a corrupted template prefix to the caller, who then trains on subtly wrong tokens.Fix
Split each
find()from its arithmetic / slice, then raise a clearRuntimeErrornaming the missing placeholder when the result is-1. Mirrors the guard pattern from #4923.Test plan
python3 -c "import ast; ast.parse(open('unsloth/chat_templates.py').read())"— parses cleanlytest_construct_chat_template()should still pass (it uses a well-formed template with both placeholders){OUTPUT}now raisesRuntimeError("...must contain '{OUTPUT}' placeholder.")instead ofIndexError: list index out of range🤖 Generated with Claude Code