diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index 0968185b084..e987179d5b7 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any, Final, Protocol, TypedDict import httpx from typing_extensions import ReadOnly +from litellm.exceptions import UnsupportedParamsError from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolCallChunk from litellm.types.utils import ( ChatCompletionMessageToolCall, @@ -308,21 +309,43 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): return body - def _transform_tool_choice_to_anthropic(self, tool_choice: Any) -> dict[str, Any]: + def _transform_tool_choice_to_anthropic(self, tool_choice: Any) -> dict[str, Any] | None: """ Convert tool_choice from OpenAI format to Anthropic format. OpenAI string values: "auto", "required", "none" OpenAI dict: {"type": "function", "function": {"name": "..."}} Anthropic: {"type": "auto"}, {"type": "any"}, {"type": "tool", "name": "..."} + + Returns None when the value is unsupported and `litellm.drop_params` + is set, in which case the caller drops the parameter. + + Unrecognized strings raise rather than defaulting to "auto". Falling + back inverts the caller's intent — "required" means the model must + call a tool, "auto" means it may — and the only symptom is the model + occasionally not calling one, which reads as a model quality problem + rather than a silently dropped constraint. """ + import litellm + if isinstance(tool_choice, str): mapping: Final = { "auto": {"type": "auto"}, "required": {"type": "any"}, "none": {"type": "none"}, } - return mapping.get(tool_choice, {"type": "auto"}) + if tool_choice not in mapping: + if litellm.drop_params is True: + return None + raise UnsupportedParamsError( + message=( + f"Snowflake doesn't support tool_choice={tool_choice}. " + "Supported tool_choice values=['auto', 'required', 'none', json object]. " + "To drop it from the call, set `litellm.drop_params = True`." + ), + status_code=400, + ) + return mapping[tool_choice] elif isinstance(tool_choice, dict): if tool_choice.get("type") == "function": func: Final = tool_choice.get("function", {}) @@ -345,7 +368,11 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): optional_params["tools"] = self._transform_tools_to_anthropic(optional_params["tools"]) if "tool_choice" in optional_params: - optional_params["tool_choice"] = self._transform_tool_choice_to_anthropic(optional_params["tool_choice"]) + transformed_tool_choice: Final = self._transform_tool_choice_to_anthropic(optional_params["tool_choice"]) + if transformed_tool_choice is None: # unsupported + litellm.drop_params + optional_params.pop("tool_choice") + else: + optional_params["tool_choice"] = transformed_tool_choice max_completion_tokens: Final = optional_params.pop("max_completion_tokens", None) if max_completion_tokens and "max_tokens" not in optional_params: diff --git a/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py b/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py index a182656e4a8..102c09de8e5 100644 --- a/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py +++ b/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py @@ -17,6 +17,7 @@ import pytest import litellm from litellm import completion, acompletion from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler +from litellm.exceptions import UnsupportedParamsError from litellm.llms.snowflake.chat.transformation import SnowflakeConfig from litellm.types.utils import ModelResponse @@ -118,6 +119,76 @@ class TestSnowflakeToolTransformation: f"got {transformed_request['tool_choice']}" ) + def test_claude_tool_choice_maps_supported_string_values(self): + """ + Claude models route through the Anthropic request path, where OpenAI + string tool_choice values are translated to Anthropic's object form. + """ + config = SnowflakeConfig() + + expected = { + "auto": {"type": "auto"}, + "required": {"type": "any"}, + "none": {"type": "none"}, + } + + for value, want in expected.items(): + transformed_request = config.transform_request( + model="snowflake/claude-3-5-sonnet", + messages=[{"role": "user", "content": "Test"}], + optional_params={"tool_choice": value}, + litellm_params={}, + headers={}, + ) + + assert transformed_request["tool_choice"] == want, ( + f"tool_choice='{value}' should map to {want}, got {transformed_request['tool_choice']}" + ) + + @pytest.mark.parametrize( + "tool_choice", + ["any", "Required", "REQUIRED", "required ", "requried", "tool"], + ) + def test_claude_unsupported_tool_choice_string_raises(self, tool_choice): + """ + Unrecognized strings must raise, not silently fall back to "auto". + + "required" means the model must call a tool and "auto" means it may, + so defaulting inverts the caller's intent with no error surfaced. The + lookup is exact-match, so casing and whitespace variants land here too. + """ + config = SnowflakeConfig() + + with pytest.raises(UnsupportedParamsError) as exc_info: + config.transform_request( + model="snowflake/claude-3-5-sonnet", + messages=[{"role": "user", "content": "Test"}], + optional_params={"tool_choice": tool_choice}, + litellm_params={}, + headers={}, + ) + + assert "tool_choice" in str(exc_info.value) + + def test_claude_unsupported_tool_choice_dropped_when_drop_params_set(self): + """With litellm.drop_params, an unsupported value is dropped, not raised.""" + config = SnowflakeConfig() + + original = litellm.drop_params + litellm.drop_params = True + try: + transformed_request = config.transform_request( + model="snowflake/claude-3-5-sonnet", + messages=[{"role": "user", "content": "Test"}], + optional_params={"tool_choice": "any"}, + litellm_params={}, + headers={}, + ) + finally: + litellm.drop_params = original + + assert "tool_choice" not in transformed_request + def test_transform_response_with_tool_calls(self): """ Test that standard OpenAI tool_calls response format is parsed correctly.