Repository: BerriAI/litellm
Pull request: fix: support Cursor IDE tool_choice format {"type": "auto"}
## Title

Support Cursor IDE tool_choice format {"type": "auto"}

## Relevant issues

Fixes #12098

## Pre-Submission checklist

**Please complete all items before asking a LiteLLM maintainer to review your PR**

- [x] I have Added testing in the [`tests/litellm/`](https://github.com/BerriAI/litellm/tree/main/tests/litellm) directory, **Adding at least 1 test is a hard requirement** - [see details](https://docs.litellm.ai/docs/extras/contributing_code)
- [x] I have added a screenshot of my new test passing locally 
- [x] My PR passes all unit tests on [`make test-unit`](https://docs.litellm.ai/docs/extras/contributing_code)
- [x] My PR's scope is as isolated as possible, it only solves 1 specific problem


## Type

🐛 Bug Fix

## Changes

- Updated `validate_chat_completion_tool_choice` function in `litellm/utils.py` to handle Cursor IDE's non-standard tool_choice format
- When receiving `{"type": "auto"}`, `{"type": "none"}`, or `{"type": "required"}`, the function now normalizes these to their string equivalents ("auto", "none", "required")
- Added comprehensive test coverage in `tests/litellm_utils_tests/test_validate_tool_choice.py`

### Test Results

All tests pass successfully:
```
tests/litellm_utils_tests/test_validate_tool_choice.py::test_validate_tool_choice_cursor_format PASSED
tests/litellm_utils_tests/test_validate_tool_choice.py::test_validate_tool_choice_invalid_dict PASSED
tests/litellm_utils_tests/test_validate_tool_choice.py::test_validate_tool_choice_invalid_type PASSED
tests/litellm_utils_tests/test_validate_tool_choice.py::test_validate_tool_choice_none PASSED
tests/litellm_utils_tests/test_validate_tool_choice.py::test_validate_tool_choice_standard_dict PASSED
tests/litellm_utils_tests/test_validate_tool_choice.py::test_validate_tool_choice_string PASSED
```

### Changes Summary

1. Modified validation logic to detect and normalize Cursor IDE's format
2. Maintains backward compatibility with standard OpenAI formats
3. Added 6 comprehensive test cases covering all scenarios

Problem statement:
[Bug]: Cursor IDE compatibility: tool_choice validation error when using {"type": "auto"} format - should support both OpenAI specs
### What happened?

I'm encountering an API validation error when using LiteLLM as a proxy for Claude models through Cursor IDE. The issue occurs when Cursor sends requests with `tool_choice: {"type": "auto"}` format, which LiteLLM rejects as invalid.

**Setup:**
- LiteLLM deployed as API proxy
- Cursor IDE configured to use LiteLLM endpoint with custom model `claude-sonnet-4-20250514`
- Using Cursor's Agent mode (which enables function calling)

**Expected Behavior:**
LiteLLM should accept `tool_choice: {"type": "auto"}` as it's a valid format used by some OpenAI-compatible clients.

**Actual Behavior:**
LiteLLM throws validation error: `Invalid tool choice, tool_choice={'type': 'auto'}. Please ensure tool_choice follows the OpenAI spec`

**Request payload from Cursor:**
```json
{
  "model": "claude-sonnet-4-20250514",
  "tools": [...],
  "stream": true,
  "system": [...],
  "messages": [...],
  "max_tokens": 4096,
  "temperature": 0.7,
  "tool_choice": {
    "type": "auto"
  }
}
```

The issue appears to be in the `validate_chat_completion_tool_choice` function which doesn't recognize the `{"type": "auto"}` format as valid, even though it should be equivalent to `"auto"`.


### Relevant log output

```shell
litellm.APIConnectionError: Invalid tool choice, tool_choice={'type': 'auto'}. Please ensure tool_choice follows the OpenAI spec
Traceback (most recent call last):
  File "/usr/lib/python3.13/site-packages/litellm/main.py", line 517, in acompletion
    init_response = await loop.run_in_executor(None, func_with_context)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.13/concurrent/futures/thread.py", line 59, in run
    result = self.fn(*self.args, **self.kwargs)
  File "/usr/lib/python3.13/site-packages/litellm/utils.py", line 1032, in wrapper
    result = original_function(*args, **kwargs)
  File "/usr/lib/python3.13/site-packages/litellm/main.py", line 922, in completion
    tool_choice = validate_chat_completion_tool_choice(tool_choice=tool_choice)
  File "/usr/lib/python3.13/site-packages/litellm/utils.py", line 6569, in validate_chat_completion_tool_choice
    raise Exception(
      f"Invalid tool choice, tool_choice={tool_choice}. Please ensure tool_choice follows the OpenAI spec"
    )
Exception: Invalid tool choice, tool_choice={'type': 'auto'}. Please ensure tool_choice follows the OpenAI spec
. Received Model Group=claude-sonnet-4-20250514
Available Model Group Fallbacks=None
```

### Are you a ML Ops Team?

No

### What LiteLLM version are you on ?

v1.73.2

### Twitter / LinkedIn details

_No response_

Hints from issue discussion:

Commit message: fix: support Cursor IDE tool_choice format {"type": "auto"}

- Update validate_chat_completion_tool_choice to normalize {"type": "auto"} to "auto"
- Handles Cursor IDE sending non-standard tool_choice format
- Add comprehensive tests for tool choice validation

Fixes #12098

Patch to review:
diff --git a/litellm/utils.py b/litellm/utils.py
index 43f2b6c3f9f5..15ae459de9b0 100644
--- a/litellm/utils.py
+++ b/litellm/utils.py
@@ -6571,6 +6571,14 @@ def validate_chat_completion_tool_choice(
     elif isinstance(tool_choice, str):
         return tool_choice
     elif isinstance(tool_choice, dict):
+        # Handle Cursor IDE format: {"type": "auto"} -> "auto"
+        if (
+            tool_choice.get("type") in ["auto", "none", "required"]
+            and "function" not in tool_choice
+        ):
+            return tool_choice["type"]
+        
+        # Standard OpenAI format: {"type": "function", "function": {...}}
         if tool_choice.get("type") is None or tool_choice.get("function") is None:
             raise Exception(
                 f"Invalid tool choice, tool_choice={tool_choice}. Please ensure tool_choice follows the OpenAI spec"
diff --git a/tests/litellm_utils_tests/test_validate_tool_choice.py b/tests/litellm_utils_tests/test_validate_tool_choice.py
new file mode 100644
index 000000000000..b4484b87505d
--- /dev/null
+++ b/tests/litellm_utils_tests/test_validate_tool_choice.py
@@ -0,0 +1,63 @@
+import pytest
+import sys
+import os
+
+sys.path.insert(0, os.path.abspath("../.."))
+
+from litellm.utils import validate_chat_completion_tool_choice
+
+
+def test_validate_tool_choice_none():
+    """Test that None is returned as-is."""
+    result = validate_chat_completion_tool_choice(None)
+    assert result is None
+
+
+def test_validate_tool_choice_string():
+    """Test that string values are returned as-is."""
+    assert validate_chat_completion_tool_choice("auto") == "auto"
+    assert validate_chat_completion_tool_choice("none") == "none"
+    assert validate_chat_completion_tool_choice("required") == "required"
+
+
+def test_validate_tool_choice_standard_dict():
+    """Test standard OpenAI format with function."""
+    tool_choice = {"type": "function", "function": {"name": "my_function"}}
+    result = validate_chat_completion_tool_choice(tool_choice)
+    assert result == tool_choice
+
+
+def test_validate_tool_choice_cursor_format():
+    """Test Cursor IDE format: {"type": "auto"} -> "auto"."""
+    assert validate_chat_completion_tool_choice({"type": "auto"}) == "auto"
+    assert validate_chat_completion_tool_choice({"type": "none"}) == "none"
+    assert validate_chat_completion_tool_choice({"type": "required"}) == "required"
+
+
+def test_validate_tool_choice_invalid_dict():
+    """Test that invalid dict formats raise exceptions."""
+    # Missing both type and function
+    with pytest.raises(Exception) as exc_info:
+        validate_chat_completion_tool_choice({})
+    assert "Invalid tool choice" in str(exc_info.value)
+    
+    # Invalid type value
+    with pytest.raises(Exception) as exc_info:
+        validate_chat_completion_tool_choice({"type": "invalid"})
+    assert "Invalid tool choice" in str(exc_info.value)
+    
+    # Has type but missing function when type is "function"
+    with pytest.raises(Exception) as exc_info:
+        validate_chat_completion_tool_choice({"type": "function"})
+    assert "Invalid tool choice" in str(exc_info.value)
+
+
+def test_validate_tool_choice_invalid_type():
+    """Test that invalid types raise exceptions."""
+    with pytest.raises(Exception) as exc_info:
+        validate_chat_completion_tool_choice(123)
+    assert "Got=<class 'int'>" in str(exc_info.value)
+    
+    with pytest.raises(Exception) as exc_info:
+        validate_chat_completion_tool_choice([])
+    assert "Got=<class 'list'>" in str(exc_info.value)
\ No newline at end of file


Code review comments:
diff_hunk: @@ -6571,6 +6571,14 @@ def validate_chat_completion_tool_choice(
     elif isinstance(tool_choice, str):
         return tool_choice
     elif isinstance(tool_choice, dict):
+        # Handle Cursor IDE format: {"type": "auto"} -> "auto"
+        if (
+            tool_choice.get("type") in ["auto", "none", "required"]
+            and "function" not in tool_choice
+        ):
+            return tool_choice["type"]
path: litellm/utils.py
text: @user1:
why return the type here ? IMO you should return the full tool choice object 

See how this is used `tool_choice = validate_chat_completion_tool_choice(tool_choice=tool_choice)`