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:
Tai An 2026-05-08 15:17:18 -07:00
parent 144279eb57
commit 6bfb0360d6
3 changed files with 108 additions and 2 deletions

View file

@ -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_"):

View file

@ -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"

View file

@ -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