mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
2bc9bb4a9d
commit
4c49d03732
5 changed files with 114 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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": {}}}]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue