Merge branch 'main' into litellm_image_edit_vertex_cred_fix

This commit is contained in:
Sameer Kankute
2025-12-17 22:38:07 +05:30
committed by GitHub
37 changed files with 2258 additions and 428 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ RUN pip wheel --no-cache-dir --wheel-dir=/wheels/ -r requirements.txt
# Runtime stage
FROM $LITELLM_RUNTIME_IMAGE AS runtime
# Update dependencies and clean up
RUN apk upgrade --no-cache
# Update dependencies and clean up, install libsndfile for audio processing
RUN apk upgrade --no-cache && apk add --no-cache libsndfile
WORKDIR /app
@@ -0,0 +1,222 @@
---
slug: gemini_3_flash
title: "DAY 0 Support: Gemini 3 Flash on LiteLLM"
date: 2025-12-17T10:00:00
authors:
- name: Sameer Kankute
title: SWE @ LiteLLM (LLM Translation)
url: https://www.linkedin.com/in/sameer-kankute/
image_url: https://media.licdn.com/dms/image/v2/D4D03AQHB_loQYd5gjg/profile-displayphoto-shrink_800_800/profile-displayphoto-shrink_800_800/0/1719137160975?e=1765411200&v=beta&t=c8396f--_lH6Fb_pVvx_jGholPfcl0bvwmNynbNdnII
- 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: [gemini, day 0 support, llms]
hide_table_of_contents: false
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Gemini 3 Flash Day 0 Support
LiteLLM now supports `gemini-3-flash-preview` and all the new API changes along with it.
## What's New
### 1. New Thinking Levels: `thinkingLevel` with MINIMAL & MEDIUM
Gemini 3 Flash introduces granular thinking control with `thinkingLevel` instead of `thinkingBudget`.
- **MINIMAL**: Ultra-lightweight thinking for fast responses
- **MEDIUM**: Balanced thinking for complex reasoning
- **HIGH**: Maximum reasoning depth
LiteLLM automatically maps the OpenAI `reasoning_effort` parameter to Gemini's `thinkingLevel`, so you can use familiar `reasoning_effort` values (`minimal`, `low`, `medium`, `high`) without changing your code!
### 2. Thought Signatures
Like `gemini-3-pro`, this model also includes thought signatures for tool calls. LiteLLM handles signature extraction and embedding internally. [Learn more about thought signatures](../gemini_3/index.md#thought-signatures).
**Edge Case Handling**: If thought signatures are missing in the request, LiteLLM adds a dummy signature ensuring the API call doesn't break
---
## Supported Endpoints
LiteLLM provides **full end-to-end support** for Gemini 3 Flash on:
-`/v1/chat/completions` - OpenAI-compatible chat completions endpoint
-`/v1/responses` - OpenAI Responses API endpoint (streaming and non-streaming)
- ✅ [`/v1/messages`](../../docs/anthropic_unified) - Anthropic-compatible messages endpoint
-`/v1/generateContent` [Google Gemini API](../../docs/generateContent.md) compatible endpoint
All endpoints support:
- Streaming and non-streaming responses
- Function calling with thought signatures
- Multi-turn conversations
- All Gemini 3-specific features
- Converstion of provider specific thinking related param to thinkingLevel
## Quick Start
<Tabs>
<TabItem value="sdk" label="SDK">
**Basic Usage with MEDIUM thinking (NEW)**
```python
from litellm import completion
# No need to make any changes to your code as we map openai reasoning param to thinkingLevel
response = completion(
model="gemini/gemini-3-flash-preview",
messages=[{"role": "user", "content": "Solve this complex math problem: 25 * 4 + 10"}],
reasoning_effort="medium", # NEW: MEDIUM thinking level
)
print(response.choices[0].message.content)
```
</TabItem>
<TabItem value="proxy" label="PROXY">
**1. Setup config.yaml**
```yaml
model_list:
- model_name: gemini-3-flash
litellm_params:
model: gemini/gemini-3-flash-preview
api_key: os.environ/GEMINI_API_KEY
```
**2. Start proxy**
```bash
litellm --config /path/to/config.yaml
```
**3. Call with MEDIUM thinking**
```bash
curl -X POST http://localhost:4000/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <YOUR-LITELLM-KEY>" \
-d '{
"model": "gemini-3-flash",
"messages": [{"role": "user", "content": "Complex reasoning task"}],
"reasoning_effort": "medium"
}'
``'
</TabItem>
</Tabs>
---
## All `reasoning_effort` Levels
<Tabs>
<TabItem value="minimal" label="MINIMAL">
**Ultra-fast, minimal reasoning**
```python
from litellm import completion
response = completion(
model="gemini/gemini-3-flash-preview",
messages=[{"role": "user", "content": "What's 2+2?"}],
reasoning_effort="minimal",
)
```
</TabItem>
<TabItem value="low" label="LOW">
**Simple instruction following**
```python
response = completion(
model="gemini/gemini-3-flash-preview",
messages=[{"role": "user", "content": "Write a haiku about coding"}],
reasoning_effort="low",
)
```
</TabItem>
<TabItem value="medium" label="MEDIUM (NEW)">
**Balanced reasoning for complex tasks**
```python
response = completion(
model="gemini/gemini-3-flash-preview",
messages=[{"role": "user", "content": "Analyze this dataset and find patterns"}],
reasoning_effort="medium", # NEW!
)
```
</TabItem>
<TabItem value="high" label="HIGH">
**Maximum reasoning depth**
```python
response = completion(
model="gemini/gemini-3-flash-preview",
messages=[{"role": "user", "content": "Prove this mathematical theorem"}],
reasoning_effort="high",
)
```
</TabItem>
</Tabs>
---
## Key Features
**Thinking Levels**: MINIMAL, LOW, MEDIUM, HIGH
**Thought Signatures**: Track reasoning with unique identifiers
**Seamless Integration**: Works with existing OpenAI-compatible client
**Backward Compatible**: Gemini 2.5 models continue using `thinkingBudget`
---
## Installation
```bash
pip install litellm --upgrade
```
```python
import litellm
from litellm import completion
response = completion(
model="gemini/gemini-3-flash-preview",
messages=[{"role": "user", "content": "Your question here"}],
reasoning_effort="medium", # Use MEDIUM thinking
)
print(response)
```
## `reasoning_effort` Mapping for Gemini 3+
| reasoning_effort | thinking_level |
|------------------|----------------|
| `minimal` | `minimal` |
| `low` | `low` |
| `medium` | `medium` |
| `high` | `high` |
| `disable` | `minimal` |
| `none` | `minimal` |
+1 -1
View File
@@ -172,7 +172,7 @@ class MyUser(HttpUser):
## Logging Callbacks
### [GCS Bucket Logging](https://docs.litellm.ai/docs/proxy/bucket)
### [GCS Bucket Logging](https://docs.litellm.ai/docs/observability/gcs_bucket_integration)
Using GCS Bucket has **no impact on latency, RPS compared to Basic Litellm Proxy**
+1 -1
View File
@@ -29,7 +29,7 @@ Features:
- **Spend Tracking & Data Exports**
- ✅ [Set USD Budgets Spend for Custom Tags](./provider_budget_routing#-tag-budgets)
- ✅ [Set Model budgets for Virtual Keys](./users#-virtual-key-model-specific)
- ✅ [Exporting LLM Logs to GCS Bucket, Azure Blob Storage](./proxy/bucket#🪣-logging-gcs-s3-buckets)
- ✅ [Exporting LLM Logs to GCS Bucket, Azure Blob Storage](../observability/gcs_bucket_integration)
- ✅ [`/spend/report` API endpoint](cost_tracking.md#✨-enterprise-api-endpoints-to-get-spend)
- **Control Guardrails per API Key/Team**
- **Custom Branding**
+2
View File
@@ -795,6 +795,8 @@ def image_edit(
model=model,
image_edit_provider_config=image_edit_provider_config,
image_edit_optional_params=image_edit_optional_params,
drop_params=kwargs.get("drop_params"),
additional_drop_params=kwargs.get("additional_drop_params"),
)
)
+26 -14
View File
@@ -1,5 +1,5 @@
from io import BufferedReader, BytesIO
from typing import Any, Dict, cast, get_type_hints
from typing import Any, Dict, List, Optional, cast, get_type_hints
import litellm
from litellm.litellm_core_utils.token_counter import get_image_type
@@ -14,41 +14,53 @@ class ImageEditRequestUtils:
model: str,
image_edit_provider_config: BaseImageEditConfig,
image_edit_optional_params: ImageEditOptionalRequestParams,
drop_params: Optional[bool] = None,
additional_drop_params: Optional[List[str]] = None,
) -> Dict:
"""
Get optional parameters for the image edit API.
Args:
params: Dictionary of all parameters
model: The model name
image_edit_provider_config: The provider configuration for image edit API
image_edit_optional_params: The optional parameters for the image edit API
drop_params: If True, silently drop unsupported parameters instead of raising
additional_drop_params: List of additional parameter names to drop
Returns:
A dictionary of supported parameters for the image edit API
"""
# Remove None values and internal parameters
# Get supported parameters for the model
supported_params = image_edit_provider_config.get_supported_openai_params(model)
# Check for unsupported parameters
should_drop = litellm.drop_params is True or drop_params is True
filtered_optional_params = dict(image_edit_optional_params)
if additional_drop_params:
for param in additional_drop_params:
filtered_optional_params.pop(param, None)
unsupported_params = [
param
for param in image_edit_optional_params
for param in filtered_optional_params
if param not in supported_params
]
if unsupported_params:
raise litellm.UnsupportedParamsError(
model=model,
message=f"The following parameters are not supported for model {model}: {', '.join(unsupported_params)}",
)
if should_drop:
for param in unsupported_params:
filtered_optional_params.pop(param, None)
else:
raise litellm.UnsupportedParamsError(
model=model,
message=f"The following parameters are not supported for model {model}: {', '.join(unsupported_params)}",
)
# Map parameters to provider-specific format
mapped_params = image_edit_provider_config.map_openai_params(
image_edit_optional_params=image_edit_optional_params,
image_edit_optional_params=cast(
ImageEditOptionalRequestParams, filtered_optional_params
),
model=model,
drop_params=litellm.drop_params,
drop_params=should_drop,
)
return mapped_params
+1 -1
View File
@@ -8,5 +8,5 @@ This folder contains the GCS Bucket Logging integration for LiteLLM Gateway.
- `gcs_bucket_base.py`: This file contains the GCSBucketBase class which handles Authentication for GCS Buckets
## Further Reading
- [Doc setting up GCS Bucket Logging on LiteLLM Proxy (Gateway)](https://docs.litellm.ai/docs/proxy/bucket)
- [Doc setting up GCS Bucket Logging on LiteLLM Proxy (Gateway)](https://docs.litellm.ai/docs/observability/gcs_bucket_integration)
- [Doc on Key / Team Based logging with GCS](https://docs.litellm.ai/docs/proxy/team_logging)
+13
View File
@@ -815,7 +815,20 @@ class PrometheusLogger(CustomLogger):
user_api_key_auth_metadata: Optional[dict] = standard_logging_payload[
"metadata"
].get("user_api_key_auth_metadata")
# Include top-level metadata fields (excluding nested dictionaries)
# This allows accessing fields like requester_ip_address from top-level metadata
top_level_metadata = standard_logging_payload.get("metadata", {})
top_level_fields: Dict[str, Any] = {}
if isinstance(top_level_metadata, dict):
top_level_fields = {
k: v
for k, v in top_level_metadata.items()
if not isinstance(v, dict) # Exclude nested dicts to avoid conflicts
}
combined_metadata: Dict[str, Any] = {
**top_level_fields, # Include top-level fields first
**(_requester_metadata if _requester_metadata else {}),
**(user_api_key_auth_metadata if user_api_key_auth_metadata else {}),
}
@@ -917,9 +917,11 @@ class Logging(LiteLLMLoggingBaseClass):
raw_request_body=self._get_raw_request_body(
additional_args.get("complete_input_dict", {})
),
# NOTE: setting ignore_sensitive_headers to True will cause
# the Authorization header to be leaked when calls to the health
# endpoint are made and fail.
raw_request_headers=self._get_masked_headers(
additional_args.get("headers", {}) or {},
ignore_sensitive_headers=True,
),
error=None,
)
@@ -253,20 +253,39 @@ class AnthropicMessagesHandler(BaseTranslation):
task_mappings: List[Tuple[int, Optional[int]]] = []
# Track (content_index, None) for each text
response_content = response.get("content", [])
# Handle both dict and object responses
response_content: List[Any] = []
if isinstance(response, dict):
response_content = response.get("content", []) or []
elif hasattr(response, "content"):
content = getattr(response, "content", None)
response_content = content or []
else:
response_content = []
if not response_content:
return response
# Step 1: Extract all text content and tool calls from response
for content_idx, content_block in enumerate(response_content):
# Check if this is a text or tool_use block by checking the 'type' field
if isinstance(content_block, dict) and content_block.get("type") in [
"text",
"tool_use",
]:
# Cast to dict to handle the union type properly
# Handle both dict and Pydantic object content blocks
block_dict: Dict[str, Any] = {}
if isinstance(content_block, dict):
block_type = content_block.get("type")
block_dict = cast(Dict[str, Any], content_block)
elif hasattr(content_block, "type"):
block_type = getattr(content_block, "type", None)
# Convert Pydantic object to dict for processing
if hasattr(content_block, "model_dump"):
block_dict = content_block.model_dump()
else:
block_dict = {"type": block_type, "text": getattr(content_block, "text", None)}
else:
continue
if block_type in ["text", "tool_use"]:
self._extract_output_text_and_images(
content_block=cast(Dict[str, Any], content_block),
content_block=block_dict,
content_idx=content_idx,
texts_to_check=texts_to_check,
images_to_check=images_to_check,
@@ -530,7 +549,11 @@ class AnthropicMessagesHandler(BaseTranslation):
Override this method to customize text content detection.
"""
response_content = response.get("content", [])
if isinstance(response, dict):
response_content = response.get("content", [])
else:
response_content = getattr(response, "content", None) or []
if not response_content:
return False
for content_block in response_content:
@@ -590,7 +613,16 @@ class AnthropicMessagesHandler(BaseTranslation):
mapping = task_mappings[task_idx]
content_idx = cast(int, mapping[0])
response_content = response.get("content", [])
# Handle both dict and object responses
response_content: List[Any] = []
if isinstance(response, dict):
response_content = response.get("content", []) or []
elif hasattr(response, "content"):
content = getattr(response, "content", None)
response_content = content or []
else:
continue
if not response_content:
continue
@@ -601,7 +633,11 @@ class AnthropicMessagesHandler(BaseTranslation):
content_block = response_content[content_idx]
# Verify it's a text block and update the text field
if isinstance(content_block, dict) and content_block.get("type") == "text":
# Cast to dict to handle the union type properly for assignment
content_block = cast("AnthropicResponseTextBlock", content_block)
content_block["text"] = guardrail_response
# Handle both dict and Pydantic object content blocks
if isinstance(content_block, dict):
if content_block.get("type") == "text":
cast(Dict[str, Any], content_block)["text"] = guardrail_response
elif hasattr(content_block, "type") and getattr(content_block, "type", None) == "text":
# Update Pydantic object's text attribute
if hasattr(content_block, "text"):
content_block.text = guardrail_response
+8
View File
@@ -357,6 +357,14 @@ class BaseAWSLLM:
model_id = BaseAWSLLM._get_model_id_from_model_with_spec(
model_id, spec="openai"
)
elif provider == "qwen2" and "qwen2/" in model_id:
model_id = BaseAWSLLM._get_model_id_from_model_with_spec(
model_id, spec="qwen2"
)
elif provider == "qwen3" and "qwen3/" in model_id:
model_id = BaseAWSLLM._get_model_id_from_model_with_spec(
model_id, spec="qwen3"
)
return model_id
@staticmethod
@@ -75,6 +75,7 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM):
"seed",
"response_mime_type",
"response_schema",
"response_json_schema",
"routing_config",
"model_selection_config",
"safety_settings",
@@ -105,13 +106,37 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM):
Returns:
Mapped parameters for the provider
"""
from litellm.llms.vertex_ai.gemini.transformation import (
_camel_to_snake,
_snake_to_camel,
)
_generate_content_config_dict: Dict[str, Any] = {}
supported_google_genai_params = (
self.get_supported_generate_content_optional_params(model)
)
# Create a set with both camelCase and snake_case versions for faster lookup
supported_params_set = set(supported_google_genai_params)
supported_params_set.update(_snake_to_camel(p) for p in supported_google_genai_params)
supported_params_set.update(_camel_to_snake(p) for p in supported_google_genai_params if "_" not in p)
for param, value in generate_content_config_dict.items():
if param in supported_google_genai_params:
_generate_content_config_dict[param] = value
# Google GenAI API expects camelCase, so we'll always output in camelCase
# Check if param (or its variants) is supported
param_snake = _camel_to_snake(param)
param_camel = _snake_to_camel(param)
# Check if param is supported in any format
is_supported = (
param in supported_google_genai_params or
param_snake in supported_google_genai_params or
param_camel in supported_google_genai_params
)
if is_supported:
# Always output in camelCase for Google GenAI API
output_key = param_camel if param != param_camel else param
_generate_content_config_dict[output_key] = value
return _generate_content_config_dict
def validate_environment(
@@ -30,7 +30,7 @@ Output: response.output is List[GenericResponseOutputItem] where each has:
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast
from openai.types.responses import ResponseFunctionToolCall
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
from pydantic import BaseModel
from litellm._logging import verbose_proxy_logger
@@ -299,8 +299,25 @@ class OpenAIResponsesHandler(BaseTranslation):
task_mappings: List[Tuple[int, int]] = []
# Track (output_item_index, content_index) for each text
# Handle both dict and Pydantic object responses
if isinstance(response, dict):
response_output = response.get("output", [])
elif hasattr(response, "output"):
response_output = response.output or []
else:
verbose_proxy_logger.debug(
"OpenAI Responses API: No output found in response"
)
return response
if not response_output:
verbose_proxy_logger.debug(
"OpenAI Responses API: Empty output in response"
)
return response
# Step 1: Extract all text content and tool calls from response output
for output_idx, output_item in enumerate(response.output):
for output_idx, output_item in enumerate(response_output):
self._extract_output_text_and_images(
output_item=output_item,
output_idx=output_idx,
@@ -538,13 +555,18 @@ class OpenAIResponsesHandler(BaseTranslation):
content: Optional[Union[List[OutputText], List[dict]]] = None
if isinstance(output_item, BaseModel):
try:
output_item_dump = output_item.model_dump()
generic_response_output_item = GenericResponseOutputItem.model_validate(
output_item.model_dump()
output_item_dump
)
if generic_response_output_item.content:
content = generic_response_output_item.content
except Exception:
return
# Try to extract content directly from output_item if validation fails
if hasattr(output_item, "content") and output_item.content:
content = output_item.content
else:
return
elif isinstance(output_item, dict):
content = output_item.get("content", [])
else:
@@ -582,22 +604,53 @@ class OpenAIResponsesHandler(BaseTranslation):
Override this method to customize how responses are applied.
"""
# Handle both dict and Pydantic object responses
if isinstance(response, dict):
response_output = response.get("output", [])
elif hasattr(response, "output"):
response_output = response.output or []
else:
return
for task_idx, guardrail_response in enumerate(responses):
mapping = task_mappings[task_idx]
output_idx = cast(int, mapping[0])
content_idx = cast(int, mapping[1])
output_item = response.output[output_idx]
if output_idx >= len(response_output):
continue
# Handle both GenericResponseOutputItem and dict
output_item = response_output[output_idx]
# Handle both GenericResponseOutputItem, BaseModel, and dict
if isinstance(output_item, GenericResponseOutputItem):
content_item = output_item.content[content_idx]
if isinstance(content_item, OutputText):
content_item.text = guardrail_response
elif isinstance(content_item, dict):
content_item["text"] = guardrail_response
if output_item.content and content_idx < len(output_item.content):
content_item = output_item.content[content_idx]
if isinstance(content_item, OutputText):
content_item.text = guardrail_response
elif isinstance(content_item, dict):
content_item["text"] = guardrail_response
elif isinstance(output_item, BaseModel):
# Handle other Pydantic models by converting to GenericResponseOutputItem
try:
generic_item = GenericResponseOutputItem.model_validate(
output_item.model_dump()
)
if generic_item.content and content_idx < len(generic_item.content):
content_item = generic_item.content[content_idx]
if isinstance(content_item, OutputText):
content_item.text = guardrail_response
# Update the original response output
if hasattr(output_item, "content") and output_item.content:
original_content = output_item.content[content_idx]
if hasattr(original_content, "text"):
original_content.text = guardrail_response
except Exception:
pass
elif isinstance(output_item, dict):
content = output_item.get("content", [])
if content and content_idx < len(content):
if isinstance(content[content_idx], dict):
content[content_idx]["text"] = guardrail_response
elif hasattr(content[content_idx], "text"):
content[content_idx].text = guardrail_response
@@ -228,12 +228,13 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
Gemini 3 models include:
- gemini-3-pro-preview
- gemini-3-flash
- gemini-3-flash-preview (Gemini 3 Flash)
- Any future Gemini 3.x models
"""
# Check for Gemini 3 models
if "gemini-3" in model:
return True
return False
def _supports_penalty_parameters(self, model: str) -> bool:
@@ -685,22 +686,40 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
Returns:
GeminiThinkingConfig with thinkingLevel and includeThoughts
"""
# Check if this is gemini-3-flash which supports MINIMAL thinking level
is_gemini3flash= model and (
"gemini-3-flash-preview" in model.lower() or "gemini-3-flash" in model.lower()
)
if reasoning_effort == "minimal":
return {"thinkingLevel": "low", "includeThoughts": True}
if is_gemini3flash:
return {"thinkingLevel": "minimal", "includeThoughts": True}
else:
return {"thinkingLevel": "low", "includeThoughts": True}
elif reasoning_effort == "low":
return {"thinkingLevel": "low", "includeThoughts": True}
elif reasoning_effort == "medium":
return {
"thinkingLevel": "high",
"includeThoughts": True,
} # medium is not out yet
# For gemini-3-flash-preview, medium maps to "medium", otherwise "high"
if is_gemini3flash:
return {"thinkingLevel": "medium", "includeThoughts": True}
else:
return {
"thinkingLevel": "high",
"includeThoughts": True,
} # medium is not out yet for other models
elif reasoning_effort == "high":
return {"thinkingLevel": "high", "includeThoughts": True}
elif reasoning_effort == "disable":
# Gemini 3 cannot fully disable thinking, so we use "low" but hide thoughts
return {"thinkingLevel": "low", "includeThoughts": False}
# Gemini 3 cannot fully disable thinking, so we use "minimal" for gemini-3-flash-preview, "low" for others
if is_gemini3flash:
return {"thinkingLevel": "minimal", "includeThoughts": False}
else:
return {"thinkingLevel": "low", "includeThoughts": False}
elif reasoning_effort == "none":
return {"thinkingLevel": "low", "includeThoughts": False}
# For gemini-3-flash-preview, use "minimal" instead of "low"
if is_gemini3flash:
return {"thinkingLevel": "minimal", "includeThoughts": False}
else:
return {"thinkingLevel": "low", "includeThoughts": False}
else:
raise ValueError(f"Invalid reasoning effort: {reasoning_effort}")
@@ -751,17 +770,38 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
@staticmethod
def _map_thinking_param(
thinking_param: AnthropicThinkingParam,
model: Optional[str] = None,
) -> GeminiThinkingConfig:
thinking_enabled = thinking_param.get("type") == "enabled"
thinking_budget = thinking_param.get("budget_tokens")
params: GeminiThinkingConfig = {}
if thinking_enabled and not VertexGeminiConfig._is_thinking_budget_zero(
thinking_budget
):
params["includeThoughts"] = True
if thinking_budget is not None and isinstance(thinking_budget, int):
params["thinkingBudget"] = thinking_budget
# For Gemini 3+ models, use thinkingLevel instead of thinkingBudget
if model and VertexGeminiConfig._is_gemini_3_or_newer(model):
if thinking_enabled:
if thinking_budget is None or thinking_budget == 0:
params["includeThoughts"] = False
else:
params["includeThoughts"] = True
if thinking_budget >= 10000:
is_gemini3flash = "gemini-3-flash-preview" in model.lower() or "gemini-3-flash" in model.lower()
params["thinkingLevel"] = "minimal" if is_gemini3flash else "low"
else:
is_gemini3flash = "gemini-3-flash-preview" in model.lower() or "gemini-3-flash" in model.lower()
params["thinkingLevel"] = "minimal" if is_gemini3flash else "low"
else:
# Thinking disabled
params["includeThoughts"] = False
else:
# For older Gemini models, use thinkingBudget
if thinking_enabled and not VertexGeminiConfig._is_thinking_budget_zero(
thinking_budget
):
params["includeThoughts"] = True
if thinking_budget is not None and isinstance(thinking_budget, int):
params["thinkingBudget"] = thinking_budget
return params
def map_response_modalities(self, value: list) -> list:
@@ -938,7 +978,8 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
optional_params[
"thinkingConfig"
] = VertexGeminiConfig._map_thinking_param(
cast(AnthropicThinkingParam, value)
cast(AnthropicThinkingParam, value),
model=model,
)
elif param == "modalities" and isinstance(value, list):
response_modalities = self.map_response_modalities(value)
@@ -970,7 +1011,10 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"thinkingLevel" not in thinking_config
and "thinkingBudget" not in thinking_config
):
thinking_config["thinkingLevel"] = "low"
# For gemini-3-flash-preview, default to "minimal" to match Gemini 2.5 Flash behavior
# For other Gemini 3 models, default to "low"
is_gemini3flash = "gemini-3-flash-preview" in model.lower() or "gemini-3-flash" in model.lower()
thinking_config["thinkingLevel"] = "minimal" if is_gemini3flash else "low"
optional_params["thinkingConfig"] = thinking_config
return optional_params
@@ -14732,6 +14732,98 @@
"supports_web_search": true,
"tpm": 800000
},
"gemini/gemini-3-flash-preview": {
"cache_read_input_token_cost": 5e-08,
"input_cost_per_audio_token": 1e-06,
"input_cost_per_token": 5e-07,
"litellm_provider": "gemini",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65535,
"max_pdf_size_mb": 30,
"max_tokens": 65535,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "chat",
"output_cost_per_reasoning_token": 3e-06,
"output_cost_per_token": 3e-06,
"rpm": 2000,
"source": "https://ai.google.dev/pricing/gemini-3",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_audio_output": false,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true,
"tpm": 800000
},
"gemini-3-flash-preview": {
"cache_read_input_token_cost": 5e-08,
"input_cost_per_audio_token": 1e-06,
"input_cost_per_token": 5e-07,
"litellm_provider": "vertex_ai-language-models",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65535,
"max_pdf_size_mb": 30,
"max_tokens": 65535,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "chat",
"output_cost_per_reasoning_token": 3e-06,
"output_cost_per_token": 3e-06,
"source": "https://ai.google.dev/pricing/gemini-3",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_audio_output": false,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true
},
"gemini/gemini-2.5-pro-exp-03-25": {
"cache_read_input_token_cost": 0.0,
"input_cost_per_token": 0.0,
@@ -16673,6 +16765,34 @@
"/v1/audio/transcriptions"
]
},
"gpt-image-1.5": {
"cache_read_input_image_token_cost": 2e-06,
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_token": 1e-05,
"input_cost_per_image_token": 8e-06,
"output_cost_per_image_token": 3.2e-05,
"supported_endpoints": [
"/v1/images/generations"
],
"supports_vision": true
},
"gpt-image-1.5-2025-12-16": {
"cache_read_input_image_token_cost": 2e-06,
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_token": 1e-05,
"input_cost_per_image_token": 8e-06,
"output_cost_per_image_token": 3.2e-05,
"supported_endpoints": [
"/v1/images/generations"
],
"supports_vision": true
},
"gpt-5": {
"cache_read_input_token_cost": 1.25e-07,
"cache_read_input_token_cost_flex": 6.25e-08,
+8
View File
@@ -616,6 +616,14 @@ def get_model_from_request(
if match:
model = match.group(1)
# If still not found, extract from Vertex AI passthrough route
# Pattern: /vertex_ai/.../models/{model_id}:*
# Example: /vertex_ai/v1/.../models/gemini-1.5-pro:generateContent
if model is None and "/vertex" in route.lower():
vertex_match = re.search(r"/models/([^/:]+)", route)
if vertex_match:
model = vertex_match.group(1)
return model
@@ -40,6 +40,12 @@ def initialize_guardrail(
),
categories=_get_config_value(litellm_params, optional_params, "categories"),
policy_id=_get_config_value(litellm_params, optional_params, "policy_id"),
streaming_end_of_stream_only=_get_config_value(
litellm_params, optional_params, "streaming_end_of_stream_only"
) or False,
streaming_sampling_rate=_get_config_value(
litellm_params, optional_params, "streaming_sampling_rate"
) or 5,
event_hook=litellm_params.mode,
default_on=litellm_params.default_on,
)
@@ -1,26 +1,25 @@
"""Gray Swan Cygnal guardrail integration."""
import os
from typing import Any, Dict, Literal, Optional, Union
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional
from fastapi import HTTPException
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_guardrail import (
CustomGuardrail,
log_guardrail_information,
ModifyResponseException,
)
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.callback_utils import (
add_guardrail_to_applied_guardrails_header,
)
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import Choices, LLMResponseTypes, ModelResponse
from litellm.types.utils import GenericGuardrailAPIInputs
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
class GraySwanGuardrailMissingSecrets(Exception):
@@ -35,6 +34,15 @@ class GraySwanGuardrail(CustomGuardrail):
"""
Guardrail that calls Gray Swan's Cygnal monitoring endpoint.
Uses the unified guardrail system via `apply_guardrail` method,
which automatically works with all LiteLLM endpoints:
- OpenAI Chat Completions
- OpenAI Responses API
- OpenAI Text Completions
- Anthropic Messages
- Image Generation
- And more...
see: https://docs.grayswan.ai/cygnal/monitor-requests
"""
@@ -54,6 +62,8 @@ class GraySwanGuardrail(CustomGuardrail):
reasoning_mode: Optional[str] = None,
categories: Optional[Dict[str, str]] = None,
policy_id: Optional[str] = None,
streaming_end_of_stream_only: bool = False,
streaming_sampling_rate: int = 5,
**kwargs: Any,
) -> None:
self.async_handler = get_async_httpx_client(
@@ -88,6 +98,16 @@ class GraySwanGuardrail(CustomGuardrail):
self.categories = categories
self.policy_id = policy_id
# Streaming configuration
self.streaming_end_of_stream_only = streaming_end_of_stream_only
self.streaming_sampling_rate = streaming_sampling_rate
verbose_proxy_logger.debug(
"GraySwan __init__: streaming_end_of_stream_only=%s, streaming_sampling_rate=%s",
streaming_end_of_stream_only,
streaming_sampling_rate,
)
supported_event_hooks = [
GuardrailEventHooks.pre_call,
GuardrailEventHooks.during_call,
@@ -101,217 +121,227 @@ class GraySwanGuardrail(CustomGuardrail):
)
# ------------------------------------------------------------------
# Guardrail hook entry points
# Debug override to trace post_call issues
# ------------------------------------------------------------------
@log_guardrail_information
async def async_pre_call_hook(
def should_run_guardrail(self, data, event_type) -> bool:
"""Override to add debug logging."""
result = super().should_run_guardrail(data, event_type)
# Check if apply_guardrail is in __dict__
has_apply_guardrail = "apply_guardrail" in type(self).__dict__
verbose_proxy_logger.debug(
"GraySwan DEBUG: should_run_guardrail event_type=%s, result=%s, event_hook=%s, has_apply_guardrail=%s, class=%s",
event_type,
result,
self.event_hook,
has_apply_guardrail,
type(self).__name__,
)
return result
# ------------------------------------------------------------------
# Unified Guardrail Interface (works with ALL endpoints automatically)
# ------------------------------------------------------------------
async def apply_guardrail(
self,
user_api_key_dict: UserAPIKeyAuth,
cache,
data: dict,
call_type: Literal[
"completion",
"text_completion",
"embeddings",
"image_generation",
"moderation",
"audio_transcription",
"pass_through_endpoint",
"rerank",
"mcp_call",
"anthropic_messages",
],
) -> Optional[Union[Exception, str, dict]]:
if (
self.should_run_guardrail(
data=data, event_type=GuardrailEventHooks.pre_call
)
is not True
):
return data
inputs: GenericGuardrailAPIInputs,
request_data: dict,
input_type: Literal["request", "response"],
logging_obj: Optional["LiteLLMLoggingObj"] = None,
) -> GenericGuardrailAPIInputs:
"""
Apply Gray Swan guardrail to extracted text content.
verbose_proxy_logger.debug("Gray Swan Guardrail: pre-call hook triggered")
This method is called by the unified guardrail system which handles
extracting text from any request format (OpenAI, Anthropic, etc.).
messages = data.get("messages")
if not messages:
verbose_proxy_logger.debug("Gray Swan Guardrail: No messages in data")
return data
Args:
inputs: Dictionary containing:
- texts: List of texts to scan
- images: Optional list of images (not currently used by GraySwan)
- tool_calls: Optional list of tool calls (not currently used)
request_data: The original request data
input_type: "request" for pre-call, "response" for post-call
logging_obj: Optional logging object
dynamic_body = self.get_guardrail_dynamic_request_body_params(data) or {}
Returns:
GenericGuardrailAPIInputs - texts may be replaced with violation message in passthrough mode
Raises:
HTTPException: If content is blocked (block mode)
Exception: If guardrail check fails
"""
# DEBUG: Log when apply_guardrail is called
verbose_proxy_logger.debug(
"GraySwan DEBUG: apply_guardrail called with input_type=%s, texts=%s",
input_type,
inputs.get("texts", [])[:100] if inputs.get("texts") else "NONE",
)
texts = inputs.get("texts", [])
if not texts:
verbose_proxy_logger.debug("Gray Swan Guardrail: No texts to scan")
return inputs
verbose_proxy_logger.debug(
"Gray Swan Guardrail: Scanning %d text(s) for %s",
len(texts),
input_type,
)
# Convert texts to messages format for GraySwan API
# Use "user" role for request content, "assistant" for response content
role = "assistant" if input_type == "response" else "user"
messages = [{"role": role, "content": text} for text in texts]
# Get dynamic params from request metadata
dynamic_body = self.get_guardrail_dynamic_request_body_params(request_data) or {}
# Prepare and send payload
payload = self._prepare_payload(messages, dynamic_body)
if payload is None:
verbose_proxy_logger.debug(
"Gray Swan Guardrail: no content to scan; skipping request"
)
return data
return inputs
await self.run_grayswan_guardrail(payload, data, GuardrailEventHooks.pre_call)
add_guardrail_to_applied_guardrails_header(
request_data=data, guardrail_name=self.guardrail_name
# Call GraySwan API
response_json = await self._call_grayswan_api(payload)
# Process response
is_output = input_type == "response"
result = self._process_response_internal(
response_json=response_json,
request_data=request_data,
inputs=inputs,
is_output=is_output,
)
return data
@log_guardrail_information
async def async_moderation_hook(
self,
data: dict,
user_api_key_dict: UserAPIKeyAuth,
call_type: Literal[
"completion",
"embeddings",
"image_generation",
"moderation",
"audio_transcription",
"responses",
"mcp_call",
"anthropic_messages",
],
) -> Optional[Union[Exception, str, dict]]:
if (
self.should_run_guardrail(
data=data, event_type=GuardrailEventHooks.during_call
)
is not True
):
return data
verbose_proxy_logger.debug("GraySwan Guardrail: during-call hook triggered")
messages = data.get("messages")
if not messages:
verbose_proxy_logger.debug("Gray Swan Guardrail: No messages in data")
return data
dynamic_body = self.get_guardrail_dynamic_request_body_params(data) or {}
payload = self._prepare_payload(messages, dynamic_body)
if payload is None:
verbose_proxy_logger.debug(
"Gray Swan Guardrail: no content to scan; skipping request"
)
return data
await self.run_grayswan_guardrail(
payload, data, GuardrailEventHooks.during_call
)
add_guardrail_to_applied_guardrails_header(
request_data=data, guardrail_name=self.guardrail_name
)
return data
@log_guardrail_information
async def async_post_call_success_hook(
self,
data: dict,
user_api_key_dict: UserAPIKeyAuth,
response: LLMResponseTypes,
) -> LLMResponseTypes:
if (
self.should_run_guardrail(
data=data, event_type=GuardrailEventHooks.post_call
)
is not True
):
return response
verbose_proxy_logger.debug("GraySwan Guardrail: post-call hook triggered")
response_dict = response.model_dump() if hasattr(response, "model_dump") else {} # type: ignore[union-attr]
response_messages = [
msg if isinstance(msg, dict) else msg.model_dump()
for choice in response_dict.get("choices", [])
if isinstance(choice, dict)
for msg in [choice.get("message")]
if msg is not None
]
if not response_messages:
verbose_proxy_logger.debug(
"Gray Swan Guardrail: no response messages detected; skipping post-call scan"
)
return response
dynamic_body = self.get_guardrail_dynamic_request_body_params(data) or {}
payload = self._prepare_payload(response_messages, dynamic_body)
if payload is None:
verbose_proxy_logger.debug(
"Gray Swan Guardrail: no content to scan; skipping request"
)
return response
await self.run_grayswan_guardrail(payload, data, GuardrailEventHooks.post_call)
# If passthrough mode and detection info exists, replace response content with violation message
if self.on_flagged_action == "passthrough" and "metadata" in data:
guardrail_detections = data.get("metadata", {}).get(
"guardrail_detections", []
)
if guardrail_detections:
# Replace the model response content with guardrail violation message
violation_message = self._format_violation_message(
guardrail_detections, is_output=True
)
# Handle ModelResponse (OpenAI-style chat/text completions)
# Use isinstance to narrow the type for mypy
if isinstance(response, ModelResponse) and response.choices:
verbose_proxy_logger.debug(
"Gray Swan Guardrail: Replacing response content in ModelResponse format"
)
for choice in response.choices:
# Handle chat completion format (message.content)
# Choices has message attribute, StreamingChoices has delta
if isinstance(choice, Choices) and hasattr(choice, "message") and hasattr(
choice.message, "content"
):
choice.message.content = violation_message
# Handle text completion format (text)
# Text attribute might be set dynamically, use setattr
elif hasattr(choice, "text"):
setattr(choice, "text", violation_message)
# Update finish_reason to indicate content filtering
if hasattr(choice, "finish_reason"):
choice.finish_reason = "content_filter"
# Handle AnthropicMessagesResponse format
elif hasattr(response, "content") and isinstance(response.content, list): # type: ignore
verbose_proxy_logger.debug(
"Gray Swan Guardrail: Replacing response content in Anthropic Messages format"
)
# Replace content blocks with text block containing violation message
response.content = [ # type: ignore
{"type": "text", "text": violation_message}
]
# Update stop_reason if present
if hasattr(response, "stop_reason"):
response.stop_reason = "end_turn" # type: ignore
else:
verbose_proxy_logger.warning(
"Gray Swan Guardrail: Passthrough mode enabled but response format not recognized. "
"Cannot replace content. Response type: %s",
type(response).__name__,
)
add_guardrail_to_applied_guardrails_header(
request_data=data, guardrail_name=self.guardrail_name
)
return response
return result
# ------------------------------------------------------------------
# Core GraySwan interaction
# Legacy Test Interface (for backward compatibility)
# ------------------------------------------------------------------
async def run_grayswan_guardrail(
async def run_grayswan_guardrail(self, payload: dict) -> Dict[str, Any]:
"""
Run the GraySwan guardrail on a payload.
This is a legacy method for testing purposes.
Args:
payload: The payload to scan
Returns:
Dict containing the GraySwan API response
"""
response_json = await self._call_grayswan_api(payload)
# Call the legacy response processor (for test compatibility)
self._process_grayswan_response(response_json)
return response_json
def _process_grayswan_response(
self,
payload: dict,
response_json: dict,
data: Optional[dict] = None,
hook_type: Optional[GuardrailEventHooks] = None,
):
) -> None:
"""
Legacy method for processing GraySwan API responses.
This method is maintained for backward compatibility with existing tests.
It handles the test scenarios where responses need to be processed with
knowledge of the request context (pre/during/post call hooks).
Args:
response_json: Response from GraySwan API
data: Optional request data (for passthrough exceptions)
hook_type: Optional GuardrailEventHooks for determining behavior
"""
violation_score = float(response_json.get("violation", 0.0) or 0.0)
violated_rules = response_json.get("violated_rules", [])
mutation_detected = response_json.get("mutation")
ipi_detected = response_json.get("ipi")
flagged = violation_score >= self.violation_threshold
if not flagged:
verbose_proxy_logger.debug(
"Gray Swan Guardrail: content passed (score=%s, threshold=%s)",
violation_score,
self.violation_threshold,
)
return
verbose_proxy_logger.warning(
"Gray Swan Guardrail: violation score %.3f exceeds threshold %.3f",
violation_score,
self.violation_threshold,
)
detection_info = {
"guardrail": "grayswan",
"flagged": True,
"violation_score": violation_score,
"violated_rules": violated_rules,
"mutation": mutation_detected,
"ipi": ipi_detected,
}
# Determine if this is input (pre-call/during-call) or output (post-call)
if hook_type is not None:
is_input = hook_type in [
GuardrailEventHooks.pre_call,
GuardrailEventHooks.during_call,
]
else:
is_input = True
if self.on_flagged_action == "block":
violation_location = "output" if (not is_input) else "input"
raise HTTPException(
status_code=400,
detail={
"error": "Blocked by Gray Swan Guardrail",
"violation_location": violation_location,
"violation": violation_score,
"violated_rules": violated_rules,
"mutation": mutation_detected,
"ipi": ipi_detected,
},
)
elif self.on_flagged_action == "passthrough":
# For passthrough mode, we need to handle violations
detections = [detection_info]
violation_message = self._format_violation_message(
detections, is_output=not is_input
)
verbose_proxy_logger.info(
"Gray Swan Guardrail: Passthrough mode - handling violation"
)
# If hook_type is provided and in pre/during call, raise exception
if hook_type in [GuardrailEventHooks.pre_call, GuardrailEventHooks.during_call]:
# Raise ModifyResponseException to short-circuit LLM call
if data is None:
data = {}
self.raise_passthrough_exception(
violation_message=violation_message,
request_data=data,
detection_info=detection_info,
)
elif hook_type == GuardrailEventHooks.post_call:
# For post-call, store detection info in metadata
if data is None:
data = {}
if "metadata" not in data:
data["metadata"] = {}
if "guardrail_detections" not in data["metadata"]:
data["metadata"]["guardrail_detections"] = []
data["metadata"]["guardrail_detections"].append(detection_info)
# ------------------------------------------------------------------
# Core GraySwan API interaction
# ------------------------------------------------------------------
async def _call_grayswan_api(self, payload: dict) -> Dict[str, Any]:
"""Call the GraySwan monitoring API."""
headers = self._prepare_headers()
try:
@@ -326,15 +356,107 @@ class GraySwanGuardrail(CustomGuardrail):
verbose_proxy_logger.debug(
"Gray Swan Guardrail: monitor response %s", safe_dumps(result)
)
return result
except HTTPException:
raise
except Exception as exc: # pragma: no cover - depends on HTTP client behaviour
except Exception as exc:
verbose_proxy_logger.exception(
"Gray Swan Guardrail: API request failed: %s", exc
)
raise GraySwanGuardrailAPIError(str(exc)) from exc
self._process_grayswan_response(result, data, hook_type)
def _process_response_internal(
self,
response_json: Dict[str, Any],
request_data: dict,
inputs: GenericGuardrailAPIInputs,
is_output: bool,
) -> GenericGuardrailAPIInputs:
"""
Process GraySwan API response and handle violations.
Args:
response_json: Response from GraySwan API
request_data: Original request data
inputs: The inputs being scanned
is_output: True if scanning model output, False for input
Returns:
GenericGuardrailAPIInputs - possibly modified with violation message
Raises:
HTTPException: If content is blocked (block mode)
"""
violation_score = float(response_json.get("violation", 0.0) or 0.0)
violated_rules = response_json.get("violated_rule_descriptions", [])
mutation_detected = response_json.get("mutation")
ipi_detected = response_json.get("ipi")
flagged = violation_score >= self.violation_threshold
if not flagged:
verbose_proxy_logger.debug(
"Gray Swan Guardrail: content passed (score=%s, threshold=%s)",
violation_score,
self.violation_threshold,
)
return inputs
verbose_proxy_logger.warning(
"Gray Swan Guardrail: violation score %.3f exceeds threshold %.3f",
violation_score,
self.violation_threshold,
)
detection_info = {
"guardrail": "grayswan",
"flagged": True,
"violation_score": violation_score,
"violated_rules": violated_rules,
"mutation": mutation_detected,
"ipi": ipi_detected,
}
if self.on_flagged_action == "block":
violation_location = "output" if is_output else "input"
raise HTTPException(
status_code=400,
detail={
"error": "Blocked by Gray Swan Guardrail",
"violation_location": violation_location,
"violation": violation_score,
"violated_rules": violated_rules,
"mutation": mutation_detected,
"ipi": ipi_detected,
},
)
elif self.on_flagged_action == "monitor":
verbose_proxy_logger.info(
"Gray Swan Guardrail: Monitoring mode - allowing flagged content"
)
return inputs
elif self.on_flagged_action == "passthrough":
# Replace content with violation message
violation_message = self._format_violation_message(
detection_info, is_output=is_output
)
verbose_proxy_logger.info(
"Gray Swan Guardrail: Passthrough mode - replacing content with violation message"
)
if not is_output:
# For pre-call (request), raise exception to short-circuit LLM call
# and return synthetic response with violation message
self.raise_passthrough_exception(
violation_message=violation_message,
request_data=request_data,
detection_info=detection_info,
)
# For post-call (response), replace texts and let unified system apply them
inputs["texts"] = [violation_message]
return inputs
return inputs
# ------------------------------------------------------------------
# Helpers
@@ -348,10 +470,9 @@ class GraySwanGuardrail(CustomGuardrail):
}
def _prepare_payload(
self, messages: list[dict], dynamic_body: dict
self, messages: List[Dict[str, str]], dynamic_body: dict
) -> Optional[Dict[str, Any]]:
payload: Dict[str, Any] = {}
payload["messages"] = messages
payload: Dict[str, Any] = {"messages": messages}
categories = dynamic_body.get("categories") or self.categories
if categories:
@@ -367,128 +488,41 @@ class GraySwanGuardrail(CustomGuardrail):
return payload
def _process_grayswan_response(
self,
response_json: Dict[str, Any],
data: Optional[dict] = None,
hook_type: Optional[GuardrailEventHooks] = None,
) -> None:
violation_score = float(response_json.get("violation", 0.0) or 0.0)
violated_rules = response_json.get("violated_rules", [])
mutation_detected = response_json.get("mutation")
ipi_detected = response_json.get("ipi")
flagged = violation_score >= self.violation_threshold
if not flagged:
verbose_proxy_logger.debug(
"Gray Swan Guardrail: request passed (score=%s, rules=%s)",
violation_score,
violated_rules,
)
return
verbose_proxy_logger.warning(
"Gray Swan Guardrail: violation score %.3f exceeds threshold %.3f",
violation_score,
self.violation_threshold,
)
if self.on_flagged_action == "block":
# Determine if violation was in input or output
violation_location = (
"output"
if hook_type == GuardrailEventHooks.post_call
else "input"
)
raise HTTPException(
status_code=400,
detail={
"error": "Blocked by Gray Swan Guardrail",
"violation_location": violation_location,
"violation": violation_score,
"violated_rules": violated_rules,
"mutation": mutation_detected,
"ipi": ipi_detected,
},
)
elif self.on_flagged_action == "monitor":
verbose_proxy_logger.info(
"Gray Swan Guardrail: Monitoring mode - allowing flagged content to proceed"
)
elif self.on_flagged_action == "passthrough":
# Store detection info
detection_info = {
"guardrail": "grayswan",
"flagged": True,
"violation_score": violation_score,
"violated_rules": violated_rules,
"mutation": mutation_detected,
"ipi": ipi_detected,
}
# For pre_call and during_call, raise exception to short-circuit LLM call
if hook_type in (
GuardrailEventHooks.pre_call,
GuardrailEventHooks.during_call,
):
verbose_proxy_logger.info(
"Gray Swan Guardrail: Passthrough mode - raising exception to short-circuit LLM call"
)
violation_message = self._format_violation_message(
[detection_info], is_output=False
)
self.raise_passthrough_exception(
violation_message=violation_message,
request_data=data or {},
detection_info=detection_info,
)
# For post_call, store in metadata to replace response later
verbose_proxy_logger.info(
"Gray Swan Guardrail: Passthrough mode - storing detection info in metadata"
)
if data is not None:
if "metadata" not in data:
data["metadata"] = {}
if "guardrail_detections" not in data["metadata"]:
data["metadata"]["guardrail_detections"] = []
data["metadata"]["guardrail_detections"].append(detection_info)
def _format_violation_message(
self, guardrail_detections: list, is_output: bool = False
self, detection_info: Any, is_output: bool = False
) -> str:
"""
Format guardrail detections into a user-friendly violation message.
Format detection info into a user-friendly violation message.
Args:
guardrail_detections: List of detection info dictionaries
is_output: True if violation is in model output (post_call), False if in input (pre_call/during_call)
detection_info: Can be either:
- A single dict with violation_score, violated_rules, mutation, ipi keys
- A list of such dicts (legacy format)
is_output: True if violation is in model output, False if in input
Returns:
Formatted violation message string
"""
if not guardrail_detections:
return "Content was flagged by guardrail"
# Handle legacy format where detection_info is a list
if isinstance(detection_info, list) and len(detection_info) > 0:
detection_info = detection_info[0]
violation_score = detection_info.get("violation_score", 0.0)
violated_rules = detection_info.get("violated_rules", [])
mutation = detection_info.get("mutation", False)
ipi = detection_info.get("ipi", False)
# Get the most recent detection (should be from this guardrail)
detection = guardrail_detections[-1]
violation_score = detection.get("violation_score", 0.0)
violated_rules = detection.get("violated_rules", [])
mutation = detection.get("mutation", False)
ipi = detection.get("ipi", False)
# Indicate whether violation was in input or output
violation_location = "the model response" if is_output else "input query"
message_parts = [
f"Sorry I can't help with that. According to the Gray Swan Cygnal Guardrail, the {violation_location} has a violation score of {violation_score:.2f}.",
f"Sorry I can't help with that. According to the Gray Swan Cygnal Guardrail, "
f"the {violation_location} has a violation score of {violation_score:.2f}.",
]
if violated_rules:
message_parts.append(
f"It was violating the rule(s): {', '.join(map(str, violated_rules))}."
)
formatted_rules = self._format_violated_rules(violated_rules)
if formatted_rules:
message_parts.append(f"It was violating the rule(s): {formatted_rules}.")
if mutation:
message_parts.append(
@@ -496,31 +530,51 @@ class GraySwanGuardrail(CustomGuardrail):
)
if ipi:
message_parts.append("Indirect Prompt Injection was DETECTED.")
message_parts.append(
"Indirect Prompt Injection was DETECTED."
)
return "\n".join(message_parts)
def _resolve_threshold(self, threshold: Optional[float]) -> float:
if threshold is not None:
return min(max(threshold, 0.0), 1.0)
def _format_violated_rules(self, violated_rules: List) -> str:
"""Format violated rules list into a readable string."""
formatted: List[str] = []
for rule in violated_rules:
if isinstance(rule, dict):
# New format: {'rule': 6, 'name': 'Illegal Activities...', 'description': '...'}
rule_num = rule.get("rule", "")
rule_name = rule.get("name", "")
rule_desc = rule.get("description", "")
if rule_num and rule_name:
if rule_desc:
formatted.append(f"#{rule_num} {rule_name}: {rule_desc}")
else:
formatted.append(f"#{rule_num} {rule_name}")
elif rule_name:
formatted.append(rule_name)
else:
formatted.append(str(rule))
else:
# Legacy format: simple value
formatted.append(str(rule))
return ", ".join(formatted)
def _resolve_threshold(self, value: Optional[float]) -> float:
if value is not None:
return float(value)
env_val = os.getenv("GRAYSWAN_VIOLATION_THRESHOLD")
if env_val:
try:
return float(env_val)
except ValueError:
pass
return 0.5
def _resolve_reasoning_mode(self, candidate: Optional[str]) -> Optional[str]:
if candidate is None:
return None
normalised = candidate.strip().lower()
if normalised in self.SUPPORTED_REASONING_MODES:
return normalised
verbose_proxy_logger.warning(
"Gray Swan Guardrail: ignoring unsupported reasoning_mode '%s'",
candidate,
)
def _resolve_reasoning_mode(self, value: Optional[str]) -> Optional[str]:
if value and value.lower() in self.SUPPORTED_REASONING_MODES:
return value.lower()
env_val = os.getenv("GRAYSWAN_REASONING_MODE")
if env_val and env_val.lower() in self.SUPPORTED_REASONING_MODES:
return env_val.lower()
return None
@staticmethod
def get_config_model():
from litellm.types.proxy.guardrails.guardrail_hooks.grayswan import (
GraySwanGuardrailConfigModel,
)
return GraySwanGuardrailConfigModel
@@ -180,7 +180,7 @@ class UnifiedLLMGuardrails(CustomLogger):
call_type: Optional[CallTypesLiteral] = None
if user_api_key_dict.request_route is not None:
call_types = get_call_types_for_route(user_api_key_dict.request_route)
if call_types is not None:
if call_types is not None and len(call_types) > 0:
call_type = call_types[0]
if call_type is None:
call_type = _infer_call_type(call_type=None, completion_response=response)
@@ -213,7 +213,7 @@ class UnifiedLLMGuardrails(CustomLogger):
return response
async def async_post_call_streaming_iterator_hook(
async def async_post_call_streaming_iterator_hook( # noqa: PLR0915
self,
user_api_key_dict: UserAPIKeyAuth,
response: Any,
@@ -238,19 +238,36 @@ class UnifiedLLMGuardrails(CustomLogger):
"guardrail_to_apply", None
)
# Get sampling rate from guardrail config or optional_params, default to 5
# Get streaming configuration from guardrail or optional_params
sampling_rate = 5
end_of_stream_only = False # If True, only apply guardrail at end of stream
if guardrail_to_apply is not None:
# Check guardrail config first
guardrail_config = getattr(guardrail_to_apply, "guardrail_config", {})
sampling_rate = guardrail_config.get(
"streaming_sampling_rate", sampling_rate
# Check direct attributes on guardrail first
sampling_rate = getattr(
guardrail_to_apply, "streaming_sampling_rate", sampling_rate
)
end_of_stream_only = getattr(
guardrail_to_apply, "streaming_end_of_stream_only", end_of_stream_only
)
# Also check guardrail_config dict if present
guardrail_config = getattr(guardrail_to_apply, "guardrail_config", {})
if isinstance(guardrail_config, dict):
sampling_rate = guardrail_config.get(
"streaming_sampling_rate", sampling_rate
)
end_of_stream_only = guardrail_config.get(
"streaming_end_of_stream_only", end_of_stream_only
)
# Also check optional_params as fallback
sampling_rate = self.optional_params.get(
"streaming_sampling_rate", sampling_rate
)
end_of_stream_only = self.optional_params.get(
"streaming_end_of_stream_only", end_of_stream_only
)
if guardrail_to_apply is None:
async for item in response:
@@ -306,6 +323,11 @@ class UnifiedLLMGuardrails(CustomLogger):
yield remaining_item
return
# If end_of_stream_only mode, yield chunks without processing
if end_of_stream_only:
yield item
continue
# Process chunk based on sampling rate
if chunk_counter % sampling_rate == 0:
@@ -19,6 +19,10 @@ from litellm.types.guardrails import (
LitellmParams,
SupportedGuardrailIntegrations,
)
from litellm.proxy.guardrails.guardrail_hooks.grayswan import (
GraySwanGuardrail,
initialize_guardrail as initialize_grayswan,
)
from .guardrail_initializers import (
initialize_bedrock,
@@ -36,9 +40,12 @@ guardrail_initializer_registry = {
SupportedGuardrailIntegrations.PRESIDIO.value: initialize_presidio,
SupportedGuardrailIntegrations.HIDE_SECRETS.value: initialize_hide_secrets,
SupportedGuardrailIntegrations.TOOL_PERMISSION.value: initialize_tool_permission,
SupportedGuardrailIntegrations.GRAYSWAN.value: initialize_grayswan,
}
guardrail_class_registry: Dict[str, Type[CustomGuardrail]] = {}
guardrail_class_registry: Dict[str, Type[CustomGuardrail]] = {
SupportedGuardrailIntegrations.GRAYSWAN.value: GraySwanGuardrail
}
def get_guardrail_initializer_from_hooks():
+67 -20
View File
@@ -5,6 +5,7 @@ import io
import os
import random
import secrets
import shutil
import subprocess
import sys
import time
@@ -939,31 +940,68 @@ origins = ["*"]
# get current directory
try:
current_dir = os.path.dirname(os.path.abspath(__file__))
ui_path = os.path.join(current_dir, "_experimental", "out")
packaged_ui_path = os.path.join(current_dir, "_experimental", "out")
ui_path = packaged_ui_path
litellm_asset_prefix = "/litellm-asset-prefix"
# For non-root Docker, use the pre-built UI from /tmp/litellm_ui
# Support both "true" and "True" for case-insensitive comparison
if os.getenv("LITELLM_NON_ROOT", "").lower() == "true":
non_root_ui_path = "/tmp/litellm_ui"
def _dir_has_content(path: str) -> bool:
try:
return os.path.isdir(path) and any(os.scandir(path))
except FileNotFoundError:
return False
# Check if the UI was built and exists at the expected location
if os.path.exists(non_root_ui_path) and os.listdir(non_root_ui_path):
# Use a writable runtime UI directory whenever possible.
# This prevents mutating the packaged UI directory (e.g. site-packages or the repo checkout)
# and ensures extensionless routes like /ui/login work via <route>/index.html.
is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true"
runtime_ui_path = "/tmp/litellm_ui"
if _dir_has_content(runtime_ui_path):
if is_non_root:
verbose_proxy_logger.info(
f"Using pre-built UI for non-root Docker: {non_root_ui_path}"
f"Using pre-built UI for non-root Docker: {runtime_ui_path}"
)
verbose_proxy_logger.info(
f"UI files found: {len(os.listdir(non_root_ui_path))} items"
)
ui_path = non_root_ui_path
else:
verbose_proxy_logger.info(
f"Using cached runtime UI directory: {runtime_ui_path}"
)
ui_path = runtime_ui_path
else:
if is_non_root:
verbose_proxy_logger.error(
f"UI not found at {non_root_ui_path}. UI will not be available."
f"UI not found at {runtime_ui_path}. Attempting to populate it from packaged UI."
)
verbose_proxy_logger.error(
f"Path exists: {os.path.exists(non_root_ui_path)}, Has content: {os.path.exists(non_root_ui_path) and bool(os.listdir(non_root_ui_path))}"
f"Path exists: {os.path.exists(runtime_ui_path)}, Has content: {_dir_has_content(runtime_ui_path)}"
)
try:
os.makedirs(runtime_ui_path, exist_ok=True)
if not _dir_has_content(runtime_ui_path) and _dir_has_content(
packaged_ui_path
):
shutil.copytree(
packaged_ui_path,
runtime_ui_path,
dirs_exist_ok=True,
)
except Exception as e:
if is_non_root:
verbose_proxy_logger.exception(
f"Failed to populate runtime UI directory {runtime_ui_path} from {packaged_ui_path}: {e}"
)
else:
if _dir_has_content(runtime_ui_path):
if is_non_root:
verbose_proxy_logger.info(
f"Using populated UI for non-root Docker: {runtime_ui_path}"
)
else:
verbose_proxy_logger.info(
f"Using populated runtime UI directory: {runtime_ui_path}"
)
ui_path = runtime_ui_path
# Only modify files if a custom server root path is set
if server_root_path and server_root_path != "/":
# Iterate through files in the UI directory
@@ -1042,16 +1080,25 @@ try:
target_path = os.path.join(target_dir, "index.html")
os.makedirs(target_dir, exist_ok=True)
os.replace(file_path, target_path)
try:
os.replace(file_path, target_path)
except FileNotFoundError:
# Another process may have already moved this file.
continue
# Handle HTML file restructuring
# Skip this for non-root Docker since it's done at build time
# Support both "true" and "True" for case-insensitive comparison
if os.getenv("LITELLM_NON_ROOT", "").lower() != "true":
_restructure_ui_html_files(ui_path)
# Always restructure the directory we actually serve, but avoid mutating the packaged UI.
# This is critical for extensionless routes like /ui/login (expects login/index.html).
if ui_path != packaged_ui_path:
try:
_restructure_ui_html_files(ui_path)
except PermissionError as e:
verbose_proxy_logger.exception(
f"Permission error while restructuring UI directory {ui_path}: {e}"
)
else:
verbose_proxy_logger.info(
"Skipping runtime HTML restructuring for non-root Docker (already done at build time)"
f"Skipping runtime HTML restructuring for packaged UI directory: {ui_path}"
)
except Exception:
@@ -1,12 +1,16 @@
import asyncio
import time
from typing import Any, AsyncIterator, cast
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_guardrail import ModifyResponseException
from litellm.proxy._types import *
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
from litellm.types.responses.main import DeleteResponseResult
router = APIRouter()
@@ -169,6 +173,28 @@ async def responses_api(
user_api_base=user_api_base,
version=version,
)
except ModifyResponseException as e:
# Guardrail passthrough: return violation message in Responses API format (200)
_data = e.request_data
await proxy_logging_obj.post_call_failure_hook(
user_api_key_dict=user_api_key_dict,
original_exception=e,
request_data=_data,
)
violation_text = e.message
response_obj = ResponsesAPIResponse(
id=f"resp_{uuid4()}",
object="response",
created_at=int(time.time()),
model=e.model or data.get("model"),
output=cast(Any, [{"content": [{"type": "text", "text": violation_text}]}]),
status="completed",
usage=ResponseAPIUsage(
input_tokens=0, output_tokens=0, total_tokens=0
),
)
return response_obj
except Exception as e:
raise await processor._handle_llm_api_exception(
e=e,
@@ -90,6 +90,7 @@ class LiteLLMCompletionResponsesConfig:
"metadata",
"parallel_tool_calls",
"previous_response_id",
"reasoning",
"stream",
"temperature",
"text",
@@ -178,6 +179,17 @@ class LiteLLMCompletionResponsesConfig:
text_param
)
# Extract reasoning_effort from reasoning parameter
reasoning_effort = None
reasoning_param = responses_api_request.get("reasoning")
if reasoning_param:
if isinstance(reasoning_param, dict):
# reasoning can be {"effort": "low|medium|high"}
reasoning_effort = reasoning_param.get("effort")
elif isinstance(reasoning_param, str):
# reasoning could be a string directly
reasoning_effort = reasoning_param
litellm_completion_request: dict = {
"messages": LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=input,
@@ -198,6 +210,7 @@ class LiteLLMCompletionResponsesConfig:
"service_tier": kwargs.get("service_tier"),
"web_search_options": web_search_options,
"response_format": response_format,
"reasoning_effort": reasoning_effort,
# litellm specific params
"custom_llm_provider": custom_llm_provider,
"extra_headers": extra_headers,
@@ -219,7 +232,6 @@ class LiteLLMCompletionResponsesConfig:
litellm_completion_request = {
k: v for k, v in litellm_completion_request.items() if v is not None
}
return litellm_completion_request
@staticmethod
+1 -1
View File
@@ -169,7 +169,7 @@ class SafetSettingsConfig(TypedDict, total=False):
class GeminiThinkingConfig(TypedDict, total=False):
includeThoughts: bool
thinkingBudget: int
thinkingLevel: Literal["low", "medium", "high"]
thinkingLevel: Literal["minimal", "low", "medium", "high"]
GeminiResponseModalities = Literal["TEXT", "IMAGE", "AUDIO", "VIDEO"]
+2
View File
@@ -7258,6 +7258,8 @@ class ProviderConfigManager:
return litellm.AzureOpenAIGPT5Config()
return litellm.AzureOpenAIConfig()
elif litellm.LlmProviders.AZURE_AI == provider:
if "claude" in model.lower():
return litellm.AzureAnthropicConfig()
return litellm.AzureAIStudioConfig()
elif litellm.LlmProviders.AZURE_TEXT == provider:
return litellm.AzureOpenAITextConfig()
+120
View File
@@ -14732,6 +14732,98 @@
"supports_web_search": true,
"tpm": 800000
},
"gemini/gemini-3-flash-preview": {
"cache_read_input_token_cost": 5e-08,
"input_cost_per_audio_token": 1e-06,
"input_cost_per_token": 5e-07,
"litellm_provider": "gemini",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65535,
"max_pdf_size_mb": 30,
"max_tokens": 65535,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "chat",
"output_cost_per_reasoning_token": 3e-06,
"output_cost_per_token": 3e-06,
"rpm": 2000,
"source": "https://ai.google.dev/pricing/gemini-3",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_audio_output": false,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true,
"tpm": 800000
},
"gemini-3-flash-preview": {
"cache_read_input_token_cost": 5e-08,
"input_cost_per_audio_token": 1e-06,
"input_cost_per_token": 5e-07,
"litellm_provider": "vertex_ai-language-models",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65535,
"max_pdf_size_mb": 30,
"max_tokens": 65535,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "chat",
"output_cost_per_reasoning_token": 3e-06,
"output_cost_per_token": 3e-06,
"source": "https://ai.google.dev/pricing/gemini-3",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_audio_output": false,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true
},
"gemini/gemini-2.5-pro-exp-03-25": {
"cache_read_input_token_cost": 0.0,
"input_cost_per_token": 0.0,
@@ -16673,6 +16765,34 @@
"/v1/audio/transcriptions"
]
},
"gpt-image-1.5": {
"cache_read_input_image_token_cost": 2e-06,
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_token": 1e-05,
"input_cost_per_image_token": 8e-06,
"output_cost_per_image_token": 3.2e-05,
"supported_endpoints": [
"/v1/images/generations"
],
"supports_vision": true
},
"gpt-image-1.5-2025-12-16": {
"cache_read_input_image_token_cost": 2e-06,
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_token": 1e-05,
"input_cost_per_image_token": 8e-06,
"output_cost_per_image_token": 3.2e-05,
"supported_endpoints": [
"/v1/images/generations"
],
"supports_vision": true
},
"gpt-5": {
"cache_read_input_token_cost": 1.25e-07,
"cache_read_input_token_cost_flex": 6.25e-08,
+1 -1
View File
@@ -228,4 +228,4 @@ general_settings:
# settings for using redis caching
# REDIS_HOST: redis-16337.c322.us-east-1-2.ec2.cloud.redislabs.com
# REDIS_PORT: "16337"
# REDIS_PASSWORD:
# REDIS_PASSWORD:
@@ -1124,6 +1124,124 @@ def test_get_custom_labels_from_metadata_tags(monkeypatch):
assert get_custom_labels_from_metadata(metadata) == {}
def test_get_custom_labels_from_top_level_metadata(monkeypatch):
"""
Test that get_custom_labels_from_metadata can extract fields from top-level metadata,
such as requester_ip_address, not just from nested dictionaries like requester_metadata.
"""
monkeypatch.setattr(
"litellm.custom_prometheus_metadata_labels",
["requester_ip_address", "user_api_key_alias"],
)
# Simulate metadata structure with top-level fields
metadata = {
"requester_ip_address": "10.48.203.20", # Top-level field
"user_api_key_alias": "TestAlias", # Top-level field
"requester_metadata": {"nested_field": "nested_value"}, # Nested dict (excluded)
"user_api_key_auth_metadata": {"another_nested": "value"}, # Nested dict (excluded)
}
result = get_custom_labels_from_metadata(metadata)
assert result == {
"requester_ip_address": "10.48.203.20",
"user_api_key_alias": "TestAlias",
}
def test_get_custom_labels_from_top_level_and_nested_metadata(monkeypatch):
"""
Test that get_custom_labels_from_metadata can extract fields from both top-level
and nested metadata (requester_metadata, user_api_key_auth_metadata).
"""
monkeypatch.setattr(
"litellm.custom_prometheus_metadata_labels",
[
"requester_ip_address", # Top-level
"metadata.foo", # From requester_metadata
"metadata.bar", # From user_api_key_auth_metadata
],
)
# Simulate combined_metadata structure as it would appear after merging
# This is what gets passed to get_custom_labels_from_metadata
combined_metadata = {
"requester_ip_address": "10.48.203.20", # Top-level field
"foo": "bar_value", # From requester_metadata (spread)
"bar": "baz_value", # From user_api_key_auth_metadata (spread)
}
result = get_custom_labels_from_metadata(combined_metadata)
assert result == {
"requester_ip_address": "10.48.203.20",
"metadata_foo": "bar_value",
"metadata_bar": "baz_value",
}
async def test_async_log_success_event_with_top_level_metadata(prometheus_logger, monkeypatch):
"""
Test that async_log_success_event correctly extracts custom labels from top-level metadata
fields like requester_ip_address, not just from nested dictionaries.
"""
# Configure custom metadata labels to extract requester_ip_address
monkeypatch.setattr(
"litellm.custom_prometheus_metadata_labels", ["requester_ip_address"]
)
# Create standard logging payload with requester_ip_address at top-level metadata
standard_logging_object = create_standard_logging_payload()
standard_logging_object["metadata"]["requester_ip_address"] = "10.48.203.20"
standard_logging_object["metadata"]["requester_metadata"] = {} # Empty nested dict
standard_logging_object["metadata"]["user_api_key_auth_metadata"] = {} # Empty nested dict
kwargs = {
"model": "gpt-3.5-turbo",
"stream": True,
"litellm_params": {
"metadata": {
"user_api_key": "test_key",
"user_api_key_user_id": "test_user",
"user_api_key_team_id": "test_team",
"user_api_key_end_user_id": "test_end_user",
}
},
"start_time": datetime.now(),
"completion_start_time": datetime.now(),
"api_call_start_time": datetime.now(),
"end_time": datetime.now() + timedelta(seconds=1),
"standard_logging_object": standard_logging_object,
}
response_obj = MagicMock()
# Mock the prometheus client methods
prometheus_logger.litellm_requests_metric = MagicMock()
prometheus_logger.litellm_spend_metric = MagicMock()
prometheus_logger.litellm_tokens_metric = MagicMock()
prometheus_logger.litellm_input_tokens_metric = MagicMock()
prometheus_logger.litellm_output_tokens_metric = MagicMock()
prometheus_logger.litellm_remaining_team_budget_metric = MagicMock()
prometheus_logger.litellm_remaining_api_key_budget_metric = MagicMock()
prometheus_logger.litellm_remaining_api_key_requests_for_model = MagicMock()
prometheus_logger.litellm_remaining_api_key_tokens_for_model = MagicMock()
prometheus_logger.litellm_llm_api_time_to_first_token_metric = MagicMock()
prometheus_logger.litellm_llm_api_latency_metric = MagicMock()
prometheus_logger.litellm_request_total_latency_metric = MagicMock()
await prometheus_logger.async_log_success_event(
kwargs, response_obj, kwargs["start_time"], kwargs["end_time"]
)
# Verify that the metrics were called with labels including requester_ip_address
# Check that labels() was called - the actual labels dict should include requester_ip_address
assert prometheus_logger.litellm_requests_metric.labels.called
assert prometheus_logger.litellm_spend_metric.labels.called
# Get the actual call arguments to verify requester_ip_address is included
# The custom labels should be extracted and included in the label factory
call_args = prometheus_logger.litellm_requests_metric.labels.call_args
assert call_args is not None
# The labels() method receives a dict with label names and values
# We can't easily assert the exact values without checking the internal implementation,
# but we've verified the function is called, which means the extraction happened
def test_get_custom_labels_from_tags(monkeypatch):
from litellm.integrations.prometheus import get_custom_labels_from_tags
+172
View File
@@ -1229,3 +1229,175 @@ def test_gemini_function_args_preserve_unicode():
assert parsed_args["recipient"] == "José"
assert "\\u" not in arguments_str
assert "José" in arguments_str
def test_anthropic_thinking_param_to_gemini_3_thinkingLevel():
"""
Test that Anthropic thinking parameters are correctly transformed to Gemini 3 thinkingLevel
instead of thinkingBudget.
For Gemini 3+ models (gemini-3-flash, gemini-3-pro, gemini-3-flash-preview):
- Should use thinkingLevel instead of thinkingBudget
- budget_tokens should map to thinkingLevel
Related issue: https://github.com/BerriAI/litellm/issues/XXXX
"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
from litellm.types.llms.anthropic import AnthropicThinkingParam
# Test 1: Anthropic thinking enabled with budget_tokens for Gemini 3 model
thinking_param: AnthropicThinkingParam = {
"type": "enabled",
"budget_tokens": 10000,
}
result = VertexGeminiConfig._map_thinking_param(
thinking_param=thinking_param,
model="gemini-3-flash",
)
# For Gemini 3, should use thinkingLevel, not thinkingBudget
assert "thinkingLevel" in result, "Should have thinkingLevel for Gemini 3"
assert "thinkingBudget" not in result, "Should NOT have thinkingBudget for Gemini 3"
assert result["includeThoughts"] is True
assert result["thinkingLevel"] in ["minimal", "low"], "thinkingLevel should be 'minimal' or 'low'"
# Test 2: Anthropic thinking disabled for Gemini 3
thinking_param_disabled: AnthropicThinkingParam = {
"type": "disabled",
"budget_tokens": None,
}
result_disabled = VertexGeminiConfig._map_thinking_param(
thinking_param=thinking_param_disabled,
model="gemini-3-pro-preview",
)
assert result_disabled.get("includeThoughts") is False
assert "thinkingLevel" not in result_disabled or result_disabled.get("thinkingLevel") is None
# Test 3: Budget tokens = 0 for Gemini 3
thinking_param_zero: AnthropicThinkingParam = {
"type": "enabled",
"budget_tokens": 0,
}
result_zero = VertexGeminiConfig._map_thinking_param(
thinking_param=thinking_param_zero,
model="gemini-3-flash",
)
assert result_zero["includeThoughts"] is False
assert "thinkingLevel" not in result_zero or result_zero.get("thinkingLevel") is None
# Test 4: Fiercefalcon model (Gemini 3 Flash checkpoint) should use thinkingLevel
result_gemini3flashpreview = VertexGeminiConfig._map_thinking_param(
thinking_param=thinking_param,
model="gemini-3-flash-preview",
)
assert "thinkingLevel" in result_gemini3flashpreview, "Should have thinkingLevel for gemini-3-flash-preview"
assert "thinkingBudget" not in result_gemini3flashpreview, "Should NOT have thinkingBudget for gemini-3-flash-preview"
assert result_gemini3flashpreview["includeThoughts"] is True
def test_anthropic_thinking_param_to_gemini_2_thinkingBudget():
"""
Test that Anthropic thinking parameters are correctly transformed to Gemini 2 thinkingBudget
(not thinkingLevel).
For Gemini 2.x models (gemini-2.5-flash, gemini-2.0-flash):
- Should continue using thinkingBudget
- thinkingLevel should NOT be used
Related issue: https://github.com/BerriAI/litellm/issues/XXXX
"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
from litellm.types.llms.anthropic import AnthropicThinkingParam
# Test 1: Anthropic thinking enabled with budget_tokens for Gemini 2 model
thinking_param: AnthropicThinkingParam = {
"type": "enabled",
"budget_tokens": 10000,
}
result = VertexGeminiConfig._map_thinking_param(
thinking_param=thinking_param,
model="gemini-2.5-flash",
)
# For Gemini 2, should use thinkingBudget, not thinkingLevel
assert "thinkingBudget" in result, "Should have thinkingBudget for Gemini 2"
assert "thinkingLevel" not in result, "Should NOT have thinkingLevel for Gemini 2"
assert result["includeThoughts"] is True
assert result["thinkingBudget"] == 10000
# Test 2: Anthropic thinking enabled for gemini-2.0-flash model
result_gemini2 = VertexGeminiConfig._map_thinking_param(
thinking_param=thinking_param,
model="gemini-2.0-flash-thinking-exp-01-21",
)
assert "thinkingBudget" in result_gemini2, "Should have thinkingBudget for Gemini 2"
assert "thinkingLevel" not in result_gemini2, "Should NOT have thinkingLevel for Gemini 2"
assert result_gemini2["includeThoughts"] is True
assert result_gemini2["thinkingBudget"] == 10000
def test_anthropic_thinking_param_via_map_openai_params():
"""
Test that the thinking parameter is correctly transformed through the full map_openai_params flow
for Gemini 3 models, resulting in thinkingConfig with thinkingLevel.
This tests the full integration from Anthropic API format to Gemini format.
"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
from litellm.types.llms.anthropic import AnthropicThinkingParam
config = VertexGeminiConfig()
# Test with Gemini 3 model
non_default_params = {
"thinking": {
"type": "enabled",
"budget_tokens": 10000,
}
}
optional_params: dict = {}
result = config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model="gemini-3-flash",
drop_params=False,
)
# Check that thinkingConfig was created with thinkingLevel
assert "thinkingConfig" in result, "Should have thinkingConfig in optional_params"
thinking_config = result["thinkingConfig"]
assert "thinkingLevel" in thinking_config, "Should have thinkingLevel for Gemini 3"
assert "thinkingBudget" not in thinking_config, "Should NOT have thinkingBudget for Gemini 3"
assert thinking_config["includeThoughts"] is True
# Test with Gemini 2 model
optional_params_2 = {}
result_2 = config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params_2,
model="gemini-2.5-flash",
drop_params=False,
)
# Check that thinkingConfig was created with thinkingBudget
assert "thinkingConfig" in result_2, "Should have thinkingConfig in optional_params"
thinking_config_2 = result_2["thinkingConfig"]
assert "thinkingBudget" in thinking_config_2, "Should have thinkingBudget for Gemini 2"
assert "thinkingLevel" not in thinking_config_2, "Should NOT have thinkingLevel for Gemini 2"
assert thinking_config_2["includeThoughts"] is True
assert thinking_config_2["thinkingBudget"] == 10000
+53
View File
@@ -311,3 +311,56 @@ def test_get_internal_user_header_from_mapping_no_internal_returns_none():
single_mapping = {"header_name": "X-Only-Customer", "litellm_user_role": "customer"}
result = LiteLLMProxyRequestSetup.get_internal_user_header_from_mapping(single_mapping)
assert result is None
@pytest.mark.parametrize(
"request_data, route, expected_model",
[
# Vertex AI passthrough URL patterns
(
{},
"/vertex_ai/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-1.5-pro:generateContent",
"gemini-1.5-pro"
),
(
{},
"/vertex_ai/v1beta1/projects/my-project/locations/us-central1/publishers/google/models/gemini-1.0-pro:streamGenerateContent",
"gemini-1.0-pro"
),
(
{},
"/vertex_ai/v1/projects/my-project/locations/asia-southeast1/publishers/google/models/gemini-2.0-flash:generateContent",
"gemini-2.0-flash"
),
# Model without method suffix (no colon) - should still extract
(
{},
"/vertex_ai/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-pro",
"gemini-pro" # Should match even without colon
),
# Request body model takes precedence over URL
(
{"model": "gpt-4o"},
"/vertex_ai/v1/projects/my-project/locations/us-central1/publishers/google/models/gemini-1.5-pro:generateContent",
"gpt-4o"
),
# Non-vertex route should not extract from vertex pattern
(
{},
"/openai/v1/chat/completions",
None
),
# Azure deployment pattern should still work
(
{},
"/openai/deployments/my-deployment/chat/completions",
"my-deployment"
),
],
)
def test_get_model_from_request_vertex_ai_passthrough(request_data, route, expected_model):
"""Test that get_model_from_request correctly extracts Vertex AI model from URL"""
from litellm.proxy.auth.auth_utils import get_model_from_request
model = get_model_from_request(request_data, route)
assert model == expected_model
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""
Test to verify the Google GenAI transformation logic for generateContent parameters
"""
import os
import sys
sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path
import pytest
from litellm.llms.gemini.google_genai.transformation import GoogleGenAIConfig
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
def test_map_generate_content_optional_params_response_json_schema_camelcase():
"""Test that responseJsonSchema (camelCase) is passed through correctly"""
config = GoogleGenAIConfig()
generate_content_config_dict = {
"responseJsonSchema": {
"type": "object",
"properties": {
"recipe_name": {"type": "string"}
}
},
"temperature": 1.0
}
result = config.map_generate_content_optional_params(
generate_content_config_dict=generate_content_config_dict,
model="gemini/gemini-3-flash-preview"
)
# responseJsonSchema should be in the result (camelCase format for Google GenAI API)
assert "responseJsonSchema" in result
assert result["responseJsonSchema"] == generate_content_config_dict["responseJsonSchema"]
assert "temperature" in result
assert result["temperature"] == 1.0
def test_map_generate_content_optional_params_response_schema_snakecase():
"""Test that response_schema (snake_case) is converted to responseJsonSchema (camelCase)"""
config = GoogleGenAIConfig()
generate_content_config_dict = {
"response_json_schema": {
"type": "object",
"properties": {
"recipe_name": {"type": "string"}
}
},
"temperature": 1.0
}
result = config.map_generate_content_optional_params(
generate_content_config_dict=generate_content_config_dict,
model="gemini/gemini-3-flash-preview"
)
# response_schema should be converted to responseJsonSchema (camelCase)
assert "responseJsonSchema" in result
assert result["responseJsonSchema"] == generate_content_config_dict["response_json_schema"]
assert "temperature" in result
def test_map_generate_content_optional_params_thinking_config_camelcase():
"""Test that thinkingConfig (camelCase) is passed through correctly"""
config = GoogleGenAIConfig()
generate_content_config_dict = {
"thinkingConfig": {
"thinkingLevel": "minimal",
"includeThoughts": True
},
"temperature": 1.0
}
result = config.map_generate_content_optional_params(
generate_content_config_dict=generate_content_config_dict,
model="gemini/gemini-3-flash-preview"
)
# thinkingConfig should be in the result (camelCase format for Google GenAI API)
assert "thinkingConfig" in result
assert result["thinkingConfig"]["thinkingLevel"] == "minimal"
assert result["thinkingConfig"]["includeThoughts"] is True
assert "temperature" in result
def test_map_generate_content_optional_params_thinking_config_snakecase():
"""Test that thinking_config (snake_case) is converted to thinkingConfig (camelCase)"""
config = GoogleGenAIConfig()
generate_content_config_dict = {
"thinking_config": {
"thinkingLevel": "medium",
"includeThoughts": True
},
"temperature": 1.0
}
result = config.map_generate_content_optional_params(
generate_content_config_dict=generate_content_config_dict,
model="gemini/gemini-3-flash-preview"
)
# thinking_config should be converted to thinkingConfig (camelCase)
assert "thinkingConfig" in result
assert result["thinkingConfig"]["thinkingLevel"] == "medium"
assert result["thinkingConfig"]["includeThoughts"] is True
assert "thinking_config" not in result # Should not be in snake_case format
assert "temperature" in result
def test_map_generate_content_optional_params_mixed_formats():
"""Test that both camelCase and snake_case parameters work together"""
config = GoogleGenAIConfig()
generate_content_config_dict = {
"responseJsonSchema": {
"type": "object",
"properties": {
"recipe_name": {"type": "string"}
}
},
"thinking_config": {
"thinkingLevel": "low",
"includeThoughts": True
},
"temperature": 1.0,
"max_output_tokens": 100
}
result = config.map_generate_content_optional_params(
generate_content_config_dict=generate_content_config_dict,
model="gemini/gemini-3-flash-preview"
)
# All parameters should be converted to camelCase
assert "responseJsonSchema" in result
assert "thinkingConfig" in result
assert result["thinkingConfig"]["thinkingLevel"] == "low"
assert "temperature" in result
assert "maxOutputTokens" in result # This one stays as-is if it's in supported list
def test_map_generate_content_optional_params_response_mime_type():
"""Test that responseMimeType is handled correctly"""
config = GoogleGenAIConfig()
generate_content_config_dict = {
"responseMimeType": "application/json",
"responseJsonSchema": {
"type": "object",
"properties": {
"recipe_name": {"type": "string"}
}
}
}
result = config.map_generate_content_optional_params(
generate_content_config_dict=generate_content_config_dict,
model="gemini/gemini-3-flash-preview"
)
# responseMimeType should be passed through (it's already camelCase)
assert "responseMimeType" in result or "response_mime_type" in result
assert "responseJsonSchema" in result
def test_responses_api_reasoning_dict_format():
"""Test that reasoning parameter with dict format is mapped to reasoning_effort"""
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
responses_api_request: ResponsesAPIOptionalRequestParams = {
"reasoning": {"effort": "high"},
"temperature": 1.0,
}
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="gemini/2.5-pro",
input="Hello, what is the capital of France?",
responses_api_request=responses_api_request,
)
# reasoning_effort should be extracted from reasoning dict
assert "reasoning_effort" in result
assert result["reasoning_effort"] == "high"
def test_responses_api_reasoning_string_format():
"""Test that reasoning parameter with string format is mapped to reasoning_effort"""
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
responses_api_request: ResponsesAPIOptionalRequestParams = {
"reasoning": "medium", # Could be a string directly
"temperature": 1.0,
}
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="gemini/2.5-pro",
input="Hello, what is the capital of France?",
responses_api_request=responses_api_request,
)
# reasoning_effort should be extracted from reasoning string
assert "reasoning_effort" in result
assert result["reasoning_effort"] == "medium"
def test_responses_api_reasoning_low_effort():
"""Test that low reasoning effort is correctly mapped"""
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
responses_api_request: ResponsesAPIOptionalRequestParams = {
"reasoning": {"effort": "low"},
}
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="gemini/2.5-pro",
input="Test",
responses_api_request=responses_api_request,
)
assert "reasoning_effort" in result
assert result["reasoning_effort"] == "low"
def test_responses_api_no_reasoning():
"""Test that no reasoning_effort is included when reasoning is not provided"""
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
responses_api_request: ResponsesAPIOptionalRequestParams = {
"temperature": 1.0,
}
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="gemini/2.5-pro",
input="Test",
responses_api_request=responses_api_request,
)
# reasoning_effort should not be in result if not provided (filtered out as None)
assert "reasoning_effort" not in result or result.get("reasoning_effort") is None
@@ -0,0 +1,170 @@
from typing import Any, Dict, List
from unittest.mock import MagicMock, patch
import pytest
import litellm
from litellm.images.utils import ImageEditRequestUtils
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
from litellm.types.images.main import ImageEditOptionalRequestParams
class MockImageEditConfig(BaseImageEditConfig):
def get_supported_openai_params(self, model: str) -> List[str]:
return ["size", "quality"]
def map_openai_params(
self,
image_edit_optional_params: ImageEditOptionalRequestParams,
model: str,
drop_params: bool,
) -> Dict[str, Any]:
return dict(image_edit_optional_params)
def get_complete_url(
self, model: str, api_base: str, litellm_params: dict
) -> str:
return "https://example.com/api"
def validate_environment(
self, headers: dict, model: str, api_key: str = None
) -> dict:
return headers
def transform_image_edit_request(self, *args, **kwargs):
return {}, []
def transform_image_edit_response(self, *args, **kwargs):
return MagicMock()
class TestImageEditRequestUtilsDropParams:
def setup_method(self):
self.config = MockImageEditConfig()
self.model = "test-model"
self._original_drop_params = getattr(litellm, "drop_params", None)
def teardown_method(self):
if self._original_drop_params is None:
if hasattr(litellm, "drop_params"):
delattr(litellm, "drop_params")
else:
litellm.drop_params = self._original_drop_params
def test_unsupported_params_raises_without_drop(self):
litellm.drop_params = False
optional_params: ImageEditOptionalRequestParams = {
"size": "1024x1024",
"unsupported_param": "value",
}
with pytest.raises(litellm.UnsupportedParamsError) as exc_info:
ImageEditRequestUtils.get_optional_params_image_edit(
model=self.model,
image_edit_provider_config=self.config,
image_edit_optional_params=optional_params,
)
assert "unsupported_param" in str(exc_info.value)
def test_drop_params_global_setting(self):
litellm.drop_params = True
optional_params: ImageEditOptionalRequestParams = {
"size": "1024x1024",
"unsupported_param": "value",
}
result = ImageEditRequestUtils.get_optional_params_image_edit(
model=self.model,
image_edit_provider_config=self.config,
image_edit_optional_params=optional_params,
)
assert "size" in result
assert "unsupported_param" not in result
def test_drop_params_explicit_parameter(self):
litellm.drop_params = False
optional_params: ImageEditOptionalRequestParams = {
"size": "1024x1024",
"unsupported_param": "value",
}
result = ImageEditRequestUtils.get_optional_params_image_edit(
model=self.model,
image_edit_provider_config=self.config,
image_edit_optional_params=optional_params,
drop_params=True,
)
assert "size" in result
assert "unsupported_param" not in result
def test_additional_drop_params(self):
litellm.drop_params = False
optional_params: ImageEditOptionalRequestParams = {
"size": "1024x1024",
"quality": "high",
}
result = ImageEditRequestUtils.get_optional_params_image_edit(
model=self.model,
image_edit_provider_config=self.config,
image_edit_optional_params=optional_params,
additional_drop_params=["quality"],
)
assert "size" in result
assert "quality" not in result
def test_drop_params_false_with_global_true(self):
litellm.drop_params = True
optional_params: ImageEditOptionalRequestParams = {
"size": "1024x1024",
"unsupported_param": "value",
}
result = ImageEditRequestUtils.get_optional_params_image_edit(
model=self.model,
image_edit_provider_config=self.config,
image_edit_optional_params=optional_params,
drop_params=False,
)
assert "size" in result
assert "unsupported_param" not in result
def test_supported_params_pass_through(self):
litellm.drop_params = False
optional_params: ImageEditOptionalRequestParams = {
"size": "1024x1024",
"quality": "high",
}
result = ImageEditRequestUtils.get_optional_params_image_edit(
model=self.model,
image_edit_provider_config=self.config,
image_edit_optional_params=optional_params,
)
assert result["size"] == "1024x1024"
assert result["quality"] == "high"
def test_additional_drop_params_with_unsupported_and_drop_true(self):
litellm.drop_params = True
optional_params: ImageEditOptionalRequestParams = {
"size": "1024x1024",
"quality": "high",
"unsupported_param": "value",
}
result = ImageEditRequestUtils.get_optional_params_image_edit(
model=self.model,
image_edit_provider_config=self.config,
image_edit_optional_params=optional_params,
additional_drop_params=["quality"],
)
assert "size" in result
assert "quality" not in result
assert "unsupported_param" not in result
@@ -11,6 +11,7 @@ sys.path.insert(
from litellm.constants import LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
from litellm.litellm_core_utils.health_check_helpers import HealthCheckHelpers
from litellm.main import ahealth_check
from litellm.proxy._types import UserAPIKeyAuth
@@ -78,4 +79,59 @@ def test_get_litellm_internal_health_check_user_api_key_auth():
assert result.api_key == LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
assert result.team_id == LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
assert result.key_alias == LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
assert result.team_alias == LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
assert result.team_alias == LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
@pytest.mark.asyncio
async def test_ahealth_check_failure_masks_raw_request_headers():
"""
Security test: Verify that when ahealth_check() fails, the raw_request_headers
in raw_request_typed_dict are properly masked to prevent API key leaks.
This tests the fix for the security vulnerability where Authorization headers
were being exposed in health check error responses.
"""
# Use a model configuration that will fail (invalid endpoint)
test_api_key = "dapi-test-key-1234567890abcdef"
test_headers = {
"Authorization": f"Bearer {test_api_key}",
"Content-Type": "application/json",
}
response = await ahealth_check(
model_params={
"model": "databricks/dbrx-instruct",
"api_base": "https://invalid-endpoint-that-will-fail.com/",
"api_key": test_api_key,
"headers": test_headers,
},
mode="chat",
)
# Should have error and raw_request_typed_dict
assert "error" in response
assert "raw_request_typed_dict" in response
raw_request_dict = response["raw_request_typed_dict"]
assert raw_request_dict is not None
assert isinstance(raw_request_dict, dict)
assert "raw_request_headers" in raw_request_dict
headers = raw_request_dict["raw_request_headers"]
assert headers is not None
# Security check: Authorization header should be masked, not show full key
if "Authorization" in headers:
auth_header = headers["Authorization"]
# Should be masked (e.g., "Be****90" or similar)
assert auth_header != f"Bearer {test_api_key}", "Authorization header must be masked"
assert auth_header != test_api_key, "API key must not appear in Authorization header"
# Masked headers typically have asterisks or are truncated
assert "*" in auth_header or len(auth_header) < len(f"Bearer {test_api_key}"), \
f"Authorization header should be masked but got: {auth_header}"
# Content-Type should remain unmasked (not sensitive)
if "Content-Type" in headers:
assert headers["Content-Type"] == "application/json"
print(f"Masked Authorization header: {headers.get('Authorization', 'NOT FOUND')}")
@@ -290,3 +290,72 @@ def test_qwen2_provider_detection():
assert config is not None
assert isinstance(config, AmazonQwen2Config)
def test_qwen2_model_id_extraction_with_arn():
"""Test that model ID is correctly extracted from bedrock/qwen2/arn... paths"""
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
# Test case: bedrock/qwen2/arn:aws:bedrock:us-east-1:123456789012:imported-model/test-qwen2
# The qwen2/ prefix should be stripped, leaving only the ARN for encoding
model = "qwen2/arn:aws:bedrock:us-east-1:123456789012:imported-model/test-qwen2"
provider = "qwen2"
result = BaseAWSLLM.get_bedrock_model_id(
optional_params={},
provider=provider,
model=model
)
# The result should NOT contain "qwen2/" - it should be stripped
assert "qwen2/" not in result
# The result should be URL-encoded ARN
assert "arn%3Aaws%3Abedrock" in result or "arn:aws:bedrock" in result
def test_qwen2_model_id_extraction_without_qwen2_prefix():
"""Test that model ID extraction doesn't strip qwen2/ when provider is not qwen2"""
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
# Test case: just a model name without qwen2/ prefix
model = "arn:aws:bedrock:us-east-1:123456789012:imported-model/test-qwen2"
provider = "qwen2"
result = BaseAWSLLM.get_bedrock_model_id(
optional_params={},
provider=provider,
model=model
)
# Result should be encoded ARN
assert "arn" in result.lower() or "aws" in result.lower()
def test_qwen2_get_bedrock_model_id_with_various_formats():
"""Test get_bedrock_model_id with various Qwen2 model path formats"""
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
test_cases = [
{
"model": "qwen2/arn:aws:bedrock:us-east-1:123456789012:imported-model/test-qwen2",
"provider": "qwen2",
"should_not_contain": "qwen2/",
"description": "Qwen2 imported model ARN"
},
{
"model": "bedrock/qwen2/arn:aws:bedrock:us-east-1:123456789012:imported-model/test-qwen2",
"provider": "qwen2",
"should_not_contain": "qwen2/",
"description": "Bedrock prefixed Qwen2 ARN"
}
]
for test_case in test_cases:
result = BaseAWSLLM.get_bedrock_model_id(
optional_params={},
provider=test_case["provider"],
model=test_case["model"]
)
assert test_case["should_not_contain"] not in result, \
f"Failed for {test_case['description']}: {test_case['should_not_contain']} found in {result}"
@@ -164,6 +164,54 @@ class TestVertexAIGeminiImageEditTransformation:
assert call_kwargs["credentials"] == "/path/to/custom/credentials.json"
assert call_kwargs["project_id"] == "custom-project"
assert result == {"Authorization": "Bearer test-token"}
def test_get_complete_url_from_litellm_params(self) -> None:
"""Test vertex_project/vertex_location read from litellm_params first"""
url = self.config.get_complete_url(
model="gemini-2.5-flash",
api_base=None,
litellm_params={
"vertex_project": "params-project",
"vertex_location": "us-east1",
},
)
assert "params-project" in url
assert "us-east1" in url
def test_get_complete_url_global_location(self) -> None:
"""Test global location uses correct base URL without region prefix"""
url = self.config.get_complete_url(
model="gemini-2.5-flash",
api_base=None,
litellm_params={
"vertex_project": "test-project",
"vertex_location": "global",
},
)
assert "aiplatform.googleapis.com" in url
assert "global-aiplatform.googleapis.com" not in url
assert "/locations/global/" in url
def test_get_complete_url_litellm_params_overrides_env(self) -> None:
"""Test litellm_params takes precedence over environment variables"""
with patch.dict(
os.environ,
{
"VERTEXAI_PROJECT": "env-project",
"VERTEXAI_LOCATION": "us-central1",
},
):
url = self.config.get_complete_url(
model="gemini-2.5-flash",
api_base=None,
litellm_params={
"vertex_project": "params-project",
"vertex_location": "eu-west1",
},
)
assert "params-project" in url
assert "eu-west1" in url
assert "env-project" not in url
assert "us-central1" not in url
class TestVertexAIImagenImageEditTransformation:
@@ -15,6 +15,7 @@ import httpx
import pytest
import yaml
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.testclient import TestClient
sys.path.insert(
@@ -196,6 +197,32 @@ def test_restructure_ui_html_files_handles_nested_routes(tmp_path):
)
def test_ui_extensionless_route_requires_restructure(tmp_path):
"""Regression for non-root fallback: /ui/login expects login/index.html."""
from litellm.proxy import proxy_server
ui_root = tmp_path / "ui"
ui_root.mkdir()
(ui_root / "index.html").write_text("index")
(ui_root / "login.html").write_text("login")
fastapi_app = FastAPI()
fastapi_app.mount(
"/ui", StaticFiles(directory=str(ui_root), html=True), name="ui"
)
client = TestClient(fastapi_app)
assert client.get("/ui/login.html").status_code == 200
assert client.get("/ui/login").status_code == 404
proxy_server._restructure_ui_html_files(str(ui_root))
response = client.get("/ui/login")
assert response.status_code == 200
assert "login" in response.text
@pytest.mark.asyncio
async def test_initialize_scheduled_jobs_credentials(monkeypatch):
"""
+27
View File
@@ -2602,3 +2602,30 @@ class TestIsCachedMessage:
"""Empty list content should return False."""
message = {"role": "user", "content": []}
assert is_cached_message(message) is False
def test_azure_ai_claude_provider_config():
"""Test that Azure AI Claude models return AzureAnthropicConfig for proper tool transformation."""
from litellm import AzureAnthropicConfig, AzureAIStudioConfig
from litellm.utils import ProviderConfigManager
# Claude models should return AzureAnthropicConfig
config = ProviderConfigManager.get_provider_chat_config(
model="claude-sonnet-4-5",
provider=LlmProviders.AZURE_AI,
)
assert isinstance(config, AzureAnthropicConfig)
# Test case-insensitive matching
config = ProviderConfigManager.get_provider_chat_config(
model="Claude-Opus-4",
provider=LlmProviders.AZURE_AI,
)
assert isinstance(config, AzureAnthropicConfig)
# Non-Claude models should return AzureAIStudioConfig
config = ProviderConfigManager.get_provider_chat_config(
model="mistral-large",
provider=LlmProviders.AZURE_AI,
)
assert isinstance(config, AzureAIStudioConfig)