diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 4dea2d5f..bf82e538 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import hashlib import importlib.util import os @@ -222,6 +223,24 @@ class OpenAICompatProvider(LLMProvider): return tool_call_id return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9] + @staticmethod + def _normalize_tool_call_arguments(arguments: Any) -> str: + """Force function.arguments into a valid JSON object string.""" + if isinstance(arguments, str): + stripped = arguments.strip() + if not stripped: + return "{}" + try: + parsed = json_repair.loads(stripped) + except Exception: + return "{}" + if isinstance(parsed, dict): + return json.dumps(parsed, ensure_ascii=False) + return "{}" + if isinstance(arguments, dict): + return json.dumps(arguments, ensure_ascii=False) + return "{}" + 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) @@ -241,6 +260,16 @@ class OpenAICompatProvider(LLMProvider): continue tc_clean = dict(tc) tc_clean["id"] = map_id(tc_clean.get("id")) + function = tc_clean.get("function") + if isinstance(function, dict): + function_clean = dict(function) + if "arguments" in function_clean: + function_clean["arguments"] = self._normalize_tool_call_arguments( + function_clean.get("arguments") + ) + else: + function_clean["arguments"] = "{}" + tc_clean["function"] = function_clean normalized.append(tc_clean) clean["tool_calls"] = normalized if clean.get("role") == "assistant": diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index ec2581cd..31bdaa55 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -584,6 +584,54 @@ def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() - assert sanitized[2]["tool_call_id"] == "3ec83c30d" +def test_openai_compat_stringifies_dict_tool_arguments() -> None: + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + sanitized = provider._sanitize_messages([ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "exec", "arguments": {"cmd": "ls -la"}}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "name": "exec", "content": "ok"}, + {"role": "user", "content": "done"}, + ]) + + assert sanitized[1]["tool_calls"][0]["function"]["arguments"] == '{"cmd": "ls -la"}' + + +def test_openai_compat_repairs_non_json_tool_arguments_string() -> None: + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider() + + sanitized = provider._sanitize_messages([ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "exec", "arguments": "{'cmd': 'pwd'}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "name": "exec", "content": "ok"}, + {"role": "user", "content": "done"}, + ]) + + assert sanitized[1]["tool_calls"][0]["function"]["arguments"] == '{"cmd": "pwd"}' + + @pytest.mark.asyncio async def test_openai_compat_stream_watchdog_returns_error_on_stall(monkeypatch) -> None: monkeypatch.setenv("NANOBOT_STREAM_IDLE_TIMEOUT_S", "0")