diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index e0c1051dd29..bbe4b76775d 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -9,6 +9,14 @@ body:
Thanks for taking the time to fill out this bug report!
**💡 Tip:** See our [Troubleshooting Guide](https://docs.litellm.ai/docs/troubleshoot) for what information to include.
+ - type: checkboxes
+ id: duplicate-check
+ attributes:
+ label: Check for existing issues
+ description: Please search to see if an issue already exists for the bug you encountered.
+ options:
+ - label: I have searched the existing issues and checked that my issue is not a duplicate.
+ required: true
- type: textarea
id: what-happened
attributes:
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index e575db7302a..4cc42901897 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -7,6 +7,14 @@ body:
attributes:
value: |
Thanks for making LiteLLM better!
+ - type: checkboxes
+ id: duplicate-check
+ attributes:
+ label: Check for existing issues
+ description: Please search to see if an issue already exists for the feature you are requesting.
+ options:
+ - label: I have searched the existing issues and checked that my issue is not a duplicate.
+ required: true
- type: textarea
id: the-feature
attributes:
diff --git a/.github/workflows/check_duplicate_issues.yml b/.github/workflows/check_duplicate_issues.yml
new file mode 100644
index 00000000000..14d6964fcdb
--- /dev/null
+++ b/.github/workflows/check_duplicate_issues.yml
@@ -0,0 +1,29 @@
+name: Check Duplicate Issues
+
+on:
+ issues:
+ types: [opened, edited]
+
+jobs:
+ check-duplicate:
+ runs-on: ubuntu-latest
+ permissions:
+ issues: write
+ contents: read
+ steps:
+ - name: Check for potential duplicates
+ uses: wow-actions/potential-duplicates@v1
+ with:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ label: potential-duplicate
+ threshold: 0.6
+ reaction: eyes
+ comment: |
+ **⚠️ Potential duplicate detected**
+
+ This issue appears similar to existing issue(s):
+ {{#issues}}
+ - [#{{number}}]({{html_url}}) - {{title}} ({{accuracy}}% similar)
+ {{/issues}}
+
+ Please review the linked issue(s) to see if they address your concern. If this is not a duplicate, please provide additional context to help us understand the difference.
diff --git a/docs/my-website/docs/completion/message_sanitization.md b/docs/my-website/docs/completion/message_sanitization.md
deleted file mode 100644
index 0a1f766e2fd..00000000000
--- a/docs/my-website/docs/completion/message_sanitization.md
+++ /dev/null
@@ -1,468 +0,0 @@
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
-
-# Message Sanitization for Tool Calling for anthropic models
-
-**Automatically fix common message formatting issues when using tool calling with `modify_params=True`**
-
-LiteLLM can automatically sanitize messages to handle common issues that occur during tool calling workflows, especially when using OpenAI-compatible clients with providers that have strict message format requirements (like Anthropic Claude).
-
-## Overview
-
-When `litellm.modify_params = True` is enabled, LiteLLM automatically sanitizes messages to fix three common issues:
-
-1. **Orphaned Tool Calls** - Assistant messages with tool_calls but missing tool results
-2. **Orphaned Tool Results** - Tool messages that reference non-existent tool_call_ids
-3. **Empty Message Content** - Messages with empty or whitespace-only text content
-
-This ensures your tool calling workflows work seamlessly across different LLM providers without manual message validation.
-
-## Why Message Sanitization?
-
-Different LLM providers have varying requirements for message formats, especially during tool calling:
-
-- **Anthropic Claude** requires every tool_call to have a corresponding tool result
-- Some providers reject messages with empty content
-- OpenAI-compatible clients may not always maintain perfect message consistency
-
-Without sanitization, these issues cause API errors that interrupt your workflows. With `modify_params=True`, LiteLLM handles these edge cases automatically.
-
-## Quick Start
-
-
-
-
-```python
-import litellm
-
-# Enable automatic message sanitization
-litellm.modify_params = True
-
-# This will work even if messages have formatting issues
-response = litellm.completion(
- model="anthropic/claude-3-5-sonnet-20241022",
- messages=[
- {"role": "user", "content": "What's the weather in Boston?"},
- {
- "role": "assistant",
- "tool_calls": [
- {
- "id": "call_123",
- "type": "function",
- "function": {"name": "get_weather", "arguments": '{"city": "Boston"}'}
- }
- ]
- # Missing tool result - LiteLLM will add a dummy result automatically
- },
- {"role": "user", "content": "Thanks!"}
- ],
- tools=[{
- "type": "function",
- "function": {
- "name": "get_weather",
- "description": "Get weather for a city",
- "parameters": {
- "type": "object",
- "properties": {"city": {"type": "string"}},
- "required": ["city"]
- }
- }
- }]
-)
-```
-
-
-
-
-```yaml
-litellm_settings:
- modify_params: true # Enable automatic message sanitization
-
-model_list:
- - model_name: claude-3-5-sonnet
- litellm_params:
- model: anthropic/claude-3-5-sonnet-20241022
-```
-
-
-
-
-## Sanitization Cases
-
-### Case A: Orphaned Tool Calls (Missing Tool Results)
-
-**Problem:** An assistant message contains `tool_calls`, but no corresponding tool result messages follow.
-
-**Solution:** LiteLLM automatically adds dummy tool result messages for any missing tool results.
-
-**Example:**
-
-```python
-import litellm
-litellm.modify_params = True
-
-# Messages with orphaned tool calls
-messages = [
- {"role": "user", "content": "Search for Python tutorials"},
- {
- "role": "assistant",
- "tool_calls": [
- {
- "id": "call_abc123",
- "type": "function",
- "function": {"name": "web_search", "arguments": '{"query": "Python tutorials"}'}
- }
- ]
- },
- # Missing tool result here!
- {"role": "user", "content": "What about JavaScript?"}
-]
-
-# LiteLLM automatically adds:
-# {
-# "role": "tool",
-# "tool_call_id": "call_abc123",
-# "content": "[System: Tool execution skipped/interrupted by user. No result provided for tool 'web_search'.]"
-# }
-
-response = litellm.completion(
- model="anthropic/claude-3-5-sonnet-20241022",
- messages=messages,
- tools=[...]
-)
-```
-
-**When this happens:**
-- User interrupts tool execution
-- Client loses tool results due to network issues
-- Conversation flow changes before tool completes
-- Multi-turn conversations where tools are optional
-
-### Case B: Orphaned Tool Results (Invalid tool_call_id)
-
-**Problem:** A tool message references a `tool_call_id` that doesn't exist in any previous assistant message.
-
-**Solution:** LiteLLM automatically removes these orphaned tool result messages.
-
-**Example:**
-
-```python
-import litellm
-litellm.modify_params = True
-
-# Messages with orphaned tool result
-messages = [
- {"role": "user", "content": "Hello"},
- {"role": "assistant", "content": "Hi! How can I help?"},
- {
- "role": "tool",
- "tool_call_id": "call_nonexistent", # This tool_call_id doesn't exist!
- "content": "Some result"
- }
-]
-
-# LiteLLM automatically removes the orphaned tool message
-
-response = litellm.completion(
- model="anthropic/claude-3-5-sonnet-20241022",
- messages=messages
-)
-```
-
-**When this happens:**
-- Message history is manually edited
-- Tool results are duplicated or mismatched
-- Conversation state is restored incorrectly
-- Messages are merged from different conversations
-
-### Case C: Empty Message Content
-
-**Problem:** User or assistant messages have empty or whitespace-only content.
-
-**Solution:** LiteLLM replaces empty content with a system placeholder message.
-
-**Example:**
-
-```python
-import litellm
-litellm.modify_params = True
-
-# Messages with empty content
-messages = [
- {"role": "user", "content": ""}, # Empty content
- {"role": "assistant", "content": " "}, # Whitespace only
-]
-
-# LiteLLM automatically replaces with:
-# {"role": "user", "content": "[System: Empty message content sanitised to satisfy protocol]"}
-# {"role": "assistant", "content": "[System: Empty message content sanitised to satisfy protocol]"}
-
-response = litellm.completion(
- model="anthropic/claude-3-5-sonnet-20241022",
- messages=messages
-)
-```
-
-**When this happens:**
-- UI sends empty messages
-- Content is stripped during preprocessing
-- Placeholder messages in conversation history
-- Edge cases in message construction
-
-## Configuration
-
-### Enable Globally
-
-
-
-
-```python
-import litellm
-
-# Enable for all completion calls
-litellm.modify_params = True
-```
-
-
-
-
-```yaml
-litellm_settings:
- modify_params: true
-```
-
-
-
-
-```bash
-export LITELLM_MODIFY_PARAMS=True
-```
-
-
-
-
-### Enable Per-Request
-
-```python
-import litellm
-
-# Enable only for specific requests
-response = litellm.completion(
- model="anthropic/claude-3-5-sonnet-20241022",
- messages=messages,
- modify_params=True # Override global setting
-)
-```
-
-## Supported Providers
-
-Message sanitization works with all LLM providers that support tool calling:
-
-- ✅ Anthropic (Claude)
-- ✅ OpenAI (GPT-4, GPT-3.5)
-- ✅ AWS Bedrock (Claude, Titan)
-- ✅ Google Vertex AI (Claude, Gemini)
-- ✅ Azure OpenAI
-- ✅ And all other providers with tool calling support
-
-## Implementation Details
-
-### How It Works
-
-The message sanitization process runs **before** messages are converted to provider-specific formats:
-
-1. **Input:** OpenAI-format messages with potential issues
-2. **Sanitization:** Three helper functions process the messages:
- - `_sanitize_empty_text_content()` - Fixes empty content
- - `_add_missing_tool_results()` - Adds dummy tool results
- - `_is_orphaned_tool_result()` - Identifies orphaned results
-3. **Output:** Clean, provider-compatible messages
-
-### Code Reference
-
-The sanitization logic is implemented in:
-- `litellm/litellm_core_utils/prompt_templates/factory.py`
-- Function: `sanitize_messages_for_tool_calling()`
-
-### Logging
-
-When sanitization occurs, LiteLLM logs debug messages:
-
-```python
-import litellm
-litellm.set_verbose = True # Enable debug logging
-
-# You'll see logs like:
-# "_add_missing_tool_results: Found 1 orphaned tool calls. Adding dummy tool results."
-# "_is_orphaned_tool_result: Found orphaned tool result with tool_call_id=call_123"
-# "_sanitize_empty_text_content: Replaced empty text content in user message"
-```
-
-## Best Practices
-
-### 1. Enable for Production Workflows
-
-```python
-# Recommended for production
-litellm.modify_params = True
-
-# Ensures robust handling of edge cases
-response = litellm.completion(
- model="anthropic/claude-3-5-sonnet-20241022",
- messages=messages,
- tools=tools
-)
-```
-
-### 2. Preserve Tool Results When Possible
-
-While sanitization handles missing tool results, it's better to provide actual results:
-
-```python
-# Good: Provide actual tool results
-messages = [
- {"role": "user", "content": "Search for Python"},
- {"role": "assistant", "tool_calls": [...]},
- {"role": "tool", "tool_call_id": "call_123", "content": "Actual search results"}
-]
-
-# Fallback: Sanitization adds dummy result if missing
-messages = [
- {"role": "user", "content": "Search for Python"},
- {"role": "assistant", "tool_calls": [...]},
- # Missing tool result - sanitization adds dummy
-]
-```
-
-### 3. Monitor Sanitization Events
-
-Use logging to track when sanitization occurs:
-
-```python
-import litellm
-import logging
-
-# Enable debug logging
-litellm.set_verbose = True
-logging.basicConfig(level=logging.DEBUG)
-
-# Track sanitization events in your application
-response = litellm.completion(
- model="anthropic/claude-3-5-sonnet-20241022",
- messages=messages
-)
-```
-
-### 4. Test Edge Cases
-
-Ensure your application handles sanitized messages correctly:
-
-```python
-import litellm
-litellm.modify_params = True
-
-# Test orphaned tool calls
-test_messages = [
- {"role": "user", "content": "Test"},
- {"role": "assistant", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "test", "arguments": "{}"}}]},
- {"role": "user", "content": "Continue"} # No tool result
-]
-
-response = litellm.completion(
- model="anthropic/claude-3-5-sonnet-20241022",
- messages=test_messages,
- tools=[...]
-)
-
-# Verify the response handles the dummy tool result appropriately
-```
-
-## Related Features
-
-- **[Drop Params](./drop_params.md)** - Drop unsupported parameters for specific providers
-- **[Message Trimming](./message_trimming.md)** - Trim messages to fit token limits
-- **[Function Calling](./function_call.md)** - Complete guide to tool/function calling
-- **[Reasoning Content](../reasoning_content.md)** - Extended thinking with tool calling
-
-## Troubleshooting
-
-### Sanitization Not Working
-
-**Issue:** Messages still cause errors despite `modify_params=True`
-
-**Solution:**
-1. Verify `modify_params` is enabled:
- ```python
- import litellm
- print(litellm.modify_params) # Should be True
- ```
-
-2. Check if the issue is provider-specific:
- ```python
- litellm.set_verbose = True # Enable debug logging
- ```
-
-3. Ensure you're using a recent version of LiteLLM:
- ```bash
- pip install --upgrade litellm
- ```
-
-### Unexpected Dummy Tool Results
-
-**Issue:** Dummy tool results appear when you expect actual results
-
-**Cause:** Tool result messages are missing or have incorrect `tool_call_id`
-
-**Solution:**
-1. Verify tool result messages have correct `tool_call_id`:
- ```python
- # Correct
- {"role": "tool", "tool_call_id": "call_123", "content": "result"}
-
- # Incorrect - will be treated as orphaned
- {"role": "tool", "tool_call_id": "wrong_id", "content": "result"}
- ```
-
-2. Ensure tool results immediately follow assistant messages with tool_calls
-
-### Performance Impact
-
-**Issue:** Concerned about performance overhead
-
-**Details:** Message sanitization has minimal performance impact:
-- Runs in O(n) time where n = number of messages
-- Only processes messages when `modify_params=True`
-- Typically adds < 1ms to request processing time
-
-## FAQ
-
-**Q: Does sanitization modify my original messages?**
-
-A: No, sanitization creates a new list of messages. Your original messages remain unchanged.
-
-**Q: Can I disable specific sanitization cases?**
-
-A: Currently, all three cases are handled together when `modify_params=True`. To disable sanitization entirely, set `modify_params=False`.
-
-**Q: What happens to the dummy tool results?**
-
-A: Dummy tool results are sent to the LLM provider along with other messages. The model sees them as regular tool results with informative error messages.
-
-**Q: Does this work with streaming?**
-
-A: Yes, message sanitization works with both streaming and non-streaming requests.
-
-**Q: Is this related to `drop_params`?**
-
-A: No, they're separate features:
-- `modify_params` - Modifies/fixes message content and structure
-- `drop_params` - Removes unsupported API parameters
-
-Both can be enabled simultaneously.
-
-## See Also
-
-- [Reasoning Content with Tool Calling](../reasoning_content.md)
-- [Function Calling Guide](./function_call.md)
-- [Bedrock Provider Documentation](../providers/bedrock.md)
-- [Anthropic Provider Documentation](../providers/anthropic.md)
diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js
index acc5d538550..38a26f6b183 100644
--- a/docs/my-website/sidebars.js
+++ b/docs/my-website/sidebars.js
@@ -822,7 +822,6 @@ const sidebars = {
"completion/knowledgebase",
"guides/code_interpreter",
"completion/message_trimming",
- "completion/message_sanitization",
"completion/model_alias",
"completion/mock_requests",
"completion/predict_outputs",
diff --git a/litellm/_logging.py b/litellm/_logging.py
index 73902d2fc5a..b3156b15ba7 100644
--- a/litellm/_logging.py
+++ b/litellm/_logging.py
@@ -133,6 +133,26 @@ ALL_LOGGERS = [
]
+def _get_loggers_to_initialize():
+ """
+ Get all loggers that should be initialized with the JSON handler.
+
+ Includes third-party integration loggers (like langfuse) if they are
+ configured as callbacks.
+ """
+ import litellm
+
+ loggers = list(ALL_LOGGERS)
+
+ # Add langfuse logger if langfuse is being used as a callback
+ langfuse_callbacks = {"langfuse", "langfuse_otel"}
+ all_callbacks = set(litellm.success_callback + litellm.failure_callback)
+ if langfuse_callbacks & all_callbacks:
+ loggers.append(logging.getLogger("langfuse"))
+
+ return loggers
+
+
def _initialize_loggers_with_handler(handler: logging.Handler):
"""
Initialize all loggers with a handler
@@ -140,7 +160,7 @@ def _initialize_loggers_with_handler(handler: logging.Handler):
- Adds a handler to each logger
- Prevents bubbling to parent/root (critical to prevent duplicate JSON logs)
"""
- for lg in ALL_LOGGERS:
+ for lg in _get_loggers_to_initialize():
lg.handlers.clear() # remove any existing handlers
lg.addHandler(handler) # add JSON formatter handler
lg.propagate = False # prevent bubbling to parent/root
diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py
index 2311b34a2cc..4320f756454 100644
--- a/litellm/litellm_core_utils/prompt_templates/factory.py
+++ b/litellm/litellm_core_utils/prompt_templates/factory.py
@@ -1989,223 +1989,6 @@ def anthropic_process_openai_file_message(
)
-def _sanitize_empty_text_content(
- message: AllMessageValues,
-) -> AllMessageValues:
- """
- Case C: Sanitize empty text content
- - Replace empty or whitespace-only text content with a placeholder message.
-
- Returns:
- The message with sanitized content if needed, otherwise the original message
- """
- if message.get("role") in ["user", "assistant"]:
- content = message.get("content")
- if isinstance(content, str):
- if not content or not content.strip():
- message = dict(message) # Make a copy
- message["content"] = "[System: Empty message content sanitised to satisfy protocol]"
- verbose_logger.debug(
- f"_sanitize_empty_text_content: Replaced empty text content in {message.get('role')} message"
- )
- return message
-
-
-def _add_missing_tool_results(
- current_message: AllMessageValues,
- messages: List[AllMessageValues],
- current_index: int,
-) -> List[AllMessageValues]:
- """
- Case A: Missing tool_result for tool_use (orphaned tool calls)
- - If an assistant message has tool_calls but no corresponding tool result follows,
- add a dummy tool result message indicating the user did not provide the result.
-
- Returns:
- A list containing the assistant message followed by any dummy tool results needed
- """
- result_messages: List[AllMessageValues] = []
- tool_calls = current_message.get("tool_calls")
-
- if not tool_calls or len(tool_calls) == 0:
- return [current_message]
-
- # Collect all tool_call_ids from this assistant message
- expected_tool_call_ids = set()
- for tool_call in tool_calls:
- tool_call_id = None
- if isinstance(tool_call, dict):
- tool_call_id = tool_call.get("id")
- else:
- tool_call_id = getattr(tool_call, "id", None)
- if tool_call_id:
- expected_tool_call_ids.add(tool_call_id)
-
- found_tool_call_ids = set()
- j = current_index + 1
-
- while j < len(messages):
- next_msg = messages[j]
- next_role = next_msg.get("role")
-
- if next_role == "assistant":
- break
-
- if next_role in ["tool", "function"]:
- tool_call_id = next_msg.get("tool_call_id")
- if tool_call_id:
- found_tool_call_ids.add(tool_call_id)
-
- j += 1
-
- # Find missing tool results
- missing_tool_call_ids = expected_tool_call_ids - found_tool_call_ids
-
- if missing_tool_call_ids:
- verbose_logger.debug(
- f"_add_missing_tool_results: Found {len(missing_tool_call_ids)} orphaned tool calls. Adding dummy tool results."
- )
-
- result_messages.append(current_message)
-
- for tool_call_id in missing_tool_call_ids:
- tool_name = "unknown_tool"
- for tool_call in tool_calls:
- tc_id = None
- if isinstance(tool_call, dict):
- tc_id = tool_call.get("id")
- else:
- tc_id = getattr(tool_call, "id", None)
-
- if tc_id == tool_call_id:
- if isinstance(tool_call, dict):
- function = tool_call.get("function", {})
- if isinstance(function, dict):
- tool_name = function.get("name", "unknown_tool")
- else:
- tool_name = getattr(function, "name", "unknown_tool")
- else:
- function = getattr(tool_call, "function", None)
- if function:
- tool_name = getattr(function, "name", "unknown_tool")
- break
-
- dummy_tool_result: ChatCompletionToolMessage = {
- "role": "tool",
- "tool_call_id": tool_call_id,
- "content": f"[System: Tool execution skipped/interrupted by user. No result provided for tool '{tool_name}'.]",
- }
- result_messages.append(dummy_tool_result)
-
- return result_messages
-
- return [current_message]
-
-
-def _is_orphaned_tool_result(
- current_message: AllMessageValues,
- sanitized_messages: List[AllMessageValues],
-) -> bool:
- """
- Case B: Orphaned tool_result (unexpected result)
- - Check if a tool message references a tool_call_id that doesn't exist in the previous
- assistant message.
-
- Returns:
- True if this is an orphaned tool result that should be removed, False otherwise
- """
- if current_message.get("role") not in ["tool", "function"]:
- return False
-
- tool_call_id = current_message.get("tool_call_id")
-
- if not tool_call_id:
- return False
-
- # Look back to find the most recent assistant message with tool_calls
- found_matching_tool_call = False
-
- for j in range(len(sanitized_messages) - 1, -1, -1):
- prev_msg = sanitized_messages[j]
- if prev_msg.get("role") == "assistant":
- tool_calls = prev_msg.get("tool_calls")
- if tool_calls:
- for tool_call in tool_calls:
- tc_id = None
- if isinstance(tool_call, dict):
- tc_id = tool_call.get("id")
- else:
- tc_id = getattr(tool_call, "id", None)
-
- if tc_id == tool_call_id:
- found_matching_tool_call = True
- break
-
- break
-
- if not found_matching_tool_call:
- verbose_logger.debug(
- "_is_orphaned_tool_result: Found orphaned tool result with redacted tool_call_id"
- )
- return True
-
- return False
-
-
-def sanitize_messages_for_tool_calling(
- messages: List[AllMessageValues],
-) -> List[AllMessageValues]:
- """
- Sanitize messages for tool calling to handle common issues when modify_params=True:
-
- Case A: Missing tool_result for tool_use (orphaned tool calls)
- - If an assistant message has tool_calls but no corresponding tool result follows,
- add a dummy tool result message indicating the user did not provide the result.
-
- Case B: Orphaned tool_result (unexpected result)
- - If a tool message references a tool_call_id that doesn't exist in the previous
- assistant message, remove that tool message.
-
- Case C: Empty text content
- - Replace empty or whitespace-only text content with a placeholder message.
-
- This function operates on OpenAI format messages before they are converted to
- provider-specific formats.
- """
- if not litellm.modify_params:
- return messages
-
- sanitized_messages: List[AllMessageValues] = []
- i = 0
-
- while i < len(messages):
- current_message = messages[i]
-
- # Case C: Sanitize empty text content
- current_message = _sanitize_empty_text_content(current_message)
-
- # Case A: Check if assistant message has tool_calls without following tool results
- if current_message.get("role") == "assistant":
- result_messages = _add_missing_tool_results(current_message, messages, i)
-
- # If dummy tool results were added, extend sanitized_messages and continue
- if len(result_messages) > 1:
- sanitized_messages.extend(result_messages)
- i += 1
- continue
-
- # Case B: Check for orphaned tool results
- if _is_orphaned_tool_result(current_message, sanitized_messages):
- i += 1
- continue # Skip this orphaned tool result
-
- # Add the message to sanitized list
- sanitized_messages.append(current_message)
- i += 1
-
- return sanitized_messages
-
-
def anthropic_messages_pt( # noqa: PLR0915
messages: List[AllMessageValues],
model: str,
@@ -2225,9 +2008,6 @@ def anthropic_messages_pt( # noqa: PLR0915
5. System messages are a separate param to the Messages API
6. Ensure we only accept role, content. (message.name is not supported)
"""
- # Sanitize messages for tool calling issues when modify_params=True
- messages = sanitize_messages_for_tool_calling(messages)
-
# add role=tool support to allow function call result/error submission
user_message_types = {"user", "tool", "function"}
# reformat messages to ensure user/assistant are alternating, if there's either 2 consecutive 'user' messages or 2 consecutive 'assistant' message, merge them.
@@ -3453,21 +3233,17 @@ def _convert_to_bedrock_tool_call_invoke(
id = tool["id"]
name = tool["function"].get("name", "")
arguments = tool["function"].get("arguments", "")
+ arguments_dict = json.loads(arguments) if arguments else {}
+ # Ensure arguments_dict is always a dict (Bedrock requires toolUse.input to be an object)
+ # When some providers return arguments: '""' (JSON-encoded empty string), json.loads returns ""
+ if not isinstance(arguments_dict, dict):
+ arguments_dict = {}
if not arguments or not arguments.strip():
- arguments_input = {}
+ arguments_dict = {}
else:
- # Try to parse the arguments JSON
- try:
- arguments_input = json.loads(arguments)
- except json.JSONDecodeError as e:
- verbose_logger.warning(
- f"Malformed JSON in tool call arguments for tool '{name}': {str(e)}. "
- f"Storing as raw string to allow conversation to continue."
- )
- arguments_input = arguments
-
+ arguments_dict = json.loads(arguments)
bedrock_tool = BedrockToolUseBlock(
- input=arguments_input, name=name, toolUseId=id
+ input=arguments_dict, name=name, toolUseId=id
)
bedrock_content_block = BedrockContentBlock(toolUse=bedrock_tool)
_parts_list.append(bedrock_content_block)
diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py
index 9bc1e8c85e2..59590e464fc 100644
--- a/litellm/llms/bedrock/chat/converse_transformation.py
+++ b/litellm/llms/bedrock/chat/converse_transformation.py
@@ -1395,16 +1395,9 @@ class AmazonConverseConfig(BaseConfig):
response_tool_name = get_bedrock_tool_name(
response_tool_name=_response_tool_name
)
- tool_input = content["toolUse"]["input"]
- if isinstance(tool_input, str):
- arguments_str = tool_input
- else:
- # Otherwise, serialize it to JSON
- arguments_str = json.dumps(tool_input)
-
_function_chunk = ChatCompletionToolCallFunctionChunk(
name=response_tool_name,
- arguments=arguments_str,
+ arguments=json.dumps(content["toolUse"]["input"]),
)
_tool_response_chunk = ChatCompletionToolCallChunk(
diff --git a/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py b/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py
index cff1bebceb9..289963e917a 100644
--- a/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py
+++ b/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py
@@ -304,7 +304,7 @@ class ContextCachingEndpoints(VertexBase):
## CHECK IF CACHED ALREADY
generated_cache_key = local_cache_obj.get_cache_key(
- messages=cached_messages, tools=tools
+ messages=cached_messages, tools=tools, model=model
)
google_cache_name = self.check_cache(
cache_key=generated_cache_key,
@@ -433,7 +433,7 @@ class ContextCachingEndpoints(VertexBase):
## CHECK IF CACHED ALREADY
generated_cache_key = local_cache_obj.get_cache_key(
- messages=cached_messages, tools=tools
+ messages=cached_messages, tools=tools, model=model
)
google_cache_name = await self.async_check_cache(
cache_key=generated_cache_key,
diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py
index a741869e5fc..5e0a211906e 100644
--- a/litellm/proxy/auth/auth_checks.py
+++ b/litellm/proxy/auth/auth_checks.py
@@ -202,21 +202,29 @@ async def common_checks(
and general_settings["enforce_user_param"] is True
):
# Get HTTP method from request
- http_method = request.method if hasattr(request, 'method') else None
-
+ http_method = request.method if hasattr(request, "method") else None
+
# Check if it's a POST request and if it's an OpenAI route but not MCP
is_post_method = http_method and http_method.upper() == "POST"
is_openai_route = RouteChecks.is_llm_api_route(route=route)
- is_mcp_route = route in LiteLLMRoutes.mcp_routes.value or RouteChecks.check_route_access(
- route=route, allowed_routes=LiteLLMRoutes.mcp_routes.value
+ is_mcp_route = (
+ route in LiteLLMRoutes.mcp_routes.value
+ or RouteChecks.check_route_access(
+ route=route, allowed_routes=LiteLLMRoutes.mcp_routes.value
+ )
)
-
+
# Enforce user param only for POST requests on OpenAI routes (excluding MCP routes)
- if is_post_method and is_openai_route and not is_mcp_route and "user" not in request_body:
+ if (
+ is_post_method
+ and is_openai_route
+ and not is_mcp_route
+ and "user" not in request_body
+ ):
raise Exception(
f"'user' param not passed in. 'enforce_user_param'={general_settings['enforce_user_param']}"
)
-
+
# 6.1 [OPTIONAL] If 'reject_clientside_metadata_tags' enabled - reject request if it has client-side 'metadata.tags'
if (
general_settings.get("reject_clientside_metadata_tags", None) is not None
@@ -502,53 +510,51 @@ async def get_default_end_user_budget(
) -> Optional[LiteLLM_BudgetTable]:
"""
Fetches the default end user budget from the database if litellm.max_end_user_budget_id is configured.
-
+
This budget is applied to end users who don't have an explicit budget_id set.
Results are cached for performance.
-
+
Args:
prisma_client: Database client instance
user_api_key_cache: Cache for storing/retrieving budget data
parent_otel_span: Optional OpenTelemetry span for tracing
-
+
Returns:
LiteLLM_BudgetTable if configured and found, None otherwise
"""
if prisma_client is None or litellm.max_end_user_budget_id is None:
return None
-
+
cache_key = f"default_end_user_budget:{litellm.max_end_user_budget_id}"
-
+
# Check cache first
cached_budget = await user_api_key_cache.async_get_cache(key=cache_key)
if cached_budget is not None:
return LiteLLM_BudgetTable(**cached_budget)
-
+
# Fetch from database
try:
budget_record = await prisma_client.db.litellm_budgettable.find_unique(
where={"budget_id": litellm.max_end_user_budget_id}
)
-
+
if budget_record is None:
verbose_proxy_logger.warning(
f"Default end user budget not found in database: {litellm.max_end_user_budget_id}"
)
return None
-
+
# Cache the budget for 60 seconds
await user_api_key_cache.async_set_cache(
- key=cache_key,
+ key=cache_key,
value=budget_record.dict(),
ttl=DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL,
)
-
+
return LiteLLM_BudgetTable(**budget_record.dict())
-
+
except Exception as e:
- verbose_proxy_logger.error(
- f"Error fetching default end user budget: {str(e)}"
- )
+ verbose_proxy_logger.error(f"Error fetching default end user budget: {str(e)}")
return None
@@ -560,38 +566,38 @@ async def _apply_default_budget_to_end_user(
) -> LiteLLM_EndUserTable:
"""
Helper function to apply default budget to end user if they don't have a budget assigned.
-
+
Args:
end_user_obj: The end user object to potentially apply default budget to
prisma_client: Database client instance
user_api_key_cache: Cache for storing/retrieving data
parent_otel_span: Optional OpenTelemetry span for tracing
-
+
Returns:
Updated end user object with default budget applied if applicable
"""
# If end user already has a budget assigned, no need to apply default
if end_user_obj.litellm_budget_table is not None:
return end_user_obj
-
+
# If no default budget configured, return as-is
if litellm.max_end_user_budget_id is None:
return end_user_obj
-
+
# Fetch and apply default budget
default_budget = await get_default_end_user_budget(
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
)
-
+
if default_budget is not None:
# Apply default budget to end user object
end_user_obj.litellm_budget_table = default_budget
verbose_proxy_logger.debug(
f"Applied default budget {litellm.max_end_user_budget_id} to end user {end_user_obj.user_id}"
)
-
+
return end_user_obj
@@ -601,20 +607,20 @@ def _check_end_user_budget(
) -> None:
"""
Check if end user is within their budget limit.
-
+
Args:
end_user_obj: The end user object to check
route: The request route
-
+
Raises:
litellm.BudgetExceededError: If end user has exceeded their budget
"""
if route in LiteLLMRoutes.info_routes.value:
return
-
+
if end_user_obj.litellm_budget_table is None:
return
-
+
end_user_budget = end_user_obj.litellm_budget_table.max_budget
if end_user_budget is not None and end_user_obj.spend > end_user_budget:
raise litellm.BudgetExceededError(
@@ -635,8 +641,8 @@ async def get_end_user_object(
) -> Optional[LiteLLM_EndUserTable]:
"""
Returns end user object from database or cache.
-
- If end user exists but has no budget_id, applies the default budget
+
+ If end user exists but has no budget_id, applies the default budget
(if configured via litellm.max_end_user_budget_id).
Args:
@@ -646,7 +652,7 @@ async def get_end_user_object(
route: The request route
parent_otel_span: Optional OpenTelemetry span for tracing
proxy_logging_obj: Optional proxy logging object
-
+
Returns:
LiteLLM_EndUserTable if found, None otherwise
"""
@@ -655,14 +661,14 @@ async def get_end_user_object(
if end_user_id is None:
return None
-
+
_key = "end_user_id:{}".format(end_user_id)
# Check cache first
cached_user_obj = await user_api_key_cache.async_get_cache(key=_key)
if cached_user_obj is not None:
return_obj = LiteLLM_EndUserTable(**cached_user_obj)
-
+
# Apply default budget if needed
return_obj = await _apply_default_budget_to_end_user(
end_user_obj=return_obj,
@@ -670,10 +676,10 @@ async def get_end_user_object(
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
)
-
+
# Check budget limits
_check_end_user_budget(end_user_obj=return_obj, route=route)
-
+
return return_obj
# Fetch from database
@@ -688,7 +694,7 @@ async def get_end_user_object(
# Convert to LiteLLM_EndUserTable object
_response = LiteLLM_EndUserTable(**response.dict())
-
+
# Apply default budget if needed
_response = await _apply_default_budget_to_end_user(
end_user_obj=_response,
@@ -696,18 +702,17 @@ async def get_end_user_object(
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
)
-
+
# Save to cache (always store as dict for consistency)
await user_api_key_cache.async_set_cache(
- key="end_user_id:{}".format(end_user_id),
- value=_response.dict()
+ key="end_user_id:{}".format(end_user_id), value=_response.dict()
)
-
+
# Check budget limits
_check_end_user_budget(end_user_obj=_response, route=route)
return _response
-
+
except Exception as e:
if isinstance(e, litellm.BudgetExceededError):
raise e
@@ -747,7 +752,6 @@ async def get_tag_objects_batch(
tag_objects = {}
uncached_tags = []
-
# Try to get all tags from cache first
for tag_name in tag_names:
@@ -1138,7 +1142,6 @@ async def _cache_management_object(
user_api_key_cache: DualCache,
proxy_logging_obj: Optional[ProxyLogging],
):
-
await user_api_key_cache.async_set_cache(
key=key,
value=value,
@@ -1459,9 +1462,7 @@ async def get_team_object_by_alias(
except HTTPException:
raise
except Exception as e:
- verbose_proxy_logger.exception(
- "Error looking up team by alias: %s", team_alias
- )
+ verbose_proxy_logger.exception("Error looking up team by alias: %s", team_alias)
raise HTTPException(
status_code=500,
detail={
@@ -1602,11 +1603,11 @@ class ExperimentalUIJWTToken:
) -> str:
"""
Generate a JWT token for CLI authentication with 24-hour expiration.
-
+
Args:
user_info: User information from the database
team_id: Team ID for the user (optional, uses user's team if available)
-
+
Returns:
Encrypted JWT token string
"""
@@ -1800,7 +1801,7 @@ async def get_org_object(
- Check if org id in proxy Org Table
- if valid, return LiteLLM_OrganizationTable object
- if not, then raise an error
-
+
Args:
org_id: Organization ID to look up
prisma_client: Database client
@@ -1820,7 +1821,7 @@ async def get_org_object(
cache_key = "org_id:{}".format(org_id)
if include_budget_table:
cache_key = "org_id:{}:with_budget".format(org_id)
-
+
# check if in cache
cached_org_obj = user_api_key_cache.async_get_cache(key=cache_key)
if cached_org_obj is not None:
@@ -1833,7 +1834,7 @@ async def get_org_object(
query_kwargs: Dict[str, Any] = {"where": {"organization_id": org_id}}
if include_budget_table:
query_kwargs["include"] = {"litellm_budget_table": True}
-
+
response = await prisma_client.db.litellm_organizationtable.find_unique(
**query_kwargs
)
@@ -1844,7 +1845,9 @@ async def get_org_object(
# Cache the result
await user_api_key_cache.async_set_cache(
key=cache_key,
- value=response.model_dump() if hasattr(response, "model_dump") else response,
+ value=response.model_dump()
+ if hasattr(response, "model_dump")
+ else response,
ttl=DEFAULT_IN_MEMORY_TTL,
)
@@ -2218,10 +2221,15 @@ async def _virtual_key_max_budget_alert_check(
and valid_token.spend is not None
and valid_token.spend > 0
):
- alert_threshold = valid_token.max_budget * EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE
-
+ alert_threshold = (
+ valid_token.max_budget * EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE
+ )
+
# Only alert if we've crossed the threshold but haven't exceeded max_budget yet
- if valid_token.spend >= alert_threshold and valid_token.spend < valid_token.max_budget:
+ if (
+ valid_token.spend >= alert_threshold
+ and valid_token.spend < valid_token.max_budget
+ ):
verbose_proxy_logger.debug(
"Reached Max Budget Alert Threshold for token %s, spend %s, max_budget %s, alert_threshold %s",
valid_token.token,
@@ -2274,7 +2282,7 @@ async def _check_team_member_budget(
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
-
+
if (
team_membership is not None
and team_membership.litellm_budget_table is not None
@@ -2282,8 +2290,8 @@ async def _check_team_member_budget(
):
team_member_budget = team_membership.litellm_budget_table.max_budget
team_member_spend = team_membership.spend or 0.0
-
- if team_member_spend > team_member_budget:
+
+ if team_member_spend >= team_member_budget:
raise litellm.BudgetExceededError(
current_cost=team_member_spend,
max_budget=team_member_budget,
@@ -2343,11 +2351,11 @@ async def _organization_max_budget_check(
):
"""
Check if the organization is over its max budget.
-
+
This function checks the organization budget using:
1. First, tries to use valid_token.org_id (if key has organization_id set)
2. Falls back to team_object.organization_id (if key doesn't have org_id but team does)
-
+
This ensures organization budget checks work even when keys don't have organization_id
set directly, as long as their team belongs to an organization.
@@ -2364,7 +2372,7 @@ async def _organization_max_budget_check(
org_id = valid_token.org_id
elif team_object is not None and team_object.organization_id is not None:
org_id = team_object.organization_id
-
+
# If no organization_id found, skip the check
if org_id is None:
return
@@ -2655,4 +2663,4 @@ def _can_object_call_vector_stores(
code=status.HTTP_401_UNAUTHORIZED,
)
- return True
\ No newline at end of file
+ return True
diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py
index f52abf86b97..c52491efc7c 100644
--- a/litellm/proxy/management_endpoints/common_daily_activity.py
+++ b/litellm/proxy/management_endpoints/common_daily_activity.py
@@ -343,7 +343,7 @@ def _build_where_conditions(
start_date: str,
end_date: str,
model: Optional[str],
- api_key: Optional[str],
+ api_key: Optional[Union[str, List[str]]],
exclude_entity_ids: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Build prisma where clause for daily activity queries."""
@@ -357,7 +357,10 @@ def _build_where_conditions(
if model:
where_conditions["model"] = model
if api_key:
- where_conditions["api_key"] = api_key
+ if isinstance(api_key, list):
+ where_conditions["api_key"] = {"in": api_key}
+ else:
+ where_conditions["api_key"] = api_key
if entity_id is not None:
if isinstance(entity_id, list):
@@ -445,7 +448,7 @@ async def get_daily_activity(
start_date: Optional[str],
end_date: Optional[str],
model: Optional[str],
- api_key: Optional[str],
+ api_key: Optional[Union[str, List[str]]],
page: int,
page_size: int,
exclude_entity_ids: Optional[List[str]] = None,
diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py
index 3f97803b42b..c606420cc05 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -3715,7 +3715,7 @@ async def get_team_daily_activity(
},
)
- ## Fetch team aliases
+ ## Fetch team aliases and check team admin status
where_condition = {}
if team_ids_list:
where_condition["team_id"] = {"in": list(team_ids_list)}
@@ -3726,6 +3726,36 @@ async def get_team_daily_activity(
t.team_id: {"team_alias": t.team_alias} for t in team_aliases
}
+ # Check if user is team admin for any requested teams
+ # If not, filter by user's API keys
+ user_api_keys: Optional[List[str]] = None
+ if not _user_has_admin_view(user_api_key_dict) and team_ids_list and team_aliases:
+ # Check if user is team admin for any of the teams
+ is_team_admin_for_any = False
+ for team_alias in team_aliases:
+ team_obj = LiteLLM_TeamTable(**team_alias.model_dump())
+ if _is_user_team_admin(
+ user_api_key_dict=user_api_key_dict, team_obj=team_obj
+ ):
+ is_team_admin_for_any = True
+ break
+
+ # If user is not a team admin for any team, filter by their API keys
+ if not is_team_admin_for_any:
+ # Get all API keys for this user
+ user_keys = await prisma_client.db.litellm_verificationtoken.find_many(
+ where={"user_id": user_api_key_dict.user_id}
+ )
+ user_api_keys = [key.token for key in user_keys if key.token]
+ # If user has no API keys, return empty result
+ if not user_api_keys:
+ user_api_keys = [""] # Use empty string to ensure no matches
+
+ # If api_key parameter is provided, use it; otherwise use user_api_keys if set
+ final_api_key_filter: Optional[Union[str, List[str]]] = api_key
+ if final_api_key_filter is None and user_api_keys is not None:
+ final_api_key_filter = user_api_keys
+
return await get_daily_activity(
prisma_client=prisma_client,
table_name="litellm_dailyteamspend",
@@ -3736,7 +3766,7 @@ async def get_team_daily_activity(
start_date=start_date,
end_date=end_date,
model=model,
- api_key=api_key,
+ api_key=final_api_key_filter,
page=page,
page_size=page_size,
)
diff --git a/litellm/types/llms/bedrock.py b/litellm/types/llms/bedrock.py
index e0858898eae..ef2f1ba4d5e 100644
--- a/litellm/types/llms/bedrock.py
+++ b/litellm/types/llms/bedrock.py
@@ -62,7 +62,7 @@ class ToolResultBlock(TypedDict, total=False):
class ToolUseBlock(TypedDict):
- input: Any # Per boto3 spec: document type can be dict, list, int, float, str, bool, or None
+ input: dict
name: str
toolUseId: str
diff --git a/tests/llm_translation/test_bedrock_completion.py b/tests/llm_translation/test_bedrock_completion.py
index f08060214c5..7c0db41d13a 100644
--- a/tests/llm_translation/test_bedrock_completion.py
+++ b/tests/llm_translation/test_bedrock_completion.py
@@ -3954,157 +3954,3 @@ def test_bedrock_openai_error_handling():
assert exc_info.value.status_code == 422
print("✓ Error handling works correctly")
-
-
-def test_bedrock_malformed_tool_json_handling():
- """
- Test that Bedrock handles malformed JSON in tool call arguments gracefully.
-
- This test covers the issue where:
- 1. LLM generates malformed JSON in tool call arguments
- 2. Subsequent requests with conversation history should not crash
- 3. The toolUse.input field should handle any JSON value type per boto3 spec
-
- Related issue: https://github.com/BerriAI/litellm/issues/[issue_number]
- """
- from litellm.litellm_core_utils.prompt_templates.factory import (
- _convert_to_bedrock_tool_call_invoke,
- )
- from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
- from litellm.types.llms.bedrock import ContentBlock
-
- # Test 1: Malformed JSON in tool call arguments
- malformed_tool_calls = [
- {
- "id": "call_123",
- "type": "function",
- "function": {
- "name": "get_weather",
- "arguments": '{"location": "Paris", "invalid_json', # Malformed JSON
- },
- }
- ]
-
- # Should not raise an exception, but store as raw string
- result = _convert_to_bedrock_tool_call_invoke(malformed_tool_calls)
- assert len(result) == 1
- assert result[0]["toolUse"]["name"] == "get_weather"
- # The malformed JSON should be stored as a string
- assert isinstance(result[0]["toolUse"]["input"], str)
- assert result[0]["toolUse"]["input"] == '{"location": "Paris", "invalid_json'
- print("✓ Malformed JSON stored as raw string")
-
- # Test 2: Valid JSON should still work normally
- valid_tool_calls = [
- {
- "id": "call_456",
- "type": "function",
- "function": {
- "name": "get_weather",
- "arguments": '{"location": "London"}',
- },
- }
- ]
-
- result = _convert_to_bedrock_tool_call_invoke(valid_tool_calls)
- assert len(result) == 1
- assert result[0]["toolUse"]["name"] == "get_weather"
- assert isinstance(result[0]["toolUse"]["input"], dict)
- assert result[0]["toolUse"]["input"] == {"location": "London"}
- print("✓ Valid JSON parsed correctly")
-
- # Test 3: Empty arguments should create empty dict
- empty_tool_calls = [
- {
- "id": "call_789",
- "type": "function",
- "function": {
- "name": "no_args_function",
- "arguments": "",
- },
- }
- ]
-
- result = _convert_to_bedrock_tool_call_invoke(empty_tool_calls)
- assert len(result) == 1
- assert result[0]["toolUse"]["input"] == {}
- print("✓ Empty arguments handled correctly")
-
- # Test 4: Bedrock to OpenAI conversion handles string input
- converse_config = AmazonConverseConfig()
- content_blocks = [
- ContentBlock(
- toolUse={
- "name": "get_weather",
- "toolUseId": "call_123",
- "input": '{"location": "Paris", "invalid_json', # String input (malformed)
- }
- )
- ]
-
- content_str, tools, reasoning = converse_config._translate_message_content(
- content_blocks
- )
- assert len(tools) == 1
- assert tools[0]["function"]["name"] == "get_weather"
- # Should return the string as-is
- assert tools[0]["function"]["arguments"] == '{"location": "Paris", "invalid_json'
- print("✓ Bedrock to OpenAI conversion handles string input")
-
- # Test 5: Bedrock to OpenAI conversion handles dict input
- content_blocks_dict = [
- ContentBlock(
- toolUse={
- "name": "get_weather",
- "toolUseId": "call_456",
- "input": {"location": "London"}, # Dict input (normal case)
- }
- )
- ]
-
- content_str, tools, reasoning = converse_config._translate_message_content(
- content_blocks_dict
- )
- assert len(tools) == 1
- assert tools[0]["function"]["name"] == "get_weather"
- # Should serialize dict to JSON string
- assert tools[0]["function"]["arguments"] == '{"location": "London"}'
- print("✓ Bedrock to OpenAI conversion handles dict input")
-
- # Test 6: Round-trip conversion with malformed JSON
- # Test that we can convert OpenAI -> Bedrock -> OpenAI with malformed JSON
- malformed_tool_calls_roundtrip = [
- {
- "id": "call_999",
- "type": "function",
- "function": {
- "name": "test_function",
- "arguments": '{"key": "value", "broken', # Malformed
- },
- }
- ]
-
- # Step 1: OpenAI to Bedrock (should store as string)
- bedrock_blocks = _convert_to_bedrock_tool_call_invoke(malformed_tool_calls_roundtrip)
- assert isinstance(bedrock_blocks[0]["toolUse"]["input"], str)
-
- # Step 2: Bedrock back to OpenAI (should preserve the string)
- content_blocks_roundtrip = [
- ContentBlock(
- toolUse={
- "name": bedrock_blocks[0]["toolUse"]["name"],
- "toolUseId": bedrock_blocks[0]["toolUse"]["toolUseId"],
- "input": bedrock_blocks[0]["toolUse"]["input"],
- }
- )
- ]
-
- content_str, tools_roundtrip, reasoning = converse_config._translate_message_content(
- content_blocks_roundtrip
- )
-
- # Should preserve the malformed JSON string through the round trip
- assert tools_roundtrip[0]["function"]["arguments"] == '{"key": "value", "broken'
- print("✓ Round-trip conversion preserves malformed JSON")
-
- print("✓ All malformed JSON handling tests passed")
diff --git a/tests/test_litellm/llms/anthropic/test_message_sanitization.py b/tests/test_litellm/llms/anthropic/test_message_sanitization.py
deleted file mode 100644
index 489ef527b48..00000000000
--- a/tests/test_litellm/llms/anthropic/test_message_sanitization.py
+++ /dev/null
@@ -1,380 +0,0 @@
-"""
-Test message sanitization for Anthropic API when modify_params=True
-
-Tests three cases:
-A. Missing tool_result for tool_use (orphaned tool calls)
-B. Orphaned tool_result without matching tool_use
-C. Empty text content
-"""
-
-import pytest
-import sys
-import os
-
-# Add the parent directory to the path so we can import litellm
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..")))
-
-import litellm
-from litellm.litellm_core_utils.prompt_templates.factory import (
- sanitize_messages_for_tool_calling,
- anthropic_messages_pt,
-)
-
-
-class TestMessageSanitization:
- """Test message sanitization for tool calling scenarios"""
-
- def setup_method(self):
- """Setup for each test"""
- # Save original modify_params value
- self.original_modify_params = litellm.modify_params
- litellm.modify_params = True
-
- def teardown_method(self):
- """Cleanup after each test"""
- # Restore original modify_params value
- litellm.modify_params = self.original_modify_params
-
- def test_case_a_orphaned_tool_call_single(self):
- """
- Test Case A: Assistant message with tool_calls but no tool result
- Should add a dummy tool result message
- """
- messages = [
- {
- "role": "user",
- "content": "What is the weather in Nashik?"
- },
- {
- "role": "assistant",
- "content": None,
- "tool_calls": [
- {
- "id": "toolu_01Kus2cC3ydjBW7UK4GJqBP4",
- "type": "function",
- "function": {
- "name": "get_weather",
- "arguments": '{"location": "Nashik, India"}'
- }
- }
- ]
- }
- ]
-
- sanitized = sanitize_messages_for_tool_calling(messages)
-
- # Should have 3 messages: user, assistant, and dummy tool result
- assert len(sanitized) == 3
- assert sanitized[0]["role"] == "user"
- assert sanitized[1]["role"] == "assistant"
- assert sanitized[2]["role"] == "tool"
- assert sanitized[2]["tool_call_id"] == "toolu_01Kus2cC3ydjBW7UK4GJqBP4"
- assert "skipped" in sanitized[2]["content"].lower() or "interrupted" in sanitized[2]["content"].lower()
- assert "get_weather" in sanitized[2]["content"]
-
- def test_case_a_orphaned_tool_call_multiple(self):
- """
- Test Case A: Assistant message with multiple tool_calls, some missing results
- """
- messages = [
- {
- "role": "user",
- "content": "Get weather for Nashik and Mumbai"
- },
- {
- "role": "assistant",
- "content": None,
- "tool_calls": [
- {
- "id": "call_1",
- "type": "function",
- "function": {
- "name": "get_weather",
- "arguments": '{"location": "Nashik"}'
- }
- },
- {
- "id": "call_2",
- "type": "function",
- "function": {
- "name": "get_weather",
- "arguments": '{"location": "Mumbai"}'
- }
- }
- ]
- },
- {
- "role": "tool",
- "tool_call_id": "call_1",
- "content": "Weather in Nashik: 25°C"
- }
- ]
-
- sanitized = sanitize_messages_for_tool_calling(messages)
-
- # Should have 4 messages: user, assistant, tool result for call_1, dummy for call_2
- assert len(sanitized) == 4
- assert sanitized[0]["role"] == "user"
- assert sanitized[1]["role"] == "assistant"
- assert sanitized[2]["tool_call_id"] == "call_2" # Dummy added first
- assert sanitized[3]["tool_call_id"] == "call_1" # Original tool result
-
- def test_case_b_orphaned_tool_result(self):
- """
- Test Case B: Tool result without matching tool_call in previous assistant message
- Should remove the orphaned tool result
- """
- messages = [
- {
- "role": "user",
- "content": "Hello"
- },
- {
- "role": "assistant",
- "content": "Hi there!"
- },
- {
- "role": "tool",
- "tool_call_id": "nonexistent_id",
- "content": "Some result"
- }
- ]
-
- sanitized = sanitize_messages_for_tool_calling(messages)
-
- # Should have only 2 messages, orphaned tool result removed
- assert len(sanitized) == 2
- assert sanitized[0]["role"] == "user"
- assert sanitized[1]["role"] == "assistant"
-
- def test_case_b_valid_tool_result_preserved(self):
- """
- Test Case B: Valid tool result with matching tool_call should be preserved
- """
- messages = [
- {
- "role": "user",
- "content": "What's the weather?"
- },
- {
- "role": "assistant",
- "content": None,
- "tool_calls": [
- {
- "id": "call_123",
- "type": "function",
- "function": {
- "name": "get_weather",
- "arguments": '{"location": "Boston"}'
- }
- }
- ]
- },
- {
- "role": "tool",
- "tool_call_id": "call_123",
- "content": "Weather: 20°C"
- }
- ]
-
- sanitized = sanitize_messages_for_tool_calling(messages)
-
- # All messages should be preserved
- assert len(sanitized) == 3
- assert sanitized[2]["role"] == "tool"
- assert sanitized[2]["tool_call_id"] == "call_123"
-
- def test_case_c_empty_text_content_user(self):
- """
- Test Case C: Empty text content in user message
- Should replace with placeholder
- """
- messages = [
- {
- "role": "user",
- "content": ""
- },
- {
- "role": "assistant",
- "content": "Hello!"
- }
- ]
-
- sanitized = sanitize_messages_for_tool_calling(messages)
-
- assert len(sanitized) == 2
- assert sanitized[0]["role"] == "user"
- assert sanitized[0]["content"] == "[System: Empty message content sanitised to satisfy protocol]"
-
- def test_case_c_whitespace_only_content(self):
- """
- Test Case C: Whitespace-only content
- Should replace with placeholder
- """
- messages = [
- {
- "role": "user",
- "content": " \n \t "
- },
- {
- "role": "assistant",
- "content": " "
- }
- ]
-
- sanitized = sanitize_messages_for_tool_calling(messages)
-
- assert len(sanitized) == 2
- assert sanitized[0]["content"] == "[System: Empty message content sanitised to satisfy protocol]"
- assert sanitized[1]["content"] == "[System: Empty message content sanitised to satisfy protocol]"
-
- def test_case_c_valid_content_preserved(self):
- """
- Test Case C: Valid non-empty content should be preserved
- """
- messages = [
- {
- "role": "user",
- "content": "Hello"
- },
- {
- "role": "assistant",
- "content": "Hi there!"
- }
- ]
-
- sanitized = sanitize_messages_for_tool_calling(messages)
-
- assert len(sanitized) == 2
- assert sanitized[0]["content"] == "Hello"
- assert sanitized[1]["content"] == "Hi there!"
-
- def test_combined_cases(self):
- """
- Test combination of multiple cases
- """
- messages = [
- {
- "role": "user",
- "content": "Get weather"
- },
- {
- "role": "assistant",
- "content": None,
- "tool_calls": [
- {
- "id": "call_1",
- "type": "function",
- "function": {
- "name": "get_weather",
- "arguments": '{"location": "NYC"}'
- }
- }
- ]
- },
- # Missing tool result for call_1
- {
- "role": "user",
- "content": "" # Empty content
- },
- {
- "role": "assistant",
- "content": "Response"
- },
- {
- "role": "tool",
- "tool_call_id": "orphaned_id", # Orphaned tool result
- "content": "Some data"
- }
- ]
-
- sanitized = sanitize_messages_for_tool_calling(messages)
-
- # Should have: user, assistant, dummy tool result, user (sanitized), assistant
- # Orphaned tool result should be removed
- assert len(sanitized) == 5
- assert sanitized[0]["role"] == "user"
- assert sanitized[1]["role"] == "assistant"
- assert sanitized[2]["role"] == "tool"
- assert sanitized[2]["tool_call_id"] == "call_1" # Dummy added
- assert sanitized[3]["role"] == "user"
- assert sanitized[3]["content"] == "[System: Empty message content sanitised to satisfy protocol]"
- assert sanitized[4]["role"] == "assistant"
-
- def test_modify_params_false_no_sanitization(self):
- """
- Test that sanitization is skipped when modify_params=False
- """
- litellm.modify_params = False
-
- messages = [
- {
- "role": "user",
- "content": ""
- },
- {
- "role": "assistant",
- "content": None,
- "tool_calls": [
- {
- "id": "call_1",
- "type": "function",
- "function": {
- "name": "get_weather",
- "arguments": '{}'
- }
- }
- ]
- }
- ]
-
- sanitized = sanitize_messages_for_tool_calling(messages)
-
- # Messages should be unchanged
- assert len(sanitized) == 2
- assert sanitized[0]["content"] == ""
- assert len(sanitized[1].get("tool_calls", [])) == 1
-
- def test_anthropic_messages_pt_integration(self):
- """
- Test that sanitization is integrated into anthropic_messages_pt
- """
- litellm.modify_params = True
-
- messages = [
- {
- "role": "user",
- "content": "What is the weather in Nashik?"
- },
- {
- "role": "assistant",
- "content": None,
- "tool_calls": [
- {
- "id": "toolu_01Kus2cC3ydjBW7UK4GJqBP4",
- "type": "function",
- "function": {
- "name": "get_weather",
- "arguments": '{"location": "Nashik, India"}'
- }
- }
- ]
- }
- ]
-
- # This should not raise an error and should add dummy tool result
- result = anthropic_messages_pt(
- messages=messages,
- model="claude-sonnet-4-5",
- llm_provider="anthropic"
- )
-
- # Should have at least 2 messages (user and assistant)
- # The tool result will be merged into user content
- assert len(result) >= 2
- assert result[0]["role"] == "user"
- assert result[1]["role"] == "assistant"
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
diff --git a/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py b/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py
index 88d1b59c5b5..e9d14d4e18f 100644
--- a/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py
+++ b/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py
@@ -187,9 +187,9 @@ class TestContextCachingEndpoints:
assert returned_params == optional_params
assert returned_cache == "existing_cache_name"
- # Verify cache key was generated with tools
+ # Verify cache key was generated with tools and model
mock_cache_obj.get_cache_key.assert_called_once_with(
- messages=cached_messages, tools=self.sample_tools
+ messages=cached_messages, tools=self.sample_tools, model="gemini-1.5-pro"
)
@pytest.mark.parametrize(
@@ -460,9 +460,9 @@ class TestContextCachingEndpoints:
assert returned_params == optional_params
assert returned_cache == "existing_cache_name"
- # Verify cache key was generated with tools
+ # Verify cache key was generated with tools and model
mock_cache_obj.get_cache_key.assert_called_once_with(
- messages=cached_messages, tools=self.sample_tools
+ messages=cached_messages, tools=self.sample_tools, model="gemini-1.5-pro"
)
@pytest.mark.asyncio
diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
index 397a6af556f..33f2a75fac6 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
@@ -12,7 +12,6 @@ sys.path.insert(
from litellm.proxy._types import (
LiteLLM_UserTableFiltered,
- LitellmUserRoles,
NewUserRequest,
ProxyException,
UpdateUserRequest,
@@ -307,88 +306,6 @@ async def test_new_user_license_over_limit(mocker):
mock_license_check.is_over_limit.assert_called_once_with(total_users=1000)
-@pytest.mark.asyncio
-async def test_new_user_non_admin_cannot_create_admin(mocker):
- """
- Test that non-admin users cannot create administrative users (PROXY_ADMIN or PROXY_ADMIN_VIEW_ONLY).
- This prevents privilege escalation vulnerabilities.
- """
- from litellm.proxy.management_endpoints.internal_user_endpoints import new_user
-
- # Mock the prisma client
- mock_prisma_client = mocker.MagicMock()
-
- # Setup the mock count response (under license limit)
- async def mock_count(*args, **kwargs):
- return 5 # Low user count, under limit
-
- mock_prisma_client.db.litellm_usertable.count = mock_count
-
- # Mock duplicate checks to pass
- async def mock_check_duplicate_user_email(*args, **kwargs):
- return None # No duplicate found
-
- async def mock_check_duplicate_user_id(*args, **kwargs):
- return None # No duplicate found
-
- mocker.patch(
- "litellm.proxy.management_endpoints.internal_user_endpoints._check_duplicate_user_email",
- mock_check_duplicate_user_email,
- )
- mocker.patch(
- "litellm.proxy.management_endpoints.internal_user_endpoints._check_duplicate_user_id",
- mock_check_duplicate_user_id,
- )
-
- # Mock the license check to return False (under limit)
- mock_license_check = mocker.MagicMock()
- mock_license_check.is_over_limit.return_value = False
-
- # Patch the imports in the endpoint
- mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
- mocker.patch("litellm.proxy.proxy_server._license_check", mock_license_check)
-
- # Test Case 1: INTERNAL_USER trying to create PROXY_ADMIN
- user_request = NewUserRequest(
- user_email="admin@example.com", user_role=LitellmUserRoles.PROXY_ADMIN
- )
-
- # Mock user_api_key_dict with non-admin role
- mock_user_api_key_dict = UserAPIKeyAuth(
- user_id="test_internal_user", user_role=LitellmUserRoles.INTERNAL_USER
- )
-
- # Call new_user function and expect ProxyException
- with pytest.raises(ProxyException) as exc_info:
- await new_user(data=user_request, user_api_key_dict=mock_user_api_key_dict)
-
- # Verify the exception details
- assert exc_info.value.code == 403 or exc_info.value.code == "403"
- assert "Only proxy admins can create administrative users" in str(exc_info.value.message)
- assert "proxy_admin" in str(exc_info.value.message)
- assert "proxy_admin_viewer" in str(exc_info.value.message)
- assert str(LitellmUserRoles.PROXY_ADMIN) in str(exc_info.value.message)
- assert str(LitellmUserRoles.INTERNAL_USER) in str(exc_info.value.message)
-
- # Test Case 2: INTERNAL_USER trying to create PROXY_ADMIN_VIEW_ONLY
- user_request_viewer = NewUserRequest(
- user_email="admin_viewer@example.com",
- user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
- )
-
- with pytest.raises(ProxyException) as exc_info2:
- await new_user(
- data=user_request_viewer, user_api_key_dict=mock_user_api_key_dict
- )
-
- # Verify the exception details
- assert exc_info2.value.code == 403 or exc_info2.value.code == "403"
- assert "Only proxy admins can create administrative users" in str(
- exc_info2.value.message
- )
- assert str(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY) in str(exc_info2.value.message)
-
-
@pytest.mark.asyncio
async def test_user_info_url_encoding_plus_character(mocker):
"""
diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
index 0685d026722..a1e8efdbb48 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
@@ -20,6 +20,7 @@ from litellm.proxy._types import (
LiteLLM_OrganizationTable,
LiteLLM_OrganizationTableWithMembers,
LiteLLM_TeamTable,
+ LiteLLM_UserTable,
LitellmUserRoles,
Member,
ProxyErrorTypes,
@@ -4825,6 +4826,187 @@ async def test_new_team_with_router_settings(mock_db_client, mock_admin_auth):
assert deserialized_settings == router_settings_data
+@pytest.mark.asyncio
+async def test_get_team_daily_activity_non_admin_filters_by_user_api_keys(
+ mock_db_client,
+):
+ """
+ Test that non-team-admin users only see their own spend (filtered by their API keys)
+ when calling /team/daily/activity endpoint.
+ """
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ get_team_daily_activity,
+ )
+
+ # Create a non-admin user
+ user_id = "test_user_123"
+ team_id = "test_team_456"
+ user_api_key_dict = UserAPIKeyAuth(
+ user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER
+ )
+
+ # Mock user info
+ mock_user_info = LiteLLM_UserTable(
+ user_id=user_id,
+ teams=[team_id],
+ max_budget=1000.0,
+ spend=0.0,
+ user_email="test@example.com",
+ user_role="internal_user",
+ )
+
+ # Mock team with user as non-admin member
+ mock_team_member = Member(user_id=user_id, role="user")
+ mock_team = MagicMock(spec=LiteLLM_TeamTable)
+ mock_team.team_id = team_id
+ mock_team.team_alias = "Test Team"
+ mock_team.members_with_roles = [mock_team_member]
+ mock_team.model_dump.return_value = {
+ "team_id": team_id,
+ "team_alias": "Test Team",
+ "members_with_roles": [{"user_id": user_id, "role": "user"}],
+ }
+
+ # Mock user's API keys
+ user_api_key_1 = MagicMock()
+ user_api_key_1.token = "user_key_1"
+ user_api_key_2 = MagicMock()
+ user_api_key_2.token = "user_key_2"
+
+ # Setup mocks
+ mock_db_client.db.litellm_teamtable.find_many = AsyncMock(
+ return_value=[mock_team]
+ )
+ mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(
+ return_value=[user_api_key_1, user_api_key_2]
+ )
+
+ # Mock get_user_object
+ with patch(
+ "litellm.proxy.management_endpoints.team_endpoints.get_user_object",
+ new_callable=AsyncMock,
+ ) as mock_get_user_object:
+ mock_get_user_object.return_value = mock_user_info
+
+ # Mock get_daily_activity to capture the api_key parameter
+ with patch(
+ "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity",
+ new_callable=AsyncMock,
+ ) as mock_get_daily_activity:
+ mock_get_daily_activity.return_value = MagicMock()
+
+ # Call the endpoint
+ await get_team_daily_activity(
+ team_ids=team_id,
+ start_date="2024-01-01",
+ end_date="2024-01-02",
+ model=None,
+ api_key=None,
+ page=1,
+ page_size=10,
+ exclude_team_ids=None,
+ user_api_key_dict=user_api_key_dict,
+ )
+
+ # Verify get_daily_activity was called with user's API keys as filter
+ mock_get_daily_activity.assert_called_once()
+ call_kwargs = mock_get_daily_activity.call_args[1]
+ assert call_kwargs["api_key"] == ["user_key_1", "user_key_2"]
+ assert call_kwargs["entity_id"] == [team_id]
+
+ # Verify user's API keys were fetched
+ mock_db_client.db.litellm_verificationtoken.find_many.assert_called_once()
+ api_key_call_kwargs = (
+ mock_db_client.db.litellm_verificationtoken.find_many.call_args[1]
+ )
+ assert api_key_call_kwargs["where"] == {"user_id": user_id}
+
+
+@pytest.mark.asyncio
+async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client):
+ """
+ Test that team admin users see all team spend (no API key filtering)
+ when calling /team/daily/activity endpoint.
+ """
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ get_team_daily_activity,
+ )
+
+ # Create a team admin user
+ user_id = "test_admin_123"
+ team_id = "test_team_456"
+ user_api_key_dict = UserAPIKeyAuth(
+ user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER
+ )
+
+ # Mock user info
+ mock_user_info = LiteLLM_UserTable(
+ user_id=user_id,
+ teams=[team_id],
+ max_budget=1000.0,
+ spend=0.0,
+ user_email="admin@example.com",
+ user_role="internal_user",
+ )
+
+ # Mock team with user as admin member
+ mock_team_member = Member(user_id=user_id, role="admin")
+ mock_team = MagicMock(spec=LiteLLM_TeamTable)
+ mock_team.team_id = team_id
+ mock_team.team_alias = "Test Team"
+ mock_team.members_with_roles = [mock_team_member]
+ mock_team.model_dump.return_value = {
+ "team_id": team_id,
+ "team_alias": "Test Team",
+ "members_with_roles": [{"user_id": user_id, "role": "admin"}],
+ }
+
+ # Setup mocks
+ mock_db_client.db.litellm_teamtable.find_many = AsyncMock(
+ return_value=[mock_team]
+ )
+
+ # Mock get_user_object
+ with patch(
+ "litellm.proxy.management_endpoints.team_endpoints.get_user_object",
+ new_callable=AsyncMock,
+ ) as mock_get_user_object:
+ mock_get_user_object.return_value = mock_user_info
+
+ # Mock get_daily_activity to capture the api_key parameter
+ with patch(
+ "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity",
+ new_callable=AsyncMock,
+ ) as mock_get_daily_activity:
+ mock_get_daily_activity.return_value = MagicMock()
+
+ # Call the endpoint
+ await get_team_daily_activity(
+ team_ids=team_id,
+ start_date="2024-01-01",
+ end_date="2024-01-02",
+ model=None,
+ api_key=None,
+ page=1,
+ page_size=10,
+ exclude_team_ids=None,
+ user_api_key_dict=user_api_key_dict,
+ )
+
+ # Verify get_daily_activity was called WITHOUT API key filtering
+ mock_get_daily_activity.assert_called_once()
+ call_kwargs = mock_get_daily_activity.call_args[1]
+ assert call_kwargs["api_key"] is None
+ assert call_kwargs["entity_id"] == [team_id]
+
+ # Verify user's API keys were NOT fetched (since they're admin)
+ if hasattr(
+ mock_db_client.db.litellm_verificationtoken, "find_many"
+ ) and mock_db_client.db.litellm_verificationtoken.find_many.called:
+ # If it was called, that's unexpected for admin users
+ assert False, "API keys should not be fetched for team admin users"
+
+
@pytest.mark.asyncio
async def test_update_team_with_router_settings(mock_db_client, mock_admin_auth):
"""
@@ -4901,3 +5083,184 @@ async def test_update_team_with_router_settings(mock_db_client, mock_admin_auth)
# Verify router_settings can be deserialized and matches input
deserialized_settings = json.loads(team_data["router_settings"])
assert deserialized_settings == router_settings_data
+
+
+@pytest.mark.asyncio
+async def test_get_team_daily_activity_non_admin_filters_by_user_api_keys(
+ mock_db_client,
+):
+ """
+ Test that non-team-admin users only see their own spend (filtered by their API keys)
+ when calling /team/daily/activity endpoint.
+ """
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ get_team_daily_activity,
+ )
+
+ # Create a non-admin user
+ user_id = "test_user_123"
+ team_id = "test_team_456"
+ user_api_key_dict = UserAPIKeyAuth(
+ user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER
+ )
+
+ # Mock user info
+ mock_user_info = LiteLLM_UserTable(
+ user_id=user_id,
+ teams=[team_id],
+ max_budget=1000.0,
+ spend=0.0,
+ user_email="test@example.com",
+ user_role="internal_user",
+ )
+
+ # Mock team with user as non-admin member
+ mock_team_member = Member(user_id=user_id, role="user")
+ mock_team = MagicMock(spec=LiteLLM_TeamTable)
+ mock_team.team_id = team_id
+ mock_team.team_alias = "Test Team"
+ mock_team.members_with_roles = [mock_team_member]
+ mock_team.model_dump.return_value = {
+ "team_id": team_id,
+ "team_alias": "Test Team",
+ "members_with_roles": [{"user_id": user_id, "role": "user"}],
+ }
+
+ # Mock user's API keys
+ user_api_key_1 = MagicMock()
+ user_api_key_1.token = "user_key_1"
+ user_api_key_2 = MagicMock()
+ user_api_key_2.token = "user_key_2"
+
+ # Setup mocks
+ mock_db_client.db.litellm_teamtable.find_many = AsyncMock(
+ return_value=[mock_team]
+ )
+ mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(
+ return_value=[user_api_key_1, user_api_key_2]
+ )
+
+ # Mock get_user_object
+ with patch(
+ "litellm.proxy.management_endpoints.team_endpoints.get_user_object",
+ new_callable=AsyncMock,
+ ) as mock_get_user_object:
+ mock_get_user_object.return_value = mock_user_info
+
+ # Mock get_daily_activity to capture the api_key parameter
+ with patch(
+ "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity",
+ new_callable=AsyncMock,
+ ) as mock_get_daily_activity:
+ mock_get_daily_activity.return_value = MagicMock()
+
+ # Call the endpoint
+ await get_team_daily_activity(
+ team_ids=team_id,
+ start_date="2024-01-01",
+ end_date="2024-01-02",
+ model=None,
+ api_key=None,
+ page=1,
+ page_size=10,
+ exclude_team_ids=None,
+ user_api_key_dict=user_api_key_dict,
+ )
+
+ # Verify get_daily_activity was called with user's API keys as filter
+ mock_get_daily_activity.assert_called_once()
+ call_kwargs = mock_get_daily_activity.call_args[1]
+ assert call_kwargs["api_key"] == ["user_key_1", "user_key_2"]
+ assert call_kwargs["entity_id"] == [team_id]
+
+ # Verify user's API keys were fetched
+ mock_db_client.db.litellm_verificationtoken.find_many.assert_called_once()
+ api_key_call_kwargs = (
+ mock_db_client.db.litellm_verificationtoken.find_many.call_args[1]
+ )
+ assert api_key_call_kwargs["where"] == {"user_id": user_id}
+
+
+@pytest.mark.asyncio
+async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client):
+ """
+ Test that team admin users see all team spend (no API key filtering)
+ when calling /team/daily/activity endpoint.
+ """
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ get_team_daily_activity,
+ )
+
+ # Create a team admin user
+ user_id = "test_admin_123"
+ team_id = "test_team_456"
+ user_api_key_dict = UserAPIKeyAuth(
+ user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER
+ )
+
+ # Mock user info
+ mock_user_info = LiteLLM_UserTable(
+ user_id=user_id,
+ teams=[team_id],
+ max_budget=1000.0,
+ spend=0.0,
+ user_email="admin@example.com",
+ user_role="internal_user",
+ )
+
+ # Mock team with user as admin member
+ mock_team_member = Member(user_id=user_id, role="admin")
+ mock_team = MagicMock(spec=LiteLLM_TeamTable)
+ mock_team.team_id = team_id
+ mock_team.team_alias = "Test Team"
+ mock_team.members_with_roles = [mock_team_member]
+ mock_team.model_dump.return_value = {
+ "team_id": team_id,
+ "team_alias": "Test Team",
+ "members_with_roles": [{"user_id": user_id, "role": "admin"}],
+ }
+
+ # Setup mocks
+ mock_db_client.db.litellm_teamtable.find_many = AsyncMock(
+ return_value=[mock_team]
+ )
+
+ # Mock get_user_object
+ with patch(
+ "litellm.proxy.management_endpoints.team_endpoints.get_user_object",
+ new_callable=AsyncMock,
+ ) as mock_get_user_object:
+ mock_get_user_object.return_value = mock_user_info
+
+ # Mock get_daily_activity to capture the api_key parameter
+ with patch(
+ "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity",
+ new_callable=AsyncMock,
+ ) as mock_get_daily_activity:
+ mock_get_daily_activity.return_value = MagicMock()
+
+ # Call the endpoint
+ await get_team_daily_activity(
+ team_ids=team_id,
+ start_date="2024-01-01",
+ end_date="2024-01-02",
+ model=None,
+ api_key=None,
+ page=1,
+ page_size=10,
+ exclude_team_ids=None,
+ user_api_key_dict=user_api_key_dict,
+ )
+
+ # Verify get_daily_activity was called WITHOUT API key filtering
+ mock_get_daily_activity.assert_called_once()
+ call_kwargs = mock_get_daily_activity.call_args[1]
+ assert call_kwargs["api_key"] is None
+ assert call_kwargs["entity_id"] == [team_id]
+
+ # Verify user's API keys were NOT fetched (since they're admin)
+ if hasattr(
+ mock_db_client.db.litellm_verificationtoken, "find_many"
+ ) and mock_db_client.db.litellm_verificationtoken.find_many.called:
+ # If it was called, that's unexpected for admin users
+ assert False, "API keys should not be fetched for team admin users"