From 6bfb0360d6a7a1e70b2efa2a264ca2a069e01969 Mon Sep 17 00:00:00 2001 From: Tai An Date: Fri, 8 May 2026 15:17:18 -0700 Subject: [PATCH] fix(anthropic/chat): place tool 'strict' at top level (#27490) Anthropic only enforces tool-call 'strict' when set as a sibling of name/input_schema on the tool object; nested inside input_schema it is silently ignored. _map_tool_helper was forwarding the OpenAI 'strict' field through the input_schema filter (because AnthropicInputSchema's annotation included 'strict'), so callers got Anthropic API responses behaving as if strict was disabled while believing it was engaged. Extract 'strict' from the canonical OpenAI location (tool.function.strict) and as a fallback from the legacy nested location (tool.function.parameters.strict), then place it at the tool top level (_tool['strict']). Drop 'strict' from AnthropicInputSchema's TypedDict keys so the input_schema filter strips it from the schema. Adds three regression tests covering: canonical-location lift, legacy-nested lift, and absence-by-default (no strict key injected when the caller did not opt in). Fixes #27490 --- litellm/llms/anthropic/chat/transformation.py | 13 +++ litellm/types/llms/anthropic.py | 4 +- .../test_anthropic_chat_transformation.py | 93 +++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 2f11a3fccb5..46497f23410 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -653,6 +653,16 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): }, ) + # Extract `strict` so it can be placed at the tool top level. + # Anthropic only enforces `strict` when set as a sibling of + # `name`/`input_schema`; nested inside `input_schema` it is + # silently ignored. Accept it from the canonical OpenAI location + # (`tool.function.strict`) and, as a fallback, from the legacy + # nested location (`tool.function.parameters.strict`). + _strict: Optional[bool] = tool["function"].get("strict") + if _strict is None and isinstance(_input_schema, dict): + _strict = _input_schema.get("strict") + # Anthropic requires input_schema.type to be "object". Normalize # schemas from external sources (MCP servers, OpenAI callers) that # may omit the type field or use a non-object type. @@ -686,6 +696,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): if _description is not None: _tool["description"] = _description + if _strict is not None: + _tool["strict"] = _strict + returned_tool = _tool elif tool["type"].startswith("computer_"): diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index 1c4d31d21ad..5d9abf65305 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -25,7 +25,6 @@ AnthropicInputSchema = TypedDict( "additionalProperties": Optional[bool], "required": Optional[List[str]], "$defs": Optional[Dict], - "strict": Optional[bool], }, total=False, ) @@ -47,6 +46,7 @@ class AnthropicMessagesTool(TypedDict, total=False): description: str input_schema: Optional[AnthropicInputSchema] type: Literal["custom"] + strict: Optional[bool] cache_control: Optional[Union[dict, ChatCompletionCachedContent]] defer_loading: bool allowed_callers: Optional[List[str]] @@ -686,4 +686,4 @@ ANTHROPIC_EFFORT_BETA_HEADER = "effort-2025-11-24" ANTHROPIC_OAUTH_TOKEN_PREFIX = "sk-ant-oat" ANTHROPIC_OAUTH_BETA_HEADER = "oauth-2025-04-20" -ANTHROPIC_PROMPT_CACHING_SCOPE_BETA_HEADER = "prompt-caching-scope-2026-01-05" +ANTHROPIC_PROMPT_CACHING_SCOPE_BETA_HEADER = "prompt-caching-scope-2026-01-05" \ No newline at end of file diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index e38698c9100..003bdbc6c8f 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -3692,6 +3692,99 @@ def test_map_tool_helper_empty_parameters_get_default(): assert result["input_schema"].get("properties") == {} +def test_map_tool_helper_lifts_strict_from_function_to_tool_top_level(): + """ + Anthropic only enforces `strict` when set as a sibling of name/input_schema + on the tool object; nested inside `input_schema` it is silently ignored. + + When a caller passes `strict: true` at the canonical OpenAI location + (`tool.function.strict`), `_map_tool_helper` must place it on the tool + top level and NOT inside `input_schema`. + + Regression test for: tools[i].function.strict ending up nested under + input_schema, causing Anthropic to silently drop strict-mode enforcement. + """ + config = AnthropicConfig() + + tool = { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": False, + }, + "strict": True, + }, + } + + result, _ = config._map_tool_helper(tool) + assert result is not None + assert result.get("strict") is True, ( + "strict should be lifted to the tool top level so Anthropic enforces it" + ) + assert "strict" not in result["input_schema"], ( + "strict must not be nested inside input_schema (Anthropic ignores it there)" + ) + + +def test_map_tool_helper_lifts_strict_from_legacy_nested_parameters(): + """ + Legacy fallback: some callers and earlier code paths placed `strict` inside + `tool.function.parameters` (the raw input_schema). Anthropic silently + ignores `strict` in that location, so `_map_tool_helper` must also lift it + to the tool top level when found there, and strip it from input_schema. + """ + config = AnthropicConfig() + + tool = { + "type": "function", + "function": { + "name": "search", + "parameters": { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + "additionalProperties": False, + "strict": True, + }, + }, + } + + result, _ = config._map_tool_helper(tool) + assert result is not None + assert result.get("strict") is True + assert "strict" not in result["input_schema"] + + +def test_map_tool_helper_omits_strict_when_caller_did_not_set_it(): + """ + When no caller-supplied `strict` is present (neither at function level nor + nested in parameters), the resulting tool must NOT have a strict key — we + don't want to inject `strict: false`/`null` and silently change behavior + for callers who never asked for strict mode. + """ + config = AnthropicConfig() + + tool = { + "type": "function", + "function": { + "name": "ping", + "parameters": {"type": "object", "properties": {}}, + }, + } + + result, _ = config._map_tool_helper(tool) + assert result is not None + assert "strict" not in result, ( + "strict key should be absent unless the caller opted in" + ) + assert "strict" not in result["input_schema"] + + def test_extract_response_content_thinking_block_null_thinking(): """ Test that thinking blocks are not dropped when the 'thinking' field is null