fix(bedrock): forward named-function name in parallel tool_choice config

CodeRabbit follow-up: for a named-function tool_choice with
parallel_tool_calls=False, the config emitted {"type": "tool", ...} but dropped
the function name. _apply_parallel_tool_use_config then removes the native
toolChoice that carried the name, so Bedrock received a nameless "tool" choice
and rejected it.

Mirror the native Anthropic transform (which sets _tool_choice["name"] =
_tool_name): extract _bedrock_parallel_tool_choice() to build the full payload
(type + disable_parallel_tool_use, plus name when type == "tool").

Live before/after against Bedrock claude-sonnet-5 (named tool_choice +
parallel_tool_calls=False):
- before: 400 BadRequest "tool_choice.tool.name: Field required"
- after:  200, single tool_call to the named function
Added a parametrized name assertion + an end-to-end test asserting the request
carries {"type": "tool", "name": ..., "disable_parallel_tool_use": True} and
the native toolChoice is dropped. Non-vacuous (both fail against the nameless
build). ruff/lint clean, C901 strict budget unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seanturner83 2026-07-11 02:20:12 +01:00
parent 7038d2354a
commit b1b434e2a9
No known key found for this signature in database
2 changed files with 84 additions and 9 deletions

View file

@ -115,6 +115,30 @@ def _bedrock_tool_choice_type(tool_choice: Any) -> str:
return "auto"
def _bedrock_parallel_tool_choice(tool_choice: Any, disable_parallel: bool) -> dict:
"""Build the Anthropic-on-Bedrock ``tool_choice`` payload for the
parallel-tool-use config.
Derives ``type`` from an explicit tool_choice (see
``_bedrock_tool_choice_type``) and carries the ``disable_parallel_tool_use``
flag this config exists to set. For a named-function choice (``type ==
"tool"``) it also forwards the function ``name`` — mirroring the native
Anthropic transform (``_tool_choice["name"] = _tool_name``). Without the
name, ``_apply_parallel_tool_use_config`` drops the native ``toolChoice``
that carried it, so Bedrock would receive ``type="tool"`` with no tool named
and reject the request.
"""
payload: dict = {
"type": _bedrock_tool_choice_type(tool_choice),
"disable_parallel_tool_use": disable_parallel,
}
if payload["type"] == "tool" and isinstance(tool_choice, dict):
name = tool_choice.get("function", {}).get("name")
if name:
payload["name"] = name
return payload
def _apply_parallel_tool_use_config(
parallel_tool_use_config: dict,
additional_request_params: dict,
@ -945,10 +969,7 @@ class AmazonConverseConfig(BaseConfig):
# defaults to ``type="auto"`` only when none was given). Without a
# ``type`` Bedrock Converse returns 400 "missing field `type`".
optional_params["_parallel_tool_use_config"] = {
"tool_choice": {
"type": _bedrock_tool_choice_type(non_default_params.get("tool_choice")),
"disable_parallel_tool_use": not value,
}
"tool_choice": _bedrock_parallel_tool_choice(non_default_params.get("tool_choice"), not value)
}
if param == "thinking":
optional_params["thinking"] = value

View file

@ -696,17 +696,24 @@ def test_parallel_tool_calls_tool_choice_includes_type(model: str):
@pytest.mark.parametrize(
"tool_choice, expected_type",
"tool_choice, expected_type, expected_name",
[
# An explicit tool_choice must drive the parallel-config type rather than
# being silently overridden with "auto" (mirrors the native Anthropic
# transform, which sets disable_parallel_tool_use on the existing choice).
("required", "any"),
("auto", "auto"),
({"type": "function", "function": {"name": "get_current_weather"}}, "tool"),
("required", "any", None),
("auto", "auto", None),
# A named-function choice must forward the function name alongside
# type="tool" — else _apply_parallel_tool_use_config drops the native
# toolChoice carrying it and Bedrock gets a nameless "tool" choice.
(
{"type": "function", "function": {"name": "get_current_weather"}},
"tool",
"get_current_weather",
),
],
)
def test_parallel_tool_calls_preserves_explicit_tool_choice_type(tool_choice, expected_type):
def test_parallel_tool_calls_preserves_explicit_tool_choice_type(tool_choice, expected_type, expected_name):
old_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
old_cost = litellm.model_cost
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
@ -726,6 +733,53 @@ def test_parallel_tool_calls_preserves_explicit_tool_choice_type(tool_choice, ex
tc = optional_params["_parallel_tool_use_config"]["tool_choice"]
assert tc["type"] == expected_type, f"expected {expected_type}, got {tc}"
assert tc["disable_parallel_tool_use"] is True
assert tc.get("name") == expected_name, f"name mismatch: {tc}"
finally:
litellm.model_cost = old_cost
if old_env is None:
os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None)
else:
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = old_env
def test_parallel_tool_calls_named_function_survives_to_request():
"""End-to-end: a named-function tool_choice with parallel_tool_calls=False
must reach Bedrock as a single, complete tool_choice — type="tool" WITH the
function name — in additionalModelRequestFields, and the native toolChoice
must be dropped so the two don't conflict."""
old_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP")
old_cost = litellm.model_cost
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
try:
config = AmazonConverseConfig()
optional_params = config.map_openai_params(
model="anthropic.claude-sonnet-5",
non_default_params={
"tool_choice": {
"type": "function",
"function": {"name": "get_current_weather"},
},
"parallel_tool_calls": False,
"tools": _TOOL_PARAM,
},
optional_params={},
drop_params=False,
)
data = config._transform_request_helper(
model="anthropic.claude-sonnet-5",
system_content_blocks=[],
optional_params=optional_params,
messages=None,
)
tool_choice = data["additionalModelRequestFields"]["tool_choice"]
assert tool_choice == {
"type": "tool",
"name": "get_current_weather",
"disable_parallel_tool_use": True,
}
# Native toolChoice dropped to avoid the conflicting-field 400.
assert "toolChoice" not in data.get("toolConfig", {})
finally:
litellm.model_cost = old_cost
if old_env is None: