From 7011724a426fb1971683d59cd0add9c76605431d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:39:51 -0700 Subject: [PATCH 1/5] fix(hosted_vllm): forward strict and additionalProperties in tool schemas as sent --- .../llms/hosted_vllm/chat/transformation.py | 14 ++-- .../test_hosted_vllm_strict_tools_wire.py | 66 +++++++++++++++ .../test_hosted_vllm_chat_transformation.py | 82 +++++++++++++++++++ tests/unit/test_utils_get_optional_params.py | 1 - 4 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 tests/integration/providers/test_hosted_vllm_strict_tools_wire.py diff --git a/litellm/llms/hosted_vllm/chat/transformation.py b/litellm/llms/hosted_vllm/chat/transformation.py index 63e6cb2b5da..71d27908521 100644 --- a/litellm/llms/hosted_vllm/chat/transformation.py +++ b/litellm/llms/hosted_vllm/chat/transformation.py @@ -23,7 +23,6 @@ from litellm.types.llms.openai import ( ChatCompletionVideoUrlObject, ) -from ....utils import remove_additional_properties, remove_strict_from_schema from ...openai.chat.gpt_transformation import OpenAIGPTConfig @@ -86,14 +85,11 @@ class HostedVLLMChatConfig(OpenAIGPTConfig): model: str, drop_params: bool, ) -> dict: - _tools = non_default_params.pop("tools", None) - if _tools is not None: - _tools = remove_additional_properties(_tools) - _tools = remove_strict_from_schema(_tools) - if isinstance(_tools, list): - _tools = self._convert_custom_tools_to_function_tools(_tools) - if _tools is not None: - non_default_params["tools"] = _tools + 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 + ) thinking: Final = non_default_params.pop("thinking", None) thinking_effort: Final = ( diff --git a/tests/integration/providers/test_hosted_vllm_strict_tools_wire.py b/tests/integration/providers/test_hosted_vllm_strict_tools_wire.py new file mode 100644 index 00000000000..7fdc89360c3 --- /dev/null +++ b/tests/integration/providers/test_hosted_vllm_strict_tools_wire.py @@ -0,0 +1,66 @@ +import uuid +from typing import Final + +import pytest +from integration._support.client import Gateway +from integration._support.openai_wire import answering_model_discovery, chat_reply +from integration._support.wire import Reply, Request, wire_server +from pydantic import JsonValue, TypeAdapter + +_BACKEND: Final = "qwen3-strict-tools" +_API_KEY: Final = "synthetic-hosted-vllm-key" +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_TOOLS: Final[list[JsonValue]] = [ + { + "type": "function", + "function": { + "name": "record_code", + "description": "Record a ten letter code", + "strict": True, + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "object", + "properties": {"value": {"type": "string", "pattern": "^Z{10}$"}}, + "required": ["value"], + "additionalProperties": False, + } + }, + "required": ["code"], + "additionalProperties": False, + }, + }, + } +] + + +@pytest.mark.parametrize("drop_params", [True, False]) +def test_hosted_vllm_strict_tool_schema_reaches_the_wire_as_sent(gateway: Gateway, drop_params: bool) -> None: + identity: Final = f"hosted-vllm-strict-tools-{uuid.uuid4().hex}" + + def respond(request: Request) -> Reply: + assert (request.method, request.target) == ("POST", "/v1/chat/completions"), request + body: Final = _JSON_OBJECT.validate_json(request.body) + assert body["tools"] == _TOOLS, body["tools"] + assert body["tool_choice"] == "auto", body + return chat_reply(identity, _BACKEND, "recorded", stream=False) + + with wire_server(answering_model_discovery(respond)) as wire, gateway.scenario() as scenario: + model: Final = scenario.model( + model=f"hosted_vllm/{_BACKEND}", api_base=wire.url + "/v1", api_key=_API_KEY, drop_params=drop_params + ) + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + { + "model": model, + "messages": [{"role": "user", "content": "Record the code ZZZZZZZZZZ."}], + "tools": _TOOLS, + "tool_choice": "auto", + "num_retries": 0, + "cache": {"no-cache": True}, + }, + ) + assert response.status_code == 200, response.text + assert _JSON_OBJECT.validate_json(response.content)["id"] == identity 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 c792d5dffcd..559bbe12aba 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 @@ -1,4 +1,6 @@ +import copy import json +from typing import Final from unittest.mock import MagicMock, patch import pytest @@ -9,6 +11,7 @@ from litellm.constants import ( DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET, ) from litellm.llms.hosted_vllm.chat.transformation import HostedVLLMChatConfig +from litellm.utils import get_optional_params def test_hosted_vllm_chat_transformation_file_url(): @@ -397,3 +400,82 @@ def test_hosted_vllm_custom_tools_use_top_level_input_schema(): assert tools[0]["function"]["name"] == "search" assert tools[0]["function"]["description"] == "Search docs" assert tools[0]["function"]["parameters"] == input_schema + + +def _strict_tools() -> list[dict[str, object]]: + return [ + { + "type": "function", + "function": { + "name": "record_code", + "description": "Record a ten letter code", + "strict": True, + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "object", + "properties": {"value": {"type": "string", "pattern": "^Z{10}$"}}, + "required": ["value"], + "additionalProperties": False, + } + }, + "required": ["code"], + "additionalProperties": False, + }, + }, + } + ] + + +@pytest.mark.parametrize("drop_params", [True, False]) +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( + model="my-vllm-model", + custom_llm_provider="hosted_vllm", + tools=tools, + tool_choice="auto", + drop_params=drop_params, + ) + + assert optional_params["tools"] == sent + 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) + + optional_params: Final = get_optional_params( + model="my-vllm-model", + custom_llm_provider="hosted_vllm", + tools=tools, + tool_choice="auto", + additional_drop_params=["tools[*].function.strict", "tools[*].function.parameters.additionalProperties"], + ) + + assert optional_params["tools"] == [ + { + "type": "function", + "function": { + "name": "record_code", + "description": "Record a ten letter code", + "parameters": { + "type": "object", + "properties": { + "code": { + "type": "object", + "properties": {"value": {"type": "string", "pattern": "^Z{10}$"}}, + "required": ["value"], + "additionalProperties": False, + } + }, + "required": ["code"], + }, + }, + } + ] + assert tools == sent diff --git a/tests/unit/test_utils_get_optional_params.py b/tests/unit/test_utils_get_optional_params.py index 13a64577efb..bfd0f1259b1 100644 --- a/tests/unit/test_utils_get_optional_params.py +++ b/tests/unit/test_utils_get_optional_params.py @@ -684,7 +684,6 @@ def test_unmapped_gemini_model_params(): @pytest.mark.parametrize( "provider, model", [ - ("hosted_vllm", "my-vllm-model"), ("gemini", "gemini-1.5-pro"), ("vertex_ai", "gemini-1.5-pro"), ], From 4ac942adf01074fb2e219926f0edeb1ba570e80a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:37:25 -0700 Subject: [PATCH 2/5] test(hosted_vllm): cover dropping a function-level additionalProperties with additional_drop_params --- .../test_hosted_vllm_chat_transformation.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 559bbe12aba..2a5bb400671 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 @@ -479,3 +479,24 @@ def test_hosted_vllm_additional_drop_params_removes_only_the_named_tool_fields() } ] assert tools == sent + + +def test_hosted_vllm_additional_drop_params_removes_function_level_additional_properties() -> None: + parameters: Final = {"type": "object", "properties": {"q": {"type": "string"}}, "required": ["q"]} + tools: Final = [ + { + "type": "function", + "function": {"name": "search", "parameters": parameters, "additionalProperties": False, "strict": True}, + } + ] + + optional_params: Final = get_optional_params( + model="my-vllm-model", + custom_llm_provider="hosted_vllm", + tools=tools, + additional_drop_params=["tools[*].function.additionalProperties"], + ) + + assert optional_params["tools"] == [ + {"type": "function", "function": {"name": "search", "parameters": parameters, "strict": True}} + ] From 3c629ce7c39968b9504e025a8491acf32747d95a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:57:02 -0700 Subject: [PATCH 3/5] fix(hosted_vllm): stop the Responses bridge and MCP tools from adding a strict the caller never set --- litellm/mcp_client/tools.py | 1 - .../transformation.py | 20 ++++++++++------ .../test_hosted_vllm_chat_transformation.py | 9 ++++---- tests/unit/mcp_client/test_tools.py | 1 + .../test_litellm_completion_responses.py | 23 +++++++++++++++++++ 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/litellm/mcp_client/tools.py b/litellm/mcp_client/tools.py index 4f204b3629e..11aef794c4b 100644 --- a/litellm/mcp_client/tools.py +++ b/litellm/mcp_client/tools.py @@ -37,7 +37,6 @@ 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 bc92bc9cb3a..6512314d0e5 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 = ( + parameters: Final[Mapping[str, object]] = ( MappingProxyType(raw_parameters) if isinstance(raw_parameters, Mapping) else MappingProxyType({}) ) normalized_parameters: Final = ( @@ -2716,11 +2716,16 @@ class LiteLLMCompletionResponsesConfig: ) tool_name: Final = str(namespace_tool.get("name") or "") chat_tool_name: Final = f"{namespace}__{tool_name}" if nested else tool_name - function: Final = ChatCompletionToolParamFunctionChunk( - name=chat_tool_name, - description=description, - parameters=dict(normalized_parameters), - strict=bool(namespace_tool.get("strict", False)), + 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 + ) ) return function_chat_tool( function, @@ -2798,13 +2803,14 @@ 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": typed_tool.get("strict", False) or False, + **({"strict": caller_strict} if isinstance(caller_strict, bool) else {}), }, } 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 2a5bb400671..9112d6ee30e 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,14 +402,14 @@ def test_hosted_vllm_custom_tools_use_top_level_input_schema(): assert tools[0]["function"]["parameters"] == input_schema -def _strict_tools() -> list[dict[str, object]]: +def _strict_tools(strict: bool = True) -> list[dict[str, object]]: return [ { "type": "function", "function": { "name": "record_code", "description": "Record a ten letter code", - "strict": True, + "strict": strict, "parameters": { "type": "object", "properties": { @@ -428,9 +428,10 @@ def _strict_tools() -> 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) -> None: - tools: Final = _strict_tools() +def test_hosted_vllm_forwards_strict_tool_schema_as_sent(drop_params: bool, strict: bool) -> None: + tools: Final = _strict_tools(strict) sent: Final = copy.deepcopy(tools) optional_params: Final = get_optional_params( diff --git a/tests/unit/mcp_client/test_tools.py b/tests/unit/mcp_client/test_tools.py index ca3cf29bd90..849df68b7f1 100644 --- a/tests/unit/mcp_client/test_tools.py +++ b/tests/unit/mcp_client/test_tools.py @@ -73,6 +73,7 @@ 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 3fd4be6f722..0d9adda3499 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,6 +2001,29 @@ 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 From 4b59c5a240cbd0d17a48bd1359374853e872fe48 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:58:22 -0700 Subject: [PATCH 4/5] fix(hosted_vllm): forward strict only when true and leave the Responses bridge and MCP tools unchanged --- .../llms/hosted_vllm/chat/transformation.py | 19 +++++++++++-- litellm/mcp_client/tools.py | 1 + .../transformation.py | 20 +++++--------- .../test_hosted_vllm_chat_transformation.py | 27 ++++++++++++++++--- tests/unit/mcp_client/test_tools.py | 1 - .../test_litellm_completion_responses.py | 23 ---------------- 6 files changed, 48 insertions(+), 43 deletions(-) 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 From 60d98cc4cc8ca23f81c264e6148fcc0cc96227e0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:28:52 -0700 Subject: [PATCH 5/5] fix(hosted_vllm): keep strict: false next to a strict tool unless tool_choice forces a call --- .../llms/hosted_vllm/chat/transformation.py | 46 ++++++-- .../test_hosted_vllm_chat_transformation.py | 107 +++++++++++++----- 2 files changed, 116 insertions(+), 37 deletions(-) diff --git a/litellm/llms/hosted_vllm/chat/transformation.py b/litellm/llms/hosted_vllm/chat/transformation.py index bf9e5af00e0..0e06f99890f 100644 --- a/litellm/llms/hosted_vllm/chat/transformation.py +++ b/litellm/llms/hosted_vllm/chat/transformation.py @@ -3,7 +3,7 @@ Translate from OpenAI's `/v1/chat/completions` to VLLM's `/v1/chat/completions` """ import json -from collections.abc import Coroutine, Mapping +from collections.abc import Coroutine, Mapping, Sequence from typing import Final, Literal, cast, overload from typing_extensions import TypeIs # noqa: TID251 # TypeIs reaches typing only on 3.13 @@ -32,13 +32,36 @@ def _is_str_keyed_mapping(value: object) -> TypeIs[Mapping[str, object]]: # gua return isinstance(value, Mapping) -def _strict_only_when_true(tool: Mapping[str, object]) -> Mapping[str, object]: +def _is_strict(tool: object) -> bool: + function: Final = tool.get("function") if _is_str_keyed_mapping(tool) else None + return _is_str_keyed_mapping(function) and function.get("strict") is True + + +def _str_keyed(value: object) -> Mapping[str, object]: + return value if _is_str_keyed_mapping(value) else {} + + +def _forces_a_tool_call(tool_choice: object) -> bool: + return tool_choice == "required" or _is_str_keyed_mapping(tool_choice) + + +def _with_forwardable_strict(tool: object, keep_strict_false: bool) -> object: + if not _is_str_keyed_mapping(tool): + return tool function: Final = tool.get("function") - if not _is_str_keyed_mapping(function) or function.get("strict", True) is True: + if not _is_str_keyed_mapping(function): + return tool + strict: Final = function.get("strict", True) + if strict is True or (keep_strict_false and strict is False): return tool return {**tool, "function": {key: value for key, value in function.items() if key != "strict"}} +def _tools_with_forwardable_strict(tools: Sequence[object], tool_choice: object) -> Sequence[object]: + keep_strict_false: Final = not _forces_a_tool_call(tool_choice) and any(_is_strict(tool) for tool in tools) + return [_with_forwardable_strict(tool, keep_strict_false) for tool in tools] + + class HostedVLLMChatConfig(OpenAIGPTConfig): def _convert_custom_tools_to_function_tools(self, tools: list[dict[str, object]]) -> list[dict[str, object]]: """ @@ -98,20 +121,23 @@ class HostedVLLMChatConfig(OpenAIGPTConfig): model: str, drop_params: bool, ) -> dict: + params: Final = _str_keyed(non_default_params) + thinking_effort: Final = ( + None + if "reasoning_effort" in params + else reasoning_effort_from_thinking(params.get("thinking"), drop_params=drop_params) + ) tools: Final = non_default_params.pop("tools", None) if tools is not None: non_default_params["tools"] = ( - [_strict_only_when_true(tool) for tool in self._convert_custom_tools_to_function_tools(tools)] + _tools_with_forwardable_strict( + self._convert_custom_tools_to_function_tools(tools), params.get("tool_choice") + ) if isinstance(tools, list) else tools ) - thinking: Final = non_default_params.pop("thinking", None) - thinking_effort: Final = ( - None - if "reasoning_effort" in non_default_params - else reasoning_effort_from_thinking(thinking, drop_params=drop_params) - ) + non_default_params.pop("thinking", None) if thinking_effort is not None: non_default_params["reasoning_effort"] = thinking_effort 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 65245c0f192..4574af98a9c 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 @@ -1,10 +1,11 @@ import copy import json +from collections.abc import Mapping from typing import Final from unittest.mock import MagicMock, patch import pytest - +from pydantic import BaseModel, ConfigDict from litellm.constants import ( DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, @@ -402,30 +403,32 @@ def test_hosted_vllm_custom_tools_use_top_level_input_schema(): assert tools[0]["function"]["parameters"] == input_schema -def _strict_tools(strict: object = True) -> list[dict[str, object]]: - return [ - { - "type": "function", - "function": { - "name": "record_code", - "description": "Record a ten letter code", - "strict": strict, - "parameters": { +def _record_code_function() -> Mapping[str, object]: + return { + "name": "record_code", + "description": "Record a ten letter code", + "parameters": { + "type": "object", + "properties": { + "code": { "type": "object", - "properties": { - "code": { - "type": "object", - "properties": {"value": {"type": "string", "pattern": "^Z{10}$"}}, - "required": ["value"], - "additionalProperties": False, - } - }, - "required": ["code"], + "properties": {"value": {"type": "string", "pattern": "^Z{10}$"}}, + "required": ["value"], "additionalProperties": False, - }, + } }, - } - ] + "required": ["code"], + "additionalProperties": False, + }, + } + + +def _strict_tools(strict: object = True) -> list[dict[str, object]]: + return [{"type": "function", "function": {**_record_code_function(), "strict": strict}}] + + +def _unmarked_tool() -> Mapping[str, object]: + return {"type": "function", "function": _record_code_function()} @pytest.mark.parametrize("drop_params", [True, False]) @@ -457,14 +460,64 @@ def test_hosted_vllm_drops_a_strict_that_is_not_true(unmarked_strict: object) -> 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 optional_params["tools"] == [*_strict_tools(), _unmarked_tool()] assert tools == sent +@pytest.mark.parametrize( + ("tool_choice", "keeps_strict_false"), + [ + (None, True), + ("auto", True), + ("required", False), + ({"type": "function", "function": {"name": "record_code"}}, False), + ], +) +def test_hosted_vllm_keeps_strict_false_beside_a_strict_tool_unless_a_call_is_forced( + tool_choice: object, keeps_strict_false: bool +) -> None: + tools: Final = [*_strict_tools(), *_strict_tools(False)] + + optional_params: Final = get_optional_params( + model="my-vllm-model", + custom_llm_provider="hosted_vllm", + tools=tools, + tool_choice=tool_choice, + ) + + expected_second: Final = _strict_tools(False)[0] if keeps_strict_false else _unmarked_tool() + assert optional_params["tools"] == [*_strict_tools(), expected_second] + + +def test_hosted_vllm_drops_strict_false_when_no_tool_is_strict() -> None: + optional_params: Final = get_optional_params( + model="my-vllm-model", + custom_llm_provider="hosted_vllm", + tools=_strict_tools(False), + tool_choice="auto", + ) + + assert optional_params["tools"] == [_unmarked_tool()] + + +class _PydanticTool(BaseModel): + model_config = ConfigDict(frozen=True) + + type: str = "function" + + +@pytest.mark.parametrize("non_dict_tool", [_PydanticTool(), None]) +def test_hosted_vllm_passes_tools_that_are_not_dicts_through(non_dict_tool: object) -> None: + optional_params: Final = get_optional_params( + model="my-vllm-model", + custom_llm_provider="hosted_vllm", + tools=[*_strict_tools(), non_dict_tool], + tool_choice="auto", + ) + + assert optional_params["tools"] == [*_strict_tools(), non_dict_tool] + + def test_hosted_vllm_additional_drop_params_removes_only_the_named_tool_fields() -> None: tools: Final = _strict_tools() sent: Final = copy.deepcopy(tools)