From 9e7c07ac8943bef17525b7db37e65bfcdb386f55 Mon Sep 17 00:00:00 2001 From: moranfong Date: Tue, 7 Apr 2026 23:30:08 +0800 Subject: [PATCH] test(provider): add StepFun reasoning field fallback tests Add comprehensive tests for the StepFun Plan API compatibility fix: - _parse dict branch: content and reasoning_content fallback to reasoning - _parse SDK object branch: same fallback for pydantic response objects - _parse_chunks dict branch: reasoning field handled in streaming mode - _parse_chunks SDK branch: reasoning fallback for SDK delta objects - Precedence tests: reasoning_content field takes priority over reasoning Refs: fix(provider): support StepFun Plan API reasoning field fallback --- tests/providers/test_stepfun_reasoning.py | 204 ++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 tests/providers/test_stepfun_reasoning.py diff --git a/tests/providers/test_stepfun_reasoning.py b/tests/providers/test_stepfun_reasoning.py new file mode 100644 index 000000000..0c39d9342 --- /dev/null +++ b/tests/providers/test_stepfun_reasoning.py @@ -0,0 +1,204 @@ +"""Tests for StepFun Plan API reasoning field fallback in OpenAICompatProvider. + +StepFun Plan API returns response content in the 'reasoning' field when +the model is in thinking mode and 'content' is empty. This test module +verifies the fallback logic for all code paths. +""" + +from types import SimpleNamespace +from unittest.mock import patch + +from nanobot.providers.openai_compat_provider import OpenAICompatProvider + + +# ── _parse: dict branch ───────────────────────────────────────────────────── + + +def test_parse_dict_stepfun_reasoning_fallback() -> None: + """When content is None and reasoning exists, content falls back to reasoning.""" + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + response = { + "choices": [{ + "message": { + "content": None, + "reasoning": "Let me think... The answer is 42.", + }, + "finish_reason": "stop", + }], + } + + result = provider._parse(response) + + assert result.content == "Let me think... The answer is 42." + # reasoning_content should also be populated from reasoning + assert result.reasoning_content == "Let me think... The answer is 42." + + +def test_parse_dict_stepfun_reasoning_priority() -> None: + """reasoning_content field takes priority over reasoning when both present.""" + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + response = { + "choices": [{ + "message": { + "content": None, + "reasoning": "informal thinking", + "reasoning_content": "formal reasoning content", + }, + "finish_reason": "stop", + }], + } + + result = provider._parse(response) + + assert result.content == "informal thinking" + # reasoning_content uses the dedicated field, not reasoning + assert result.reasoning_content == "formal reasoning content" + + +# ── _parse: SDK object branch ─────────────────────────────────────────────── + + +def _make_sdk_message(content, reasoning=None, reasoning_content=None): + """Create a mock SDK message object.""" + msg = SimpleNamespace(content=content, tool_calls=None) + if reasoning is not None: + msg.reasoning = reasoning + if reasoning_content is not None: + msg.reasoning_content = reasoning_content + return msg + + +def test_parse_sdk_stepfun_reasoning_fallback() -> None: + """SDK branch: content falls back to msg.reasoning when content is None.""" + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + msg = _make_sdk_message(content=None, reasoning="After analysis: result is 4.") + choice = SimpleNamespace(finish_reason="stop", message=msg) + response = SimpleNamespace(choices=[choice], usage=None) + + result = provider._parse(response) + + assert result.content == "After analysis: result is 4." + assert result.reasoning_content == "After analysis: result is 4." + + +def test_parse_sdk_stepfun_reasoning_priority() -> None: + """reasoning_content field takes priority over reasoning in SDK branch.""" + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + msg = _make_sdk_message( + content=None, + reasoning="thinking process", + reasoning_content="formal reasoning" + ) + choice = SimpleNamespace(finish_reason="stop", message=msg) + response = SimpleNamespace(choices=[choice], usage=None) + + result = provider._parse(response) + + assert result.content == "thinking process" + assert result.reasoning_content == "formal reasoning" + + +# ── _parse_chunks: streaming dict branch ──────────────────────────────────── + + +def test_parse_chunks_dict_stepfun_reasoning_fallback() -> None: + """Streaming dict: reasoning field used when reasoning_content is absent.""" + chunks = [ + { + "choices": [{ + "finish_reason": None, + "delta": {"content": None, "reasoning": "Thinking step 1... "}, + }], + }, + { + "choices": [{ + "finish_reason": None, + "delta": {"content": None, "reasoning": "step 2. "}, + }], + }, + { + "choices": [{ + "finish_reason": "stop", + "delta": {"content": "final answer"}, + }], + }, + ] + + result = OpenAICompatProvider._parse_chunks(chunks) + + assert result.content == "final answer" + assert result.reasoning_content == "Thinking step 1... step 2." + + +def test_parse_chunks_dict_reasoning_precedence() -> None: + """reasoning_content takes precedence over reasoning in dict chunks.""" + chunks = [ + { + "choices": [{ + "finish_reason": None, + "delta": { + "content": None, + "reasoning_content": "formal: ", + "reasoning": "informal: ", + }, + }], + }, + { + "choices": [{ + "finish_reason": "stop", + "delta": {"content": "result"}, + }], + }, + ] + + result = OpenAICompatProvider._parse_chunks(chunks) + + assert result.reasoning_content == "formal: " + + +# ── _parse_chunks: streaming SDK-object branch ───────────────────────────── + + +def _make_sdk_chunk(reasoning_content=None, reasoning=None, content=None, finish=None): + """Create a mock SDK chunk object.""" + delta = SimpleNamespace( + content=content, + reasoning_content=reasoning_content, + reasoning=reasoning, + tool_calls=None, + ) + choice = SimpleNamespace(finish_reason=finish, delta=delta) + return SimpleNamespace(choices=[choice], usage=None) + + +def test_parse_chunks_sdk_stepfun_reasoning_fallback() -> None: + """SDK streaming: reasoning field used when reasoning_content is None.""" + chunks = [ + _make_sdk_chunk(reasoning="Thinking... ", content=None, finish=None), + _make_sdk_chunk(reasoning=None, content="answer", finish="stop"), + ] + + result = OpenAICompatProvider._parse_chunks(chunks) + + assert result.content == "answer" + assert result.reasoning_content == "Thinking... " + + +def test_parse_chunks_sdk_reasoning_precedence() -> None: + """reasoning_content takes precedence over reasoning in SDK chunks.""" + chunks = [ + _make_sdk_chunk(reasoning_content="formal: ", reasoning="informal: ", content=None), + _make_sdk_chunk(reasoning_content=None, reasoning=None, content="result", finish="stop"), + ] + + result = OpenAICompatProvider._parse_chunks(chunks) + + assert result.reasoning_content == "formal: "