From 6ba3234a87888c38c181411c0003fef5606545bb Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 13 May 2026 14:59:24 +0800 Subject: [PATCH] fix(llm): DeepSeek strict tools via function.strict, not extra_body Match DeepSeek Tool Calls docs: set strict:true on each tools[].function. Remove non-documented strict_tool_mode extra_body. Keep AIA_DEEPSEEK_STRICT_TOOL_MODE=0 to opt out. Co-authored-by: Cursor --- svc/llm/transports/openai_chat_completions.py | 56 +++++++------ tests/test_openai_chat_strict_tool_mode.py | 78 +++++++------------ 2 files changed, 60 insertions(+), 74 deletions(-) diff --git a/svc/llm/transports/openai_chat_completions.py b/svc/llm/transports/openai_chat_completions.py index 2087877c..1b22b958 100644 --- a/svc/llm/transports/openai_chat_completions.py +++ b/svc/llm/transports/openai_chat_completions.py @@ -109,11 +109,13 @@ 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``). +def _deepseek_strict_tools_enabled(*, base_url: str | None, model: str | None) -> bool: + """Whether to set ``strict: true`` on each OpenAI-style ``tools[].function`` for DeepSeek. - 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. + DeepSeek documents strict tool output in + https://api-docs.deepseek.com/zh-cn/guides/tool_calls : + each ``function`` should include ``\"strict\": true`` (Beta; also needs ``/beta`` base URL + and JSON Schema rules on the provider side). Opt out with ``AIA_DEEPSEEK_STRICT_TOOL_MODE=0``. """ raw = str(os.getenv("AIA_DEEPSEEK_STRICT_TOOL_MODE") or "").strip().lower() if raw in ("0", "false", "no", "off"): @@ -122,19 +124,28 @@ def _deepseek_strict_tool_mode_enabled(*, base_url: str | None, model: str | Non 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 _apply_deepseek_strict_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None: + """Return a shallow-copied tools list with ``function.strict: true`` on each function tool.""" + if not tools: + return tools + out: list[dict[str, Any]] = [] + for raw in tools: + if not isinstance(raw, dict): + out.append(raw) # type: ignore[arg-type] + continue + if str(raw.get("type") or "").strip().lower() != "function": + out.append(dict(raw)) + continue + t = dict(raw) + fn = t.get("function") + if isinstance(fn, dict): + fn2 = dict(fn) + fn2["strict"] = True + t["function"] = fn2 + else: + t["function"] = {"strict": True} + out.append(t) + return out def _should_proactively_downgrade_multimodal_messages( @@ -556,9 +567,10 @@ 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 - ) + if use_tools and _deepseek_strict_tools_enabled(base_url=self.base_url, model=self.model): + tw = kwargs.get("tools") + if isinstance(tw, list): + kwargs["tools"] = _apply_deepseek_strict_tools(tw) try: return self._client.chat.completions.create(**kwargs) except Exception as exc: @@ -712,8 +724,8 @@ __all__ = [ "OpenAIChatModel", "_likely_gemini_openai_compat_base_url", "_model_id_suggests_gemini", - "_deepseek_strict_tool_mode_enabled", - "_merge_deepseek_strict_tool_mode_extra_body", + "_deepseek_strict_tools_enabled", + "_apply_deepseek_strict_tools", ] diff --git a/tests/test_openai_chat_strict_tool_mode.py b/tests/test_openai_chat_strict_tool_mode.py index 03c1dba7..62356df3 100644 --- a/tests/test_openai_chat_strict_tool_mode.py +++ b/tests/test_openai_chat_strict_tool_mode.py @@ -1,4 +1,4 @@ -"""DeepSeek OpenAI-compat: strict_tool_mode via extra_body.""" +"""DeepSeek: per-function ``strict: true`` on tools (official Tool Calls / strict Beta).""" from __future__ import annotations @@ -6,15 +6,15 @@ import os import unittest from svc.llm.transports.openai_chat_completions import ( - _deepseek_strict_tool_mode_enabled, - _merge_deepseek_strict_tool_mode_extra_body, + _apply_deepseek_strict_tools, + _deepseek_strict_tools_enabled, ) -class DeepseekStrictToolModeTests(unittest.TestCase): +class DeepseekStrictToolsTests(unittest.TestCase): def test_enabled_when_deepseek_in_base_url(self) -> None: self.assertTrue( - _deepseek_strict_tool_mode_enabled( + _deepseek_strict_tools_enabled( base_url="https://api.deepseek.com/v1", model="deepseek-chat", ) @@ -25,7 +25,7 @@ class DeepseekStrictToolModeTests(unittest.TestCase): try: os.environ["AIA_DEEPSEEK_STRICT_TOOL_MODE"] = "0" self.assertFalse( - _deepseek_strict_tool_mode_enabled( + _deepseek_strict_tools_enabled( base_url="https://api.deepseek.com/v1", model="x", ) @@ -36,54 +36,28 @@ class DeepseekStrictToolModeTests(unittest.TestCase): 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_apply_sets_strict_on_function_tools(self) -> None: + tools = [ + {"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}}}, + {"type": "not_function", "x": 1}, + ] + out = _apply_deepseek_strict_tools(tools) + self.assertIsNotNone(out) + assert out is not None + self.assertTrue(out[0]["function"]["strict"]) + self.assertEqual(out[1].get("type"), "not_function") + self.assertNotIn("strict", out[1]) + # input not mutated + self.assertNotIn("strict", tools[0].get("function", {})) - 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_apply_skips_non_deepseek_not_tested_here(self) -> None: + out = _apply_deepseek_strict_tools([]) + self.assertEqual(out, []) - 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) + def test_apply_handles_missing_function_dict(self) -> None: + out = _apply_deepseek_strict_tools([{"type": "function"}]) + assert out is not None + self.assertEqual(out[0]["function"], {"strict": True}) if __name__ == "__main__":