From 8e0ce59c0e2d6dd1accc7b9e493ecd5139adf76c Mon Sep 17 00:00:00 2001 From: hanyuanling Date: Mon, 27 Apr 2026 00:30:45 +0800 Subject: [PATCH] fix(provider): normalize DeepSeek non-string message content --- nanobot/providers/openai_compat_provider.py | 20 ++++++++ tests/providers/test_litellm_kwargs.py | 51 +++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index fdbad585..6cd1ffb8 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -345,10 +345,25 @@ class OpenAICompatProvider(LLMProvider): return json.dumps(arguments, ensure_ascii=False) return "{}" + @staticmethod + def _coerce_content_to_string(content: Any) -> str | None: + """Coerce block/list content into plain text for strict string-only APIs.""" + if content is None or isinstance(content, str): + return content + text = OpenAICompatProvider._extract_text_content(content) + if isinstance(text, str) and text: + return text + try: + dumped = json.dumps(content, ensure_ascii=False) + except Exception: + dumped = str(content) + return dumped or "(empty)" + def _sanitize_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: """Strip non-standard keys, normalize tool_call IDs.""" sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS) id_map: dict[str, str] = {} + force_string_content = bool(self._spec and self._spec.name == "deepseek") def map_id(value: Any) -> Any: if not isinstance(value, str): @@ -382,6 +397,11 @@ class OpenAICompatProvider(LLMProvider): clean["content"] = None if "tool_call_id" in clean and clean["tool_call_id"]: clean["tool_call_id"] = map_id(clean["tool_call_id"]) + if ( + force_string_content + and not (clean.get("role") == "assistant" and clean.get("tool_calls")) + ): + clean["content"] = self._coerce_content_to_string(clean.get("content")) return self._enforce_role_alternation(sanitized) def _drop_deepseek_incomplete_reasoning_history( diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index 1c3cfb85..e91d1cae 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -929,6 +929,57 @@ def test_backfill_does_not_touch_messages_when_thinking_off() -> None: assert "reasoning_content" not in msg +def test_deepseek_coerces_list_content_to_string() -> None: + """DeepSeek chat endpoint expects message.content to be a string.""" + spec = find_by_name("deepseek") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + p = OpenAICompatProvider(api_key="k", default_model="deepseek-chat", spec=spec) + + kw = p._build_kwargs( + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": "hello "}, + {"type": "text", "text": "world"}, + ], + }], + tools=None, + model="deepseek-chat", + max_tokens=1024, + temperature=0.7, + reasoning_effort=None, + tool_choice=None, + ) + + assert isinstance(kw["messages"][0]["content"], str) + assert "hello" in kw["messages"][0]["content"] + assert "world" in kw["messages"][0]["content"] + + +def test_non_deepseek_keeps_list_content() -> None: + """Only DeepSeek should force string content; OpenAI-compatible providers keep blocks.""" + spec = find_by_name("openai") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + p = OpenAICompatProvider(api_key="k", default_model="gpt-4o", spec=spec) + + kw = p._build_kwargs( + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": "hello"}, + ], + }], + tools=None, + model="gpt-4o", + max_tokens=1024, + temperature=0.7, + reasoning_effort=None, + tool_choice=None, + ) + + assert isinstance(kw["messages"][0]["content"], list) + + def test_openai_no_thinking_extra_body() -> None: """Non-thinking providers should never get extra_body for thinking.""" kw = _build_kwargs_for("openai", "gpt-4o", reasoning_effort="medium")