Skip to content

Commit fe1a2d4

Browse files
authored
Merge pull request #24 from achraf-mer/claude/bedrock-parallel-tool-choice-type
fix(bedrock): emit tool_choice.type when parallel_tool_calls is set
2 parents 212a0c9 + 962dc7f commit fe1a2d4

2 files changed

Lines changed: 524 additions & 17 deletions

File tree

litellm/llms/bedrock/chat/converse_transformation.py

Lines changed: 120 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import json
77
import time
88
import types
9-
from typing import List, Literal, Optional, Tuple, Union, cast, overload
9+
from typing import Any, Dict, List, Literal, Optional, Tuple, Union, cast, overload
1010

1111
import httpx
1212

@@ -100,6 +100,36 @@
100100
]
101101

102102

103+
def _apply_parallel_tool_use_config(
104+
parallel_tool_use_config: dict,
105+
additional_request_params: dict,
106+
inference_params: dict,
107+
) -> None:
108+
"""Merge the parallel-tool-use config into ``additionalModelRequestFields``.
109+
110+
The disable-parallel flag can only ride on the Anthropic-passthrough
111+
``tool_choice`` (Bedrock's native ``toolConfig.toolChoice`` has no such
112+
field). When an explicit tool_choice was ALSO mapped into the native
113+
channel, sending both makes Bedrock reject the request ("The additional
114+
field tool_choice/type conflicts with the existing field
115+
toolConfig.toolChoice.<type>"). The passthrough already carries the
116+
equivalent type (any/auto/tool), so drop the native toolChoice to leave a
117+
single, non-conflicting directive.
118+
"""
119+
# Plain assignment, not a merge. ``additional_request_params`` is built as the
120+
# inference params NOT in ``total_supported_params``, and ``tool_choice`` IS in
121+
# that set, so the key can never already be present. The old merge branch was
122+
# unreachable, and had it been reachable it would have produced a corrupt
123+
# hybrid such as {"type": "auto", "name": <user>, "disable_parallel_tool_use": true}.
124+
additional_request_params.update(parallel_tool_use_config)
125+
126+
# No `"type" in ...` check: _map_parallel_tool_use_config sets type on every
127+
# branch, so that condition could never be false. (Same reasoning that removed
128+
# the unreachable merge branch above.)
129+
if isinstance(parallel_tool_use_config.get("tool_choice"), dict):
130+
inference_params.pop("tool_choice", None)
131+
132+
103133
class AmazonConverseConfig(BaseConfig):
104134
"""
105135
Reference - https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html
@@ -893,11 +923,8 @@ def map_openai_params(
893923
)
894924
if _tool_choice_value is not None:
895925
optional_params["tool_choice"] = _tool_choice_value
896-
if param == "parallel_tool_calls":
897-
disable_parallel = not value
898-
optional_params["_parallel_tool_use_config"] = {
899-
"tool_choice": {"disable_parallel_tool_use": disable_parallel}
900-
}
926+
# NOTE: parallel_tool_calls is handled after this loop, in
927+
# _map_parallel_tool_use_config -- see its docstring for why.
901928
if param == "thinking":
902929
optional_params["thinking"] = value
903930
elif param == "reasoning_effort" and isinstance(value, str):
@@ -938,8 +965,94 @@ def map_openai_params(
938965
)
939966
optional_params["tool_choice"] = ToolChoiceValuesBlock(auto={})
940967

968+
self._map_parallel_tool_use_config(
969+
non_default_params=non_default_params, optional_params=optional_params
970+
)
971+
941972
return optional_params
942973

974+
def _map_parallel_tool_use_config(
975+
self, non_default_params: dict, optional_params: dict
976+
) -> None:
977+
"""Build the Anthropic-on-Bedrock parallel-tool-use passthrough.
978+
979+
Deliberately runs AFTER the whole param loop and after the reasoning
980+
downgrade above, and derives the type from the FINAL mapped native
981+
``tool_choice`` block rather than re-reading the caller's raw OpenAI
982+
value. Three defects came from doing the latter inside the loop, all of
983+
which are silent because ``_apply_parallel_tool_use_config`` deletes the
984+
native ``toolChoice`` that would otherwise have carried the truth:
985+
986+
* The reasoning downgrade rewrites only ``optional_params["tool_choice"]``,
987+
so a passthrough built earlier still said ``type="any"`` and re-sent the
988+
forced-tool payload that the downgrade exists to prevent.
989+
* The raw-value classifier required ``tool_choice["type"] == "function"``
990+
while the native mapper treats ANY dict as a named tool, so
991+
``{"function": {"name": ...}}`` silently lost the forced-tool directive
992+
and the model was free to answer in prose.
993+
* The raw name was forwarded unsanitized, while the native mapper and the
994+
tool list both sanitize, so a tool named ``get.current weather`` forced
995+
a tool that did not exist in ``toolConfig.tools``.
996+
997+
Reading the mapped block instead makes disagreement impossible: it is the
998+
same value Bedrock would have received natively, and it already carries
999+
the sanitized name.
1000+
"""
1001+
if "parallel_tool_calls" not in non_default_params:
1002+
return
1003+
value = non_default_params["parallel_tool_calls"]
1004+
# Only a literal False has anything to say. ``parallel_tool_calls: true``
1005+
# is Anthropic's default, so emitting the passthrough for it would move
1006+
# the caller's forced-tool directive out of the documented Converse field
1007+
# into an undocumented passthrough for no benefit. ``None`` is not a
1008+
# request to disable anything either.
1009+
if not isinstance(value, bool) or value is True:
1010+
return
1011+
# ``tool_choice`` without ``tools`` is rejected by Anthropic, so do not
1012+
# invent one for a request that has no tools.
1013+
#
1014+
# Read optional_params, NOT non_default_params: the tools that reach the
1015+
# request are the MAPPED ones, and the two disagree in both directions.
1016+
# A truthy non-list ``tools`` (e.g. a bare dict) passes a
1017+
# non_default_params check but is skipped by the mapping loop's
1018+
# isinstance(value, list) guard, so the payload came out with
1019+
# additionalModelRequestFields.tool_choice and NO toolConfig at all. In the
1020+
# other direction a json_schema response_format injects a synthetic tool
1021+
# into optional_params with nothing in non_default_params.
1022+
#
1023+
# This narrows that payload but does not eliminate it: _bedrock_tools_pt
1024+
# later drops tools carrying neither "function" nor "input_schema" (the
1025+
# Responses built-ins such as web_search), so a request whose tools are ALL
1026+
# of that kind still reaches the provider with a tool_choice and no
1027+
# toolConfig. Measured, and unchanged from before this feature -- the only
1028+
# place the answer is known is _transform_request_helper, where
1029+
# bedrock_tools has already been computed.
1030+
if not optional_params.get("tools"):
1031+
return
1032+
1033+
# A caller who said "no tools" must not have ``auto`` invented for them.
1034+
# map_tool_choice_values drops "none" and returns None, which is
1035+
# indistinguishable here from "the caller sent nothing" -- so check the
1036+
# raw value. litellm's own Anthropic transform does exactly this
1037+
# (anthropic/chat/transformation.py: `if tool_choice == "none": pass`).
1038+
if non_default_params.get("tool_choice") == "none":
1039+
return
1040+
1041+
native = optional_params.get("tool_choice")
1042+
tool_choice: Dict[str, Any] = {"disable_parallel_tool_use": True}
1043+
if isinstance(native, dict) and "any" in native:
1044+
tool_choice["type"] = "any"
1045+
elif isinstance(native, dict) and "tool" in native:
1046+
tool_choice["type"] = "tool"
1047+
named = native["tool"]
1048+
name = named.get("name") if isinstance(named, dict) else None
1049+
if name:
1050+
# Already sanitized by map_tool_choice_values.
1051+
tool_choice["name"] = name
1052+
else:
1053+
tool_choice["type"] = "auto"
1054+
optional_params["_parallel_tool_use_config"] = {"tool_choice": tool_choice}
1055+
9431056
def _map_request_metadata_param(self, value: Any, optional_params: dict) -> None:
9441057
if value is not None and isinstance(value, dict):
9451058
self._validate_request_metadata(value) # type: ignore
@@ -1254,15 +1367,7 @@ def _prepare_request_params(
12541367
# Handle parallel_tool_calls configuration
12551368
parallel_tool_use_config = additional_request_params.pop("_parallel_tool_use_config", None)
12561369
if parallel_tool_use_config is not None and bedrock_converse_supports_parallel_tool_use_config(model):
1257-
for key, value in parallel_tool_use_config.items():
1258-
if (
1259-
key in additional_request_params
1260-
and isinstance(additional_request_params[key], dict)
1261-
and isinstance(value, dict)
1262-
):
1263-
additional_request_params[key].update(value)
1264-
else:
1265-
additional_request_params[key] = value
1370+
_apply_parallel_tool_use_config(parallel_tool_use_config, additional_request_params, inference_params)
12661371

12671372
additional_request_params.pop("parallel_tool_calls", None)
12681373

0 commit comments

Comments
 (0)