Skip to content

Commit d9b2adf

Browse files
committed
fix(inference): handle null content and propagate reasoning from thinking models
Some providers return content=null for thinking models when the token budget is consumed by reasoning. This caused a Pydantic ValidationError in Message() because content must be str | list[ContentItem], not None. Changes: - Add optional `reasoning_content` field to Message for per-turn reasoning. Named `reasoning_content` to match Qwen3's chat template convention — templates that support this field render it natively inside <think> tags - RemoteInferenceEngine (Together, Fireworks, OpenAI, OpenRouter, Gemini, GCP/Vertex, Cerebras, DeepSeek, Parasail, RemoteVLLM): default content to empty string when null, extract reasoning from "reasoning" or "reasoning_content" response fields - AnthropicInferenceEngine: extract reasoning from "thinking" content blocks, handle interleaved thinking/text blocks correctly - BedrockInferenceEngine: same as Anthropic (thinking content blocks) - SambanovaInferenceEngine: same as RemoteInferenceEngine (OpenAI-compat) - conversation_utils: preserve reasoning_content when reconstructing Messages in remove_excessive_images and truncate_text_in_content_items - SGLang/local engines: no change (reasoning embedded in text via tags) Null handling: uses explicit `is None` checks (not truthiness) so that empty-string reasoning is preserved correctly. Multiple content blocks (Anthropic/Bedrock) are concatenated directly without separators, matching Anthropic's recommended approach. Training: models whose chat templates reference `reasoning_content` (e.g., Qwen3) will tokenize it natively. For models without template support, a fallback to prepend reasoning as <think> tags in content is needed (separate follow-up).
1 parent b0d2b6e commit d9b2adf

13 files changed

Lines changed: 329 additions & 39 deletions

src/oumi/core/processors/default_processor.py

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414

1515
import copy
1616
from collections.abc import Callable
17+
from functools import lru_cache
1718
from pathlib import Path
1819
from typing import Any
1920

21+
import jinja2
2022
import PIL.Image
2123
import transformers
2224
from typing_extensions import override
@@ -29,6 +31,38 @@
2931
from oumi.utils.logging import logger
3032
from oumi.utils.str_utils import truncate_to_max_tokens_limit
3133

34+
_REASONING_FIELD_NAMES = frozenset({"reasoning", "reasoning_content"})
35+
_jinja_env = jinja2.Environment()
36+
37+
38+
@lru_cache(maxsize=8)
39+
def _template_references_reasoning(template: str) -> bool:
40+
"""Check if a Jinja2 template references reasoning fields on messages.
41+
42+
Parses the template AST and walks it to find attribute access
43+
(``message.reasoning_content``) or item access
44+
(``message['reasoning']``) nodes that reference reasoning fields.
45+
This avoids false positives from comments or unrelated text.
46+
"""
47+
from jinja2.nodes import Const, Getattr, Getitem, Node
48+
49+
try:
50+
ast = _jinja_env.parse(template)
51+
except jinja2.TemplateSyntaxError:
52+
return False
53+
54+
pending: list[Node] = [ast]
55+
while pending:
56+
node = pending.pop()
57+
if isinstance(node, Getattr):
58+
if node.attr in _REASONING_FIELD_NAMES:
59+
return True
60+
elif isinstance(node, Getitem):
61+
if isinstance(node.arg, Const) and node.arg.value in _REASONING_FIELD_NAMES:
62+
return True
63+
pending.extend(node.iter_child_nodes())
64+
return False
65+
3266

3367
class DefaultProcessor(BaseProcessor):
3468
"""Default implementation of processor that wraps a worker processor.
@@ -226,12 +260,48 @@ def __call__(
226260
)
227261
return result
228262

263+
def _template_supports_reasoning(self) -> bool:
264+
"""Check if the chat template natively handles reasoning fields.
265+
266+
Parses the Jinja2 AST to find actual variable references to
267+
``reasoning`` or ``reasoning_content`` on message objects, avoiding
268+
false positives from comments or unrelated text.
269+
"""
270+
return _template_references_reasoning(self.chat_template)
271+
229272
def _convert_messages_to_dicts(self, messages: list[Message]) -> list[dict]:
230-
"""Converts Message objects to dict format for HuggingFace compatibility."""
231-
return [
232-
msg.model_dump(mode="json", exclude_none=True, exclude_unset=True)
233-
for msg in messages
234-
]
273+
"""Converts Message objects to dict format for HuggingFace compatibility.
274+
275+
When a message has ``reasoning_content``, the behavior depends on
276+
whether the chat template natively supports reasoning fields:
277+
278+
- **Template supports reasoning** (e.g., Qwen3): both
279+
``reasoning_content`` and ``reasoning`` keys are included so the
280+
template can render them natively (typically inside ``<think>``
281+
tags).
282+
- **Template does not support reasoning** (e.g., Llama, DeepSeek):
283+
reasoning is prepended to ``content`` wrapped in ``<think>`` tags
284+
so it appears in the tokenized sequence.
285+
"""
286+
template_supports = self._template_supports_reasoning()
287+
result = []
288+
for msg in messages:
289+
d = msg.model_dump(mode="json", exclude_none=True, exclude_unset=True)
290+
if msg.reasoning_content is not None:
291+
if template_supports:
292+
# Pass as separate keys — template handles formatting.
293+
if "reasoning" not in d:
294+
d["reasoning"] = msg.reasoning_content
295+
else:
296+
# Fold into content — template doesn't know about
297+
# reasoning, so we prepend it with <think> tags.
298+
d.pop("reasoning_content", None)
299+
content = d.get("content", "")
300+
d["content"] = (
301+
f"<think>\n{msg.reasoning_content}\n</think>\n\n{content}"
302+
)
303+
result.append(d)
304+
return result
235305

236306
@override
237307
def apply_chat_template(

src/oumi/core/types/conversation.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,14 +239,21 @@ class Message(pydantic.BaseModel):
239239
role: Role
240240
"""The role of the entity sending the message (e.g., user, assistant, system)."""
241241

242-
reasoning: str | None = None
242+
reasoning_content: str | None = None
243243
"""Optional reasoning/thinking content from the model.
244244
245-
Some providers (e.g., Together) return a separate ``reasoning`` field for
246-
thinking models (Qwen3.x, Qwen3.5-x). When present, ``content`` holds the
247-
visible output and ``reasoning`` holds the internal chain-of-thought.
248-
If the model exhausts its token budget on reasoning, ``content`` may be
249-
empty while ``reasoning`` contains the partial thinking.
245+
Some providers (e.g., Together) return a separate ``reasoning`` or
246+
``reasoning_content`` field for thinking models (Qwen3.x, Qwen3.5-x).
247+
When present, ``content`` holds the visible output and
248+
``reasoning_content`` holds the internal chain-of-thought. If the model
249+
exhausts its token budget on reasoning, ``content`` may be empty while
250+
``reasoning_content`` contains the partial thinking.
251+
252+
Named ``reasoning_content`` to match Qwen3's chat template convention.
253+
The inference engines handle extracting from both ``reasoning`` and
254+
``reasoning_content`` API response fields. When passed to chat
255+
templates, both key names are included in the message dict so that
256+
any template can find it.
250257
"""
251258

252259
def model_post_init(self, __context) -> None:

src/oumi/inference/anthropic_inference_engine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ def _convert_api_output_to_conversation(
216216
new_message = Message(
217217
content=text,
218218
role=Role.ASSISTANT,
219-
reasoning=reasoning,
219+
reasoning_content=reasoning,
220220
)
221221
metadata = dict(original_conversation.metadata)
222222
usage = self._extract_usage_from_response(response)

src/oumi/inference/bedrock_inference_engine.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,9 @@ def _convert_api_output_to_conversation(
371371
text_parts.append(part)
372372
text = "".join(text_parts)
373373
reasoning = "".join(reasoning_parts) if reasoning_parts else None
374-
new_message = Message(content=text, role=Role.ASSISTANT, reasoning=reasoning)
374+
new_message = Message(
375+
content=text, role=Role.ASSISTANT, reasoning_content=reasoning
376+
)
375377
metadata = dict(original.metadata)
376378
finish_reason = self._extract_finish_reason_from_response(response)
377379
if finish_reason is not None:

src/oumi/inference/remote_inference_engine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ def _convert_api_output_to_conversation(
504504
Message(
505505
content=content,
506506
role=Role(message["role"]),
507-
reasoning=reasoning,
507+
reasoning_content=reasoning,
508508
),
509509
],
510510
metadata=metadata,

src/oumi/inference/sambanova_inference_engine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def _convert_api_output_to_conversation(
118118
new_message = Message(
119119
content=content,
120120
role=Role.ASSISTANT,
121-
reasoning=reasoning,
121+
reasoning_content=reasoning,
122122
)
123123

124124
return Conversation(

src/oumi/utils/conversation_utils.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ def create_list_of_message_json_dicts(
218218
messages: list[Message],
219219
*,
220220
group_adjacent_same_role_turns: bool,
221+
include_reasoning: bool = False,
221222
) -> list[dict[str, Any]]:
222223
"""Returns a list of JSON dictionaries representing messages.
223224
@@ -227,6 +228,11 @@ def create_list_of_message_json_dicts(
227228
messages: The input messages.
228229
group_adjacent_same_role_turns: Whether to pack adjacent messages
229230
from the same role into a single element in output list.
231+
include_reasoning: Whether to include ``reasoning_content`` and
232+
``reasoning`` keys in the output dicts. Set to ``True`` when
233+
building dicts for chat templates (training). Set to ``False``
234+
(default) when building dicts for API requests, since some
235+
providers (e.g., Anthropic) reject unknown fields.
230236
231237
Returns:
232238
list[Dict[str, Any]]: The list of messages encoded as nested JSON dicts.
@@ -235,6 +241,7 @@ def create_list_of_message_json_dicts(
235241
result = []
236242
idx = 0
237243
while idx < num_messages:
244+
start_idx = idx
238245
end_idx = idx + 1
239246
if group_adjacent_same_role_turns:
240247
while end_idx < num_messages and (
@@ -258,6 +265,21 @@ def create_list_of_message_json_dicts(
258265
idx += 1
259266
item["content"] = content_list
260267

268+
if include_reasoning:
269+
# Collect reasoning from all messages in the group and
270+
# concatenate. Include under both key names so that any chat
271+
# template can find it (Qwen3 uses "reasoning_content", others
272+
# may use "reasoning").
273+
reasoning_parts: list[str] = [
274+
rc
275+
for i in range(start_idx, end_idx)
276+
if (rc := messages[i].reasoning_content) is not None
277+
]
278+
if reasoning_parts:
279+
reasoning = "".join(reasoning_parts)
280+
item["reasoning_content"] = reasoning
281+
item["reasoning"] = reasoning
282+
261283
idx = end_idx
262284
result.append(item)
263285

@@ -308,12 +330,20 @@ def remove_excessive_images(
308330
if len(filtered_items) == 1 and isinstance(filtered_items[0].content, str):
309331
result.append(
310332
Message(
311-
id=message.id, content=filtered_items[0].content, role=message.role
333+
id=message.id,
334+
content=filtered_items[0].content,
335+
role=message.role,
336+
reasoning_content=message.reasoning_content,
312337
)
313338
)
314339
else:
315340
result.append(
316-
Message(id=message.id, content=filtered_items, role=message.role)
341+
Message(
342+
id=message.id,
343+
content=filtered_items,
344+
role=message.role,
345+
reasoning_content=message.reasoning_content,
346+
)
317347
)
318348

319349
return result
@@ -425,11 +455,17 @@ def truncate_text_in_content_items(
425455
):
426456
assert isinstance(items[0].content, str)
427457
result[msg_idx] = Message(
428-
id=message.id, content=items[0].content, role=message.role
458+
id=message.id,
459+
content=items[0].content,
460+
role=message.role,
461+
reasoning_content=message.reasoning_content,
429462
)
430463
else:
431464
result[msg_idx] = Message(
432-
id=message.id, content=items, role=message.role
465+
id=message.id,
466+
content=items,
467+
role=message.role,
468+
reasoning_content=message.reasoning_content,
433469
)
434470

435471
return result

tests/unit/builders/test_processors.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,3 +338,134 @@ def test_processor_apply_chat_template_multimodal_text_content():
338338
prompt = processor.apply_chat_template(messages)
339339
assert isinstance(prompt, str)
340340
assert "Describe the following:" in prompt
341+
342+
343+
def test_convert_messages_reasoning_with_supporting_template():
344+
"""When chat template references reasoning, pass as separate keys."""
345+
from unittest.mock import MagicMock, PropertyMock
346+
347+
from oumi.core.processors.default_processor import DefaultProcessor
348+
349+
processor = MagicMock(spec=DefaultProcessor)
350+
processor._convert_messages_to_dicts = (
351+
DefaultProcessor._convert_messages_to_dicts.__get__(processor)
352+
)
353+
processor._template_supports_reasoning = (
354+
DefaultProcessor._template_supports_reasoning.__get__(processor)
355+
)
356+
type(processor).chat_template = PropertyMock(
357+
return_value="{% if message.reasoning_content %}...{% endif %}"
358+
)
359+
360+
messages = [
361+
Message(
362+
role=Role.ASSISTANT,
363+
content="The answer is 4.",
364+
reasoning_content="2+2=4",
365+
),
366+
]
367+
result = processor._convert_messages_to_dicts(messages)
368+
assert result[0]["reasoning_content"] == "2+2=4"
369+
assert result[0]["reasoning"] == "2+2=4"
370+
assert result[0]["content"] == "The answer is 4."
371+
372+
373+
def test_convert_messages_reasoning_with_unsupporting_template():
374+
"""When chat template doesn't reference reasoning, fold into content."""
375+
from unittest.mock import MagicMock, PropertyMock
376+
377+
from oumi.core.processors.default_processor import DefaultProcessor
378+
379+
processor = MagicMock(spec=DefaultProcessor)
380+
processor._convert_messages_to_dicts = (
381+
DefaultProcessor._convert_messages_to_dicts.__get__(processor)
382+
)
383+
processor._template_supports_reasoning = (
384+
DefaultProcessor._template_supports_reasoning.__get__(processor)
385+
)
386+
type(processor).chat_template = PropertyMock(
387+
return_value="{% for message in messages %}{{ message.content }}{% endfor %}"
388+
)
389+
390+
messages = [
391+
Message(
392+
role=Role.ASSISTANT,
393+
content="The answer is 4.",
394+
reasoning_content="2+2=4",
395+
),
396+
]
397+
result = processor._convert_messages_to_dicts(messages)
398+
assert "reasoning_content" not in result[0]
399+
assert "reasoning" not in result[0]
400+
assert result[0]["content"] == "<think>\n2+2=4\n</think>\n\nThe answer is 4."
401+
402+
403+
def test_convert_messages_no_reasoning():
404+
"""When message has no reasoning, no reasoning keys in output."""
405+
from unittest.mock import MagicMock, PropertyMock
406+
407+
from oumi.core.processors.default_processor import DefaultProcessor
408+
409+
processor = MagicMock(spec=DefaultProcessor)
410+
processor._convert_messages_to_dicts = (
411+
DefaultProcessor._convert_messages_to_dicts.__get__(processor)
412+
)
413+
processor._template_supports_reasoning = (
414+
DefaultProcessor._template_supports_reasoning.__get__(processor)
415+
)
416+
type(processor).chat_template = PropertyMock(return_value="simple template")
417+
418+
messages = [
419+
Message(role=Role.ASSISTANT, content="Hello"),
420+
]
421+
result = processor._convert_messages_to_dicts(messages)
422+
assert "reasoning_content" not in result[0]
423+
assert "reasoning" not in result[0]
424+
assert result[0]["content"] == "Hello"
425+
426+
427+
def test_template_supports_reasoning_false_positive_comment():
428+
"""Template with 'reasoning' in a comment should NOT match."""
429+
from oumi.core.processors.default_processor import _template_references_reasoning
430+
431+
template = """{# This template handles reasoning models #}
432+
{% for message in messages %}{{ message.content }}{% endfor %}"""
433+
assert _template_references_reasoning(template) is False
434+
435+
436+
def test_template_supports_reasoning_false_positive_string_literal():
437+
"""Template with 'reasoning' in a string literal should NOT match."""
438+
from oumi.core.processors.default_processor import _template_references_reasoning
439+
440+
template = (
441+
"{% for message in messages %}"
442+
'{{ "reasoning_content is not used" }}'
443+
"{{ message.content }}{% endfor %}"
444+
)
445+
assert _template_references_reasoning(template) is False
446+
447+
448+
def test_template_supports_reasoning_attribute_access():
449+
"""Template with message.reasoning_content attribute access should match."""
450+
from oumi.core.processors.default_processor import _template_references_reasoning
451+
452+
template = (
453+
"{% if message.reasoning_content %}{{ message.reasoning_content }}{% endif %}"
454+
)
455+
assert _template_references_reasoning(template) is True
456+
457+
458+
def test_template_supports_reasoning_dict_access():
459+
"""Template with message['reasoning'] dict access should match."""
460+
from oumi.core.processors.default_processor import _template_references_reasoning
461+
462+
template = """{% if message['reasoning'] %}{{ message['reasoning'] }}{% endif %}"""
463+
assert _template_references_reasoning(template) is True
464+
465+
466+
def test_template_supports_reasoning_no_reasoning():
467+
"""Template with no reasoning references should not match."""
468+
from oumi.core.processors.default_processor import _template_references_reasoning
469+
470+
template = """{% for m in messages %}{{ m.content }}{% endfor %}"""
471+
assert _template_references_reasoning(template) is False

0 commit comments

Comments
 (0)