fix(hf): parse <think> tags into mot.thinking on LocalHFBackend - #1616
fix(hf): parse <think> tags into mot.thinking on LocalHFBackend#1616planetf1 wants to merge 10 commits into
Conversation
LocalHFBackend never populated ModelOutputThunk.thinking; raw HF completions kept Granite's <think>...</think> block inline in mot.value, unlike every other backend which gets reasoning pre-separated by its SDK/provider response. Adds _split_think_tags(), gated on the chat template actually exposing a thinking variable (avoids corrupting answers that merely mention "</think>") and skipped for streaming generations (splitting would shrink mot.value after ModelOutputThunk.astream() has already captured a delta offset against the longer raw string, corrupting the final streamed delta). Fixes generative-computing#1610. Assisted-by: Claude Code Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
jakelorocco
left a comment
There was a problem hiding this comment.
I think I did not realize how many aspects of the hf backend this would impact when I created the issue. I think there's actually a fair bit of design work that might be required to address these concerns.
| _THINK_OPEN_TAG: str = "<think>" | ||
| _THINK_CLOSE_TAG: str = "</think>" |
There was a problem hiding this comment.
I think we may need to actually look at the tokens for this to work properly? The model may produce </think> in text form, which we wouldn't want to parse as the end of a thinking section.
There was a problem hiding this comment.
Looked into token-level checks — won't work here: Granite's </think> token is itself non-special, so it's indistinguishable from literal text even at the token level. transformers' own parser has the same limit. Documented the splitter as an explicit fallback and gated it on the resolved thinking value, which cuts the practical risk.
There was a problem hiding this comment.
I did test this and I do see that <think> and </think> are specific tokens. When I asked the model what it's thinking token was, it output <, think, > or something like that. So there might be more to this.
There was a problem hiding this comment.
The string check is the safe superset here: it fires on every case a token check would, plus any model whose delimiter isn't a single vocab token — a token check would regress those, missing real splits entirely. Reason enough to keep it as the default mechanism on its own.
Separately, verified against ibm-granite/granite-4.2-3b's tokenizer (one model, four test strings): encoding </think> — standalone, mid-sentence, glued to other text — always yields the same single vocab token, id 100275, non-special. Generation doesn't re-encode a target string though — it samples token-by-token — so the model could in principle emit <, /think, > as separate steps decoding to the same string, which a check against the raw generated ids (hf_output.sequences[0], which includes prompt tokens and would need slicing) could tell apart from the atomic-token case, unlike a string match on decoded text.
My expectation is this doesn't help the case we actually care about — a model naturally writing the tag inline is more likely to hit the same atomic token as a genuine close, since training data containing that literal substring would itself have been encoded into the atomic token. Haven't run the sampling experiment to confirm that — it's a hypothesis, not something verified like the tokenizer facts above.
The superset argument doesn't depend on that hypothesis either way — suggest keeping the string-fallback as primary. Happy to add a token check as a supplementary non-streaming-only check if you want it, but it's real complexity (guarding models without a single-token delimiter, slicing prompt tokens, a second code path, more tests) for a benefit that's currently a guess, not a fact.
| # Gate on the template exposing a thinking var (not ModelOption.THINKING) since some | ||
| # models think by default; otherwise an answer that just mentions "</think>" gets split. | ||
| # Skip for streaming: astream() assumes mot.value only grows, and shrinking it here | ||
| # would corrupt the final delta (see #1604 for proper incremental splitting later). | ||
| thinking_allowlist: frozenset[str] = getattr( | ||
| self, "_chat_template_allowlist", frozenset() | ||
| ) | ||
| if not mot.generation.streaming and thinking_allowlist.intersection( | ||
| _CHAT_TEMPLATE_THINKING_VARS | ||
| ): |
There was a problem hiding this comment.
Wouldn't this still trigger if one of the thinking vars is set to false?
There was a problem hiding this comment.
Fixed — gate now checks the actual ModelOption.THINKING value for the call, not just whether the template mentions the variable. Tests cover both explicit False and unset.
Issue generative-computing#1610 asked LocalHFBackend to split Granite's <think> block into mot.thinking; PR generative-computing#1616 implemented it, and review surfaced cache-key timing, to_chat round-tripping, gating truthiness, and token-boundary questions the original issue didn't anticipate. Writes up a full RFC at docs/dev/proposals/1604-hf-output-parsing.md resolving those four findings plus several more (streaming, intrinsic input changes, replay policy layering), verified directly against the code and Granite's own chat template, with open questions for further review. Assisted-by: Claude Code Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
…eplay Addresses review feedback on PR generative-computing#1616 (see docs/dev/proposals/1604-hf-output-parsing.md): - Reorder the KV-cache key computation to run after the <think> split, not before, so a future cache_get() call can actually find the entry by the same key mot.value holds afterward. - Gate the split on the resolved per-call ModelOption.THINKING value, not just on the chat template declaring a thinking variable — a template declaring the variable is not proof thinking was requested on this call. - Attach reasoning captured in mot.thinking to the assistant wire message in to_chat() as reasoning_content (Granite/Qwen3 templates already consume this key). This is an interim, unconditional forward, not yet gated by should_replay_reasoning() like other backends — needed now because the capture fix alone silently dropped reasoning on multi-turn HF Granite replay, since Granite's template re-inlines an empty <think></think> onto any assistant turn missing reasoning_content. - Include captured reasoning in GenerateLog, matching other backends. - Fix a fabricated citation in a test docstring and move a qualitative marker from module to function level per test/README.md. Assisted-by: Claude Code Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Closes the last outstanding item from qwen's review of the design proposal and PR generative-computing#1616: document and test the conflict policy when a Message.provider_fields entry also targets reasoning_content for the huggingface provider. to_chat() already set reasoning_content before merge_provider_fields() runs (same known-fields-first ordering as tool_calls/tool_call_id), so Mellea's own value silently wins; this pins that behavior with a test and records it as D4's conflict policy in the design proposal. Also fixes stale line-number citations and status markers in the proposal (Message._parse range, §15/§16 shipped-item tracking, header banner) to reflect the code fixes committed in c0394e0. Assisted-by: Claude Code Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
…coverage
Round 2 review found that the reasoning_content forward in to_chat()
only restores prior parity for tool-call turns, not plain multi-turn:
Granite's chat template strips reasoning whenever the serialized
content carries both <think> and </think>, which the reconstructed
form always does but the pre-fix inline form (closing tag only) never
did. Corrects the utils.py comment and PR description to state this
precisely instead of claiming full parity, and adds two tests against
the real granite-4.2-3b template pinning both shapes.
Also:
- Gate the reasoning_content forward on role == "assistant".
- Add a seam test proving Message._parse() -> to_chat() actually
carries .thinking through for HF in both the tool-call and plain
branches, not just by inspection.
- Tighten an e2e assertion ("4" in output.value could match "40"/"14").
- Correct the _split_think_tags docstring to name the generation-prompt
path (not the replay path) as the source of its whitespace framing.
Removes the design-proposal doc used to plan this work — it was
workflow scaffolding, not a deliverable. Its substance (behaviour
change, open questions) now lives in the PR description.
Assisted-by: Claude Code
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Per test/README.md, backend markers (huggingface, ollama, etc.) apply only to e2e/qualitative tests, not integration. The two rendered-prompt tests added in the previous commit are integration-tier (tokenizer-only, no GPU) and incorrectly carried @pytest.mark.huggingface alongside @pytest.mark.integration — drop the backend marker. Also removes a redundant pytest.mark.skipif(CICD==1) from the e2e file's module-level pytestmark: both tests already carry @pytest.mark.qualitative individually, which conftest's central CICD-skip logic already covers. The skipif duplicated that policy and its only purpose was to justify the now-removed `import os`. A third finding (empty-string mot.thinking forwarded downstream) was checked and refuted: changing the post_processing() guard to a truthy check would break splitting for Granite's thinking-disabled <think></think> shape, leaving the empty tag pair in the visible answer. Confirmed empirically; not applied. Assisted-by: Claude Code Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
…swer Covers the exact risk jakelorocco raised on :278: thinking genuinely on, gate correctly fires, and the model's answer itself mentions the literal text "</think>" (e.g. explaining what the tag does). First-occurrence splitting keeps the full answer intact instead of truncating it at the second, unrelated occurrence. Assisted-by: Claude Code Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Comments and docstrings had accumulated references to PR numbers and a since-removed design doc. Tighten to state the behaviour and rationale directly instead of pointing elsewhere. Assisted-by: Claude Code Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
jakelorocco
left a comment
There was a problem hiding this comment.
I think for this PR; it might be good enough to just always attempt to split on </think>? I just worry that if someone asks the model to output that token in an answer, it will be problematic.
| resolved_thinking = ( | ||
| mot._call.model_options.get(ModelOption.THINKING) | ||
| if mot._call.model_options | ||
| else None | ||
| ) | ||
| if ( | ||
| not mot.generation.streaming | ||
| and resolved_thinking is not False | ||
| and thinking_allowlist.intersection(_CHAT_TEMPLATE_THINKING_VARS) | ||
| ): |
There was a problem hiding this comment.
I may have been wrong about my earlier comment; since some models default to thinking on, we can't go off of model options alone here (and their truthy value).
Maybe we should just always split on unless it's structured output? And then we can eventually do more sophisticated stuff like hf does with looking at the chat template, etc...
There was a problem hiding this comment.
reverting to draft. will revisit next week
There was a problem hiding this comment.
Message.thinking is forwarded as reasoning_content for every assistant turn that has one (mellea/backends/utils.py) — HF has no should_replay_reasoning() gate the way litellm/watsonx/openai/ollama do. What decides whether it survives is Granite's own template: a turn keeps reasoning if its index is at or after the most recent user message, and loses it otherwise — turn-recency, not turn-type. (truncate_history_thinking defaults True and mellea never overrides it, so this always applies today.) A tool-call turn usually has no user message before its continuation, so it tends to survive; a plain turn from an earlier exchange tends not to — a side effect of recency, not a rule Mellea enforces.
So a false split — real answer text landing in mot.thinking — gets attached as reasoning_content and is silently dropped from every subsequent prompt once a newer user turn exists. Not destroyed everywhere (it survives in mot.thinking/the ChatContext for that turn, and Guardian's safety check reads mot.thinking directly — guardian.py:407), but once conversation moves on, it's gone from what the model sees, with no trace anything was dropped.
A missed split — raw tags staying in mot.value — replays through ordinary content instead. Nothing re-parses it; it just gets read and judged as part of the answer. So "recoverable" overstates it too — the real difference is visibility: a missed split stays visible in the output, a false split disappears silently once history moves past it.
Suggest keeping the current gate on that basis — an invisible failure is worse than a visible one, even if neither is strictly safe. This reasoning is Granite-template-specific though, and the gate is generic across LocalHFBackend, so a different model's template semantics could shift the calculus. Documented both failure modes at the gate (huggingface.py:1871-1894), with a pointer to #1604 for always-thinking models without a declared var.
…eoff _split_think_tags()'s docstring claimed token-level detection "can't disambiguate a genuine end-of-reasoning token from literal </think> text either" — overstated. Verified against the real granite-4.2-3b tokenizer: encoding the literal string always yields the same single token id, but that only shows what the tokenizer's own encoder would choose, not what the model can generate — it may emit the tag as separate token pieces that decode to the same string. A check against the raw generated sequence would therefore be strictly more conservative than the string match (though not fully precise, and blocked today by streaming only exposing decoded text via TextIteratorStreamer). Also documents the under-split/over-split tradeoff behind the post_processing() gate, with the specific downstream mechanics (reasoning_content replay, Granite's own history truncation) that make over-splitting the worse failure mode. Assisted-by: Claude Code Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Both comments claimed Granite's chat template strips reasoning_content on "any non-tool-call/plain turn" — checked against the actual chat_template.jinja and that's wrong. The template gates on turn recency (loop.index0 >= last_user_idx), identically in both the tool-call and plain-turn branches; there is no turn-type check. Tool-call turns just usually happen to have no intervening user message, so they usually land on the "keep" side of the recency gate. Also notes HF has no should_replay_reasoning() gate unlike the other backends, and that truncate_history_thinking defaults True and is never overridden by mellea today. Assisted-by: Claude Code Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Documents ModelOption.THINKING string levels and result.thinking as supported on LocalHFBackend too, matching the state after #1639 (HF string forwarding fix) and #1616 (HF <think> tag parsing) merge. Both are still open — this docs PR should merge after them, or the LocalHFBackend row will describe behaviour that isn't live yet. Assisted-by: Claude Code Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
- Qualify the OpenAIBackend/LiteLLM `False` cell: real OpenAI reasoning models and non-Ollama LiteLLM targets never receive `reasoning_effort= "none"` (openai.py's server-type guard, litellm.py's ollama-prefix guard), so `False` does not actually disable thinking there. - Add an inline callout marking the LocalHFBackend string-forwarding and result.thinking claims as contingent on #1639/#1616 merging, instead of only noting it in the PR description/comment. - Correct "same mechanism as the OpenAI backend" — HF forwards through a gated chat-template variable, OpenAI sends an ungated top-level param. - Note the runtime-forwarding dependency for the cross-backend Granite claim, add a `> Full example:` link, fix the unresolvable bare "#1617" reference, add a non-determinism note to the new code block, drop the unnecessary `qualitative` marker, and fix US-English spelling in new content per CONTRIBUTING_DOCS.md. - Add cheap assertions to docs/examples/thinking_mode.py pinning the documented per-arm behaviour; reran live against granite4.2:3b, all pass. Assisted-by: Claude Code Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Fixes #1610.
What changed
LocalHFBackendnever populatedModelOutputThunk.thinking. Other backends split reasoning from the answer at the SDK level; HF decoded one flat string, leaving Granite's<think>...</think>block unparsed insidemot.value.Adds
_split_think_tags()inpost_processing():</think>tag (the opening tag lives in the prompt, never in the model's output).ModelOption.THINKINGfor this call isn't explicitlyFalse.m serve) — incremental splitting is follow-up work (feat: implement better hugging face output parsing #1604).transformers'parse_response(); no tokenizer today declares aresponse_schema.Behaviour change: reasoning replay on multi-turn conversations
Splitting reasoning out of
mot.valuebreaks history replay: Granite's template used to see reasoning inline on every turn by accident (no opening tag, so its truncation gate never fired). Oncemot.valueis clean, the template drops it.to_chat()now forwards captured reasoning asreasoning_content— the key Granite/Qwen3 templates already consume. Measured against the real template:truncate_history_thinkingstrips it)Tool-call turns match the cross-backend convention (#1201: replay on tool-call turns only). Plain turns are left as-is by design — matching that convention, not diverging from it just because Granite's template allows it. Pinned by
test_rendered_prompt_preserves_reasoning_on_tool_call_turn/test_rendered_prompt_drops_reasoning_on_plain_multi_turn.Other fixes
GenerateLog.extraincludes captured reasoning.reasoning_contentforward is assistant-only; aprovider_fieldscollision on that key resolves in Mellea's favour (tested).Design decisions
response_schema— no tokenizer offers one; nothing to do until one does.</think>— kept first, deliberately opposite to Granite's own template. The template is discarding old reasoning, where dropping too much is safe; we're extracting a clean answer, where an answer mentioning</think>must stay intact.Test plan
test_huggingface_thinking.py— splitter units,post_processing()gating/streaming/cache-key tests, rendered-prompt tests against the real template.test_huggingface_thinking_e2e.py— realgranite-4.2-3bgeneration, GPU-gated, skipped in CI. Run locally on Apple Silicon MPS: both pass,mot.thinkingholds a genuine trace,mot.valuea clean answer.test_utils.py—to_chat()reasoning tests (attach, omit, role guard, collision).pytest,ruff,mypyclean.