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