From 643a8b177e73524495b28bb4f048755cf1da2c02 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 27 Apr 2026 11:43:49 +0800 Subject: [PATCH 1/2] fix: update reasoning_content handling to support empty string values --- .../agent/runners/tool_loop_agent_runner.py | 12 ++--- astrbot/core/provider/entities.py | 4 +- .../core/provider/sources/anthropic_source.py | 2 +- .../core/provider/sources/gemini_source.py | 2 +- .../core/provider/sources/openai_source.py | 53 +++++++++---------- 5 files changed, 34 insertions(+), 39 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index cf70b41504..d96a4c92cb 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -183,10 +183,10 @@ async def _complete_with_assistant_response(self, llm_resp: LLMResponse) -> None self.stats.end_time = time.time() parts = [] - if llm_resp.reasoning_content or llm_resp.reasoning_signature: + if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature: parts.append( ThinkPart( - think=llm_resp.reasoning_content, + think=llm_resp.reasoning_content or "", encrypted=llm_resp.reasoning_signature, ) ) @@ -876,10 +876,10 @@ async def step(self): # 将结果添加到上下文中 parts = [] - if llm_resp.reasoning_content or llm_resp.reasoning_signature: + if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature: parts.append( ThinkPart( - think=llm_resp.reasoning_content, + think=llm_resp.reasoning_content or "", encrypted=llm_resp.reasoning_signature, ) ) @@ -1361,10 +1361,10 @@ async def _finalize_aborted_step( self.stats.end_time = time.time() parts = [] - if llm_resp.reasoning_content or llm_resp.reasoning_signature: + if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature: parts.append( ThinkPart( - think=llm_resp.reasoning_content, + think=llm_resp.reasoning_content or "", encrypted=llm_resp.reasoning_signature, ) ) diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index d4bf8814d9..9b64196e7a 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -353,7 +353,7 @@ class LLMResponse: """Tool call IDs.""" tools_call_extra_content: dict[str, dict[str, Any]] = field(default_factory=dict) """Tool call extra content. tool_call_id -> extra_content dict""" - reasoning_content: str = "" + reasoning_content: str | None = None """The reasoning content extracted from the LLM, if any.""" reasoning_signature: str | None = None """The signature of the reasoning content, if any.""" @@ -404,8 +404,6 @@ def __init__( raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None. """ - if reasoning_content is None: - reasoning_content = "" if tools_call_args is None: tools_call_args = [] if tools_call_name is None: diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index d2fce17ded..87e1b0284a 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -39,7 +39,7 @@ def _ensure_usable_response( stop_reason: str | None = None, ) -> None: has_text_output = bool((llm_response.completion_text or "").strip()) - has_reasoning_output = bool(llm_response.reasoning_content.strip()) + has_reasoning_output = bool((llm_response.reasoning_content or "").strip()) has_tool_output = bool(llm_response.tools_call_args) if has_text_output or has_reasoning_output or has_tool_output: return diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index 158170d516..a942c56e4a 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -462,7 +462,7 @@ def _ensure_usable_response( finish_reason: str | None = None, ) -> None: has_text_output = bool((llm_response.completion_text or "").strip()) - has_reasoning_output = bool(llm_response.reasoning_content.strip()) + has_reasoning_output = bool((llm_response.reasoning_content or "").strip()) has_tool_output = bool(llm_response.tools_call_args) if has_text_output or has_reasoning_output or has_tool_output: return diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index c1191a2dd3..03dcf63382 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -671,9 +671,9 @@ async def _query_stream( reasoning = self._extract_reasoning_content(chunk) _y = False llm_response.id = chunk.id - llm_response.reasoning_content = "" + llm_response.reasoning_content = None llm_response.completion_text = "" - if reasoning: + if reasoning is not None: llm_response.reasoning_content = reasoning _y = True if delta and delta.content: @@ -701,22 +701,28 @@ async def _query_stream( def _extract_reasoning_content( self, completion: ChatCompletion | ChatCompletionChunk, - ) -> str: + ) -> str | None: """Extract reasoning content from OpenAI ChatCompletion if available.""" - reasoning_text = "" + + def _get_reasoning_attr(obj: Any) -> str | None: + fields_set = getattr(obj, "model_fields_set", None) + if isinstance(fields_set, set) and self.reasoning_key in fields_set: + attr = getattr(obj, self.reasoning_key, "") + return "" if attr is None else str(attr) + attr = getattr(obj, self.reasoning_key, None) + return None if attr is None else str(attr) + if not completion.choices: - return reasoning_text + return None if isinstance(completion, ChatCompletion): choice = completion.choices[0] - reasoning_attr = getattr(choice.message, self.reasoning_key, None) - if reasoning_attr: - reasoning_text = str(reasoning_attr) + reasoning_attr = _get_reasoning_attr(choice.message) elif isinstance(completion, ChatCompletionChunk): delta = completion.choices[0].delta - reasoning_attr = getattr(delta, self.reasoning_key, None) - if reasoning_attr: - reasoning_text = str(reasoning_attr) - return reasoning_text + reasoning_attr = _get_reasoning_attr(delta) + else: + return None + return reasoning_attr def _extract_usage(self, usage: CompletionUsage | dict) -> TokenUsage: ptd = getattr(usage, "prompt_tokens_details", None) @@ -859,7 +865,9 @@ async def _parse_openai_completion( # parse the reasoning content if any # the priority is higher than the tag extraction - llm_response.reasoning_content = self._extract_reasoning_content(completion) + reasoning_content = self._extract_reasoning_content(completion) + if reasoning_content is not None: + llm_response.reasoning_content = reasoning_content # parse tool calls if any if choice.message.tool_calls and tools is not None: @@ -906,7 +914,7 @@ async def _parse_openai_completion( "API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。", ) has_text_output = bool((llm_response.completion_text or "").strip()) - has_reasoning_output = bool(llm_response.reasoning_content.strip()) + has_reasoning_output = bool((llm_response.reasoning_content or "").strip()) if ( not has_text_output and not has_reasoning_output @@ -982,34 +990,23 @@ def _finally_convert_payload(self, payloads: dict) -> None: """Finally convert the payload. Such as think part conversion, tool inject.""" model = payloads.get("model", "").lower() is_gemini = "gemini" in model - deepseek_reasoning_models = {"deepseek-v4-pro", "deepseek-v4-flash"} - is_deepseek_v4_reasoning = ( - model in deepseek_reasoning_models - or "api.deepseek.com" in self.client.base_url.host - ) - for message in payloads.get("messages", []): if message.get("role") == "assistant" and isinstance( message.get("content"), list ): reasoning_content = "" + reasoning_content_present = False new_content = [] # not including think part for part in message["content"]: if part.get("type") == "think": + reasoning_content_present = True reasoning_content += str(part.get("think")) else: new_content.append(part) # Some providers (Grok, etc.) reject empty content lists. # When all parts were think blocks, fall back to None. message["content"] = new_content or None - if is_deepseek_v4_reasoning and not reasoning_content: - logger.info( - "Deepseek v4 model requires non-empty reasoning content, but got empty. Setting to 'none' to satisfy the requirement." - ) - # Deepseek models require the field on assistant - # history messages, even when the reasoning content is empty. - message["reasoning_content"] = "none" - elif reasoning_content: + if reasoning_content_present: message["reasoning_content"] = reasoning_content # Gemini 的 function_response 要求 google.protobuf.Struct(即 JSON 对象), From 47ea036a810451b2717d47350c1fc9b8a6f49ad6 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 27 Apr 2026 11:45:42 +0800 Subject: [PATCH 2/2] fix: add reasoning_content field for DeepSeek v4 models in assistant messages --- astrbot/core/provider/sources/openai_source.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 03dcf63382..512e47233a 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -990,6 +990,11 @@ def _finally_convert_payload(self, payloads: dict) -> None: """Finally convert the payload. Such as think part conversion, tool inject.""" model = payloads.get("model", "").lower() is_gemini = "gemini" in model + deepseek_reasoning_models = {"deepseek-v4-pro", "deepseek-v4-flash"} + is_deepseek_v4_reasoning = ( + model in deepseek_reasoning_models + or "api.deepseek.com" in self.client.base_url.host + ) for message in payloads.get("messages", []): if message.get("role") == "assistant" and isinstance( message.get("content"), list @@ -1009,6 +1014,15 @@ def _finally_convert_payload(self, payloads: dict) -> None: if reasoning_content_present: message["reasoning_content"] = reasoning_content + if ( + message.get("role") == "assistant" + and is_deepseek_v4_reasoning + and "reasoning_content" not in message + ): + # DeepSeek v4 reasoning models require the field on assistant + # history messages, even when the reasoning content is empty. + message["reasoning_content"] = "" + # Gemini 的 function_response 要求 google.protobuf.Struct(即 JSON 对象), # 纯文本会触发 400 Invalid argument,需要包一层 JSON。 if is_gemini and message.get("role") == "tool":