diff --git a/litellm/llms/snowflake/anthropic/transformation.py b/litellm/llms/snowflake/anthropic/transformation.py index 48dd33ab6e6..13313664290 100644 --- a/litellm/llms/snowflake/anthropic/transformation.py +++ b/litellm/llms/snowflake/anthropic/transformation.py @@ -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] = { diff --git a/tests/test_litellm/llms/snowflake/test_snowflake_native_endpoints.py b/tests/test_litellm/llms/snowflake/test_snowflake_native_endpoints.py index a52724168c3..7902a78c4c9 100644 --- a/tests/test_litellm/llms/snowflake/test_snowflake_native_endpoints.py +++ b/tests/test_litellm/llms/snowflake/test_snowflake_native_endpoints.py @@ -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"