nanobot/tests/providers/test_reasoning_content.py
chengyongru 631fdb4a46 test: cover empty reasoning_content history preservation
maintainer edit: add SDK-object and tool-call history regressions so the empty-string reasoning_content fix is covered across both parse branches and the sanitized request path.
2026-06-08 01:08:27 +08:00

205 lines
6.9 KiB
Python

"""Tests for reasoning_content extraction in OpenAICompatProvider.
Covers non-streaming (_parse) and streaming (_parse_chunks) paths for
providers that return a reasoning_content field (e.g. MiMo, DeepSeek-R1).
"""
from types import SimpleNamespace
from unittest.mock import patch
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.utils.helpers import build_assistant_message
# ── _parse: non-streaming ─────────────────────────────────────────────────
def test_parse_dict_extracts_reasoning_content() -> None:
"""reasoning_content at message level is surfaced in LLMResponse."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
response = {
"choices": [{
"message": {
"content": "42",
"reasoning_content": "Let me think step by step…",
},
"finish_reason": "stop",
}],
"usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15},
}
result = provider._parse(response)
assert result.content == "42"
assert result.reasoning_content == "Let me think step by step…"
def test_parse_dict_reasoning_content_none_when_absent() -> None:
"""reasoning_content is None when the response doesn't include it."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
response = {
"choices": [{
"message": {"content": "hello"},
"finish_reason": "stop",
}],
}
result = provider._parse(response)
assert result.reasoning_content is None
def test_parse_dict_reasoning_content_empty_string_preserved() -> None:
"""reasoning_content=\"\" is preserved, not coerced to None.
Some providers (e.g. DeepSeek) require the reasoning_content key to
be present in subsequent requests even when empty. Coercing \"\" to
None drops the key downstream and causes API errors.
"""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
response = {
"choices": [{
"message": {
"content": "answer",
"reasoning_content": "",
},
"finish_reason": "stop",
}],
"usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
}
result = provider._parse(response)
assert result.reasoning_content == ""
def test_parse_sdk_reasoning_content_empty_string_preserved() -> None:
"""SDK response objects preserve reasoning_content=\"\"."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
message = SimpleNamespace(content="answer", reasoning_content="", tool_calls=None)
choice = SimpleNamespace(message=message, finish_reason="stop")
response = SimpleNamespace(choices=[choice], usage=None)
result = provider._parse(response)
assert result.content == "answer"
assert result.reasoning_content == ""
def test_tool_call_history_preserves_empty_reasoning_content_after_sanitize() -> None:
"""Empty reasoning_content survives the tool-call history round trip."""
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
response = {
"choices": [{
"message": {
"content": "",
"reasoning_content": "",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": "{}"},
}],
},
"finish_reason": "tool_calls",
}],
}
result = provider._parse(response)
assistant_message = build_assistant_message(
result.content or "",
tool_calls=[tc.to_openai_tool_call() for tc in result.tool_calls],
reasoning_content=result.reasoning_content,
)
sanitized = provider._sanitize_messages([
{"role": "user", "content": "look something up"},
assistant_message,
{"role": "tool", "tool_call_id": "call_1", "content": "done"},
])
assert sanitized[1]["reasoning_content"] == ""
# ── _parse_chunks: streaming dict branch ─────────────────────────────────
def test_parse_chunks_dict_accumulates_reasoning_content() -> None:
"""reasoning_content deltas in dict chunks are joined into one string."""
chunks = [
{
"choices": [{
"finish_reason": None,
"delta": {"content": None, "reasoning_content": "Step 1. "},
}],
},
{
"choices": [{
"finish_reason": None,
"delta": {"content": None, "reasoning_content": "Step 2."},
}],
},
{
"choices": [{
"finish_reason": "stop",
"delta": {"content": "answer"},
}],
},
]
result = OpenAICompatProvider._parse_chunks(chunks)
assert result.content == "answer"
assert result.reasoning_content == "Step 1. Step 2."
def test_parse_chunks_dict_reasoning_content_none_when_absent() -> None:
"""reasoning_content is None when no chunk contains it."""
chunks = [
{"choices": [{"finish_reason": "stop", "delta": {"content": "hi"}}]},
]
result = OpenAICompatProvider._parse_chunks(chunks)
assert result.content == "hi"
assert result.reasoning_content is None
# ── _parse_chunks: streaming SDK-object branch ────────────────────────────
def _make_reasoning_chunk(reasoning: str | None, content: str | None, finish: str | None):
delta = SimpleNamespace(content=content, reasoning_content=reasoning, tool_calls=None)
choice = SimpleNamespace(finish_reason=finish, delta=delta)
return SimpleNamespace(choices=[choice], usage=None)
def test_parse_chunks_sdk_accumulates_reasoning_content() -> None:
"""reasoning_content on SDK delta objects is joined across chunks."""
chunks = [
_make_reasoning_chunk("Think… ", None, None),
_make_reasoning_chunk("Done.", None, None),
_make_reasoning_chunk(None, "result", "stop"),
]
result = OpenAICompatProvider._parse_chunks(chunks)
assert result.content == "result"
assert result.reasoning_content == "Think… Done."
def test_parse_chunks_sdk_reasoning_content_none_when_absent() -> None:
"""reasoning_content is None when SDK deltas carry no reasoning_content."""
chunks = [_make_reasoning_chunk(None, "hello", "stop")]
result = OpenAICompatProvider._parse_chunks(chunks)
assert result.reasoning_content is None