From 4c49d03732dc107dc52ea7f469e848ec556fec85 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Fri, 14 Aug 2026 14:56:06 -0400 Subject: [PATCH] fix(anthropic): preserve optional Responses tool properties Translating Anthropic tools left the outbound function-tool `strict` unset, which the Responses API does not read as non-strict. OpenAI's function-calling docs say strict mode requires every field in `properties` to be marked required, and with `strict` omitted the schema gets normalized to satisfy that instead of being rejected. What users see is a tool whose `required` lists every property, so models fill optional Anthropic tool arguments with empty values. Send `strict` explicitly so an unset value stays non-strict and an explicit `strict: true` still reaches the provider On the Chat Completions adapter, `strict` was also missing from `mapped_tool_params`, so a tool-level `strict` was merged into the OpenAI function `parameters` schema (mutating the caller's `input_schema` along the way) instead of being set on the function. Map it to `function.strict` and leave it unset when the caller omits it, since Chat Completions already defaults to non-strict --- .../adapters/transformation.py | 3 + .../responses_adapters/transformation.py | 8 ++- litellm/types/llms/anthropic.py | 3 +- ...al_pass_through_adapters_transformation.py | 47 ++++++++++++++++ .../test_responses_adapters_transformation.py | 55 +++++++++++++++++++ 5 files changed, 114 insertions(+), 2 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 51f2b661421..ea0eebe0511 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -741,6 +741,7 @@ class LiteLLMAnthropicMessagesAdapter: "input_schema", "description", "cache_control", + "strict", "type", ] @@ -770,6 +771,8 @@ class LiteLLMAnthropicMessagesAdapter: function_chunk["parameters"] = tool["input_schema"] if "description" in tool: function_chunk["description"] = tool["description"] + if "strict" in tool: + function_chunk["strict"] = bool(tool["strict"]) for k, v in tool.items(): if k not in mapped_tool_params: # pass additional computer kwargs diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py index bf3f6153e7c..03e66388c91 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -231,7 +231,13 @@ class LiteLLMAnthropicToResponsesAPIAdapter: if (isinstance(tool_type, str) and tool_type.startswith("web_search")) or tool_name == "web_search": result.append({"type": "web_search_preview"}) continue - func_tool: dict[str, Any] = {"type": "function", "name": tool_name} + # Responses turns strict mode on when `strict` is omitted, silently rewriting + # `required` to every property. Anthropic tools are non-strict unless asked. + func_tool: dict[str, Any] = { + "type": "function", + "name": tool_name, + "strict": bool(tool_dict.get("strict")), + } if "description" in tool_dict: func_tool["description"] = tool_dict["description"] if "input_schema" in tool_dict: diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index 69d291eebd0..17ba78b0190 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Any, Final, Literal, TypeAlias from pydantic import BaseModel, ConfigDict -from typing_extensions import NotRequired, Required, TypedDict +from typing_extensions import NotRequired, ReadOnly, Required, TypedDict from .openai import ( ChatCompletionCachedContent, @@ -48,6 +48,7 @@ class AnthropicMessagesTool(TypedDict, total=False): name: Required[str] description: str input_schema: AnthropicInputSchema | None + strict: ReadOnly[bool] type: Literal["custom"] cache_control: dict | ChatCompletionCachedContent | None defer_loading: bool diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index fe6adade6a8..0c30d8a8322 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -3508,3 +3508,50 @@ def test_translate_anthropic_tools_to_openai_preserves_parameters_type(): params = new_tools[0]["function"]["parameters"] assert params["type"] == "object" assert new_tools[0]["type"] == "function" + + +def test_translate_anthropic_tools_to_openai_maps_strict_onto_function_not_parameters(): + """A tool-level `strict` lands on the OpenAI function, leaving the caller's `input_schema` untouched.""" + adapter = LiteLLMAnthropicMessagesAdapter() + input_schema = { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": False, + } + tools = [{"type": "custom", "name": "get_weather", "strict": True, "input_schema": input_schema}] + + new_tools, _ = adapter.translate_anthropic_tools_to_openai(tools=tools) + + function = new_tools[0]["function"] + assert function["strict"] is True + assert "strict" not in function["parameters"] + assert input_schema == { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": False, + } + + +def test_translate_anthropic_tools_to_openai_omits_unset_strict(): + """Chat Completions already defaults to non-strict, so an unset `strict` stays unset.""" + adapter = LiteLLMAnthropicMessagesAdapter() + tools = [ + { + "type": "custom", + "name": "search", + "input_schema": { + "type": "object", + "properties": {"query": {"type": "string"}, "cursor": {"type": "string"}}, + "required": ["query"], + }, + } + ] + + new_tools, _ = adapter.translate_anthropic_tools_to_openai(tools=tools) + + function = new_tools[0]["function"] + assert "strict" not in function + assert "strict" not in function["parameters"] + assert function["parameters"]["required"] == ["query"] diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py index a736ca684aa..90733dc9134 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py @@ -605,6 +605,7 @@ class TestTranslateToolsToResponsesAPI: { "type": "function", "name": "get_weather", + "strict": False, "description": "Get current weather for a city.", "parameters": { "type": "object", @@ -614,6 +615,60 @@ class TestTranslateToolsToResponsesAPI: } ] + def test_tool_with_optional_properties_stays_non_strict(self): + """Regression: an unset Anthropic `strict` must not become the Responses strict default, + which would rewrite `required` to include every optional property.""" + tools = [ + { + "name": "search", + "input_schema": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "cursor": {"type": "string"}, + }, + "required": ["query"], + "additionalProperties": False, + }, + } + ] + + result = _ADAPTER.translate_tools_to_responses_api(tools) # type: ignore[arg-type] + + assert result[0]["strict"] is False + assert result[0]["parameters"]["required"] == ["query"] + + def test_tool_forwards_explicit_strict_true(self): + """An explicit Anthropic `strict: True` still reaches Responses as True.""" + tools = [ + { + "name": "search", + "strict": True, + "input_schema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + "additionalProperties": False, + }, + } + ] + + result = _ADAPTER.translate_tools_to_responses_api(tools) # type: ignore[arg-type] + + assert result == [ + { + "type": "function", + "name": "search", + "strict": True, + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + "additionalProperties": False, + }, + } + ] + def test_tool_without_description(self): """Tool without a description omits the description key.""" tools = [{"name": "ping", "input_schema": {"type": "object", "properties": {}}}]