diff --git a/litellm/llms/hosted_vllm/chat/transformation.py b/litellm/llms/hosted_vllm/chat/transformation.py index 71d27908521..bf9e5af00e0 100644 --- a/litellm/llms/hosted_vllm/chat/transformation.py +++ b/litellm/llms/hosted_vllm/chat/transformation.py @@ -3,9 +3,11 @@ Translate from OpenAI's `/v1/chat/completions` to VLLM's `/v1/chat/completions` """ import json -from collections.abc import Coroutine +from collections.abc import Coroutine, Mapping from typing import Final, Literal, cast, overload +from typing_extensions import TypeIs # noqa: TID251 # TypeIs reaches typing only on 3.13 + from litellm.litellm_core_utils.prompt_templates.common_utils import ( get_image_mime_type_from_url, ) @@ -26,6 +28,17 @@ from litellm.types.llms.openai import ( from ...openai.chat.gpt_transformation import OpenAIGPTConfig +def _is_str_keyed_mapping(value: object) -> TypeIs[Mapping[str, object]]: # guard-ok: JSON tool objects have str keys + return isinstance(value, Mapping) + + +def _strict_only_when_true(tool: Mapping[str, object]) -> Mapping[str, object]: + function: Final = tool.get("function") + if not _is_str_keyed_mapping(function) or function.get("strict", True) is True: + return tool + return {**tool, "function": {key: value for key, value in function.items() if key != "strict"}} + + class HostedVLLMChatConfig(OpenAIGPTConfig): def _convert_custom_tools_to_function_tools(self, tools: list[dict[str, object]]) -> list[dict[str, object]]: """ @@ -88,7 +101,9 @@ class HostedVLLMChatConfig(OpenAIGPTConfig): tools: Final = non_default_params.pop("tools", None) if tools is not None: non_default_params["tools"] = ( - self._convert_custom_tools_to_function_tools(tools) if isinstance(tools, list) else tools + [_strict_only_when_true(tool) for tool in self._convert_custom_tools_to_function_tools(tools)] + if isinstance(tools, list) + else tools ) thinking: Final = non_default_params.pop("thinking", None) diff --git a/litellm/mcp_client/tools.py b/litellm/mcp_client/tools.py index 11aef794c4b..4f204b3629e 100644 --- a/litellm/mcp_client/tools.py +++ b/litellm/mcp_client/tools.py @@ -37,6 +37,7 @@ def transform_mcp_tool_to_openai_tool(mcp_tool: MCPTool) -> ChatCompletionToolPa name=mcp_tool.name, description=mcp_tool.description or "", parameters=normalized_parameters, + strict=False, ), ) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 6512314d0e5..bc92bc9cb3a 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -2708,7 +2708,7 @@ class LiteLLMCompletionResponsesConfig: return convert_custom_tool_to_function_tool({**namespace_tool, "description": description}) raw_parameters: Final = namespace_tool.get("parameters") - parameters: Final[Mapping[str, object]] = ( + parameters: Final = ( MappingProxyType(raw_parameters) if isinstance(raw_parameters, Mapping) else MappingProxyType({}) ) normalized_parameters: Final = ( @@ -2716,16 +2716,11 @@ class LiteLLMCompletionResponsesConfig: ) tool_name: Final = str(namespace_tool.get("name") or "") chat_tool_name: Final = f"{namespace}__{tool_name}" if nested else tool_name - chat_parameters: Final = dict(normalized_parameters) - caller_strict: Final = namespace_tool.get("strict") - function: Final = ( - ChatCompletionToolParamFunctionChunk( - name=chat_tool_name, description=description, parameters=chat_parameters, strict=caller_strict - ) - if isinstance(caller_strict, bool) - else ChatCompletionToolParamFunctionChunk( - name=chat_tool_name, description=description, parameters=chat_parameters - ) + function: Final = ChatCompletionToolParamFunctionChunk( + name=chat_tool_name, + description=description, + parameters=dict(normalized_parameters), + strict=bool(namespace_tool.get("strict", False)), ) return function_chat_tool( function, @@ -2803,14 +2798,13 @@ class LiteLLMCompletionResponsesConfig: typed_tool: Final = cast(FunctionToolParam, tool) raw_parameters: Final = typed_tool.get("parameters", {}) or {} parameters: Final = {**raw_parameters} if "type" in raw_parameters else {**raw_parameters, "type": "object"} - caller_strict: Final = typed_tool.get("strict") chat_completion_tool: Final[dict[str, object]] = { "type": "function", "function": { "name": typed_tool.get("name") or "", "description": typed_tool.get("description") or "", "parameters": parameters, - **({"strict": caller_strict} if isinstance(caller_strict, bool) else {}), + "strict": typed_tool.get("strict", False) or False, }, } if tool.get("cache_control"): diff --git a/tests/unit/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py b/tests/unit/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py index 9112d6ee30e..65245c0f192 100644 --- a/tests/unit/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py +++ b/tests/unit/llms/hosted_vllm/chat/test_hosted_vllm_chat_transformation.py @@ -402,7 +402,7 @@ def test_hosted_vllm_custom_tools_use_top_level_input_schema(): assert tools[0]["function"]["parameters"] == input_schema -def _strict_tools(strict: bool = True) -> list[dict[str, object]]: +def _strict_tools(strict: object = True) -> list[dict[str, object]]: return [ { "type": "function", @@ -428,10 +428,9 @@ def _strict_tools(strict: bool = True) -> list[dict[str, object]]: ] -@pytest.mark.parametrize("strict", [True, False]) @pytest.mark.parametrize("drop_params", [True, False]) -def test_hosted_vllm_forwards_strict_tool_schema_as_sent(drop_params: bool, strict: bool) -> None: - tools: Final = _strict_tools(strict) +def test_hosted_vllm_forwards_strict_tool_schema_as_sent(drop_params: bool) -> None: + tools: Final = _strict_tools() sent: Final = copy.deepcopy(tools) optional_params: Final = get_optional_params( @@ -446,6 +445,26 @@ def test_hosted_vllm_forwards_strict_tool_schema_as_sent(drop_params: bool, stri assert tools == sent +@pytest.mark.parametrize("unmarked_strict", [False, None, "true", 1]) +def test_hosted_vllm_drops_a_strict_that_is_not_true(unmarked_strict: object) -> None: + tools: Final = [*_strict_tools(), *_strict_tools(unmarked_strict)] + sent: Final = copy.deepcopy(tools) + + optional_params: Final = get_optional_params( + model="my-vllm-model", + custom_llm_provider="hosted_vllm", + tools=tools, + tool_choice="required", + ) + + strict_tool, unmarked_tool = _strict_tools()[0], _strict_tools(unmarked_strict)[0] + assert optional_params["tools"] == [ + strict_tool, + {**unmarked_tool, "function": {k: v for k, v in unmarked_tool["function"].items() if k != "strict"}}, + ] + assert tools == sent + + def test_hosted_vllm_additional_drop_params_removes_only_the_named_tool_fields() -> None: tools: Final = _strict_tools() sent: Final = copy.deepcopy(tools) diff --git a/tests/unit/mcp_client/test_tools.py b/tests/unit/mcp_client/test_tools.py index 849df68b7f1..ca3cf29bd90 100644 --- a/tests/unit/mcp_client/test_tools.py +++ b/tests/unit/mcp_client/test_tools.py @@ -73,7 +73,6 @@ def test_transform_mcp_tool_to_openai_tool(mock_mcp_tool): "properties": {"test": {"type": "string"}}, "additionalProperties": False, } - assert "strict" not in openai_tool["function"] def testtransform_openai_tool_call_request_to_mcp_tool_call_request(mock_mcp_tool): diff --git a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 0d9adda3499..3fd4be6f722 100644 --- a/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/unit/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -2001,29 +2001,6 @@ class TestToolTransformation: assert [tool["function"]["name"] for tool in not_deferred] == ["finance__get_stock_price", "get_weather"] assert all("defer_loading" not in tool for tool in not_deferred) - def test_function_tools_carry_strict_only_when_the_caller_set_it(self) -> None: - strict_variants: Final = {"unset": {}, "null": {"strict": None}, "on": {"strict": True}, "off": {"strict": False}} - function_tools: Final = [ - {"type": "function", "name": name, "parameters": {"type": "object"}, **strict} - for name, strict in strict_variants.items() - ] - - chat_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=[*function_tools, {"type": "namespace", "name": "ns", "tools": function_tools}] - ) - - sent_strict: Final = {tool["function"]["name"]: tool["function"].get("strict", "absent") for tool in chat_tools} - assert sent_strict == { - "unset": "absent", - "null": "absent", - "on": True, - "off": False, - "ns__unset": "absent", - "ns__null": "absent", - "ns__on": True, - "ns__off": False, - } - @pytest.mark.parametrize("global_drop_params", [False, True]) def test_malformed_tool_flags_answer_400_unless_drop_params_drops_them( self, monkeypatch: pytest.MonkeyPatch, global_drop_params: bool