From 05de864f5b6cc258c3f408e77e53d3bb5c1a635f Mon Sep 17 00:00:00 2001 From: michaelxer Date: Sat, 6 Jun 2026 06:34:19 +0800 Subject: [PATCH] fix: preserve empty-string reasoning_content instead of coercing to None Custom providers (e.g. DeepSeek) may return reasoning_content as an empty string "" to explicitly indicate no reasoning occurred. The previous truthiness checks (, ) treated "" as falsy and converted it to None, which caused the field to be dropped from the message history entirely. Providers that require reasoning_content on all assistant messages then rejected subsequent requests. Replace truthiness checks with identity checks () so that empty-string reasoning_content is preserved as-is. The streaming path is unchanged since an empty join genuinely means no chunks received. Fixes #4105 --- nanobot/providers/openai_compat_provider.py | 8 +++--- tests/providers/test_reasoning_content.py | 27 ++++++++++++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 5cc7431fb..6fe00b327 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -999,7 +999,7 @@ class OpenAICompatProvider(LLMProvider): 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"): + if reasoning_content is None and msg0.get("reasoning"): reasoning_content = self._extract_text_content(msg0.get("reasoning")) for ch in choices: ch_map = self._maybe_mapping(ch) or {} @@ -1011,7 +1011,7 @@ class OpenAICompatProvider(LLMProvider): finish_reason = str(ch_map["finish_reason"]) if not content: content = self._extract_text_content(m.get("content")) - if not reasoning_content: + if reasoning_content is None: reasoning_content = m.get("reasoning_content") parsed_tool_calls = [] @@ -1074,8 +1074,8 @@ class OpenAICompatProvider(LLMProvider): function_provider_specific_fields=fn_prov, )) - reasoning_content = getattr(msg, "reasoning_content", None) or None - if not reasoning_content and getattr(msg, "reasoning", None): + reasoning_content = getattr(msg, "reasoning_content", None) + if reasoning_content is None and getattr(msg, "reasoning", None): reasoning_content = msg.reasoning return LLMResponse( diff --git a/tests/providers/test_reasoning_content.py b/tests/providers/test_reasoning_content.py index a58569143..8bb0b45fd 100644 --- a/tests/providers/test_reasoning_content.py +++ b/tests/providers/test_reasoning_content.py @@ -9,7 +9,6 @@ from unittest.mock import patch from nanobot.providers.openai_compat_provider import OpenAICompatProvider - # ── _parse: non-streaming ───────────────────────────────────────────────── @@ -52,6 +51,32 @@ def test_parse_dict_reasoning_content_none_when_absent() -> None: 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 == "" + + # ── _parse_chunks: streaming dict branch ─────────────────────────────────