Merge pull request #41871 from BerriAI/litellm_bedrock_eager_input_streaming

feat: honor eager_input_streaming on Bedrock and Anthropic Claude tools
This commit is contained in:
Mateo Wang 2026-09-18 16:05:10 -07:00 committed by GitHub
commit 3490754e65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 570 additions and 55 deletions

View file

@ -94,6 +94,7 @@ from litellm.utils import (
from ..common_utils import (
AnthropicError,
AnthropicModelInfo,
eager_input_streaming_flag,
process_anthropic_headers,
strip_advisor_blocks_from_messages,
)
@ -732,10 +733,20 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
input_anthropic_schema: Final = sanitize_input_schema_for_anthropic(_input_schema)
_tool: Final = AnthropicMessagesTool(
name=tool["function"]["name"],
input_schema=input_anthropic_schema,
type="custom",
_eager_input_streaming: Final = eager_input_streaming_flag(tool)
_tool: Final = (
AnthropicMessagesTool(
name=tool["function"]["name"],
input_schema=input_anthropic_schema,
type="custom",
)
if _eager_input_streaming is None
else AnthropicMessagesTool(
name=tool["function"]["name"],
input_schema=input_anthropic_schema,
type="custom",
eager_input_streaming=_eager_input_streaming,
)
)
_description: Final = tool["function"].get("description")

View file

@ -10,7 +10,7 @@ from types import MappingProxyType
from typing import Any, Final, Literal
import httpx
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
from pydantic import BaseModel, ConfigDict, StrictBool, TypeAdapter, ValidationError
import litellm
from litellm.constants import (
@ -19,6 +19,7 @@ from litellm.constants import (
DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET,
)
from litellm.exceptions import UnsupportedParamsError
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_file_ids_from_messages,
is_encrypted_reasoning_block,
@ -231,6 +232,27 @@ def optionally_handle_anthropic_oauth(headers: dict, api_key: str | None) -> tup
return headers, api_key
class _EagerInputStreamingFunction(BaseModel):
eager_input_streaming: StrictBool | None = None
class _EagerInputStreamingTool(BaseModel):
eager_input_streaming: StrictBool | None = None
function: _EagerInputStreamingFunction | None = None
def eager_input_streaming_flag(tool: object) -> bool | None:
try:
parsed: Final = _EagerInputStreamingTool.model_validate(tool)
except ValidationError as error:
if isinstance(tool, Mapping):
raise UnsupportedParamsError(message="eager_input_streaming must be a boolean") from error
return None
if parsed.eager_input_streaming is not None:
return parsed.eager_input_streaming
return parsed.function.eager_input_streaming if parsed.function is not None else None
class AnthropicError(BaseLLMException):
def __init__(
self,
@ -373,6 +395,9 @@ class AnthropicModelInfo(BaseLLMModelInfo):
return False
def is_eager_input_streaming_used(self, tools: Sequence[object] | None) -> bool:
return any(eager_input_streaming_flag(tool) is True for tool in tools or ())
@staticmethod
def _supports_sampling_params(model: str) -> bool:
"""Claude 4.7+ (Opus 4.7/4.8, Fable 5) removed sampling params: the API

View file

@ -111,6 +111,7 @@ from litellm.litellm_core_utils.reasoning_effort_utils import (
reasoning_effort_from_thinking_budget,
)
from litellm.llms.anthropic.common_utils import (
eager_input_streaming_flag,
is_empty_unsigned_thinking_block,
normalize_anthropic_tool_use_id,
strip_encrypted_reasoning_blocks_from_anthropic_messages,
@ -197,6 +198,15 @@ def target_supports_mid_conversation_system(model: str | None, custom_llm_provid
return supports_mid_conversation_system(model=model, custom_llm_provider=custom_llm_provider)
def _chat_tool_param(function_chunk: ChatCompletionToolParamFunctionChunk, tool: object) -> ChatCompletionToolParam:
eager_input_streaming: Final = eager_input_streaming_flag(tool)
if eager_input_streaming is None:
return ChatCompletionToolParam(type="function", function=function_chunk)
return ChatCompletionToolParam(
type="function", function=function_chunk, eager_input_streaming=eager_input_streaming
)
class AnthropicAdapter:
def __init__(self) -> None:
pass
@ -770,6 +780,7 @@ class LiteLLMAnthropicMessagesAdapter:
"cache_control",
"strict",
"type",
"eager_input_streaming",
]
for idx, tool in enumerate(tools):
@ -808,7 +819,7 @@ class LiteLLMAnthropicMessagesAdapter:
for k, v in tool.items():
if k not in mapped_tool_params: # pass additional computer kwargs
function_chunk.setdefault("parameters", {}).update({k: v})
tool_param = ChatCompletionToolParam(type="function", function=function_chunk)
tool_param = _chat_tool_param(function_chunk, tool)
self._add_cache_control_if_applicable(tool, tool_param, model)
new_tools.append(tool_param)

View file

@ -48,6 +48,7 @@ from litellm.llms.bedrock.request_metadata import (
merge_bedrock_invoke_headers,
resolve_bedrock_request_metadata,
)
from litellm.types.llms.anthropic import ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER
from litellm.types.llms.bedrock import *
from litellm.types.llms.openai import (
AllMessageValues,
@ -1517,12 +1518,6 @@ class AmazonConverseConfig(BaseConfig):
"""Process tools and collect anthropic_beta values."""
bedrock_tools: list[ToolBlock] = []
# Collect anthropic_beta values from user headers
anthropic_beta_list: Final = []
if headers:
user_betas: Final = get_anthropic_beta_from_headers(headers)
anthropic_beta_list.extend(user_betas)
# Separate pre-formatted Bedrock tools (e.g. systemTool from web_search_options)
# from OpenAI-format tools that need transformation via _bedrock_tools_pt
filtered_tools: Final = []
@ -1542,6 +1537,17 @@ class AmazonConverseConfig(BaseConfig):
continue
filtered_tools.append(tool)
base_model: Final = BedrockModelInfo.get_base_model(model)
client_beta_list: Final = get_anthropic_beta_from_headers(headers or {})
eager_beta: Final = (
(ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER,)
if base_model.startswith("anthropic")
and AnthropicModelInfo().is_eager_input_streaming_used(filtered_tools)
and ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER not in client_beta_list
else ()
)
anthropic_beta_list: Final = [*client_beta_list, *eager_beta]
# Only separate tools if computer use tools are actually present
if filtered_tools and self.is_computer_use_tool_used(filtered_tools, model):
# Separate computer use tools from regular function tools
@ -1619,7 +1625,6 @@ class AmazonConverseConfig(BaseConfig):
# Opus 4.5 gates ``output_config.effort`` behind a beta header;
# Claude 4.6/4.7 accept it without one.
base_model: Final = BedrockModelInfo.get_base_model(model)
if base_model.startswith("anthropic"):
output_config: Final = additional_request_params.get("output_config")
if (

View file

@ -24,8 +24,12 @@ from litellm.llms.bedrock.common_utils import (
normalize_custom_field_on_tools,
normalize_tool_input_schema_types_for_bedrock_invoke,
strip_unsupported_bedrock_invoke_output_config_keys,
tools_without_eager_input_streaming,
)
from litellm.types.llms.anthropic import (
ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER,
ANTHROPIC_TOOL_SEARCH_BETA_HEADER,
)
from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_BETA_HEADER
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponse
@ -237,6 +241,9 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
# Hoist `custom.defer_loading` then drop `custom` (Bedrock doesn't support it)
normalize_custom_field_on_tools(anthropic_request)
normalize_tool_input_schema_types_for_bedrock_invoke(anthropic_request)
outbound_tools: Final = tools_without_eager_input_streaming(anthropic_request)
if outbound_tools is not None:
anthropic_request["tools"] = outbound_tools
return anthropic_request
def _compute_bedrock_invoke_beta_headers(
@ -269,6 +276,9 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
if bedrock_supports_tool_search(model):
beta_set.add("tool-search-tool-2025-10-19")
if self.is_eager_input_streaming_used(tools):
beta_set.add(ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER)
auto_beta_list: Final = filter_and_transform_beta_headers(
beta_headers=list(beta_set - user_beta_set),
provider="bedrock",

View file

@ -9,7 +9,7 @@ import functools
import json
import os
import re
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict
if TYPE_CHECKING:
@ -18,6 +18,7 @@ if TYPE_CHECKING:
from litellm.types.llms.bedrock import BedrockCreateBatchRequest
import httpx
from pydantic import TypeAdapter, ValidationError
import litellm
from litellm import verbose_logger
@ -330,6 +331,17 @@ def normalize_custom_field_on_tools(request_body: dict) -> None:
tool["defer_loading"] = deferred
_TOOL_DICTS_ADAPTER: Final = TypeAdapter(tuple[Mapping[str, object], ...])
def tools_without_eager_input_streaming(request_body: Mapping[str, object]) -> Sequence[object] | None:
try:
tools: Final = _TOOL_DICTS_ADAPTER.validate_python(request_body.get("tools"))
except ValidationError:
return None
return [{key: value for key, value in tool.items() if key != "eager_input_streaming"} for tool in tools]
def normalize_json_schema_custom_types_to_object(schema: dict) -> None:
"""
In-place: replace JSON Schema ``type: \"custom\"`` with ``\"object\"`` (iterative walk).

View file

@ -39,6 +39,7 @@ from litellm.llms.bedrock.common_utils import (
normalize_custom_field_on_tools,
normalize_tool_input_schema_types_for_bedrock_invoke,
strip_unsupported_bedrock_invoke_output_config_keys,
tools_without_eager_input_streaming,
)
from litellm.llms.bedrock.request_metadata import (
bedrock_request_metadata_headers,
@ -46,6 +47,7 @@ from litellm.llms.bedrock.request_metadata import (
)
from litellm.types.llms.anthropic import (
ANTHROPIC_BETA_HEADER_VALUES,
ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER,
ANTHROPIC_TOOL_SEARCH_BETA_HEADER,
)
from litellm.types.llms.bedrock import BedrockInvokeAnthropicMessagesRequest
@ -525,6 +527,9 @@ class AmazonAnthropicClaudeMessagesConfig(
if injected_thinking_for_clear_thinking:
beta_set.add("interleaved-thinking-2025-05-14")
if anthropic_model_info.is_eager_input_streaming_used(tools):
beta_set.add(ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER)
self._filter_context_management_for_bedrock_invoke(
anthropic_messages_request=anthropic_messages_request,
beta_set=beta_set,
@ -719,6 +724,10 @@ class AmazonAnthropicClaudeMessagesConfig(
if filtered_betas:
anthropic_messages_request["anthropic_beta"] = filtered_betas
outbound_tools: Final = tools_without_eager_input_streaming(anthropic_messages_request)
if outbound_tools is not None:
anthropic_messages_request["tools"] = outbound_tools
remaining_output_config: Final = anthropic_messages_request.get("output_config")
if (
litellm.drop_params is True

View file

@ -1146,37 +1146,35 @@ def responses_api_bridge_check(
return model_info, model
def _should_allow_input_examples(custom_llm_provider: str | None, model: str) -> bool:
_ANTHROPIC_ONLY_TOOL_KEYS: Final = frozenset({"input_examples", "eager_input_streaming"})
def _is_claude_tool_target(custom_llm_provider: str | None, model: str) -> bool:
if custom_llm_provider == "anthropic":
return True
if custom_llm_provider == "azure_ai" or custom_llm_provider == "bedrock" or custom_llm_provider == "vertex_ai":
return "claude" in model.lower()
model_lower: Final = model.lower()
if custom_llm_provider == "bedrock":
return "claude" in model_lower or ("arn:" in model_lower and ":bedrock:" in model_lower)
if custom_llm_provider == "azure_ai" or custom_llm_provider == "vertex_ai":
return "claude" in model_lower
return False
def _drop_input_examples_from_tool(tool: dict) -> dict:
tool_copy: Final = tool.copy()
tool_copy.pop("input_examples", None)
function = tool_copy.get("function")
if isinstance(function, dict):
function = function.copy()
function.pop("input_examples", None)
tool_copy["function"] = function
return tool_copy
def _without_anthropic_only_tool_keys(tool: dict) -> dict:
kept: Final = {key: value for key, value in tool.items() if key not in _ANTHROPIC_ONLY_TOOL_KEYS}
function: Final = tool.get("function")
if not isinstance(function, dict):
return kept
return {
**kept,
"function": {key: value for key, value in function.items() if key not in _ANTHROPIC_ONLY_TOOL_KEYS},
}
def _drop_input_examples_from_tools(
tools: list[dict] | None,
) -> list[dict] | None:
def _drop_anthropic_only_tool_keys(tools: list[dict] | None) -> list[dict] | None:
if tools is None:
return None
cleaned_tools: Final[list[dict]] = []
for tool in tools:
if isinstance(tool, dict):
cleaned_tools.append(_drop_input_examples_from_tool(tool))
else:
cleaned_tools.append(tool)
return cleaned_tools
return [_without_anthropic_only_tool_keys(tool) if isinstance(tool, dict) else tool for tool in tools]
class _ProxyAuthHeadersProvider(Protocol):
@ -5360,8 +5358,8 @@ def completion(
api_base=api_base,
)
if not _should_allow_input_examples(custom_llm_provider=custom_llm_provider, model=model):
tools = _drop_input_examples_from_tools(tools=tools)
if not _is_claude_tool_target(custom_llm_provider=custom_llm_provider, model=model):
tools = _drop_anthropic_only_tool_keys(tools=tools)
if provider_specific_header is not None:
headers.update(

View file

@ -33378,6 +33378,10 @@
"cache_control": {
"$ref": "#/components/schemas/ChatCompletionCachedContent"
},
"eager_input_streaming": {
"title": "Eager Input Streaming",
"type": "boolean"
},
"function": {
"$ref": "#/components/schemas/ChatCompletionToolParamFunctionChunk"
},
@ -33407,6 +33411,10 @@
"title": "Description",
"type": "string"
},
"eager_input_streaming": {
"title": "Eager Input Streaming",
"type": "boolean"
},
"name": {
"title": "Name",
"type": "string"

View file

@ -2020,6 +2020,8 @@ class LiteLLMCompletionResponsesConfig:
chat_completion_tool["allowed_callers"] = tool.get("allowed_callers")
if tool.get("input_examples"):
chat_completion_tool["input_examples"] = tool.get("input_examples")
if tool.get("eager_input_streaming") is not None:
chat_completion_tool["eager_input_streaming"] = tool.get("eager_input_streaming")
return ResponsesToolChatForm(
chat_tools=(cast(ChatCompletionToolParam, chat_completion_tool),), web_search_options=None
)
@ -2096,6 +2098,8 @@ class LiteLLMCompletionResponsesConfig:
responses_tool["allowed_callers"] = tool.get("allowed_callers")
if tool.get("input_examples") is not None:
responses_tool["input_examples"] = tool.get("input_examples")
if tool.get("eager_input_streaming") is not None:
responses_tool["eager_input_streaming"] = tool.get("eager_input_streaming")
result.append(responses_tool)
else:
# mcp or other: pass through unchanged

View file

@ -56,6 +56,7 @@ class AnthropicMessagesTool(TypedDict, total=False):
defer_loading: bool
allowed_callers: list[str] | None
input_examples: list[dict[str, Any]] | None
eager_input_streaming: ReadOnly[bool]
class AnthropicComputerTool(TypedDict, total=False):
@ -755,6 +756,8 @@ ANTHROPIC_TOOL_SEARCH_BETA_HEADER: Final = "advanced-tool-use-2025-11-20"
# Effort beta header constant
ANTHROPIC_EFFORT_BETA_HEADER: Final = "effort-2025-11-24"
ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER: Final = "fine-grained-tool-streaming-2025-05-14"
# OAuth constants
ANTHROPIC_OAUTH_TOKEN_PREFIX: Final = "sk-ant-oat"
ANTHROPIC_OAUTH_BETA_HEADER: Final = "oauth-2025-04-20"

View file

@ -992,6 +992,7 @@ class ChatCompletionToolParamFunctionChunk(TypedDict, total=False):
description: str
parameters: dict
strict: bool
eager_input_streaming: ReadOnly[bool]
class OpenAIChatCompletionToolParam(TypedDict):
@ -1002,6 +1003,7 @@ class OpenAIChatCompletionToolParam(TypedDict):
class ChatCompletionToolParam(OpenAIChatCompletionToolParam, total=False):
cache_control: ChatCompletionCachedContent
allowed_callers: list[str]
eager_input_streaming: ReadOnly[bool]
class Function(TypedDict, total=False):

View file

@ -6370,3 +6370,74 @@ def test_response_format_tool_path_skips_forced_tool_choice_when_unsupported(loc
assert "tools" in result
assert "tool_choice" not in result
def _eager_chat_function(**extra: object) -> dict[str, object]:
return {
"name": "write_file",
"description": "Write a file",
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]},
**extra,
}
def _eager_chat_tool(**extra: object) -> dict[str, object]:
return {"type": "function", "function": _eager_chat_function(), **extra}
@pytest.mark.parametrize("flag", [True, False])
def test_eager_input_streaming_passed_through_from_tool_top_level(flag):
mapped_tool, _ = AnthropicConfig()._map_tool_helper(_eager_chat_tool(eager_input_streaming=flag))
assert mapped_tool == {
"name": "write_file",
"description": "Write a file",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]},
"type": "custom",
"eager_input_streaming": flag,
}
def test_eager_input_streaming_passed_through_from_function():
mapped_tool, _ = AnthropicConfig()._map_tool_helper(
{"type": "function", "function": _eager_chat_function(eager_input_streaming=True)}
)
assert mapped_tool["eager_input_streaming"] is True
assert "eager_input_streaming" not in mapped_tool["input_schema"]
def test_eager_input_streaming_absent_stays_absent():
mapped_tool, _ = AnthropicConfig()._map_tool_helper(_eager_chat_tool())
assert "eager_input_streaming" not in mapped_tool
def test_eager_input_streaming_rejects_non_boolean():
with pytest.raises(litellm.BadRequestError, match="eager_input_streaming must be a boolean"):
AnthropicConfig()._map_tool_helper(_eager_chat_tool(eager_input_streaming="true"))
def test_eager_input_streaming_not_set_on_computer_use_tool():
computer_tool = {
"type": "computer_20250124",
"function": {"name": "computer", "parameters": {"display_width_px": 1024, "display_height_px": 768}},
"eager_input_streaming": True,
}
mapped_tool, _ = AnthropicConfig()._map_tool_helper(computer_tool)
assert mapped_tool["type"] == "computer_20250124"
assert "eager_input_streaming" not in mapped_tool
def test_eager_input_streaming_reaches_anthropic_request_tools():
result = AnthropicConfig().map_openai_params(
non_default_params={"tools": [_eager_chat_tool(eager_input_streaming=True)], "stream": True},
optional_params={},
model="claude-sonnet-5",
drop_params=False,
)
assert result["tools"][0]["eager_input_streaming"] is True
assert result["tools"][0]["name"] == "write_file"

View file

@ -5017,3 +5017,45 @@ def test_redacted_thinking_blocks_never_carry_cache_control():
replayed: Final = outbound["messages"][1]["content"][0]
assert replayed["type"] == "redacted_thinking"
assert "cache_control" not in replayed
EAGER_INPUT_SCHEMA: Final = {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}
@pytest.mark.parametrize("flag", [True, False])
def test_translate_anthropic_tools_to_openai_carries_eager_input_streaming_onto_tool(flag):
"""The per-tool flag lands on the OpenAI tool object, never inside the JSON schema Bedrock sends as inputSchema."""
tools: Final = [{"name": "write_file", "input_schema": EAGER_INPUT_SCHEMA, "eager_input_streaming": flag}]
new_tools, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_tools_to_openai(tools=tools)
assert new_tools[0]["eager_input_streaming"] is flag
assert new_tools[0]["function"]["parameters"] == EAGER_INPUT_SCHEMA
assert "eager_input_streaming" not in new_tools[0]["function"]
def test_translate_anthropic_tools_to_openai_omits_unset_eager_input_streaming():
tools: Final = [{"name": "write_file", "input_schema": EAGER_INPUT_SCHEMA}]
new_tools, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_tools_to_openai(tools=tools)
assert "eager_input_streaming" not in new_tools[0]
assert "eager_input_streaming" not in new_tools[0]["function"]["parameters"]
def test_eager_input_streaming_tool_reaches_bedrock_converse_as_beta():
"""An Anthropic Messages request routed to bedrock/converse/ turns the flag into the fine-grained streaming beta."""
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
tools: Final = [{"name": "write_file", "input_schema": EAGER_INPUT_SCHEMA, "eager_input_streaming": True}]
new_tools, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_tools_to_openai(tools=tools)
data: Final = AmazonConverseConfig()._transform_request_helper(
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
system_content_blocks=[],
optional_params={"tools": new_tools},
messages=[{"role": "user", "content": "write a big file"}],
)
assert data["additionalModelRequestFields"]["anthropic_beta"] == ["fine-grained-tool-streaming-2025-05-14"]
assert data["toolConfig"]["tools"][0]["toolSpec"]["inputSchema"]["json"] == EAGER_INPUT_SCHEMA

View file

@ -1,6 +1,7 @@
import asyncio
import json
import uuid
from typing import Final
from unittest.mock import patch
import httpx
@ -859,3 +860,58 @@ def test_bedrock_chat_invoke_tool_search_beta_follows_model_map(
)
assert result.get("anthropic_beta") == expected_betas
FINE_GRAINED_TOOL_STREAMING_BETA: Final = "fine-grained-tool-streaming-2025-05-14"
EAGER_TOOL_SCHEMA: Final = {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}
def _chat_invoke_request_with_tools(
tools: list[dict[str, object]], headers: dict[str, str] | None = None
) -> dict[str, object]:
config: Final = AmazonAnthropicClaudeConfig()
model: Final = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
optional_params: Final = config.map_openai_params(
non_default_params={"max_tokens": 64, "stream": True, "tools": tools},
optional_params={},
model=model,
drop_params=False,
)
return config.transform_request(
model=model,
messages=[{"role": "user", "content": "write a big file"}],
optional_params=optional_params,
litellm_params={},
headers=headers or {},
)
def _eager_openai_tool(name: str, **extra: object) -> dict[str, object]:
return {"type": "function", "function": {"name": name, "parameters": EAGER_TOOL_SCHEMA}, **extra}
def test_bedrock_chat_invoke_eager_input_streaming_tool_adds_beta_and_strips_key():
result = _chat_invoke_request_with_tools(
[_eager_openai_tool("write_file", eager_input_streaming=True), _eager_openai_tool("read_file")]
)
assert result["anthropic_beta"] == [FINE_GRAINED_TOOL_STREAMING_BETA]
assert [tool["name"] for tool in result["tools"]] == ["write_file", "read_file"]
assert all("eager_input_streaming" not in tool for tool in result["tools"])
assert result["tools"][0]["input_schema"] == EAGER_TOOL_SCHEMA
def test_bedrock_chat_invoke_eager_input_streaming_false_strips_key_without_beta():
result = _chat_invoke_request_with_tools([_eager_openai_tool("write_file", eager_input_streaming=False)])
assert "anthropic_beta" not in result
assert "eager_input_streaming" not in result["tools"][0]
def test_bedrock_chat_invoke_eager_input_streaming_beta_not_duplicated_with_client_header():
result = _chat_invoke_request_with_tools(
[_eager_openai_tool("write_file", eager_input_streaming=True)],
headers={"anthropic-beta": FINE_GRAINED_TOOL_STREAMING_BETA},
)
assert result["anthropic_beta"] == [FINE_GRAINED_TOOL_STREAMING_BETA]

View file

@ -4,6 +4,7 @@ import os
import httpx
import pytest
from typing import Final
from unittest.mock import MagicMock, patch
import litellm
@ -7400,3 +7401,111 @@ def test_transform_response_honors_json_mode_kwarg_when_optional_params_lack_it(
)
assert result.choices[0].message.tool_calls is None
assert json.loads(result.choices[0].message.content) == {"city": "Paris", "population": 2100000}
FINE_GRAINED_TOOL_STREAMING_BETA: Final = "fine-grained-tool-streaming-2025-05-14"
EAGER_TOOL_SCHEMA: Final = {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}
def _eager_openai_tool(**extra: object) -> dict[str, object]:
return {"type": "function", "function": {"name": "write_file", "parameters": EAGER_TOOL_SCHEMA}, **extra}
def _eager_openai_function_tool(**extra: object) -> dict[str, object]:
return {"type": "function", "function": {"name": "write_file", "parameters": EAGER_TOOL_SCHEMA, **extra}}
def _eager_anthropic_tool(**extra: object) -> dict[str, object]:
return {"name": "write_file", "input_schema": EAGER_TOOL_SCHEMA, **extra}
def _converse_request(
model: str, tools: list[dict[str, object]], headers: dict[str, object] | None = None
) -> dict[str, object]:
return AmazonConverseConfig()._transform_request_helper(
model=model,
system_content_blocks=[],
optional_params={"tools": tools},
messages=[{"role": "user", "content": "write a big file"}],
headers=headers,
)
@pytest.mark.parametrize(
"tool",
[
_eager_openai_tool(eager_input_streaming=True),
_eager_openai_function_tool(eager_input_streaming=True),
_eager_anthropic_tool(eager_input_streaming=True),
],
ids=["openai_top_level", "openai_under_function", "anthropic_shape"],
)
def test_eager_input_streaming_tool_adds_fine_grained_tool_streaming_beta(tool):
data = _converse_request("us.anthropic.claude-sonnet-4-5-20250929-v1:0", [tool])
assert data["additionalModelRequestFields"]["anthropic_beta"] == [FINE_GRAINED_TOOL_STREAMING_BETA]
tool_spec = data["toolConfig"]["tools"][0]["toolSpec"]
assert tool_spec["name"] == "write_file"
assert "eager_input_streaming" not in tool_spec
assert "eager_input_streaming" not in tool_spec["inputSchema"]["json"]
@pytest.mark.parametrize(
"tool",
[
_eager_openai_tool(eager_input_streaming=False),
_eager_openai_function_tool(eager_input_streaming=False),
_eager_anthropic_tool(eager_input_streaming=False),
_eager_openai_tool(),
],
ids=["openai_false", "function_false", "anthropic_false", "absent"],
)
def test_eager_input_streaming_false_or_absent_adds_no_beta(tool):
data = _converse_request("us.anthropic.claude-sonnet-4-5-20250929-v1:0", [tool])
assert "anthropic_beta" not in data.get("additionalModelRequestFields", {})
assert "eager_input_streaming" not in data["toolConfig"]["tools"][0]["toolSpec"]
def test_eager_input_streaming_beta_only_on_anthropic_models():
data = _converse_request("amazon.nova-pro-v1:0", [_eager_openai_tool(eager_input_streaming=True)])
assert "anthropic_beta" not in data.get("additionalModelRequestFields", {})
assert data["toolConfig"]["tools"][0]["toolSpec"]["name"] == "write_file"
def test_eager_input_streaming_beta_not_duplicated_with_client_header():
data = _converse_request(
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
[_eager_openai_tool(eager_input_streaming=True)],
headers={"anthropic-beta": f"{FINE_GRAINED_TOOL_STREAMING_BETA},interleaved-thinking-2025-05-14"},
)
assert data["additionalModelRequestFields"]["anthropic_beta"] == [
FINE_GRAINED_TOOL_STREAMING_BETA,
"interleaved-thinking-2025-05-14",
]
def test_eager_input_streaming_beta_never_written_back_into_client_header_list():
headers = {"anthropic-beta": ["interleaved-thinking-2025-05-14"]}
data = _converse_request(
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
[_eager_openai_tool(eager_input_streaming=True)],
headers=headers,
)
assert data["additionalModelRequestFields"]["anthropic_beta"] == [
"interleaved-thinking-2025-05-14",
FINE_GRAINED_TOOL_STREAMING_BETA,
]
assert headers == {"anthropic-beta": ["interleaved-thinking-2025-05-14"]}
def test_eager_input_streaming_non_boolean_is_a_bad_request():
with pytest.raises(litellm.BadRequestError, match="eager_input_streaming must be a boolean"):
_converse_request(
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
[_eager_openai_tool(eager_input_streaming="true")],
)

View file

@ -4,6 +4,7 @@ import json
import os
from datetime import datetime
from types import SimpleNamespace
from typing import Final
from unittest.mock import Mock
import pytest
@ -3244,3 +3245,67 @@ def test_bedrock_messages_strips_effort_but_keeps_format_for_sonnet_4_5(local_mo
)
assert result.get("output_config") == {"format": schema_format}
FINE_GRAINED_TOOL_STREAMING_BETA: Final = "fine-grained-tool-streaming-2025-05-14"
def _invoke_request_with_tools(
tools: list[dict[str, object]], headers: dict[str, str] | None = None
) -> dict[str, object]:
from litellm.types.router import GenericLiteLLMParams
return AmazonAnthropicClaudeMessagesConfig().transform_anthropic_messages_request(
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=[{"role": "user", "content": "write a big file"}],
anthropic_messages_optional_request_params={"max_tokens": 4096, "tools": copy.deepcopy(tools), "stream": True},
litellm_params=GenericLiteLLMParams(),
headers=headers or {},
)
def _eager_invoke_tool(name: str, eager_input_streaming: bool) -> dict[str, object]:
return {
"name": name,
"description": f"{name} tool",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}},
"eager_input_streaming": eager_input_streaming,
}
def test_bedrock_invoke_eager_input_streaming_tool_adds_beta_and_strips_key():
result = _invoke_request_with_tools(
[
_eager_invoke_tool("write_file", True),
_eager_invoke_tool("read_file", False),
{"name": "list_files", "input_schema": {"type": "object", "properties": {}}},
]
)
assert result["anthropic_beta"] == [FINE_GRAINED_TOOL_STREAMING_BETA]
assert [tool["name"] for tool in result["tools"]] == ["write_file", "read_file", "list_files"]
assert all("eager_input_streaming" not in tool for tool in result["tools"])
assert result["tools"][0]["description"] == "write_file tool"
assert result["tools"][0]["input_schema"] == {"type": "object", "properties": {"path": {"type": "string"}}}
def test_bedrock_invoke_eager_input_streaming_false_strips_key_without_beta():
result = _invoke_request_with_tools([_eager_invoke_tool("write_file", False)])
assert "anthropic_beta" not in result
assert result["tools"] == [
{
"name": "write_file",
"description": "write_file tool",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}},
}
]
def test_bedrock_invoke_eager_input_streaming_beta_not_duplicated_with_client_header():
result = _invoke_request_with_tools(
[_eager_invoke_tool("write_file", True)],
headers={"anthropic-beta": FINE_GRAINED_TOOL_STREAMING_BETA},
)
assert result["anthropic_beta"] == [FINE_GRAINED_TOOL_STREAMING_BETA]

View file

@ -1900,6 +1900,38 @@ class TestToolTransformation:
assert "defer_loading" not in result_tool
assert "allowed_callers" not in result_tool
assert "input_examples" not in result_tool
assert "eager_input_streaming" not in result_tool
@pytest.mark.parametrize("eager_input_streaming", [True, False])
def test_transform_function_tools_forwards_eager_input_streaming(self, eager_input_streaming: bool) -> None:
function_tool: Final = {
"type": "function",
"name": "write_file",
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}},
"eager_input_streaming": eager_input_streaming,
}
result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
tools=[function_tool]
)
assert result_tools[0]["eager_input_streaming"] is eager_input_streaming
@pytest.mark.parametrize("eager_input_streaming", [True, False])
def test_chat_completion_tools_to_responses_tools_keeps_eager_input_streaming(
self, eager_input_streaming: bool
) -> None:
chat_tool: Final = {
"type": "function",
"function": {"name": "write_file", "parameters": {"type": "object"}},
"eager_input_streaming": eager_input_streaming,
}
result_tools: Final = LiteLLMCompletionResponsesConfig.transform_chat_completion_tool_params_to_responses_api_tools(
[chat_tool]
)
assert result_tools[0]["eager_input_streaming"] is eager_input_streaming
def test_transform_code_execution_tools(self):
"""Test that code_execution tools are passed through as-is"""

View file

@ -349,28 +349,66 @@ def test_bedrock_latency_optimized_inference():
assert json_data["performanceConfig"]["latency"] == "optimized"
def test_strip_input_examples_for_non_anthropic_providers():
@pytest.mark.parametrize(
("custom_llm_provider", "model", "expected"),
[
("anthropic", "claude-sonnet-5", True),
("bedrock", "us.anthropic.claude-sonnet-5-20260501-v1:0", True),
("bedrock", "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123", True),
("bedrock", "us.amazon.nova-2-lite-v1:0", False),
("vertex_ai", "claude-sonnet-5", True),
("vertex_ai", "gemini-3.8-flash", False),
("azure_ai", "claude-sonnet-4-6", True),
("azure_ai", "gpt-5.6", False),
("openai", "gpt-5.6", False),
("gemini", "gemini-3.8-flash", False),
],
)
def test_is_claude_tool_target(custom_llm_provider: str, model: str, expected: bool):
assert litellm_main._is_claude_tool_target(custom_llm_provider=custom_llm_provider, model=model) is expected
@pytest.mark.parametrize("key", ["input_examples", "eager_input_streaming"])
def test_drop_anthropic_only_tool_keys_strips_tool_and_function_levels(key: str):
tools = [
{
"type": "function",
"name": "example_tool",
"input_examples": [{"foo": "bar"}],
"function": {
"name": "example_tool",
"input_examples": [{"foo": "bar"}],
},
}
{"type": "function", "name": "example_tool", key: True, "function": {"name": "example_tool", key: True}},
"opaque_tool",
]
assert not litellm_main._should_allow_input_examples(
custom_llm_provider="openai", model="gpt-4o-mini"
cleaned = litellm_main._drop_anthropic_only_tool_keys(tools=tools)
assert cleaned == [
{"type": "function", "name": "example_tool", "function": {"name": "example_tool"}},
"opaque_tool",
]
assert tools[0][key] is True
assert tools[0]["function"][key] is True
def test_completion_strips_eager_input_streaming_before_openai(respx_mock: respx.MockRouter, openai_api_response):
api_base: Final = "http://localhost:12346/v1"
mock_route: Final = respx_mock.post(url__regex=rf"{api_base}/chat/completions.*").mock(
return_value=httpx.Response(status_code=200, json=openai_api_response)
)
cleaned = litellm_main._drop_input_examples_from_tools(tools=tools)
litellm.completion(
model="openai/gpt-5.6",
messages=[{"role": "user", "content": "Write the file"}],
tools=[
{
"type": "function",
"function": {"name": "write_file", "parameters": {"type": "object", "properties": {}}},
"eager_input_streaming": True,
}
],
api_base=api_base,
api_key="fake_openai_api_key",
)
assert isinstance(cleaned, list)
assert "input_examples" not in cleaned[0]
assert "input_examples" not in cleaned[0]["function"]
assert mock_route.called
sent_tool: Final = json.loads(respx_mock.calls[0].request.content)["tools"][0]
assert "eager_input_streaming" not in sent_tool
assert sent_tool["function"]["name"] == "write_file"
def test_custom_provider_with_extra_headers():

View file

@ -26070,6 +26070,8 @@ export interface components {
/** Allowed Callers */
allowed_callers?: string[];
cache_control?: components["schemas"]["ChatCompletionCachedContent"];
/** Eager Input Streaming */
eager_input_streaming?: boolean;
function: components["schemas"]["ChatCompletionToolParamFunctionChunk"];
/** Type */
type: "function" | string;
@ -26078,6 +26080,8 @@ export interface components {
ChatCompletionToolParamFunctionChunk: {
/** Description */
description?: string;
/** Eager Input Streaming */
eager_input_streaming?: boolean;
/** Name */
name: string;
/** Parameters */