fix(provider): gate reasoning-to-content fallback behind spec flag
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
This commit is contained in:
@@ -759,8 +759,8 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
finish_reason = str(choice0.get("finish_reason") or "stop")
|
finish_reason = str(choice0.get("finish_reason") or "stop")
|
||||||
|
|
||||||
raw_tool_calls: list[Any] = []
|
raw_tool_calls: list[Any] = []
|
||||||
# StepFun Plan: fallback to reasoning field when content is empty
|
# StepFun: fallback to reasoning field when content is empty
|
||||||
if not content and msg0.get("reasoning"):
|
if not content and msg0.get("reasoning") and self._spec and self._spec.reasoning_as_content:
|
||||||
content = self._extract_text_content(msg0.get("reasoning"))
|
content = self._extract_text_content(msg0.get("reasoning"))
|
||||||
reasoning_content = msg0.get("reasoning_content")
|
reasoning_content = msg0.get("reasoning_content")
|
||||||
if not reasoning_content and msg0.get("reasoning"):
|
if not reasoning_content and msg0.get("reasoning"):
|
||||||
@@ -820,7 +820,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
finish_reason = ch.finish_reason
|
finish_reason = ch.finish_reason
|
||||||
if not content and m.content:
|
if not content and m.content:
|
||||||
content = 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
|
content = m.reasoning
|
||||||
|
|
||||||
tool_calls = []
|
tool_calls = []
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ class ProviderSpec:
|
|||||||
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
|
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
|
||||||
thinking_style: str = ""
|
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
|
@property
|
||||||
def label(self) -> str:
|
def label(self) -> str:
|
||||||
return self.display_name or self.name.title()
|
return self.display_name or self.name.title()
|
||||||
@@ -325,6 +330,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
display_name="Step Fun",
|
display_name="Step Fun",
|
||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
default_api_base="https://api.stepfun.com/v1",
|
default_api_base="https://api.stepfun.com/v1",
|
||||||
|
reasoning_as_content=True,
|
||||||
),
|
),
|
||||||
# Xiaomi MIMO (小米): OpenAI-compatible API
|
# Xiaomi MIMO (小米): OpenAI-compatible API
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
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 ─────────────────────────────────────────────────────
|
# ── _parse: dict branch ─────────────────────────────────────────────────────
|
||||||
@@ -17,7 +28,7 @@ from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
|||||||
def test_parse_dict_stepfun_reasoning_fallback() -> None:
|
def test_parse_dict_stepfun_reasoning_fallback() -> None:
|
||||||
"""When content is None and reasoning exists, content falls back to reasoning."""
|
"""When content is None and reasoning exists, content falls back to reasoning."""
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
provider = OpenAICompatProvider()
|
provider = OpenAICompatProvider(spec=_STEPFUN_SPEC)
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"choices": [{
|
"choices": [{
|
||||||
@@ -39,7 +50,7 @@ def test_parse_dict_stepfun_reasoning_fallback() -> None:
|
|||||||
def test_parse_dict_stepfun_reasoning_priority() -> None:
|
def test_parse_dict_stepfun_reasoning_priority() -> None:
|
||||||
"""reasoning_content field takes priority over reasoning when both present."""
|
"""reasoning_content field takes priority over reasoning when both present."""
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
provider = OpenAICompatProvider()
|
provider = OpenAICompatProvider(spec=_STEPFUN_SPEC)
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"choices": [{
|
"choices": [{
|
||||||
@@ -75,7 +86,7 @@ def _make_sdk_message(content, reasoning=None, reasoning_content=None):
|
|||||||
def test_parse_sdk_stepfun_reasoning_fallback() -> None:
|
def test_parse_sdk_stepfun_reasoning_fallback() -> None:
|
||||||
"""SDK branch: content falls back to msg.reasoning when content is None."""
|
"""SDK branch: content falls back to msg.reasoning when content is None."""
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
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.")
|
msg = _make_sdk_message(content=None, reasoning="After analysis: result is 4.")
|
||||||
choice = SimpleNamespace(finish_reason="stop", message=msg)
|
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:
|
def test_parse_sdk_stepfun_reasoning_priority() -> None:
|
||||||
"""reasoning_content field takes priority over reasoning in SDK branch."""
|
"""reasoning_content field takes priority over reasoning in SDK branch."""
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||||
provider = OpenAICompatProvider()
|
provider = OpenAICompatProvider(spec=_STEPFUN_SPEC)
|
||||||
|
|
||||||
msg = _make_sdk_message(
|
msg = _make_sdk_message(
|
||||||
content=None,
|
content=None,
|
||||||
@@ -244,3 +255,44 @@ def test_parse_chunks_sdk_reasoning_precedence() -> None:
|
|||||||
result = OpenAICompatProvider._parse_chunks(chunks)
|
result = OpenAICompatProvider._parse_chunks(chunks)
|
||||||
|
|
||||||
assert result.reasoning_content == "formal: "
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user