mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-23 00:41:40 +00:00
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
This commit is contained in:
parent
144279eb57
commit
6bfb0360d6
3 changed files with 108 additions and 2 deletions
|
|
@ -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_"):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue