From 83ccdf61862ffcd665fb096922087b4924b15d9a Mon Sep 17 00:00:00 2001 From: muskliu <862259098@qq.com> Date: Sun, 22 Feb 2026 00:14:22 +0800 Subject: [PATCH 1/2] fix(provider): filter empty text content blocks causing API 400 When MCP tools return empty content, messages may contain empty-string text blocks. OpenAI-compatible providers reject these with HTTP 400. Changes: - Add _prevent_empty_text_blocks() to filter empty text items from content lists and handle empty string content - For assistant messages with tool_calls, set content to None (valid) - For other messages, replace with '(empty)' placeholder - Only copy message dict when modification is needed (zero-copy path for normal messages) Co-Authored-By: nanobot --- nanobot/providers/custom_provider.py | 52 ++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index f190ccf..ec5d48b 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -19,8 +19,12 @@ class CustomProvider(LLMProvider): async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse: - kwargs: dict[str, Any] = {"model": model or self.default_model, "messages": messages, - "max_tokens": max(1, max_tokens), "temperature": temperature} + kwargs: dict[str, Any] = { + "model": model or self.default_model, + "messages": self._prevent_empty_text_blocks(messages), + "max_tokens": max(1, max_tokens), + "temperature": temperature, + } if tools: kwargs.update(tools=tools, tool_choice="auto") try: @@ -45,3 +49,47 @@ class CustomProvider(LLMProvider): def get_default_model(self) -> str: return self.default_model + + @staticmethod + def _prevent_empty_text_blocks(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Filter empty text content blocks that cause provider 400 errors. + + When MCP tools return empty content, messages may contain empty-string + text blocks. Most providers (OpenAI-compatible) reject these with 400. + This method filters them out before sending to the API. + """ + patched: list[dict[str, Any]] = [] + for msg in messages: + content = msg.get("content") + + # Empty string content + if isinstance(content, str) and content == "": + clean = dict(msg) + if msg.get("role") == "assistant" and msg.get("tool_calls"): + clean["content"] = None + else: + clean["content"] = "(empty)" + patched.append(clean) + continue + + # List content — filter out empty text items + if isinstance(content, list): + filtered = [ + item for item in content + if not (isinstance(item, dict) + and item.get("type") in {"text", "input_text", "output_text"} + and item.get("text") == "") + ] + if filtered != content: + clean = dict(msg) + if filtered: + clean["content"] = filtered + elif msg.get("role") == "assistant" and msg.get("tool_calls"): + clean["content"] = None + else: + clean["content"] = "(empty)" + patched.append(clean) + continue + + patched.append(msg) + return patched From b653183bb0f76b0f3e157506e9906398efe7c311 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 22 Feb 2026 18:26:42 +0000 Subject: [PATCH 2/2] refactor(providers): move empty content sanitization to base class --- nanobot/providers/base.py | 40 ++++++++++++++++++++++++ nanobot/providers/custom_provider.py | 45 +-------------------------- nanobot/providers/litellm_provider.py | 2 +- 3 files changed, 42 insertions(+), 45 deletions(-) diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index c69c38b..eb1599a 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -39,6 +39,46 @@ class LLMProvider(ABC): def __init__(self, api_key: str | None = None, api_base: str | None = None): self.api_key = api_key self.api_base = api_base + + @staticmethod + def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Replace empty text content that causes provider 400 errors. + + Empty content can appear when MCP tools return nothing. Most providers + reject empty-string content or empty text blocks in list content. + """ + result: list[dict[str, Any]] = [] + for msg in messages: + content = msg.get("content") + + if isinstance(content, str) and not content: + clean = dict(msg) + clean["content"] = None if (msg.get("role") == "assistant" and msg.get("tool_calls")) else "(empty)" + result.append(clean) + continue + + if isinstance(content, list): + filtered = [ + item for item in content + if not ( + isinstance(item, dict) + and item.get("type") in ("text", "input_text", "output_text") + and not item.get("text") + ) + ] + if len(filtered) != len(content): + clean = dict(msg) + if filtered: + clean["content"] = filtered + elif msg.get("role") == "assistant" and msg.get("tool_calls"): + clean["content"] = None + else: + clean["content"] = "(empty)" + result.append(clean) + continue + + result.append(msg) + return result @abstractmethod async def chat( diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index 9a0901c..a578d14 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -21,7 +21,7 @@ class CustomProvider(LLMProvider): model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse: kwargs: dict[str, Any] = { "model": model or self.default_model, - "messages": self._prevent_empty_text_blocks(messages), + "messages": self._sanitize_empty_content(messages), "max_tokens": max(1, max_tokens), "temperature": temperature, } @@ -50,46 +50,3 @@ class CustomProvider(LLMProvider): def get_default_model(self) -> str: return self.default_model - @staticmethod - def _prevent_empty_text_blocks(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Filter empty text content blocks that cause provider 400 errors. - - When MCP tools return empty content, messages may contain empty-string - text blocks. Most providers (OpenAI-compatible) reject these with 400. - This method filters them out before sending to the API. - """ - patched: list[dict[str, Any]] = [] - for msg in messages: - content = msg.get("content") - - # Empty string content - if isinstance(content, str) and content == "": - clean = dict(msg) - if msg.get("role") == "assistant" and msg.get("tool_calls"): - clean["content"] = None - else: - clean["content"] = "(empty)" - patched.append(clean) - continue - - # List content — filter out empty text items - if isinstance(content, list): - filtered = [ - item for item in content - if not (isinstance(item, dict) - and item.get("type") in {"text", "input_text", "output_text"} - and item.get("text") == "") - ] - if filtered != content: - clean = dict(msg) - if filtered: - clean["content"] = filtered - elif msg.get("role") == "assistant" and msg.get("tool_calls"): - clean["content"] = None - else: - clean["content"] = "(empty)" - patched.append(clean) - continue - - patched.append(msg) - return patched diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 784f02c..7402a2b 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -196,7 +196,7 @@ class LiteLLMProvider(LLMProvider): kwargs: dict[str, Any] = { "model": model, - "messages": self._sanitize_messages(messages), + "messages": self._sanitize_messages(self._sanitize_empty_content(messages)), "max_tokens": max_tokens, "temperature": temperature, }