diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 81b82403e6..edd9795bb3 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -193,8 +193,10 @@ async def _complete_with_assistant_response(self, llm_resp: LLMResponse) -> None if llm_resp.completion_text: parts.append(TextPart(text=llm_resp.completion_text)) if len(parts) == 0: - logger.warning("LLM returned empty assistant message with no tool calls.") - self.run_context.messages.append(Message(role="assistant", content=parts)) + logger.warning("LLM returned empty assistant message with no tool calls. Skipping message.") + # 不添加空消息到上下文,避免严格 API(如 DeepSeek R1)返回 400 错误 + else: + self.run_context.messages.append(Message(role="assistant", content=parts)) try: await self.agent_hooks.on_agent_done(self.run_context, llm_resp) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 67971a2a93..d29e2d993f 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -438,7 +438,7 @@ async def _fallback_to_text_only_and_retry( image_fallback_used, ) - def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient: + def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None: """创建带代理的 HTTP 客户端""" proxy = provider_config.get("proxy", "") return create_proxy_client("OpenAI", proxy) @@ -546,23 +546,23 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: extra_body.update(custom_extra_body) self._apply_provider_specific_extra_body_overrides(extra_body) - model = payloads.get("model", "").lower() - + # 在调用 self.client.chat.completions.create 之前添加清理逻辑 if "messages" in payloads and isinstance(payloads["messages"], list): cleaned_messages = [] for idx, msg in enumerate(payloads["messages"]): - # 过滤空的 assistant 消息,防止严格 API(如 Moonshot)返回 400 错误 if msg.get("role") == "assistant": content = msg.get("content") tool_calls = msg.get("tool_calls") - # 情况1: 空/null content 且无 tool_calls -> 过滤掉 - if not tool_calls and (content == "" or content is None): - logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") + # 空内容:空字符串、None 或空列表 + empty_content = content in ("", None, []) + if not tool_calls and empty_content: + # 无 tool_calls 且内容为空 -> 过滤掉,避免 400 错误 + logger.debug(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") continue - # 情况2: 空 content 但有 tool_calls -> 设为 None (符合 OpenAI 规范) - if content == "" and tool_calls: + # 有 tool_calls 但 content 为空 -> 设为 None(API 可接受) + if empty_content and tool_calls: msg["content"] = None cleaned_messages.append(msg) @@ -605,11 +605,6 @@ async def _query_stream( # 不在默认参数中的参数放在 extra_body 中 extra_body = {} - # 读取并合并 custom_extra_body 配置 - custom_extra_body = self.provider_config.get("custom_extra_body", {}) - if isinstance(custom_extra_body, dict): - extra_body.update(custom_extra_body) - to_del = [] for key in payloads: if key not in self.default_params: @@ -617,8 +612,36 @@ async def _query_stream( to_del.append(key) for key in to_del: del payloads[key] + + # 读取并合并 custom_extra_body 配置(custom 优先级高于 payload 中的扩展参数) + custom_extra_body = self.provider_config.get("custom_extra_body", {}) + if isinstance(custom_extra_body, dict): + extra_body.update(custom_extra_body) self._apply_provider_specific_extra_body_overrides(extra_body) + # 在调用 self.client.chat.completions.create 之前添加清理逻辑(与非流式 _query 方法保持一致) + if "messages" in payloads and isinstance(payloads["messages"], list): + cleaned_messages = [] + for idx, msg in enumerate(payloads["messages"]): + if msg.get("role") == "assistant": + content = msg.get("content") + tool_calls = msg.get("tool_calls") + + # 空内容:空字符串、None 或空列表 + empty_content = content in ("", None, []) + if not tool_calls and empty_content: + # 无 tool_calls 且内容为空 -> 过滤掉,避免 400 错误 + logger.debug(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") + continue + + # 有 tool_calls 但 content 为空 -> 设为 None(API 可接受) + if empty_content and tool_calls: + msg["content"] = None + + cleaned_messages.append(msg) + + payloads["messages"] = cleaned_messages + stream = await self.client.chat.completions.create( **payloads, stream=True, diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index 83e18137c4..fcb3863059 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -1618,3 +1618,325 @@ async def fake_create(**kwargs): assert messages[2] == {"role": "user", "content": "hello"} finally: await provider.terminate() + + +# --------------------------------------------------------------------------- +# Tests for _filter_empty_assistant_messages (unit tests for the shared helper) +# --------------------------------------------------------------------------- + + +def test_filter_empty_assistant_messages_removes_empty_string_content(): + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": ""}, + {"role": "user", "content": "world"}, + ] + } + ProviderOpenAIOfficial._filter_empty_assistant_messages(payloads) + assert len(payloads["messages"]) == 2 + assert payloads["messages"][0]["role"] == "user" + assert payloads["messages"][1]["role"] == "user" + + +def test_filter_empty_assistant_messages_removes_none_content(): + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": None}, + {"role": "user", "content": "world"}, + ] + } + ProviderOpenAIOfficial._filter_empty_assistant_messages(payloads) + assert len(payloads["messages"]) == 2 + + +def test_filter_empty_assistant_messages_removes_empty_list_content(): + """content == [] should also be treated as empty and filtered out.""" + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": []}, + {"role": "user", "content": "world"}, + ] + } + ProviderOpenAIOfficial._filter_empty_assistant_messages(payloads) + assert len(payloads["messages"]) == 2 + + +def test_filter_empty_assistant_messages_sets_empty_list_to_none_with_tool_calls(): + """content == [] with tool_calls should be set to None, not dropped.""" + tool_calls = [ + {"id": "c1", "type": "function", "function": {"name": "f", "arguments": "{}"}} + ] + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [], "tool_calls": tool_calls}, + {"role": "user", "content": "world"}, + ] + } + ProviderOpenAIOfficial._filter_empty_assistant_messages(payloads) + assert len(payloads["messages"]) == 3 + assert payloads["messages"][1]["content"] is None + assert payloads["messages"][1]["tool_calls"] is not None + + +def test_filter_empty_assistant_messages_keeps_valid_messages(): + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "I'm here"}, + {"role": "user", "content": "world"}, + ] + } + ProviderOpenAIOfficial._filter_empty_assistant_messages(payloads) + assert len(payloads["messages"]) == 3 + + +def test_filter_empty_assistant_messages_noop_on_missing_messages(): + payloads: dict = {} + ProviderOpenAIOfficial._filter_empty_assistant_messages(payloads) + assert payloads == {} + + +# --------------------------------------------------------------------------- +# Tests for _query_stream: verify filtering is applied before streaming +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_query_stream_filters_empty_assistant_message(monkeypatch): + """_query_stream should filter empty assistant messages just like _query.""" + provider = _make_provider() + try: + captured_kwargs = {} + chunks = [ + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "ok"}, + "finish_reason": None, + } + ], + } + ), + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + ), + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ), + ] + + async def fake_stream(): + for chunk in chunks: + yield chunk + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return fake_stream() + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + payloads = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": ""}, # Should be filtered + {"role": "user", "content": "world"}, + ], + } + + [r async for r in provider._query_stream(payloads=payloads, tools=None)] + + messages = captured_kwargs["messages"] + assert len(messages) == 2 + assert messages[0] == {"role": "user", "content": "hello"} + assert messages[1] == {"role": "user", "content": "world"} + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_query_stream_filters_empty_list_content_assistant_message(monkeypatch): + """_query_stream should filter assistant messages with content == [].""" + provider = _make_provider() + try: + captured_kwargs = {} + chunks = [ + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "ok"}, + "finish_reason": None, + } + ], + } + ), + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + ), + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ), + ] + + async def fake_stream(): + for chunk in chunks: + yield chunk + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return fake_stream() + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + payloads = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": []}, # Should be filtered + {"role": "user", "content": "world"}, + ], + } + + [r async for r in provider._query_stream(payloads=payloads, tools=None)] + + messages = captured_kwargs["messages"] + assert len(messages) == 2 + assert messages[0] == {"role": "user", "content": "hello"} + assert messages[1] == {"role": "user", "content": "world"} + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_query_stream_converts_empty_list_content_to_none_with_tool_calls( + monkeypatch, +): + """_query_stream: content==[] with tool_calls should become None, not be dropped.""" + provider = _make_provider() + try: + captured_kwargs = {} + chunks = [ + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "ok"}, + "finish_reason": None, + } + ], + } + ), + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + ), + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ), + ] + + async def fake_stream(): + for chunk in chunks: + yield chunk + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return fake_stream() + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + tool_calls = [ + { + "id": "c1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ] + payloads = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [], "tool_calls": tool_calls}, + {"role": "user", "content": "world"}, + ], + } + + [r async for r in provider._query_stream(payloads=payloads, tools=None)] + + messages = captured_kwargs["messages"] + assert len(messages) == 3 + assert messages[1]["content"] is None + assert messages[1]["tool_calls"] is not None + finally: + await provider.terminate()