From 26f3216db8159e9dc28ead4b59231b73358b15aa Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Fri, 22 May 2026 20:22:50 +0800 Subject: [PATCH] fix(bedrock): parse Nexus Claude text tool calls in converse responses --- .../bedrock/chat/converse_transformation.py | 175 ++++++++++++- .../chat/test_converse_transformation.py | 245 ++++++++++++++++++ 2 files changed, 410 insertions(+), 10 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index efc890d9ee2..2bb379655b4 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -4,9 +4,10 @@ Translating between OpenAI's `/chat/completion` format and Amazon's `/converse` import copy import json +import re import time import types -from typing import List, Literal, Optional, Tuple, Union, cast, overload +from typing import Any, List, Literal, Optional, Tuple, Union, cast, overload import httpx @@ -1777,6 +1778,131 @@ class AmazonConverseConfig(BaseConfig): tool_set.add(_name) return list(tool_set) + @staticmethod + def _extract_xml_tag_text(content: str, tag_name: str) -> Optional[str]: + match = re.search( + rf"<{tag_name}>\s*(.*?)\s*", + content, + flags=re.DOTALL | re.IGNORECASE, + ) + if match is None: + return None + return match.group(1).strip() + + @staticmethod + def _parse_json_string_if_possible(value: str) -> Any: + value = value.strip() + if value == "": + return "" + try: + return json.loads(value) + except Exception: + return value + + @staticmethod + def _resolve_nexus_tool_name( + raw_name: Optional[str], tool_call_names: set[str] + ) -> Optional[str]: + if raw_name is None: + return None + + normalized_name = raw_name.strip() + if normalized_name in tool_call_names: + return normalized_name + + short_name = normalized_name.split(".")[-1] + if short_name in tool_call_names: + return short_name + + return None + + def _nexus_text_tool_call_to_openai_tool_call( + self, + content: str, + tools: Optional[ + Union[List[ToolBlock], List[OpenAIChatCompletionToolParam]] + ] = None, + ) -> Optional[ChatCompletionMessageToolCall]: + tool_call_names = set(self.get_tool_call_names(tools)) + if content.strip() == "" or not tool_call_names: + return None + + function_match = re.search( + r"\s*(.*?)\s*", + content, + flags=re.DOTALL | re.IGNORECASE, + ) + if function_match is not None: + raw_function_content = function_match.group(1) + parameter_matches = re.findall( + r'(.*?)', + raw_function_content, + flags=re.DOTALL | re.IGNORECASE, + ) + + function_name: Optional[str] = None + function_args: dict[str, Any] = {} + for key, value in parameter_matches: + normalized_key = key.strip() + parsed_value = self._parse_json_string_if_possible(value) + if normalized_key in {"name", "command", "tool_name", "tool"}: + function_name = str(parsed_value).strip() + else: + function_args[normalized_key] = parsed_value + + resolved_function_name = self._resolve_nexus_tool_name( + function_name, tool_call_names + ) + if resolved_function_name is None: + return None + + return ChatCompletionMessageToolCall( + function=Function( + name=resolved_function_name, + arguments=json.dumps(function_args), + ) + ) + + tool_use_match = re.search( + r"\s*(.*?)\s*", + content, + flags=re.DOTALL | re.IGNORECASE, + ) + if tool_use_match is None: + return None + + raw_tool_use_content = tool_use_match.group(1).strip() + raw_tool_name = self._extract_xml_tag_text(raw_tool_use_content, "tool_name") + raw_tool_input = self._extract_xml_tag_text(raw_tool_use_content, "input") + + if raw_tool_name is None: + tool_use_lines = [ + line.strip() for line in raw_tool_use_content.splitlines() if line.strip() + ] + if len(tool_use_lines) == 0: + return None + raw_tool_name = tool_use_lines[0] + raw_tool_input = "\n".join(tool_use_lines[1:]).strip() + + resolved_tool_name = self._resolve_nexus_tool_name( + raw_tool_name, tool_call_names + ) + if resolved_tool_name is None: + return None + + parsed_tool_input: Any = {} + if raw_tool_input: + parsed_tool_input = self._parse_json_string_if_possible(raw_tool_input) + if not isinstance(parsed_tool_input, dict): + return None + + return ChatCompletionMessageToolCall( + function=Function( + name=resolved_tool_name, + arguments=json.dumps(parsed_tool_input), + ) + ) + def apply_tool_call_transformation_if_needed( self, message: Message, @@ -1795,6 +1921,7 @@ class AmazonConverseConfig(BaseConfig): return message, returned_finish_reason if message.content is not None: + parsed_tool_call: Optional[ChatCompletionMessageToolCall] = None try: tool_call_names = self.get_tool_call_names(tools) json_content = json.loads(message.content) @@ -1802,15 +1929,19 @@ class AmazonConverseConfig(BaseConfig): json_content.get("type") == "function" and json_content.get("name") in tool_call_names ): - tool_calls = [ - ChatCompletionMessageToolCall(function=Function(**json_content)) - ] - - message.tool_calls = tool_calls - message.content = None - returned_finish_reason = "tool_calls" + parsed_tool_call = ChatCompletionMessageToolCall( + function=Function(**json_content) + ) except Exception: - pass + parsed_tool_call = self._nexus_text_tool_call_to_openai_tool_call( + content=message.content, + tools=tools, + ) + + if parsed_tool_call is not None: + message.tool_calls = [parsed_tool_call] + message.content = None + returned_finish_reason = "tool_calls" return message, returned_finish_reason @@ -2094,6 +2225,30 @@ class AmazonConverseConfig(BaseConfig): ## HANDLE TOOL CALLS _message = Message(**chat_completion_message) initial_finish_reason = map_finish_reason(completion_response["stopReason"]) + response_tools = optional_params.get("tools") + if response_tools is None: + logging_details = ( + getattr(logging_obj, "model_call_details", None) + if logging_obj is not None + else None + ) + if isinstance(logging_details, dict): + response_tools = logging_details.get("tools") + + if response_tools is None: + response_data: Optional[dict] = None + if isinstance(data, dict): + response_data = data + elif isinstance(data, str): + try: + parsed_data = json.loads(data) + if isinstance(parsed_data, dict): + response_data = parsed_data + except Exception: + pass + + if response_data is not None: + response_tools = (response_data.get("toolConfig") or {}).get("tools") # When json_mode filtered out all synthetic tool calls the response # is plain content, not a pending tool invocation. Fix finish_reason @@ -2106,7 +2261,7 @@ class AmazonConverseConfig(BaseConfig): returned_finish_reason, ) = self.apply_tool_call_transformation_if_needed( message=_message, - tools=optional_params.get("tools"), + tools=response_tools, initial_finish_reason=initial_finish_reason, ) model_response.choices = [ diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index 5f2ed3dc00f..f467cd9f80f 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -206,6 +206,251 @@ def test_apply_tool_call_transformation_if_needed(): ) +def test_apply_tool_call_transformation_if_needed_with_nexus_function_text(): + from litellm.types.utils import Message + + config = AmazonConverseConfig() + tools = [ + { + "type": "function", + "function": { + "name": "read_file", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + }, + "required": ["path"], + }, + }, + } + ] + message = Message( + role="assistant", + content='\n\nread_file\nREADME.md\n', + ) + + transformed_message, transformed_finish_reason = ( + config.apply_tool_call_transformation_if_needed( + message=message, + tools=tools, + initial_finish_reason="stop", + ) + ) + + assert transformed_message.content is None + assert transformed_finish_reason == "tool_calls" + assert transformed_message.tool_calls is not None + assert transformed_message.tool_calls[0].function.name == "read_file" + assert transformed_message.tool_calls[0].function.arguments == json.dumps( + {"path": "README.md"} + ) + + +def test_apply_tool_call_transformation_if_needed_with_nexus_tool_use_line_format(): + from litellm.types.utils import Message + + config = AmazonConverseConfig() + tools = [ + { + "type": "function", + "function": { + "name": "read_file", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + }, + "required": ["path"], + }, + }, + } + ] + message = Message( + role="assistant", + content='desktop-commander.read_file\n{"path":"README.md"}', + ) + + transformed_message, transformed_finish_reason = ( + config.apply_tool_call_transformation_if_needed( + message=message, + tools=tools, + initial_finish_reason="stop", + ) + ) + + assert transformed_message.content is None + assert transformed_finish_reason == "tool_calls" + assert transformed_message.tool_calls is not None + assert transformed_message.tool_calls[0].function.name == "read_file" + assert transformed_message.tool_calls[0].function.arguments == json.dumps( + {"path": "README.md"} + ) + + +def test_apply_tool_call_transformation_if_needed_with_nexus_tool_use_xml_format(): + from litellm.types.utils import Message + + config = AmazonConverseConfig() + tools = [ + { + "type": "function", + "function": { + "name": "read_file", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + }, + "required": ["path"], + }, + }, + } + ] + message = Message( + role="assistant", + content='desktop-commanderread_file{"path":"README.md"}', + ) + + transformed_message, transformed_finish_reason = ( + config.apply_tool_call_transformation_if_needed( + message=message, + tools=tools, + initial_finish_reason="stop", + ) + ) + + assert transformed_message.content is None + assert transformed_finish_reason == "tool_calls" + assert transformed_message.tool_calls is not None + assert transformed_message.tool_calls[0].function.name == "read_file" + assert transformed_message.tool_calls[0].function.arguments == json.dumps( + {"path": "README.md"} + ) + + +def test_apply_tool_call_transformation_if_needed_ignores_unknown_nexus_tool_name(): + from litellm.types.utils import Message + + config = AmazonConverseConfig() + tools = [ + { + "type": "function", + "function": { + "name": "write_file", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + }, + "required": ["path"], + }, + }, + } + ] + message = Message( + role="assistant", + content='\n\nread_file\nREADME.md\n', + ) + + transformed_message, transformed_finish_reason = ( + config.apply_tool_call_transformation_if_needed( + message=message, + tools=tools, + initial_finish_reason="stop", + ) + ) + + assert transformed_message.content == message.content + assert transformed_finish_reason == "stop" + assert transformed_message.tool_calls is None + + +def test_transform_response_uses_logging_tools_when_optional_tools_missing(): + import httpx + + from litellm.types.utils import ModelResponse + + response_json = { + "output": { + "message": { + "role": "assistant", + "content": [ + { + "text": '\n\nread_file\nREADME.md\n' + } + ], + } + }, + "stopReason": "end_turn", + "usage": { + "inputTokens": 10, + "outputTokens": 5, + "totalTokens": 15, + }, + } + response = httpx.Response(200, json=response_json) + config = AmazonConverseConfig() + logging_obj = MagicMock() + logging_obj.model_call_details = { + "tools": [ + { + "type": "function", + "function": { + "name": "read_file", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string"}, + } + }, + }, + } + ] + } + + request_data = { + "toolConfig": { + "tools": [ + { + "toolSpec": { + "name": "read_file", + "inputSchema": { + "json": { + "type": "object", + "properties": { + "path": {"type": "string"}, + }, + } + }, + } + } + ] + } + } + + result = config._transform_response( + model="bedrock/converse/claude-opus-4.6", + response=response, + model_response=ModelResponse(), + stream=False, + logging_obj=logging_obj, + optional_params={}, + api_key=None, + data=json.dumps(request_data), + messages=[], + encoding=None, + ) + + assert result.choices[0].finish_reason == "tool_calls" + assert result.choices[0].message.content is None + assert result.choices[0].message.tool_calls is not None + assert result.choices[0].message.tool_calls[0].function.name == "read_file" + assert result.choices[0].message.tool_calls[0].function.arguments == json.dumps( + {"path": "README.md"} + ) + + def test_transform_tool_call_with_cache_control(): from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig