Skip to content

fix(hf): parse <think> tags into mot.thinking on LocalHFBackend - #1616

Open
planetf1 wants to merge 10 commits into
generative-computing:mainfrom
planetf1:issue-1610
Open

fix(hf): parse <think> tags into mot.thinking on LocalHFBackend#1616
planetf1 wants to merge 10 commits into
generative-computing:mainfrom
planetf1:issue-1610

Conversation

@planetf1

@planetf1 planetf1 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #1610.

What changed

LocalHFBackend never populated ModelOutputThunk.thinking. Other backends split reasoning from the answer at the SDK level; HF decoded one flat string, leaving Granite's <think>...</think> block unparsed inside mot.value.

Adds _split_think_tags() in post_processing():

  • Splits on the closing </think> tag (the opening tag lives in the prompt, never in the model's output).
  • Only splits when the template declares a thinking variable and ModelOption.THINKING for this call isn't explicitly False.
  • Skipped for streaming (m serve) — incremental splitting is follow-up work (feat: implement better hugging face output parsing #1604).
  • A documented fallback for transformers' parse_response(); no tokenizer today declares a response_schema.

Behaviour change: reasoning replay on multi-turn conversations

Splitting reasoning out of mot.value breaks history replay: Granite's template used to see reasoning inline on every turn by accident (no opening tag, so its truncation gate never fired). Once mot.value is clean, the template drops it.

to_chat() now forwards captured reasoning as reasoning_content — the key Granite/Qwen3 templates already consume. Measured against the real template:

Turn shape Reasoning replayed?
Assistant turn with a tool call
Plain assistant turn, followed by another user turn ❌ (template's truncate_history_thinking strips 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

  • Cache key (KV-cache LRU) computed after the split, not before — the old key was an object identity that changed a few lines later.
  • GenerateLog.extra includes captured reasoning.
  • reasoning_content forward is assistant-only; a provider_fields collision on that key resolves in Mellea's favour (tested).
  • Intrinsic/adapter functions on HF now get reasoning-free input on thinking turns — a correctness fix, but a real input change for anyone tracking intrinsic eval numbers.

Design decisions

  • Plain-turn replay — not extended, stays with Better handling of reasoning output from thinking models #1201.
  • response_schema — no tokenizer offers one; nothing to do until one does.
  • First vs. last </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 — real granite-4.2-3b generation, GPU-gated, skipped in CI. Run locally on Apple Silicon MPS: both pass, mot.thinking holds a genuine trace, mot.value a clean answer.
  • test_utils.pyto_chat() reasoning tests (attach, omit, role guard, collision).
  • pytest, ruff, mypy clean.

@github-actions github-actions Bot added the bug Something isn't working label Sep 3, 2026
@planetf1
planetf1 marked this pull request as ready for review September 3, 2026 11:08
@planetf1
planetf1 requested a review from a team as a code owner September 3, 2026 11:08
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 jakelorocco left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +277 to +278
_THINK_OPEN_TAG: str = "<think>"
_THINK_CLOSE_TAG: str = "</think>"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread mellea/backends/huggingface.py Outdated
Comment on lines +1841 to +1850
# 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
):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wouldn't this still trigger if one of the thinking vars is set to false?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread mellea/backends/huggingface.py Outdated
Comment thread mellea/backends/huggingface.py
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>
@planetf1
planetf1 marked this pull request as draft September 4, 2026 12:17
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>
@planetf1
planetf1 marked this pull request as ready for review September 4, 2026 13:38

@jakelorocco jakelorocco left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +1876 to +1885
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)
):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

reverting to draft. will revisit next week

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@planetf1
planetf1 marked this pull request as draft September 4, 2026 17:24
@planetf1 planetf1 added the area/thinking Reasoning/thinking control: enable_thinking, reasoning_effort, low_effort, THINKING mapping label Sep 9, 2026
…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>
@planetf1
planetf1 marked this pull request as ready for review September 10, 2026 10:36
planetf1 added a commit that referenced this pull request Sep 10, 2026
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>
planetf1 added a commit that referenced this pull request Sep 10, 2026
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/thinking Reasoning/thinking control: enable_thinking, reasoning_effort, low_effort, THINKING mapping bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add think tag parsing for granite models to hugging face backend

2 participants