Skip to content

Commit 538e229

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 538e229

13 files changed

Lines changed: 240 additions & 39 deletions

src/oumi/core/processors/default_processor.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,12 +226,44 @@ def __call__(
226226
)
227227
return result
228228

229+
def _template_supports_reasoning(self) -> bool:
230+
"""Check if the chat template natively handles reasoning fields."""
231+
template = self.chat_template
232+
return "reasoning_content" in template or "reasoning" in template
233+
229234
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-
]
235+
"""Converts Message objects to dict format for HuggingFace compatibility.
236+
237+
When a message has ``reasoning_content``, the behavior depends on
238+
whether the chat template natively supports reasoning fields:
239+
240+
- **Template supports reasoning** (e.g., Qwen3): both
241+
``reasoning_content`` and ``reasoning`` keys are included so the
242+
template can render them natively (typically inside ``<think>``
243+
tags).
244+
- **Template does not support reasoning** (e.g., Llama, DeepSeek):
245+
reasoning is prepended to ``content`` wrapped in ``<think>`` tags
246+
so it appears in the tokenized sequence.
247+
"""
248+
template_supports = self._template_supports_reasoning()
249+
result = []
250+
for msg in messages:
251+
d = msg.model_dump(mode="json", exclude_none=True, exclude_unset=True)
252+
if msg.reasoning_content is not None:
253+
if template_supports:
254+
# Pass as separate keys — template handles formatting.
255+
if "reasoning" not in d:
256+
d["reasoning"] = msg.reasoning_content
257+
else:
258+
# Fold into content — template doesn't know about
259+
# reasoning, so we prepend it with <think> tags.
260+
d.pop("reasoning_content", None)
261+
content = d.get("content", "")
262+
d["content"] = (
263+
f"<think>\n{msg.reasoning_content}\n</think>\n\n{content}"
264+
)
265+
result.append(d)
266+
return result
235267

236268
@override
237269
def apply_chat_template(

src/oumi/core/types/conversation.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,14 +239,23 @@ 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 = pydantic.Field(
243+
default=None,
244+
validation_alias=pydantic.AliasChoices("reasoning_content", "reasoning"),
245+
)
243246
"""Optional reasoning/thinking content from the model.
244247
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.
248+
Some providers (e.g., Together) return a separate ``reasoning`` or
249+
``reasoning_content`` field for thinking models (Qwen3.x, Qwen3.5-x).
250+
When present, ``content`` holds the visible output and
251+
``reasoning_content`` holds the internal chain-of-thought. If the model
252+
exhausts its token budget on reasoning, ``content`` may be empty while
253+
``reasoning_content`` contains the partial thinking.
254+
255+
Accepts both ``reasoning_content`` and ``reasoning`` as input keys
256+
(via Pydantic alias) for compatibility across providers. Always
257+
serialized as ``reasoning_content`` in output to match Qwen3's chat
258+
template convention.
250259
"""
251260

252261
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: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ def create_list_of_message_json_dicts(
235235
result = []
236236
idx = 0
237237
while idx < num_messages:
238+
start_idx = idx
238239
end_idx = idx + 1
239240
if group_adjacent_same_role_turns:
240241
while end_idx < num_messages and (
@@ -258,6 +259,14 @@ def create_list_of_message_json_dicts(
258259
idx += 1
259260
item["content"] = content_list
260261

262+
# Include reasoning under both key names so that any chat template
263+
# can find it (Qwen3 uses "reasoning_content", others may use
264+
# "reasoning"). Only added when the value is not None.
265+
reasoning = messages[start_idx].reasoning_content
266+
if reasoning is not None:
267+
item["reasoning_content"] = reasoning
268+
item["reasoning"] = reasoning
269+
261270
idx = end_idx
262271
result.append(item)
263272

@@ -308,12 +317,20 @@ def remove_excessive_images(
308317
if len(filtered_items) == 1 and isinstance(filtered_items[0].content, str):
309318
result.append(
310319
Message(
311-
id=message.id, content=filtered_items[0].content, role=message.role
320+
id=message.id,
321+
content=filtered_items[0].content,
322+
role=message.role,
323+
reasoning_content=message.reasoning_content,
312324
)
313325
)
314326
else:
315327
result.append(
316-
Message(id=message.id, content=filtered_items, role=message.role)
328+
Message(
329+
id=message.id,
330+
content=filtered_items,
331+
role=message.role,
332+
reasoning_content=message.reasoning_content,
333+
)
317334
)
318335

319336
return result
@@ -425,11 +442,17 @@ def truncate_text_in_content_items(
425442
):
426443
assert isinstance(items[0].content, str)
427444
result[msg_idx] = Message(
428-
id=message.id, content=items[0].content, role=message.role
445+
id=message.id,
446+
content=items[0].content,
447+
role=message.role,
448+
reasoning_content=message.reasoning_content,
429449
)
430450
else:
431451
result[msg_idx] = Message(
432-
id=message.id, content=items, role=message.role
452+
id=message.id,
453+
content=items,
454+
role=message.role,
455+
reasoning_content=message.reasoning_content,
433456
)
434457

435458
return result

tests/unit/builders/test_processors.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,3 +338,87 @@ 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"

tests/unit/inference/test_anthropic_inference_engine.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,11 @@ def test_convert_api_output_interleaved_blocks(anthropic_engine):
114114
api_response, original
115115
)
116116
assert result.messages[-1].content == "First part.Second part."
117-
assert result.messages[-1].reasoning == "Step 1...Step 2..."
117+
assert result.messages[-1].reasoning_content == "Step 1...Step 2..."
118118

119119

120120
def test_convert_api_output_with_thinking_blocks(anthropic_engine):
121-
"""Test that thinking blocks are extracted into Message.reasoning."""
121+
"""Test that thinking blocks are extracted into Message.reasoning_content."""
122122
original = Conversation(
123123
messages=[Message(content="Hello", role=Role.USER)],
124124
)
@@ -136,7 +136,7 @@ def test_convert_api_output_with_thinking_blocks(anthropic_engine):
136136
api_response, original
137137
)
138138
assert result.messages[-1].content == "Here is my answer."
139-
assert result.messages[-1].reasoning == "Let me reason about this..."
139+
assert result.messages[-1].reasoning_content == "Let me reason about this..."
140140

141141

142142
def test_convert_api_output_no_thinking_blocks(anthropic_engine):
@@ -149,7 +149,7 @@ def test_convert_api_output_no_thinking_blocks(anthropic_engine):
149149
api_response, original
150150
)
151151
assert result.messages[-1].content == "Simple answer."
152-
assert result.messages[-1].reasoning is None
152+
assert result.messages[-1].reasoning_content is None
153153

154154

155155
def test_convert_api_output_null_thinking_content(anthropic_engine):
@@ -168,7 +168,7 @@ def test_convert_api_output_null_thinking_content(anthropic_engine):
168168
api_response, original
169169
)
170170
assert result.messages[-1].content == "Answer."
171-
assert result.messages[-1].reasoning is None
171+
assert result.messages[-1].reasoning_content is None
172172

173173

174174
def test_convert_api_output_null_text_content(anthropic_engine):
@@ -186,7 +186,7 @@ def test_convert_api_output_null_text_content(anthropic_engine):
186186
api_response, original
187187
)
188188
assert result.messages[-1].content == ""
189-
assert result.messages[-1].reasoning == "Reasoning here."
189+
assert result.messages[-1].reasoning_content == "Reasoning here."
190190

191191

192192
@pytest.mark.parametrize(

tests/unit/inference/test_bedrock_inference_engine.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def test_convert_api_output_to_conversation(bedrock_engine):
103103

104104
@pytest.mark.skipif(boto3_import_failed, reason="boto3 not available")
105105
def test_convert_api_output_with_thinking_blocks(bedrock_engine):
106-
"""Test that thinking blocks are extracted into Message.reasoning."""
106+
"""Test that thinking blocks are extracted into Message.reasoning_content."""
107107
original = Conversation(
108108
messages=[Message(content="Hello", role=Role.USER)],
109109
)
@@ -122,7 +122,7 @@ def test_convert_api_output_with_thinking_blocks(bedrock_engine):
122122
}
123123
result = bedrock_engine._convert_api_output_to_conversation(api_response, original)
124124
assert result.messages[-1].content == "The answer is 42."
125-
assert result.messages[-1].reasoning == "Step 1: analyze..."
125+
assert result.messages[-1].reasoning_content == "Step 1: analyze..."
126126

127127

128128
@pytest.mark.skipif(boto3_import_failed, reason="boto3 not available")
@@ -134,7 +134,7 @@ def test_convert_api_output_no_thinking_blocks(bedrock_engine):
134134
api_response = {"output": {"message": {"content": [{"text": "Simple answer."}]}}}
135135
result = bedrock_engine._convert_api_output_to_conversation(api_response, original)
136136
assert result.messages[-1].content == "Simple answer."
137-
assert result.messages[-1].reasoning is None
137+
assert result.messages[-1].reasoning_content is None
138138

139139

140140
@pytest.mark.skipif(boto3_import_failed, reason="boto3 not available")

0 commit comments

Comments
 (0)