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
This commit is contained in:
Scott Wilson 2026-08-14 14:56:06 -04:00
parent 2bc9bb4a9d
commit 4c49d03732
5 changed files with 114 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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": {}}}]