mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
fix(bedrock): parse Nexus Claude text tool calls in converse responses
This commit is contained in:
parent
d04373f4ce
commit
26f3216db8
2 changed files with 410 additions and 10 deletions
|
|
@ -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*</{tag_name}>",
|
||||
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"<function>\s*(.*?)\s*</function>",
|
||||
content,
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if function_match is not None:
|
||||
raw_function_content = function_match.group(1)
|
||||
parameter_matches = re.findall(
|
||||
r'<parameter\s+name=["\']([^"\']+)["\']\s*>(.*?)</parameter>',
|
||||
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"<tool_use>\s*(.*?)\s*</tool_use>",
|
||||
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 = [
|
||||
|
|
|
|||
|
|
@ -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<function>\n<parameter name="name">read_file</parameter>\n<parameter name="path">README.md</parameter>\n</function>',
|
||||
)
|
||||
|
||||
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='<tool_use>desktop-commander.read_file\n{"path":"README.md"}</tool_use>',
|
||||
)
|
||||
|
||||
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='<tool_use><server_name>desktop-commander</server_name><tool_name>read_file</tool_name><input>{"path":"README.md"}</input></tool_use>',
|
||||
)
|
||||
|
||||
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<function>\n<parameter name="name">read_file</parameter>\n<parameter name="path">README.md</parameter>\n</function>',
|
||||
)
|
||||
|
||||
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<function>\n<parameter name="name">read_file</parameter>\n<parameter name="path">README.md</parameter>\n</function>'
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
"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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue