From 3de843a229a22f2c58e22bed647ee9a8bf742a52 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sat, 25 Apr 2026 23:13:46 +0800 Subject: [PATCH] fix(provider): gate reasoning-to-content fallback behind spec flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-streaming parse path unconditionally promoted the `reasoning` response field to `content` when content was empty. This was intended for StepFun (whose API returns the actual answer in `reasoning`), but it applied to every OpenAI-compatible provider — causing internal thinking chains from models like Xiaomi MIMO to be leaked as formal replies. Add `reasoning_as_content: bool` to ProviderSpec (default False) and set it only for StepFun. The fallback now requires this flag rather than running globally. Fixes #3443 --- nanobot/providers/openai_compat_provider.py | 6 +-- nanobot/providers/registry.py | 6 +++ tests/providers/test_stepfun_reasoning.py | 60 +++++++++++++++++++-- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index ef255cf2..55809382 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -759,8 +759,8 @@ class OpenAICompatProvider(LLMProvider): finish_reason = str(choice0.get("finish_reason") or "stop") raw_tool_calls: list[Any] = [] - # StepFun Plan: fallback to reasoning field when content is empty - if not content and msg0.get("reasoning"): + # StepFun: fallback to reasoning field when content is empty + if not content and msg0.get("reasoning") and self._spec and self._spec.reasoning_as_content: content = self._extract_text_content(msg0.get("reasoning")) reasoning_content = msg0.get("reasoning_content") if not reasoning_content and msg0.get("reasoning"): @@ -820,7 +820,7 @@ class OpenAICompatProvider(LLMProvider): finish_reason = ch.finish_reason if not content and m.content: content = m.content - if not content and getattr(m, "reasoning", None): + if not content and getattr(m, "reasoning", None) and self._spec and self._spec.reasoning_as_content: content = m.reasoning tool_calls = [] diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 5037e300..6cb57cb0 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -71,6 +71,11 @@ class ProviderSpec: # "reasoning_split" — {"reasoning_split": true/false} (MiniMax) thinking_style: str = "" + # When True, treat the "reasoning" response field as formal content + # when "content" is empty. Only set this for providers (e.g. StepFun) + # whose API returns the actual answer in "reasoning" instead of "content". + reasoning_as_content: bool = False + @property def label(self) -> str: return self.display_name or self.name.title() @@ -325,6 +330,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( display_name="Step Fun", backend="openai_compat", default_api_base="https://api.stepfun.com/v1", + reasoning_as_content=True, ), # Xiaomi MIMO (小米): OpenAI-compatible API ProviderSpec( diff --git a/tests/providers/test_stepfun_reasoning.py b/tests/providers/test_stepfun_reasoning.py index 05e5416d..8d7cbdb9 100644 --- a/tests/providers/test_stepfun_reasoning.py +++ b/tests/providers/test_stepfun_reasoning.py @@ -9,6 +9,17 @@ from types import SimpleNamespace from unittest.mock import patch from nanobot.providers.openai_compat_provider import OpenAICompatProvider +from nanobot.providers.registry import ProviderSpec + +_STEPFUN_SPEC = ProviderSpec( + name="stepfun", + keywords=("stepfun", "step"), + env_key="STEPFUN_API_KEY", + display_name="Step Fun", + backend="openai_compat", + default_api_base="https://api.stepfun.com/v1", + reasoning_as_content=True, +) # ── _parse: dict branch ───────────────────────────────────────────────────── @@ -17,7 +28,7 @@ from nanobot.providers.openai_compat_provider import OpenAICompatProvider 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() + provider = OpenAICompatProvider(spec=_STEPFUN_SPEC) response = { "choices": [{ @@ -39,7 +50,7 @@ def test_parse_dict_stepfun_reasoning_fallback() -> None: 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() + provider = OpenAICompatProvider(spec=_STEPFUN_SPEC) response = { "choices": [{ @@ -75,7 +86,7 @@ def _make_sdk_message(content, reasoning=None, reasoning_content=None): 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() + provider = OpenAICompatProvider(spec=_STEPFUN_SPEC) msg = _make_sdk_message(content=None, reasoning="After analysis: result is 4.") choice = SimpleNamespace(finish_reason="stop", message=msg) @@ -90,7 +101,7 @@ def test_parse_sdk_stepfun_reasoning_fallback() -> None: 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() + provider = OpenAICompatProvider(spec=_STEPFUN_SPEC) msg = _make_sdk_message( content=None, @@ -244,3 +255,44 @@ def test_parse_chunks_sdk_reasoning_precedence() -> None: result = OpenAICompatProvider._parse_chunks(chunks) assert result.reasoning_content == "formal: " + + +# ── Regression: non-StepFun providers must NOT promote reasoning to content ─ + + +def test_parse_dict_non_stepfun_no_reasoning_as_content() -> None: + """Providers without reasoning_as_content flag must not treat reasoning as content.""" + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + response = { + "choices": [{ + "message": { + "content": None, + "reasoning": "internal thought process that should NOT be shown to user", + }, + "finish_reason": "stop", + }], + } + + result = provider._parse(response) + + # content stays None — reasoning is NOT promoted + assert result.content is None + # reasoning still goes to reasoning_content for display as thinking + assert result.reasoning_content == "internal thought process that should NOT be shown to user" + + +def test_parse_sdk_non_stepfun_no_reasoning_as_content() -> None: + """SDK branch: providers without flag must not treat reasoning as content.""" + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + msg = _make_sdk_message(content=None, reasoning="internal monologue") + choice = SimpleNamespace(finish_reason="stop", message=msg) + response = SimpleNamespace(choices=[choice], usage=None) + + result = provider._parse(response) + + assert result.content is None + assert result.reasoning_content == "internal monologue"