diff --git a/svc/llm/transports/openai_chat_completions.py b/svc/llm/transports/openai_chat_completions.py index 1b22b958..5733bfbe 100644 --- a/svc/llm/transports/openai_chat_completions.py +++ b/svc/llm/transports/openai_chat_completions.py @@ -109,22 +109,28 @@ def _messages_contain_list_with_image(messages: list[dict[str, Any]]) -> bool: return False -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. +def _openai_tool_function_strict_enabled() -> bool: + """Whether to set ``strict: true`` on each ``tools[].function`` for OpenAI-style chat completions. - 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``. + Default **on** for every provider/model: many gateways ignore unknown ``function`` keys; some + reject them—use ``AIA_TOOL_FUNCTION_STRICT=0`` to disable. + + ``AIA_DEEPSEEK_STRICT_TOOL_MODE=0`` is still honored as a legacy global opt-out when + ``AIA_TOOL_FUNCTION_STRICT`` is unset. DeepSeek strict mode details: + https://api-docs.deepseek.com/zh-cn/guides/tool_calls """ - raw = str(os.getenv("AIA_DEEPSEEK_STRICT_TOOL_MODE") or "").strip().lower() - if raw in ("0", "false", "no", "off"): + primary = str(os.getenv("AIA_TOOL_FUNCTION_STRICT") or "").strip().lower() + if primary in ("0", "false", "no", "off"): return False - hay = f"{base_url or ''} {model or ''}".lower() - return "deepseek" in hay + if primary in ("1", "true", "yes", "on"): + return True + legacy = str(os.getenv("AIA_DEEPSEEK_STRICT_TOOL_MODE") or "").strip().lower() + if legacy in ("0", "false", "no", "off"): + return False + return True -def _apply_deepseek_strict_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None: +def _apply_strict_flag_to_function_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 @@ -567,10 +573,10 @@ class OpenAIChatModel(ChatModel): kwargs["tools"] = plan.tools_wired except Exception: kwargs["tools"] = complete_openai_tools_wire_parameters(tools) - if use_tools and _deepseek_strict_tools_enabled(base_url=self.base_url, model=self.model): + if use_tools and _openai_tool_function_strict_enabled(): tw = kwargs.get("tools") if isinstance(tw, list): - kwargs["tools"] = _apply_deepseek_strict_tools(tw) + kwargs["tools"] = _apply_strict_flag_to_function_tools(tw) try: return self._client.chat.completions.create(**kwargs) except Exception as exc: @@ -724,8 +730,8 @@ __all__ = [ "OpenAIChatModel", "_likely_gemini_openai_compat_base_url", "_model_id_suggests_gemini", - "_deepseek_strict_tools_enabled", - "_apply_deepseek_strict_tools", + "_openai_tool_function_strict_enabled", + "_apply_strict_flag_to_function_tools", ] diff --git a/tests/test_openai_chat_strict_tool_mode.py b/tests/test_openai_chat_strict_tool_mode.py index 62356df3..875d2d0b 100644 --- a/tests/test_openai_chat_strict_tool_mode.py +++ b/tests/test_openai_chat_strict_tool_mode.py @@ -1,4 +1,4 @@ -"""DeepSeek: per-function ``strict: true`` on tools (official Tool Calls / strict Beta).""" +"""OpenAI-style tools: optional ``function.strict: true`` (default on; env to disable).""" from __future__ import annotations @@ -6,56 +6,83 @@ import os import unittest from svc.llm.transports.openai_chat_completions import ( - _apply_deepseek_strict_tools, - _deepseek_strict_tools_enabled, + _apply_strict_flag_to_function_tools, + _openai_tool_function_strict_enabled, ) -class DeepseekStrictToolsTests(unittest.TestCase): - def test_enabled_when_deepseek_in_base_url(self) -> None: - self.assertTrue( - _deepseek_strict_tools_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") +class OpenAIToolFunctionStrictTests(unittest.TestCase): + def test_enabled_by_default(self) -> None: + prev_t = os.environ.pop("AIA_TOOL_FUNCTION_STRICT", None) + prev_d = os.environ.pop("AIA_DEEPSEEK_STRICT_TOOL_MODE", None) try: - os.environ["AIA_DEEPSEEK_STRICT_TOOL_MODE"] = "0" - self.assertFalse( - _deepseek_strict_tools_enabled( - base_url="https://api.deepseek.com/v1", - model="x", - ) - ) + self.assertTrue(_openai_tool_function_strict_enabled()) + finally: + if prev_t is not None: + os.environ["AIA_TOOL_FUNCTION_STRICT"] = prev_t + if prev_d is not None: + os.environ["AIA_DEEPSEEK_STRICT_TOOL_MODE"] = prev_d + + def test_disabled_when_tool_function_strict_off(self) -> None: + prev = os.environ.get("AIA_TOOL_FUNCTION_STRICT") + try: + os.environ["AIA_TOOL_FUNCTION_STRICT"] = "0" + self.assertFalse(_openai_tool_function_strict_enabled()) finally: if prev is None: + os.environ.pop("AIA_TOOL_FUNCTION_STRICT", None) + else: + os.environ["AIA_TOOL_FUNCTION_STRICT"] = prev + + def test_disabled_legacy_deepseek_env_when_primary_unset(self) -> None: + prev_t = os.environ.pop("AIA_TOOL_FUNCTION_STRICT", None) + prev_d = os.environ.get("AIA_DEEPSEEK_STRICT_TOOL_MODE") + try: + os.environ["AIA_DEEPSEEK_STRICT_TOOL_MODE"] = "0" + self.assertFalse(_openai_tool_function_strict_enabled()) + finally: + if prev_t is not None: + os.environ["AIA_TOOL_FUNCTION_STRICT"] = prev_t + if prev_d is None: os.environ.pop("AIA_DEEPSEEK_STRICT_TOOL_MODE", None) else: - os.environ["AIA_DEEPSEEK_STRICT_TOOL_MODE"] = prev + os.environ["AIA_DEEPSEEK_STRICT_TOOL_MODE"] = prev_d + + def test_primary_env_overrides_legacy_off(self) -> None: + prev_t = os.environ.get("AIA_TOOL_FUNCTION_STRICT") + prev_d = os.environ.get("AIA_DEEPSEEK_STRICT_TOOL_MODE") + try: + os.environ["AIA_DEEPSEEK_STRICT_TOOL_MODE"] = "0" + os.environ["AIA_TOOL_FUNCTION_STRICT"] = "1" + self.assertTrue(_openai_tool_function_strict_enabled()) + finally: + if prev_t is None: + os.environ.pop("AIA_TOOL_FUNCTION_STRICT", None) + else: + os.environ["AIA_TOOL_FUNCTION_STRICT"] = prev_t + if prev_d is None: + os.environ.pop("AIA_DEEPSEEK_STRICT_TOOL_MODE", None) + else: + os.environ["AIA_DEEPSEEK_STRICT_TOOL_MODE"] = prev_d 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) + out = _apply_strict_flag_to_function_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_apply_skips_non_deepseek_not_tested_here(self) -> None: - out = _apply_deepseek_strict_tools([]) - self.assertEqual(out, []) + def test_apply_empty(self) -> None: + self.assertEqual(_apply_strict_flag_to_function_tools([]), []) def test_apply_handles_missing_function_dict(self) -> None: - out = _apply_deepseek_strict_tools([{"type": "function"}]) + out = _apply_strict_flag_to_function_tools([{"type": "function"}]) assert out is not None self.assertEqual(out[0]["function"], {"strict": True})