fix(provider): normalize DeepSeek non-string message content
This commit is contained in:
@@ -345,10 +345,25 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
return json.dumps(arguments, ensure_ascii=False)
|
return json.dumps(arguments, ensure_ascii=False)
|
||||||
return "{}"
|
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]]:
|
def _sanitize_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Strip non-standard keys, normalize tool_call IDs."""
|
"""Strip non-standard keys, normalize tool_call IDs."""
|
||||||
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
||||||
id_map: dict[str, str] = {}
|
id_map: dict[str, str] = {}
|
||||||
|
force_string_content = bool(self._spec and self._spec.name == "deepseek")
|
||||||
|
|
||||||
def map_id(value: Any) -> Any:
|
def map_id(value: Any) -> Any:
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
@@ -382,6 +397,11 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
clean["content"] = None
|
clean["content"] = None
|
||||||
if "tool_call_id" in clean and clean["tool_call_id"]:
|
if "tool_call_id" in clean and clean["tool_call_id"]:
|
||||||
clean["tool_call_id"] = map_id(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)
|
return self._enforce_role_alternation(sanitized)
|
||||||
|
|
||||||
def _drop_deepseek_incomplete_reasoning_history(
|
def _drop_deepseek_incomplete_reasoning_history(
|
||||||
|
|||||||
@@ -929,6 +929,57 @@ def test_backfill_does_not_touch_messages_when_thinking_off() -> None:
|
|||||||
assert "reasoning_content" not in msg
|
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:
|
def test_openai_no_thinking_extra_body() -> None:
|
||||||
"""Non-thinking providers should never get extra_body for thinking."""
|
"""Non-thinking providers should never get extra_body for thinking."""
|
||||||
kw = _build_kwargs_for("openai", "gpt-4o", reasoning_effort="medium")
|
kw = _build_kwargs_for("openai", "gpt-4o", reasoning_effort="medium")
|
||||||
|
|||||||
Reference in New Issue
Block a user