|
6 | 6 | import json |
7 | 7 | import time |
8 | 8 | 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 |
10 | 10 |
|
11 | 11 | import httpx |
12 | 12 |
|
|
100 | 100 | ] |
101 | 101 |
|
102 | 102 |
|
| 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 | + |
103 | 133 | class AmazonConverseConfig(BaseConfig): |
104 | 134 | """ |
105 | 135 | Reference - https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html |
@@ -893,11 +923,8 @@ def map_openai_params( |
893 | 923 | ) |
894 | 924 | if _tool_choice_value is not None: |
895 | 925 | 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. |
901 | 928 | if param == "thinking": |
902 | 929 | optional_params["thinking"] = value |
903 | 930 | elif param == "reasoning_effort" and isinstance(value, str): |
@@ -938,8 +965,94 @@ def map_openai_params( |
938 | 965 | ) |
939 | 966 | optional_params["tool_choice"] = ToolChoiceValuesBlock(auto={}) |
940 | 967 |
|
| 968 | + self._map_parallel_tool_use_config( |
| 969 | + non_default_params=non_default_params, optional_params=optional_params |
| 970 | + ) |
| 971 | + |
941 | 972 | return optional_params |
942 | 973 |
|
| 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 | + |
943 | 1056 | def _map_request_metadata_param(self, value: Any, optional_params: dict) -> None: |
944 | 1057 | if value is not None and isinstance(value, dict): |
945 | 1058 | self._validate_request_metadata(value) # type: ignore |
@@ -1254,15 +1367,7 @@ def _prepare_request_params( |
1254 | 1367 | # Handle parallel_tool_calls configuration |
1255 | 1368 | parallel_tool_use_config = additional_request_params.pop("_parallel_tool_use_config", None) |
1256 | 1369 | 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) |
1266 | 1371 |
|
1267 | 1372 | additional_request_params.pop("parallel_tool_calls", None) |
1268 | 1373 |
|
|
0 commit comments