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:
Navnit Shukla 2026-06-08 13:54:33 -07:00
parent a7de06c337
commit c5b36cffc6
No known key found for this signature in database
GPG key ID: 46D87897A91C215F
2 changed files with 223 additions and 0 deletions

View file

@ -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] = {

View file

@ -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"