chore: resolve merge conflict with main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Julio Quinteros Pro 2026-02-18 19:10:36 -03:00
commit db615cd721
122 changed files with 8339 additions and 1340 deletions

View file

@ -22,7 +22,7 @@ commands:
name: "Install local version of litellm-enterprise"
command: |
cd enterprise
python -m pip install -e .
python -m pip install --force-reinstall --no-deps -e .
cd ..
setup_litellm_test_deps:
steps:

View file

@ -100,7 +100,11 @@ jobs:
- name: Setup litellm-enterprise
run: |
cd enterprise && poetry run pip install -e . && cd ..
cd enterprise && poetry run pip install --force-reinstall --no-deps -e . && cd ..
- name: Generate Prisma client
run: |
poetry run prisma generate --schema litellm/proxy/schema.prisma
- name: Run tests - ${{ matrix.test-group.name }}
run: |

View file

@ -43,7 +43,7 @@ jobs:
- name: Setup litellm-enterprise as local package
run: |
cd enterprise
poetry run pip install -e .
poetry run pip install --force-reinstall --no-deps -e .
cd ..
- name: Run tests
run: |

View file

@ -41,7 +41,7 @@ jobs:
- name: Setup litellm-enterprise as local package
run: |
cd enterprise
python -m pip install -e .
python -m pip install --force-reinstall --no-deps -e .
cd ..
- name: Run MCP tests

View file

@ -26,7 +26,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/Dockerfile.database
file: ./docker/Dockerfile.non_root
tags: litellm-test:${{ github.sha }}
load: true
cache-from: type=gha

View file

@ -24,6 +24,8 @@ hide_table_of_contents: false
**Severity:** High
**Status:** Resolved
> **Note:** This fix will be available starting from `v1.81.13-nightly` or higher of LiteLLM.
## Summary
Claude Code began sending unsupported Anthropic beta headers to non-Anthropic providers (Bedrock, Azure AI, Vertex AI), causing `invalid beta flag` errors. LiteLLM was forwarding all beta headers without provider-specific validation. Users experienced request failures when routing Claude Code requests through LiteLLM to these providers.

View file

@ -0,0 +1,117 @@
---
slug: vllm-embeddings-incident
title: "Incident Report: vLLM Embeddings Broken by encoding_format Parameter"
date: 2026-02-18T10:00:00
authors:
- name: Sameer Kankute
title: SWE @ LiteLLM (LLM Translation)
url: https://www.linkedin.com/in/sameer-kankute/
image_url: https://pbs.twimg.com/profile_images/2001352686994907136/ONgNuSk5_400x400.jpg
- name: Krrish Dholakia
title: "CEO, LiteLLM"
url: https://www.linkedin.com/in/krish-d/
image_url: https://pbs.twimg.com/profile_images/1298587542745358340/DZv3Oj-h_400x400.jpg
- name: Ishaan Jaff
title: "CTO, LiteLLM"
url: https://www.linkedin.com/in/reffajnaahsi/
image_url: https://pbs.twimg.com/profile_images/1613813310264340481/lz54oEiB_400x400.jpg
tags: [incident-report, embeddings, vllm]
hide_table_of_contents: false
---
**Date:** Feb 16, 2026
**Duration:** ~3 hours
**Severity:** High (for vLLM embedding users)
**Status:** Resolved
## Summary
A commit ([`dbcae4a`](https://github.com/BerriAI/litellm/commit/dbcae4aca5836770d0e9cd43abab0333c3d61ab2)) intended to fix OpenAI SDK behavior broke vLLM embeddings by explicitly passing `encoding_format=None` in API requests. vLLM rejects this with error: `"unknown variant \`\`, expected float or base64"`.
- **vLLM embedding calls:** Complete failure - all requests rejected
- **Other providers:** No impact - OpenAI and other providers functioned normally
- **Other vLLM functionality:** No impact - only embeddings were affected
{/* truncate */}
---
## Background
The `encoding_format` parameter for embeddings specifies whether vectors should be returned as `float` arrays or `base64` encoded strings. Different providers have different expectations:
- **OpenAI SDK:** If `encoding_format` is omitted, the SDK adds a default value of `"float"`
- **vLLM:** Strictly validates `encoding_format` - only accepts `"float"`, `"base64"`, or complete omission. Rejects `None` or empty string values.
```mermaid
flowchart TD
A["1. User calls litellm.embedding()
litellm/main.py"] --> B["2. Transform request for provider
litellm/llms/openai_like/embedding/handler.py"]
B --> C["3. Send request to vLLM endpoint"]
C -->|"encoding_format omitted"| D["4a. ✅ vLLM processes request"]
C -->|"encoding_format='float' or 'base64'"| D
C -->|"encoding_format=None or ''"| E["4b. ❌ vLLM rejects with error:
'unknown variant, expected float or base64'"]
style D fill:#d4edda,stroke:#28a745
style E fill:#f8d7da,stroke:#dc3545
style B fill:#fff3cd,stroke:#ffc107
```
---
## Root cause
A well-intentioned fix for OpenAI SDK behavior inadvertently broke vLLM embeddings:
**The Breaking Change ([`dbcae4a`](https://github.com/BerriAI/litellm/commit/dbcae4aca5836770d0e9cd43abab0333c3d61ab2)):**
In `litellm/main.py`, the code was changed to explicitly set `encoding_format=None` instead of omitting it:
```python
# Added in dbcae4a
if encoding_format is not None:
optional_params["encoding_format"] = encoding_format
else:
# Omitting causes openai sdk to add default value of "float"
optional_params["encoding_format"] = None
```
This fix worked correctly for OpenAI - explicitly passing `None` prevented the SDK from adding its default value. However, vLLM's strict parameter validation rejected `None` values, causing all embedding requests to fail.
---
## The Fix
Fix deployed ([`55348dd`](https://github.com/BerriAI/litellm/commit/55348dd9c51b5b028f676d25ad023b8f052fc071)). The solution filters out `None` and empty string values from `optional_params` before sending requests to OpenAI-like providers (including vLLM).
**In `litellm/llms/openai_like/embedding/handler.py`:**
```python
# Before (broken)
data = {"model": model, "input": input, **optional_params}
# After (fixed)
filtered_optional_params = {k: v for k, v in optional_params.items() if v not in (None, '')}
data = {"model": model, "input": input, **filtered_optional_params}
```
This ensures:
- Valid values (`"float"`, `"base64"`) are preserved and sent
- `None` and empty string values are filtered out (parameter omitted entirely)
- OpenAI SDK no longer adds defaults because liteLLM handles the parameter upstream
---
## Remediation
| # | Action | Status | Code |
|---|---|---|---|
| 1 | Filter `None` and empty string values in OpenAI-like embedding handler | ✅ Done | [`handler.py#L108`](https://github.com/BerriAI/litellm/blob/main/litellm/llms/openai_like/embedding/handler.py#L108) |
| 2 | Unit tests for parameter filtering (None, empty string, valid values) | ✅ Done | [`test_openai_like_embedding.py`](https://github.com/BerriAI/litellm/blob/main/tests/test_litellm/llms/openai_like/embedding/test_openai_like_embedding.py) |
| 3 | Transformation tests for hosted_vllm embedding config | ✅ Done | [`test_hosted_vllm_embedding_transformation.py`](https://github.com/BerriAI/litellm/blob/main/tests/test_litellm/llms/hosted_vllm/embedding/test_hosted_vllm_embedding_transformation.py) |
| 4 | E2E tests with actual vLLM endpoint | ✅ Done | [`test_hosted_vllm_embedding_e2e.py`](https://github.com/BerriAI/litellm/blob/main/tests/test_litellm/llms/hosted_vllm/embedding/test_hosted_vllm_embedding_e2e.py) |
| 5 | Validate JSON payload structure matches vLLM expectations | ✅ Done | Tests verify exact JSON sent to endpoint |
---

View file

@ -237,6 +237,7 @@ litellm_settings:
mode: pre_call # or post_call, during_call
api_base: https://your-guardrail-api.com
api_key: os.environ/YOUR_GUARDRAIL_API_KEY # optional
unreachable_fallback: fail_closed # default: fail_closed. Set to fail_open to proceed if the guardrail endpoint is unreachable (network errors, or HTTP 502/503/504 from an upstream proxy/LB).
additional_provider_specific_params:
# your custom parameters
threshold: 0.8

View file

@ -0,0 +1,465 @@
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
<Tabs>
<TabItem value="sdk" label="SDK">
```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"]
}
}
}]
)
```
</TabItem>
<TabItem value="proxy" label="PROXY">
```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
```
</TabItem>
</Tabs>
## 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
<Tabs>
<TabItem value="sdk" label="SDK">
```python
import litellm
# Enable for all completion calls
litellm.modify_params = True
```
</TabItem>
<TabItem value="proxy" label="PROXY">
```yaml
litellm_settings:
modify_params: true
```
</TabItem>
<TabItem value="env" label="Environment Variable">
```bash
export LITELLM_MODIFY_PARAMS=True
```
</TabItem>
</Tabs>
### 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 currently works with:
- ✅ Anthropic (Claude)
**Note:** While the sanitization logic is provider-agnostic, it is currently only applied in the Anthropic message transformation pipeline. Support for additional providers may be added in future releases.
## 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)

View file

@ -253,3 +253,12 @@ LiteLLM supports customizing the following Datadog environment variables
\* **Required when using Direct API** (default): `DD_API_KEY` and `DD_SITE` are required
\* **Optional when using DataDog Agent**: Set `LITELLM_DD_AGENT_HOST` to use agent mode; `DD_API_KEY` and `DD_SITE` are not required for **Datadog Logs**. (**Note: `DD_API_KEY` IS REQUIRED for Datadog LLM Observability**)
## Automatic Tags
LiteLLM automatically adds the following tags to your Datadog logs and metrics if the information is available in the request:
| Tag | Description | Source |
|-----|-------------|--------|
| `team` | The team alias or ID associated with the API Key | `user_api_key_team_alias`, `team_alias`, `user_api_key_team_id`, or `team_id` in metadata |
| `request_tag` | Custom tags passed in the request | `request_tags` in logging payload |

View file

@ -0,0 +1,52 @@
# watsonx.ai Rerank
## Overview
| Property | Details |
|----------|--------------------------------------------------------------------------|
| Description | watsonx.ai rerank integration |
| Provider Route on LiteLLM | `watsonx/` |
| Supported Operations | `/ml/v1/text/rerank` |
| Link to Provider Doc | [IBM WatsonX.ai ↗](https://cloud.ibm.com/apidocs/watsonx-ai#text-rerank) |
## Quick Start
### **LiteLLM SDK**
```python
import os
from litellm import rerank
os.environ["WATSONX_APIKEY"] = "YOUR_WATSONX_APIKEY"
os.environ["WATSONX_API_BASE"] = "YOUR_WATSONX_API_BASE"
os.environ["WATSONX_PROJECT_ID"] = "YOUR_WATSONX_PROJECT_ID"
query="Best programming language for beginners?"
documents=[
"Python is great for beginners due to simple syntax.",
"JavaScript runs in browsers and is versatile.",
"Rust has a steep learning curve but is very safe.",
]
response = rerank(
model="watsonx/cross-encoder/ms-marco-minilm-l-12-v2",
query=query,
documents=documents,
top_n=2,
return_documents=True,
)
print(response)
```
### **LiteLLM Proxy**
```yaml
model_list:
- model_name: cross-encoder/ms-marco-minilm-l-12-v2
litellm_params:
model: watsonx/cross-encoder/ms-marco-minilm-l-12-v2
api_key: os.environ/WATSONX_APIKEY
api_base: os.environ/WATSONX_API_BASE
project_id: os.environ/WATSONX_PROJECT_ID
```

View file

@ -358,7 +358,8 @@ router_settings:
| redis_url | str | URL for Redis server. **Known performance issue with Redis URL.** |
| cache_responses | boolean | Flag to enable caching LLM Responses, if cache set under `router_settings`. If true, caches responses. Defaults to False. |
| router_general_settings | RouterGeneralSettings | [SDK-Only] Router general settings - contains optimizations like 'async_only_mode'. [Docs](../routing.md#router-general-settings) |
| optional_pre_call_checks | List[str] | List of pre-call checks to add to the router. Currently supported: 'router_budget_limiting', 'prompt_caching' |
| optional_pre_call_checks | List[str] | List of pre-call checks to add to the router. Supported: `router_budget_limiting`, `prompt_caching`, `responses_api_deployment_check`, `deployment_affinity`, `forward_client_headers_by_model_group` |
| deployment_affinity_ttl_seconds | int | TTL (seconds) for user-key → deployment affinity mapping when `deployment_affinity` is enabled (configured at Router init / proxy startup). Defaults to `3600` (1 hour). |
| ignore_invalid_deployments | boolean | If true, ignores invalid deployments. Default for proxy is True - to prevent invalid models from blocking other models from being loaded. |
| search_tools | List[SearchToolTypedDict] | List of search tool configurations for Search API integration. Each tool specifies a search_tool_name and litellm_params with search_provider, api_key, api_base, etc. [Further Docs](../search.md) |
| guardrail_list | List[GuardrailTypedDict] | List of guardrail configurations for guardrail load balancing. Enables load balancing across multiple guardrail deployments with the same guardrail_name. [Further Docs](./guardrails/guardrail_load_balancing.md) |
@ -540,7 +541,7 @@ router_settings:
| DEFAULT_IMAGE_WIDTH | Default width for images. Default is 300
| DEFAULT_IN_MEMORY_TTL | Default time-to-live for in-memory cache in seconds. Default is 5
| DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL | Default time-to-live in seconds for management objects (User, Team, Key, Organization) in memory cache. Default is 60 seconds.
| DEFAULT_MAX_LRU_CACHE_SIZE | Default maximum size for LRU cache. Default is 16
| DEFAULT_MAX_LRU_CACHE_SIZE | Default maximum size for LRU cache. Default is 64
| DEFAULT_MAX_RECURSE_DEPTH | Default maximum recursion depth. Default is 100
| DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER | Default maximum recursion depth for sensitive data masker. Default is 10
| DEFAULT_MAX_RETRIES | Default maximum retry attempts. Default is 2

View file

@ -8,15 +8,15 @@ LiteLLM Follows the [cohere api request / response for the rerank api](https://c
## Overview
| Feature | Supported | Notes |
|---------|-----------|-------|
| Cost Tracking | ✅ | Works with all supported models |
| Logging | ✅ | Works across all integrations |
| End-user Tracking | ✅ | |
| Fallbacks | ✅ | Works between supported models |
| Loadbalancing | ✅ | Works between supported models |
| Guardrails | ✅ | Applies to input query only (not documents) |
| Supported Providers | Cohere, Together AI, Azure AI, DeepInfra, Nvidia NIM, Infinity, Fireworks AI, Voyage AI | |
| Feature | Supported | Notes |
|---------|-----------------------------------------------------------------------------------------------------|-------|
| Cost Tracking | ✅ | Works with all supported models |
| Logging | ✅ | Works across all integrations |
| End-user Tracking | ✅ | |
| Fallbacks | ✅ | Works between supported models |
| Loadbalancing | ✅ | Works between supported models |
| Guardrails | ✅ | Applies to input query only (not documents) |
| Supported Providers | Cohere, Together AI, Azure AI, DeepInfra, Nvidia NIM, Infinity, Fireworks AI, Voyage AI, watsonx.ai | |
## **LiteLLM Python SDK Usage**
### Quick Start
@ -123,17 +123,18 @@ curl http://0.0.0.0:4000/rerank \
#### ⚡See all supported models and providers at [models.litellm.ai](https://models.litellm.ai/)
| Provider | Link to Usage |
|-------------|--------------------|
| Cohere (v1 + v2 clients) | [Usage](#quick-start) |
| Together AI| [Usage](../docs/providers/togetherai) |
| Azure AI| [Usage](../docs/providers/azure_ai#rerank-endpoint) |
| Jina AI| [Usage](../docs/providers/jina_ai) |
| AWS Bedrock| [Usage](../docs/providers/bedrock#rerank-api) |
| HuggingFace| [Usage](../docs/providers/huggingface_rerank) |
| Infinity| [Usage](../docs/providers/infinity) |
| vLLM| [Usage](../docs/providers/vllm#rerank-endpoint) |
| DeepInfra| [Usage](../docs/providers/deepinfra#rerank-endpoint) |
| Vertex AI| [Usage](../docs/providers/vertex#rerank-api) |
| Fireworks AI| [Usage](../docs/providers/fireworks_ai#rerank-endpoint) |
| Voyage AI| [Usage](../docs/providers/voyage#rerank) |
| Provider | Link to Usage |
|--------------------------|------------------------------------------------------|
| Cohere (v1 + v2 clients) | [Usage](#quick-start) |
| Together AI | [Usage](../docs/providers/togetherai) |
| Azure AI | [Usage](../docs/providers/azure_ai#rerank-endpoint) |
| Jina AI | [Usage](../docs/providers/jina_ai) |
| AWS Bedrock | [Usage](../docs/providers/bedrock#rerank-api) |
| HuggingFace | [Usage](../docs/providers/huggingface_rerank) |
| Infinity | [Usage](../docs/providers/infinity) |
| vLLM | [Usage](../docs/providers/vllm#rerank-endpoint) |
| DeepInfra | [Usage](../docs/providers/deepinfra#rerank-endpoint) |
| Vertex AI | [Usage](../docs/providers/vertex#rerank-api) |
| Fireworks AI | [Usage](../docs/providers/fireworks_ai#rerank-endpoint) |
| Voyage AI | [Usage](../docs/providers/voyage#rerank) |
| IBM watsonx.ai | [Usage](../docs/providers/watsonx/rerank) |

View file

@ -884,7 +884,12 @@ router = litellm.Router(
},
},
],
optional_pre_call_checks=["responses_api_deployment_check"],
# `responses_api_deployment_check` ensures Requests with `previous_response_id`
# are routed to the same deployment. `deployment_affinity` adds sticky sessions
# for requests without `previous_response_id` (useful for implicit caching).
optional_pre_call_checks=["responses_api_deployment_check", "deployment_affinity"],
# Optional (default is 3600 seconds / 1 hour)
deployment_affinity_ttl_seconds=3600,
)
# Initial request
@ -911,7 +916,16 @@ follow_up = await router.aresponses(
#### 1. Setup session continuity on proxy config.yaml
To enable session continuity for Responses API in your LiteLLM proxy, set `optional_pre_call_checks: ["responses_api_deployment_check"]` in your proxy config.yaml.
To enable session continuity for Responses API in your LiteLLM proxy, set `optional_pre_call_checks` in your proxy config.yaml.
- `responses_api_deployment_check`: high priority routing when `previous_response_id` is provided
- `deployment_affinity`: sticky sessions based on user key (applies even without `previous_response_id`)
Notes:
- User-key affinity is keyed on `metadata.user_api_key_hash` (the API key hash). The OpenAI `user` request parameter is an end-user identifier and is intentionally not used for deployment affinity.
- `user_api_key_hash` is already SHA-256, and is used as-is (no double hashing).
- Affinity is scoped by a stable model identifier (the model-map key, e.g. `model_map_information.model_map_key`) so model aliases map to the same stickiness bucket.
- The mapping TTL is controlled by `deployment_affinity_ttl_seconds` (configured on Router init / proxy startup).
```yaml showLineNumbers title="config.yaml with Session Continuity"
model_list:
@ -929,7 +943,11 @@ model_list:
api_base: https://endpoint2.openai.azure.com
router_settings:
optional_pre_call_checks: ["responses_api_deployment_check"]
optional_pre_call_checks:
- responses_api_deployment_check
- deployment_affinity
# Optional (default is 3600 seconds / 1 hour)
deployment_affinity_ttl_seconds: 3600
```
#### 2. Use the OpenAI Python SDK to make requests to LiteLLM Proxy
@ -1356,8 +1374,3 @@ Response:

View file

@ -276,6 +276,7 @@ The response follows Perplexity's search format with the following structure:
| Firecrawl | `FIRECRAWL_API_KEY` | `firecrawl` |
| SearXNG | `SEARXNG_API_BASE` (required) | `searxng` |
| Linkup | `LINKUP_API_KEY` | `linkup` |
| DuckDuckGo | `DUCKDUCKGO_API_BASE` | `duckduckgo` |
See the individual provider documentation for detailed setup instructions and provider-specific parameters.

View file

@ -944,6 +944,7 @@ const sidebars = {
"providers/anthropic_tool_search",
"guides/code_interpreter",
"completion/message_trimming",
"completion/message_sanitization",
"completion/model_alias",
"completion/mock_requests",
"completion/predict_outputs",

View file

@ -1051,6 +1051,168 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
"""Handled in files_endpoints.py"""
return []
def _is_batch_polling_enabled(self) -> bool:
"""
Check if batch cost tracking is actually enabled and running.
Returns:
bool: True if batch cost tracking is active, False otherwise
"""
try:
# Import here to avoid circular dependencies
import litellm.proxy.proxy_server as proxy_server_module
# Check if the scheduler has the batch cost checking job registered
scheduler = getattr(proxy_server_module, 'scheduler', None)
if scheduler is None:
return False
# Check if the check_batch_cost_job exists in the scheduler
try:
job = scheduler.get_job('check_batch_cost_job')
if job is not None:
return True
except Exception:
# Job not found or scheduler doesn't support get_job
pass
return False
except Exception as e:
verbose_logger.warning(
f"Error checking batch polling configuration: {e}. Assuming disabled."
)
return False
async def _get_batches_referencing_file(
self, file_id: str
) -> List[Dict[str, Any]]:
"""
Find batches in non-terminal states that reference this file.
Non-terminal states: validating, in_progress, finalizing
Terminal states: completed, complete, failed, expired, cancelled
Args:
file_id: The unified file ID to check
Returns:
List of batch objects referencing this file in non-terminal state
(max 10 for error message display)
"""
# Prepare list of file IDs to check (both unified and provider IDs)
file_ids_to_check = [file_id]
# Get model-specific file IDs for this unified file ID if it's a managed file
try:
model_file_id_mapping = await self.get_model_file_id_mapping(
[file_id], litellm_parent_otel_span=None
)
if model_file_id_mapping and file_id in model_file_id_mapping:
# Add all provider file IDs for this unified file
provider_file_ids = list(model_file_id_mapping[file_id].values())
file_ids_to_check.extend(provider_file_ids)
except Exception as e:
verbose_logger.debug(
f"Could not get model file ID mapping for {file_id}: {e}. "
f"Will only check unified file ID."
)
MAX_MATCHES_TO_RETURN = 10
batches = await self.prisma_client.db.litellm_managedobjecttable.find_many(
where={
"file_purpose": "batch",
"status": {"in": ["validating", "in_progress", "finalizing"]},
},
take=MAX_MATCHES_TO_RETURN,
order={"created_at": "desc"},
)
referencing_batches = []
for batch in batches:
try:
# Parse the batch file_object to check for file references
batch_data = json.loads(batch.file_object) if isinstance(batch.file_object, str) else batch.file_object
# Extract file IDs from batch
# Batches typically reference the unified file ID in input_file_id
# Output and error files are generated by the provider
input_file_id = batch_data.get("input_file_id")
output_file_id = batch_data.get("output_file_id")
error_file_id = batch_data.get("error_file_id")
referenced_file_ids = [fid for fid in [input_file_id, output_file_id, error_file_id] if fid]
# Check if any referenced file ID matches the file we're trying to delete
if any(ref_id in file_ids_to_check for ref_id in referenced_file_ids):
referencing_batches.append({
"batch_id": batch.unified_object_id,
"status": batch.status,
"created_at": batch.created_at,
})
except Exception as e:
verbose_logger.warning(
f"Error parsing batch object {batch.unified_object_id}: {e}"
)
continue
return referencing_batches
async def _check_file_deletion_allowed(self, file_id: str) -> None:
"""
Check if file deletion should be blocked due to batch references.
Blocks deletion if:
1. File is referenced by any batch in non-terminal state, AND
2. Batch polling is configured (user wants cost tracking)
Args:
file_id: The unified file ID to check
Raises:
HTTPException: If file deletion should be blocked
"""
# Check if batch polling is enabled
if not self._is_batch_polling_enabled():
# Batch polling not configured, allow deletion
return
# Check if file is referenced by any non-terminal batches
referencing_batches = await self._get_batches_referencing_file(file_id)
if referencing_batches:
# File is referenced by non-terminal batches and polling is enabled
MAX_BATCHES_IN_ERROR = 5 # Limit batches shown in error message for readability
# Show up to MAX_BATCHES_IN_ERROR in the error message
batches_to_show = referencing_batches[:MAX_BATCHES_IN_ERROR]
batch_statuses = [f"{b['batch_id']}: {b['status']}" for b in batches_to_show]
# Determine the count message
count_message = f"{len(referencing_batches)}"
if len(referencing_batches) >= 10: # MAX_MATCHES_TO_RETURN from _get_batches_referencing_file
count_message = "10+"
error_message = (
f"Cannot delete file {file_id}. "
f"The file is referenced by {count_message} batch(es) in non-terminal state"
)
# Add specific batch details if not too many
if len(referencing_batches) <= MAX_BATCHES_IN_ERROR:
error_message += f": {', '.join(batch_statuses)}. "
else:
error_message += f" (showing {MAX_BATCHES_IN_ERROR} most recent): {', '.join(batch_statuses)}. "
error_message += (
f"To delete this file before complete cost tracking, please delete or cancel the referencing batch(es) first. "
f"Alternatively, wait for all batches to complete processing."
)
raise HTTPException(
status_code=400,
detail=error_message,
)
async def afile_delete(
self,
file_id: str,
@ -1059,6 +1221,9 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
**data: Dict,
) -> OpenAIFileObject:
# Check if file deletion should be blocked due to batch references
await self._check_file_deletion_allowed(file_id)
# file_id = convert_b64_uid_to_unified_uid(file_id)
model_file_id_mapping = await self.get_model_file_id_mapping(
[file_id], litellm_parent_otel_span

View file

@ -1,10 +1,13 @@
-- AlterTable
ALTER TABLE "LiteLLM_ManagedVectorStoresTable" ADD COLUMN "team_id" TEXT,
ADD COLUMN "user_id" TEXT;
ALTER TABLE "LiteLLM_ManagedVectorStoresTable"
ADD COLUMN IF NOT EXISTS "team_id" TEXT,
ADD COLUMN IF NOT EXISTS "user_id" TEXT;
-- CreateIndex
CREATE INDEX "LiteLLM_ManagedVectorStoresTable_team_id_idx" ON "LiteLLM_ManagedVectorStoresTable"("team_id");
CREATE INDEX IF NOT EXISTS "LiteLLM_ManagedVectorStoresTable_team_id_idx"
ON "LiteLLM_ManagedVectorStoresTable"("team_id");
-- CreateIndex
CREATE INDEX "LiteLLM_ManagedVectorStoresTable_user_id_idx" ON "LiteLLM_ManagedVectorStoresTable"("user_id");
CREATE INDEX IF NOT EXISTS "LiteLLM_ManagedVectorStoresTable_user_id_idx"
ON "LiteLLM_ManagedVectorStoresTable"("user_id");

View file

@ -1355,6 +1355,7 @@ if TYPE_CHECKING:
from .llms.vertex_ai.rerank.transformation import VertexAIRerankConfig as VertexAIRerankConfig
from .llms.fireworks_ai.rerank.transformation import FireworksAIRerankConfig as FireworksAIRerankConfig
from .llms.voyage.rerank.transformation import VoyageRerankConfig as VoyageRerankConfig
from .llms.watsonx.rerank.transformation import IBMWatsonXRerankConfig as IBMWatsonXRerankConfig
from .llms.clarifai.chat.transformation import ClarifaiConfig as ClarifaiConfig
from .llms.ai21.chat.transformation import AI21ChatConfig as AI21ChatConfig
from .llms.meta_llama.chat.transformation import LlamaAPIConfig as LlamaAPIConfig

View file

@ -155,6 +155,7 @@ LLM_CONFIG_NAMES = (
"VertexAIRerankConfig",
"FireworksAIRerankConfig",
"VoyageRerankConfig",
"IBMWatsonXRerankConfig",
"ClarifaiConfig",
"AI21ChatConfig",
"LlamaAPIConfig",
@ -672,6 +673,7 @@ _LLM_CONFIGS_IMPORT_MAP = {
"FireworksAIRerankConfig",
),
"VoyageRerankConfig": (".llms.voyage.rerank.transformation", "VoyageRerankConfig"),
"IBMWatsonXRerankConfig": (".llms.watsonx.rerank.transformation", "IBMWatsonXRerankConfig"),
"ClarifaiConfig": (".llms.clarifai.chat.transformation", "ClarifaiConfig"),
"AI21ChatConfig": (".llms.ai21.chat.transformation", "AI21ChatConfig"),
"LlamaAPIConfig": (".llms.meta_llama.chat.transformation", "LlamaAPIConfig"),

View file

@ -148,5 +148,35 @@
"tool-search-tool-2025-10-19": "tool-search-tool-2025-10-19",
"web-fetch-2025-09-10": null,
"web-search-2025-03-05": "web-search-2025-03-05"
},
"databricks": {
"advanced-tool-use-2025-11-20": "advanced-tool-use-2025-11-20",
"bash_20241022": null,
"bash_20250124": null,
"code-execution-2025-08-25": "code-execution-2025-08-25",
"compact-2026-01-12": "compact-2026-01-12",
"computer-use-2025-01-24": "computer-use-2025-01-24",
"computer-use-2025-11-24": "computer-use-2025-11-24",
"context-1m-2025-08-07": "context-1m-2025-08-07",
"context-management-2025-06-27": "context-management-2025-06-27",
"effort-2025-11-24": "effort-2025-11-24",
"fast-mode-2026-02-01": "fast-mode-2026-02-01",
"files-api-2025-04-14": "files-api-2025-04-14",
"structured-output-2024-03-01": null,
"fine-grained-tool-streaming-2025-05-14": "fine-grained-tool-streaming-2025-05-14",
"interleaved-thinking-2025-05-14": "interleaved-thinking-2025-05-14",
"mcp-client-2025-11-20": "mcp-client-2025-11-20",
"mcp-client-2025-04-04": "mcp-client-2025-04-04",
"mcp-servers-2025-12-04": null,
"oauth-2025-04-20": "oauth-2025-04-20",
"output-128k-2025-02-19": "output-128k-2025-02-19",
"prompt-caching-scope-2026-01-05": "prompt-caching-scope-2026-01-05",
"skills-2025-10-02": "skills-2025-10-02",
"structured-outputs-2025-11-13": "structured-outputs-2025-11-13",
"text_editor_20241022": null,
"text_editor_20250124": null,
"token-efficient-tools-2025-02-19": "token-efficient-tools-2025-02-19",
"web-fetch-2025-09-10": "web-fetch-2025-09-10",
"web-search-2025-03-05": "web-search-2025-03-05"
}
}

View file

@ -62,9 +62,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
def __init__(self):
pass
def _handle_raw_dict_response_item(
self, item: Dict[str, Any], index: int
) -> Tuple[Optional[Any], int]:
def _handle_raw_dict_response_item(self, item: Dict[str, Any], index: int) -> Tuple[Optional[Any], int]:
"""
Handle raw dict response items from Responses API (e.g., GPT-5 Codex format).
@ -107,13 +105,9 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
if item_type == "function_call":
# Extract provider_specific_fields if present and pass through as-is
provider_specific_fields = item.get("provider_specific_fields")
if provider_specific_fields and not isinstance(
provider_specific_fields, dict
):
if provider_specific_fields and not isinstance(provider_specific_fields, dict):
provider_specific_fields = (
dict(provider_specific_fields)
if hasattr(provider_specific_fields, "__dict__")
else {}
dict(provider_specific_fields) if hasattr(provider_specific_fields, "__dict__") else {}
)
tool_call_dict = {
@ -129,9 +123,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
if provider_specific_fields:
tool_call_dict["provider_specific_fields"] = provider_specific_fields
# Also add to function's provider_specific_fields for consistency
tool_call_dict["function"][
"provider_specific_fields"
] = provider_specific_fields
tool_call_dict["function"]["provider_specific_fields"] = provider_specific_fields
msg = Message(
content=None,
@ -169,7 +161,8 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
"type": "message",
"role": role,
"content": self._convert_content_to_responses_format(
content, role # type: ignore
content,
role, # type: ignore
),
}
)
@ -186,7 +179,8 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
elif isinstance(content, list):
# Transform list content to Responses API format
tool_output = self._convert_content_to_responses_format(
content, "user" # Use "user" role to get input_* types
content,
"user", # Use "user" role to get input_* types
)
else:
# Fallback: convert unexpected types to input_text
@ -219,9 +213,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
{
"type": "message",
"role": role,
"content": self._convert_content_to_responses_format(
content, cast(str, role)
),
"content": self._convert_content_to_responses_format(content, cast(str, role)),
}
)
@ -344,9 +336,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
previous_response_id = optional_params.get("previous_response_id")
if previous_response_id:
# Use the existing session handler for responses API
verbose_logger.debug(
f"Chat provider: Warning ignoring previous response ID: {previous_response_id}"
)
verbose_logger.debug(f"Chat provider: Warning ignoring previous response ID: {previous_response_id}")
# Convert back to responses API format for the actual request
@ -368,9 +358,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
"client": client,
}
verbose_logger.debug(
f"Chat provider: Final request model={api_model}, input_items={len(input_items)}"
)
verbose_logger.debug(f"Chat provider: Final request model={api_model}, input_items={len(input_items)}")
self._merge_responses_api_request_into_request_data(
request_data, responses_api_request, instructions
@ -450,9 +438,11 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
LiteLLMCompletionResponsesConfig,
)
tool_call_dict = LiteLLMCompletionResponsesConfig.convert_response_function_tool_call_to_chat_completion_tool_call(
tool_call_item=item,
index=tool_call_index,
tool_call_dict = (
LiteLLMCompletionResponsesConfig.convert_response_function_tool_call_to_chat_completion_tool_call(
tool_call_item=item,
index=tool_call_index,
)
)
accumulated_tool_calls.append(tool_call_dict)
tool_call_index += 1
@ -472,9 +462,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
tool_calls=accumulated_tool_calls,
reasoning_content=reasoning_content,
)
choices.append(
Choices(message=msg, finish_reason="tool_calls", index=index)
)
choices.append(Choices(message=msg, finish_reason="tool_calls", index=index))
reasoning_content = None
return choices
@ -510,17 +498,10 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
)
if len(choices) == 0:
if (
raw_response.incomplete_details is not None
and raw_response.incomplete_details.reason is not None
):
raise ValueError(
f"{model} unable to complete request: {raw_response.incomplete_details.reason}"
)
if raw_response.incomplete_details is not None and raw_response.incomplete_details.reason is not None:
raise ValueError(f"{model} unable to complete request: {raw_response.incomplete_details.reason}")
else:
raise ValueError(
f"Unknown items in responses API response: {raw_response.output}"
)
raise ValueError(f"Unknown items in responses API response: {raw_response.output}")
setattr(model_response, "choices", choices)
@ -529,11 +510,9 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
setattr(
model_response,
"usage",
ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(
raw_response.usage
),
ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(raw_response.usage),
)
# Preserve hidden params from the ResponsesAPIResponse, especially the headers
# which contain important provider information like x-request-id
raw_response_hidden_params = getattr(raw_response, "_hidden_params", {})
@ -550,24 +529,18 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
model_response._hidden_params[key] = merged_headers
else:
model_response._hidden_params[key] = value
return model_response
def get_model_response_iterator(
self,
streaming_response: Union[
Iterator[str], AsyncIterator[str], "ModelResponse", "BaseModel"
],
streaming_response: Union[Iterator[str], AsyncIterator[str], "ModelResponse", "BaseModel"],
sync_stream: bool,
json_mode: Optional[bool] = False,
) -> BaseModelResponseIterator:
return OpenAiResponsesToChatCompletionStreamIterator(
streaming_response, sync_stream, json_mode
)
return OpenAiResponsesToChatCompletionStreamIterator(streaming_response, sync_stream, json_mode)
def _convert_content_str_to_input_text(
self, content: str, role: str
) -> Dict[str, Any]:
def _convert_content_str_to_input_text(self, content: str, role: str) -> Dict[str, Any]:
if role == "user" or role == "system" or role == "tool":
return {"type": "input_text", "text": content}
else:
@ -594,9 +567,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
if actual_image_url is None:
raise ValueError(f"Invalid image URL: {content_image_url}")
image_param = ResponseInputImageParam(
image_url=actual_image_url, detail="auto", type="input_image"
)
image_param = ResponseInputImageParam(image_url=actual_image_url, detail="auto", type="input_image")
if detail:
image_param["detail"] = detail
@ -605,31 +576,29 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
def _convert_content_to_responses_format(
self,
content: Union[
str,
Iterable[
Union["OpenAIMessageContentListBlock", "ChatCompletionThinkingBlock"]
],
content: Optional[
Union[
str,
Iterable[Union["OpenAIMessageContentListBlock", "ChatCompletionThinkingBlock"]],
]
],
role: str,
) -> List[Dict[str, Any]]:
"""Convert chat completion content to responses API format"""
from litellm.types.llms.openai import ChatCompletionImageObject
verbose_logger.debug(
f"Chat provider: Converting content to responses format - input type: {type(content)}"
)
verbose_logger.debug(f"Chat provider: Converting content to responses format - input type: {type(content)}")
if isinstance(content, str):
if content is None:
return [self._convert_content_str_to_input_text("", role)]
elif isinstance(content, str):
result = [self._convert_content_str_to_input_text(content, role)]
verbose_logger.debug(f"Chat provider: String content -> {result}")
return result
elif isinstance(content, list):
result = []
for i, item in enumerate(content):
verbose_logger.debug(
f"Chat provider: Processing content item {i}: {type(item)} = {item}"
)
verbose_logger.debug(f"Chat provider: Processing content item {i}: {type(item)} = {item}")
if isinstance(item, str):
converted = self._convert_content_str_to_input_text(item, role)
result.append(converted)
@ -638,9 +607,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
# Handle multimodal content
original_type = item.get("type")
if original_type == "text":
converted = self._convert_content_str_to_input_text(
item.get("text", ""), role
)
converted = self._convert_content_str_to_input_text(item.get("text", ""), role)
result.append(converted)
verbose_logger.debug(f"Chat provider: text -> {converted}")
elif original_type == "image_url":
@ -652,18 +619,14 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
),
)
result.append(converted)
verbose_logger.debug(
f"Chat provider: image_url -> {converted}"
)
verbose_logger.debug(f"Chat provider: image_url -> {converted}")
else:
# Try to map other types to responses API format
item_type = original_type or "input_text"
if item_type == "image":
converted = {"type": "input_image", **item}
result.append(converted)
verbose_logger.debug(
f"Chat provider: image -> {converted}"
)
verbose_logger.debug(f"Chat provider: image -> {converted}")
elif item_type in [
"input_text",
"input_image",
@ -675,18 +638,12 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
]:
# Already in responses API format
result.append(item)
verbose_logger.debug(
f"Chat provider: passthrough -> {item}"
)
verbose_logger.debug(f"Chat provider: passthrough -> {item}")
else:
# Default to input_text for unknown types
converted = self._convert_content_str_to_input_text(
str(item.get("text", item)), role
)
converted = self._convert_content_str_to_input_text(str(item.get("text", item)), role)
result.append(converted)
verbose_logger.debug(
f"Chat provider: unknown({original_type}) -> {converted}"
)
verbose_logger.debug(f"Chat provider: unknown({original_type}) -> {converted}")
verbose_logger.debug(f"Chat provider: Final converted content: {result}")
return result
else:
@ -694,17 +651,13 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
verbose_logger.debug(f"Chat provider: Other content type -> {result}")
return result
def _convert_tools_to_responses_format(
self, tools: List[Dict[str, Any]]
) -> List["ALL_RESPONSES_API_TOOL_PARAMS"]:
def _convert_tools_to_responses_format(self, tools: List[Dict[str, Any]]) -> List["ALL_RESPONSES_API_TOOL_PARAMS"]:
"""Convert chat completion tools to responses API tools format"""
responses_tools: List["ALL_RESPONSES_API_TOOL_PARAMS"] = []
for tool in tools:
# convert function tool from chat completion to responses API format
if tool.get("type") == "function":
function_tool = cast(
ChatCompletionToolParamFunctionChunk, tool.get("function")
)
function_tool = cast(ChatCompletionToolParamFunctionChunk, tool.get("function"))
responses_tools.append(
FunctionToolParam(
name=function_tool["name"],
@ -730,9 +683,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
if not extra_body:
return optional_params
supported_responses_api_params = set(
ResponsesAPIOptionalRequestParams.__annotations__.keys()
)
supported_responses_api_params = set(ResponsesAPIOptionalRequestParams.__annotations__.keys())
# Also include params we handle specially
supported_responses_api_params.update(
{
@ -750,9 +701,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
return optional_params
def _map_reasoning_effort(
self, reasoning_effort: Union[str, Dict[str, Any]]
) -> Optional[Reasoning]:
def _map_reasoning_effort(self, reasoning_effort: Union[str, Dict[str, Any]]) -> Optional[Reasoning]:
# If dict is passed, convert it directly to Reasoning object
if isinstance(reasoning_effort, dict):
return Reasoning(**reasoning_effort) # type: ignore[typeddict-item]
@ -760,8 +709,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
# Check if auto-summary is enabled via flag or environment variable
# Priority: litellm.reasoning_auto_summary flag > LITELLM_REASONING_AUTO_SUMMARY env var
auto_summary_enabled = (
litellm.reasoning_auto_summary
or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true"
litellm.reasoning_auto_summary or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true"
)
# If string is passed, map with optional summary based on flag/env var
@ -772,11 +720,15 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
elif reasoning_effort == "xhigh":
return Reasoning(effort="xhigh", summary="detailed") if auto_summary_enabled else Reasoning(effort="xhigh") # type: ignore[typeddict-item]
elif reasoning_effort == "medium":
return Reasoning(effort="medium", summary="detailed") if auto_summary_enabled else Reasoning(effort="medium")
return (
Reasoning(effort="medium", summary="detailed") if auto_summary_enabled else Reasoning(effort="medium")
)
elif reasoning_effort == "low":
return Reasoning(effort="low", summary="detailed") if auto_summary_enabled else Reasoning(effort="low")
elif reasoning_effort == "minimal":
return Reasoning(effort="minimal", summary="detailed") if auto_summary_enabled else Reasoning(effort="minimal")
return (
Reasoning(effort="minimal", summary="detailed") if auto_summary_enabled else Reasoning(effort="minimal")
)
return None
def _add_web_search_tool(
@ -855,7 +807,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
return {"format": {"type": "text"}}
return None
@staticmethod
def _convert_annotations_to_chat_format(
annotations: Optional[List[Any]],
@ -908,9 +860,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
def __init__(
self, streaming_response, sync_stream: bool, json_mode: Optional[bool] = False
):
def __init__(self, streaming_response, sync_stream: bool, json_mode: Optional[bool] = False):
super().__init__(streaming_response, sync_stream, json_mode)
def _handle_string_chunk(
@ -923,9 +873,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
if not str_line or str_line.startswith("event:"):
# ignore.
return GenericStreamingChunk(
text="", tool_use=None, is_finished=False, finish_reason="", usage=None
)
return GenericStreamingChunk(text="", tool_use=None, is_finished=False, finish_reason="", usage=None)
index = str_line.find("data:")
if index != -1:
str_line = str_line[index + 5 :]
@ -988,13 +936,9 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
if output_item.get("type") == "function_call":
# Extract provider_specific_fields if present
provider_specific_fields = output_item.get("provider_specific_fields")
if provider_specific_fields and not isinstance(
provider_specific_fields, dict
):
if provider_specific_fields and not isinstance(provider_specific_fields, dict):
provider_specific_fields = (
dict(provider_specific_fields)
if hasattr(provider_specific_fields, "__dict__")
else {}
dict(provider_specific_fields) if hasattr(provider_specific_fields, "__dict__") else {}
)
function_chunk = ChatCompletionToolCallFunctionChunk(
@ -1003,9 +947,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
)
if provider_specific_fields:
function_chunk["provider_specific_fields"] = (
provider_specific_fields
)
function_chunk["provider_specific_fields"] = provider_specific_fields
tool_call_chunk = ChatCompletionToolCallChunk(
id=output_item.get("call_id"),
@ -1040,9 +982,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
id=None,
index=0,
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=None, arguments=content_part
),
function=ChatCompletionToolCallFunctionChunk(name=None, arguments=content_part),
)
]
),
@ -1051,22 +991,16 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
]
)
else:
raise ValueError(
f"Chat provider: Invalid function argument delta {parsed_chunk}"
)
raise ValueError(f"Chat provider: Invalid function argument delta {parsed_chunk}")
elif event_type == "response.output_item.done":
# New output item added
output_item = parsed_chunk.get("item", {})
if output_item.get("type") == "function_call":
# Extract provider_specific_fields if present
provider_specific_fields = output_item.get("provider_specific_fields")
if provider_specific_fields and not isinstance(
provider_specific_fields, dict
):
if provider_specific_fields and not isinstance(provider_specific_fields, dict):
provider_specific_fields = (
dict(provider_specific_fields)
if hasattr(provider_specific_fields, "__dict__")
else {}
dict(provider_specific_fields) if hasattr(provider_specific_fields, "__dict__") else {}
)
function_chunk = ChatCompletionToolCallFunctionChunk(
@ -1076,9 +1010,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
# Add provider_specific_fields to function if present
if provider_specific_fields:
function_chunk["provider_specific_fields"] = (
provider_specific_fields
)
function_chunk["provider_specific_fields"] = provider_specific_fields
tool_call_chunk = ChatCompletionToolCallChunk(
id=output_item.get("call_id"),
@ -1142,21 +1074,31 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
elif event_type == "response.completed":
# Response is fully complete - now we can signal is_finished=True
# This ensures we don't prematurely end the stream before tool_calls arrive
# Check if response contains function_call items in output
# to determine correct finish_reason
response_data = parsed_chunk.get("response", {})
output_items = response_data.get("output", []) if response_data else []
has_function_calls = any(
item.get("type") == "function_call" for item in output_items if isinstance(item, dict)
)
finish_reason = "tool_calls" if has_function_calls else "stop"
return ModelResponseStream(
choices=[
StreamingChoices(
index=0,
delta=Delta(content=""),
finish_reason="stop",
finish_reason=finish_reason,
)
]
)
else:
pass
# For any unhandled event types, create a minimal valid chunk or skip
verbose_logger.debug(
f"Chat provider: Unhandled event type '{event_type}', creating empty chunk"
)
verbose_logger.debug(f"Chat provider: Unhandled event type '{event_type}', creating empty chunk")
# Return a minimal valid chunk for unknown events
return ModelResponseStream(
@ -1179,9 +1121,5 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
Returns:
ModelResponseStream: OpenAI-formatted streaming chunk
"""
verbose_logger.debug(
f"Chat provider: transform_streaming_response called with chunk: {chunk}"
)
return OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(
chunk
)
verbose_logger.debug(f"Chat provider: transform_streaming_response called with chunk: {chunk}")
return OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(chunk)

View file

@ -287,7 +287,9 @@ MIN_NON_ZERO_TEMPERATURE = float(os.getenv("MIN_NON_ZERO_TEMPERATURE", 0.0001))
REPEATED_STREAMING_CHUNK_LIMIT = int(
os.getenv("REPEATED_STREAMING_CHUNK_LIMIT", 100)
) # catch if model starts looping the same chunk while streaming. Uses high default to prevent false positives.
DEFAULT_MAX_LRU_CACHE_SIZE = int(os.getenv("DEFAULT_MAX_LRU_CACHE_SIZE", 16))
# Shared maxsize for functools.lru_cache usage across hot paths.
# Defaulted to 64 to avoid cache thrash in multi-model production workloads.
DEFAULT_MAX_LRU_CACHE_SIZE = int(os.getenv("DEFAULT_MAX_LRU_CACHE_SIZE", 64))
_REALTIME_BODY_CACHE_SIZE = 1000 # Keep realtime helper caches bounded; workloads rarely exceed 1k models/intents
INITIAL_RETRY_DELAY = float(os.getenv("INITIAL_RETRY_DELAY", 0.5))
MAX_RETRY_DELAY = float(os.getenv("MAX_RETRY_DELAY", 8.0))
@ -576,6 +578,11 @@ OPENAI_CHAT_COMPLETION_PARAMS = [
"thinking",
"web_search_options",
"service_tier",
"store",
"prompt_cache_key",
"prompt_cache_retention",
"safety_identifier",
"verbosity",
]
OPENAI_TRANSCRIPTION_PARAMS = [

View file

@ -448,7 +448,9 @@ def cost_per_token( # noqa: PLR0915
elif custom_llm_provider == "anthropic":
return anthropic_cost_per_token(model=model, usage=usage_block)
elif custom_llm_provider == "bedrock":
return bedrock_cost_per_token(model=model, usage=usage_block)
return bedrock_cost_per_token(
model=model, usage=usage_block, service_tier=service_tier
)
elif custom_llm_provider == "openai":
return openai_cost_per_token(
model=model, usage=usage_block, service_tier=service_tier
@ -2146,4 +2148,3 @@ def handle_realtime_stream_cost_calculation(
return total_cost

View file

@ -93,7 +93,9 @@ class DatadogCostManagementLogger(CustomBatchLogger):
Aggregates costs by Provider, Model, and Date.
Returns a list of DatadogFOCUSCostEntry.
"""
aggregator: Dict[Tuple[str, str, str, Tuple[Tuple[str, str], ...]], DatadogFOCUSCostEntry] = {}
aggregator: Dict[
Tuple[str, str, str, Tuple[Tuple[str, str], ...]], DatadogFOCUSCostEntry
] = {}
for log in logs:
try:
@ -167,10 +169,20 @@ class DatadogCostManagementLogger(CustomBatchLogger):
metadata = log.get("metadata", {})
if metadata:
# Add user info
if "user_api_key_alias" in metadata:
# Add user info
if metadata.get("user_api_key_alias"):
tags["user"] = str(metadata["user_api_key_alias"])
if "user_api_key_team_alias" in metadata:
tags["team"] = str(metadata["user_api_key_team_alias"])
# Add Team Tag
team_tag = (
metadata.get("user_api_key_team_alias")
or metadata.get("team_alias") # type: ignore
or metadata.get("user_api_key_team_id")
or metadata.get("team_id") # type: ignore
)
if team_tag:
tags["team"] = str(team_tag)
# model_group is not in StandardLoggingMetadata TypedDict, so we need to access it via dict.get()
model_group = metadata.get("model_group") # type: ignore[misc]
if model_group:

View file

@ -55,4 +55,15 @@ def get_datadog_tags(
request_tags = standard_logging_object.get("request_tags", []) or []
tags.extend(f"request_tag:{tag}" for tag in request_tags)
# Add Team Tag
metadata = standard_logging_object.get("metadata", {}) or {}
team_tag = (
metadata.get("user_api_key_team_alias")
or metadata.get("team_alias")
or metadata.get("user_api_key_team_id")
or metadata.get("team_id")
)
if team_tag:
tags.append(f"team:{team_tag}")
return ",".join(tags)

View file

@ -22,6 +22,10 @@ from typing import (
import litellm
from litellm._logging import print_verbose, verbose_logger
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.core_helpers import (
get_litellm_metadata_from_kwargs,
get_metadata_variable_name_from_kwargs,
)
from litellm.proxy._types import (
LiteLLM_DeletedVerificationToken,
LiteLLM_TeamTable,
@ -1055,16 +1059,16 @@ class PrometheusLogger(CustomLogger):
enum_values=enum_values,
)
if (
standard_logging_payload["stream"] is True
): # log successful streaming requests from logging event hook.
_labels = prometheus_label_factory(
supported_enum_labels=self.get_labels_for_metric(
metric_name="litellm_proxy_total_requests_metric"
),
enum_values=enum_values,
)
self.litellm_proxy_total_requests_metric.labels(**_labels).inc()
# increment litellm_proxy_total_requests_metric for all successful requests
# (both streaming and non-streaming) in this single location to prevent
# double-counting that occurs when async_post_call_success_hook also increments
_labels = prometheus_label_factory(
supported_enum_labels=self.get_labels_for_metric(
metric_name="litellm_proxy_total_requests_metric"
),
enum_values=enum_values,
)
self.litellm_proxy_total_requests_metric.labels(**_labels).inc()
def _increment_token_metrics(
self,
@ -1086,13 +1090,6 @@ class PrometheusLogger(CustomLogger):
):
_tags = standard_logging_payload["request_tags"]
_labels = prometheus_label_factory(
supported_enum_labels=self.get_labels_for_metric(
metric_name="litellm_proxy_total_requests_metric"
),
enum_values=enum_values,
)
_labels = prometheus_label_factory(
supported_enum_labels=self.get_labels_for_metric(
metric_name="litellm_total_tokens_metric"
@ -1655,49 +1652,12 @@ class PrometheusLogger(CustomLogger):
):
"""
Proxy level tracking - triggered when the proxy responds with a success response to the client
Note: litellm_proxy_total_requests_metric is NOT incremented here to avoid
double-counting. It is incremented in async_log_success_event which fires
for all successful requests (both streaming and non-streaming).
"""
try:
from litellm.litellm_core_utils.litellm_logging import (
StandardLoggingPayloadSetup,
)
if self._should_skip_metrics_for_invalid_key(
user_api_key_dict=user_api_key_dict
):
return
_metadata = data.get("metadata", {}) or {}
enum_values = UserAPIKeyLabelValues(
end_user=user_api_key_dict.end_user_id,
hashed_api_key=user_api_key_dict.api_key,
api_key_alias=user_api_key_dict.key_alias,
requested_model=data.get("model", ""),
team=user_api_key_dict.team_id,
team_alias=user_api_key_dict.team_alias,
user=user_api_key_dict.user_id,
user_email=user_api_key_dict.user_email,
status_code="200",
route=user_api_key_dict.request_route,
tags=StandardLoggingPayloadSetup._get_request_tags(
litellm_params=data,
proxy_server_request=data.get("proxy_server_request", {}),
),
client_ip=_metadata.get("requester_ip_address"),
user_agent=_metadata.get("user_agent"),
)
_labels = prometheus_label_factory(
supported_enum_labels=self.get_labels_for_metric(
metric_name="litellm_proxy_total_requests_metric"
),
enum_values=enum_values,
)
self.litellm_proxy_total_requests_metric.labels(**_labels).inc()
except Exception as e:
verbose_logger.exception(
"prometheus Layer Error(): Exception occured - {}".format(str(e))
)
pass
pass
def _safe_get(self, obj: Any, key: str, default: Any = None) -> Any:
"""Get value from dict or Pydantic model."""
@ -2004,7 +1964,7 @@ class PrometheusLogger(CustomLogger):
api_base = standard_logging_payload["api_base"]
_litellm_params = request_kwargs.get("litellm_params", {}) or {}
_metadata = _litellm_params.get("metadata", {})
_metadata = get_litellm_metadata_from_kwargs(request_kwargs)
litellm_model_name = request_kwargs.get("model", None)
llm_provider = _litellm_params.get("custom_llm_provider", None)
_model_info = _metadata.get("model_info") or {}
@ -2220,7 +2180,8 @@ class PrometheusLogger(CustomLogger):
original_model_group,
kwargs,
)
_metadata = kwargs.get("metadata", {})
_metadata_key = get_metadata_variable_name_from_kwargs(kwargs)
_metadata = kwargs.get(_metadata_key) or {}
standard_metadata: StandardLoggingMetadata = (
StandardLoggingPayloadSetup.get_standard_logging_metadata(
metadata=_metadata
@ -2265,7 +2226,8 @@ class PrometheusLogger(CustomLogger):
kwargs,
)
_new_model = kwargs.get("model")
_metadata = kwargs.get("metadata", {})
_metadata_key = get_metadata_variable_name_from_kwargs(kwargs)
_metadata = kwargs.get(_metadata_key) or {}
_tags = cast(List[str], kwargs.get("tags") or [])
standard_metadata: StandardLoggingMetadata = (
StandardLoggingPayloadSetup.get_standard_logging_metadata(

View file

@ -1335,7 +1335,11 @@ class Logging(LiteLLMLoggingBaseClass):
)
# Store additional costs if provided (free-form dict for extensibility)
if additional_costs and isinstance(additional_costs, dict) and len(additional_costs) > 0:
if (
additional_costs
and isinstance(additional_costs, dict)
and len(additional_costs) > 0
):
self.cost_breakdown["additional_costs"] = additional_costs
# Store discount information if provided
@ -4519,13 +4523,19 @@ class StandardLoggingPayloadSetup:
requester_custom_headers=None,
cold_storage_object_key=None,
user_api_key_auth_metadata=None,
team_alias=None,
team_id=None,
)
if isinstance(metadata, dict):
for key in metadata.keys() & _STANDARD_LOGGING_METADATA_KEYS:
clean_metadata[key] = metadata[key] # type: ignore
user_api_key = metadata.get("user_api_key")
if user_api_key and isinstance(user_api_key, str) and is_valid_sha256_hash(user_api_key):
if (
user_api_key
and isinstance(user_api_key, str)
and is_valid_sha256_hash(user_api_key)
):
clean_metadata["user_api_key_hash"] = user_api_key
_potential_requester_metadata = metadata.get(
"metadata", None
@ -5279,6 +5289,8 @@ def get_standard_logging_metadata(
user_api_key_request_route=None,
cold_storage_object_key=None,
user_api_key_auth_metadata=None,
team_alias=None,
team_id=None,
)
if isinstance(metadata, dict):
# Update the clean_metadata with values from input metadata that match StandardLoggingMetadata fields

View file

@ -546,7 +546,11 @@ def convert_to_model_response_object( # noqa: PLR0915
message = litellm.Message(content=json_mode_content_str)
finish_reason = "stop"
if message is None:
provider_specific_fields = {}
# Preserve provider_specific_fields if already present
# in the response (e.g. from proxy passthrough)
provider_specific_fields = dict(
choice["message"].get("provider_specific_fields", None) or {}
)
message_keys = Message.model_fields.keys()
for field in choice["message"].keys():
if field not in message_keys:

View file

@ -2018,6 +2018,235 @@ 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 = cast(AllMessageValues, 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( # noqa: PLR0915
current_message: AllMessageValues,
messages: List[AllMessageValues],
current_index: int,
) -> Tuple[List[AllMessageValues], int]:
"""
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 tuple of:
- List containing the assistant message, followed by existing tool results,
followed by any dummy tool results needed
- Number of original messages consumed (to adjust iteration index)
"""
result_messages: List[AllMessageValues] = []
tool_calls = current_message.get("tool_calls")
if not tool_calls or len(cast(list, tool_calls)) == 0:
return ([current_message], 0)
# Collect all tool_call_ids from this assistant message
expected_tool_call_ids = set()
for tool_call in cast(list, 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)
# Collect actual tool result messages that follow this assistant message
found_tool_call_ids = set()
actual_tool_results: List[AllMessageValues] = []
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 and tool_call_id in expected_tool_call_ids:
found_tool_call_ids.add(tool_call_id)
actual_tool_results.append(next_msg)
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)
# Add existing tool results FIRST
result_messages.extend(actual_tool_results)
# Then add dummy tool results for missing ones
for tool_call_id in missing_tool_call_ids:
tool_name = "unknown_tool"
for tool_call in cast(list, 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 the messages and the number of original messages to skip
return (result_messages, len(actual_tool_results))
return ([current_message], 0)
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 cast(list, 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, messages_consumed = _add_missing_tool_results(current_message, messages, i)
# If dummy tool results were added, extend sanitized_messages and skip consumed messages
if len(result_messages) > 1:
sanitized_messages.extend(result_messages)
# Skip the assistant message and any actual tool results that were included
i += 1 + messages_consumed
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,
@ -2037,6 +2266,9 @@ 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.

View file

@ -172,8 +172,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
@staticmethod
def _is_claude_opus_4_6(model: str) -> bool:
"""Check if the model is Claude Opus 4.5."""
return "opus-4-6" in model.lower() or "opus_4_6" in model.lower()
"""Check if the model is Claude Opus 4.5 or Sonnet 4.6."""
return "opus-4-6" in model.lower() or "opus_4_6" in model.lower() or "sonnet-4-6" in model.lower() or "sonnet_4_6" in model.lower() or "sonnet-4.6" in model.lower()
def get_supported_openai_params(self, model: str):
params = [
@ -881,6 +881,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
"opus-4-5",
"opus-4.6",
"opus-4-6",
"sonnet-4.6",
"sonnet-4-6",
"sonnet_4.6",
"sonnet_4_6",
}
):
_output_format = (

View file

@ -22,6 +22,15 @@ from litellm.types.llms.anthropic import (
from litellm.types.llms.openai import AllMessageValues
def is_anthropic_oauth_key(value: Optional[str]) -> bool:
"""Check if a value contains an Anthropic OAuth token (sk-ant-oat*)."""
if value is None:
return False
# Handle both raw token and "Bearer <token>" format
if value.startswith("Bearer "):
value = value[7:]
return value.startswith(ANTHROPIC_OAUTH_TOKEN_PREFIX)
def optionally_handle_anthropic_oauth(
headers: dict, api_key: Optional[str]
) -> tuple[dict, Optional[str]]:

View file

@ -299,6 +299,26 @@ class LiteLLMAnthropicMessagesAdapter:
"""
return ["messages", "metadata", "system", "tool_choice", "tools", "thinking", "output_format"]
def _is_web_search_tool(self, tool: Dict[str, Any]) -> bool:
"""
Check if a tool is an Anthropic web search tool.
Anthropic web search tools have:
- type starting with "web_search" (e.g., "web_search_20260209")
- name = "web_search"
Args:
tool: Tool definition dict
Returns:
True if this is a web search tool
"""
tool_type = tool.get("type", "")
tool_name = tool.get("name", "")
return (
isinstance(tool_type, str) and tool_type.startswith("web_search")
) or tool_name == "web_search"
def translate_anthropic_messages_to_openai( # noqa: PLR0915
self,
messages: List[
@ -872,10 +892,25 @@ class LiteLLMAnthropicMessagesAdapter:
if "tools" in anthropic_message_request:
tools = anthropic_message_request["tools"]
if tools:
new_kwargs["tools"], tool_name_mapping = self.translate_anthropic_tools_to_openai(
tools=cast(List[AllAnthropicToolsValues], tools),
model=new_kwargs.get("model"),
)
# Separate web search tools from regular tools
web_search_tools = []
regular_tools = []
for tool in tools:
if self._is_web_search_tool(cast(Dict[str, Any], tool)):
web_search_tools.append(tool)
else:
regular_tools.append(tool)
# If web search tools are present, add web_search_options parameter
if web_search_tools:
new_kwargs["web_search_options"] = {} # type: ignore
# Only translate regular tools (non-web-search)
if regular_tools:
new_kwargs["tools"], tool_name_mapping = self.translate_anthropic_tools_to_openai(
tools=cast(List[AllAnthropicToolsValues], regular_tools),
model=new_kwargs.get("model"),
)
## CONVERT THINKING
if "thinking" in anthropic_message_request:

View file

@ -384,6 +384,14 @@ class BaseAWSLLM:
model_id = BaseAWSLLM._get_model_id_from_model_with_spec(
model_id, spec="moonshot"
)
elif "nova-2/" in model_id:
model_id = BaseAWSLLM._get_model_id_from_model_with_spec(
model_id, spec="nova-2"
)
elif "nova/" in model_id:
model_id = BaseAWSLLM._get_model_id_from_model_with_spec(
model_id, spec="nova"
)
return model_id
@staticmethod

View file

@ -272,7 +272,18 @@ class BedrockConverseLLM(BaseAWSLLM):
if unencoded_model_id is not None:
modelId = self.encode_model_id(model_id=unencoded_model_id)
else:
modelId = self.encode_model_id(model_id=model)
# Strip nova spec prefixes before encoding model ID for API URL
_model_for_id = model
_stripped = _model_for_id
for rp in ["bedrock/converse/", "bedrock/", "converse/"]:
if _stripped.startswith(rp):
_stripped = _stripped[len(rp):]
break
for _nova_prefix in ["nova-2/", "nova/"]:
if _stripped.startswith(_nova_prefix):
_model_for_id = _model_for_id.replace(_nova_prefix, "", 1)
break
modelId = self.encode_model_id(model_id=_model_for_id)
fake_stream = litellm.AmazonConverseConfig().should_fake_stream(
fake_stream=fake_stream,

View file

@ -3,6 +3,7 @@ Translating between OpenAI's `/chat/completion` format and Amazon's `/converse`
"""
import copy
import json
import time
import types
from typing import List, Literal, Optional, Tuple, Union, cast, overload
@ -85,9 +86,37 @@ BEDROCK_COMPUTER_USE_TOOLS = [
UNSUPPORTED_BEDROCK_CONVERSE_BETA_PATTERNS = [
"advanced-tool-use", # Bedrock Converse doesn't support advanced-tool-use beta headers
"prompt-caching", # Prompt caching not supported in Converse API
"compact-2026-01-12", # The compact beta feature is not currently supported on the Converse and ConverseStream APIs
"compact-2026-01-12", # The compact beta feature is not currently supported on the Converse and ConverseStream APIs
]
# Models that support Bedrock's native structured outputs API (outputConfig.textFormat)
# Uses substring matching against the Bedrock model ID
# Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/structured-output.html
BEDROCK_NATIVE_STRUCTURED_OUTPUT_MODELS = {
# Anthropic Claude 4.5+
"claude-haiku-4-5",
"claude-sonnet-4-5",
"claude-opus-4-5",
"claude-opus-4-6",
# Qwen3
"qwen3",
# DeepSeek
"deepseek-v3.1",
# Gemma 3
"gemma-3",
# MiniMax
"minimax-m2",
# Mistral (magistral-small excluded: broken constrained decoding on Bedrock)
"ministral",
"mistral-large-3",
"voxtral",
# Moonshot
"kimi-k2",
# NVIDIA
"nemotron-nano",
# OpenAI (gpt-oss excluded: broken constrained decoding, works via tool-call fallback)
}
class AmazonConverseConfig(BaseConfig):
"""
@ -270,45 +299,56 @@ class AmazonConverseConfig(BaseConfig):
llm_provider="bedrock",
)
def _is_nova_lite_2_model(self, model: str) -> bool:
def _is_nova_2_model(self, model: str) -> bool:
"""
Check if the model is a Nova Lite 2 model that supports reasoningConfig.
Check if the model is a Nova 2 model that supports reasoningConfig.
Nova Lite 2 models use a different reasoning configuration structure compared to
Nova 2 models use a different reasoning configuration structure compared to
Anthropic's thinking parameter and GPT-OSS's reasoning_effort parameter.
Supported models:
- amazon.nova-2-lite-v1:0
- amazon.nova-2-pro-preview-20251202-v1:0
- us.amazon.nova-2-lite-v1:0
- eu.amazon.nova-2-lite-v1:0
- apac.amazon.nova-2-lite-v1:0
- (and other regional variants)
Args:
model: The model identifier
Returns:
True if the model is a Nova Lite 2 model, False otherwise
True if the model is a Nova 2 model, False otherwise
Examples:
>>> config = AmazonConverseConfig()
>>> config._is_nova_lite_2_model("amazon.nova-2-lite-v1:0")
>>> config._is_nova_2_model("amazon.nova-2-lite-v1:0")
True
>>> config._is_nova_lite_2_model("us.amazon.nova-2-lite-v1:0")
>>> config._is_nova_2_model("us.amazon.nova-2-lite-v1:0")
True
>>> config._is_nova_lite_2_model("amazon.nova-pro-1-5-v1:0")
>>> config._is_nova_2_model("us.amazon.nova-2-pro-preview-20251202-v1:0")
True
>>> config._is_nova_2_model("amazon.nova-pro-1-5-v1:0")
False
>>> config._is_nova_lite_2_model("amazon.nova-pro-v1:0")
>>> config._is_nova_2_model("amazon.nova-pro-v1:0")
False
"""
# Remove regional prefix if present (us., eu., apac.)
# Remove provider routing prefix if present (bedrock/converse/, bedrock/, converse/)
model_without_region = model
for prefix in ["us.", "eu.", "apac."]:
if model.startswith(prefix):
model_without_region = model[len(prefix) :]
for routing_prefix in ["bedrock/converse/", "bedrock/", "converse/"]:
if model_without_region.startswith(routing_prefix):
model_without_region = model_without_region[len(routing_prefix) :]
break
# Check if the model is specifically Nova Lite 2
return "nova-2-lite" in model_without_region
# Remove regional prefix if present (us., eu., apac.)
for prefix in ["us.", "eu.", "apac."]:
if model_without_region.startswith(prefix):
model_without_region = model_without_region[len(prefix) :]
break
# Check if the model is a Nova 2 model (matches nova-2-lite, nova-2-pro, etc.)
# Also check for nova-2/ spec prefix for imported models
return model_without_region.startswith("amazon.nova-2-") or model_without_region.startswith("nova-2/")
def _map_web_search_options(
self, web_search_options: dict, model: str
@ -396,7 +436,7 @@ class AmazonConverseConfig(BaseConfig):
Different model families handle reasoning effort differently:
- GPT-OSS models: Keep reasoning_effort as-is (passed to additionalModelRequestFields)
- Nova Lite 2 models: Transform to reasoningConfig structure
- Nova 2 models: Transform to reasoningConfig structure
- Other models (Anthropic, etc.): Convert to thinking parameter
Args:
@ -425,8 +465,8 @@ class AmazonConverseConfig(BaseConfig):
# GPT-OSS models: keep reasoning_effort as-is
# It will be passed through to additionalModelRequestFields
optional_params["reasoning_effort"] = reasoning_effort
elif self._is_nova_lite_2_model(model):
# Nova Lite 2 models: transform to reasoningConfig
elif self._is_nova_2_model(model):
# Nova 2 models: transform to reasoningConfig
reasoning_config = self._transform_reasoning_effort_to_reasoning_config(
reasoning_effort
)
@ -480,6 +520,9 @@ class AmazonConverseConfig(BaseConfig):
supported_params.append("tool_choice")
supported_params.append("thinking")
supported_params.append("reasoning_effort")
# For nova imported models, also add web_search_options
if "nova" in model.lower():
supported_params.append("web_search_options")
return supported_params
## Filter out 'cross-region' from model name
@ -514,8 +557,8 @@ class AmazonConverseConfig(BaseConfig):
if "gpt-oss" in model:
supported_params.append("reasoning_effort")
elif self._is_nova_lite_2_model(model):
# Nova Lite 2 models support reasoning_effort (transformed to reasoningConfig)
elif self._is_nova_2_model(model):
# Nova 2 models support reasoning_effort (transformed to reasoningConfig)
# These models use a different reasoning structure than Anthropic's thinking parameter
supported_params.append("reasoning_effort")
elif (
@ -714,6 +757,100 @@ class AmazonConverseConfig(BaseConfig):
)
return _tool
@staticmethod
def _supports_native_structured_outputs(model: str) -> bool:
"""Check if the Bedrock model supports native structured outputs (outputConfig.textFormat)."""
return any(
substring in model
for substring in BEDROCK_NATIVE_STRUCTURED_OUTPUT_MODELS
)
@staticmethod
def _add_additional_properties_to_schema(schema: dict) -> dict:
"""
Recursively ensure all object types in a JSON schema have
``"additionalProperties": false``.
Bedrock's native structured-outputs API requires this field to be
explicitly set on every object node, otherwise it returns a
validation error.
"""
if not isinstance(schema, dict):
return schema
result = dict(schema)
if result.get("type") == "object" and "additionalProperties" not in result:
result["additionalProperties"] = False
# Recurse into nested schemas
if "properties" in result and isinstance(result["properties"], dict):
result["properties"] = {
k: AmazonConverseConfig._add_additional_properties_to_schema(v)
for k, v in result["properties"].items()
}
if "items" in result and isinstance(result["items"], dict):
result["items"] = AmazonConverseConfig._add_additional_properties_to_schema(
result["items"]
)
for defs_key in ("$defs", "definitions"):
if defs_key in result and isinstance(result[defs_key], dict):
result[defs_key] = {
k: AmazonConverseConfig._add_additional_properties_to_schema(v)
for k, v in result[defs_key].items()
}
for key in ("anyOf", "allOf", "oneOf"):
if key in result and isinstance(result[key], list):
result[key] = [
AmazonConverseConfig._add_additional_properties_to_schema(item)
for item in result[key]
]
return result
@staticmethod
def _create_output_config_for_response_format(
json_schema: Optional[dict] = None,
name: Optional[str] = None,
description: Optional[str] = None,
) -> "OutputConfigBlock":
"""
Build an outputConfig block for Bedrock's native structured outputs API.
The Converse API expects:
{
"outputConfig": {
"textFormat": {
"type": "json_schema",
"structure": {
"jsonSchema": {
"schema": "<json-string>",
"name": "optional",
"description": "optional"
}
}
}
}
}
"""
if json_schema is not None:
json_schema = AmazonConverseConfig._add_additional_properties_to_schema(
json_schema
)
schema_str = json.dumps(json_schema) if json_schema is not None else "{}"
json_schema_def: JsonSchemaDefinition = {"schema": schema_str}
if name is not None:
json_schema_def["name"] = name
if description is not None:
json_schema_def["description"] = description
return OutputConfigBlock(
textFormat=OutputFormat(
type="json_schema",
structure=OutputFormatStructure(jsonSchema=json_schema_def),
)
)
def _apply_tool_call_transformation(
self,
tools: List[OpenAIChatCompletionToolParam],
@ -806,8 +943,8 @@ class AmazonConverseConfig(BaseConfig):
)
# Only update thinking tokens for non-GPT-OSS models and non-Nova-Lite-2 models
# Nova Lite 2 handles token budgeting differently through reasoningConfig
if "gpt-oss" not in model and not self._is_nova_lite_2_model(model):
# Nova 2 handles token budgeting differently through reasoningConfig
if "gpt-oss" not in model and not self._is_nova_2_model(model):
self.update_optional_params_with_thinking_tokens(
non_default_params=non_default_params, optional_params=optional_params
)
@ -843,45 +980,53 @@ class AmazonConverseConfig(BaseConfig):
return optional_params
json_schema: Optional[dict] = None
name: Optional[str] = None
description: Optional[str] = None
if "response_schema" in value:
json_schema = value["response_schema"]
elif "json_schema" in value:
json_schema = value["json_schema"]["schema"]
name = value["json_schema"].get("name")
description = value["json_schema"].get("description")
if "type" in value and value["type"] == "text":
return optional_params
"""
Follow similar approach to anthropic - translate to a single tool call.
When using tools in this way: - https://docs.anthropic.com/en/docs/build-with-claude/tool-use#json-mode
- You usually want to provide a single tool
- You should set tool_choice (see Forcing tool use) to instruct the model to explicitly use that tool
- Remember that the model will pass the input to the tool, so the name of the tool and description should be from the models perspective.
"""
_tool = self._create_json_tool_call_for_response_format(
json_schema=json_schema,
description=description,
)
optional_params = self._add_tools_to_optional_params(
optional_params=optional_params, tools=[_tool]
)
if (
litellm.utils.supports_tool_choice(
model=model, custom_llm_provider=self.custom_llm_provider
if self._supports_native_structured_outputs(model) and json_schema is not None:
# Use Bedrock's native structured outputs API (outputConfig.textFormat)
# No synthetic tool injection, no fake_stream needed.
# Requires an explicit schema — json_object with no schema falls through
# to the tool-call path below.
output_config = self._create_output_config_for_response_format(
json_schema=json_schema,
name=name,
description=description,
)
and not is_thinking_enabled
):
optional_params["tool_choice"] = ToolChoiceValuesBlock(
tool=SpecificToolChoiceBlock(name=RESPONSE_FORMAT_TOOL_NAME)
optional_params["outputConfig"] = output_config
else:
# Fallback: translate to a synthetic tool call
# https://docs.anthropic.com/en/docs/build-with-claude/tool-use#json-mode
_tool = self._create_json_tool_call_for_response_format(
json_schema=json_schema,
description=description,
)
optional_params = self._add_tools_to_optional_params(
optional_params=optional_params, tools=[_tool]
)
if (
litellm.utils.supports_tool_choice(
model=model, custom_llm_provider=self.custom_llm_provider
)
and not is_thinking_enabled
):
optional_params["tool_choice"] = ToolChoiceValuesBlock(
tool=SpecificToolChoiceBlock(name=RESPONSE_FORMAT_TOOL_NAME)
)
if non_default_params.get("stream", False) is True:
optional_params["fake_stream"] = True
optional_params["json_mode"] = True
if non_default_params.get("stream", False) is True:
optional_params["fake_stream"] = True
return optional_params
def update_optional_params_with_thinking_tokens(
@ -1024,7 +1169,7 @@ class AmazonConverseConfig(BaseConfig):
def _prepare_request_params(
self, optional_params: dict, model: str
) -> Tuple[dict, dict, dict]:
) -> Tuple[dict, dict, dict, Optional[OutputConfigBlock]]:
"""Prepare and separate request parameters."""
# Filter out exception objects before deepcopy to prevent deepcopy failures
# Exceptions should not be stored in optional_params (this is a defensive fix)
@ -1047,6 +1192,8 @@ class AmazonConverseConfig(BaseConfig):
if request_metadata is not None:
self._validate_request_metadata(request_metadata)
output_config: Optional[OutputConfigBlock] = inference_params.pop("outputConfig", None)
# keep supported params in 'inference_params', and set all model-specific params in 'additional_request_params'
additional_request_params = {
k: v for k, v in inference_params.items() if k not in total_supported_params
@ -1071,7 +1218,12 @@ class AmazonConverseConfig(BaseConfig):
additional_request_params
)
return inference_params, additional_request_params, request_metadata
return (
inference_params,
additional_request_params,
request_metadata,
output_config,
)
def _process_tools_and_beta(
self,
@ -1125,22 +1277,44 @@ class AmazonConverseConfig(BaseConfig):
# "computer-use-2025-01-24" for Claude Sonnet 4.5, Haiku 4.5, Opus 4.1, Sonnet 4, Opus 4, and Sonnet 3.7
# "computer-use-2024-10-22" for older models
model_lower = model.lower()
if "opus-4.6" in model_lower or "opus_4.6" in model_lower or "opus-4-6" in model_lower or "opus_4_6" in model_lower:
if "opus-4.6" in model_lower or "opus_4.6" in model_lower or "opus-4-6" in model_lower or "opus_4_6" in model_lower or "sonnet-4.6" in model_lower or "sonnet_4.6" in model_lower or "sonnet-4-6" in model_lower or "sonnet_4_6" in model_lower:
computer_use_header = "computer-use-2025-11-24"
elif "opus-4.5" in model_lower or "opus_4.5" in model_lower or "opus-4-5" in model_lower or "opus_4_5" in model_lower:
elif (
"opus-4.5" in model_lower
or "opus_4.5" in model_lower
or "opus-4-5" in model_lower
or "opus_4_5" in model_lower
):
computer_use_header = "computer-use-2025-11-24"
elif any(pattern in model_lower for pattern in [
"sonnet-4.5", "sonnet_4.5", "sonnet-4-5", "sonnet_4_5",
"haiku-4.5", "haiku_4.5", "haiku-4-5", "haiku_4_5",
"opus-4.1", "opus_4.1", "opus-4-1", "opus_4_1",
"sonnet-4", "sonnet_4",
"opus-4", "opus_4",
"sonnet-3.7", "sonnet_3.7", "sonnet-3-7", "sonnet_3_7"
]):
elif any(
pattern in model_lower
for pattern in [
"sonnet-4.5",
"sonnet_4.5",
"sonnet-4-5",
"sonnet_4_5",
"haiku-4.5",
"haiku_4.5",
"haiku-4-5",
"haiku_4_5",
"opus-4.1",
"opus_4.1",
"opus-4-1",
"opus_4_1",
"sonnet-4",
"sonnet_4",
"opus-4",
"opus_4",
"sonnet-3.7",
"sonnet_3.7",
"sonnet-3-7",
"sonnet_3_7",
]
):
computer_use_header = "computer-use-2025-01-24"
else:
computer_use_header = "computer-use-2024-10-22"
anthropic_beta_list.append(computer_use_header)
# Transform computer use tools to proper Bedrock format
transformed_computer_tools = self._transform_computer_use_tools(
@ -1214,6 +1388,7 @@ class AmazonConverseConfig(BaseConfig):
inference_params,
additional_request_params,
request_metadata,
output_config,
) = self._prepare_request_params(optional_params, model)
original_tools = inference_params.pop("tools", [])
@ -1256,6 +1431,9 @@ class AmazonConverseConfig(BaseConfig):
if request_metadata is not None:
data["requestMetadata"] = request_metadata
if output_config is not None:
data["outputConfig"] = output_config
return data
async def _async_transform_request(
@ -1504,9 +1682,7 @@ class AmazonConverseConfig(BaseConfig):
return message, returned_finish_reason
def _translate_message_content(
self, content_blocks: List[ContentBlock]
) -> Tuple[
def _translate_message_content(self, content_blocks: List[ContentBlock]) -> Tuple[
str,
List[ChatCompletionToolCallChunk],
Optional[List[BedrockConverseReasoningContentBlock]],
@ -1523,9 +1699,9 @@ class AmazonConverseConfig(BaseConfig):
"""
content_str = ""
tools: List[ChatCompletionToolCallChunk] = []
reasoningContentBlocks: Optional[
List[BedrockConverseReasoningContentBlock]
] = None
reasoningContentBlocks: Optional[List[BedrockConverseReasoningContentBlock]] = (
None
)
citationsContentBlocks: Optional[List[CitationsContentBlock]] = None
for idx, content in enumerate(content_blocks):
"""
@ -1652,9 +1828,9 @@ class AmazonConverseConfig(BaseConfig):
chat_completion_message: ChatCompletionResponseMessage = {"role": "assistant"}
content_str = ""
tools: List[ChatCompletionToolCallChunk] = []
reasoningContentBlocks: Optional[
List[BedrockConverseReasoningContentBlock]
] = None
reasoningContentBlocks: Optional[List[BedrockConverseReasoningContentBlock]] = (
None
)
citationsContentBlocks: Optional[List[CitationsContentBlock]] = None
if message is not None:
@ -1673,17 +1849,17 @@ class AmazonConverseConfig(BaseConfig):
provider_specific_fields["citationsContent"] = citationsContentBlocks
if provider_specific_fields:
chat_completion_message[
"provider_specific_fields"
] = provider_specific_fields
chat_completion_message["provider_specific_fields"] = (
provider_specific_fields
)
if reasoningContentBlocks is not None:
chat_completion_message[
"reasoning_content"
] = self._transform_reasoning_content(reasoningContentBlocks)
chat_completion_message[
"thinking_blocks"
] = self._transform_thinking_blocks(reasoningContentBlocks)
chat_completion_message["reasoning_content"] = (
self._transform_reasoning_content(reasoningContentBlocks)
)
chat_completion_message["thinking_blocks"] = (
self._transform_thinking_blocks(reasoningContentBlocks)
)
chat_completion_message["content"] = content_str
if (
json_mode is True
@ -1696,8 +1872,6 @@ class AmazonConverseConfig(BaseConfig):
)
json_mode_content_str: Optional[str] = tools[0]["function"].get("arguments")
if json_mode_content_str is not None:
import json
# Bedrock returns the response wrapped in a "properties" object
# We need to extract the actual content from this wrapper
try:
@ -1716,7 +1890,7 @@ class AmazonConverseConfig(BaseConfig):
pass
chat_completion_message["content"] = json_mode_content_str
else:
elif tools:
chat_completion_message["tool_calls"] = tools
## CALCULATING USAGE - bedrock returns usage in the headers

View file

@ -404,7 +404,7 @@ def extract_model_name_from_bedrock_arn(model: str) -> str:
def strip_bedrock_routing_prefix(model: str) -> str:
"""Strip LiteLLM routing prefixes from model name."""
for prefix in ["bedrock/", "converse/", "invoke/", "openai/"]:
for prefix in ["bedrock/", "converse/", "invoke/", "openai/", "nova-2/", "nova/"]:
if model.startswith(prefix):
model = model.split("/", 1)[1]
return model
@ -427,7 +427,20 @@ def get_bedrock_base_model(model: str) -> str:
- "us.meta.llama3-2-11b-instruct-v1:0" -> "meta.llama3-2-11b-instruct-v1"
- "bedrock/converse/model" -> "model"
- "anthropic.claude-3-5-sonnet-20241022-v2:0:51k" -> "anthropic.claude-3-5-sonnet-20241022-v2:0"
- "bedrock/nova-2/arn:aws:..." -> "amazon.nova-2-custom"
- "bedrock/nova/arn:aws:..." -> "amazon.nova-custom"
"""
# Detect nova spec prefixes before stripping them
stripped = model
for rp in ["bedrock/converse/", "bedrock/", "converse/"]:
if stripped.startswith(rp):
stripped = stripped[len(rp):]
break
if stripped.startswith("nova-2/"):
return "amazon.nova-2-custom"
elif stripped.startswith("nova/"):
return "amazon.nova-custom"
model = strip_bedrock_routing_prefix(model)
model = extract_model_name_from_bedrock_arn(model)
model = strip_bedrock_throughput_suffix(model)
@ -465,6 +478,14 @@ def is_claude_4_5_on_bedrock(model: str) -> bool:
"opus_4.5",
"opus-4-5",
"opus_4_5",
"sonnet-4.6",
"sonnet_4.6",
"sonnet-4-6",
"sonnet_4_6",
"opus-4.6",
"opus_4.6",
"opus-4-6",
"opus_4_6",
]
return any(pattern in model_lower for pattern in claude_4_5_patterns)
@ -594,6 +615,11 @@ class BedrockModelInfo(BaseLLMModelInfo):
if prefix in model:
return route_type
# Check for nova spec prefixes (nova/ and nova-2/)
_model_after_bedrock = model.replace("bedrock/", "", 1)
if _model_after_bedrock.startswith("nova-2/") or _model_after_bedrock.startswith("nova/"):
return "converse"
base_model = BedrockModelInfo.get_base_model(model)
alt_model = BedrockModelInfo.get_non_litellm_routing_model_name(model=model)
if (

View file

@ -3,7 +3,7 @@ Helper util for handling bedrock-specific cost calculation
- e.g.: prompt caching
"""
from typing import TYPE_CHECKING, Tuple
from typing import TYPE_CHECKING, Optional, Tuple
from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token
@ -11,12 +11,17 @@ if TYPE_CHECKING:
from litellm.types.utils import Usage
def cost_per_token(model: str, usage: "Usage") -> Tuple[float, float]:
def cost_per_token(
model: str, usage: "Usage", service_tier: Optional[str] = None
) -> Tuple[float, float]:
"""
Calculates the cost per token for a given model, prompt tokens, and completion tokens.
Follows the same logic as Anthropic's cost per token calculation.
"""
return generic_cost_per_token(
model=model, usage=usage, custom_llm_provider="bedrock"
)
model=model,
usage=usage,
custom_llm_provider="bedrock",
service_tier=service_tier,
)

View file

@ -180,6 +180,14 @@ class AmazonAnthropicClaudeMessagesConfig(
"opus_4", # Opus 4
"sonnet-4",
"sonnet_4", # Sonnet 4
"sonnet-4.6",
"sonnet_4.6",
"sonnet-4-6",
"sonnet_4_6",
"opus-4.6",
"opus_4.6",
"opus-4-6",
"opus_4_6",
]
return any(pattern in model_lower for pattern in supported_patterns)
@ -251,6 +259,11 @@ class AmazonAnthropicClaudeMessagesConfig(
"opus_4.6",
"opus-4-6",
"opus_4_6",
#sonnet 4.6
"sonnet-4.6",
"sonnet_4.6",
"sonnet-4-6",
"sonnet_4_6",
]
return any(pattern in model_lower for pattern in supported_patterns)
@ -285,7 +298,7 @@ class AmazonAnthropicClaudeMessagesConfig(
programmatic_tool_calling_used or input_examples_used
):
beta_set.discard(ANTHROPIC_TOOL_SEARCH_BETA_HEADER)
if "opus-4" in model.lower() or "opus_4" in model.lower():
if self._supports_tool_search_on_bedrock(model):
beta_set.add("tool-search-tool-2025-10-19")
def _convert_output_format_to_inline_schema(
@ -420,10 +433,8 @@ class AmazonAnthropicClaudeMessagesConfig(
beta_set=beta_set,
)
# --- Custom logic: if tool-search-tool-2025-10-19 is present, add tool-examples-2025-10-29 ---
if "tool-search-tool-2025-10-19" in beta_set:
beta_set.add("tool-examples-2025-10-29")
# ------------------------------------------------------------------------------
if beta_set:
anthropic_messages_request["anthropic_beta"] = list(beta_set)

View file

@ -0,0 +1,6 @@
"""
DuckDuckGo Search API module.
"""
from litellm.llms.duckduckgo.search.transformation import DuckDuckGoSearchConfig
__all__ = ["DuckDuckGoSearchConfig"]

View file

@ -0,0 +1,252 @@
"""
Calls DuckDuckGo's Instant Answer API to search the web.
DuckDuckGo API Reference: https://duckduckgo.com/api
"""
from typing import Dict, List, Literal, Optional, TypedDict, Union
from urllib.parse import urlencode
import httpx
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.search.transformation import (
BaseSearchConfig,
SearchResponse,
SearchResult,
)
from litellm.secret_managers.main import get_secret_str
class _DuckDuckGoSearchRequestRequired(TypedDict):
"""Required fields for DuckDuckGo Search API request."""
q: str # Required - search query
class DuckDuckGoSearchRequest(_DuckDuckGoSearchRequestRequired, total=False):
"""
DuckDuckGo Instant Answer API request format.
Based on: https://duckduckgo.com/api
"""
format: str # Optional - output format ('json', 'xml'), default 'json'
pretty: int # Optional - pretty print (0 or 1), default 1
no_redirect: int # Optional - skip HTTP redirects (0 or 1), default 0
no_html: int # Optional - remove HTML from text (0 or 1), default 0
skip_disambig: int # Optional - skip disambiguation results (0 or 1), default 0
class DuckDuckGoSearchConfig(BaseSearchConfig):
DUCKDUCKGO_API_BASE = "https://api.duckduckgo.com"
@staticmethod
def ui_friendly_name() -> str:
return "DuckDuckGo"
def get_http_method(self) -> Literal["GET", "POST"]:
"""
Get HTTP method for search requests.
DuckDuckGo Instant Answer API uses GET requests.
Returns:
HTTP method 'GET'
"""
return "GET"
def validate_environment(
self,
headers: Dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
**kwargs,
) -> Dict:
"""
Validate environment and return headers.
DuckDuckGo Instant Answer API does not require authentication.
"""
# DuckDuckGo API is free and doesn't require API key
headers["Content-Type"] = "application/json"
return headers
def get_complete_url(
self,
api_base: Optional[str],
optional_params: dict,
data: Optional[Union[Dict, List[Dict]]] = None,
**kwargs,
) -> str:
"""
Get complete URL for Search endpoint.
DuckDuckGo uses query parameters, so we construct the URL with the query.
"""
api_base = api_base or get_secret_str("DUCKDUCKGO_API_BASE") or self.DUCKDUCKGO_API_BASE
# Build query parameters from the transformed request body
if data and isinstance(data, dict) and "_duckduckgo_params" in data:
params = data["_duckduckgo_params"]
query_string = urlencode(params, doseq=True)
return f"{api_base}/?{query_string}"
return api_base
def transform_search_request(
self,
query: Union[str, List[str]],
optional_params: dict,
**kwargs,
) -> Dict:
"""
Transform Search request to DuckDuckGo API format.
Args:
query: Search query (string or list of strings). DuckDuckGo only supports single string queries.
optional_params: Optional parameters for the request
- max_results: Maximum number of search results (DuckDuckGo API doesn't directly support this, used for filtering)
- format: Output format ('json', 'xml')
- pretty: Pretty print (0 or 1)
- no_redirect: Skip HTTP redirects (0 or 1)
- no_html: Remove HTML from text (0 or 1)
- skip_disambig: Skip disambiguation results (0 or 1)
Returns:
Dict with typed request data following DuckDuckGoSearchRequest spec
"""
if isinstance(query, list):
# DuckDuckGo only supports single string queries
query = " ".join(query)
request_data: DuckDuckGoSearchRequest = {
"q": query,
"format": "json", # Always use JSON format
}
# Convert to dict before dynamic key assignments
result_data = dict(request_data)
if "max_results" in optional_params:
result_data["_max_results"] = optional_params["max_results"]
# Pass through DuckDuckGo-specific parameters
ddg_params = ["pretty", "no_redirect", "no_html", "skip_disambig"]
for param in ddg_params:
if param in optional_params:
result_data[param] = optional_params[param]
return {
"_duckduckgo_params": result_data,
}
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
**kwargs,
) -> SearchResponse:
"""
Transform DuckDuckGo API response to LiteLLM unified SearchResponse format.
DuckDuckGo LiteLLM mappings:
- RelatedTopics[].Text SearchResult.title + snippet
- RelatedTopics[].FirstURL SearchResult.url
- RelatedTopics[].Text SearchResult.snippet
- No date/last_updated fields in DuckDuckGo response (set to None)
Args:
raw_response: Raw httpx response from DuckDuckGo API
logging_obj: Logging object for tracking
Returns:
SearchResponse with standardized format
"""
response_json = raw_response.json()
# Extract max_results from the request URL params
query_params = raw_response.request.url.params if raw_response.request else {}
max_results = None
if "_max_results" in query_params:
try:
max_results = int(query_params["_max_results"])
except (ValueError, TypeError):
pass
# Transform results to SearchResult objects
results = []
# DuckDuckGo can return results in different fields
# Priority: Abstract > Answer > RelatedTopics
# Check if there's an Abstract with URL
if response_json.get("AbstractURL") and response_json.get("AbstractText"):
abstract_result = SearchResult(
title=response_json.get("Heading", ""),
url=response_json.get("AbstractURL", ""),
snippet=response_json.get("AbstractText", ""),
date=None,
last_updated=None,
)
results.append(abstract_result)
# Process RelatedTopics
related_topics = response_json.get("RelatedTopics", [])
for topic in related_topics:
# Stop if we've reached max_results
if max_results is not None and len(results) >= max_results:
break
if isinstance(topic, dict):
# Check if it's a direct result
if "FirstURL" in topic and "Text" in topic:
text = topic.get("Text", "")
url = topic.get("FirstURL", "")
# Try to split title and snippet
if " - " in text:
parts = text.split(" - ", 1)
title = parts[0]
snippet = parts[1] if len(parts) > 1 else text
else:
title = text[:50] + "..." if len(text) > 50 else text
snippet = text
search_result = SearchResult(
title=title,
url=url,
snippet=snippet,
date=None,
last_updated=None,
)
results.append(search_result)
# Check if it contains nested topics
elif "Topics" in topic:
nested_topics = topic.get("Topics", [])
for nested_topic in nested_topics:
# Stop if we've reached max_results
if max_results is not None and len(results) >= max_results:
break
if "FirstURL" in nested_topic and "Text" in nested_topic:
text = nested_topic.get("Text", "")
url = nested_topic.get("FirstURL", "")
# Try to split title and snippet
if " - " in text:
parts = text.split(" - ", 1)
title = parts[0]
snippet = parts[1] if len(parts) > 1 else text
else:
title = text[:50] + "..." if len(text) > 50 else text
snippet = text
search_result = SearchResult(
title=title,
url=url,
snippet=snippet,
date=None,
last_updated=None,
)
results.append(search_result)
return SearchResponse(
results=results,
object="search",
)

View file

@ -770,14 +770,36 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
class OpenAIChatCompletionStreamingHandler(BaseModelResponseIterator):
def _map_reasoning_to_reasoning_content(self, choices: list) -> list:
"""
Map 'reasoning' field to 'reasoning_content' field in delta.
Some OpenAI-compatible providers (e.g., GLM-5, hosted_vllm) return
delta.reasoning, but LiteLLM expects delta.reasoning_content.
Args:
choices: List of choice objects from the streaming chunk
Returns:
List of choices with reasoning field mapped to reasoning_content
"""
for choice in choices:
delta = choice.get("delta", {})
if "reasoning" in delta:
delta["reasoning_content"] = delta.pop("reasoning")
return choices
def chunk_parser(self, chunk: dict) -> ModelResponseStream:
try:
choices = chunk.get("choices", [])
choices = self._map_reasoning_to_reasoning_content(choices)
kwargs = {
"id": chunk["id"],
"object": "chat.completion.chunk",
"created": chunk.get("created"),
"model": chunk.get("model"),
"choices": chunk.get("choices", []),
"choices": choices,
}
if "usage" in chunk and chunk["usage"] is not None:
kwargs["usage"] = chunk["usage"]

View file

@ -1072,7 +1072,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
elif param == "modalities" and isinstance(value, list):
response_modalities = self.map_response_modalities(value)
optional_params["responseModalities"] = response_modalities
elif param == "web_search_options" and value and isinstance(value, dict):
elif param == "web_search_options" and isinstance(value, dict):
_tools = self._map_web_search_options(value)
optional_params = self._add_tools_to_optional_params(
optional_params, [_tools]

View file

View file

View file

View file

View file

@ -0,0 +1,204 @@
"""
Transformation logic for IBM watsonx.ai's /ml/v1/text/rerank endpoint.
Docs - https://cloud.ibm.com/apidocs/watsonx-ai#text-rerank
"""
import uuid
from typing import Any, Dict, List, Optional, Union, cast
import httpx
from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj
from litellm.llms.base_llm.rerank.transformation import BaseRerankConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.watsonx import (
WatsonXAIEndpoint,
)
from litellm.types.rerank import (
RerankResponse,
RerankResponseMeta,
RerankTokens,
)
from ..common_utils import IBMWatsonXMixin, _generate_watsonx_token, _get_api_params
class IBMWatsonXRerankConfig(IBMWatsonXMixin, BaseRerankConfig):
"""
IBM watsonx.ai Rerank API configuration
"""
def get_complete_url(
self,
api_base: Optional[str],
model: str,
optional_params: Optional[dict] = None,
) -> str:
base_url = self._get_base_url(api_base=api_base)
endpoint = WatsonXAIEndpoint.RERANK.value
url = base_url.rstrip("/") + endpoint
params = optional_params or {}
complete_url = self._add_api_version_to_url(url=url, api_version=(params.get("api_version", None)))
return complete_url
def get_supported_cohere_rerank_params(self, model: str) -> list:
return [
"query",
"documents",
"top_n",
"return_documents",
"max_tokens_per_doc",
]
def validate_environment( # type: ignore[override]
self,
headers: dict,
model: str,
api_key: Optional[str] = None,
optional_params: Optional[dict] = None,
) -> Dict:
optional_params = optional_params or {}
default_headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
if "Authorization" in headers:
return {**default_headers, **headers}
token = cast(
Optional[str],
optional_params.pop("token", None) or get_secret_str("WATSONX_TOKEN"),
)
zen_api_key = cast(
Optional[str],
optional_params.pop("zen_api_key", None) or get_secret_str("WATSONX_ZENAPIKEY"),
)
if token:
headers["Authorization"] = f"Bearer {token}"
elif zen_api_key:
headers["Authorization"] = f"ZenApiKey {zen_api_key}"
else:
token = _generate_watsonx_token(api_key=api_key, token=token)
# build auth headers
headers["Authorization"] = f"Bearer {token}"
return {**default_headers, **headers}
def map_cohere_rerank_params(
self,
non_default_params: Optional[dict],
model: str,
drop_params: bool,
query: str,
documents: List[Union[str, Dict[str, Any]]],
custom_llm_provider: Optional[str] = None,
top_n: Optional[int] = None,
rank_fields: Optional[List[str]] = None,
return_documents: Optional[bool] = True,
max_chunks_per_doc: Optional[int] = None,
max_tokens_per_doc: Optional[int] = None,
) -> Dict:
"""
Map Cohere rerank params to IBM watsonx.ai rerank params
"""
optional_rerank_params = {}
if non_default_params is not None:
for k, v in non_default_params.items():
if k == "query" and v is not None:
optional_rerank_params["query"] = v
elif k == "documents" and v is not None:
optional_rerank_params["inputs"] = [
{"text": el} if isinstance(el, str) else el for el in v
]
elif k == "top_n" and v is not None:
optional_rerank_params.setdefault("parameters", {}).setdefault("return_options", {})["top_n"] = v
elif k == "return_documents" and v is not None and isinstance(v, bool):
optional_rerank_params.setdefault("parameters", {}).setdefault("return_options", {})["inputs"] = v
elif k == "max_tokens_per_doc" and v is not None:
optional_rerank_params.setdefault("parameters", {})["truncate_input_tokens"] = v
# IBM watsonx.ai require one of below parameters
elif k == "project_id" and v is not None:
optional_rerank_params["project_id"] = v
elif k == "space_id" and v is not None:
optional_rerank_params["space_id"] = v
return dict(optional_rerank_params)
def transform_rerank_request(
self,
model: str,
optional_rerank_params: Dict,
headers: dict,
) -> dict:
"""
Transform request to IBM watsonx.ai rerank format
"""
watsonx_api_params = _get_api_params(params=optional_rerank_params, model=model)
watsonx_auth_payload = self._prepare_payload(
model=model,
api_params=watsonx_api_params,
)
return optional_rerank_params | watsonx_auth_payload
def transform_rerank_response(
self,
model: str,
raw_response: httpx.Response,
model_response: RerankResponse,
logging_obj: LiteLLMLoggingObj,
api_key: Optional[str] = None,
request_data: dict = {},
optional_params: dict = {},
litellm_params: dict = {},
) -> RerankResponse:
"""
Transform IBM watsonx.ai rerank response to LiteLLM RerankResponse format
"""
try:
raw_response_json = raw_response.json()
except Exception as e:
raise self.get_error_class(
error_message=f"Failed to parse response: {str(e)}",
status_code=raw_response.status_code,
headers=raw_response.headers,
)
_results: Optional[List[dict]] = raw_response_json.get("results")
if _results is None:
raise ValueError(f"No results found in the response={raw_response_json}")
transformed_results = []
for result in _results:
transformed_result: Dict[str, Any] = {
"index": result["index"],
"relevance_score": result["score"],
}
if "input" in result:
if isinstance(result["input"], str):
transformed_result["document"] = {"text": result["input"]}
else:
transformed_result["document"] = result["input"]
transformed_results.append(transformed_result)
response_id = raw_response_json.get("id") or raw_response_json.get("model_id") or str(uuid.uuid4())
# Extract usage information
_tokens = RerankTokens(
input_tokens=raw_response_json.get("input_token_count", 0),
)
rerank_meta = RerankResponseMeta(tokens=_tokens)
return RerankResponse(
id=response_id,
results=transformed_results, # type: ignore
meta=rerank_meta,
)

View file

@ -8294,6 +8294,37 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
},
"us/claude-sonnet-4-6": {
"cache_creation_input_token_cost": 4.125e-06,
"cache_creation_input_token_cost_above_200k_tokens": 8.25e-06,
"cache_read_input_token_cost": 3.3e-07,
"cache_read_input_token_cost_above_200k_tokens": 6.6e-07,
"input_cost_per_token": 3.3e-06,
"input_cost_per_token_above_200k_tokens": 6.6e-06,
"litellm_provider": "anthropic",
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 1.65e-05,
"output_cost_per_token_above_200k_tokens": 2.475e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"inference_geo": "us"
},
"claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 3.75e-06,
"cache_read_input_token_cost": 3e-07,
@ -22465,6 +22496,20 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
"mistral/devstral-small-latest": {
"input_cost_per_token": 1e-07,
"litellm_provider": "mistral",
"max_input_tokens": 256000,
"max_output_tokens": 256000,
"max_tokens": 256000,
"mode": "chat",
"output_cost_per_token": 3e-07,
"source": "https://docs.mistral.ai/models/devstral-small-2-25-12",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"mistral/labs-devstral-small-2512": {
"input_cost_per_token": 1e-07,
"litellm_provider": "mistral",
@ -22479,6 +22524,34 @@
"supports_response_schema": true,
"supports_tool_choice": true
},
"mistral/devstral-latest": {
"input_cost_per_token": 4e-07,
"litellm_provider": "mistral",
"max_input_tokens": 256000,
"max_output_tokens": 256000,
"max_tokens": 256000,
"mode": "chat",
"output_cost_per_token": 2e-06,
"source": "https://mistral.ai/news/devstral-2-vibe-cli",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"mistral/devstral-medium-latest": {
"input_cost_per_token": 4e-07,
"litellm_provider": "mistral",
"max_input_tokens": 256000,
"max_output_tokens": 256000,
"max_tokens": 256000,
"mode": "chat",
"output_cost_per_token": 2e-06,
"source": "https://mistral.ai/news/devstral-2-vibe-cli",
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"mistral/devstral-2512": {
"input_cost_per_token": 4e-07,
"litellm_provider": "mistral",
@ -37270,5 +37343,13 @@
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
}
},
"duckduckgo/search": {
"litellm_provider": "duckduckgo",
"mode": "search",
"input_cost_per_query": 0.0,
"metadata": {
"notes": "DuckDuckGo Instant Answer API is free and does not require an API key."
}
}
}

View file

@ -223,12 +223,14 @@ def get_known_models_from_wildcard(
except ValueError: # safely fail
return []
if litellm_params is None: # need litellm params to extract litellm model name
return []
try:
provider = litellm_params.model.split("/", 1)[0]
except ValueError:
# Use provider from litellm_params when available, otherwise from wildcard prefix
# (e.g., "openai" from "openai/*" - needed for BYOK where wildcard isn't in router)
if litellm_params is not None:
try:
provider = litellm_params.model.split("/", 1)[0]
except ValueError:
provider = wildcard_provider_prefix
else:
provider = wildcard_provider_prefix
# get all known provider models
@ -282,7 +284,7 @@ def _get_wildcard_models(
## get litellm params from model
if llm_router is not None:
model_list = llm_router.get_model_list(model_name=model)
if model_list is not None:
if model_list:
for router_model in model_list:
wildcard_models = get_known_models_from_wildcard(
wildcard_model=model,
@ -291,11 +293,22 @@ def _get_wildcard_models(
),
)
all_wildcard_models.extend(wildcard_models)
else:
# Router has no deployment for this wildcard (e.g., BYOK team models)
# Fall back to expanding from known provider models
wildcard_models = get_known_models_from_wildcard(
wildcard_model=model, litellm_params=None
)
if wildcard_models:
models_to_remove.add(model)
all_wildcard_models.extend(wildcard_models)
else:
# get all known provider models
wildcard_models = get_known_models_from_wildcard(wildcard_model=model)
wildcard_models = get_known_models_from_wildcard(
wildcard_model=model, litellm_params=None
)
if wildcard_models is not None:
if wildcard_models:
models_to_remove.add(model)
all_wildcard_models.extend(wildcard_models)

View file

@ -18,6 +18,9 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"
additional_provider_specific_params=getattr(
litellm_params, "additional_provider_specific_params", {}
),
unreachable_fallback=getattr(
litellm_params, "unreachable_fallback", "fail_closed"
),
guardrail_name=guardrail.get("guardrail_name", ""),
event_hook=litellm_params.mode,
default_on=litellm_params.default_on,

View file

@ -14,6 +14,7 @@ litellm_settings:
mode: pre_call # Options: pre_call, post_call, during_call, [pre_call, post_call]
api_key: os.environ/GENERIC_GUARDRAIL_API_KEY # Optional if using Bearer auth
api_base: http://localhost:8080 # Required. Endpoint /beta/litellm_basic_guardrail_api is automatically appended
unreachable_fallback: fail_closed # Options: fail_closed (default, raise), fail_open (proceed if endpoint unreachable or upstream returns 502/503/504)
default_on: false # Set to true to apply to all requests by default
additional_provider_specific_params:
# Any additional parameters your guardrail API needs

View file

@ -9,9 +9,11 @@ import fnmatch
import os
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional
import httpx
from litellm._logging import verbose_proxy_logger
from litellm._version import version as litellm_version
from litellm.exceptions import GuardrailRaisedException
from litellm.exceptions import GuardrailRaisedException, Timeout
from litellm.integrations.custom_guardrail import (
CustomGuardrail,
log_guardrail_information,
@ -34,17 +36,19 @@ if TYPE_CHECKING:
GUARDRAIL_NAME = "generic_guardrail_api"
# Headers whose values are forwarded as-is (case-insensitive). Glob patterns supported (e.g. x-stainless-*, x-litellm*).
_HEADER_VALUE_ALLOWLIST = frozenset({
"host",
"accept-encoding",
"connection",
"accept",
"content-type",
"user-agent",
"x-stainless-*",
"x-litellm-*",
"content-length",
})
_HEADER_VALUE_ALLOWLIST = frozenset(
{
"host",
"accept-encoding",
"connection",
"accept",
"content-type",
"user-agent",
"x-stainless-*",
"x-litellm-*",
"content-length",
}
)
# Placeholder for headers that exist but are not on the allowlist (we don't expose their value).
_HEADER_PRESENT_PLACEHOLDER = "[present]"
@ -166,6 +170,7 @@ class GenericGuardrailAPI(CustomGuardrail):
api_base: Optional[str] = None,
api_key: Optional[str] = None,
additional_provider_specific_params: Optional[Dict[str, Any]] = None,
unreachable_fallback: Literal["fail_closed", "fail_open"] = "fail_closed",
**kwargs,
):
self.async_handler = get_async_httpx_client(
@ -196,6 +201,10 @@ class GenericGuardrailAPI(CustomGuardrail):
additional_provider_specific_params or {}
)
self.unreachable_fallback: Literal["fail_closed", "fail_open"] = (
unreachable_fallback
)
# Set supported event hooks
if "supported_event_hooks" not in kwargs:
kwargs["supported_event_hooks"] = [
@ -259,6 +268,54 @@ class GenericGuardrailAPI(CustomGuardrail):
return result_metadata
def _fail_open_passthrough(
self,
*,
inputs: GenericGuardrailAPIInputs,
input_type: Literal["request", "response"],
logging_obj: Optional["LiteLLMLoggingObj"],
error: Exception,
http_status_code: Optional[int] = None,
) -> GenericGuardrailAPIInputs:
status_suffix = f" http_status_code={http_status_code}" if http_status_code else ""
verbose_proxy_logger.critical(
"Generic Guardrail API unreachable (fail-open). Proceeding without guardrail.%s "
"guardrail_name=%s api_base=%s input_type=%s litellm_call_id=%s litellm_trace_id=%s",
status_suffix,
getattr(self, "guardrail_name", None),
getattr(self, "api_base", None),
input_type,
getattr(logging_obj, "litellm_call_id", None) if logging_obj else None,
getattr(logging_obj, "litellm_trace_id", None) if logging_obj else None,
exc_info=error,
)
# Keep flow going - treat as action=NONE (no modifications)
return_inputs: GenericGuardrailAPIInputs = {}
return_inputs.update(inputs)
return return_inputs
def _build_guardrail_return_inputs(
self,
*,
texts: list,
images: Any,
tools: Any,
guardrail_response: GenericGuardrailAPIResponse,
) -> GenericGuardrailAPIInputs:
# Action is NONE or no modifications needed
return_inputs = GenericGuardrailAPIInputs(texts=texts)
if guardrail_response.texts:
return_inputs["texts"] = guardrail_response.texts
if guardrail_response.images:
return_inputs["images"] = guardrail_response.images
elif images:
return_inputs["images"] = images
if guardrail_response.tools:
return_inputs["tools"] = guardrail_response.tools
elif tools:
return_inputs["tools"] = tools
return return_inputs
@log_guardrail_information
async def apply_guardrail(
self,
@ -313,7 +370,9 @@ class GenericGuardrailAPI(CustomGuardrail):
# Extract user API key metadata
user_metadata = self._extract_user_api_key_metadata(request_data)
inbound_headers = _extract_inbound_headers(request_data=request_data, logging_obj=logging_obj)
inbound_headers = _extract_inbound_headers(
request_data=request_data, logging_obj=logging_obj
)
# Create request payload
guardrail_request = GenericGuardrailAPIRequest(
@ -370,23 +429,64 @@ class GenericGuardrailAPI(CustomGuardrail):
should_wrap_with_default_message=False,
)
# Action is NONE or no modifications needed
return_inputs = GenericGuardrailAPIInputs(texts=texts)
if guardrail_response.texts:
return_inputs["texts"] = guardrail_response.texts
if guardrail_response.images:
return_inputs["images"] = guardrail_response.images
elif images:
return_inputs["images"] = images
if guardrail_response.tools:
return_inputs["tools"] = guardrail_response.tools
elif tools:
return_inputs["tools"] = tools
return return_inputs
return self._build_guardrail_return_inputs(
texts=texts,
images=images,
tools=tools,
guardrail_response=guardrail_response,
)
except GuardrailRaisedException:
# Re-raise guardrail exceptions as-is
raise
except Timeout as e:
# AsyncHTTPHandler wraps httpx.TimeoutException into litellm.Timeout
if self.unreachable_fallback == "fail_open":
return self._fail_open_passthrough(
inputs=inputs,
input_type=input_type,
logging_obj=logging_obj,
error=e,
)
verbose_proxy_logger.error(
"Generic Guardrail API: failed to make request: %s", str(e)
)
raise Exception(f"Generic Guardrail API failed: {str(e)}")
except httpx.HTTPStatusError as e:
# Common reverse-proxy/LB failures can present as HTTP errors even when the backend is unreachable.
status_code = getattr(getattr(e, "response", None), "status_code", None)
if self.unreachable_fallback == "fail_open" and status_code in (
502,
503,
504,
):
return self._fail_open_passthrough(
inputs=inputs,
input_type=input_type,
logging_obj=logging_obj,
error=e,
http_status_code=status_code,
)
verbose_proxy_logger.error(
"Generic Guardrail API: failed to make request: %s", str(e)
)
raise Exception(f"Generic Guardrail API failed: {str(e)}")
except httpx.RequestError as e:
# Guardrail endpoint is unreachable (DNS/connect/timeout/etc)
if self.unreachable_fallback == "fail_open":
return self._fail_open_passthrough(
inputs=inputs,
input_type=input_type,
logging_obj=logging_obj,
error=e,
)
verbose_proxy_logger.error(
"Generic Guardrail API: failed to make request: %s", str(e)
)
raise Exception(f"Generic Guardrail API failed: {str(e)}")
except Exception as e:
verbose_proxy_logger.error(
"Generic Guardrail API: failed to make request: %s", str(e)

View file

@ -31,11 +31,15 @@ from litellm import Router
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.utils import GuardrailTracingDetail, ModelResponseStream
from litellm.types.utils import (
GenericGuardrailAPIInputs,
GuardrailStatus,
GuardrailTracingDetail,
ModelResponseStream,
)
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.utils import GenericGuardrailAPIInputs, GuardrailStatus
from litellm.types.guardrails import (
BlockedWord,
@ -1546,8 +1550,6 @@ class ContentFilterGuardrail(CustomGuardrail):
Raises:
HTTPException: If sensitive content is detected and action is BLOCK
"""
from litellm.types.utils import GuardrailStatus
start_time = datetime.now()
detections: List[ContentFilterDetection] = []
masked_entity_count: Dict[str, int] = {}
@ -1693,4 +1695,4 @@ class ContentFilterGuardrail(CustomGuardrail):
LitellmContentFilterGuardrailConfigModel,
)
return LitellmContentFilterGuardrailConfigModel
return LitellmContentFilterGuardrailConfigModel

View file

@ -239,6 +239,8 @@ def clean_headers(
"""
Removes litellm api key from headers
"""
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
clean_headers = {}
litellm_key_lower = (
litellm_key_header_name.lower() if litellm_key_header_name is not None else None
@ -246,8 +248,13 @@ def clean_headers(
for header, value in headers.items():
header_lower = header.lower()
# Preserve Authorization header if it contains Anthropic OAuth token (sk-ant-oat*)
# This allows OAuth tokens to be forwarded to Anthropic-compatible providers
# via add_provider_specific_headers_to_request()
if header_lower == "authorization" and is_anthropic_oauth_key(value):
clean_headers[header] = value
# Check if header should be excluded: either in special headers cache or matches custom litellm key
if header_lower not in _SPECIAL_HEADERS_CACHE and (
elif header_lower not in _SPECIAL_HEADERS_CACHE and (
litellm_key_lower is None or header_lower != litellm_key_lower
):
clean_headers[header] = value
@ -1717,6 +1724,8 @@ def add_provider_specific_headers_to_request(
data: dict,
headers: dict,
):
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
anthropic_headers = {}
# boolean to indicate if a header was added
added_header = False
@ -1726,6 +1735,14 @@ def add_provider_specific_headers_to_request(
anthropic_headers[header] = header_value
added_header = True
# Check for Authorization header with Anthropic OAuth token (sk-ant-oat*)
# This needs to be handled via provider-specific headers to ensure it only
# goes to Anthropic-compatible providers, not all providers in the router
for header, value in headers.items():
if header.lower() == "authorization" and is_anthropic_oauth_key(value):
anthropic_headers[header] = value
added_header = True
break
if added_header is True:
# Anthropic headers work across multiple providers
# Store as comma-separated list so retrieval can match any of them

View file

@ -20,6 +20,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Literal, Optional, Tuple, cast
import fastapi
import prisma
import yaml
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
@ -74,7 +75,6 @@ from litellm.proxy.utils import (
_hash_token_if_needed,
handle_exception_on_proxy,
is_valid_api_key,
jsonify_object,
)
from litellm.router import Router
from litellm.secret_managers.main import get_secret
@ -3052,7 +3052,7 @@ async def delete_key_aliases(
)
async def _rotate_master_key(
async def _rotate_master_key( # noqa: PLR0915
prisma_client: PrismaClient,
user_api_key_dict: UserAPIKeyAuth,
current_master_key: str,
@ -3095,13 +3095,17 @@ async def _rotate_master_key(
should_create_model_in_db=False,
)
if new_model:
new_models.append(jsonify_object(new_model.model_dump()))
_dumped = new_model.model_dump(exclude_none=True)
_dumped["litellm_params"] = prisma.Json(_dumped["litellm_params"]) # type: ignore[attr-defined]
_dumped["model_info"] = prisma.Json(_dumped["model_info"]) # type: ignore[attr-defined]
new_models.append(_dumped)
verbose_proxy_logger.debug("Resetting proxy model table")
await prisma_client.db.litellm_proxymodeltable.delete_many()
verbose_proxy_logger.debug("Creating %s models", len(new_models))
await prisma_client.db.litellm_proxymodeltable.create_many(
data=new_models,
)
async with prisma_client.db.tx() as tx:
await tx.litellm_proxymodeltable.delete_many()
verbose_proxy_logger.debug("Creating %s models", len(new_models))
await tx.litellm_proxymodeltable.create_many(
data=new_models,
)
# 3. process config table
try:
config = await prisma_client.db.litellm_config.find_many()
@ -3127,15 +3131,20 @@ async def _rotate_master_key(
if encrypted_env_vars:
await prisma_client.db.litellm_config.update(
where={"param_name": "environment_variables"},
data={"param_value": jsonify_object(encrypted_env_vars)},
data={"param_value": prisma.Json(encrypted_env_vars)}, # type: ignore[attr-defined]
)
# 4. process MCP server table
await rotate_mcp_server_credentials_master_key(
prisma_client=prisma_client,
touched_by=user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
new_master_key=new_master_key,
)
try:
await rotate_mcp_server_credentials_master_key(
prisma_client=prisma_client,
touched_by=user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
new_master_key=new_master_key,
)
except Exception as e:
verbose_proxy_logger.warning(
"Failed to rotate MCP server credentials: %s", str(e)
)
# 5. process credentials table
try:
@ -3153,13 +3162,19 @@ async def _rotate_master_key(
updated_patch=decrypted_cred,
new_encryption_key=new_master_key,
)
credential_object_jsonified = jsonify_object(
encrypted_cred.model_dump()
)
_cred_data = encrypted_cred.model_dump(exclude_none=True)
if "credential_values" in _cred_data:
_cred_data["credential_values"] = prisma.Json( # type: ignore[attr-defined]
_cred_data["credential_values"]
)
if "credential_info" in _cred_data:
_cred_data["credential_info"] = prisma.Json( # type: ignore[attr-defined]
_cred_data["credential_info"]
)
await prisma_client.db.litellm_credentialstable.update(
where={"credential_name": cred.credential_name},
data={
**credential_object_jsonified,
**_cred_data,
"updated_by": user_api_key_dict.user_id,
},
)

View file

@ -456,18 +456,26 @@ class ProxyLogging:
def _init_litellm_callbacks(self, llm_router: Optional[Router] = None):
self._add_proxy_hooks(llm_router)
litellm.logging_callback_manager.add_litellm_callback(self.service_logging_obj) # type: ignore
for callback in litellm.callbacks:
# Track string callbacks and their initialized instances so we can
# replace them in-place, preventing duplicates (string + instance) in
# litellm.callbacks which caused double-counting of metrics.
string_callbacks_to_replace: Dict[int, CustomLogger] = {}
for idx, callback in enumerate(litellm.callbacks):
if isinstance(callback, str):
callback = litellm.litellm_core_utils.litellm_logging._init_custom_logger_compatible_class( # type: ignore
initialized_callback = litellm.litellm_core_utils.litellm_logging._init_custom_logger_compatible_class(
cast(_custom_logger_compatible_callbacks_literal, callback),
internal_usage_cache=self.internal_usage_cache.dual_cache,
llm_router=llm_router,
)
if callback is None:
continue
if initialized_callback is not None:
string_callbacks_to_replace[idx] = initialized_callback
litellm.logging_callback_manager.add_litellm_callback(callback)
# Replace string entries in litellm.callbacks with initialized instances
for idx, initialized_callback in string_callbacks_to_replace.items():
litellm.callbacks[idx] = initialized_callback
async def update_request_status(
self, litellm_call_id: str, status: Literal["success", "fail"]

View file

@ -10,6 +10,7 @@ from litellm.llms.base_llm.rerank.transformation import BaseRerankConfig
from litellm.llms.bedrock.rerank.handler import BedrockRerankHandler
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.llms.together_ai.rerank.handler import TogetherAIRerank
from litellm.llms.watsonx.common_utils import IBMWatsonXMixin
from litellm.rerank_api.rerank_utils import get_optional_rerank_params
from litellm.secret_managers.main import get_secret, get_secret_str
from litellm.types.rerank import RerankResponse
@ -29,7 +30,7 @@ async def arerank(
model: str,
query: str,
documents: List[Union[str, Dict[str, Any]]],
custom_llm_provider: Optional[Literal["cohere", "together_ai", "deepinfra", "fireworks_ai", "voyage"]] = None,
custom_llm_provider: Optional[Literal["cohere", "together_ai", "deepinfra", "fireworks_ai", "voyage", "watsonx"]] = None,
top_n: Optional[int] = None,
rank_fields: Optional[List[str]] = None,
return_documents: Optional[bool] = None,
@ -85,6 +86,7 @@ def rerank( # noqa: PLR0915
"deepinfra",
"fireworks_ai",
"voyage",
"watsonx",
]
] = None,
top_n: Optional[int] = None,
@ -478,6 +480,31 @@ def rerank( # noqa: PLR0915
or get_secret_str("VOYAGE_API_BASE")
)
response = base_llm_http_handler.rerank(
model=model,
custom_llm_provider=_custom_llm_provider,
provider_config=rerank_provider_config,
optional_rerank_params=optional_rerank_params,
logging_obj=litellm_logging_obj,
timeout=optional_params.timeout,
api_key=api_key,
api_base=api_base,
_is_async=_is_async,
headers=headers or litellm.headers or {},
client=client,
model_response=model_response,
)
elif _custom_llm_provider == litellm.LlmProviders.WATSONX:
credentials = IBMWatsonXMixin.get_watsonx_credentials(
optional_params=dict(optional_params), api_key=dynamic_api_key, api_base=dynamic_api_base
)
api_key = credentials["api_key"]
api_base = credentials["api_base"]
if credentials.get("token") is not None:
optional_rerank_params["token"] = credentials["token"]
response = base_llm_http_handler.rerank(
model=model,
custom_llm_provider=_custom_llm_provider,

View file

@ -600,8 +600,12 @@ def responses(
# Update input and tools with provider-specific file IDs if managed files are used
#########################################################
model_file_id_mapping = kwargs.get("model_file_id_mapping")
model_info_id = kwargs.get("model_info", {}).get("id") if isinstance(kwargs.get("model_info"), dict) else None
model_info_id = (
kwargs.get("model_info", {}).get("id")
if isinstance(kwargs.get("model_info"), dict)
else None
)
input = cast(
Union[str, ResponseInputParam],
update_responses_input_with_model_file_ids(
@ -611,7 +615,7 @@ def responses(
),
)
local_vars["input"] = input
# Update tools with provider-specific file IDs if needed
if tools:
tools = cast(
@ -696,7 +700,10 @@ def responses(
)
)
# Pre Call logging
# Pre Call logging - preserve metadata for custom callbacks
# When called from completion bridge (codex models), metadata is in litellm_metadata
metadata_for_callbacks = metadata or kwargs.get("litellm_metadata") or {}
litellm_logging_obj.update_environment_variables(
model=model,
user=user,
@ -705,7 +712,7 @@ def responses(
**responses_api_request_params,
"aresponses": _is_async,
"litellm_call_id": litellm_call_id,
"metadata": metadata,
"metadata": metadata_for_callbacks,
},
custom_llm_provider=custom_llm_provider,
)

View file

@ -113,12 +113,12 @@ from litellm.router_utils.handle_error import (
from litellm.router_utils.pre_call_checks.model_rate_limit_check import (
ModelRateLimitingCheck,
)
from litellm.router_utils.pre_call_checks.deployment_affinity_check import (
DeploymentAffinityCheck,
)
from litellm.router_utils.pre_call_checks.prompt_caching_deployment_check import (
PromptCachingDeploymentCheck,
)
from litellm.router_utils.pre_call_checks.responses_api_deployment_check import (
ResponsesApiDeploymentCheck,
)
from litellm.router_utils.router_callbacks.track_deployment_metrics import (
increment_deployment_failures_for_current_minute,
increment_deployment_successes_for_current_minute,
@ -293,6 +293,7 @@ class Router:
router_general_settings: Optional[
RouterGeneralSettings
] = RouterGeneralSettings(),
deployment_affinity_ttl_seconds: int = 3600,
ignore_invalid_deployments: bool = False,
) -> None:
"""
@ -326,6 +327,7 @@ class Router:
routing_strategy_args (dict): Additional args for latency-based routing. Defaults to {}.
alerting_config (AlertingConfig): Slack alerting configuration. Defaults to None.
provider_budget_config (ProviderBudgetConfig): Provider budget configuration. Use this to set llm_provider budget limits. example $100/day to OpenAI, $100/day to Azure, etc. Defaults to None.
deployment_affinity_ttl_seconds (int): TTL for user-key -> deployment affinity mapping. Defaults to 3600.
ignore_invalid_deployments (bool): Ignores invalid deployments, and continues with other deployments. Default is to raise an error.
Returns:
Router: An instance of the litellm.Router class.
@ -604,6 +606,7 @@ class Router:
litellm.failure_callback = [self.deployment_callback_on_failure]
self.routing_strategy_args = routing_strategy_args
self.provider_budget_config = provider_budget_config
self.deployment_affinity_ttl_seconds = deployment_affinity_ttl_seconds
self.router_budget_logger: Optional[RouterBudgetLimiting] = None
if RouterBudgetLimiting.should_init_router_budget_limiter(
model_list=model_list, provider_budget_config=self.provider_budget_config
@ -1184,26 +1187,78 @@ class Router:
def add_optional_pre_call_checks(
self, optional_pre_call_checks: Optional[OptionalPreCallChecks]
):
if optional_pre_call_checks is not None:
for pre_call_check in optional_pre_call_checks:
_callback: Optional[CustomLogger] = None
if pre_call_check == "prompt_caching":
_callback = PromptCachingDeploymentCheck(cache=self.cache)
elif pre_call_check == "router_budget_limiting":
_callback = RouterBudgetLimiting(
dual_cache=self.cache,
provider_budget_config=self.provider_budget_config,
model_list=self.model_list,
)
elif pre_call_check == "responses_api_deployment_check":
_callback = ResponsesApiDeploymentCheck()
elif pre_call_check == "enforce_model_rate_limits":
_callback = ModelRateLimitingCheck(dual_cache=self.cache)
if _callback is not None:
if self.optional_callbacks is None:
self.optional_callbacks = []
self.optional_callbacks.append(_callback)
litellm.logging_callback_manager.add_litellm_callback(_callback)
if optional_pre_call_checks is None:
return
# ---------------------------------------------------------------------
# Unified deployment affinity (session stickiness)
# ---------------------------------------------------------------------
enable_user_key_affinity = "deployment_affinity" in optional_pre_call_checks
enable_responses_api_affinity = (
"responses_api_deployment_check" in optional_pre_call_checks
)
if enable_user_key_affinity or enable_responses_api_affinity:
if self.optional_callbacks is None:
self.optional_callbacks = []
existing_affinity_callback: Optional[DeploymentAffinityCheck] = None
for cb in self.optional_callbacks:
if isinstance(cb, DeploymentAffinityCheck):
existing_affinity_callback = cb
break
if existing_affinity_callback is not None:
existing_affinity_callback.enable_user_key_affinity = (
existing_affinity_callback.enable_user_key_affinity
or enable_user_key_affinity
)
existing_affinity_callback.enable_responses_api_affinity = (
existing_affinity_callback.enable_responses_api_affinity
or enable_responses_api_affinity
)
existing_affinity_callback.ttl_seconds = (
self.deployment_affinity_ttl_seconds
)
else:
affinity_callback = DeploymentAffinityCheck(
cache=self.cache,
ttl_seconds=self.deployment_affinity_ttl_seconds,
enable_user_key_affinity=enable_user_key_affinity,
enable_responses_api_affinity=enable_responses_api_affinity,
)
self.optional_callbacks.append(affinity_callback)
litellm.logging_callback_manager.add_litellm_callback(
affinity_callback
)
# ---------------------------------------------------------------------
# Remaining optional pre-call checks
# ---------------------------------------------------------------------
for pre_call_check in optional_pre_call_checks:
_callback: Optional[CustomLogger] = None
if pre_call_check in (
"deployment_affinity",
"responses_api_deployment_check",
):
continue
if pre_call_check == "prompt_caching":
_callback = PromptCachingDeploymentCheck(cache=self.cache)
elif pre_call_check == "router_budget_limiting":
_callback = RouterBudgetLimiting(
dual_cache=self.cache,
provider_budget_config=self.provider_budget_config,
model_list=self.model_list,
)
elif pre_call_check == "enforce_model_rate_limits":
_callback = ModelRateLimitingCheck(dual_cache=self.cache)
if _callback is None:
continue
if self.optional_callbacks is None:
self.optional_callbacks = []
self.optional_callbacks.append(_callback)
litellm.logging_callback_manager.add_litellm_callback(_callback)
def print_deployment(self, deployment: dict):
"""
@ -7600,9 +7655,16 @@ class Router:
Used by `.get_model_list` to get model list from model alias.
"""
returned_models: List[DeploymentTypedDict] = []
for model_alias, model_value in self.model_group_alias.items():
if model_name is not None and model_alias != model_name:
continue
if model_name is not None:
# Fast path: direct dict lookup avoids scanning all aliases for non-alias model names.
if model_name not in self.model_group_alias:
return returned_models
alias_items = [(model_name, self.model_group_alias[model_name])]
else:
alias_items = list(self.model_group_alias.items())
for model_alias, model_value in alias_items:
if isinstance(model_value, str):
_router_model_name: str = model_value
elif isinstance(model_value, dict):
@ -9099,4 +9161,3 @@ class Router:
litellm._async_failure_callback = []
self.retry_policy = None
self.flush_cache()

View file

@ -335,13 +335,14 @@ class LowestTPMLoggingHandler_v2(BaseRoutingStrategy, CustomLogger):
):
lowest_tpm = float("inf")
potential_deployments = [] # if multiple deployments have the same low value
deployment_lookup = {
deployment.get("model_info", {}).get("id"): deployment
for deployment in healthy_deployments
}
for item, item_tpm in all_deployments.items():
## get the item from model list
_deployment = None
item = item.split(":")[0]
for m in healthy_deployments:
if item == m["model_info"]["id"]:
_deployment = m
_deployment = deployment_lookup.get(item)
if _deployment is None:
continue # skip to next one
elif item_tpm is None:

View file

@ -58,7 +58,7 @@ def filter_team_based_models(
request_team_id = metadata.get("user_api_key_team_id") or litellm_metadata.get(
"user_api_key_team_id"
)
ids_to_remove = []
ids_to_remove = set()
if isinstance(healthy_deployments, dict):
return healthy_deployments
for deployment in healthy_deployments:
@ -67,7 +67,7 @@ def filter_team_based_models(
if model_team_id is None:
continue
if model_team_id != request_team_id:
ids_to_remove.append(deployment.get("model_info", {}).get("id"))
ids_to_remove.add(_model_info.get("id"))
return [
deployment
@ -125,4 +125,3 @@ def filter_web_search_deployments(
if len(healthy_deployments) > 0 and len(final_deployments) == 0:
verbose_logger.warning("No deployments support web search for request")
return final_deployments

View file

@ -0,0 +1,396 @@
"""
Unified deployment affinity (session stickiness) for the Router.
Features (independently enable-able):
1. Responses API continuity: when a `previous_response_id` is provided, route to the
deployment that generated the original response (highest priority).
2. API-key affinity: map an API key hash -> deployment id for a TTL and re-use that
deployment for subsequent requests to the same router deployment model name
(alias-safe, aligns to `model_map_information.model_map_key`).
This is designed to support "implicit prompt caching" scenarios (no explicit cache_control),
where routing to a consistent deployment is still beneficial.
"""
import hashlib
from typing import Any, Dict, List, Optional, cast
from typing_extensions import TypedDict
from litellm._logging import verbose_router_logger
from litellm.caching.dual_cache import DualCache
from litellm.integrations.custom_logger import CustomLogger, Span
from litellm.responses.utils import ResponsesAPIRequestUtils
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import CallTypes
class DeploymentAffinityCacheValue(TypedDict):
model_id: str
class DeploymentAffinityCheck(CustomLogger):
"""
Router deployment affinity callback.
NOTE: This is a Router-only callback intended to be wired through
`Router(optional_pre_call_checks=[...])`.
"""
CACHE_KEY_PREFIX = "deployment_affinity:v1"
def __init__(
self,
cache: DualCache,
ttl_seconds: int,
enable_user_key_affinity: bool,
enable_responses_api_affinity: bool,
):
super().__init__()
self.cache = cache
self.ttl_seconds = ttl_seconds
self.enable_user_key_affinity = enable_user_key_affinity
self.enable_responses_api_affinity = enable_responses_api_affinity
@staticmethod
def _looks_like_sha256_hex(value: str) -> bool:
if len(value) != 64:
return False
try:
int(value, 16)
except ValueError:
return False
return True
@staticmethod
def _hash_user_key(user_key: str) -> str:
"""
Hash user identifiers before storing them in cache keys.
This avoids putting raw API keys / user identifiers into Redis keys (and therefore
into logs/metrics), while keeping the cache key stable and a fixed length.
"""
# If the proxy already provides a stable SHA-256 (e.g. `metadata.user_api_key_hash`),
# keep it as-is to avoid double-hashing and to make correlation/debugging possible.
if DeploymentAffinityCheck._looks_like_sha256_hex(user_key):
return user_key.lower()
return hashlib.sha256(user_key.encode("utf-8")).hexdigest()
@staticmethod
def _get_model_map_key_from_litellm_model_name(litellm_model_name: str) -> Optional[str]:
"""
Best-effort derivation of a stable "model map key" for affinity scoping.
The intent is to align with `standard_logging_payload.model_map_information.model_map_key`,
which is typically the base model identifier (stable across deployments/endpoints).
Notes:
- When the model name is in "provider/model" format, the provider prefix is stripped.
- For Azure, the string after "azure/" is commonly an *Azure deployment name*, which may
differ across instances. If `base_model` is not explicitly set, we skip deriving a
model-map key from the model string to avoid generating unstable keys.
"""
if not litellm_model_name:
return None
if "/" not in litellm_model_name:
return litellm_model_name
provider_prefix, remainder = litellm_model_name.split("/", 1)
if provider_prefix == "azure":
return None
return remainder
@staticmethod
def _get_model_map_key_from_deployment(deployment: dict) -> Optional[str]:
"""
Derive a stable model-map key from a router deployment dict.
Primary source: `deployment.model_name` (Router's canonical group name after
alias resolution). This is stable across provider-specific deployments (e.g.,
Azure/Vertex/Bedrock for the same logical model) and aligns with
`model_map_information.model_map_key` in standard logging.
Prefer `base_model` when available (important for Azure), otherwise fall back to
parsing `litellm_params.model`.
"""
model_name = deployment.get("model_name")
if isinstance(model_name, str) and model_name:
return model_name
model_info = deployment.get("model_info")
if isinstance(model_info, dict):
base_model = model_info.get("base_model")
if isinstance(base_model, str) and base_model:
return base_model
litellm_params = deployment.get("litellm_params")
if isinstance(litellm_params, dict):
base_model = litellm_params.get("base_model")
if isinstance(base_model, str) and base_model:
return base_model
litellm_model_name = litellm_params.get("model")
if isinstance(litellm_model_name, str) and litellm_model_name:
return DeploymentAffinityCheck._get_model_map_key_from_litellm_model_name(
litellm_model_name
)
return None
@staticmethod
def _get_stable_model_map_key_from_deployments(
healthy_deployments: List[dict],
) -> Optional[str]:
"""
Only use model-map key scoping when it is stable across the deployment set.
This prevents accidentally keying on per-deployment identifiers like Azure deployment
names (when `base_model` is not configured).
"""
if not healthy_deployments:
return None
keys: List[str] = []
for deployment in healthy_deployments:
key = DeploymentAffinityCheck._get_model_map_key_from_deployment(deployment)
if key is None:
return None
keys.append(key)
unique_keys = set(keys)
if len(unique_keys) != 1:
return None
return keys[0]
@staticmethod
def _shorten_for_logs(value: str, keep: int = 8) -> str:
if len(value) <= keep:
return value
return f"{value[:keep]}..."
@classmethod
def get_affinity_cache_key(cls, model_group: str, user_key: str) -> str:
hashed_user_key = cls._hash_user_key(user_key=user_key)
return f"{cls.CACHE_KEY_PREFIX}:{model_group}:{hashed_user_key}"
@staticmethod
def _get_user_key_from_metadata_dict(metadata: dict) -> Optional[str]:
# NOTE: affinity is keyed on the *API key hash* provided by the proxy (not the
# OpenAI `user` parameter, which is an end-user identifier).
user_key = metadata.get("user_api_key_hash")
if user_key is None:
return None
return str(user_key)
@staticmethod
def _iter_metadata_dicts(request_kwargs: dict) -> List[dict]:
"""
Return all metadata dicts available on the request.
Depending on the endpoint, Router may populate `metadata` or `litellm_metadata`.
Users may also send one or both, so we check both (rather than using `or`).
"""
metadata_dicts: List[dict] = []
for key in ("litellm_metadata", "metadata"):
md = request_kwargs.get(key)
if isinstance(md, dict):
metadata_dicts.append(md)
return metadata_dicts
@staticmethod
def _get_user_key_from_request_kwargs(request_kwargs: dict) -> Optional[str]:
"""
Extract a stable affinity key from request kwargs.
Source (proxy): `metadata.user_api_key_hash`
Note: the OpenAI `user` parameter is an end-user identifier and is intentionally
not used for deployment affinity.
"""
# Check metadata dicts (Proxy usage)
for metadata in DeploymentAffinityCheck._iter_metadata_dicts(request_kwargs):
user_key = DeploymentAffinityCheck._get_user_key_from_metadata_dict(
metadata=metadata
)
if user_key is not None:
return user_key
return None
@staticmethod
def _find_deployment_by_model_id(healthy_deployments: List[dict], model_id: str) -> Optional[dict]:
for deployment in healthy_deployments:
model_info = deployment.get("model_info")
if not isinstance(model_info, dict):
continue
deployment_model_id = model_info.get("id")
if deployment_model_id is not None and str(deployment_model_id) == str(model_id):
return deployment
return None
async def async_filter_deployments(
self,
model: str,
healthy_deployments: List,
messages: Optional[List[AllMessageValues]],
request_kwargs: Optional[dict] = None,
parent_otel_span: Optional[Span] = None,
) -> List[dict]:
"""
Optionally filter healthy deployments based on:
1. `previous_response_id` (Responses API continuity) [highest priority]
2. cached API-key deployment affinity
"""
request_kwargs = request_kwargs or {}
typed_healthy_deployments = cast(List[dict], healthy_deployments)
# 1) Responses API continuity (high priority)
if self.enable_responses_api_affinity:
previous_response_id = request_kwargs.get("previous_response_id")
if previous_response_id is not None:
responses_model_id = ResponsesAPIRequestUtils.get_model_id_from_response_id(str(previous_response_id))
if responses_model_id is not None:
deployment = self._find_deployment_by_model_id(
healthy_deployments=typed_healthy_deployments,
model_id=responses_model_id,
)
if deployment is not None:
verbose_router_logger.debug(
"DeploymentAffinityCheck: previous_response_id pinning -> deployment=%s",
responses_model_id,
)
return [deployment]
# 2) User key -> deployment affinity
if not self.enable_user_key_affinity:
return typed_healthy_deployments
user_key = self._get_user_key_from_request_kwargs(request_kwargs=request_kwargs)
if user_key is None:
return typed_healthy_deployments
stable_model_map_key = self._get_stable_model_map_key_from_deployments(
healthy_deployments=typed_healthy_deployments
)
if stable_model_map_key is None:
return typed_healthy_deployments
cache_key = self.get_affinity_cache_key(
model_group=stable_model_map_key, user_key=user_key
)
cache_result = await self.cache.async_get_cache(key=cache_key)
model_id: Optional[str] = None
if isinstance(cache_result, dict):
model_id = cast(Optional[str], cache_result.get("model_id"))
elif isinstance(cache_result, str):
# Backwards / safety: allow raw string values.
model_id = cache_result
if not model_id:
return typed_healthy_deployments
deployment = self._find_deployment_by_model_id(
healthy_deployments=typed_healthy_deployments,
model_id=model_id,
)
if deployment is None:
verbose_router_logger.debug(
"DeploymentAffinityCheck: pinned deployment=%s not found in healthy_deployments",
model_id,
)
return typed_healthy_deployments
verbose_router_logger.debug(
"DeploymentAffinityCheck: api-key affinity hit -> deployment=%s user_key=%s",
model_id,
self._shorten_for_logs(user_key),
)
return [deployment]
async def async_pre_call_deployment_hook(
self, kwargs: Dict[str, Any], call_type: Optional[CallTypes]
) -> Optional[dict]:
"""
Persist/update the API-key -> deployment mapping for this request.
Why pre-call?
- LiteLLM runs async success callbacks via a background logging worker for performance.
- We want affinity to be immediately available for subsequent requests.
"""
if not self.enable_user_key_affinity:
return None
user_key = self._get_user_key_from_request_kwargs(request_kwargs=kwargs)
if user_key is None:
return None
metadata_dicts = self._iter_metadata_dicts(kwargs)
model_info = kwargs.get("model_info")
if not isinstance(model_info, dict):
model_info = None
if model_info is None:
for metadata in metadata_dicts:
maybe_model_info = metadata.get("model_info")
if isinstance(maybe_model_info, dict):
model_info = maybe_model_info
break
if model_info is None:
# Router sets `model_info` after selecting a deployment. If it's missing, this is
# likely a non-router call or a call path that doesn't support affinity.
return None
model_id = model_info.get("id")
if not model_id:
verbose_router_logger.warning(
"DeploymentAffinityCheck: model_id missing; skipping affinity cache update."
)
return None
# Scope affinity by the Router deployment model name (alias-safe, consistent across
# heterogeneous providers, and matches standard logging's `model_map_key`).
deployment_model_name: Optional[str] = None
for metadata in metadata_dicts:
maybe_deployment_model_name = metadata.get("deployment_model_name")
if isinstance(maybe_deployment_model_name, str) and maybe_deployment_model_name:
deployment_model_name = maybe_deployment_model_name
break
if not deployment_model_name:
verbose_router_logger.warning(
"DeploymentAffinityCheck: deployment_model_name missing; skipping affinity cache update. model_id=%s",
model_id,
)
return None
try:
cache_key = self.get_affinity_cache_key(
model_group=deployment_model_name, user_key=user_key
)
await self.cache.async_set_cache(
cache_key,
DeploymentAffinityCacheValue(model_id=str(model_id)),
ttl=self.ttl_seconds,
)
verbose_router_logger.debug(
"DeploymentAffinityCheck: set affinity mapping model_map_key=%s deployment=%s ttl=%s user_key=%s",
deployment_model_name,
model_id,
self.ttl_seconds,
self._shorten_for_logs(user_key),
)
except Exception as e:
# Non-blocking: affinity is a best-effort optimization.
verbose_router_logger.debug(
"DeploymentAffinityCheck: failed to set affinity cache. model_map_key=%s error=%s",
deployment_model_name,
e,
)
return None

View file

@ -10,6 +10,7 @@ This is different from the normal behavior of the router, which does not have ro
If previous_response_id is provided, route to the deployment that returned the previous response
"""
import warnings
from typing import List, Optional
from litellm.integrations.custom_logger import CustomLogger, Span
@ -18,6 +19,17 @@ from litellm.types.llms.openai import AllMessageValues
class ResponsesApiDeploymentCheck(CustomLogger):
def __init__(self) -> None:
super().__init__()
warnings.warn(
(
"ResponsesApiDeploymentCheck is deprecated. "
"Use DeploymentAffinityCheck(enable_responses_api_affinity=True) instead."
),
DeprecationWarning,
stacklevel=2,
)
async def async_filter_deployments(
self,
model: str,

View file

@ -652,6 +652,15 @@ class BaseLitellmParams(
description="Additional provider-specific parameters for generic guardrail APIs",
)
unreachable_fallback: Literal["fail_closed", "fail_open"] = Field(
default="fail_closed",
description=(
"Behavior when a guardrail endpoint is unreachable due to network errors. "
"NOTE: This is currently only implemented by guardrail='generic_guardrail_api'. "
"'fail_closed' raises an error (default). 'fail_open' logs a critical error and allows the request to proceed."
),
)
# Custom code guardrail params
custom_code: Optional[str] = Field(
default=None,
@ -693,6 +702,7 @@ class LitellmParams(
"mode",
"default_action",
"on_disallowed_action",
"unreachable_fallback",
mode="before",
check_fields=False,
)

View file

@ -302,6 +302,33 @@ class PerformanceConfigBlock(TypedDict):
latency: Literal["optimized", "throughput"]
class JsonSchemaDefinition(TypedDict, total=False):
"""JSON schema structured output format options for Bedrock Converse API."""
schema: Required[str] # JSON string, not dict
name: str
description: str
class OutputFormatStructure(TypedDict, total=False):
"""The structure that the model's output must adhere to (union type)."""
jsonSchema: Required[JsonSchemaDefinition]
class OutputFormat(TypedDict):
"""Structured output parameters to control the model's response."""
type: Literal["json_schema"]
structure: OutputFormatStructure
class OutputConfigBlock(TypedDict, total=False):
"""Output configuration for a model response in Converse/ConverseStream."""
textFormat: OutputFormat
class CommonRequestObject(
TypedDict, total=False
): # common request object across sync + async flows
@ -314,6 +341,7 @@ class CommonRequestObject(
performanceConfig: Optional[PerformanceConfigBlock]
serviceTier: Optional[ServiceTierBlock]
requestMetadata: Optional[Dict[str, str]]
outputConfig: Optional[OutputConfigBlock]
class RequestObject(CommonRequestObject, total=False):

View file

@ -63,6 +63,7 @@ class WatsonXAIEndpoint(str, Enum):
EMBEDDINGS = "/ml/v1/text/embeddings"
PROMPTS = "/ml/v1/prompts"
AVAILABLE_MODELS = "/ml/v1/foundation_model_specs"
RERANK = "/ml/v1/text/rerank"
class WatsonXModelPattern(str, Enum):

View file

@ -31,6 +31,14 @@ class GenericGuardrailAPIOptionalParams(BaseModel):
description="Additional provider-specific parameters to send with the guardrail request",
)
unreachable_fallback: Optional[Literal["fail_closed", "fail_open"]] = Field(
default="fail_closed",
description=(
"Behavior when the guardrail endpoint is unreachable due to network errors. "
"'fail_closed' raises an error (default). 'fail_open' logs a critical error and allows the request to proceed."
),
)
class GenericGuardrailAPIConfigModel(
GuardrailConfigModel[GenericGuardrailAPIOptionalParams],
@ -52,9 +60,9 @@ class GenericGuardrailAPIRequest(BaseModel):
input_type: Literal["request", "response"]
litellm_call_id: Optional[str] = None # the call id of the individual LLM call
litellm_trace_id: Optional[
str
] = None # the trace id of the LLM call - useful if there are multiple LLM calls for the same conversation
litellm_trace_id: Optional[str] = (
None # the trace id of the LLM call - useful if there are multiple LLM calls for the same conversation
)
structured_messages: Optional[List[AllMessageValues]] = None
images: Optional[List[str]] = None
tools: Optional[List[ChatCompletionToolParam]] = None

View file

@ -802,6 +802,7 @@ OptionalPreCallChecks = List[
"prompt_caching",
"router_budget_limiting",
"responses_api_deployment_check",
"deployment_affinity",
"forward_client_headers_by_model_group",
"enforce_model_rate_limits",
]

View file

@ -2532,6 +2532,8 @@ class StandardLoggingMetadata(StandardLoggingUserAPIKeyMetadata):
cold_storage_object_key: Optional[
str
] # S3/GCS object key for cold storage retrieval
team_alias: Optional[str]
team_id: Optional[str]
class StandardLoggingAdditionalHeaders(TypedDict, total=False):
@ -3197,7 +3199,7 @@ class SearchProviders(str, Enum):
FIRECRAWL = "firecrawl"
SEARXNG = "searxng"
LINKUP = "linkup"
DUCKDUCKGO = "duckduckgo"
# Create a set of all search provider values for quick lookup
SearchProvidersSet = {provider.value for provider in SearchProviders}

View file

@ -8151,6 +8151,8 @@ class ProviderConfigManager:
return litellm.FireworksAIRerankConfig()
elif litellm.LlmProviders.VOYAGE == provider:
return litellm.VoyageRerankConfig()
elif litellm.LlmProviders.WATSONX == provider:
return litellm.IBMWatsonXRerankConfig()
return litellm.CohereRerankConfig()
@staticmethod
@ -8776,6 +8778,7 @@ class ProviderConfigManager:
"""
from litellm.llms.brave.search.transformation import BraveSearchConfig
from litellm.llms.dataforseo.search.transformation import DataForSEOSearchConfig
from litellm.llms.duckduckgo.search.transformation import DuckDuckGoSearchConfig
from litellm.llms.exa_ai.search.transformation import ExaAISearchConfig
from litellm.llms.firecrawl.search.transformation import FirecrawlSearchConfig
from litellm.llms.google_pse.search.transformation import GooglePSESearchConfig
@ -8798,6 +8801,7 @@ class ProviderConfigManager:
SearchProviders.FIRECRAWL: FirecrawlSearchConfig,
SearchProviders.SEARXNG: SearXNGSearchConfig,
SearchProviders.LINKUP: LinkupSearchConfig,
SearchProviders.DUCKDUCKGO: DuckDuckGoSearchConfig,
}
config_class = PROVIDER_TO_CONFIG_MAP.get(provider, None)
if config_class is None:

View file

@ -8294,6 +8294,37 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
},
"us/claude-sonnet-4-6": {
"cache_creation_input_token_cost": 4.125e-06,
"cache_creation_input_token_cost_above_200k_tokens": 8.25e-06,
"cache_read_input_token_cost": 3.3e-07,
"cache_read_input_token_cost_above_200k_tokens": 6.6e-07,
"input_cost_per_token": 3.3e-06,
"input_cost_per_token_above_200k_tokens": 6.6e-06,
"litellm_provider": "anthropic",
"max_input_tokens": 200000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 1.65e-05,
"output_cost_per_token_above_200k_tokens": 2.475e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346,
"inference_geo": "us"
},
"claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 3.75e-06,
"cache_read_input_token_cost": 3e-07,
@ -37312,5 +37343,13 @@
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
}
},
"duckduckgo/search": {
"litellm_provider": "duckduckgo",
"mode": "search",
"input_cost_per_query": 0.0,
"metadata": {
"notes": "DuckDuckGo Instant Answer API is free and does not require an API key."
}
}
}

View file

@ -761,6 +761,23 @@
"interactions": true
}
},
"duckduckgo": {
"display_name": "DuckDuckGo (`duckduckgo`)",
"url": "https://docs.litellm.ai/docs/search/duckduckgo",
"endpoints": {
"chat_completions": false,
"messages": false,
"responses": false,
"embeddings": false,
"image_generations": false,
"audio_transcriptions": false,
"audio_speech": false,
"moderations": false,
"batches": false,
"rerank": false,
"search": true
}
},
"elevenlabs": {
"display_name": "ElevenLabs (`elevenlabs`)",
"url": "https://docs.litellm.ai/docs/providers/elevenlabs",

View file

@ -16,6 +16,7 @@ SEARCH_PROVIDERS = [
"firecrawl",
"searxng",
"linkup",
"duckduckgo",
]
ALLOWED_FILES_IN_LLMS_FOLDER = [
@ -73,8 +74,8 @@ def run_lint_check(unique_names):
def main():
llms_dir = "./litellm/llms/" # Update this path if needed
# llms_dir = "../../litellm/llms/" # LOCAL TESTING
# llms_dir = "./litellm/llms/" # Update this path if needed
llms_dir = "litellm/litellm/llms" # LOCAL TESTING
unique_names = get_unique_names_from_llms_dir(llms_dir)
print("Unique names in llms directory:", sorted(list(unique_names)))

View file

@ -792,7 +792,8 @@ async def test_async_post_call_success_hook(prometheus_logger):
"""
Test for the async_post_call_success_hook method
it should increment the litellm_proxy_total_requests_metric
litellm_proxy_total_requests_metric is NOT incremented here to avoid double-counting.
It is incremented in async_log_success_event instead.
"""
# Mock the prometheus metric
prometheus_logger.litellm_proxy_total_requests_metric = MagicMock()
@ -817,23 +818,8 @@ async def test_async_post_call_success_hook(prometheus_logger):
data=data, user_api_key_dict=user_api_key_dict, response=response
)
# Assert total requests metric was incremented with correct labels
prometheus_logger.litellm_proxy_total_requests_metric.labels.assert_called_once_with(
end_user=None,
hashed_api_key="test_key",
api_key_alias="test_alias",
requested_model="gpt-3.5-turbo",
team="test_team",
team_alias="test_team_alias",
user="test_user",
status_code="200",
user_email=None,
route=user_api_key_dict.request_route,
model_id=None,
client_ip=None,
user_agent=None,
)
prometheus_logger.litellm_proxy_total_requests_metric.labels().inc.assert_called_once()
# Assert total requests metric was NOT incremented (moved to async_log_success_event)
prometheus_logger.litellm_proxy_total_requests_metric.labels.assert_not_called()
def test_set_llm_deployment_success_metrics(prometheus_logger):

View file

@ -545,6 +545,9 @@ async def test_request_counter_semantic_validation(mock_prometheus_logger):
CRITICAL TEST: Validates that request counters are incremented by 1, not by token count.
This test specifically catches the bug where litellm_proxy_total_requests_metric
is incorrectly incremented by total_tokens instead of 1.
The metric is now ONLY incremented in async_log_success_event (for both streaming
and non-streaming) to prevent double-counting.
"""
from datetime import datetime, timedelta
from unittest.mock import MagicMock
@ -583,18 +586,18 @@ async def test_request_counter_semantic_validation(mock_prometheus_logger):
},
}
# Call the success event
# Call the success event - should increment for both streaming and non-streaming
await mock_prometheus_logger.async_log_success_event(
kwargs, None, kwargs["start_time"], kwargs["end_time"]
)
# CRITICAL ASSERTION: Request counter should not be incremented
# CRITICAL ASSERTION: Request counter should be incremented by 1
total_requests_metric = mock_prometheus_logger.litellm_proxy_total_requests_metric
assert (
len(total_requests_metric.inc_calls) == 0
), "Request metric should not be incremented"
len(total_requests_metric.inc_calls) == 1
), "Request metric should be incremented once in async_log_success_event"
# Call the post-call logging hook
# Call the post-call logging hook - should NOT increment (to prevent double-counting)
await mock_prometheus_logger.async_post_call_success_hook(
data={},
user_api_key_dict=UserAPIKeyAuth(
@ -607,11 +610,11 @@ async def test_request_counter_semantic_validation(mock_prometheus_logger):
response=MagicMock(),
)
# CRITICAL ASSERTION: Request counter be incremented by 1
# CRITICAL ASSERTION: Request counter should still be 1 (not incremented again)
total_requests_metric = mock_prometheus_logger.litellm_proxy_total_requests_metric
assert (
len(total_requests_metric.inc_calls) == 1
), "Request metric should not be incremented"
), "Request metric should not be incremented again in async_post_call_success_hook"
# Check that ALL request counter increments are by 1 (not by token count)
for inc_value in total_requests_metric.inc_calls:
@ -684,8 +687,8 @@ async def test_multiple_requests_counter_semantics(mock_prometheus_logger):
expected_total_tokens = num_requests * tokens_per_request # 3 * 500 = 1500
# With the bug, total_request_increments would be 1500 instead of 3
assert total_request_increments == 0, (
f"SEMANTIC BUG: Request counter total increments = 0, "
assert total_request_increments == num_requests, (
f"SEMANTIC BUG: Request counter total increments = {total_request_increments}, "
f"expected {num_requests}. This suggests request counters are being incremented "
f"by token counts instead of request counts."
)

View file

@ -0,0 +1,92 @@
"""
Tests for Nova imported/custom model support via spec prefixes (nova/, nova-2/).
"""
import pytest
from litellm.llms.bedrock.common_utils import (
BedrockModelInfo,
get_bedrock_base_model,
strip_bedrock_routing_prefix,
)
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
NOVA_ARN = "arn:aws:bedrock:us-east-1:123456789012:custom-model-deployment/a1b2c3d4e5f6"
NOVA_MODEL = f"bedrock/nova/{NOVA_ARN}"
NOVA2_MODEL = f"bedrock/nova-2/{NOVA_ARN}"
class TestGetBedrockRoute:
def test_nova_prefix_routes_to_converse(self):
assert BedrockModelInfo.get_bedrock_route(NOVA_MODEL) == "converse"
def test_nova2_prefix_routes_to_converse(self):
assert BedrockModelInfo.get_bedrock_route(NOVA2_MODEL) == "converse"
def test_plain_arn_routes_to_invoke(self):
# Without spec prefix, ARN doesn't match converse models
result = BedrockModelInfo.get_bedrock_route(f"bedrock/{NOVA_ARN}")
assert result == "invoke"
class TestGetBedrockBaseModel:
def test_nova_prefix_returns_sentinel(self):
assert get_bedrock_base_model(f"nova/{NOVA_ARN}") == "amazon.nova-custom"
def test_nova2_prefix_returns_sentinel(self):
assert get_bedrock_base_model(f"nova-2/{NOVA_ARN}") == "amazon.nova-2-custom"
def test_bedrock_nova_prefix_returns_sentinel(self):
assert get_bedrock_base_model(NOVA_MODEL) == "amazon.nova-custom"
def test_bedrock_nova2_prefix_returns_sentinel(self):
assert get_bedrock_base_model(NOVA2_MODEL) == "amazon.nova-2-custom"
class TestStripBedrockRoutingPrefix:
def test_strips_nova_prefix(self):
result = strip_bedrock_routing_prefix(f"nova/{NOVA_ARN}")
assert result == NOVA_ARN
def test_strips_nova2_prefix(self):
result = strip_bedrock_routing_prefix(f"nova-2/{NOVA_ARN}")
assert result == NOVA_ARN
class TestIsNova2Model:
def setup_method(self):
self.config = AmazonConverseConfig()
def test_standard_nova2_model(self):
assert self.config._is_nova_2_model("amazon.nova-2-lite-v1:0") is True
def test_nova2_imported_model(self):
assert self.config._is_nova_2_model(NOVA2_MODEL) is True
def test_nova_imported_model_is_not_nova2(self):
assert self.config._is_nova_2_model(NOVA_MODEL) is False
def test_plain_nova_model(self):
assert self.config._is_nova_2_model("amazon.nova-pro-v1:0") is False
class TestGetSupportedOpenaiParams:
def setup_method(self):
self.config = AmazonConverseConfig()
def test_nova_imported_has_tools_and_web_search(self):
params = self.config.get_supported_openai_params(NOVA_MODEL)
assert "tools" in params
assert "tool_choice" in params
assert "web_search_options" in params
def test_nova2_imported_has_reasoning_effort(self):
params = self.config.get_supported_openai_params(NOVA2_MODEL)
assert "reasoning_effort" in params
assert "web_search_options" in params
def test_nova2_imported_has_tools(self):
params = self.config.get_supported_openai_params(NOVA2_MODEL)
assert "tools" in params
assert "tool_choice" in params

View file

@ -0,0 +1,175 @@
"""
Unit tests for ProxyLogging._init_litellm_callbacks.
Validates that string callbacks in litellm.callbacks are replaced in-place
with their initialized instances, preventing duplicate entries (string + instance)
that caused double-counting of metrics like litellm_proxy_total_requests_metric.
"""
from typing import List, Union
from unittest.mock import MagicMock, patch
import pytest
import litellm
from litellm.integrations.custom_logger import CustomLogger
class FakeCustomLogger(CustomLogger):
"""A minimal CustomLogger subclass for testing."""
pass
class TestInitLitellmCallbacks:
"""Tests for ProxyLogging._init_litellm_callbacks."""
def _make_proxy_logging(self):
"""Create a ProxyLogging instance with mocked dependencies."""
from litellm.proxy.utils import ProxyLogging
mock_cache = MagicMock()
proxy_logging = ProxyLogging(user_api_key_cache=mock_cache)
return proxy_logging
@patch(
"litellm.proxy.utils.ProxyLogging._add_proxy_hooks",
new_callable=lambda: lambda self, *a, **kw: None,
)
def test_should_replace_string_callback_with_instance(self, _mock_hooks):
"""
When litellm.callbacks contains a string callback (e.g. "lago"),
_init_litellm_callbacks should replace the string with the initialized
CustomLogger instance, not leave both the string and instance in the list.
"""
fake_logger = FakeCustomLogger()
# Start with a string callback in litellm.callbacks
litellm.callbacks = ["lago"] # type: ignore
proxy_logging = self._make_proxy_logging()
with patch(
"litellm.litellm_core_utils.litellm_logging._init_custom_logger_compatible_class",
return_value=fake_logger,
):
proxy_logging._init_litellm_callbacks(llm_router=None)
# The string "lago" should be replaced by the instance, not appended
string_entries = [c for c in litellm.callbacks if isinstance(c, str)]
instance_entries = [
c for c in litellm.callbacks if isinstance(c, FakeCustomLogger)
]
assert len(string_entries) == 0, (
f"String callbacks should have been replaced, but found: {string_entries}"
)
assert len(instance_entries) == 1, (
f"Expected exactly one FakeCustomLogger instance, found {len(instance_entries)}"
)
assert instance_entries[0] is fake_logger
# Clean up
litellm.callbacks = [] # type: ignore
@patch(
"litellm.proxy.utils.ProxyLogging._add_proxy_hooks",
new_callable=lambda: lambda self, *a, **kw: None,
)
def test_should_not_duplicate_existing_instance_callbacks(self, _mock_hooks):
"""
When litellm.callbacks already contains a CustomLogger instance (not a string),
_init_litellm_callbacks should not create a duplicate.
"""
existing_logger = FakeCustomLogger()
litellm.callbacks = [existing_logger] # type: ignore
proxy_logging = self._make_proxy_logging()
proxy_logging._init_litellm_callbacks(llm_router=None)
# Count how many FakeCustomLogger instances are in litellm.callbacks
instance_count = sum(
1 for c in litellm.callbacks if isinstance(c, FakeCustomLogger)
)
assert instance_count == 1, (
f"Expected exactly 1 FakeCustomLogger instance, found {instance_count}. "
f"litellm.callbacks = {litellm.callbacks}"
)
# Clean up
litellm.callbacks = [] # type: ignore
@patch(
"litellm.proxy.utils.ProxyLogging._add_proxy_hooks",
new_callable=lambda: lambda self, *a, **kw: None,
)
def test_should_handle_unrecognized_string_callback(self, _mock_hooks):
"""
When _init_custom_logger_compatible_class returns None for a string callback,
the string should remain in litellm.callbacks (not crash).
"""
litellm.callbacks = ["unknown_callback"] # type: ignore
proxy_logging = self._make_proxy_logging()
with patch(
"litellm.litellm_core_utils.litellm_logging._init_custom_logger_compatible_class",
return_value=None,
):
proxy_logging._init_litellm_callbacks(llm_router=None)
# The unknown string callback should still be there (not replaced, not crashed)
assert "unknown_callback" in litellm.callbacks
# Clean up
litellm.callbacks = [] # type: ignore
@patch(
"litellm.proxy.utils.ProxyLogging._add_proxy_hooks",
new_callable=lambda: lambda self, *a, **kw: None,
)
def test_should_replace_multiple_string_callbacks(self, _mock_hooks):
"""
When litellm.callbacks contains multiple string callbacks,
each should be replaced with its corresponding initialized instance.
"""
fake_logger_a = FakeCustomLogger()
fake_logger_b = FakeCustomLogger()
litellm.callbacks = ["callback_a", "callback_b"] # type: ignore
proxy_logging = self._make_proxy_logging()
call_count = 0
def mock_init_class(callback_name, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return fake_logger_a
return fake_logger_b
with patch(
"litellm.litellm_core_utils.litellm_logging._init_custom_logger_compatible_class",
side_effect=mock_init_class,
):
proxy_logging._init_litellm_callbacks(llm_router=None)
string_entries = [c for c in litellm.callbacks if isinstance(c, str)]
instance_entries = [
c for c in litellm.callbacks if isinstance(c, FakeCustomLogger)
]
assert len(string_entries) == 0, (
f"All string callbacks should have been replaced: {string_entries}"
)
assert len(instance_entries) == 2, (
f"Expected 2 FakeCustomLogger instances, found {len(instance_entries)}"
)
assert instance_entries[0] is fake_logger_a
assert instance_entries[1] is fake_logger_b
# Clean up
litellm.callbacks = [] # type: ignore

View file

@ -1037,6 +1037,193 @@ def test_convert_to_model_response_object_with_empty_dict_error():
assert result.choices[0].message.content == "Hello!"
def test_convert_to_model_response_object_preserves_provider_specific_fields_from_proxy():
"""
Test that provider_specific_fields (e.g. Anthropic citations) are preserved
when the response already contains them (e.g. from a proxy passthrough).
Regression test for https://github.com/BerriAI/litellm/issues/21153
"""
citations = [
[
{
"type": "web_search_result_location",
"cited_text": "The Sony WH-1000XM5 remains one of the best...",
"url": "https://example.com/headphones-review",
"title": "Best Headphones 2025",
"supported_text": "Based on current reviews...",
}
],
]
web_search_results = [
{
"url": "https://example.com/headphones-review",
"title": "Best Headphones 2025",
"snippet": "The Sony WH-1000XM5 remains one of the best...",
}
]
response_object = {
"id": "chatcmpl-proxy-123",
"object": "chat.completion",
"created": 1728933352,
"model": "anthropic/claude-opus-4-5-20251101",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Based on current reviews, the Sony WH-1000XM5 remains one of the best headphones.",
"tool_calls": [
{
"id": "call_ws_123",
"type": "function",
"function": {
"name": "web_search",
"arguments": '{"query": "best headphones 2025"}',
},
}
],
"provider_specific_fields": {
"citations": citations,
"web_search_results": web_search_results,
},
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 50,
"completion_tokens": 20,
"total_tokens": 70,
},
}
result = convert_to_model_response_object(
model_response_object=ModelResponse(),
response_object=response_object,
stream=False,
start_time=datetime.now(),
end_time=datetime.now(),
hidden_params=None,
_response_headers=None,
convert_tool_call_to_json_mode=False,
)
assert isinstance(result, ModelResponse)
assert result.id == "chatcmpl-proxy-123"
choice = result.choices[0]
assert choice.message.content == "Based on current reviews, the Sony WH-1000XM5 remains one of the best headphones."
assert choice.message.provider_specific_fields is not None
assert "citations" in choice.message.provider_specific_fields
assert choice.message.provider_specific_fields["citations"] == citations
assert "web_search_results" in choice.message.provider_specific_fields
assert choice.message.provider_specific_fields["web_search_results"] == web_search_results
def test_convert_to_model_response_object_provider_specific_fields_merges_extra_keys():
"""
Test that provider_specific_fields from the response are merged with
any extra non-standard keys present in the message dict.
Regression test for https://github.com/BerriAI/litellm/issues/21153
"""
response_object = {
"id": "chatcmpl-merge-123",
"object": "chat.completion",
"created": 1728933352,
"model": "some-model",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello!",
"provider_specific_fields": {
"citations": [{"url": "https://example.com"}],
},
"custom_extra_field": "extra_value",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
},
}
result = convert_to_model_response_object(
model_response_object=ModelResponse(),
response_object=response_object,
stream=False,
start_time=datetime.now(),
end_time=datetime.now(),
hidden_params=None,
_response_headers=None,
convert_tool_call_to_json_mode=False,
)
assert isinstance(result, ModelResponse)
psf = result.choices[0].message.provider_specific_fields
assert psf is not None
# Both the existing provider_specific_fields and the extra key should be present
assert "citations" in psf
assert psf["citations"] == [{"url": "https://example.com"}]
assert "custom_extra_field" in psf
assert psf["custom_extra_field"] == "extra_value"
def test_convert_to_model_response_object_no_provider_specific_fields_still_works():
"""
Test that responses without provider_specific_fields continue to work as before.
Ensures the fix for https://github.com/BerriAI/litellm/issues/21153
doesn't break normal responses.
"""
response_object = {
"id": "chatcmpl-normal-123",
"object": "chat.completion",
"created": 1728933352,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello!",
"refusal": None,
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
},
}
result = convert_to_model_response_object(
model_response_object=ModelResponse(),
response_object=response_object,
stream=False,
start_time=datetime.now(),
end_time=datetime.now(),
hidden_params=None,
_response_headers=None,
convert_tool_call_to_json_mode=False,
)
assert isinstance(result, ModelResponse)
psf = result.choices[0].message.provider_specific_fields
# refusal is not a Message model field, so it should be in provider_specific_fields
assert psf is not None
assert "refusal" in psf
def test_convert_to_model_response_object_with_error_code_only():
"""
Test that errors with only a code (no message) are still treated as real errors.

View file

@ -1894,28 +1894,28 @@ def test_validate_openai_optional_params_stop_truncation():
result = validate_openai_optional_params(stop=stop_sequences)
assert result == ["stop1", "stop2", "stop3", "stop4"]
assert len(result) == 4
# Test with exactly 4 stop sequences - should not truncate
stop_sequences_4 = ["stop1", "stop2", "stop3", "stop4"]
result = validate_openai_optional_params(stop=stop_sequences_4)
assert result == ["stop1", "stop2", "stop3", "stop4"]
assert len(result) == 4
# Test with less than 4 stop sequences - should not truncate
stop_sequences_2 = ["stop1", "stop2"]
result = validate_openai_optional_params(stop=stop_sequences_2)
assert result == ["stop1", "stop2"]
assert len(result) == 2
# Test with single stop sequence as string - should return as is
stop_string = "stop1"
result = validate_openai_optional_params(stop=stop_string)
assert result == "stop1"
# Test with None - should return None
result = validate_openai_optional_params(stop=None)
assert result is None
# Test with empty list - should return empty list
result = validate_openai_optional_params(stop=[])
assert result == []
@ -1928,7 +1928,7 @@ def test_validate_openai_optional_params_disable_stop_sequence_limit():
"""
# Save original value
original_value = litellm.disable_stop_sequence_limit
try:
# Test with disable_stop_sequence_limit = True - should NOT truncate
litellm.disable_stop_sequence_limit = True
@ -1936,7 +1936,7 @@ def test_validate_openai_optional_params_disable_stop_sequence_limit():
result = validate_openai_optional_params(stop=stop_sequences)
assert result == ["stop1", "stop2", "stop3", "stop4", "stop5", "stop6"]
assert len(result) == 6
# Test with disable_stop_sequence_limit = False - should truncate to 4
litellm.disable_stop_sequence_limit = False
stop_sequences = ["stop1", "stop2", "stop3", "stop4", "stop5", "stop6"]
@ -1965,19 +1965,83 @@ def test_validate_openai_optional_params_integration():
mock_response.usage.prompt_tokens = 10
mock_response.usage.completion_tokens = 5
mock_response.usage.total_tokens = 15
mock_client.return_value.chat.completions.create.return_value = mock_response
mock_client.return_value.chat.completions.create.return_value = (
mock_response
)
# Call completion with more than 4 stop sequences
response = litellm.completion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Hello"}],
stop=["stop1", "stop2", "stop3", "stop4", "stop5", "stop6"],
mock_response="Test response" # This will use mock
mock_response="Test response", # This will use mock
)
# Verify the call was made (stop sequences should be truncated internally)
assert response is not None
except Exception as e:
# Should not raise an exception
pytest.fail(f"validate_openai_optional_params integration failed: {e}")
def test_drop_store_param_for_anthropic():
"""
Test that the OpenAI-specific `store` parameter is correctly dropped
when calling Anthropic with drop_params=True.
`store` is an OpenAI Chat Completion parameter (for storing completions
for distillation/evals) that Anthropic does not support. Without proper
handling, it leaks through to the Anthropic API and causes a
"store: Extra inputs are not permitted" error.
Ref: https://github.com/BerriAI/litellm/issues/19700
"""
optional_params = get_optional_params(
model="claude-sonnet-4-20250514",
custom_llm_provider="anthropic",
drop_params=True,
store=True,
)
assert "store" not in optional_params
def test_additional_drop_params_store_for_anthropic():
"""
Test that `additional_drop_params=["store"]` correctly strips the `store`
parameter for non-OpenAI providers like Anthropic.
Ref: https://github.com/BerriAI/litellm/issues/19700
"""
optional_params = get_optional_params(
model="claude-sonnet-4-20250514",
custom_llm_provider="anthropic",
additional_drop_params=["store"],
store=True,
)
assert "store" not in optional_params
def test_store_in_openai_chat_completion_params():
"""
Test that `store` is recognized as a standard OpenAI Chat Completion
parameter. This ensures it is correctly handled by helper functions
like `get_standard_openai_params()` and provider configs that rely on
`OPENAI_CHAT_COMPLETION_PARAMS`.
Without `store` in this list, functions that filter by known OpenAI
params will silently drop it for OpenAI calls or incorrectly treat
it as a provider-specific param for non-OpenAI providers.
Ref: https://github.com/BerriAI/litellm/issues/19700
"""
from litellm.constants import OPENAI_CHAT_COMPLETION_PARAMS
assert "store" in OPENAI_CHAT_COMPLETION_PARAMS
# Verify get_standard_openai_params recognizes store
from litellm.utils import get_standard_openai_params
result = get_standard_openai_params({"store": True, "temperature": 0.7})
assert "store" in result
assert result["store"] is True

View file

@ -32,6 +32,8 @@ def get_all_supported_anthropic_beta_headers(provider: str):
"model_name,provider_name",
[
("claude-sonnet-4-5-20250929", "anthropic"),
("azure-ai-claude-opus-4.5", "azure_ai"),
("vertex-ai-claude-opus-4-6", "vertex_ai"),
],
)
async def test_anthropic_messages_with_all_beta_headers(model_name, provider_name):

View file

@ -34,6 +34,13 @@ model_list:
litellm_params:
model: "bedrock/converse/us.anthropic.claude-sonnet-4-5-20250929-v1:0"
aws_region_name: "us-east-1"
# Azure AI models
- model_name: azure-ai-claude-opus-4.5
litellm_params:
model: "azure_ai/claude-opus-4.5"
api_key: os.environ/AZURE_AI_API_KEY
api_base: os.environ/AZURE_AI_API_BASE
# Vertex AI models
- model_name: vertex-ai-claude-opus-4-6

View file

@ -1928,6 +1928,24 @@ def test_get_known_models_from_wildcard(
assert all(model in wildcard_models for model in expected_models)
def test_get_known_models_from_wildcard_without_litellm_params():
"""
Test wildcard expansion without litellm_params (BYOK case - team has openai/*
but no deployment in router config).
"""
from litellm.proxy.auth.model_checks import get_known_models_from_wildcard
wildcard_models = get_known_models_from_wildcard(
wildcard_model="openai/*", litellm_params=None
)
# Should return expanded OpenAI models (gpt-4o, gpt-4o-mini, etc.)
assert len(wildcard_models) > 0
assert all(m.startswith("openai/") for m in wildcard_models)
# Check for common OpenAI models
model_ids = [m.split("/", 1)[1] for m in wildcard_models]
assert "gpt-4o" in model_ids or "gpt-3.5-turbo" in model_ids
@pytest.mark.parametrize(
"data, user_api_key_dict, expected_model",
[

View file

@ -0,0 +1,50 @@
from litellm import Router
class NoItemsAliasDict(dict):
def items(self):
raise AssertionError("Unexpected full alias iteration via items()")
def test_get_model_list_from_model_alias_should_not_iterate_for_non_alias_lookup():
router = Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {"model": "gpt-3.5-turbo"},
}
],
model_group_alias={"alias-1": "gpt-4"},
)
router.model_group_alias = NoItemsAliasDict(
{f"alias-{idx}": "gpt-4" for idx in range(200)}
)
model_alias_list = router.get_model_list_from_model_alias(
model_name="gpt-3.5-turbo"
)
assert model_alias_list == []
def test_map_team_model_should_not_iterate_aliases_for_non_alias_team_model_name():
router = Router(
model_list=[
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {"model": "gpt-3.5-turbo"},
"model_info": {
"team_id": "team-1",
"team_public_model_name": "team-model",
},
}
],
model_group_alias={"alias-1": "gpt-4"},
)
router.model_group_alias = NoItemsAliasDict(
{f"alias-{idx}": "gpt-4" for idx in range(200)}
)
assert (
router.map_team_model(team_model_name="team-model", team_id="team-1")
== "gpt-3.5-turbo"
)

View file

@ -0,0 +1,356 @@
"""
Tests for DuckDuckGo Search API integration.
"""
import os
import sys
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
sys.path.insert(
0, os.path.abspath("../..")
)
import litellm
from tests.search_tests.base_search_unit_tests import BaseSearchTest
class TestDuckDuckGoSearch(BaseSearchTest):
"""
Tests for DuckDuckGo Search functionality.
"""
def get_search_provider(self) -> str:
"""
Return search_provider for DuckDuckGo Search.
"""
return "duckduckgo"
@pytest.mark.asyncio
async def test_basic_search(self):
"""
Test basic search functionality with a simple query.
"""
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
litellm._turn_on_debug()
search_provider = self.get_search_provider()
print("Search Provider=", search_provider)
try:
response = await litellm.asearch(
query="india",
search_provider=search_provider,
)
print("Search response=", response.model_dump_json(indent=4))
print(f"\n{'='*80}")
print(f"Response type: {type(response)}")
print(f"Response object: {response.object if hasattr(response, 'object') else 'N/A'}")
# Check if response has expected Search format
assert hasattr(response, "results"), "Response should have 'results' attribute"
assert hasattr(response, "object"), "Response should have 'object' attribute"
assert response.object == "search", f"Expected object='search', got '{response.object}'"
# Validate results structure
assert isinstance(response.results, list), "results should be a list"
assert len(response.results) > 0, "Should have at least one result"
# Check first result structure
first_result = response.results[0]
assert hasattr(first_result, "title"), "Result should have 'title' attribute"
assert hasattr(first_result, "url"), "Result should have 'url' attribute"
assert hasattr(first_result, "snippet"), "Result should have 'snippet' attribute"
print(f"Total results: {len(response.results)}")
print(f"First result title: {first_result.title}")
print(f"First result URL: {first_result.url}")
print(f"First result snippet: {first_result.snippet[:100]}...")
print(f"{'='*80}\n")
assert len(first_result.title) > 0, "Title should not be empty"
assert len(first_result.url) > 0, "URL should not be empty"
assert len(first_result.snippet) > 0, "Snippet should not be empty"
# Validate cost tracking in _hidden_params
assert hasattr(response, "_hidden_params"), "Response should have '_hidden_params' attribute"
hidden_params = response._hidden_params
assert "response_cost" in hidden_params, "_hidden_params should contain 'response_cost'"
response_cost = hidden_params["response_cost"]
assert response_cost is not None, "response_cost should not be None"
assert isinstance(response_cost, (int, float)), "response_cost should be a number"
assert response_cost == 0, "response_cost should be 0"
print(f"Cost tracking: ${response_cost:.6f}")
except Exception as e:
pytest.fail(f"Search call failed: {str(e)}")
def test_search_response_structure(self):
"""
Test that the Search response has the correct structure.
"""
litellm.set_verbose = True
search_provider = self.get_search_provider()
response = litellm.search(
query="india",
search_provider=search_provider,
)
# Validate response structure
assert hasattr(response, "results"), "Response should have 'results' attribute"
assert hasattr(response, "object"), "Response should have 'object' attribute"
assert isinstance(response.results, list), "results should be a list"
assert len(response.results) > 0, "Should have at least one result"
assert response.object == "search", "object should be 'search'"
# Validate first result structure
first_result = response.results[0]
assert hasattr(first_result, "title"), "Result should have 'title' attribute"
assert hasattr(first_result, "url"), "Result should have 'url' attribute"
assert hasattr(first_result, "snippet"), "Result should have 'snippet' attribute"
assert isinstance(first_result.title, str), "title should be a string"
assert isinstance(first_result.url, str), "url should be a string"
assert isinstance(first_result.snippet, str), "snippet should be a string"
print(f"\nResponse structure validated:")
print(f" - object: {response.object}")
print(f" - results: {len(response.results)}")
print(f" - first result has all required fields")
class TestDuckDuckGoSearchMocked:
"""
Tests for DuckDuckGo Search functionality with mocked network responses.
"""
@pytest.mark.asyncio
async def test_duckduckgo_search_request_payload(self):
"""
Test that validates the DuckDuckGo search request payload structure without making real API calls.
"""
# Create a mock response matching DuckDuckGo API format
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"Abstract": "",
"AbstractSource": "Wikipedia",
"AbstractText": "Python is a high-level programming language.",
"AbstractURL": "https://en.wikipedia.org/wiki/Python_(programming_language)",
"Answer": "",
"AnswerType": "",
"Definition": "",
"DefinitionSource": "",
"DefinitionURL": "",
"Entity": "",
"Heading": "Python (programming language)",
"Image": "",
"ImageHeight": 0,
"ImageIsLogo": 0,
"ImageWidth": 0,
"Infobox": "",
"Redirect": "",
"RelatedTopics": [
{
"FirstURL": "https://duckduckgo.com/Python_programming",
"Icon": {
"Height": "",
"URL": "/i/python.png",
"Width": ""
},
"Result": "<a href=\"https://duckduckgo.com/Python_programming\">Python Programming</a> A general-purpose programming language.",
"Text": "Python Programming - A general-purpose programming language."
},
{
"FirstURL": "https://duckduckgo.com/Python_packages",
"Icon": {
"Height": "",
"URL": "",
"Width": ""
},
"Result": "<a href=\"https://duckduckgo.com/Python_packages\">Python Packages</a> Package management in Python.",
"Text": "Python Packages - Package management in Python."
}
],
"Results": [],
"Type": "A",
"meta": {
"attribution": None,
"blockgroup": None,
"created_date": None,
"description": "Wikipedia",
"designer": None,
"dev_date": None,
"dev_milestone": "live",
"developer": [
{
"name": "DDG Team",
"type": "ddg",
"url": "http://www.duckduckhack.com"
}
],
"example_query": "python programming",
"id": "wikipedia_fathead",
"is_stackexchange": None,
"js_callback_name": "wikipedia",
"live_date": None,
"maintainer": {
"github": "duckduckgo"
},
"name": "Wikipedia",
"perl_module": "DDG::Fathead::Wikipedia",
"producer": None,
"production_state": "online",
"repo": "fathead",
"signal_from": "wikipedia_fathead",
"src_domain": "en.wikipedia.org",
"src_id": 1,
"src_name": "Wikipedia",
"src_options": {
"directory": "",
"is_fanon": 0,
"is_mediawiki": 1,
"is_wikipedia": 1,
"language": "en",
"min_abstract_length": "20",
"skip_abstract": 0,
"skip_abstract_paren": 0,
"skip_end": "0",
"skip_icon": 0,
"skip_image_name": 0,
"skip_qr": "",
"source_skip": "",
"src_info": ""
},
"src_url": None,
"status": "live",
"tab": "About",
"topic": [
"productivity"
],
"unsafe": 0
}
}
# Mock the httpx AsyncClient get method (DuckDuckGo uses GET)
with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
# Make the search call
response = await litellm.asearch(
query="python programming",
search_provider="duckduckgo",
max_results=5
)
# Verify the get method was called once
assert mock_get.call_count == 1
# Get the actual call arguments
call_args = mock_get.call_args
# Verify URL contains the query with proper URL encoding
url = call_args.kwargs["url"]
assert "api.duckduckgo.com" in url
# URL should be properly encoded with %20 for spaces
assert ("q=python+programming" in url or "q=python%20programming" in url)
assert "format=json" in url
# Verify response structure
assert hasattr(response, "results")
assert hasattr(response, "object")
assert response.object == "search"
assert len(response.results) > 0
# Verify first result (Abstract)
first_result = response.results[0]
assert first_result.title == "Python (programming language)"
assert first_result.url == "https://en.wikipedia.org/wiki/Python_(programming_language)"
assert "Python is a high-level programming language" in first_result.snippet
# Verify related topics are included
assert len(response.results) >= 2 # Abstract + at least one related topic
@pytest.mark.asyncio
async def test_duckduckgo_search_disambiguation(self):
"""
Test handling of disambiguation results from DuckDuckGo.
"""
# Create a mock response with disambiguation type
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"Abstract": "",
"AbstractSource": "Wikipedia",
"AbstractText": "",
"AbstractURL": "https://en.wikipedia.org/wiki/India_(disambiguation)",
"Answer": "",
"AnswerType": "",
"Definition": "",
"DefinitionSource": "",
"DefinitionURL": "",
"Entity": "",
"Heading": "India",
"Image": "",
"ImageHeight": 0,
"ImageIsLogo": 0,
"ImageWidth": 0,
"Infobox": "",
"Redirect": "",
"RelatedTopics": [
{
"FirstURL": "https://duckduckgo.com/India",
"Icon": {
"Height": "",
"URL": "/i/cef47a13.png",
"Width": ""
},
"Result": "<a href=\"https://duckduckgo.com/India\">India</a> A country in South Asia.",
"Text": "India - A country in South Asia."
},
{
"Name": "Related Topics",
"Topics": [
{
"FirstURL": "https://duckduckgo.com/d/Indus",
"Icon": {
"Height": "",
"URL": "",
"Width": ""
},
"Result": "<a href=\"https://duckduckgo.com/d/Indus\">Indus</a> See related meanings for the word 'Indus'.",
"Text": "Indus - See related meanings for the word 'Indus'."
}
]
}
],
"Results": [],
"Type": "D",
"meta": {}
}
# Mock the httpx AsyncClient get method
with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_response
# Make the search call
response = await litellm.asearch(
query="India",
search_provider="duckduckgo"
)
# Verify response structure
assert hasattr(response, "results")
assert hasattr(response, "object")
assert response.object == "search"
# Should have results from both direct topics and nested topics
assert len(response.results) >= 2
# Verify nested topics are processed
urls = [result.url for result in response.results]
assert any("India" in url for url in urls)
assert any("Indus" in url for url in urls)

View file

@ -9,9 +9,7 @@ from unittest.mock import ANY, MagicMock, Mock, patch
import httpx
import pytest
sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system-path
sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system-path
import litellm
@ -119,9 +117,7 @@ def test_convert_chat_completion_messages_to_responses_api_tool_result_with_imag
function_call_output = item
break
assert (
function_call_output is not None
), "function_call_output not found in response"
assert function_call_output is not None, "function_call_output not found in response"
assert function_call_output["call_id"] == "call_abc123"
# Check that the output is correctly transformed
@ -131,12 +127,8 @@ def test_convert_chat_completion_messages_to_responses_api_tool_result_with_imag
image_item = output[0]
# Should be transformed to Responses API format
assert (
image_item["type"] == "input_image"
), f"Expected type 'input_image', got '{image_item.get('type')}'"
assert (
image_item["image_url"] == test_image_base64
), "image_url should be a flat string, not a nested object"
assert image_item["type"] == "input_image", f"Expected type 'input_image', got '{image_item.get('type')}'"
assert image_item["image_url"] == test_image_base64, "image_url should be a flat string, not a nested object"
assert "detail" in image_item, "detail field should be present"
print("✓ Tool result with image correctly transformed to Responses API format")
@ -198,9 +190,7 @@ def test_convert_chat_completion_messages_to_responses_api_tool_result_with_text
function_call_output = item
break
assert (
function_call_output is not None
), "function_call_output not found in response"
assert function_call_output is not None, "function_call_output not found in response"
assert function_call_output["call_id"] == "call_abc123"
# Check that the output is correctly transformed to use input_text, not output_text
@ -210,12 +200,10 @@ def test_convert_chat_completion_messages_to_responses_api_tool_result_with_text
text_item = output[0]
# Should be transformed to use input_text for tool results in Responses API format
assert (
text_item["type"] == "input_text"
), f"Expected type 'input_text' for tool result, got '{text_item.get('type')}'"
assert (
text_item["text"] == "15 degrees"
), f"Expected text '15 degrees', got '{text_item.get('text')}'"
assert text_item["type"] == "input_text", (
f"Expected type 'input_text' for tool result, got '{text_item.get('type')}'"
)
assert text_item["text"] == "15 degrees", f"Expected text '15 degrees', got '{text_item.get('text')}'"
print("✓ Tool result with text correctly transformed to use input_text for Responses API format")
@ -226,9 +214,7 @@ def test_openai_responses_chunk_parser_reasoning_summary():
)
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
iterator = OpenAiResponsesToChatCompletionStreamIterator(
streaming_response=None, sync_stream=True
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
chunk = {
"delta": "**Compar",
@ -260,9 +246,7 @@ def test_chunk_parser_string_output_text_delta_produces_text():
)
from litellm.types.utils import ModelResponseStream
iterator = OpenAiResponsesToChatCompletionStreamIterator(
streaming_response=None, sync_stream=True
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
chunk = {"type": "response.output_text.delta", "delta": "literal text"}
@ -283,9 +267,7 @@ def test_chunk_parser_enum_output_text_delta_produces_text():
from litellm.types.llms.openai import ResponsesAPIStreamEvents
from litellm.types.utils import ModelResponseStream
iterator = OpenAiResponsesToChatCompletionStreamIterator(
streaming_response=None, sync_stream=True
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
chunk = {"type": ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA, "delta": "enum text"}
@ -306,9 +288,7 @@ def test_chunk_parser_function_call_added_produces_tool_use():
from litellm.types.llms.openai import ResponsesAPIStreamEvents
from litellm.types.utils import ModelResponseStream
iterator = OpenAiResponsesToChatCompletionStreamIterator(
streaming_response=None, sync_stream=True
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
chunk = {
"type": ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
@ -393,9 +373,7 @@ Tomorrow will bring its petitions and promises,
but for now the city breathes slow and wide,
and I learn to carry this small calm home."""
output_text = ResponseOutputText(
annotations=[], text=poem_text, type="output_text", logprobs=[]
)
output_text = ResponseOutputText(annotations=[], text=poem_text, type="output_text", logprobs=[])
output_message = ResponseOutputMessage(
id="msg_04c8021b8b3188a00068e9ae0b92f4819dac64d85b4abb67ec",
content=[output_text],
@ -407,9 +385,7 @@ and I learn to carry this small calm home."""
# Create usage information
usage = ResponseAPIUsage(
input_tokens=16,
input_tokens_details=InputTokensDetails(
audio_tokens=None, cached_tokens=0, text_tokens=None
),
input_tokens_details=InputTokensDetails(audio_tokens=None, cached_tokens=0, text_tokens=None),
output_tokens=195,
output_tokens_details=OutputTokensDetails(reasoning_tokens=0, text_tokens=None),
total_tokens=211,
@ -621,9 +597,7 @@ def test_transform_request_single_char_keys_not_matched():
assert result_correct.get("metadata") == {"user_id": "123"}
assert result_correct.get("previous_response_id") == "resp_abc"
print(
"✓ Single-character keys are not incorrectly matched to metadata/previous_response_id"
)
print("✓ Single-character keys are not incorrectly matched to metadata/previous_response_id")
# =============================================================================
@ -643,9 +617,7 @@ def test_message_done_does_not_emit_is_finished():
OpenAiResponsesToChatCompletionStreamIterator,
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(
streaming_response=None, sync_stream=True
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
chunk = {
"type": "response.output_item.done",
@ -657,9 +629,9 @@ def test_message_done_does_not_emit_is_finished():
# After the fix, message completion should NOT set finish_reason
# ModelResponseStream doesn't have is_finished - check finish_reason instead
assert len(result.choices) > 0, "result should have choices"
assert (
result.choices[0].finish_reason is None or result.choices[0].finish_reason == ""
), "message completion should not emit finish_reason"
assert result.choices[0].finish_reason is None or result.choices[0].finish_reason == "", (
"message completion should not emit finish_reason"
)
def test_response_completed_emits_is_finished():
@ -671,9 +643,7 @@ def test_response_completed_emits_is_finished():
OpenAiResponsesToChatCompletionStreamIterator,
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(
streaming_response=None, sync_stream=True
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
chunk = {"type": "response.completed"}
@ -681,9 +651,91 @@ def test_response_completed_emits_is_finished():
# response.completed should emit finish_reason='stop'
assert len(result.choices) > 0, "result should have choices"
assert (
result.choices[0].finish_reason == "stop"
), "response.completed should emit finish_reason='stop'"
assert result.choices[0].finish_reason == "stop", "response.completed should emit finish_reason='stop'"
def test_response_completed_with_function_calls_emits_tool_calls_finish_reason():
"""
Test that response.completed with function_call items in output emits finish_reason='tool_calls'.
This is a regression test for an issue where response.completed always returned
finish_reason='stop' even when the response contained tool calls, causing agents
like OpenCode to incorrectly conclude the stream ended without tools to execute.
When the response.completed event includes function_call items in its output,
the finish_reason should be 'tool_calls' to signal the client that tools need
to be executed.
"""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
OpenAiResponsesToChatCompletionStreamIterator,
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
# Simulate a response.completed event with function_call in output
# This matches what Azure/OpenAI sends for gpt-5.1-codex-mini and similar models
chunk = {
"type": "response.completed",
"response": {
"id": "resp_123",
"status": "completed",
"output": [
{
"type": "function_call",
"id": "call_abc123",
"call_id": "call_abc123",
"name": "read_file",
"arguments": '{"path": "/tmp/test.py"}',
"status": "completed",
}
],
},
}
result = iterator.chunk_parser(chunk)
# response.completed with function_call should emit finish_reason='tool_calls'
assert len(result.choices) > 0, "result should have choices"
assert result.choices[0].finish_reason == "tool_calls", (
"response.completed with function_call output should emit finish_reason='tool_calls'"
)
def test_response_completed_with_message_only_emits_stop_finish_reason():
"""
Test that response.completed with only message output (no function_call) emits finish_reason='stop'.
"""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
OpenAiResponsesToChatCompletionStreamIterator,
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
# Simulate a response.completed event with only message output
chunk = {
"type": "response.completed",
"response": {
"id": "resp_456",
"status": "completed",
"output": [
{
"type": "message",
"id": "msg_xyz",
"role": "assistant",
"content": [{"type": "output_text", "text": "Hello, world!"}],
"status": "completed",
}
],
},
}
result = iterator.chunk_parser(chunk)
# response.completed with only message should emit finish_reason='stop'
assert len(result.choices) > 0, "result should have choices"
assert result.choices[0].finish_reason == "stop", (
"response.completed with only message output should emit finish_reason='stop'"
)
def test_function_call_done_emits_is_finished():
@ -695,9 +747,7 @@ def test_function_call_done_emits_is_finished():
OpenAiResponsesToChatCompletionStreamIterator,
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(
streaming_response=None, sync_stream=True
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
chunk = {
"type": "response.output_item.done",
@ -713,13 +763,10 @@ def test_function_call_done_emits_is_finished():
# function_call completion should emit finish_reason='tool_calls'
assert len(result.choices) > 0, "result should have choices"
assert (
result.choices[0].finish_reason == "tool_calls"
), "function_call should emit finish_reason='tool_calls'"
assert (
result.choices[0].delta.tool_calls is not None
and len(result.choices[0].delta.tool_calls) > 0
), "function_call should include tool_calls"
assert result.choices[0].finish_reason == "tool_calls", "function_call should emit finish_reason='tool_calls'"
assert result.choices[0].delta.tool_calls is not None and len(result.choices[0].delta.tool_calls) > 0, (
"function_call should include tool_calls"
)
def test_text_plus_tool_calls_sequence():
@ -734,9 +781,7 @@ def test_text_plus_tool_calls_sequence():
OpenAiResponsesToChatCompletionStreamIterator,
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(
streaming_response=None, sync_stream=True
)
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
# Simulate the sequence from OpenAI Responses API
chunks = [
@ -775,26 +820,21 @@ def test_text_plus_tool_calls_sequence():
# Check message done (index 2) does NOT have finish_reason set
message_done_result = results[2]
assert len(message_done_result.choices) > 0, "message done should have choices"
assert (
message_done_result.choices[0].finish_reason is None
or message_done_result.choices[0].finish_reason == ""
), "message done should not have finish_reason"
assert message_done_result.choices[0].finish_reason is None or message_done_result.choices[0].finish_reason == "", (
"message done should not have finish_reason"
)
# Check function_call done (index 5) DOES have finish_reason='tool_calls'
function_done_result = results[5]
assert (
len(function_done_result.choices) > 0
), "function_call done should have choices"
assert (
function_done_result.choices[0].finish_reason == "tool_calls"
), "function_call done should have finish_reason='tool_calls'"
assert len(function_done_result.choices) > 0, "function_call done should have choices"
assert function_done_result.choices[0].finish_reason == "tool_calls", (
"function_call done should have finish_reason='tool_calls'"
)
# Check response.completed (index 6) has finish_reason='stop'
completed_result = results[6]
assert len(completed_result.choices) > 0, "response.completed should have choices"
assert (
completed_result.choices[0].finish_reason == "stop"
), "response.completed should have finish_reason='stop'"
assert completed_result.choices[0].finish_reason == "stop", "response.completed should have finish_reason='stop'"
# =============================================================================
@ -1012,11 +1052,11 @@ def test_multiple_tool_calls_in_single_choice():
def test_map_reasoning_effort_adds_summary_detailed():
"""
Test that _map_reasoning_effort behavior with reasoning_auto_summary flag.
By default (flag=False), summary should NOT be added to avoid:
1. Breaking for users without verified OpenAI orgs (400 errors)
2. Making requests more expensive by including summary reasoning tokens
When flag is enabled (flag=True or env var), summary="detailed" is added.
"""
import os
@ -1030,64 +1070,68 @@ def test_map_reasoning_effort_adds_summary_detailed():
# Test all string effort levels - DEFAULT BEHAVIOR (no summary)
effort_levels = ["none", "low", "medium", "high", "xhigh", "minimal"]
# Save original flag value
original_flag = litellm.reasoning_auto_summary
original_env = os.environ.get("LITELLM_REASONING_AUTO_SUMMARY")
try:
# Test 1: Default behavior (flag=False, no env var) - NO summary
litellm.reasoning_auto_summary = False
if "LITELLM_REASONING_AUTO_SUMMARY" in os.environ:
del os.environ["LITELLM_REASONING_AUTO_SUMMARY"]
for effort in effort_levels:
result = handler._map_reasoning_effort(effort)
assert result is not None, f"Result should not be None for effort={effort}"
assert result["effort"] == effort, f"Effort should be {effort}"
assert "summary" not in result, f"Summary should NOT be present by default for effort={effort}"
print(f"✓ reasoning_effort='{effort}' correctly maps to effort='{effort}' (no summary by default)")
# Test 2: With flag enabled - summary IS added
litellm.reasoning_auto_summary = True
for effort in effort_levels:
result = handler._map_reasoning_effort(effort)
assert result is not None, f"Result should not be None for effort={effort}"
assert result["effort"] == effort, f"Effort should be {effort}"
assert result["summary"] == "detailed", f"Summary should be 'detailed' when flag is enabled for effort={effort}"
print(f"✓ reasoning_effort='{effort}' correctly maps to effort='{effort}', summary='detailed' (flag enabled)")
assert result["summary"] == "detailed", (
f"Summary should be 'detailed' when flag is enabled for effort={effort}"
)
print(
f"✓ reasoning_effort='{effort}' correctly maps to effort='{effort}', summary='detailed' (flag enabled)"
)
# Test 3: With env var enabled (flag disabled) - summary IS added
litellm.reasoning_auto_summary = False
os.environ["LITELLM_REASONING_AUTO_SUMMARY"] = "true"
result = handler._map_reasoning_effort("high")
assert result["summary"] == "detailed", "Summary should be 'detailed' when env var is enabled"
print("✓ LITELLM_REASONING_AUTO_SUMMARY env var works correctly")
# Test 4: Dict input is passed through as-is (no modification)
litellm.reasoning_auto_summary = False
if "LITELLM_REASONING_AUTO_SUMMARY" in os.environ:
del os.environ["LITELLM_REASONING_AUTO_SUMMARY"]
dict_input = {"effort": "high", "summary": "custom_summary"}
result_dict = handler._map_reasoning_effort(dict_input)
assert result_dict["effort"] == "high"
assert result_dict["summary"] == "custom_summary"
print("✓ Dict input is passed through without modification")
# Test 5: None/unknown values return None
result_unknown = handler._map_reasoning_effort("unknown_value")
assert result_unknown is None
print("✓ Unknown reasoning_effort values return None")
print("✓ All reasoning_effort behaviors work correctly with flag/env var control")
finally:
# Restore original values
litellm.reasoning_auto_summary = original_flag
@ -1100,10 +1144,10 @@ def test_map_reasoning_effort_adds_summary_detailed():
def test_transform_response_preserves_annotations():
"""
Test that annotations from Responses API are preserved when transforming to Chat Completions format.
This is a regression test for the bug where annotations (like url_citation) were being
dropped during the transformation from ResponsesAPIResponse to ModelResponse.
The fix ensures annotations are extracted from ResponseOutputText content items and
passed through to the Message object in the Chat Completions response.
"""
@ -1162,13 +1206,9 @@ def test_transform_response_preserves_annotations():
# Create usage information
usage = ResponseAPIUsage(
input_tokens=10,
input_tokens_details=InputTokensDetails(
audio_tokens=None, cached_tokens=0, text_tokens=None
),
input_tokens_details=InputTokensDetails(audio_tokens=None, cached_tokens=0, text_tokens=None),
output_tokens=20,
output_tokens_details=OutputTokensDetails(
reasoning_tokens=0, text_tokens=None
),
output_tokens_details=OutputTokensDetails(reasoning_tokens=0, text_tokens=None),
total_tokens=30,
cost=None,
)

View file

@ -0,0 +1,525 @@
"""
Tests for file deletion blocking when referenced by non-terminal batches.
This tests the feature where file deletion is blocked when:
1. File is referenced by a batch in non-terminal state (validating, in_progress, finalizing)
2. Batch polling is configured (proxy_batch_polling_interval > 0)
This ensures cost tracking is not disrupted by premature file deletion.
"""
import base64
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from litellm.proxy._types import UserAPIKeyAuth
def _make_unified_file_id(file_id: str = "file-abc123") -> str:
"""Create a base64-encoded unified file ID."""
raw = f"litellm_proxy:application/json;unified_id,test-{file_id};target_model_names,azure-gpt-4;llm_output_file_id,{file_id};llm_output_file_model_id,model-123"
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
def _make_unified_batch_id(batch_id: str = "batch-123") -> str:
"""Create a base64-encoded unified batch ID."""
raw = f"litellm_proxy;model_id:model-deploy-xyz;llm_batch_id:{batch_id};llm_output_file_id:file-output"
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
def _make_user_api_key_dict(user_id: str = "user-A") -> UserAPIKeyAuth:
return UserAPIKeyAuth(
api_key="sk-test",
user_id=user_id,
parent_otel_span=None,
)
def _make_batch_db_record(
unified_object_id: str,
status: str,
file_object: dict,
created_by: str = "user-A",
):
"""Create a mock batch database record."""
mock_batch = MagicMock()
mock_batch.unified_object_id = unified_object_id
mock_batch.status = status
mock_batch.file_object = json.dumps(file_object)
mock_batch.created_by = created_by
mock_batch.created_at = 1700000000
return mock_batch
def _make_managed_files_instance_with_batches(
file_id: str,
batches: list,
file_created_by: str = "user-A",
):
"""
Create a _PROXY_LiteLLMManagedFiles instance with mocked DB and batches.
Args:
file_id: The unified file ID
batches: List of batch records to return from DB
file_created_by: The user who created the file
"""
from litellm_enterprise.proxy.hooks.managed_files import (
_PROXY_LiteLLMManagedFiles,
)
# Mock file record
mock_file_record = MagicMock()
mock_file_record.unified_file_id = file_id
mock_file_record.created_by = file_created_by
mock_file_record.model_mappings = {"model-123": "provider-file-abc"}
# Mock prisma
mock_prisma = MagicMock()
# Mock file table queries
mock_prisma.db.litellm_managedfiletable.find_first = AsyncMock(
return_value=mock_file_record
)
mock_prisma.db.litellm_managedfiletable.delete = AsyncMock(
return_value=mock_file_record
)
# Mock batch/object table queries
mock_prisma.db.litellm_managedobjecttable.find_many = AsyncMock(
return_value=batches
)
# Mock cache
mock_cache = MagicMock()
mock_cache.async_get_cache = AsyncMock(return_value={
"unified_file_id": file_id,
"model_mappings": {"model-123": "provider-file-abc"},
"flat_model_file_ids": ["provider-file-abc"],
})
mock_cache.async_set_cache = AsyncMock()
instance = _PROXY_LiteLLMManagedFiles(
internal_usage_cache=mock_cache,
prisma_client=mock_prisma,
)
return instance
# --- Test: Batch polling configuration check ---
def test_is_batch_polling_enabled_when_job_registered():
"""Test that batch polling is detected as enabled when scheduler job is registered."""
from litellm_enterprise.proxy.hooks.managed_files import (
_PROXY_LiteLLMManagedFiles,
)
instance = _PROXY_LiteLLMManagedFiles(
internal_usage_cache=MagicMock(),
prisma_client=MagicMock(),
)
# Mock scheduler with registered job
mock_scheduler = MagicMock()
mock_job = MagicMock()
mock_scheduler.get_job.return_value = mock_job
with patch("litellm.proxy.proxy_server.scheduler", mock_scheduler):
assert instance._is_batch_polling_enabled() is True
def test_is_batch_polling_disabled_when_job_not_registered():
"""Test that batch polling is detected as disabled when scheduler job is not registered."""
from litellm_enterprise.proxy.hooks.managed_files import (
_PROXY_LiteLLMManagedFiles,
)
instance = _PROXY_LiteLLMManagedFiles(
internal_usage_cache=MagicMock(),
prisma_client=MagicMock(),
)
# Mock scheduler without registered job
mock_scheduler = MagicMock()
mock_scheduler.get_job.return_value = None
with patch("litellm.proxy.proxy_server.scheduler", mock_scheduler):
assert instance._is_batch_polling_enabled() is False
def test_is_batch_polling_disabled_when_no_scheduler():
"""Test that batch polling is detected as disabled when scheduler is not available."""
from litellm_enterprise.proxy.hooks.managed_files import (
_PROXY_LiteLLMManagedFiles,
)
instance = _PROXY_LiteLLMManagedFiles(
internal_usage_cache=MagicMock(),
prisma_client=MagicMock(),
)
with patch("litellm.proxy.proxy_server.scheduler", None):
assert instance._is_batch_polling_enabled() is False
# --- Test: Finding batches referencing files ---
@pytest.mark.asyncio
async def test_get_batches_referencing_file_finds_batch_with_input_file():
"""Test finding a batch that references the file as input_file_id."""
unified_file_id = _make_unified_file_id("file-input-123")
unified_batch_id = _make_unified_batch_id("batch-123")
batch_file_object = {
"id": "batch-123",
"input_file_id": unified_file_id, # Batch references this file
"status": "validating",
}
batch_record = _make_batch_db_record(
unified_object_id=unified_batch_id,
status="validating",
file_object=batch_file_object,
)
managed_files = _make_managed_files_instance_with_batches(
file_id=unified_file_id,
batches=[batch_record],
)
referencing_batches = await managed_files._get_batches_referencing_file(unified_file_id)
assert len(referencing_batches) == 1
assert referencing_batches[0]["batch_id"] == unified_batch_id
assert referencing_batches[0]["status"] == "validating"
@pytest.mark.asyncio
async def test_get_batches_referencing_file_finds_batch_with_output_file():
"""Test finding a batch that references the file as output_file_id."""
unified_file_id = _make_unified_file_id("file-output-456")
unified_batch_id = _make_unified_batch_id("batch-456")
batch_file_object = {
"id": "batch-456",
"input_file_id": "file-input-different",
"output_file_id": unified_file_id, # Batch references this file
"status": "in_progress",
}
batch_record = _make_batch_db_record(
unified_object_id=unified_batch_id,
status="in_progress",
file_object=batch_file_object,
)
managed_files = _make_managed_files_instance_with_batches(
file_id=unified_file_id,
batches=[batch_record],
)
referencing_batches = await managed_files._get_batches_referencing_file(unified_file_id)
assert len(referencing_batches) == 1
assert referencing_batches[0]["status"] == "in_progress"
@pytest.mark.asyncio
async def test_get_batches_referencing_file_ignores_terminal_batches():
"""Test that batches in terminal states are not returned."""
unified_file_id = _make_unified_file_id("file-123")
unified_batch_id = _make_unified_batch_id("batch-completed")
batch_file_object = {
"id": "batch-completed",
"input_file_id": unified_file_id,
"status": "completed",
}
# Batch is in terminal state in DB
batch_record = _make_batch_db_record(
unified_object_id=unified_batch_id,
status="completed", # Terminal state
file_object=batch_file_object,
)
managed_files = _make_managed_files_instance_with_batches(
file_id=unified_file_id,
batches=[], # Query returns no batches (terminal states filtered out)
)
referencing_batches = await managed_files._get_batches_referencing_file(unified_file_id)
assert len(referencing_batches) == 0
@pytest.mark.asyncio
async def test_get_batches_referencing_file_finds_multiple_batches():
"""Test finding multiple batches referencing the same file."""
unified_file_id = _make_unified_file_id("file-shared")
batch1 = _make_batch_db_record(
unified_object_id=_make_unified_batch_id("batch-1"),
status="validating",
file_object={"id": "batch-1", "input_file_id": unified_file_id, "status": "validating"},
)
batch2 = _make_batch_db_record(
unified_object_id=_make_unified_batch_id("batch-2"),
status="in_progress",
file_object={"id": "batch-2", "input_file_id": unified_file_id, "status": "in_progress"},
)
managed_files = _make_managed_files_instance_with_batches(
file_id=unified_file_id,
batches=[batch1, batch2],
)
referencing_batches = await managed_files._get_batches_referencing_file(unified_file_id)
assert len(referencing_batches) == 2
statuses = [b["status"] for b in referencing_batches]
assert "validating" in statuses
assert "in_progress" in statuses
# --- Test: File deletion blocking logic ---
@pytest.mark.asyncio
async def test_file_deletion_blocked_when_batch_polling_enabled_and_batch_references_file():
"""
Test that file deletion is blocked when:
1. Batch cost tracking job is registered (polling enabled)
2. File is referenced by a non-terminal batch
"""
unified_file_id = _make_unified_file_id("file-to-delete")
unified_batch_id = _make_unified_batch_id("batch-active")
batch_file_object = {
"id": "batch-active",
"input_file_id": unified_file_id,
"status": "validating",
}
batch_record = _make_batch_db_record(
unified_object_id=unified_batch_id,
status="validating",
file_object=batch_file_object,
)
managed_files = _make_managed_files_instance_with_batches(
file_id=unified_file_id,
batches=[batch_record],
)
# Mock scheduler with registered batch cost job
mock_scheduler = MagicMock()
mock_scheduler.get_job.return_value = MagicMock() # Job exists
with patch("litellm.proxy.proxy_server.scheduler", mock_scheduler):
with pytest.raises(HTTPException) as exc_info:
await managed_files._check_file_deletion_allowed(unified_file_id)
assert exc_info.value.status_code == 400
error_detail = exc_info.value.detail
assert "Cannot delete file" in error_detail
assert unified_file_id in error_detail
assert "validating" in error_detail
assert "delete or cancel the referencing batch" in error_detail.lower()
@pytest.mark.asyncio
async def test_file_deletion_allowed_when_batch_polling_disabled():
"""
Test that file deletion is allowed when batch cost tracking job is not registered,
even if there are non-terminal batches referencing the file.
"""
unified_file_id = _make_unified_file_id("file-to-delete")
unified_batch_id = _make_unified_batch_id("batch-active")
batch_file_object = {
"id": "batch-active",
"input_file_id": unified_file_id,
"status": "validating",
}
batch_record = _make_batch_db_record(
unified_object_id=unified_batch_id,
status="validating",
file_object=batch_file_object,
)
managed_files = _make_managed_files_instance_with_batches(
file_id=unified_file_id,
batches=[batch_record],
)
# Mock scheduler without registered job (batch cost tracking disabled)
mock_scheduler = MagicMock()
mock_scheduler.get_job.return_value = None
with patch("litellm.proxy.proxy_server.scheduler", mock_scheduler):
# Should not raise an exception
await managed_files._check_file_deletion_allowed(unified_file_id)
@pytest.mark.asyncio
async def test_file_deletion_allowed_when_no_batches_reference_file():
"""
Test that file deletion is allowed when no batches reference the file,
even when batch cost tracking is enabled.
"""
unified_file_id = _make_unified_file_id("file-to-delete")
managed_files = _make_managed_files_instance_with_batches(
file_id=unified_file_id,
batches=[], # No batches reference this file
)
# Mock scheduler with registered job (batch cost tracking enabled)
mock_scheduler = MagicMock()
mock_scheduler.get_job.return_value = MagicMock()
with patch("litellm.proxy.proxy_server.scheduler", mock_scheduler):
# Should not raise an exception
await managed_files._check_file_deletion_allowed(unified_file_id)
@pytest.mark.asyncio
async def test_afile_delete_calls_check_deletion_allowed():
"""
Test that afile_delete calls _check_file_deletion_allowed before deleting.
"""
unified_file_id = _make_unified_file_id("file-to-delete")
unified_batch_id = _make_unified_batch_id("batch-active")
batch_file_object = {
"id": "batch-active",
"input_file_id": unified_file_id,
"status": "in_progress",
}
batch_record = _make_batch_db_record(
unified_object_id=unified_batch_id,
status="in_progress",
file_object=batch_file_object,
)
managed_files = _make_managed_files_instance_with_batches(
file_id=unified_file_id,
batches=[batch_record],
)
# Mock llm_router
mock_router = MagicMock()
mock_router.afile_delete = AsyncMock()
# Mock scheduler with registered job
mock_scheduler = MagicMock()
mock_scheduler.get_job.return_value = MagicMock()
with patch("litellm.proxy.proxy_server.scheduler", mock_scheduler):
with pytest.raises(HTTPException) as exc_info:
await managed_files.afile_delete(
file_id=unified_file_id,
litellm_parent_otel_span=None,
llm_router=mock_router,
)
# Should raise error before calling router delete
assert exc_info.value.status_code == 400
mock_router.afile_delete.assert_not_called()
@pytest.mark.asyncio
async def test_database_limit_respected():
"""
Test that we only fetch 10 batches from DB (not 500).
This is a performance optimization - we only fetch what we need.
"""
unified_file_id = _make_unified_file_id("file-shared")
# Create exactly 10 batches (what DB will return with take=10)
ten_batches = []
for i in range(10):
batch = _make_batch_db_record(
unified_object_id=_make_unified_batch_id(f"batch-{i}"),
status="validating",
file_object={
"id": f"batch-{i}",
"input_file_id": unified_file_id,
"status": "validating"
},
)
ten_batches.append(batch)
# Mock will return only 10 batches (as DB would with take=10)
managed_files = _make_managed_files_instance_with_batches(
file_id=unified_file_id,
batches=ten_batches,
)
referencing_batches = await managed_files._get_batches_referencing_file(unified_file_id)
# Should return all 10 that reference the file
assert len(referencing_batches) == 10
# Verify error message handles "10+" case (since we got exactly 10, might be more in DB)
mock_scheduler = MagicMock()
mock_scheduler.get_job.return_value = MagicMock()
with patch("litellm.proxy.proxy_server.scheduler", mock_scheduler):
with pytest.raises(HTTPException) as exc_info:
await managed_files._check_file_deletion_allowed(unified_file_id)
error_detail = exc_info.value.detail
# When we get exactly 10 matches, show "10+" to indicate there might be more
assert "10+ batch(es)" in error_detail
@pytest.mark.asyncio
async def test_error_message_includes_batch_details():
"""
Test that the error message includes helpful information about the blocking batches.
"""
unified_file_id = _make_unified_file_id("file-to-delete")
batch1_id = _make_unified_batch_id("batch-1")
batch2_id = _make_unified_batch_id("batch-2")
batch1 = _make_batch_db_record(
unified_object_id=batch1_id,
status="validating",
file_object={"id": "batch-1", "input_file_id": unified_file_id, "status": "validating"},
)
batch2 = _make_batch_db_record(
unified_object_id=batch2_id,
status="in_progress",
file_object={"id": "batch-2", "output_file_id": unified_file_id, "status": "in_progress"},
)
managed_files = _make_managed_files_instance_with_batches(
file_id=unified_file_id,
batches=[batch1, batch2],
)
# Mock scheduler with registered job
mock_scheduler = MagicMock()
mock_scheduler.get_job.return_value = MagicMock()
with patch("litellm.proxy.proxy_server.scheduler", mock_scheduler):
with pytest.raises(HTTPException) as exc_info:
await managed_files._check_file_deletion_allowed(unified_file_id)
error_detail = exc_info.value.detail
assert "2 batch(es)" in error_detail
assert "validating" in error_detail
assert "in_progress" in error_detail
assert "complete cost tracking" in error_detail.lower()
assert "delete or cancel the referencing batch" in error_detail.lower()

View file

@ -0,0 +1,91 @@
import os
import sys
from unittest.mock import patch
import pytest
sys.path.insert(0, os.path.abspath("../../../"))
from litellm.integrations.datadog.datadog_handler import get_datadog_tags
from litellm.integrations.datadog.datadog_cost_management import (
DatadogCostManagementLogger,
)
from litellm.types.utils import StandardLoggingPayload, StandardLoggingMetadata
class TestDatadogTagsRegression:
@pytest.fixture
def mock_env_vars(self):
"""Mock environment variables to isolate environment."""
with patch.dict(
os.environ,
{
"DD_ENV": "test-env",
"DD_SERVICE": "test-service",
"DD_VERSION": "1.0.0",
"HOSTNAME": "test-host",
"POD_NAME": "test-pod",
"DD_API_KEY": "mock-api-key",
"DD_APP_KEY": "mock-app-key",
},
):
yield
def test_get_datadog_tags_regression(self, mock_env_vars):
"""
Regression Test: Ensure that get_datadog_tags still produces basic tags correctly
AND now includes the new team tag when provided.
"""
# Case 1: Legacy behavior (no team info)
payload_legacy = StandardLoggingPayload(metadata={})
tags_legacy = get_datadog_tags(payload_legacy)
# Verify base tags exist (legacy requirement)
assert "env:test-env" in tags_legacy
assert "service:test-service" in tags_legacy
# Verify NO team tag (should not invent one)
assert "team:" not in tags_legacy
# Case 2: New feature (team info provided)
payload_with_team = StandardLoggingPayload(
metadata=StandardLoggingMetadata(user_api_key_team_alias="regression-team")
)
tags_with_team = get_datadog_tags(payload_with_team)
# Verify base tags STILL exist
assert "env:test-env" in tags_with_team
assert "service:test-service" in tags_with_team
# Verify NEW team tag is added
assert "team:regression-team" in tags_with_team
@pytest.mark.asyncio
async def test_datadog_cost_management_tags_regression(self, mock_env_vars):
"""
Regression Test: Ensure DatadogCostManagementLogger extracts tags correctly,
preserving existing behavior while adding the team tag capability.
"""
logger = DatadogCostManagementLogger()
# Case 1: Legacy metadata (user alias only)
payload_legacy = StandardLoggingPayload(
metadata=StandardLoggingMetadata(user_api_key_alias="legacy-user")
)
tags_legacy = logger._extract_tags(payload_legacy)
assert tags_legacy["env"] == "test-env"
assert tags_legacy["user"] == "legacy-user"
assert "team" not in tags_legacy # Should not exist
# Case 2: New metadata (team alias)
payload_new = StandardLoggingPayload(
metadata=StandardLoggingMetadata(
user_api_key_alias="new-user", user_api_key_team_alias="new-team-alias"
)
)
tags_new = logger._extract_tags(payload_new)
assert tags_new["env"] == "test-env"
assert tags_new["user"] == "new-user"
assert tags_new["team"] == "new-team-alias" # New feature verified

View file

@ -1,10 +1,12 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from unittest.mock import MagicMock, patch
from litellm.integrations.prometheus import PrometheusLogger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.integrations.prometheus import (
UserAPIKeyLabelValues,
)
from litellm.proxy._types import UserAPIKeyAuth
@pytest.mark.asyncio
@ -72,10 +74,12 @@ async def test_async_post_call_failure_hook_includes_client_ip_user_agent():
@pytest.mark.asyncio
async def test_async_post_call_success_hook_includes_client_ip_user_agent():
"""
Test that async_post_call_success_hook includes client_ip and user_agent in UserAPIKeyLabelValues
Test that async_log_success_event includes client_ip and user_agent in UserAPIKeyLabelValues.
Note: After PR #21159, the metric increment was moved from async_post_call_success_hook
to async_log_success_event to prevent double-counting.
"""
# Mocking
# Mocking
with patch(
"litellm.integrations.prometheus.PrometheusLogger.__init__", return_value=None
):
@ -84,16 +88,43 @@ async def test_async_post_call_success_hook_includes_client_ip_user_agent():
logger.get_labels_for_metric = MagicMock(
return_value=["client_ip", "user_agent"]
)
logger._should_skip_metrics_for_invalid_key = MagicMock(return_value=False)
logger._increment_top_level_request_and_spend_metrics = MagicMock()
logger._increment_token_metrics = MagicMock()
logger._increment_remaining_budget_metrics = AsyncMock()
logger._set_virtual_key_rate_limit_metrics = MagicMock()
logger._set_latency_metrics = MagicMock()
logger.set_llm_deployment_success_metrics = MagicMock()
logger._increment_cache_metrics = MagicMock()
data = {
kwargs = {
"model": "gpt-4",
"metadata": {
"requester_ip_address": "192.168.1.1",
"user_agent": "success-agent",
"litellm_params": {
"metadata": {}
},
"start_time": None,
"standard_logging_object": {
"model_group": "gpt-4",
"model_id": "model_1",
"api_base": "http://api.base",
"custom_llm_provider": "openai",
"completion_tokens": 10,
"total_tokens": 20,
"response_cost": 0.01,
"request_tags": [],
"metadata": {
"user_api_key_user_id": "user_1",
"user_api_key_hash": "hash_1",
"user_api_key_alias": "alias_1",
"user_api_key_team_id": "team_1",
"user_api_key_team_alias": "team_alias_1",
"user_api_key_user_email": "test@example.com",
"user_api_key_request_route": "/chat/completions",
"requester_ip_address": "192.168.1.1",
"user_agent": "success-agent",
},
},
}
user_api_key_dict = UserAPIKeyAuth(token="test_token")
response = MagicMock()
# Mock prometheus_label_factory to inspect arguments
with patch(
@ -101,10 +132,11 @@ async def test_async_post_call_success_hook_includes_client_ip_user_agent():
) as mock_label_factory:
mock_label_factory.return_value = {}
await logger.async_post_call_success_hook(
data=data,
user_api_key_dict=user_api_key_dict,
response=response,
await logger.async_log_success_event(
kwargs=kwargs,
response_obj=None,
start_time=None,
end_time=None,
)
# Verification
@ -114,8 +146,8 @@ async def test_async_post_call_success_hook_includes_client_ip_user_agent():
calls = mock_label_factory.call_args_list
found = False
for call in calls:
kwargs = call.kwargs
enum_values = kwargs.get("enum_values")
kwargs_args = call.kwargs
enum_values = kwargs_args.get("enum_values")
if isinstance(enum_values, UserAPIKeyLabelValues):
if (
enum_values.client_ip == "192.168.1.1"

View file

@ -0,0 +1,176 @@
"""
Unit tests for Prometheus handling of None metadata in litellm_params.
When the Responses API sends streaming requests, litellm_params.metadata
can be None, causing AttributeError: 'NoneType' object has no attribute 'get'
in set_llm_deployment_success_metrics.
"""
import os
import sys
from datetime import datetime
import pytest
from prometheus_client import REGISTRY
sys.path.insert(0, os.path.abspath("../../.."))
from litellm.integrations.prometheus import PrometheusLogger
from litellm.types.integrations.prometheus import UserAPIKeyLabelValues
@pytest.fixture(scope="function")
def prometheus_logger():
"""Create a PrometheusLogger instance for testing."""
collectors = list(REGISTRY._collector_to_names.keys())
for collector in collectors:
REGISTRY.unregister(collector)
return PrometheusLogger()
class TestNoneMetadataHandling:
"""
Test that Prometheus metrics don't crash when metadata is None.
This targets the bug where Responses API streaming sets
litellm_params["metadata"] = None, causing:
_metadata.get("model_info") -> AttributeError
"""
def test_set_llm_deployment_success_metrics_with_none_metadata(
self, prometheus_logger
):
"""
set_llm_deployment_success_metrics should not raise when
litellm_params.metadata is None.
"""
request_kwargs = {
"litellm_params": {
"metadata": None, # Bug trigger
"custom_llm_provider": "openai",
},
"model": "gpt-4o",
"standard_logging_object": {
"api_base": "https://api.openai.com",
"hidden_params": {
"additional_headers": None,
"litellm_overhead_time_ms": None,
},
"metadata": {
"user_api_key_hash": "test-key",
"user_api_key_alias": None,
"user_api_key_team_id": None,
"user_api_key_team_alias": None,
},
"model": "gpt-4o",
"response_cost": 0.001,
},
}
enum_values = UserAPIKeyLabelValues(
end_user=None,
hashed_api_key="test-key",
api_key_alias=None,
team=None,
team_alias=None,
requested_model="gpt-4o",
)
# Should not raise AttributeError
prometheus_logger.set_llm_deployment_success_metrics(
request_kwargs=request_kwargs,
start_time=datetime.now(),
end_time=datetime.now(),
enum_values=enum_values,
output_tokens=10.0,
)
def test_set_llm_deployment_success_metrics_with_missing_litellm_params(
self, prometheus_logger
):
"""
set_llm_deployment_success_metrics should not raise when
litellm_params is missing entirely.
"""
request_kwargs = {
"model": "gpt-4o",
"standard_logging_object": {
"api_base": "https://api.openai.com",
"hidden_params": {
"additional_headers": None,
"litellm_overhead_time_ms": None,
},
"metadata": {
"user_api_key_hash": "test-key",
"user_api_key_alias": None,
"user_api_key_team_id": None,
"user_api_key_team_alias": None,
},
"model": "gpt-4o",
"response_cost": 0.001,
},
}
enum_values = UserAPIKeyLabelValues(
end_user=None,
hashed_api_key="test-key",
api_key_alias=None,
team=None,
team_alias=None,
requested_model="gpt-4o",
)
# Should not raise
prometheus_logger.set_llm_deployment_success_metrics(
request_kwargs=request_kwargs,
start_time=datetime.now(),
end_time=datetime.now(),
enum_values=enum_values,
output_tokens=10.0,
)
def test_set_llm_deployment_success_metrics_with_litellm_metadata_key(
self, prometheus_logger
):
"""
set_llm_deployment_success_metrics should pick up litellm_metadata
when metadata is None, using get_litellm_metadata_from_kwargs.
"""
request_kwargs = {
"litellm_params": {
"metadata": None,
"litellm_metadata": {"model_info": {"id": "test-model-id"}},
"custom_llm_provider": "openai",
},
"model": "gpt-4o",
"standard_logging_object": {
"api_base": "https://api.openai.com",
"hidden_params": {
"additional_headers": None,
"litellm_overhead_time_ms": None,
},
"metadata": {
"user_api_key_hash": "test-key",
"user_api_key_alias": None,
"user_api_key_team_id": None,
"user_api_key_team_alias": None,
},
"model": "gpt-4o",
"response_cost": 0.001,
},
}
enum_values = UserAPIKeyLabelValues(
end_user=None,
hashed_api_key="test-key",
api_key_alias=None,
team=None,
team_alias=None,
requested_model="gpt-4o",
)
# Should not raise, and should pick up litellm_metadata
prometheus_logger.set_llm_deployment_success_metrics(
request_kwargs=request_kwargs,
start_time=datetime.now(),
end_time=datetime.now(),
enum_values=enum_values,
output_tokens=10.0,
)

View file

@ -1811,3 +1811,131 @@ def test_translate_openai_response_to_anthropic_input_tokens_no_cache():
# Validate: input_tokens should equal prompt_tokens when no caching
assert anthropic_response["usage"]["input_tokens"] == 100
assert anthropic_response["usage"]["output_tokens"] == 50
# =====================================================================
# Web Search Tool Transformation Tests
# =====================================================================
def test_is_web_search_tool():
"""Test detection of Anthropic web search tools."""
adapter = LiteLLMAnthropicMessagesAdapter()
# Tool with type starting with "web_search" should be detected
web_search_tool_with_type = {
"type": "web_search_20260209",
"name": "web_search",
}
assert adapter._is_web_search_tool(web_search_tool_with_type) is True
# Tool with name "web_search" should be detected
web_search_tool_with_name = {
"name": "web_search",
}
assert adapter._is_web_search_tool(web_search_tool_with_name) is True
# Regular function tool should not be detected
regular_tool = {
"name": "get_weather",
"description": "Get weather info",
"input_schema": {"type": "object"},
}
assert adapter._is_web_search_tool(regular_tool) is False
def test_translate_anthropic_to_openai_with_web_search_tool():
"""
Test that Anthropic web search tools are converted to web_search_options parameter.
When a user sends an Anthropic /v1/messages request with {"type": "web_search_20260209"}
tool, it should be transformed to OpenAI format with web_search_options: {} parameter.
"""
from litellm.types.llms.anthropic import AnthropicMessagesRequest
anthropic_request = AnthropicMessagesRequest(
model="gemini-2.5-flash-lite",
max_tokens=4096,
messages=[
{
"role": "user",
"content": "Search for the current prices of AAPL and GOOGL",
}
],
tools=[
{
"type": "web_search_20260209",
"name": "web_search",
}
],
)
adapter = LiteLLMAnthropicMessagesAdapter()
openai_request, tool_name_mapping = adapter.translate_anthropic_to_openai(
anthropic_message_request=anthropic_request
)
# web_search_options should be added
assert "web_search_options" in openai_request
assert openai_request["web_search_options"] == {}
# web search tool should NOT be in the tools array
assert "tools" not in openai_request or openai_request.get("tools") == []
# tool_name_mapping should be empty since no regular tools were present
assert tool_name_mapping == {}
def test_translate_anthropic_to_openai_with_mixed_tools():
"""
Test that web search tools are separated from regular tools.
When a request has both web search tools and regular function tools,
only the regular tools should be in the tools array, and web_search_options
should be added.
"""
from litellm.types.llms.anthropic import AnthropicMessagesRequest
anthropic_request = AnthropicMessagesRequest(
model="gemini-2.5-flash-lite",
max_tokens=4096,
messages=[
{
"role": "user",
"content": "Get weather and search the web",
}
],
tools=[
{
"type": "web_search_20260209",
"name": "web_search",
},
{
"name": "get_weather",
"description": "Get weather information",
"input_schema": {
"type": "object",
"properties": {
"location": {"type": "string"}
},
},
},
],
)
adapter = LiteLLMAnthropicMessagesAdapter()
openai_request, tool_name_mapping = adapter.translate_anthropic_to_openai(
anthropic_message_request=anthropic_request
)
# web_search_options should be added
assert "web_search_options" in openai_request
assert openai_request["web_search_options"] == {}
# Only get_weather tool should be in the tools array
assert "tools" in openai_request
assert len(openai_request["tools"]) == 1
assert openai_request["tools"][0]["function"]["name"] == "get_weather"
# tool_name_mapping should be empty for short tool names
assert tool_name_mapping == {}

View file

@ -283,3 +283,142 @@ class TestPassthroughOAuth:
assert updated_headers["x-api-key"] == FAKE_REGULAR_KEY
assert "authorization" not in updated_headers
class TestIsAnthropicOAuthKey:
"""Tests for is_anthropic_oauth_key helper function."""
def test_oauth_token_raw(self):
"""Raw OAuth token should be detected."""
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
assert is_anthropic_oauth_key("sk-ant-oat01-abc123") is True
assert is_anthropic_oauth_key("sk-ant-oat02-xyz789") is True
def test_oauth_token_bearer_format(self):
"""Bearer-prefixed OAuth token should be detected."""
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
assert is_anthropic_oauth_key("Bearer sk-ant-oat01-abc123") is True
assert is_anthropic_oauth_key("Bearer sk-ant-oat02-xyz789") is True
def test_non_oauth_tokens(self):
"""Non-OAuth values should return False."""
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
assert is_anthropic_oauth_key(None) is False
assert is_anthropic_oauth_key("") is False
assert is_anthropic_oauth_key("sk-ant-api01-abc123") is False
assert is_anthropic_oauth_key("Bearer sk-ant-api01-abc123") is False
def test_case_sensitivity(self):
"""OAuth prefix matching should be case-sensitive."""
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
assert is_anthropic_oauth_key("sk-ant-OAT01-abc123") is False
assert is_anthropic_oauth_key("SK-ANT-OAT01-abc123") is False
def test_just_prefix(self):
"""Just the prefix with no suffix should still match."""
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
assert is_anthropic_oauth_key("sk-ant-oat") is True
class TestProxyOAuthHeaderForwarding:
"""Tests for proxy-layer OAuth header preservation and forwarding."""
def test_clean_headers_preserves_oauth_authorization(self):
"""clean_headers should preserve Authorization header with OAuth tokens."""
from starlette.datastructures import Headers
from litellm.proxy.litellm_pre_call_utils import clean_headers
raw_headers = Headers(
raw=[
(b"authorization", f"Bearer {FAKE_OAUTH_TOKEN}".encode()),
(b"content-type", b"application/json"),
]
)
cleaned = clean_headers(raw_headers)
assert "authorization" in cleaned
assert cleaned["authorization"] == f"Bearer {FAKE_OAUTH_TOKEN}"
assert cleaned["content-type"] == "application/json"
def test_clean_headers_strips_non_oauth_authorization(self):
"""clean_headers should strip Authorization header with regular API keys."""
from starlette.datastructures import Headers
from litellm.proxy.litellm_pre_call_utils import clean_headers
raw_headers = Headers(
raw=[
(b"authorization", b"Bearer sk-regular-key-123"),
(b"content-type", b"application/json"),
]
)
cleaned = clean_headers(raw_headers)
assert "authorization" not in cleaned
assert cleaned["content-type"] == "application/json"
def test_add_provider_specific_headers_forwards_oauth(self):
"""add_provider_specific_headers_to_request should forward OAuth Authorization
as a ProviderSpecificHeader scoped to Anthropic-compatible providers."""
from litellm.proxy.litellm_pre_call_utils import (
add_provider_specific_headers_to_request,
)
data: dict = {}
headers = {
"authorization": f"Bearer {FAKE_OAUTH_TOKEN}",
"content-type": "application/json",
}
add_provider_specific_headers_to_request(data=data, headers=headers)
assert "provider_specific_header" in data
psh = data["provider_specific_header"]
assert "anthropic" in psh["custom_llm_provider"]
assert "bedrock" in psh["custom_llm_provider"]
assert "vertex_ai" in psh["custom_llm_provider"]
assert psh["extra_headers"]["authorization"] == f"Bearer {FAKE_OAUTH_TOKEN}"
def test_add_provider_specific_headers_ignores_non_oauth(self):
"""add_provider_specific_headers_to_request should not create a
ProviderSpecificHeader for non-OAuth Authorization headers."""
from litellm.proxy.litellm_pre_call_utils import (
add_provider_specific_headers_to_request,
)
data: dict = {}
headers = {
"authorization": "Bearer sk-regular-key-123",
"content-type": "application/json",
}
add_provider_specific_headers_to_request(data=data, headers=headers)
assert "provider_specific_header" not in data
def test_add_provider_specific_headers_combines_anthropic_and_oauth(self):
"""When both anthropic-beta and OAuth Authorization are present, both
should be included in the ProviderSpecificHeader."""
from litellm.proxy.litellm_pre_call_utils import (
add_provider_specific_headers_to_request,
)
data: dict = {}
headers = {
"authorization": f"Bearer {FAKE_OAUTH_TOKEN}",
"anthropic-beta": "oauth-2025-04-20",
"content-type": "application/json",
}
add_provider_specific_headers_to_request(data=data, headers=headers)
assert "provider_specific_header" in data
psh = data["provider_specific_header"]
assert psh["extra_headers"]["authorization"] == f"Bearer {FAKE_OAUTH_TOKEN}"
assert psh["extra_headers"]["anthropic-beta"] == "oauth-2025-04-20"

View file

@ -0,0 +1,380 @@
"""
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_1" # Original tool result (first in tool_calls)
assert sanitized[3]["tool_call_id"] == "call_2" # Dummy added for missing call_2
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"])

View file

@ -2701,37 +2701,37 @@ def test_empty_assistant_message_handling():
assert result[1]["content"][0]["text"] == "I'm doing well, thank you!"
def test_is_nova_lite_2_model():
"""Test the _is_nova_lite_2_model() method for detecting Nova 2 models."""
def test_is_nova_2_model():
"""Test the _is_nova_2_model() method for detecting Nova 2 models."""
config = AmazonConverseConfig()
# Test with amazon.nova-2-lite-v1:0
assert config._is_nova_lite_2_model("amazon.nova-2-lite-v1:0") is True
assert config._is_nova_2_model("amazon.nova-2-lite-v1:0") is True
# Test with regional variants
assert config._is_nova_lite_2_model("us.amazon.nova-2-lite-v1:0") is True
assert config._is_nova_lite_2_model("eu.amazon.nova-2-lite-v1:0") is True
assert config._is_nova_lite_2_model("apac.amazon.nova-2-lite-v1:0") is True
assert config._is_nova_2_model("us.amazon.nova-2-lite-v1:0") is True
assert config._is_nova_2_model("eu.amazon.nova-2-lite-v1:0") is True
assert config._is_nova_2_model("apac.amazon.nova-2-lite-v1:0") is True
# Test with other Nova 2 variants (pro, micro)
assert config._is_nova_lite_2_model("amazon.nova-pro-1-5-v1:0") is False
assert config._is_nova_lite_2_model("amazon.nova-micro-1-5-v1:0") is False
assert config._is_nova_lite_2_model("us.amazon.nova-pro-1-5-v1:0") is False
assert config._is_nova_lite_2_model("eu.amazon.nova-micro-1-5-v1:0") is False
assert config._is_nova_2_model("amazon.nova-pro-1-5-v1:0") is False
assert config._is_nova_2_model("amazon.nova-micro-1-5-v1:0") is False
assert config._is_nova_2_model("us.amazon.nova-pro-1-5-v1:0") is False
assert config._is_nova_2_model("eu.amazon.nova-micro-1-5-v1:0") is False
# Test with non-Nova-1.5 lite models (should return False)
assert config._is_nova_lite_2_model("amazon.nova-lite-v1:0") is False
assert config._is_nova_lite_2_model("amazon.nova-pro-v1:0") is False
assert config._is_nova_lite_2_model("amazon.nova-micro-v1:0") is False
assert config._is_nova_2_model("amazon.nova-lite-v1:0") is False
assert config._is_nova_2_model("amazon.nova-pro-v1:0") is False
assert config._is_nova_2_model("amazon.nova-micro-v1:0") is False
# Test with Nova v1:0 models (should return False)
assert config._is_nova_lite_2_model("us.amazon.nova-lite-v1:0") is False
assert config._is_nova_lite_2_model("eu.amazon.nova-pro-v1:0") is False
assert config._is_nova_2_model("us.amazon.nova-lite-v1:0") is False
assert config._is_nova_2_model("eu.amazon.nova-pro-v1:0") is False
# Test with completely different models (should return False)
assert config._is_nova_lite_2_model("anthropic.claude-3-5-sonnet-20240620-v1:0") is False
assert config._is_nova_lite_2_model("meta.llama3-70b-instruct-v1:0") is False
assert config._is_nova_lite_2_model("mistral.mistral-7b-instruct-v0:2") is False
assert config._is_nova_2_model("anthropic.claude-3-5-sonnet-20240620-v1:0") is False
assert config._is_nova_2_model("meta.llama3-70b-instruct-v1:0") is False
assert config._is_nova_2_model("mistral.mistral-7b-instruct-v0:2") is False
def test_thinking_with_max_completion_tokens():
@ -2936,6 +2936,447 @@ def test_drop_thinking_param_when_thinking_blocks_missing():
litellm.modify_params = original_modify_params
def test_supports_native_structured_outputs():
"""Test model detection for native structured outputs support."""
config = AmazonConverseConfig()
# Supported models
assert config._supports_native_structured_outputs(
"anthropic.claude-sonnet-4-5-20250929-v1:0"
)
assert config._supports_native_structured_outputs(
"anthropic.claude-haiku-4-5-20251001-v1:0"
)
assert config._supports_native_structured_outputs(
"anthropic.claude-opus-4-6-v1:0"
)
assert config._supports_native_structured_outputs(
"eu.anthropic.claude-opus-4-5-20260101-v1:0"
)
assert config._supports_native_structured_outputs("qwen.qwen3-235b-instruct-v1:0")
assert config._supports_native_structured_outputs("mistral.mistral-large-3-v1:0")
assert config._supports_native_structured_outputs("deepseek.deepseek-v3.1-v1:0")
# Unsupported models — should fall back to tool-call approach
assert not config._supports_native_structured_outputs(
"anthropic.claude-3-5-sonnet-20241022-v2:0"
)
assert not config._supports_native_structured_outputs(
"anthropic.claude-sonnet-4-20250514-v1:0"
)
assert not config._supports_native_structured_outputs(
"meta.llama3-3-70b-instruct-v1:0"
)
assert not config._supports_native_structured_outputs(
"amazon.nova-pro-v1:0"
)
# Excluded despite AWS listing them: broken constrained decoding on Bedrock
assert not config._supports_native_structured_outputs(
"openai.gpt-oss-120b-1:0"
)
assert not config._supports_native_structured_outputs(
"mistral.magistral-small-2509"
)
def test_create_output_config_for_response_format():
"""Test outputConfig dict creation from JSON schema."""
config = AmazonConverseConfig()
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
"required": ["name", "age"],
}
output_config = config._create_output_config_for_response_format(
json_schema=schema,
name="PersonInfo",
description="A person's info",
)
assert "textFormat" in output_config
text_format = output_config["textFormat"]
assert text_format["type"] == "json_schema"
assert "structure" in text_format
json_schema_def = text_format["structure"]["jsonSchema"]
assert json_schema_def["name"] == "PersonInfo"
assert json_schema_def["description"] == "A person's info"
# schema field must be a JSON string, not a dict
assert isinstance(json_schema_def["schema"], str)
parsed_schema = json.loads(json_schema_def["schema"])
# additionalProperties: false is injected by normalization
expected = {**schema, "additionalProperties": False}
assert parsed_schema == expected
def test_translate_response_format_native_output_config():
"""For supported models, _translate_response_format_param should produce outputConfig."""
config = AmazonConverseConfig()
response_format = {
"type": "json_schema",
"json_schema": {
"name": "WeatherResult",
"description": "Weather info",
"schema": {
"type": "object",
"properties": {
"temp": {"type": "number"},
},
"required": ["temp"],
},
},
}
optional_params: dict = {}
result = config._translate_response_format_param(
value=response_format,
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
optional_params=optional_params,
non_default_params={"response_format": response_format},
is_thinking_enabled=False,
)
# Should have outputConfig, NOT tools
assert "outputConfig" in result
assert "tools" not in result
assert "tool_choice" not in result
assert result["json_mode"] is True
# No fake_stream for native approach
assert "fake_stream" not in result
# Verify the schema content (additionalProperties: false is added by normalization)
schema_str = result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"]
parsed_schema = json.loads(schema_str)
expected_schema = {**response_format["json_schema"]["schema"], "additionalProperties": False}
assert parsed_schema == expected_schema
assert (
result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["name"]
== "WeatherResult"
)
def test_translate_response_format_fallback_tool_call():
"""For unsupported models, should fall back to tool-call approach."""
config = AmazonConverseConfig()
response_format = {
"type": "json_schema",
"json_schema": {
"name": "WeatherResult",
"schema": {
"type": "object",
"properties": {
"temp": {"type": "number"},
},
},
},
}
optional_params: dict = {}
result = config._translate_response_format_param(
value=response_format,
model="anthropic.claude-3-5-sonnet-20241022-v2:0",
optional_params=optional_params,
non_default_params={"response_format": response_format},
is_thinking_enabled=False,
)
# Should use tool-call approach, NOT outputConfig
assert "outputConfig" not in result
assert "tools" in result
assert result["json_mode"] is True
def test_native_structured_output_no_fake_stream():
"""When using native structured outputs with streaming, fake_stream should NOT be set."""
config = AmazonConverseConfig()
response_format = {
"type": "json_schema",
"json_schema": {
"name": "Result",
"schema": {
"type": "object",
"properties": {
"answer": {"type": "string"},
},
},
},
}
optional_params: dict = {}
result = config._translate_response_format_param(
value=response_format,
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
optional_params=optional_params,
non_default_params={"response_format": response_format, "stream": True},
is_thinking_enabled=False,
)
assert "outputConfig" in result
assert result["json_mode"] is True
# No fake_stream for native approach
assert "fake_stream" not in result
# Verify the schema content
schema_str = result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"]
assert json.loads(schema_str) == {
"type": "object",
"properties": {"answer": {"type": "string"}},
"additionalProperties": False,
}
def test_transform_request_with_output_config():
"""Test that outputConfig flows through _transform_request_helper into the final request."""
from litellm.types.llms.bedrock import OutputConfigBlock, OutputFormat, OutputFormatStructure, JsonSchemaDefinition
config = AmazonConverseConfig()
output_config = OutputConfigBlock(
textFormat=OutputFormat(
type="json_schema",
structure=OutputFormatStructure(
jsonSchema=JsonSchemaDefinition(
schema='{"type": "object", "properties": {"x": {"type": "string"}}, "additionalProperties": false}',
name="TestSchema",
)
),
)
)
messages = [{"role": "user", "content": "test"}]
optional_params = {
"outputConfig": output_config,
"json_mode": True,
}
result = config._transform_request(
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=messages,
optional_params=optional_params,
litellm_params={},
headers={},
)
assert "outputConfig" in result
assert result["outputConfig"]["textFormat"]["type"] == "json_schema"
assert result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["name"] == "TestSchema"
def test_transform_response_native_structured_output():
"""Test response handling when model returns JSON as text content (native structured output)."""
response_json = {
"output": {
"message": {
"role": "assistant",
"content": [
{
"text": '{"temp": 62, "description": "Mild and foggy"}'
}
],
}
},
"stopReason": "end_turn",
"usage": {
"inputTokens": 10,
"outputTokens": 20,
"totalTokens": 30,
},
}
class MockResponse:
def json(self):
return response_json
@property
def text(self):
return json.dumps(response_json)
config = AmazonConverseConfig()
model_response = ModelResponse()
# json_mode=True but no tool_call in response — native structured output path
optional_params = {"json_mode": True}
result = config._transform_response(
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
response=MockResponse(),
model_response=model_response,
stream=False,
logging_obj=None,
optional_params=optional_params,
api_key=None,
data={},
messages=[],
encoding=None,
)
# Content should be the JSON text directly
assert result.choices[0].message.content == '{"temp": 62, "description": "Mild and foggy"}'
# Should NOT have tool_calls
assert result.choices[0].message.tool_calls is None
assert result.choices[0].finish_reason == "stop"
def test_add_additional_properties_simple_object():
"""Object schemas without additionalProperties get it set to false."""
schema = {
"type": "object",
"properties": {
"city": {"type": "string"},
"country": {"type": "string"},
},
"required": ["city", "country"],
}
result = AmazonConverseConfig._add_additional_properties_to_schema(schema)
assert result["additionalProperties"] is False
# Original should not be mutated
assert "additionalProperties" not in schema
def test_add_additional_properties_already_set():
"""If additionalProperties is already set, don't overwrite it."""
schema = {
"type": "object",
"properties": {"x": {"type": "string"}},
"additionalProperties": True,
}
result = AmazonConverseConfig._add_additional_properties_to_schema(schema)
assert result["additionalProperties"] is True
def test_add_additional_properties_nested():
"""Recursively processes nested object types in properties, items, $defs, anyOf."""
schema = {
"type": "object",
"properties": {
"address": {
"type": "object",
"properties": {
"street": {"type": "string"},
"zip": {"type": "string"},
},
},
"tags": {
"type": "array",
"items": {
"type": "object",
"properties": {"name": {"type": "string"}},
},
},
},
"$defs": {
"Metadata": {
"type": "object",
"properties": {"key": {"type": "string"}},
}
},
"anyOf": [
{
"type": "object",
"properties": {"variant": {"type": "string"}},
}
],
}
result = AmazonConverseConfig._add_additional_properties_to_schema(schema)
# Top-level
assert result["additionalProperties"] is False
# Nested property object
assert result["properties"]["address"]["additionalProperties"] is False
# Array items object
assert result["properties"]["tags"]["items"]["additionalProperties"] is False
# $defs object
assert result["$defs"]["Metadata"]["additionalProperties"] is False
# anyOf object
assert result["anyOf"][0]["additionalProperties"] is False
def test_add_additional_properties_non_object():
"""Non-object schemas are returned unchanged."""
schema = {"type": "string"}
result = AmazonConverseConfig._add_additional_properties_to_schema(schema)
assert "additionalProperties" not in result
assert result == {"type": "string"}
def test_add_additional_properties_definitions():
"""Recursively processes object types inside 'definitions' (not just '$defs')."""
schema = {
"type": "object",
"properties": {
"item": {"$ref": "#/definitions/Item"},
},
"definitions": {
"Item": {
"type": "object",
"properties": {
"name": {"type": "string"},
"details": {
"type": "object",
"properties": {"weight": {"type": "number"}},
},
},
}
},
}
result = AmazonConverseConfig._add_additional_properties_to_schema(schema)
# Top-level
assert result["additionalProperties"] is False
# definitions object
assert result["definitions"]["Item"]["additionalProperties"] is False
# Nested object inside definitions
assert result["definitions"]["Item"]["properties"]["details"]["additionalProperties"] is False
def test_json_object_no_schema_falls_back_to_tool_call():
"""response_format: {type: json_object} with no schema should use tool-call fallback,
even for models that support native structured outputs."""
config = AmazonConverseConfig()
optional_params: dict = {}
non_default_params = {"response_format": {"type": "json_object"}}
result = config._translate_response_format_param(
value=non_default_params["response_format"],
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
optional_params=optional_params,
non_default_params=non_default_params,
is_thinking_enabled=False,
)
# Should NOT use native outputConfig (no schema provided)
assert "outputConfig" not in result
# Should use tool-call fallback
assert "tools" in result
assert result["json_mode"] is True
def test_output_config_applies_additional_properties():
"""_create_output_config_for_response_format normalizes the schema."""
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"nested": {
"type": "object",
"properties": {"val": {"type": "integer"}},
},
},
}
output_config = AmazonConverseConfig._create_output_config_for_response_format(
json_schema=schema, name="test_schema"
)
parsed = json.loads(output_config["textFormat"]["structure"]["jsonSchema"]["schema"])
assert parsed["additionalProperties"] is False
assert parsed["properties"]["nested"]["additionalProperties"] is False
class TestBedrockMinThinkingBudgetTokens:
"""Test that thinking.budget_tokens is clamped to the Bedrock minimum (1024)."""

View file

@ -1,7 +1,10 @@
"""
Unit tests for Amazon Nova 2 reasoning configuration transformation.
Tests the _transform_reasoning_effort_to_reasoning_config method in AmazonConverseConfig.
Tests request transformation, response parsing, multi-turn message translation,
and model detection for Nova 2 Lite and Nova 2 Pro via the Bedrock Converse API.
Reference: https://docs.aws.amazon.com/nova/latest/nova2-userguide/using-converse-api.html
"""
import pytest
@ -12,6 +15,7 @@ sys.path.insert(
0, os.path.abspath("../..")
) # Adds the parent directory to the system path
import httpx
import litellm
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
@ -323,248 +327,52 @@ class TestNova15SupportedParameters:
assert "response_format" in supported_params
class TestNova15ResponseParsing:
"""Test suite for Nova 2 response parsing."""
class TestNova2ResponseParsing:
"""Test that reasoningContent blocks are parsed into reasoning_content strings."""
def test_transform_reasoning_content_single_block(self):
"""Test that reasoning content is extracted correctly from a single block."""
def test_should_extract_single_reasoning_block(self):
config = AmazonConverseConfig()
reasoning_blocks = [
{"reasoningText": {"text": "Let me think through this step by step..."}}
]
result = config._transform_reasoning_content(reasoning_blocks)
result = config._transform_reasoning_content(
[{"reasoningText": {"text": "Let me think through this step by step..."}}]
)
assert result == "Let me think through this step by step..."
def test_transform_reasoning_content_multiple_blocks(self):
"""Test that reasoning content is concatenated from multiple blocks."""
def test_should_concatenate_multiple_reasoning_blocks(self):
config = AmazonConverseConfig()
reasoning_blocks = [
{"reasoningText": {"text": "First, I need to analyze the problem. "}},
{"reasoningText": {"text": "Then, I'll consider the solution."}},
]
result = config._transform_reasoning_content(reasoning_blocks)
result = config._transform_reasoning_content(
[
{"reasoningText": {"text": "First, I need to analyze the problem. "}},
{"reasoningText": {"text": "Then, I'll consider the solution."}},
]
)
assert (
result
== "First, I need to analyze the problem. Then, I'll consider the solution."
)
def test_transform_reasoning_content_empty_blocks(self):
"""Test that empty reasoning blocks return empty string."""
def test_should_return_empty_string_for_empty_blocks(self):
config = AmazonConverseConfig()
reasoning_blocks = []
result = config._transform_reasoning_content(reasoning_blocks)
assert result == ""
def test_transform_thinking_blocks_with_text(self):
"""Test that thinking blocks are populated correctly with text."""
config = AmazonConverseConfig()
reasoning_blocks = [{"reasoningText": {"text": "My reasoning process..."}}]
result = config._transform_thinking_blocks(reasoning_blocks)
assert len(result) == 1
assert result[0]["type"] == "thinking"
assert result[0]["thinking"] == "My reasoning process..."
assert "signature" not in result[0]
def test_transform_thinking_blocks_with_signature(self):
"""Test that signature field is preserved when present."""
config = AmazonConverseConfig()
reasoning_blocks = [
{
"reasoningText": {
"text": "My reasoning...",
"signature": "signature-hash-12345",
}
}
]
result = config._transform_thinking_blocks(reasoning_blocks)
assert len(result) == 1
assert result[0]["type"] == "thinking"
assert result[0]["thinking"] == "My reasoning..."
assert result[0]["signature"] == "signature-hash-12345"
def test_transform_thinking_blocks_with_redacted_content(self):
"""Test that redacted content blocks are handled correctly."""
config = AmazonConverseConfig()
reasoning_blocks = [
{"reasoningText": {"text": "First part of reasoning..."}},
{"redactedContent": {}},
{"reasoningText": {"text": "Second part after redaction..."}},
]
result = config._transform_thinking_blocks(reasoning_blocks)
assert len(result) == 3
assert result[0]["type"] == "thinking"
assert result[0]["thinking"] == "First part of reasoning..."
assert result[1]["type"] == "redacted_thinking"
assert result[2]["type"] == "thinking"
assert result[2]["thinking"] == "Second part after redaction..."
def test_transform_thinking_blocks_multiple_blocks(self):
"""Test that multiple thinking blocks are all transformed."""
config = AmazonConverseConfig()
reasoning_blocks = [
{"reasoningText": {"text": "Step 1: Analyze the problem"}},
{
"reasoningText": {
"text": "Step 2: Consider solutions",
"signature": "sig-abc",
}
},
{"reasoningText": {"text": "Step 3: Choose best approach"}},
]
result = config._transform_thinking_blocks(reasoning_blocks)
assert len(result) == 3
assert all(block["type"] == "thinking" for block in result)
assert result[0]["thinking"] == "Step 1: Analyze the problem"
assert result[1]["thinking"] == "Step 2: Consider solutions"
assert result[1]["signature"] == "sig-abc"
assert result[2]["thinking"] == "Step 3: Choose best approach"
def test_transform_thinking_blocks_empty_list(self):
"""Test that empty thinking blocks list returns empty list."""
config = AmazonConverseConfig()
reasoning_blocks = []
result = config._transform_thinking_blocks(reasoning_blocks)
assert result == []
def test_response_parsing_integration(self):
"""Test that response parsing works end-to-end with Nova 2 structure."""
config = AmazonConverseConfig()
# Simulate a Nova 2 response with reasoning content
reasoning_blocks = [
{
"reasoningText": {
"text": "Let me analyze this carefully. ",
"signature": "test-signature",
}
},
{"reasoningText": {"text": "Based on my analysis, the answer is clear."}},
]
# Test reasoning content extraction
reasoning_content = config._transform_reasoning_content(reasoning_blocks)
assert (
reasoning_content
== "Let me analyze this carefully. Based on my analysis, the answer is clear."
)
# Test thinking blocks transformation
thinking_blocks = config._transform_thinking_blocks(reasoning_blocks)
assert len(thinking_blocks) == 2
assert thinking_blocks[0]["thinking"] == "Let me analyze this carefully. "
assert thinking_blocks[0]["signature"] == "test-signature"
assert (
thinking_blocks[1]["thinking"]
== "Based on my analysis, the answer is clear."
)
assert config._transform_reasoning_content([]) == ""
class TestNova15StreamingResponseParsing:
"""Test suite for Nova 2 streaming response parsing."""
class TestNova2StreamingResponseParsing:
"""Test that streaming reasoningContent deltas produce reasoning_content on the delta."""
def test_streaming_reasoning_content_start_event(self):
"""Test that streaming start event with reasoningContent is handled correctly."""
def test_should_extract_reasoning_content_from_delta(self):
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0")
# Simulate a start event with redacted reasoning content
chunk_data = {
"start": {"reasoningContent": {"redactedContent": {}}},
"contentBlockIndex": 0,
}
result = handler.converse_chunk_parser(chunk_data)
# Verify thinking blocks are populated
assert result.choices[0].delta.thinking_blocks is not None
assert len(result.choices[0].delta.thinking_blocks) == 1
assert result.choices[0].delta.thinking_blocks[0]["type"] == "redacted_thinking"
def test_streaming_reasoning_content_delta_text(self):
"""Test that streaming delta event with reasoning text is handled correctly."""
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0")
# Simulate a delta event with reasoning text
chunk_data = {
"delta": {"reasoningContent": {"text": "Let me think about this..."}},
"contentBlockIndex": 0,
}
result = handler.converse_chunk_parser(chunk_data)
# Verify reasoning content is extracted
assert result.choices[0].delta.reasoning_content == "Let me think about this..."
# Verify thinking blocks are populated
assert result.choices[0].delta.thinking_blocks is not None
assert len(result.choices[0].delta.thinking_blocks) == 1
assert result.choices[0].delta.thinking_blocks[0]["type"] == "thinking"
assert (
result.choices[0].delta.thinking_blocks[0]["thinking"]
== "Let me think about this..."
)
def test_streaming_reasoning_content_delta_signature(self):
"""Test that streaming delta event with signature is handled correctly."""
def test_should_accumulate_multiple_reasoning_deltas(self):
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0")
# Simulate a delta event with signature
chunk_data = {
"delta": {"reasoningContent": {"signature": "signature-hash-xyz"}},
"contentBlockIndex": 0,
}
result = handler.converse_chunk_parser(chunk_data)
# Verify reasoning content is set to empty string for consistency
assert result.choices[0].delta.reasoning_content == ""
# Verify thinking blocks are populated with signature
assert result.choices[0].delta.thinking_blocks is not None
assert len(result.choices[0].delta.thinking_blocks) == 1
assert result.choices[0].delta.thinking_blocks[0]["type"] == "thinking"
assert (
result.choices[0].delta.thinking_blocks[0]["signature"]
== "signature-hash-xyz"
)
assert result.choices[0].delta.thinking_blocks[0]["thinking"] == ""
def test_streaming_reasoning_content_multiple_deltas(self):
"""Test that multiple reasoning content deltas are accumulated correctly."""
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0")
# Simulate multiple delta events
chunks = [
{
"delta": {"reasoningContent": {"text": "First, "}},
@ -579,30 +387,15 @@ class TestNova15StreamingResponseParsing:
"contentBlockIndex": 0,
},
]
results = []
for chunk_data in chunks:
result = handler.converse_chunk_parser(chunk_data)
results.append(result)
# Verify each delta has the correct reasoning content
results = [handler.converse_chunk_parser(c) for c in chunks]
assert results[0].choices[0].delta.reasoning_content == "First, "
assert results[1].choices[0].delta.reasoning_content == "I need to analyze "
assert results[2].choices[0].delta.reasoning_content == "the problem."
# Verify thinking blocks are populated for each delta
for result in results:
assert result.choices[0].delta.thinking_blocks is not None
assert len(result.choices[0].delta.thinking_blocks) == 1
assert result.choices[0].delta.thinking_blocks[0]["type"] == "thinking"
def test_streaming_reasoning_then_text_content(self):
"""Test that reasoning content followed by text content is handled correctly."""
def test_should_stream_reasoning_then_text(self):
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0")
# Simulate reasoning content followed by text content
chunks = [
{
"delta": {"reasoningContent": {"text": "Let me think..."}},
@ -611,184 +404,286 @@ class TestNova15StreamingResponseParsing:
{"delta": {"text": "Based on my reasoning, "}, "contentBlockIndex": 1},
{"delta": {"text": "the answer is 42."}, "contentBlockIndex": 1},
]
results = []
for chunk_data in chunks:
result = handler.converse_chunk_parser(chunk_data)
results.append(result)
# Verify first chunk has reasoning content
results = [handler.converse_chunk_parser(c) for c in chunks]
assert results[0].choices[0].delta.reasoning_content == "Let me think..."
assert results[0].choices[0].delta.thinking_blocks is not None
# Verify subsequent chunks have text content
assert results[1].choices[0].delta.content == "Based on my reasoning, "
assert results[2].choices[0].delta.content == "the answer is 42."
def test_streaming_redacted_content_delta(self):
"""Test that streaming delta with redacted content is handled correctly."""
def test_should_populate_provider_specific_fields(self):
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0")
# Simulate a delta event with redacted content
chunk_data = {
"delta": {"reasoningContent": {"redactedContent": {}}},
"contentBlockIndex": 0,
}
result = handler.converse_chunk_parser(chunk_data)
# Verify reasoning content is set to empty string for consistency
assert result.choices[0].delta.reasoning_content == ""
# Verify thinking blocks contain redacted block
assert result.choices[0].delta.thinking_blocks is not None
assert len(result.choices[0].delta.thinking_blocks) == 1
assert result.choices[0].delta.thinking_blocks[0]["type"] == "redacted_thinking"
def test_streaming_provider_specific_fields(self):
"""Test that provider_specific_fields are populated in streaming responses."""
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0")
# Simulate a delta event with reasoning content
chunk_data = {
"delta": {"reasoningContent": {"text": "Reasoning text"}},
"contentBlockIndex": 0,
}
result = handler.converse_chunk_parser(chunk_data)
psf = result.choices[0].delta.provider_specific_fields
assert psf is not None
assert psf["reasoningContent"]["text"] == "Reasoning text"
# Verify provider_specific_fields are populated
assert result.choices[0].delta.provider_specific_fields is not None
assert "reasoningContent" in result.choices[0].delta.provider_specific_fields
assert (
result.choices[0].delta.provider_specific_fields["reasoningContent"]["text"]
== "Reasoning text"
)
def test_streaming_mixed_content_blocks(self):
"""Test streaming with mixed content blocks (reasoning, text, tool calls)."""
def test_should_stream_reasoning_with_tool_calls(self):
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0")
# Simulate a complex streaming scenario
chunks = [
# Start with reasoning
{
"delta": {
"reasoningContent": {
"text": "I need to call a tool to get information."
}
},
"delta": {"reasoningContent": {"text": "I need to call a tool."}},
"contentBlockIndex": 0,
},
# Tool use start
{
"start": {"toolUse": {"toolUseId": "tool-123", "name": "get_weather"}},
"contentBlockIndex": 1,
},
# Tool use delta
{
"delta": {"toolUse": {"input": '{"location": "NYC"}'}},
"contentBlockIndex": 1,
},
# Text response
{"delta": {"text": "The weather is sunny."}, "contentBlockIndex": 2},
]
results = []
for chunk_data in chunks:
result = handler.converse_chunk_parser(chunk_data)
results.append(result)
# Verify reasoning content in first chunk
assert (
results[0].choices[0].delta.reasoning_content
== "I need to call a tool to get information."
)
# Verify tool call in second and third chunks
assert results[1].choices[0].delta.tool_calls is not None
results = [handler.converse_chunk_parser(c) for c in chunks]
assert results[0].choices[0].delta.reasoning_content == "I need to call a tool."
assert (
results[1].choices[0].delta.tool_calls[0]["function"]["name"]
== "get_weather"
)
assert results[2].choices[0].delta.tool_calls is not None
# Verify text content in fourth chunk
assert results[3].choices[0].delta.content == "The weather is sunny."
def test_extract_reasoning_content_str_with_text(self):
"""Test extract_reasoning_content_str method with text."""
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0")
# ---------------------------------------------------------------------------
# Model detection — _is_nova_2_model covers both Lite and Pro
# ---------------------------------------------------------------------------
reasoning_block = {"text": "This is reasoning text"}
NOVA_2_LITE = "amazon.nova-2-lite-v1:0"
NOVA_2_PRO = "us.amazon.nova-2-pro-preview-20251202-v1:0"
result = handler.extract_reasoning_content_str(reasoning_block)
assert result == "This is reasoning text"
class TestNova2ModelDetection:
"""Verify _is_nova_2_model identifies all Nova 2 variants (lite, pro, regional, routed)."""
def test_extract_reasoning_content_str_without_text(self):
"""Test extract_reasoning_content_str method without text (e.g., signature only)."""
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
@pytest.mark.parametrize(
"model",
[
"amazon.nova-2-lite-v1:0",
"amazon.nova-2-pro-preview-20251202-v1:0",
"us.amazon.nova-2-lite-v1:0",
"us.amazon.nova-2-pro-preview-20251202-v1:0",
"eu.amazon.nova-2-lite-v1:0",
"apac.amazon.nova-2-pro-preview-20251202-v1:0",
"bedrock/converse/amazon.nova-2-lite-v1:0",
"bedrock/converse/us.amazon.nova-2-pro-preview-20251202-v1:0",
"bedrock/amazon.nova-2-lite-v1:0",
"converse/us.amazon.nova-2-lite-v1:0",
"converse/amazon.nova-2-pro-preview-20251202-v1:0",
],
)
def test_should_recognize_nova_2_models(self, model):
assert AmazonConverseConfig()._is_nova_2_model(model) is True
handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0")
@pytest.mark.parametrize(
"model",
[
"amazon.nova-pro-v1:0",
"amazon.nova-lite-v1:0",
"amazon.nova-pro-1-5-v1:0",
"anthropic.claude-3-sonnet-20240229-v1:0",
"us.amazon.nova-pro-v1:0",
],
)
def test_should_not_match_non_nova_2_models(self, model):
assert AmazonConverseConfig()._is_nova_2_model(model) is False
reasoning_block = {"signature": "sig-123"}
result = handler.extract_reasoning_content_str(reasoning_block)
# ---------------------------------------------------------------------------
# End-to-end request body — reasoningConfig in additionalModelRequestFields
# ---------------------------------------------------------------------------
assert result is None
def test_translate_thinking_blocks_streaming_text(self):
"""Test translate_thinking_blocks method with text."""
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
class TestNova2EndToEndRequest:
"""Verify transform_request places reasoningConfig correctly for both model variants."""
handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0")
def _build_request(self, model, effort, **extra):
config = AmazonConverseConfig()
optional_params = config.map_openai_params(
non_default_params={"reasoning_effort": effort, **extra},
optional_params={},
model=model,
drop_params=False,
)
return config.transform_request(
model=model,
messages=[{"role": "user", "content": "What is 2+2?"}],
optional_params=optional_params,
litellm_params={},
headers={},
)
thinking_block = {"text": "Thinking content"}
@pytest.mark.parametrize("model", [NOVA_2_LITE, NOVA_2_PRO])
def test_should_place_reasoning_config_in_additional_model_request_fields(
self, model
):
body = self._build_request(model, "high")
additional = body.get("additionalModelRequestFields", {})
assert additional["reasoningConfig"] == {
"type": "enabled",
"maxReasoningEffort": "high",
}
assert "reasoningConfig" not in body # not top-level
assert "thinking" not in body # not Anthropic-style
result = handler.translate_thinking_blocks(thinking_block)
assert result is not None
assert len(result) == 1
assert result[0]["type"] == "thinking"
assert result[0]["thinking"] == "Thinking content"
def test_translate_thinking_blocks_streaming_signature(self):
"""Test translate_thinking_blocks method with signature."""
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0")
thinking_block = {"signature": "sig-abc"}
result = handler.translate_thinking_blocks(thinking_block)
assert result is not None
assert len(result) == 1
assert result[0]["type"] == "thinking"
assert result[0]["signature"] == "sig-abc"
@pytest.mark.parametrize("model", [NOVA_2_LITE, NOVA_2_PRO])
def test_should_coexist_with_inference_params(self, model):
body = self._build_request(model, "high", temperature=0.5, max_tokens=512)
assert (
result[0]["thinking"] == ""
) # Empty string for consistency with Anthropic
body["additionalModelRequestFields"]["reasoningConfig"]["type"] == "enabled"
)
inf = body.get("inferenceConfig", {})
assert inf.get("temperature") == 0.5
assert inf.get("maxTokens") == 512
def test_translate_thinking_blocks_streaming_redacted(self):
"""Test translate_thinking_blocks method with redacted content."""
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
handler = AWSEventStreamDecoder(model="amazon.nova-2-lite-v1:0")
# ---------------------------------------------------------------------------
# End-to-end response — reasoningContent parsed to reasoning_content string
# ---------------------------------------------------------------------------
thinking_block = {"redactedContent": {}}
result = handler.translate_thinking_blocks(thinking_block)
class TestNova2EndToEndResponse:
"""Verify transform_response produces reasoning_content from reasoningContent blocks."""
assert result is not None
assert len(result) == 1
assert result[0]["type"] == "redacted_thinking"
def _transform(self, content_blocks, model=NOVA_2_LITE):
config = AmazonConverseConfig()
body = {
"output": {"message": {"role": "assistant", "content": content_blocks}},
"usage": {"inputTokens": 10, "outputTokens": 50, "totalTokens": 60},
"stopReason": "end_turn",
"metrics": {"latencyMs": 100},
}
resp = httpx.Response(
200, json=body, request=httpx.Request("POST", "https://bedrock")
)
return config.transform_response(
model=model,
raw_response=resp,
model_response=litellm.ModelResponse(),
logging_obj=None,
request_data={},
messages=[],
optional_params={},
litellm_params={},
encoding=None,
api_key=None,
json_mode=None,
)
def test_should_extract_reasoning_content_as_string(self):
result = self._transform(
[
{"reasoningContent": {"reasoningText": {"text": "Step 1. "}}},
{"reasoningContent": {"reasoningText": {"text": "Step 2."}}},
{"text": "The answer is 4."},
]
)
msg = result.choices[0].message
assert msg.content == "The answer is 4."
assert msg.reasoning_content == "Step 1. Step 2."
def test_should_include_raw_blocks_in_provider_specific_fields(self):
result = self._transform(
[
{"reasoningContent": {"reasoningText": {"text": "thinking..."}}},
{"text": "done"},
]
)
psf = result.choices[0].message.get("provider_specific_fields", {})
assert "reasoningContentBlocks" in psf
def test_should_omit_reasoning_content_when_absent(self):
result = self._transform([{"text": "Plain answer."}])
assert not getattr(result.choices[0].message, "reasoning_content", None)
# ---------------------------------------------------------------------------
# Multi-turn — reasoning_content round-trips back to Bedrock format
# ---------------------------------------------------------------------------
class TestNova2MultiTurnMessageTranslation:
"""Verify that assistant messages carrying reasoning from a previous turn are
correctly translated to Bedrock content blocks via _bedrock_converse_messages_pt."""
def _to_bedrock(self, messages, model=NOVA_2_LITE):
from litellm.litellm_core_utils.prompt_templates.factory import (
_bedrock_converse_messages_pt,
)
return _bedrock_converse_messages_pt(
messages=messages,
model=model,
llm_provider="bedrock_converse",
)
def test_should_inline_unsigned_thinking_blocks_as_text(self):
"""Without a signature, reasoning text becomes a plain text block."""
bedrock_msgs = self._to_bedrock(
[
{"role": "user", "content": "What is 2+2?"},
{
"role": "assistant",
"content": "4.",
"thinking_blocks": [
{"type": "thinking", "thinking": "Simple addition"},
],
},
{"role": "user", "content": "Sure?"},
]
)
assistant = next(m for m in bedrock_msgs if m["role"] == "assistant")
texts = [b["text"] for b in assistant["content"] if "text" in b]
assert "Simple addition" in texts
assert "4." in texts
def test_should_keep_signed_thinking_blocks_as_reasoning_content(self):
"""With a signature, reasoning is preserved as a reasoningContent block."""
bedrock_msgs = self._to_bedrock(
[
{"role": "user", "content": "What is 2+2?"},
{
"role": "assistant",
"content": "4.",
"thinking_blocks": [
{"type": "thinking", "thinking": "math", "signature": "sig-1"},
],
},
{"role": "user", "content": "Sure?"},
]
)
assistant = next(m for m in bedrock_msgs if m["role"] == "assistant")
rc_blocks = [b for b in assistant["content"] if "reasoningContent" in b]
assert len(rc_blocks) >= 1
assert rc_blocks[0]["reasoningContent"]["reasoningText"]["text"] == "math"
assert rc_blocks[0]["reasoningContent"]["reasoningText"]["signature"] == "sig-1"
def test_should_translate_inline_content_list_thinking_type(self):
"""content=[{type:'thinking',...},{type:'text',...}] should also round-trip."""
bedrock_msgs = self._to_bedrock(
[
{"role": "user", "content": "Hi"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "hmm", "signature": "sig-2"},
{"type": "text", "text": "Hello!"},
],
},
{"role": "user", "content": "Bye"},
]
)
assistant = next(m for m in bedrock_msgs if m["role"] == "assistant")
rc_blocks = [b for b in assistant["content"] if "reasoningContent" in b]
text_blocks = [
b
for b in assistant["content"]
if "text" in b and "reasoningContent" not in b
]
assert len(rc_blocks) >= 1
assert any("Hello!" in b["text"] for b in text_blocks)

View file

@ -232,23 +232,6 @@ class TestHostedVLLMEmbeddingTransformation:
assert result["Authorization"] == "Bearer test-api-key"
assert result["Content-Type"] == "application/json"
def test_validate_environment_without_api_key(self):
"""Test environment validation without API key (uses fake-api-key)."""
headers = {}
result = self.config.validate_environment(
headers=headers,
model=self.model,
messages=[],
optional_params={},
litellm_params={},
api_key=None,
)
# Should not include Authorization header with fake-api-key
assert "Authorization" not in result
assert result["Content-Type"] == "application/json"
def test_encoding_format_not_sent_in_actual_request(self):
"""
E2E test that encoding_format is not sent when not provided.
@ -306,61 +289,5 @@ class TestHostedVLLMEmbeddingTransformation:
assert sent_data["model"] == "BAAI/bge-small-en-v1.5"
assert sent_data["input"] == ["Hello world"]
def test_encoding_format_float_sent_in_actual_request(self):
"""
Test that encoding_format='float' is sent when explicitly provided.
"""
from litellm.llms.custom_httpx.http_handler import HTTPHandler
client = HTTPHandler()
with patch.object(client, "post") as mock_post:
# Mock response
mock_response = Mock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.json.return_value = {
"object": "list",
"data": [
{
"object": "embedding",
"index": 0,
"embedding": [0.1, 0.2, 0.3, 0.4, 0.5],
}
],
"model": "BAAI/bge-small-en-v1.5",
"usage": {
"prompt_tokens": 5,
"total_tokens": 5,
},
}
mock_response.text = json.dumps(mock_response.json.return_value)
mock_post.return_value = mock_response
try:
litellm.embedding(
model=self.model,
input=["Hello world"],
api_base="https://test-vllm.example.com/v1",
encoding_format="float",
client=client,
)
except Exception:
pass
# Verify the request was made
mock_post.assert_called_once()
# Get the data that was sent
call_kwargs = mock_post.call_args[1]
sent_data = json.loads(call_kwargs["data"])
# Assert that encoding_format IS in the sent data
assert "encoding_format" in sent_data, (
"encoding_format='float' should be in request when provided"
)
assert sent_data["encoding_format"] == "float"
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])

View file

@ -10,8 +10,8 @@ import pytest
sys.path.insert(0, os.path.abspath("../../../../.."))
from litellm.llms.openai.chat.gpt_transformation import (
OpenAIGPTConfig,
OpenAIChatCompletionStreamingHandler,
OpenAIGPTConfig,
)
@ -204,6 +204,81 @@ class TestOpenAIChatCompletionStreamingHandler:
assert result.choices[0].delta.content == "Hello"
assert not hasattr(result, "usage") or result.usage is None
def test_chunk_parser_maps_reasoning_to_reasoning_content(self):
"""
Test that chunk_parser maps 'reasoning' field to 'reasoning_content'.
Some OpenAI-compatible providers (e.g., GLM-5, hosted_vllm) return
delta.reasoning, but LiteLLM expects delta.reasoning_content.
Regression test for: Streaming responses with delta.reasoning field
coming back empty when using openai/ or hosted_vllm/ providers.
"""
handler = OpenAIChatCompletionStreamingHandler(
streaming_response=None, sync_stream=True
)
# Simulate a chunk with reasoning field (as returned by GLM-5)
chunk = {
"id": "chatcmpl-8e3d624de9b12528",
"object": "chat.completion.chunk",
"created": 1771411455,
"model": "glm-5",
"choices": [
{
"index": 0,
"delta": {
"reasoning": "The capital of France",
"role": None,
},
"finish_reason": None,
}
],
}
# Parse the chunk
parsed_chunk = handler.chunk_parser(chunk)
# Verify that reasoning was mapped to reasoning_content
assert parsed_chunk.choices[0].delta.reasoning_content == "The capital of France"
# Verify that the original 'reasoning' field was removed
assert not hasattr(parsed_chunk.choices[0].delta, "reasoning")
def test_chunk_parser_reasoning_field_not_present(self):
"""
Test that chunks without reasoning field still work correctly.
"""
handler = OpenAIChatCompletionStreamingHandler(
streaming_response=None, sync_stream=True
)
# Simulate a chunk without reasoning field
chunk = {
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 1769511767,
"model": "gpt-4o",
"choices": [
{
"delta": {
"content": "Regular content",
"role": "assistant",
},
"finish_reason": None,
"index": 0,
}
],
}
# Parse the chunk
parsed_chunk = handler.chunk_parser(chunk)
# Verify that content is present
assert parsed_chunk.choices[0].delta.content == "Regular content"
assert parsed_chunk.choices[0].delta.role == "assistant"
# Verify that reasoning_content is not set (it should be deleted by Delta.__init__)
assert not hasattr(parsed_chunk.choices[0].delta, "reasoning_content")
class TestPromptCacheKeyIntegration:
"""Tests for prompt_cache_key support"""

View file

@ -3224,6 +3224,7 @@ def test_video_metadata_only_for_gemini_3():
def test_chunk_parser_handles_prompt_feedback_block():
"""Test chunk_parser correctly handles promptFeedback.blockReason"""
from unittest.mock import Mock
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
ModelResponseIterator,
)
@ -3260,6 +3261,7 @@ def test_chunk_parser_handles_prompt_feedback_block():
def test_chunk_parser_handles_prompt_feedback_safety_block():
"""Test chunk_parser handles different blockReason types (SAFETY)"""
from unittest.mock import Mock
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
ModelResponseIterator,
)
@ -3294,6 +3296,7 @@ def test_chunk_parser_handles_prompt_feedback_safety_block():
def test_chunk_parser_handles_prompt_feedback_block_with_usage():
"""Test chunk_parser correctly extracts usageMetadata when promptFeedback.blockReason is present"""
from unittest.mock import Mock
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
ModelResponseIterator,
)
@ -3429,3 +3432,80 @@ def test_vertex_ai_traffic_type_surfaced_in_responses_api():
assert responses_api_response.provider_specific_fields["traffic_type"] == "ON_DEMAND"
def test_vertex_ai_web_search_options_parameter():
"""
Test that web_search_options parameter is transformed to googleSearch tool.
When a user provides web_search_options as a parameter (not as a tool in the tools array),
it should be transformed to Gemini's googleSearch tool.
This is important for the /v1/messages -> chat/completions -> Gemini flow:
- Anthropic web search tool -> web_search_options parameter -> Gemini googleSearch tool
Input (optional_params):
{"web_search_options": {}}
Expected Output:
tools=[{"googleSearch": {}}]
"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
v = VertexGeminiConfig()
# Simulate the map_openai_params flow
optional_params = {}
# When web_search_options is present, it should be mapped to a tool
web_search_options = {}
_tools = v._map_web_search_options(web_search_options)
# Verify the tool is a googleSearch tool
assert "googleSearch" in _tools, f"Expected googleSearch in tool, got {_tools.keys()}"
assert _tools["googleSearch"] == {}, f"Expected empty googleSearch config, got {_tools['googleSearch']}"
def test_vertex_ai_web_search_options_in_map_openai_params():
"""
Test that web_search_options is properly handled in map_openai_params.
This tests the full flow where web_search_options parameter is converted
to a googleSearch tool and added to optional_params.
Input:
optional_params with web_search_options: {}
Expected:
optional_params should have tools with googleSearch
"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
v = VertexGeminiConfig()
# Simulate optional_params passed to map_openai_params
optional_params = {
"web_search_options": {}
}
# Call the transformation that happens in map_openai_params
# Lines 1075-1079 in vertex_and_google_ai_studio_gemini.py (after fix)
web_search_value = optional_params.get("web_search_options")
if isinstance(web_search_value, dict): # Fixed: removed 'value and' check to support empty dicts
_tools = v._map_web_search_options(web_search_value)
# Simulate _add_tools_to_optional_params
optional_params = v._add_tools_to_optional_params(optional_params, [_tools])
# Remove web_search_options as it's been transformed
optional_params.pop("web_search_options", None)
# Verify the transformation
assert "tools" in optional_params, "tools should be added to optional_params"
assert len(optional_params["tools"]) == 1, "Should have exactly one tool"
assert "googleSearch" in optional_params["tools"][0], "Tool should be googleSearch"
assert optional_params["tools"][0]["googleSearch"] == {}, "googleSearch should be empty config"
assert "web_search_options" not in optional_params, "web_search_options should be removed after transformation"

View file

@ -3,13 +3,9 @@ Integration tests for Vertex AI rerank functionality.
These tests demonstrate end-to-end usage of the Vertex AI rerank feature.
"""
import importlib
import os
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import httpx
import pytest
from litellm.llms.vertex_ai.rerank.transformation import VertexAIRerankConfig
class TestVertexAIRerankIntegration:
@ -20,16 +16,25 @@ class TestVertexAIRerankIntegration:
importlib.reload(rerank_transformation_module)
# Re-import after reload to get the fresh class
from litellm.llms.vertex_ai.rerank.transformation import VertexAIRerankConfig as FreshConfig
from litellm.llms.vertex_ai.rerank.transformation import (
VertexAIRerankConfig as FreshConfig,
)
self.config = FreshConfig()
self.model = "semantic-ranker-default@latest"
@patch('litellm.llms.vertex_ai.rerank.transformation.VertexAIRerankConfig._ensure_access_token')
def test_end_to_end_rerank_flow(self, mock_ensure_access_token):
"""Test complete rerank flow from request to response."""
# Mock authentication
mock_ensure_access_token.return_value = ("test-access-token", "test-project-123")
def test_end_to_end_rerank_flow(self):
"""
Test complete rerank flow from request to response.
Uses instance-level mocking to avoid class-reference issues caused by
importlib.reload(litellm) in conftest.py.
"""
# Mock authentication at instance level
mock_ensure_access_token = MagicMock(
return_value=("test-access-token", "test-project-123")
)
self.config._ensure_access_token = mock_ensure_access_token
# Test documents
documents = [
"Gemini is a cutting edge large language model created by Google.",
@ -38,43 +43,40 @@ class TestVertexAIRerankIntegration:
"Google's Gemini AI model represents a significant advancement in artificial intelligence technology."
]
query = "What is Google Gemini?"
# Step 1: Test request transformation
with patch.object(self.config, 'get_vertex_ai_credentials', return_value=None), \
patch.object(self.config, 'get_vertex_ai_project', return_value="test-project-123"):
# Validate environment
headers = self.config.validate_environment(
headers={},
model=self.model,
api_key=None
)
# Transform request
request_data = self.config.transform_rerank_request(
model=self.model,
optional_rerank_params={
"query": query,
"documents": documents,
"top_n": 2,
"return_documents": True
},
headers=headers
)
# Verify request structure
assert request_data["model"] == self.model
assert request_data["query"] == query
assert request_data["topN"] == 2
assert request_data["ignoreRecordDetailsInResponse"] == False
assert len(request_data["records"]) == 4
# Verify record structure
for i, record in enumerate(request_data["records"]):
assert record["id"] == str(i) # 0-based indexing
assert "title" in record
assert "content" in record
assert record["content"] == documents[i]
# Validate environment
headers = self.config.validate_environment(
headers={},
model=self.model,
api_key=None
)
# Transform request
request_data = self.config.transform_rerank_request(
model=self.model,
optional_rerank_params={
"query": query,
"documents": documents,
"top_n": 2,
"return_documents": True
},
headers=headers
)
# Verify request structure
assert request_data["model"] == self.model
assert request_data["query"] == query
assert request_data["topN"] == 2
assert request_data["ignoreRecordDetailsInResponse"] == False
assert len(request_data["records"]) == 4
# Verify record structure
for i, record in enumerate(request_data["records"]):
assert record["id"] == str(i) # 0-based indexing
assert "title" in record
assert "content" in record
assert record["content"] == documents[i]
# Step 2: Test response transformation
# Mock Vertex AI Discovery Engine response

Some files were not shown because too many files have changed in this diff Show more