mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-12 23:05:52 +00:00
Merge branch 'main' into litellm_image_edit_vertex_cred_fix
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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**
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
+118
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}")
|
||||
+69
@@ -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}"
|
||||
|
||||
|
||||
+48
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user