fix(bedrock): drop unmappable Responses tools instead of failing the request (LIT-3858) (#31663)

* fix(bedrock): drop unmappable Responses tools instead of failing the request (LIT-3858)

When an OpenAI Responses request is routed to a Bedrock Converse Anthropic model,
litellm translates the tools array into Bedrock toolConfig. Responses built-in tool
types beyond function (web_search, image_generation, namespace, tool_search, custom)
have no Bedrock equivalent, and previously caused two failures.

A web_search tool is derived into a web_search_options param. Bedrock Anthropic
models do not list web_search_options in get_supported_openai_params, so the request
raised UnsupportedParamsError (HTTP 400) even though it never needed web search. The
derived param is now dropped on the Bedrock chat-completion bridge for models that
do not support it, scoped to Bedrock so other providers are untouched and without
requiring drop_params. Nova still keeps it since it maps to a nova_grounding systemTool.

The remaining non-function tools reached _bedrock_tools_pt and were emitted as junk
litellm_unnamed_tool_N toolSpecs with empty schemas, polluting toolConfig with tools
the model could hallucinate calls to. They are now dropped because they carry neither
an OpenAI function nor an Anthropic input_schema, while mappable function and
input_schema tools survive untouched.

* refactor(responses): drop derived web_search_options via provider config

Greptile flagged that the LIT-3858 fix put Bedrock-specific logic in the
generic Responses->Chat Completion bridge: it imported AmazonConverseConfig
and branched on custom_llm_provider.startswith("bedrock").

Read web_search_options support from each provider's own
get_supported_openai_params instead, so the bridge stays provider-agnostic
and Bedrock capability knowledge lives in the Bedrock config that already
owns it. Behavior is unchanged for the cases the PR targeted (Bedrock
Anthropic drops, Bedrock Nova and OpenAI keep) and now generalizes correctly
to any provider whose config does not support the derived param.

Add a Cohere regression test proving the drop is provider-agnostic; it fails
under the old bedrock-only check and passes now.

* fix(responses): drop derived web_search_options for bedrock_converse alias

Greptile/T-Rex caught that the provider-agnostic drop regressed the
bedrock_converse route: get_supported_openai_params did not map the
bedrock_converse alias (only "bedrock"), so it returned None (unmapped) and
the derived web_search_options was forwarded for
model="bedrock/converse/us.anthropic.claude-sonnet-4-6",
custom_llm_provider="bedrock_converse" instead of being dropped. The previous
startswith("bedrock") check happened to match the alias.

Map bedrock_converse through AmazonConverseConfig in get_supported_openai_params,
mirroring the existing ["bedrock", "bedrock_converse"] pairing in
_strip_model_name. Add regression tests at both levels: the alias now drops the
derived param for Anthropic Converse models, still keeps it for Nova, and the
helper resolves identically to "bedrock".
This commit is contained in:
Mateo Wang 2026-06-29 20:23:44 -07:00 committed by GitHub
parent ea7be19225
commit 8a55e9e560
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 256 additions and 1 deletions

View file

@ -58,7 +58,7 @@ def get_supported_openai_params(
supported_params = list(dict.fromkeys([*supported_params, *base_model_params]))
return supported_params
if custom_llm_provider == "bedrock":
if custom_llm_provider == "bedrock" or custom_llm_provider == "bedrock_converse":
return litellm.AmazonConverseConfig().get_supported_openai_params(model=model)
elif custom_llm_provider == "meta_llama":
provider_config = litellm.ProviderConfigManager.get_provider_chat_config(

View file

@ -5027,6 +5027,12 @@ def _bedrock_tools_pt(tools: List, model: Optional[str] = None) -> List[BedrockT
tool_block_list.append(tool) # type: ignore
continue
# Responses built-in tools (web_search, image_generation, namespace, tool_search,
# custom) carry neither an OpenAI "function" nor an Anthropic "input_schema" and have
# no Bedrock toolSpec equivalent; drop them instead of emitting an empty junk toolSpec.
if isinstance(tool, dict) and "function" not in tool and "input_schema" not in tool:
continue
# OpenAI function tools, or Anthropic Messages / Claude Code ({name, input_schema, type, ...})
if isinstance(tool, dict) and "input_schema" in tool and "function" not in tool:
parameters = copy.deepcopy(tool.get("input_schema") or {"type": "object", "properties": {}})

View file

@ -12,6 +12,9 @@ from openai.types.responses.tool_param import FunctionToolParam
from typing_extensions import TypedDict
from litellm.caching import InMemoryCache
from litellm.litellm_core_utils.get_supported_openai_params import (
get_supported_openai_params,
)
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.responses.litellm_completion_transformation.session_handler import (
ResponsesSessionHandler,
@ -155,6 +158,22 @@ class LiteLLMCompletionResponsesConfig:
# Return as-is for unknown formats
return tool_choice
@staticmethod
def _should_drop_derived_web_search_options(model: str, custom_llm_provider: Optional[str]) -> bool:
"""
A Responses ``web_search`` built-in tool is derived into a ``web_search_options`` param.
When the resolved provider/model does not support it (e.g. Bedrock Anthropic, where only
Nova maps it to a nova_grounding systemTool), the derived param is dropped here instead of
raising UnsupportedParamsError downstream. Providers that support it keep it untouched.
Support is read from each provider's own ``get_supported_openai_params`` so this bridge
stays provider-agnostic; an unmapped provider (``None``) is treated as "keep".
"""
supported_params: Optional[List[str]] = get_supported_openai_params(
model=model, custom_llm_provider=custom_llm_provider
)
return supported_params is not None and "web_search_options" not in supported_params
@staticmethod
def transform_responses_api_request_to_chat_completion_request(
model: str,
@ -175,6 +194,11 @@ class LiteLLMCompletionResponsesConfig:
responses_api_request.get("tools") or [] # type: ignore
)
if web_search_options is not None and LiteLLMCompletionResponsesConfig._should_drop_derived_web_search_options(
model=model, custom_llm_provider=custom_llm_provider
):
web_search_options = None
response_format = None
text_param = responses_api_request.get("text")
if text_param:

View file

@ -1553,6 +1553,69 @@ def test_bedrock_tools_pt_does_not_handle_system_tool():
assert tool_spec["name"] == "get_weather"
def test_bedrock_tools_pt_drops_unmappable_responses_builtin_tools():
"""
Regression for LIT-3858: Responses built-in tools (image_generation, namespace,
tool_search, custom) have no Bedrock toolSpec equivalent. They must be dropped, not
emitted as junk ``litellm_unnamed_tool_N`` toolSpecs the model can hallucinate calls to.
Mappable ``function`` and Anthropic ``input_schema`` tools must survive untouched.
"""
from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_tools_pt
tools = [
{
"type": "function",
"function": {
"name": "noop",
"description": "x",
"parameters": {"type": "object", "properties": {}},
},
},
{"type": "image_generation", "output_format": "png"},
{"type": "namespace", "name": "grp", "description": "g", "tools": []},
{"type": "custom", "name": "free_form"},
]
result = _bedrock_tools_pt(
tools=tools, model="anthropic.claude-sonnet-4-5-20250929-v1:0"
)
names = [block["toolSpec"]["name"] for block in result if "toolSpec" in block]
assert names == ["noop"]
assert not any(name.startswith("litellm_unnamed_tool_") for name in names)
def test_bedrock_tools_pt_keeps_anthropic_input_schema_tools():
"""
The drop guard for unmappable tools must not regress Anthropic Messages format tools,
which carry an ``input_schema`` instead of an OpenAI ``function`` key.
"""
from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_tools_pt
tools = [
{
"type": "image_generation",
"output_format": "png",
},
{
"name": "lookup",
"description": "look something up",
"input_schema": {
"type": "object",
"properties": {"q": {"type": "string"}},
"required": ["q"],
},
},
]
result = _bedrock_tools_pt(
tools=tools, model="anthropic.claude-sonnet-4-5-20250929-v1:0"
)
names = [block["toolSpec"]["name"] for block in result if "toolSpec" in block]
assert names == ["lookup"]
def test_convert_to_anthropic_tool_result_image_with_cache_control():
"""
Test that cache_control is properly applied to image content in tool results.

View file

@ -146,3 +146,33 @@ def test_sambanova_embeddings_request_returns_list_not_none():
)
assert embedding_params == []
def test_bedrock_converse_alias_resolves_like_bedrock():
"""The ``bedrock_converse`` invocation alias must resolve through AmazonConverseConfig
just like ``bedrock`` (the codebase already pairs them, e.g. ``_strip_model_name``).
Before this mapping it returned ``None`` (unmapped), so callers gating on supported
params saw no Bedrock capabilities for a Converse model invoked via the alias."""
anthropic_model = "bedrock/converse/us.anthropic.claude-sonnet-4-6"
via_alias = get_supported_openai_params(
model=anthropic_model, custom_llm_provider="bedrock_converse"
)
assert via_alias is not None
assert via_alias == get_supported_openai_params(
model=anthropic_model, custom_llm_provider="bedrock"
)
assert "web_search_options" not in via_alias
assert "tools" in via_alias
def test_bedrock_converse_alias_keeps_nova_web_search_options():
"""Nova on the ``bedrock_converse`` alias still advertises web_search_options, proving the
alias routes through the model-aware config rather than a blanket Bedrock default."""
nova_params = get_supported_openai_params(
model="amazon.nova-pro-v1:0", custom_llm_provider="bedrock_converse"
)
assert nova_params is not None
assert "web_search_options" in nova_params

View file

@ -1407,6 +1407,138 @@ class TestToolTransformation:
== "string"
)
def test_bedrock_anthropic_drops_derived_web_search_options(self):
"""
Regression for LIT-3858: a Responses web_search tool becomes a derived
web_search_options param. Bedrock Anthropic models do not support it, so on the
chat-completion bridge it must be dropped (so litellm doesn't raise
UnsupportedParamsError) without the caller setting drop_params.
"""
responses_api_request = {
"tools": [{"type": "web_search", "external_web_access": False}],
}
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
input="hi",
responses_api_request=responses_api_request,
custom_llm_provider="bedrock",
)
assert "web_search_options" not in result
def test_bedrock_converse_provider_drops_derived_web_search_options(self):
"""
Regression for the ``bedrock_converse`` alias: routing a Bedrock Converse model resolves
to custom_llm_provider='bedrock_converse' with model='bedrock/converse/...'. The derived
web_search_options must still be dropped on this route, not just the bare 'bedrock' one.
"""
responses_api_request = {
"tools": [{"type": "web_search", "external_web_access": False}],
}
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="bedrock/converse/us.anthropic.claude-sonnet-4-6",
input="hi",
responses_api_request=responses_api_request,
custom_llm_provider="bedrock_converse",
)
assert "web_search_options" not in result
def test_bedrock_nova_keeps_derived_web_search_options(self):
"""Nova models map web_search_options to a nova_grounding systemTool, so keep it."""
responses_api_request = {
"tools": [{"type": "web_search", "search_context_size": "high"}],
}
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="amazon.nova-pro-v1:0",
input="hi",
responses_api_request=responses_api_request,
custom_llm_provider="bedrock",
)
assert result.get("web_search_options") is not None
def test_supported_provider_keeps_derived_web_search_options(self):
"""A provider whose config lists web_search_options (e.g. OpenAI) keeps it untouched."""
responses_api_request = {
"tools": [{"type": "web_search", "search_context_size": "high"}],
}
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="gpt-4o",
input="hi",
responses_api_request=responses_api_request,
custom_llm_provider="openai",
)
assert result.get("web_search_options") is not None
def test_unsupported_non_bedrock_provider_drops_derived_web_search_options(self):
"""
The drop is provider-agnostic, not hardcoded to Bedrock: any provider whose config
does not list web_search_options (e.g. Cohere) drops the derived param. This fails if
the drop is ever re-scoped to a single provider.
"""
responses_api_request = {
"tools": [{"type": "web_search", "search_context_size": "high"}],
}
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="command-r",
input="hi",
responses_api_request=responses_api_request,
custom_llm_provider="cohere",
)
assert "web_search_options" not in result
def test_bedrock_anthropic_responses_tools_yield_only_function_toolspec(self):
"""
End-to-end (no network) of the LIT-3858 acceptance criterion: the mixed tools array
is transformed for a Bedrock Anthropic model, then fed through the Bedrock tool layer.
The derived web_search_options is dropped, and toolConfig contains only the function
tool, never the web_search/image_generation/namespace built-ins as junk toolSpecs.
"""
from litellm.litellm_core_utils.prompt_templates.factory import (
_bedrock_tools_pt,
)
model = "anthropic.claude-sonnet-4-5-20250929-v1:0"
responses_api_request = {
"tools": [
{
"type": "function",
"name": "noop",
"description": "x",
"parameters": {"type": "object", "properties": {}},
},
{"type": "web_search", "external_web_access": False},
{"type": "image_generation", "output_format": "png"},
{"type": "namespace", "name": "grp", "description": "g", "tools": []},
],
}
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model=model,
input="hi",
responses_api_request=responses_api_request,
custom_llm_provider="bedrock",
)
assert "web_search_options" not in result
bedrock_tool_blocks = _bedrock_tools_pt(tools=result["tools"], model=model)
names = [
block["toolSpec"]["name"]
for block in bedrock_tool_blocks
if "toolSpec" in block
]
assert names == ["noop"]
assert not any(name.startswith("litellm_unnamed_tool_") for name in names)
class TestUsageTransformation:
"""Test cases for usage transformation from Chat Completion to Responses API format"""