Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 113 additions & 5 deletions mellea/backends/huggingface.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,51 @@ def _cleanup_kv_cache(cache_info: HFAloraCacheInfo) -> None:

_CHAT_TEMPLATE_THINKING_VARS: tuple[str, ...] = ("think", "thinking", "enable_thinking")

_THINK_OPEN_TAG: str = "<think>"
_THINK_CLOSE_TAG: str = "</think>"
Comment on lines +285 to +286

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.



def _split_think_tags(text: str) -> tuple[str | None, str]:
r"""Split raw HF output into (thinking, answer) on the closing </think> tag.

A string-level fallback for `transformers.PreTrainedTokenizerBase.parse_response()`
(schema-driven, token-decode-then-parse), used because no tokenizer available to
this backend today declares a `response_schema`. Even that upstream mechanism is
text-level, not token-level.

A token-position check against the raw generated sequence (scanning for
Granite's `</think>` token id rather than re-scanning decoded text) would be
strictly more conservative than this string match — it would not misfire on
a mention the model spells out as separate pieces rather than emitting the
single vocab token. It would not fully eliminate false positives either: a
model is free to emit that same single token for a literal in-prose mention,
so a genuine close and a canonical-token literal mention still decode
identically. Not adopted here because HF's streaming path
(`TextIteratorStreamer`) only exposes decoded text, not token ids, so a
token check could not apply uniformly to both the streaming and
non-streaming paths — see #1604 for a scoped follow-up.

Splits on </think> alone (not a <think>...</think> pair) because some chat
templates (e.g. granite-4.2 with enable_thinking) bake the opening tag into
the prompt, so it never appears in the model's own output. A fixed pattern
match for Granite's convention, not a general reasoning-parser; other
delimiters pass through unchanged. See #1604 for generalizing this.

Deliberately strips leading/trailing whitespace from both `thinking` and
`answer`: the whitespace immediately around the tags is delimiter framing,
not meaningful content, so the user-visible completion loses that framing
whitespace as part of this split, not incidentally. This framing comes
from the *generation-prompt* path (chat_template.jinja:178-182, e.g.
`<|im_start|>assistant\n<think>\n`), not the replay-reconstruction path
(chat_template.jinja:83-84) — this function only ever sees freshly
generated text, never a replayed history turn.
"""
if _THINK_CLOSE_TAG not in text:
return None, text
reasoning, _, answer = text.partition(_THINK_CLOSE_TAG)
return (reasoning.strip().removeprefix(_THINK_OPEN_TAG).strip(), answer.strip())


# A string THINKING level (e.g. "low") is forwarded verbatim as `reasoning_effort`
# when the chat template declares that variable — this is the actual mechanism
# Granite 4.2's chat template consumes (chat_template.jinja derives its boolean
Expand Down Expand Up @@ -1989,6 +2034,10 @@ class used during generation, if any.
if isinstance(hf_output, GenerateDecoderOnlyOutput) and mot._call.model_options:
self._surface_logits(mot, hf_output)

# Built here (before the split below) because it needs `hf_output`'s KV cache/scores
# fields, which are cleared immediately after; cached under a key derived from
# `mot.value` further down, once the split has settled on the final string object.
cache_info: HFAloraCacheInfo | None = None
if (
self._use_caches
and isinstance(hf_output, GenerateDecoderOnlyOutput)
Expand All @@ -2009,9 +2058,6 @@ class used during generation, if any.
scores=hf_output.scores,
)

cache_key = id(mot.value)
self.cache_put(cache_key, cache_info)

# Clear KV cache and scores from HF output; retained via LRU cache above.
# `ModelOutput` (`OrderedDict` subclass) does not sync `None` writes back
# to the mapping, so plain attribute assignment leaves the dict entry — and
Expand All @@ -2027,6 +2073,67 @@ class used during generation, if any.
OrderedDict.__delitem__(hf_output, "logits")
hf_output.logits = None

# Capture the raw text before any split below. Used for the stop-string check
# further down (a stop string could itself be think-tag-adjacent, so that check
# must stay on the pre-split text) and as the input to the split itself. Do not
# repoint this at `mot.value` after the split runs.
raw_value = mot.value

# Gate on the template exposing a thinking var (some models think by default, so
# declaring the var isn't itself proof thinking is on) AND the resolved per-call
# value not being explicitly False (an answer that merely mentions "</think>" on a
# template with thinking off must not be split). `None`/unset must still allow the
# split: Granite and Qwen3 both default `enable_thinking` to True in their own
# template source, so treating "unset" as "off" would under-split for the common case.
# Read from `mot._call.model_options` directly (not a value already filtered for the
# template) — it's the same dict `_filter_for_chat_template` resolves
# `ModelOption.THINKING` from when building the generation-time template kwargs, so
# this mirrors what the model was actually asked to do on this call.
# 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).
#
# Known limitation, deliberately not addressed here: this gate can under-split
# for a model that ignores an explicit ModelOption.THINKING=False, or that always
# emits <think> blocks without declaring any of _CHAT_TEMPLATE_THINKING_VARS in its
# template. The resulting raw tags leak into mot.value and get replayed as ordinary
# `content` on the next turn — visible in the answer, and still read by requirement
# checks/judges, but not silently dropped anywhere. The alternative — splitting
# unconditionally — trades this for a worse failure: an over-split misfiles real
# answer text into mot.thinking, which gets attached as `reasoning_content` at
# replay time and is silently dropped from every subsequent prompt once a newer
# user turn exists, per Granite's own recency-based truncation (see the NOTE in
# mellea/backends/utils.py). Under-split stays diagnosable; over-split is invisible
# once replayed. Kept on the more conservative side deliberately; see #1604 for
# detecting always-thinking models without a declared var.
thinking_allowlist: frozenset[str] = getattr(
self, "_chat_template_allowlist", frozenset()
)
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)
):
Comment on lines +2111 to +2120

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.

thinking, answer = _split_think_tags(raw_value)
if thinking is not None:
mot.thinking = thinking
Comment thread
jakelorocco marked this conversation as resolved.
mot.value = answer
MelleaLogger.get_logger().debug(
"Split %d chars of thinking out of HF completion for %s.",
len(thinking),
self._model_id,
)

if cache_info is not None:
# Keyed after the split above has settled on the final `mot.value` object, so a
# later lookup by the same key against the same (now-split) thunk can find it.
cache_key = id(mot.value)
self.cache_put(cache_key, cache_info)

# Only scan for tools if we are not doing structured output and tool calls were provided to the model.
if _format is None and tool_calls:
mot.tool_calls = to_tool_calls(tools, mot.value)
Expand Down Expand Up @@ -2077,8 +2184,8 @@ class used during generation, if any.
stop_strings = (
mot._call.model_options.get(ModelOption.STOP_SEQUENCES) or []
)
ends_with_stop_string = isinstance(mot.value, str) and any(
mot.value.endswith(s) for s in stop_strings
ends_with_stop_string = isinstance(raw_value, str) and any(
raw_value.endswith(s) for s in stop_strings
)
if last_token in eos_set or ends_with_stop_string:
mot.generation.finish_reasons = ["stop"]
Expand Down Expand Up @@ -2134,6 +2241,7 @@ class used during generation, if any.
"tools_available": tools,
"tools_called": mot.tool_calls,
"seed": seed,
"thinking": mot.thinking,
}
generate_log.action = mot._call.action
generate_log.result = mot
Expand Down
17 changes: 13 additions & 4 deletions mellea/backends/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,17 @@ def to_chat(

# NOTE: `self.formatter.to_chat_messages` explicitly skips `Message` objects. However, we need
# to print `Message`s to correctly serialize any documents with the message. Do the printing here.
# NOTE: reasoning is never replayed on the HF chat path — we serialize only `content` and never
# consult `should_replay_reasoning` (unlike the OpenAI/LiteLLM/Watsonx/Ollama chat paths). This is
# acceptable today because HF has a capture gap (per #1201) and never populates `Message.thinking`
# to begin with; when that gap is closed, replay must be wired in here.
# NOTE: `Message.thinking` is forwarded as `reasoning_content` (the key Granite/Qwen3
# templates consume) for every assistant turn that has it. Granite's own chat template
# (not a turn-type check) then decides whether to keep or strip it: reasoning survives
# only for a turn at or after the most recent user message (`last_user_idx` /
# `truncate_history_thinking`, defaulted True and never overridden by mellea), and is
# stripped for anything from an earlier exchange, tool-call or not. In mellea's typical
# flow a tool-call turn has no intervening user message before its continuation, so it
# tends to survive, and a plain turn from a prior exchange tends not to — but that's a
# consequence of the recency rule, not a tool-call/plain-turn distinction Mellea enforces
# (unlike #1201's cross-backend `should_replay_reasoning`, which HF does not call). See
# test_rendered_prompt_*_turn in test_huggingface_thinking.py.
ctx_as_conversation: list = []
for m in ctx_as_message_list:
msg_dict: dict = {"role": m.role, "content": formatter.print(m)}
Expand All @@ -113,6 +120,8 @@ def to_chat(
msg_dict["tool_calls"] = m.tool_calls
if m.tool_call_id:
msg_dict["tool_call_id"] = m.tool_call_id
if m.role == "assistant" and m.thinking:
msg_dict["reasoning_content"] = m.thinking
# Merge any author-declared provider fields (Mellea's known fields win;
# a mismatched target raises). Must run after the known fields are set.
msg_dict = merge_provider_fields(msg_dict, m.provider_fields, "huggingface")
Expand Down
Loading
Loading