From 7941b27f5843b00134743158bd7091d3c720f9e8 Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 13 May 2026 09:22:22 +0800 Subject: [PATCH] feat(llm): send strict_tool_mode for DeepSeek OpenAI-compat tool calls Merge strict_tool_mode: true into extra_body when tools are used and the endpoint/model looks like DeepSeek. Opt out with AIA_DEEPSEEK_STRICT_TOOL_MODE=0. Add unit tests. Co-authored-by: Cursor --- .../llm/transports/openai_chat_completions.py | 39 +++++++- tests/test_openai_chat_strict_tool_mode.py | 90 +++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 tests/test_openai_chat_strict_tool_mode.py diff --git a/platform/llm/transports/openai_chat_completions.py b/platform/llm/transports/openai_chat_completions.py index 260f3db2..599af059 100644 --- a/platform/llm/transports/openai_chat_completions.py +++ b/platform/llm/transports/openai_chat_completions.py @@ -109,6 +109,34 @@ def _messages_contain_list_with_image(messages: list[dict[str, Any]]) -> bool: return False +def _deepseek_strict_tool_mode_enabled(*, base_url: str | None, model: str | None) -> bool: + """DeepSeek OpenAI-compat: optional ``strict_tool_mode`` in request body (via ``extra_body``). + + Upstream documents this as reducing DSML-in-text tool output in favor of native ``tool_calls``. + Opt out with ``AIA_DEEPSEEK_STRICT_TOOL_MODE=0`` when a proxy rejects unknown fields. + """ + raw = str(os.getenv("AIA_DEEPSEEK_STRICT_TOOL_MODE") or "").strip().lower() + if raw in ("0", "false", "no", "off"): + return False + hay = f"{base_url or ''} {model or ''}".lower() + return "deepseek" in hay + + +def _merge_deepseek_strict_tool_mode_extra_body( + kwargs: dict[str, Any], + *, + base_url: str | None, + model: str | None, + use_tools: bool, +) -> None: + if not use_tools or not _deepseek_strict_tool_mode_enabled(base_url=base_url, model=model): + return + extra_body = kwargs.get("extra_body") if isinstance(kwargs.get("extra_body"), dict) else {} + extra_body = dict(extra_body) + extra_body["strict_tool_mode"] = True + kwargs["extra_body"] = extra_body + + def _should_proactively_downgrade_multimodal_messages( messages: list[dict[str, Any]], *, @@ -528,6 +556,9 @@ class OpenAIChatModel(ChatModel): kwargs["tools"] = plan.tools_wired except Exception: kwargs["tools"] = complete_openai_tools_wire_parameters(tools) + _merge_deepseek_strict_tool_mode_extra_body( + kwargs, base_url=self.base_url, model=self.model, use_tools=use_tools + ) try: return self._client.chat.completions.create(**kwargs) except Exception as exc: @@ -677,7 +708,13 @@ class OpenAIChatModel(ChatModel): return LLMResponse(content=content, tool_calls=tool_calls, reasoning_content=reasoning_text) -__all__ = ["OpenAIChatModel", "_likely_gemini_openai_compat_base_url", "_model_id_suggests_gemini"] +__all__ = [ + "OpenAIChatModel", + "_likely_gemini_openai_compat_base_url", + "_model_id_suggests_gemini", + "_deepseek_strict_tool_mode_enabled", + "_merge_deepseek_strict_tool_mode_extra_body", +] def _default_max_openai_tools_json_bytes(base_url: str | None) -> int | None: diff --git a/tests/test_openai_chat_strict_tool_mode.py b/tests/test_openai_chat_strict_tool_mode.py new file mode 100644 index 00000000..e82d9faf --- /dev/null +++ b/tests/test_openai_chat_strict_tool_mode.py @@ -0,0 +1,90 @@ +"""DeepSeek OpenAI-compat: strict_tool_mode via extra_body.""" + +from __future__ import annotations + +import os +import unittest + +from oclaw.platform.llm.transports.openai_chat_completions import ( + _deepseek_strict_tool_mode_enabled, + _merge_deepseek_strict_tool_mode_extra_body, +) + + +class DeepseekStrictToolModeTests(unittest.TestCase): + def test_enabled_when_deepseek_in_base_url(self) -> None: + self.assertTrue( + _deepseek_strict_tool_mode_enabled( + base_url="https://api.deepseek.com/v1", + model="deepseek-chat", + ) + ) + + def test_disabled_when_env_off(self) -> None: + prev = os.environ.get("AIA_DEEPSEEK_STRICT_TOOL_MODE") + try: + os.environ["AIA_DEEPSEEK_STRICT_TOOL_MODE"] = "0" + self.assertFalse( + _deepseek_strict_tool_mode_enabled( + base_url="https://api.deepseek.com/v1", + model="x", + ) + ) + finally: + if prev is None: + os.environ.pop("AIA_DEEPSEEK_STRICT_TOOL_MODE", None) + else: + os.environ["AIA_DEEPSEEK_STRICT_TOOL_MODE"] = prev + + def test_merge_sets_extra_body_strict_tool_mode(self) -> None: + kwargs: dict = {"model": "m", "messages": [], "stream": False, "tools": [{"type": "function"}]} + _merge_deepseek_strict_tool_mode_extra_body( + kwargs, + base_url="https://api.deepseek.com/v1", + model="deepseek-chat", + use_tools=True, + ) + self.assertEqual(kwargs.get("extra_body"), {"strict_tool_mode": True}) + + def test_merge_preserves_existing_extra_body(self) -> None: + kwargs: dict = { + "model": "m", + "messages": [], + "stream": False, + "tools": [{"type": "function"}], + "extra_body": {"thinking": {"type": "disabled"}}, + } + _merge_deepseek_strict_tool_mode_extra_body( + kwargs, + base_url="https://api.deepseek.com", + model="deepseek-reasoner", + use_tools=True, + ) + self.assertEqual( + kwargs["extra_body"], + {"thinking": {"type": "disabled"}, "strict_tool_mode": True}, + ) + + def test_merge_skips_without_tools(self) -> None: + kwargs: dict = {"model": "m", "messages": [], "stream": False} + _merge_deepseek_strict_tool_mode_extra_body( + kwargs, + base_url="https://api.deepseek.com/v1", + model="deepseek-chat", + use_tools=False, + ) + self.assertNotIn("extra_body", kwargs) + + def test_merge_skips_non_deepseek(self) -> None: + kwargs: dict = {"model": "m", "messages": [], "stream": False, "tools": [{"type": "function"}]} + _merge_deepseek_strict_tool_mode_extra_body( + kwargs, + base_url="https://api.openai.com/v1", + model="gpt-4o-mini", + use_tools=True, + ) + self.assertNotIn("extra_body", kwargs) + + +if __name__ == "__main__": + unittest.main()