mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
fix: handle multi-turn tool conversations and OpenAI→Anthropic tool format conversion
- _extract_system_and_messages now preserves tool_calls from assistant messages and converts them to Anthropic tool_use content blocks - tool role messages are converted to user role with tool_result content blocks (as required by Anthropic Messages API) - Added _transform_tools_to_anthropic() to convert OpenAI tool format (type/function/parameters) to Anthropic format (name/input_schema) - Added comprehensive tests for multi-turn tool conversations Addresses review feedback on PR #29964
This commit is contained in:
parent
a7de06c337
commit
c5b36cffc6
2 changed files with 223 additions and 0 deletions
|
|
@ -124,6 +124,31 @@ class SnowflakeCortexAnthropicConfig(SnowflakeBaseConfig):
|
|||
headers["anthropic-version"] = ANTHROPIC_VERSION
|
||||
return headers
|
||||
|
||||
def _transform_tools_to_anthropic(self, tools: List[Dict]) -> List[Dict]:
|
||||
"""
|
||||
Convert tools from OpenAI format to Anthropic format.
|
||||
|
||||
OpenAI: {"type": "function", "function": {"name": ..., "description": ..., "parameters": {...}}}
|
||||
Anthropic: {"name": ..., "description": ..., "input_schema": {...}}
|
||||
"""
|
||||
anthropic_tools = []
|
||||
for tool in tools:
|
||||
if tool.get("type") == "function" and "function" in tool:
|
||||
func = tool["function"]
|
||||
anthropic_tool: Dict[str, Any] = {
|
||||
"name": func.get("name", ""),
|
||||
}
|
||||
if "description" in func:
|
||||
anthropic_tool["description"] = func["description"]
|
||||
if "parameters" in func:
|
||||
anthropic_tool["input_schema"] = func["parameters"]
|
||||
else:
|
||||
anthropic_tool["input_schema"] = {"type": "object", "properties": {}}
|
||||
anthropic_tools.append(anthropic_tool)
|
||||
else:
|
||||
anthropic_tools.append(tool)
|
||||
return anthropic_tools
|
||||
|
||||
def _extract_system_and_messages(
|
||||
self, messages: List[AllMessageValues]
|
||||
) -> tuple[Optional[Union[str, List[Dict]]], List[Dict]]:
|
||||
|
|
@ -132,6 +157,10 @@ class SnowflakeCortexAnthropicConfig(SnowflakeBaseConfig):
|
|||
|
||||
Anthropic's /messages endpoint takes system as a top-level param,
|
||||
not inside the messages array.
|
||||
|
||||
Handles tool-use messages:
|
||||
- assistant messages with tool_calls → converted to Anthropic tool_use content blocks
|
||||
- tool role messages → converted to user role with tool_result content blocks
|
||||
"""
|
||||
system: Optional[Union[str, List[Dict]]] = None
|
||||
conversation: List[Dict] = []
|
||||
|
|
@ -146,6 +175,41 @@ class SnowflakeCortexAnthropicConfig(SnowflakeBaseConfig):
|
|||
|
||||
if role == "system":
|
||||
system = content
|
||||
elif role == "assistant":
|
||||
tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else getattr(msg, "tool_calls", None)
|
||||
if tool_calls:
|
||||
content_blocks: List[Dict[str, Any]] = []
|
||||
if content:
|
||||
content_blocks.append({"type": "text", "text": content})
|
||||
for tc in tool_calls:
|
||||
func = tc.get("function", {}) if isinstance(tc, dict) else getattr(tc, "function", {})
|
||||
tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
|
||||
func_name = func.get("name", "") if isinstance(func, dict) else getattr(func, "name", "")
|
||||
func_args = func.get("arguments", "{}") if isinstance(func, dict) else getattr(func, "arguments", "{}")
|
||||
try:
|
||||
input_data = json.loads(func_args) if isinstance(func_args, str) else func_args
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
input_data = {}
|
||||
content_blocks.append({
|
||||
"type": "tool_use",
|
||||
"id": tc_id,
|
||||
"name": func_name,
|
||||
"input": input_data,
|
||||
})
|
||||
conversation.append({"role": "assistant", "content": content_blocks})
|
||||
else:
|
||||
conversation.append({"role": "assistant", "content": content})
|
||||
elif role == "tool":
|
||||
tool_call_id = msg.get("tool_call_id", "") if isinstance(msg, dict) else getattr(msg, "tool_call_id", "")
|
||||
tool_content = content if isinstance(content, str) else json.dumps(content)
|
||||
conversation.append({
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_call_id,
|
||||
"content": tool_content,
|
||||
}],
|
||||
})
|
||||
else:
|
||||
conversation.append({"role": role, "content": content})
|
||||
|
||||
|
|
@ -172,6 +236,9 @@ class SnowflakeCortexAnthropicConfig(SnowflakeBaseConfig):
|
|||
|
||||
system, conversation = self._extract_system_and_messages(messages)
|
||||
|
||||
if "tools" in optional_params:
|
||||
optional_params["tools"] = self._transform_tools_to_anthropic(optional_params["tools"])
|
||||
|
||||
model_name = model.removeprefix("snowflake/")
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
|
|
|
|||
|
|
@ -486,3 +486,159 @@ class TestIsClaudeModel:
|
|||
assert _is_claude_model("snowflake/mistral-large") is False
|
||||
assert _is_claude_model("snowflake/deepseek-r1") is False
|
||||
assert _is_claude_model("snowflake/snowflake-arctic") is False
|
||||
|
||||
|
||||
# ─── Anthropic Tool Transformation Tests ──────────────────────────────────
|
||||
|
||||
class TestAnthropicToolTransformation:
|
||||
def setup_method(self):
|
||||
self.cfg = SnowflakeCortexAnthropicConfig()
|
||||
|
||||
def test_openai_tools_converted_to_anthropic_format(self):
|
||||
messages = [{"role": "user", "content": "What's the weather?"}]
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get current weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
body = self.cfg.transform_request(
|
||||
model="snowflake/claude-sonnet-4-5",
|
||||
messages=messages,
|
||||
optional_params={"tools": tools},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
assert len(body["tools"]) == 1
|
||||
tool = body["tools"][0]
|
||||
assert tool["name"] == "get_weather"
|
||||
assert tool["description"] == "Get current weather"
|
||||
assert "input_schema" in tool
|
||||
assert tool["input_schema"]["properties"]["city"]["type"] == "string"
|
||||
assert "function" not in tool
|
||||
assert "type" not in tool
|
||||
|
||||
def test_tools_already_in_anthropic_format_pass_through(self):
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
tools = [{"name": "my_tool", "input_schema": {"type": "object", "properties": {}}}]
|
||||
body = self.cfg.transform_request(
|
||||
model="snowflake/claude-sonnet-4-5",
|
||||
messages=messages,
|
||||
optional_params={"tools": tools},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
assert body["tools"] == tools
|
||||
|
||||
|
||||
class TestAnthropicMultiTurnToolMessages:
|
||||
def setup_method(self):
|
||||
self.cfg = SnowflakeCortexAnthropicConfig()
|
||||
|
||||
def test_assistant_tool_calls_converted_to_tool_use_blocks(self):
|
||||
messages = [
|
||||
{"role": "user", "content": "What's the weather in Paris?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "Paris"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_123",
|
||||
"content": "Sunny, 22°C",
|
||||
},
|
||||
{"role": "user", "content": "Thanks!"},
|
||||
]
|
||||
body = self.cfg.transform_request(
|
||||
model="snowflake/claude-sonnet-4-5",
|
||||
messages=messages,
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
msgs = body["messages"]
|
||||
assert msgs[0] == {"role": "user", "content": "What's the weather in Paris?"}
|
||||
|
||||
assistant_msg = msgs[1]
|
||||
assert assistant_msg["role"] == "assistant"
|
||||
assert isinstance(assistant_msg["content"], list)
|
||||
assert assistant_msg["content"][0]["type"] == "tool_use"
|
||||
assert assistant_msg["content"][0]["id"] == "call_123"
|
||||
assert assistant_msg["content"][0]["name"] == "get_weather"
|
||||
assert assistant_msg["content"][0]["input"] == {"city": "Paris"}
|
||||
|
||||
tool_result_msg = msgs[2]
|
||||
assert tool_result_msg["role"] == "user"
|
||||
assert tool_result_msg["content"][0]["type"] == "tool_result"
|
||||
assert tool_result_msg["content"][0]["tool_use_id"] == "call_123"
|
||||
assert tool_result_msg["content"][0]["content"] == "Sunny, 22°C"
|
||||
|
||||
assert msgs[3] == {"role": "user", "content": "Thanks!"}
|
||||
|
||||
def test_assistant_with_text_and_tool_calls(self):
|
||||
messages = [
|
||||
{"role": "user", "content": "Check weather"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Let me check that for you.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_456",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "London"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
body = self.cfg.transform_request(
|
||||
model="snowflake/claude-sonnet-4-5",
|
||||
messages=messages,
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
assistant_msg = body["messages"][1]
|
||||
assert assistant_msg["content"][0] == {"type": "text", "text": "Let me check that for you."}
|
||||
assert assistant_msg["content"][1]["type"] == "tool_use"
|
||||
assert assistant_msg["content"][1]["name"] == "get_weather"
|
||||
|
||||
def test_tool_role_never_in_output(self):
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "f", "arguments": "{}"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "result"},
|
||||
]
|
||||
body = self.cfg.transform_request(
|
||||
model="snowflake/claude-sonnet-4-5",
|
||||
messages=messages,
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
for msg in body["messages"]:
|
||||
assert msg["role"] != "tool"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue