Merge branch 'BerriAI:main' into LangfuseUsageDetails

This commit is contained in:
Fabrício Ceschin
2025-09-17 09:36:08 -04:00
committed by GitHub
51 changed files with 43323 additions and 40581 deletions
+2 -1
View File
@@ -95,4 +95,5 @@ test.py
litellm_config.yaml
.cursor
.vscode/launch.json
litellm/proxy/to_delete_loadtest_work/*
litellm/proxy/to_delete_loadtest_work/*
update_model_cost_map.py
@@ -1,3 +1,6 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Helicone - OSS LLM Observability Platform
:::tip
@@ -9,9 +12,68 @@ https://github.com/BerriAI/litellm
[Helicone](https://helicone.ai/) is an open source observability platform that proxies your LLM requests and provides key insights into your usage, spend, latency and more.
## Using Helicone with LiteLLM
## Quick Start
LiteLLM provides `success_callbacks` and `failure_callbacks`, allowing you to easily log data to Helicone based on the status of your responses.
<Tabs>
<TabItem value="sdk" label="Python SDK">
Use just 1 line of code to instantly log your responses **across all providers** with Helicone:
```python
import os
from litellm import completion
## Set env variables
os.environ["HELICONE_API_KEY"] = "your-helicone-key"
os.environ["OPENAI_API_KEY"] = "your-openai-key"
# Set callbacks
litellm.success_callback = ["helicone"]
# OpenAI call
response = completion(
model="gpt-4o",
messages=[{"role": "user", "content": "Hi 👋 - I'm OpenAI"}],
)
print(response)
```
</TabItem>
<TabItem value="proxy" label="LiteLLM Proxy">
Add Helicone to your LiteLLM proxy configuration:
```yaml title="config.yaml"
model_list:
- model_name: gpt-4
litellm_params:
model: gpt-4
api_key: os.environ/OPENAI_API_KEY
# Add Helicone callback
litellm_settings:
success_callback: ["helicone"]
# Set Helicone API key
environment_variables:
HELICONE_API_KEY: "your-helicone-key"
```
Start the proxy:
```bash
litellm --config config.yaml
```
</TabItem>
</Tabs>
## Integration Methods
There are two main approaches to integrate Helicone with LiteLLM:
1. **Callbacks**: Log to Helicone while using any provider
2. **Proxy Mode**: Use Helicone as a proxy for advanced features
### Supported LLM Providers
@@ -26,27 +88,16 @@ Helicone can log requests across [various LLM providers](https://docs.helicone.a
- Replicate
- And more
### Integration Methods
## Method 1: Using Callbacks
There are two main approaches to integrate Helicone with LiteLLM:
Log requests to Helicone while using any LLM provider directly.
1. Using callbacks
2. Using Helicone as a proxy
Let's explore each method in detail.
### Approach 1: Use Callbacks
Use just 1 line of code to instantly log your responses **across all providers** with Helicone:
```python
litellm.success_callback = ["helicone"]
```
Complete Code
<Tabs>
<TabItem value="sdk" label="Python SDK">
```python
import os
import litellm
from litellm import completion
## Set env variables
@@ -66,28 +117,78 @@ response = completion(
print(response)
```
### Approach 2: Use Helicone as a proxy
</TabItem>
<TabItem value="proxy" label="LiteLLM Proxy">
```yaml title="config.yaml"
model_list:
- model_name: gpt-4
litellm_params:
model: gpt-4
api_key: os.environ/OPENAI_API_KEY
- model_name: claude-3
litellm_params:
model: anthropic/claude-3-sonnet-20240229
api_key: os.environ/ANTHROPIC_API_KEY
# Add Helicone logging
litellm_settings:
success_callback: ["helicone"]
# Environment variables
environment_variables:
HELICONE_API_KEY: "your-helicone-key"
OPENAI_API_KEY: "your-openai-key"
ANTHROPIC_API_KEY: "your-anthropic-key"
```
Start the proxy:
```bash
litellm --config config.yaml
```
Make requests to your proxy:
```python
import openai
client = openai.OpenAI(
api_key="anything", # proxy doesn't require real API key
base_url="http://localhost:4000"
)
response = client.chat.completions.create(
model="gpt-4", # This gets logged to Helicone
messages=[{"role": "user", "content": "Hello!"}]
)
```
</TabItem>
</Tabs>
## Method 2: Using Helicone as a Proxy
Helicone's proxy provides [advanced functionality](https://docs.helicone.ai/getting-started/proxy-vs-async) like caching, rate limiting, LLM security through [PromptArmor](https://promptarmor.com/) and more.
To use Helicone as a proxy for your LLM requests:
<Tabs>
<TabItem value="sdk" label="Python SDK">
1. Set Helicone as your base URL via: litellm.api_base
2. Pass in Helicone request headers via: litellm.metadata
Complete Code:
Set Helicone as your base URL and pass authentication headers:
```python
import os
import litellm
from litellm import completion
# Configure LiteLLM to use Helicone proxy
litellm.api_base = "https://oai.hconeai.com/v1"
litellm.headers = {
"Helicone-Auth": f"Bearer {os.getenv('HELICONE_API_KEY')}", # Authenticate to send requests to Helicone API
"Helicone-Auth": f"Bearer {os.getenv('HELICONE_API_KEY')}",
}
response = litellm.completion(
# Set your OpenAI API key
os.environ["OPENAI_API_KEY"] = "your-openai-key"
response = completion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "How does a court case get to the Supreme Court?"}]
)
@@ -140,32 +241,112 @@ litellm.metadata = {
Track multi-step and agentic LLM interactions using session IDs and paths:
```python
litellm.metadata = {
"Helicone-Auth": f"Bearer {os.getenv('HELICONE_API_KEY')}", # Authenticate to send requests to Helicone API
"Helicone-Session-Id": "session-abc-123", # The session ID you want to track
"Helicone-Session-Path": "parent-trace/child-trace", # The path of the session
}
```
- `Helicone-Session-Id`: Use this to specify the unique identifier for the session you want to track. This allows you to group related requests together.
- `Helicone-Session-Path`: This header defines the path of the session, allowing you to represent parent and child traces. For example, "parent/child" represents a child trace of a parent trace.
By using these two headers, you can effectively group and visualize multi-step LLM interactions, gaining insights into complex AI workflows.
### Retry and Fallback Mechanisms
Set up retry mechanisms and fallback options:
<Tabs>
<TabItem value="sdk" label="Python SDK">
```python
import litellm
litellm.api_base = "https://oai.hconeai.com/v1"
litellm.metadata = {
"Helicone-Auth": f"Bearer {os.getenv('HELICONE_API_KEY')}", # Authenticate to send requests to Helicone API
"Helicone-Retry-Enabled": "true", # Enable retry mechanism
"helicone-retry-num": "3", # Set number of retries
"helicone-retry-factor": "2", # Set exponential backoff factor
"Helicone-Fallbacks": '["gpt-3.5-turbo", "gpt-4"]', # Set fallback models
"Helicone-Auth": f"Bearer {os.getenv('HELICONE_API_KEY')}",
"Helicone-Session-Id": "session-abc-123",
"Helicone-Session-Path": "parent-trace/child-trace",
}
response = litellm.completion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "Start a conversation"}]
)
```
</TabItem>
<TabItem value="proxy" label="LiteLLM Proxy">
```python
import openai
client = openai.OpenAI(
api_key="anything",
base_url="http://localhost:4000"
)
# First request in session
response1 = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
extra_headers={
"Helicone-Session-Id": "session-abc-123",
"Helicone-Session-Path": "conversation/greeting"
}
)
# Follow-up request in same session
response2 = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Tell me more"}],
extra_headers={
"Helicone-Session-Id": "session-abc-123",
"Helicone-Session-Path": "conversation/follow-up"
}
)
```
</TabItem>
</Tabs>
- `Helicone-Session-Id`: Unique identifier for the session to group related requests
- `Helicone-Session-Path`: Hierarchical path to represent parent/child traces (e.g., "parent/child")
## Retry and Fallback Mechanisms
<Tabs>
<TabItem value="sdk" label="Python SDK">
```python
import litellm
litellm.api_base = "https://oai.hconeai.com/v1"
litellm.metadata = {
"Helicone-Auth": f"Bearer {os.getenv('HELICONE_API_KEY')}",
"Helicone-Retry-Enabled": "true",
"helicone-retry-num": "3",
"helicone-retry-factor": "2", # Exponential backoff
"Helicone-Fallbacks": '["gpt-3.5-turbo", "gpt-4"]',
}
response = litellm.completion(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)
```
</TabItem>
<TabItem value="proxy" label="LiteLLM Proxy">
```yaml title="config.yaml"
model_list:
- model_name: gpt-4
litellm_params:
model: gpt-4
api_key: os.environ/OPENAI_API_KEY
api_base: "https://oai.hconeai.com/v1"
default_litellm_params:
headers:
Helicone-Auth: "Bearer ${HELICONE_API_KEY}"
Helicone-Retry-Enabled: "true"
helicone-retry-num: "3"
helicone-retry-factor: "2"
Helicone-Fallbacks: '["gpt-3.5-turbo", "gpt-4"]'
environment_variables:
HELICONE_API_KEY: "your-helicone-key"
OPENAI_API_KEY: "your-openai-key"
```
</TabItem>
</Tabs>
> **Supported Headers** - For a full list of supported Helicone headers and their descriptions, please refer to the [Helicone documentation](https://docs.helicone.ai/getting-started/quick-start).
> By utilizing these headers and metadata options, you can gain deeper insights into your LLM usage, optimize performance, and better manage your AI workflows with Helicone and LiteLLM.
+45 -1
View File
@@ -889,6 +889,19 @@ curl http://0.0.0.0:4000/v1/chat/completions \
Example of using [Bedrock Guardrails with LiteLLM](https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-use-converse-api.html)
### Selective Content Moderation with `guarded_text`
LiteLLM supports selective content moderation using the `guarded_text` content type. This allows you to wrap only specific content that should be moderated by Bedrock Guardrails, rather than evaluating the entire conversation.
**How it works:**
- Content with `type: "guarded_text"` gets automatically wrapped in `guardrailConverseContent` blocks
- Only the wrapped content is evaluated by Bedrock Guardrails
- Regular content with `type: "text"` bypasses guardrail evaluation
:::note
If `guarded_text` is not used, the entire conversation history will be sent to the guardrail for evaluation, which can increase latency and costs.
:::
<Tabs>
<TabItem value="sdk" label="LiteLLM SDK">
@@ -915,6 +928,24 @@ response = completion(
"trace": "disabled", # The trace behavior for the guardrail. Can either be "disabled" or "enabled"
},
)
# Selective guardrail usage with guarded_text - only specific content is evaluated
response_guard = completion(
model="anthropic.claude-v2",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "What is the main topic of this legal document?"},
{"type": "guarded_text", "text": "This document contains sensitive legal information that should be moderated by guardrails."}
]
}
],
guardrailConfig={
"guardrailIdentifier": "gr-abc123",
"guardrailVersion": "DRAFT"
}
)
```
</TabItem>
<TabItem value="proxy" label="Proxy on request">
@@ -993,7 +1024,20 @@ response = client.chat.completions.create(model="bedrock-claude-v1", messages =
temperature=0.7
)
print(response)
# For adding selective guardrail usage with guarded_text
response_guard = client.chat.completions.create(model="bedrock-claude-v1", messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is the main topic of this legal document?"},
{"type": "guarded_text", "text": "This document contains sensitive legal information that should be moderated by guardrails."}
]
}
],
temperature=0.7
)
print(response_guard)
```
</TabItem>
</Tabs>
@@ -4,7 +4,7 @@ import TabItem from '@theme/TabItem';
# CompactifAI
https://docs.compactif.ai/
CompactifAI offers highly compressed versions of leading language models, delivering up to **70% lower inference costs**, **4x throughput gains**, and **low-latency inference** with minimal quality loss (<5%). CompactifAI's OpenAI-compatible API makes integration straightforward, enabling developers to build ultra-efficient, scalable AI applications with superior concurrency and resource efficiency.
CompactifAI offers highly compressed versions of leading language models, delivering up to **70% lower inference costs**, **4x throughput gains**, and **low-latency inference** with minimal quality loss (under 5%). CompactifAI's OpenAI-compatible API makes integration straightforward, enabling developers to build ultra-efficient, scalable AI applications with superior concurrency and resource efficiency.
| Property | Details |
|-------|-------|
@@ -192,7 +192,7 @@ Common model formats:
## Benefits
- **Cost Efficient**: Up to 70% lower inference costs compared to standard models
- **High Performance**: 4x throughput gains with minimal quality loss (<5%)
- **High Performance**: 4x throughput gains with minimal quality loss (under 5%)
- **Low Latency**: Optimized for fast response times
- **Drop-in Replacement**: Full OpenAI API compatibility
- **Scalable**: Superior concurrency and resource efficiency
+38
View File
@@ -2758,6 +2758,44 @@ curl http://localhost:4000/v1/fine_tuning/jobs \
</Tabs>
## Labels
Google enables you to add custom metadata to its `generateContent` and `streamGenerateContent` calls.
This mechanism is useful in Vertex AI because it allows costs and usage tracking over multiple
different applications or users.
### Usage
You can use that feature through LiteLLM by sending `labels` or `metadata` field in your requests.
If the client sets the `labels` field in the request to the LiteLLM,
the LiteLLM will pass the `labels` field to the Vertex AI backend.
If the client sets the `metadata` field in the request to the LiteLLM and the `labels` field is not set,
the LiteLLM will create the `labels` field filled with `metadata` key/value pairs for all string values and
pass it to the Vertex AI backend.
Here is an example JSON request demonstrating the labels usage:
```json
{
"model": "gemini-2.0-flash-lite",
"messages": [
{ "role": "user", "content": "respond in 20 words. who are you?" }
],
"labels": {
"client_app": "acme_comp_financial_app",
"department": "finance",
"project": "acme_ai"
}
}
```
## Extra
### Using `GOOGLE_APPLICATION_CREDENTIALS`
@@ -93,6 +93,8 @@ callback_settings:
general_settings:
completion_model: string
store_prompts_in_spend_logs: boolean
forward_client_headers_to_llm_api: boolean
disable_spend_logs: boolean # turn off writing each transaction to the db
disable_master_key_return: boolean # turn off returning master key on UI (checked on '/user/info' endpoint)
disable_retry_on_max_parallel_request_limit_error: boolean # turn off retries when max parallel request limit is reached
@@ -121,6 +123,35 @@ general_settings:
alerting: ["slack", "email"]
alerting_threshold: 0
use_client_credentials_pass_through_routes: boolean # use client credentials for all pass through routes like "/vertex-ai", /bedrock/. When this is True Virtual Key auth will not be applied on these endpoints
router_settings:
routing_strategy: simple-shuffle # Literal["simple-shuffle", "least-busy", "usage-based-routing","latency-based-routing"], default="simple-shuffle" - RECOMMENDED for best performance
redis_host: <your-redis-host> # string
redis_password: <your-redis-password> # string
redis_port: <your-redis-port> # string
enable_pre_call_checks: true # bool - Before call is made check if a call is within model context window
allowed_fails: 3 # cooldown model if it fails > 1 call in a minute.
cooldown_time: 30 # (in seconds) how long to cooldown model if fails/min > allowed_fails
disable_cooldowns: True # bool - Disable cooldowns for all models
enable_tag_filtering: True # bool - Use tag based routing for requests
retry_policy: { # Dict[str, int]: retry policy for different types of exceptions
"AuthenticationErrorRetries": 3,
"TimeoutErrorRetries": 3,
"RateLimitErrorRetries": 3,
"ContentPolicyViolationErrorRetries": 4,
"InternalServerErrorRetries": 4
}
allowed_fails_policy: {
"BadRequestErrorAllowedFails": 1000, # Allow 1000 BadRequestErrors before cooling down a deployment
"AuthenticationErrorAllowedFails": 10, # int
"TimeoutErrorAllowedFails": 12, # int
"RateLimitErrorAllowedFails": 10000, # int
"ContentPolicyViolationErrorAllowedFails": 15, # int
"InternalServerErrorAllowedFails": 20, # int
}
content_policy_fallbacks=[{"claude-2": ["my-fallback-model"]}] # List[Dict[str, List[str]]]: Fallback model for content policy violations
fallbacks=[{"claude-2": ["my-fallback-model"]}] # List[Dict[str, List[str]]]: Fallback model for all errors
```
### litellm_settings - Reference
@@ -61,6 +61,11 @@ Inherits from `StandardLoggingUserAPIKeyMetadata` and adds:
| `requester_metadata` | `Optional[dict]` | Additional requester metadata |
| `vector_store_request_metadata` | `Optional[List[StandardLoggingVectorStoreRequest]]` | Vector store request metadata |
| `requester_custom_headers` | Dict[str, str] | Any custom (`x-`) headers sent by the client to the proxy. |
| `prompt_management_metadata` | `Optional[StandardLoggingPromptManagementMetadata]` | Prompt management and versioning metadata |
| `mcp_tool_call_metadata` | `Optional[StandardLoggingMCPToolCall]` | MCP (Model Context Protocol) tool call information and cost tracking |
| `applied_guardrails` | `Optional[List[str]]` | List of applied guardrail names |
| `usage_object` | `Optional[dict]` | Raw usage object from the LLM provider |
| `cold_storage_object_key` | `Optional[str]` | S3/GCS object key for cold storage retrieval |
| `guardrail_information` | `Optional[StandardLoggingGuardrailInformation]` | Guardrail information |
@@ -145,4 +150,82 @@ A literal type with two possible values:
| `duration` | `Optional[float]` | Duration of the guardrail in seconds |
| `masked_entity_count` | `Optional[Dict[str, int]]` | Count of masked entities |
## StandardLoggingPromptManagementMetadata
Used for tracking prompt versioning and management information.
| Field | Type | Description |
|-------|------|-------------|
| `prompt_id` | `str` | **Required**. Unique identifier for the prompt template or version |
| `prompt_variables` | `Optional[dict]` | Variables/parameters used in the prompt template (e.g., `{"user_name": "John", "context": "support"}`) |
| `prompt_integration` | `str` | **Required**. Integration or system managing the prompt (e.g., `"langfuse"`, `"promptlayer"`, `"custom"`) |
## StandardLoggingMCPToolCall
Used to track Model Context Protocol (MCP) tool calls within LiteLLM requests. This provides detailed logging for external tool integrations.
| Field | Type | Description |
|-------|------|-------------|
| `name` | `str` | **Required**. The name of the tool being called (e.g., `"get_weather"`, `"search_database"`) |
| `arguments` | `dict` | **Required**. Arguments passed to the tool as key-value pairs |
| `result` | `Optional[dict]` | The response/result returned by the tool execution (populated by custom logging hooks) |
| `mcp_server_name` | `Optional[str]` | Name of the MCP server that handled the tool call (e.g., `"weather-service"`, `"database-connector"`) |
| `mcp_server_logo_url` | `Optional[str]` | URL for the MCP server's logo (used for UI display in LiteLLM dashboard) |
| `namespaced_tool_name` | `Optional[str]` | Fully qualified tool name including server prefix (e.g., `"deepwiki-mcp/get_page_content"`, `"github-mcp/create_issue"`) |
| `mcp_server_cost_info` | `Optional[MCPServerCostInfo]` | Cost tracking information for the tool call |
### MCPServerCostInfo
Cost tracking structure for MCP server tool calls:
| Field | Type | Description |
|-------|------|-------------|
| `default_cost_per_query` | `Optional[float]` | Default cost in USD for any tool call to this MCP server |
| `tool_name_to_cost_per_query` | `Optional[Dict[str, float]]` | Per-tool cost mapping for granular pricing (e.g., `{"search": 0.01, "create": 0.05}`) |
### Usage
```python
# Basic MCP tool call metadata
mcp_tool_call = {
"name": "search_documents",
"arguments": {
"query": "machine learning tutorials",
"limit": 10,
"filter": "type:pdf"
},
"mcp_server_name": "document-search-service",
"namespaced_tool_name": "docs-mcp/search_documents",
"mcp_server_cost_info": {
"default_cost_per_query": 0.02,
"tool_name_to_cost_per_query": {
"search_documents": 0.02,
"get_document": 0.01
}
}
}
# optional result field (via custom logging hooks)
mcp_tool_call_with_result = {
"name": "search_documents",
"arguments": {
"query": "machine learning tutorials",
"limit": 10,
"filter": "type:pdf"
},
"result": {
"documents": [...],
"total_found": 42,
"search_time_ms": 150
},
"mcp_server_name": "document-search-service",
"namespaced_tool_name": "docs-mcp/search_documents",
"mcp_server_cost_info": {
"default_cost_per_query": 0.02,
"tool_name_to_cost_per_query": {
"search_documents": 0.02,
"get_document": 0.01
}
}
}
```
+1 -1
View File
@@ -2,7 +2,7 @@
[Schedule Demo 👋](https://calendly.com/d/4mp-gd3-k5k/berriai-1-1-onboarding-litellm-hosted-version)
[Community Discord 💭](https://discord.gg/wuPM9dRgDw)
[Community Slack 💭](https://join.slack.com/share/enQtOTE0ODczMzk2Nzk4NC01YjUxNjY2YjBlYTFmNDRiZTM3NDFiYTM3MzVkODFiMDVjOGRjMmNmZTZkZTMzOWQzZGQyZWIwYjQ0MWExYmE3)
[Community Slack 💭](https://litellmossslack.slack.com/)
Our numbers 📞 +1 (770) 8783-106 / +1 (412) 618-6238
+169 -107
View File
@@ -340,7 +340,7 @@ def create_batch(
@client
async def aretrieve_batch(
batch_id: str,
custom_llm_provider: Literal["openai", "azure", "vertex_ai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "vertex_ai", "bedrock"] = "openai",
metadata: Optional[Dict[str, str]] = None,
extra_headers: Optional[Dict[str, str]] = None,
extra_body: Optional[Dict[str, str]] = None,
@@ -378,11 +378,129 @@ async def aretrieve_batch(
except Exception as e:
raise e
def _handle_retrieve_batch_providers_without_provider_config(
batch_id: str,
optional_params: GenericLiteLLMParams,
timeout: Union[float, httpx.Timeout],
litellm_params: dict,
_retrieve_batch_request: RetrieveBatchRequest,
_is_async: bool,
custom_llm_provider: Literal["openai", "azure", "vertex_ai", "bedrock"] = "openai",
):
api_base: Optional[str] = None
if custom_llm_provider == "openai":
# for deepinfra/perplexity/anyscale/groq we check in get_llm_provider and pass in the api base from there
api_base = (
optional_params.api_base
or litellm.api_base
or os.getenv("OPENAI_BASE_URL")
or os.getenv("OPENAI_API_BASE")
or "https://api.openai.com/v1"
)
organization = (
optional_params.organization
or litellm.organization
or os.getenv("OPENAI_ORGANIZATION", None)
or None # default - https://github.com/openai/openai-python/blob/284c1799070c723c6a553337134148a7ab088dd8/openai/util.py#L105
)
# set API KEY
api_key = (
optional_params.api_key
or litellm.api_key # for deepinfra/perplexity/anyscale we check in get_llm_provider and pass in the api key from there
or litellm.openai_key
or os.getenv("OPENAI_API_KEY")
)
response = openai_batches_instance.retrieve_batch(
_is_async=_is_async,
retrieve_batch_data=_retrieve_batch_request,
api_base=api_base,
api_key=api_key,
organization=organization,
timeout=timeout,
max_retries=optional_params.max_retries,
)
elif custom_llm_provider == "azure":
api_base = (
optional_params.api_base
or litellm.api_base
or get_secret_str("AZURE_API_BASE")
)
api_version = (
optional_params.api_version
or litellm.api_version
or get_secret_str("AZURE_API_VERSION")
)
api_key = (
optional_params.api_key
or litellm.api_key
or litellm.azure_key
or get_secret_str("AZURE_OPENAI_API_KEY")
or get_secret_str("AZURE_API_KEY")
)
extra_body = optional_params.get("extra_body", {})
if extra_body is not None:
extra_body.pop("azure_ad_token", None)
else:
get_secret_str("AZURE_AD_TOKEN") # type: ignore
response = azure_batches_instance.retrieve_batch(
_is_async=_is_async,
api_base=api_base,
api_key=api_key,
api_version=api_version,
timeout=timeout,
max_retries=optional_params.max_retries,
retrieve_batch_data=_retrieve_batch_request,
litellm_params=litellm_params,
)
elif custom_llm_provider == "vertex_ai":
api_base = optional_params.api_base or ""
vertex_ai_project = (
optional_params.vertex_project
or litellm.vertex_project
or get_secret_str("VERTEXAI_PROJECT")
)
vertex_ai_location = (
optional_params.vertex_location
or litellm.vertex_location
or get_secret_str("VERTEXAI_LOCATION")
)
vertex_credentials = optional_params.vertex_credentials or get_secret_str(
"VERTEXAI_CREDENTIALS"
)
response = vertex_ai_batches_instance.retrieve_batch(
_is_async=_is_async,
batch_id=batch_id,
api_base=api_base,
vertex_project=vertex_ai_project,
vertex_location=vertex_ai_location,
vertex_credentials=vertex_credentials,
timeout=timeout,
max_retries=optional_params.max_retries,
)
else:
raise litellm.exceptions.BadRequestError(
message="LiteLLM doesn't support {} for 'create_batch'. Only 'openai' is supported.".format(
custom_llm_provider
),
model="n/a",
llm_provider=custom_llm_provider,
response=httpx.Response(
status_code=400,
content="Unsupported provider",
request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"), # type: ignore
),
)
return response
@client
def retrieve_batch(
batch_id: str,
custom_llm_provider: Literal["openai", "azure", "vertex_ai"] = "openai",
custom_llm_provider: Literal["openai", "azure", "vertex_ai", "bedrock"] = "openai",
metadata: Optional[Dict[str, str]] = None,
extra_headers: Optional[Dict[str, str]] = None,
extra_body: Optional[Dict[str, str]] = None,
@@ -430,115 +548,59 @@ def retrieve_batch(
)
_is_async = kwargs.pop("aretrieve_batch", False) is True
api_base: Optional[str] = None
if custom_llm_provider == "openai":
# for deepinfra/perplexity/anyscale/groq we check in get_llm_provider and pass in the api base from there
api_base = (
optional_params.api_base
or litellm.api_base
or os.getenv("OPENAI_BASE_URL")
or os.getenv("OPENAI_API_BASE")
or "https://api.openai.com/v1"
)
organization = (
optional_params.organization
or litellm.organization
or os.getenv("OPENAI_ORGANIZATION", None)
or None # default - https://github.com/openai/openai-python/blob/284c1799070c723c6a553337134148a7ab088dd8/openai/util.py#L105
)
# set API KEY
api_key = (
optional_params.api_key
or litellm.api_key # for deepinfra/perplexity/anyscale we check in get_llm_provider and pass in the api key from there
or litellm.openai_key
or os.getenv("OPENAI_API_KEY")
)
response = openai_batches_instance.retrieve_batch(
_is_async=_is_async,
retrieve_batch_data=_retrieve_batch_request,
api_base=api_base,
api_key=api_key,
organization=organization,
timeout=timeout,
max_retries=optional_params.max_retries,
)
elif custom_llm_provider == "azure":
api_base = (
optional_params.api_base
or litellm.api_base
or get_secret_str("AZURE_API_BASE")
)
api_version = (
optional_params.api_version
or litellm.api_version
or get_secret_str("AZURE_API_VERSION")
)
api_key = (
optional_params.api_key
or litellm.api_key
or litellm.azure_key
or get_secret_str("AZURE_OPENAI_API_KEY")
or get_secret_str("AZURE_API_KEY")
)
extra_body = optional_params.get("extra_body", {})
if extra_body is not None:
extra_body.pop("azure_ad_token", None)
else:
get_secret_str("AZURE_AD_TOKEN") # type: ignore
response = azure_batches_instance.retrieve_batch(
_is_async=_is_async,
api_base=api_base,
api_key=api_key,
api_version=api_version,
timeout=timeout,
max_retries=optional_params.max_retries,
retrieve_batch_data=_retrieve_batch_request,
litellm_params=litellm_params,
)
elif custom_llm_provider == "vertex_ai":
api_base = optional_params.api_base or ""
vertex_ai_project = (
optional_params.vertex_project
or litellm.vertex_project
or get_secret_str("VERTEXAI_PROJECT")
)
vertex_ai_location = (
optional_params.vertex_location
or litellm.vertex_location
or get_secret_str("VERTEXAI_LOCATION")
)
vertex_credentials = optional_params.vertex_credentials or get_secret_str(
"VERTEXAI_CREDENTIALS"
)
response = vertex_ai_batches_instance.retrieve_batch(
_is_async=_is_async,
batch_id=batch_id,
api_base=api_base,
vertex_project=vertex_ai_project,
vertex_location=vertex_ai_location,
vertex_credentials=vertex_credentials,
timeout=timeout,
max_retries=optional_params.max_retries,
client = kwargs.get("client", None)
# Try to use provider config first (for providers like bedrock)
model: Optional[str] = kwargs.get("model", None)
if model is not None:
provider_config = ProviderConfigManager.get_provider_batches_config(
model=model,
provider=LlmProviders(custom_llm_provider),
)
else:
raise litellm.exceptions.BadRequestError(
message="LiteLLM doesn't support {} for 'create_batch'. Only 'openai' is supported.".format(
custom_llm_provider
),
model="n/a",
llm_provider=custom_llm_provider,
response=httpx.Response(
status_code=400,
content="Unsupported provider",
request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"), # type: ignore
provider_config = None
if provider_config is not None:
response = base_llm_http_handler.retrieve_batch(
batch_id=batch_id,
provider_config=provider_config,
litellm_params=litellm_params,
headers=extra_headers or {},
api_base=optional_params.api_base,
api_key=optional_params.api_key,
logging_obj=litellm_logging_obj or LiteLLMLoggingObj(
model=model or "bedrock/unknown",
messages=[],
stream=False,
call_type="batch_retrieve",
start_time=None,
litellm_call_id="batch_retrieve_" + batch_id,
function_id="batch_retrieve",
),
_is_async=_is_async,
client=client
if client is not None
and isinstance(client, (HTTPHandler, AsyncHTTPHandler))
else None,
timeout=timeout,
model=model,
)
return response
return response
#########################################################
# Handle providers without provider config
#########################################################
return _handle_retrieve_batch_providers_without_provider_config(
batch_id=batch_id,
custom_llm_provider=custom_llm_provider,
optional_params=optional_params,
litellm_params=litellm_params,
_retrieve_batch_request=_retrieve_batch_request,
_is_async=_is_async,
timeout=timeout,
)
except Exception as e:
raise e
@@ -148,10 +148,22 @@ class DataDogLLMObsLogger(DataDogLogger, CustomBatchLogger):
),
),
}
verbose_logger.debug("payload %s", json.dumps(payload, indent=4))
# serialize datetime objects - for budget reset time in spend metrics
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
try:
verbose_logger.debug("payload %s", safe_dumps(payload))
except Exception as debug_error:
verbose_logger.debug(
"payload serialization failed: %s", str(debug_error)
)
json_payload = safe_dumps(payload)
response = await self.async_client.post(
url=self.intake_url,
json=payload,
content=json_payload,
headers={
"DD-API-KEY": self.DD_API_KEY,
"Content-Type": "application/json",
@@ -494,6 +506,12 @@ class DataDogLLMObsLogger(DataDogLogger, CustomBatchLogger):
latency_metrics = self._get_latency_metrics(standard_logging_payload)
_metadata.update({"latency_metrics": dict(latency_metrics)})
#########################################################
# Add spend metrics to metadata
#########################################################
spend_metrics = self._get_spend_metrics(standard_logging_payload)
_metadata.update({"spend_metrics": dict(spend_metrics)})
## extract tool calls and add to metadata
tool_call_metadata = self._extract_tool_call_metadata(standard_logging_payload)
_metadata.update(tool_call_metadata)
@@ -543,6 +561,71 @@ class DataDogLLMObsLogger(DataDogLogger, CustomBatchLogger):
return latency_metrics
def _get_spend_metrics(
self, standard_logging_payload: StandardLoggingPayload
) -> DDLLMObsSpendMetrics:
"""
Get the spend metrics from the standard logging payload
"""
spend_metrics: DDLLMObsSpendMetrics = DDLLMObsSpendMetrics()
# send response cost
spend_metrics["response_cost"] = standard_logging_payload.get(
"response_cost", 0.0
)
# Get budget information from metadata
metadata = standard_logging_payload.get("metadata", {})
# API key max budget
user_api_key_max_budget = metadata.get("user_api_key_max_budget")
if user_api_key_max_budget is not None:
spend_metrics["user_api_key_max_budget"] = float(user_api_key_max_budget)
# API key spend
user_api_key_spend = metadata.get("user_api_key_spend")
if user_api_key_spend is not None:
try:
spend_metrics["user_api_key_spend"] = float(user_api_key_spend)
except (ValueError, TypeError):
verbose_logger.debug(
f"Invalid user_api_key_spend value: {user_api_key_spend}"
)
# API key budget reset datetime
user_api_key_budget_reset_at = metadata.get("user_api_key_budget_reset_at")
if user_api_key_budget_reset_at is not None:
try:
from datetime import datetime, timezone
budget_reset_at = None
if isinstance(user_api_key_budget_reset_at, str):
# Handle ISO format strings that might have 'Z' suffix
iso_string = user_api_key_budget_reset_at.replace("Z", "+00:00")
budget_reset_at = datetime.fromisoformat(iso_string)
elif isinstance(user_api_key_budget_reset_at, datetime):
budget_reset_at = user_api_key_budget_reset_at
if budget_reset_at is not None:
# Preserve timezone info if already present
if budget_reset_at.tzinfo is None:
budget_reset_at = budget_reset_at.replace(tzinfo=timezone.utc)
# Convert to ISO string format for JSON serialization
# This prevents circular reference issues and ensures proper timezone representation
iso_string = budget_reset_at.isoformat()
spend_metrics["user_api_key_budget_reset_at"] = iso_string
# Debug logging to verify the conversion
verbose_logger.debug(
f"Converted budget_reset_at to ISO format: {iso_string}"
)
except Exception as e:
verbose_logger.debug(f"Error processing budget reset datetime: {e}")
verbose_logger.debug(f"Original value: {user_api_key_budget_reset_at}")
return spend_metrics
def _process_input_messages_preserving_tool_calls(
self, messages: List[Any]
) -> List[Dict[str, Any]]:
+21 -1
View File
@@ -15,7 +15,7 @@ from litellm.litellm_core_utils.redact_messages import redact_user_api_key_info
from litellm.llms.custom_httpx.http_handler import _get_httpx_client
from litellm.secret_managers.main import str_to_bool
from litellm.types.integrations.langfuse import *
from litellm.types.llms.openai import HttpxBinaryResponseContent
from litellm.types.llms.openai import HttpxBinaryResponseContent, ResponsesAPIResponse
from litellm.types.utils import (
EmbeddingResponse,
ImageResponse,
@@ -196,6 +196,7 @@ class LangFuseLogger:
TranscriptionResponse,
RerankResponse,
HttpxBinaryResponseContent,
ResponsesAPIResponse,
],
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
@@ -305,6 +306,7 @@ class LangFuseLogger:
TranscriptionResponse,
RerankResponse,
HttpxBinaryResponseContent,
ResponsesAPIResponse,
],
prompt: dict,
level: str,
@@ -369,6 +371,11 @@ class LangFuseLogger:
):
input = prompt
output = response_obj.results
elif response_obj is not None and isinstance(
response_obj, litellm.ResponsesAPIResponse
):
input = prompt
output = self._get_responses_api_content_for_langfuse(response_obj)
elif (
kwargs.get("call_type") is not None
and kwargs.get("call_type") == "_arealtime"
@@ -775,6 +782,19 @@ class LangFuseLogger:
else:
return None
@staticmethod
def _get_responses_api_content_for_langfuse(
response_obj: ResponsesAPIResponse,
):
"""
Get the responses API content for Langfuse logging
"""
if hasattr(response_obj, 'output') and response_obj.output:
# ResponsesAPIResponse.output is a list of strings
return response_obj.output
else:
return None
@staticmethod
def _get_langfuse_tags(
standard_logging_object: Optional[StandardLoggingPayload],
+21 -6
View File
@@ -228,9 +228,11 @@ def safe_deep_copy(data):
"""
Safe Deep Copy
The LiteLLM Request has some object that can-not be pickled / deep copied
Use this function to safely deep copy the LiteLLM Request
The LiteLLM request may contain objects that cannot be pickled/deep-copied
(e.g., tracing spans, locks, clients).
This helper deep-copies each top-level key independently; on failure keeps
original ref
"""
import copy
@@ -255,9 +257,22 @@ def safe_deep_copy(data):
"litellm_parent_otel_span"
)
data["litellm_metadata"]["litellm_parent_otel_span"] = "placeholder"
new_data = copy.deepcopy(data)
# Step 2: re-add the litellm_parent_otel_span after doing a deep copy
# Step 2: Per-key deepcopy with fallback
if isinstance(data, dict):
new_data = {}
for k, v in data.items():
try:
new_data[k] = copy.deepcopy(v)
except Exception:
new_data[k] = v
else:
try:
new_data = copy.deepcopy(data)
except Exception:
new_data = data
# Step 3: re-add the litellm_parent_otel_span after doing a deep copy
if isinstance(data, dict) and litellm_parent_otel_span is not None:
if "metadata" in data and "litellm_parent_otel_span" in data["metadata"]:
data["metadata"]["litellm_parent_otel_span"] = litellm_parent_otel_span
@@ -268,4 +283,4 @@ def safe_deep_copy(data):
data["litellm_metadata"][
"litellm_parent_otel_span"
] = litellm_parent_otel_span
return new_data
return new_data
+172 -156
View File
@@ -300,9 +300,9 @@ class Logging(LiteLLMLoggingBaseClass):
self.litellm_trace_id: str = litellm_trace_id or str(uuid.uuid4())
self.function_id = function_id
self.streaming_chunks: List[Any] = [] # for generating complete stream response
self.sync_streaming_chunks: List[Any] = (
[]
) # for generating complete stream response
self.sync_streaming_chunks: List[
Any
] = [] # for generating complete stream response
self.log_raw_request_response = log_raw_request_response
# Initialize dynamic callbacks
@@ -672,24 +672,23 @@ class Logging(LiteLLMLoggingBaseClass):
if anthropic_cache_control_logger := AnthropicCacheControlHook.get_custom_logger_for_anthropic_cache_control_hook(
non_default_params
):
self.model_call_details["prompt_integration"] = (
anthropic_cache_control_logger.__class__.__name__
)
self.model_call_details[
"prompt_integration"
] = anthropic_cache_control_logger.__class__.__name__
return anthropic_cache_control_logger
#########################################################
# Vector Store / Knowledge Base hooks
#########################################################
if litellm.vector_store_registry is not None:
vector_store_custom_logger = _init_custom_logger_compatible_class(
logging_integration="vector_store_pre_call_hook",
internal_usage_cache=None,
llm_router=None,
)
self.model_call_details["prompt_integration"] = (
vector_store_custom_logger.__class__.__name__
)
self.model_call_details[
"prompt_integration"
] = vector_store_custom_logger.__class__.__name__
return vector_store_custom_logger
return None
@@ -741,9 +740,9 @@ class Logging(LiteLLMLoggingBaseClass):
model
): # if model name was changes pre-call, overwrite the initial model call name with the new one
self.model_call_details["model"] = model
self.model_call_details["litellm_params"]["api_base"] = (
self._get_masked_api_base(additional_args.get("api_base", ""))
)
self.model_call_details["litellm_params"][
"api_base"
] = self._get_masked_api_base(additional_args.get("api_base", ""))
def pre_call(self, input, api_key, model=None, additional_args={}): # noqa: PLR0915
# Log the exact input to the LLM API
@@ -772,10 +771,10 @@ class Logging(LiteLLMLoggingBaseClass):
try:
# [Non-blocking Extra Debug Information in metadata]
if turn_off_message_logging is True:
_metadata["raw_request"] = (
"redacted by litellm. \
_metadata[
"raw_request"
] = "redacted by litellm. \
'litellm.turn_off_message_logging=True'"
)
else:
curl_command = self._get_request_curl_command(
api_base=additional_args.get("api_base", ""),
@@ -786,32 +785,32 @@ class Logging(LiteLLMLoggingBaseClass):
_metadata["raw_request"] = str(curl_command)
# split up, so it's easier to parse in the UI
self.model_call_details["raw_request_typed_dict"] = (
RawRequestTypedDict(
raw_request_api_base=str(
additional_args.get("api_base") or ""
),
raw_request_body=self._get_raw_request_body(
additional_args.get("complete_input_dict", {})
),
raw_request_headers=self._get_masked_headers(
additional_args.get("headers", {}) or {},
ignore_sensitive_headers=True,
),
error=None,
)
self.model_call_details[
"raw_request_typed_dict"
] = RawRequestTypedDict(
raw_request_api_base=str(
additional_args.get("api_base") or ""
),
raw_request_body=self._get_raw_request_body(
additional_args.get("complete_input_dict", {})
),
raw_request_headers=self._get_masked_headers(
additional_args.get("headers", {}) or {},
ignore_sensitive_headers=True,
),
error=None,
)
except Exception as e:
self.model_call_details["raw_request_typed_dict"] = (
RawRequestTypedDict(
error=str(e),
)
self.model_call_details[
"raw_request_typed_dict"
] = RawRequestTypedDict(
error=str(e),
)
_metadata["raw_request"] = (
"Unable to Log \
_metadata[
"raw_request"
] = "Unable to Log \
raw request: {}".format(
str(e)
)
str(e)
)
if getattr(self, "logger_fn", None) and callable(self.logger_fn):
try:
@@ -1112,13 +1111,13 @@ class Logging(LiteLLMLoggingBaseClass):
for callback in callbacks:
try:
if isinstance(callback, CustomLogger):
response: Optional[MCPPostCallResponseObject] = (
await callback.async_post_mcp_tool_call_hook(
kwargs=kwargs,
response_obj=post_mcp_tool_call_response_obj,
start_time=start_time,
end_time=end_time,
)
response: Optional[
MCPPostCallResponseObject
] = await callback.async_post_mcp_tool_call_hook(
kwargs=kwargs,
response_obj=post_mcp_tool_call_response_obj,
start_time=start_time,
end_time=end_time,
)
######################################################################
# if any of the callbacks modify the response, use the modified response
@@ -1238,9 +1237,9 @@ class Logging(LiteLLMLoggingBaseClass):
verbose_logger.debug(
f"response_cost_failure_debug_information: {debug_info}"
)
self.model_call_details["response_cost_failure_debug_information"] = (
debug_info
)
self.model_call_details[
"response_cost_failure_debug_information"
] = debug_info
return None
try:
@@ -1265,9 +1264,9 @@ class Logging(LiteLLMLoggingBaseClass):
verbose_logger.debug(
f"response_cost_failure_debug_information: {debug_info}"
)
self.model_call_details["response_cost_failure_debug_information"] = (
debug_info
)
self.model_call_details[
"response_cost_failure_debug_information"
] = debug_info
return None
@@ -1411,9 +1410,9 @@ class Logging(LiteLLMLoggingBaseClass):
end_time = datetime.datetime.now()
if self.completion_start_time is None:
self.completion_start_time = end_time
self.model_call_details["completion_start_time"] = (
self.completion_start_time
)
self.model_call_details[
"completion_start_time"
] = self.completion_start_time
self.model_call_details["log_event_type"] = "successful_api_call"
self.model_call_details["end_time"] = end_time
self.model_call_details["cache_hit"] = cache_hit
@@ -1466,39 +1465,39 @@ class Logging(LiteLLMLoggingBaseClass):
"response_cost"
]
else:
self.model_call_details["response_cost"] = (
self._response_cost_calculator(result=logging_result)
)
self.model_call_details[
"response_cost"
] = self._response_cost_calculator(result=logging_result)
## STANDARDIZED LOGGING PAYLOAD
self.model_call_details["standard_logging_object"] = (
get_standard_logging_object_payload(
kwargs=self.model_call_details,
init_response_obj=logging_result,
start_time=start_time,
end_time=end_time,
logging_obj=self,
status="success",
standard_built_in_tools_params=self.standard_built_in_tools_params,
)
self.model_call_details[
"standard_logging_object"
] = get_standard_logging_object_payload(
kwargs=self.model_call_details,
init_response_obj=logging_result,
start_time=start_time,
end_time=end_time,
logging_obj=self,
status="success",
standard_built_in_tools_params=self.standard_built_in_tools_params,
)
elif isinstance(result, dict) or isinstance(result, list):
## STANDARDIZED LOGGING PAYLOAD
self.model_call_details["standard_logging_object"] = (
get_standard_logging_object_payload(
kwargs=self.model_call_details,
init_response_obj=result,
start_time=start_time,
end_time=end_time,
logging_obj=self,
status="success",
standard_built_in_tools_params=self.standard_built_in_tools_params,
)
self.model_call_details[
"standard_logging_object"
] = get_standard_logging_object_payload(
kwargs=self.model_call_details,
init_response_obj=result,
start_time=start_time,
end_time=end_time,
logging_obj=self,
status="success",
standard_built_in_tools_params=self.standard_built_in_tools_params,
)
elif standard_logging_object is not None:
self.model_call_details["standard_logging_object"] = (
standard_logging_object
)
self.model_call_details[
"standard_logging_object"
] = standard_logging_object
else: # streaming chunks + image gen.
self.model_call_details["response_cost"] = None
@@ -1597,7 +1596,6 @@ class Logging(LiteLLMLoggingBaseClass):
)
if complete_streaming_response is not None:
self.success_handler(result=complete_streaming_response)
return
@@ -1650,23 +1648,23 @@ class Logging(LiteLLMLoggingBaseClass):
verbose_logger.debug(
"Logging Details LiteLLM-Success Call streaming complete"
)
self.model_call_details["complete_streaming_response"] = (
complete_streaming_response
)
self.model_call_details["response_cost"] = (
self._response_cost_calculator(result=complete_streaming_response)
)
self.model_call_details[
"complete_streaming_response"
] = complete_streaming_response
self.model_call_details[
"response_cost"
] = self._response_cost_calculator(result=complete_streaming_response)
## STANDARDIZED LOGGING PAYLOAD
self.model_call_details["standard_logging_object"] = (
get_standard_logging_object_payload(
kwargs=self.model_call_details,
init_response_obj=complete_streaming_response,
start_time=start_time,
end_time=end_time,
logging_obj=self,
status="success",
standard_built_in_tools_params=self.standard_built_in_tools_params,
)
self.model_call_details[
"standard_logging_object"
] = get_standard_logging_object_payload(
kwargs=self.model_call_details,
init_response_obj=complete_streaming_response,
start_time=start_time,
end_time=end_time,
logging_obj=self,
status="success",
standard_built_in_tools_params=self.standard_built_in_tools_params,
)
callbacks = self.get_combined_callback_list(
dynamic_success_callbacks=self.dynamic_success_callbacks,
@@ -1994,10 +1992,10 @@ class Logging(LiteLLMLoggingBaseClass):
)
else:
if self.stream and complete_streaming_response:
self.model_call_details["complete_response"] = (
self.model_call_details.get(
"complete_streaming_response", {}
)
self.model_call_details[
"complete_response"
] = self.model_call_details.get(
"complete_streaming_response", {}
)
result = self.model_call_details["complete_response"]
openMeterLogger.log_success_event(
@@ -2036,10 +2034,10 @@ class Logging(LiteLLMLoggingBaseClass):
)
else:
if self.stream and complete_streaming_response:
self.model_call_details["complete_response"] = (
self.model_call_details.get(
"complete_streaming_response", {}
)
self.model_call_details[
"complete_response"
] = self.model_call_details.get(
"complete_streaming_response", {}
)
result = self.model_call_details["complete_response"]
@@ -2141,10 +2139,12 @@ class Logging(LiteLLMLoggingBaseClass):
result.usage = batch_usage
elif not is_base64_unified_file_id: # only run for non-unified file ids
response_cost, batch_usage, batch_models = (
await _handle_completed_batch(
batch=result, custom_llm_provider=self.custom_llm_provider
)
(
response_cost,
batch_usage,
batch_models,
) = await _handle_completed_batch(
batch=result, custom_llm_provider=self.custom_llm_provider
)
result._hidden_params["response_cost"] = response_cost
@@ -2175,9 +2175,9 @@ class Logging(LiteLLMLoggingBaseClass):
if complete_streaming_response is not None:
print_verbose("Async success callbacks: Got a complete streaming response")
self.model_call_details["async_complete_streaming_response"] = (
complete_streaming_response
)
self.model_call_details[
"async_complete_streaming_response"
] = complete_streaming_response
try:
if self.model_call_details.get("cache_hit", False) is True:
@@ -2188,10 +2188,10 @@ class Logging(LiteLLMLoggingBaseClass):
model_call_details=self.model_call_details
)
# base_model defaults to None if not set on model_info
self.model_call_details["response_cost"] = (
self._response_cost_calculator(
result=complete_streaming_response
)
self.model_call_details[
"response_cost"
] = self._response_cost_calculator(
result=complete_streaming_response
)
verbose_logger.debug(
@@ -2204,16 +2204,16 @@ class Logging(LiteLLMLoggingBaseClass):
self.model_call_details["response_cost"] = None
## STANDARDIZED LOGGING PAYLOAD
self.model_call_details["standard_logging_object"] = (
get_standard_logging_object_payload(
kwargs=self.model_call_details,
init_response_obj=complete_streaming_response,
start_time=start_time,
end_time=end_time,
logging_obj=self,
status="success",
standard_built_in_tools_params=self.standard_built_in_tools_params,
)
self.model_call_details[
"standard_logging_object"
] = get_standard_logging_object_payload(
kwargs=self.model_call_details,
init_response_obj=complete_streaming_response,
start_time=start_time,
end_time=end_time,
logging_obj=self,
status="success",
standard_built_in_tools_params=self.standard_built_in_tools_params,
)
callbacks = self.get_combined_callback_list(
dynamic_success_callbacks=self.dynamic_async_success_callbacks,
@@ -2426,18 +2426,18 @@ class Logging(LiteLLMLoggingBaseClass):
## STANDARDIZED LOGGING PAYLOAD
self.model_call_details["standard_logging_object"] = (
get_standard_logging_object_payload(
kwargs=self.model_call_details,
init_response_obj={},
start_time=start_time,
end_time=end_time,
logging_obj=self,
status="failure",
error_str=str(exception),
original_exception=exception,
standard_built_in_tools_params=self.standard_built_in_tools_params,
)
self.model_call_details[
"standard_logging_object"
] = get_standard_logging_object_payload(
kwargs=self.model_call_details,
init_response_obj={},
start_time=start_time,
end_time=end_time,
logging_obj=self,
status="failure",
error_str=str(exception),
original_exception=exception,
standard_built_in_tools_params=self.standard_built_in_tools_params,
)
return start_time, end_time
@@ -3326,9 +3326,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
endpoint=arize_config.endpoint,
)
os.environ["OTEL_EXPORTER_OTLP_TRACES_HEADERS"] = (
f"space_id={arize_config.space_key},api_key={arize_config.api_key}"
)
os.environ[
"OTEL_EXPORTER_OTLP_TRACES_HEADERS"
] = f"space_id={arize_config.space_key},api_key={arize_config.api_key}"
for callback in _in_memory_loggers:
if (
isinstance(callback, ArizeLogger)
@@ -3352,9 +3352,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
# auth can be disabled on local deployments of arize phoenix
if arize_phoenix_config.otlp_auth_headers is not None:
os.environ["OTEL_EXPORTER_OTLP_TRACES_HEADERS"] = (
arize_phoenix_config.otlp_auth_headers
)
os.environ[
"OTEL_EXPORTER_OTLP_TRACES_HEADERS"
] = arize_phoenix_config.otlp_auth_headers
for callback in _in_memory_loggers:
if (
@@ -3462,9 +3462,9 @@ def _init_custom_logger_compatible_class( # noqa: PLR0915
exporter="otlp_http",
endpoint="https://langtrace.ai/api/trace",
)
os.environ["OTEL_EXPORTER_OTLP_TRACES_HEADERS"] = (
f"api_key={os.getenv('LANGTRACE_API_KEY')}"
)
os.environ[
"OTEL_EXPORTER_OTLP_TRACES_HEADERS"
] = f"api_key={os.getenv('LANGTRACE_API_KEY')}"
for callback in _in_memory_loggers:
if (
isinstance(callback, OpenTelemetry)
@@ -4114,10 +4114,10 @@ class StandardLoggingPayloadSetup:
for key in StandardLoggingHiddenParams.__annotations__.keys():
if key in hidden_params:
if key == "additional_headers":
clean_hidden_params["additional_headers"] = (
StandardLoggingPayloadSetup.get_additional_headers(
hidden_params[key]
)
clean_hidden_params[
"additional_headers"
] = StandardLoggingPayloadSetup.get_additional_headers(
hidden_params[key]
)
else:
clean_hidden_params[key] = hidden_params[key] # type: ignore
@@ -4150,15 +4150,28 @@ class StandardLoggingPayloadSetup:
from litellm.integrations.s3 import get_s3_object_key
# Only generate object key if cold storage is configured
if litellm.configured_cold_storage_logger is None:
configured_cold_storage_logger = litellm.configured_cold_storage_logger
if configured_cold_storage_logger is None:
return None
try:
# Generate file name in same format as litellm.utils.get_logging_id
s3_file_name = f"time-{start_time.strftime('%H-%M-%S-%f')}_{response_id}"
# Get the actual s3_path from the configured cold storage logger instance
s3_path = "" # default value
# Try to get the actual logger instance from the logger name
try:
custom_logger = litellm.logging_callback_manager.get_active_custom_logger_for_callback_name(configured_cold_storage_logger)
if custom_logger and hasattr(custom_logger, 's3_path') and custom_logger.s3_path:
s3_path = custom_logger.s3_path
except Exception:
# If any error occurs in getting the logger instance, use default empty s3_path
pass
s3_object_key = get_s3_object_key(
s3_path="", # Use empty path as default
s3_path=s3_path, # Use actual s3_path from logger configuration
team_alias_prefix="", # Don't split by team alias for cold storage
start_time=start_time,
s3_file_name=s3_file_name,
@@ -4553,6 +4566,9 @@ def get_standard_logging_metadata(
clean_metadata = StandardLoggingMetadata(
user_api_key_hash=None,
user_api_key_alias=None,
user_api_key_spend=None,
user_api_key_max_budget=None,
user_api_key_budget_reset_at=None,
user_api_key_team_id=None,
user_api_key_org_id=None,
user_api_key_user_id=None,
@@ -4602,9 +4618,9 @@ def scrub_sensitive_keys_in_metadata(litellm_params: Optional[dict]):
):
for k, v in metadata["user_api_key_metadata"].items():
if k == "logging": # prevent logging user logging keys
cleaned_user_api_key_metadata[k] = (
"scrubbed_by_litellm_for_sensitive_keys"
)
cleaned_user_api_key_metadata[
k
] = "scrubbed_by_litellm_for_sensitive_keys"
else:
cleaned_user_api_key_metadata[k] = v
@@ -16,8 +16,8 @@ from litellm import verbose_logger
from litellm.llms.custom_httpx.http_handler import HTTPHandler, get_async_httpx_client
from litellm.types.files import get_file_extension_from_mime_type
from litellm.types.llms.anthropic import *
from litellm.types.llms.bedrock import MessageBlock as BedrockMessageBlock
from litellm.types.llms.bedrock import CachePointBlock
from litellm.types.llms.bedrock import MessageBlock as BedrockMessageBlock
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.llms.ollama import OllamaVisionModelObject
from litellm.types.llms.openai import (
@@ -1067,10 +1067,10 @@ def convert_to_gemini_tool_call_invoke(
if tool_calls is not None:
for tool in tool_calls:
if "function" in tool:
gemini_function_call: Optional[VertexFunctionCall] = (
_gemini_tool_call_invoke_helper(
function_call_params=tool["function"]
)
gemini_function_call: Optional[
VertexFunctionCall
] = _gemini_tool_call_invoke_helper(
function_call_params=tool["function"]
)
if gemini_function_call is not None:
_parts_list.append(
@@ -1589,9 +1589,9 @@ def anthropic_messages_pt( # noqa: PLR0915
)
if "cache_control" in _content_element:
_anthropic_content_element["cache_control"] = (
_content_element["cache_control"]
)
_anthropic_content_element[
"cache_control"
] = _content_element["cache_control"]
user_content.append(_anthropic_content_element)
elif m.get("type", "") == "text":
m = cast(ChatCompletionTextObject, m)
@@ -1629,9 +1629,9 @@ def anthropic_messages_pt( # noqa: PLR0915
)
if "cache_control" in _content_element:
_anthropic_content_text_element["cache_control"] = (
_content_element["cache_control"]
)
_anthropic_content_text_element[
"cache_control"
] = _content_element["cache_control"]
user_content.append(_anthropic_content_text_element)
@@ -2482,8 +2482,7 @@ class BedrockImageProcessor:
if is_document:
return BedrockImageProcessor._get_document_format(
mime_type=mime_type,
supported_doc_formats=supported_doc_formats
mime_type=mime_type, supported_doc_formats=supported_doc_formats
)
else:
@@ -2495,12 +2494,9 @@ class BedrockImageProcessor:
f"Unsupported image format: {image_format}. Supported formats: {supported_image_and_video_formats}"
)
return image_format
@staticmethod
def _get_document_format(
mime_type: str,
supported_doc_formats: List[str]
) -> str:
def _get_document_format(mime_type: str, supported_doc_formats: List[str]) -> str:
"""
Get the document format from the mime type
@@ -2519,13 +2515,9 @@ class BedrockImageProcessor:
The document format
"""
valid_extensions: Optional[List[str]] = None
potential_extensions = mimetypes.guess_all_extensions(
mime_type, strict=False
)
potential_extensions = mimetypes.guess_all_extensions(mime_type, strict=False)
valid_extensions = [
ext[1:]
for ext in potential_extensions
if ext[1:] in supported_doc_formats
ext[1:] for ext in potential_extensions if ext[1:] in supported_doc_formats
]
# Fallback to types/files.py if mimetypes doesn't return valid extensions
@@ -2689,10 +2681,12 @@ def _convert_to_bedrock_tool_call_invoke(
)
bedrock_content_block = BedrockContentBlock(toolUse=bedrock_tool)
_parts_list.append(bedrock_content_block)
# Check for cache_control and add a separate cachePoint block
if tool.get("cache_control", None) is not None:
cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default"))
cache_point_block = BedrockContentBlock(
cachePoint=CachePointBlock(type="default")
)
_parts_list.append(cache_point_block)
return _parts_list
except Exception as e:
@@ -2754,7 +2748,7 @@ def _convert_to_bedrock_tool_call_result(
for content in content_list:
if content["type"] == "text":
content_str += content["text"]
message.get("name", "")
id = str(message.get("tool_call_id", str(uuid.uuid4())))
@@ -2763,7 +2757,7 @@ def _convert_to_bedrock_tool_call_result(
content=[tool_result_content_block],
toolUseId=id,
)
content_block = BedrockContentBlock(toolResult=tool_result)
return content_block
@@ -3085,6 +3079,7 @@ class BedrockConverseMessagesProcessor:
messages.append(DEFAULT_USER_CONTINUE_MESSAGE)
return messages
@staticmethod
async def _bedrock_converse_messages_pt_async( # noqa: PLR0915
messages: List,
@@ -3128,6 +3123,12 @@ class BedrockConverseMessagesProcessor:
if element["type"] == "text":
_part = BedrockContentBlock(text=element["text"])
_parts.append(_part)
elif element["type"] == "guarded_text":
# Wrap guarded_text in guardrailConverseContent block
_part = BedrockContentBlock(
guardrailConverseContent={"text": element["text"]}
)
_parts.append(_part)
elif element["type"] == "image_url":
format: Optional[str] = None
if isinstance(element["image_url"], dict):
@@ -3170,6 +3171,7 @@ class BedrockConverseMessagesProcessor:
msg_i += 1
if user_content:
if len(contents) > 0 and contents[-1]["role"] == "user":
if (
assistant_continue_message is not None
@@ -3199,26 +3201,29 @@ class BedrockConverseMessagesProcessor:
current_message = messages[msg_i]
tool_call_result = _convert_to_bedrock_tool_call_result(current_message)
tool_content.append(tool_call_result)
# Check if we need to add a separate cachePoint block
has_cache_control = False
# Check for message-level cache_control
if current_message.get("cache_control", None) is not None:
has_cache_control = True
# Check for content-level cache_control in list content
elif isinstance(current_message.get("content"), list):
for content_element in current_message["content"]:
if (isinstance(content_element, dict) and
content_element.get("cache_control", None) is not None):
if (
isinstance(content_element, dict)
and content_element.get("cache_control", None) is not None
):
has_cache_control = True
break
# Add a separate cachePoint block if cache_control is present
if has_cache_control:
cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default"))
cache_point_block = BedrockContentBlock(
cachePoint=CachePointBlock(type="default")
)
tool_content.append(cache_point_block)
msg_i += 1
if tool_content:
@@ -3299,7 +3304,7 @@ class BedrockConverseMessagesProcessor:
image_url=image_url
)
assistants_parts.append(assistants_part)
# Add cache point block for assistant content elements
# Add cache point block for assistant content elements
_cache_point_block = (
litellm.AmazonConverseConfig()._get_cache_point_block(
message_block=cast(
@@ -3311,8 +3316,12 @@ class BedrockConverseMessagesProcessor:
if _cache_point_block is not None:
assistants_parts.append(_cache_point_block)
assistant_content.extend(assistants_parts)
elif _assistant_content is not None and isinstance(_assistant_content, str):
assistant_content.append(BedrockContentBlock(text=_assistant_content))
elif _assistant_content is not None and isinstance(
_assistant_content, str
):
assistant_content.append(
BedrockContentBlock(text=_assistant_content)
)
# Add cache point block for assistant string content
_cache_point_block = (
litellm.AmazonConverseConfig()._get_cache_point_block(
@@ -3496,6 +3505,12 @@ def _bedrock_converse_messages_pt( # noqa: PLR0915
if element["type"] == "text":
_part = BedrockContentBlock(text=element["text"])
_parts.append(_part)
elif element["type"] == "guarded_text":
# Wrap guarded_text in guardrailConverseContent block
_part = BedrockContentBlock(
guardrailConverseContent={"text": element["text"]}
)
_parts.append(_part)
elif element["type"] == "image_url":
format: Optional[str] = None
if isinstance(element["image_url"], dict):
@@ -3539,6 +3554,7 @@ def _bedrock_converse_messages_pt( # noqa: PLR0915
msg_i += 1
if user_content:
if len(contents) > 0 and contents[-1]["role"] == "user":
if (
assistant_continue_message is not None
@@ -3565,29 +3581,33 @@ def _bedrock_converse_messages_pt( # noqa: PLR0915
while msg_i < len(messages) and messages[msg_i]["role"] == "tool":
tool_call_result = _convert_to_bedrock_tool_call_result(messages[msg_i])
current_message = messages[msg_i]
# Add the tool result first
tool_content.append(tool_call_result)
# Check if we need to add a separate cachePoint block
has_cache_control = False
# Check for message-level cache_control
if current_message.get("cache_control", None) is not None:
has_cache_control = True
# Check for content-level cache_control in list content
elif isinstance(current_message.get("content"), list):
for content_element in current_message["content"]:
if (isinstance(content_element, dict) and
content_element.get("cache_control", None) is not None):
if (
isinstance(content_element, dict)
and content_element.get("cache_control", None) is not None
):
has_cache_control = True
break
# Add a separate cachePoint block if cache_control is present
if has_cache_control:
cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default"))
cache_point_block = BedrockContentBlock(
cachePoint=CachePointBlock(type="default")
)
tool_content.append(cache_point_block)
msg_i += 1
if tool_content:
# if last message was a 'user' message, then add a blank assistant message (bedrock requires alternating roles)
@@ -3852,10 +3872,9 @@ def function_call_prompt(messages: list, functions: list):
if isinstance(message["content"], str):
message["content"] += f""" {function_prompt}"""
else:
message["content"].append({
"type": "text",
"text": f""" {function_prompt}"""
})
message["content"].append(
{"type": "text", "text": f""" {function_prompt}"""}
)
function_added_to_prompt = True
if function_added_to_prompt is False:
@@ -200,8 +200,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
)
_allowed_properties = set(AnthropicInputSchema.__annotations__.keys())
input_schema_filtered = {k: v for k, v in _input_schema.items() if k in _allowed_properties}
input_anthropic_schema: AnthropicInputSchema = AnthropicInputSchema(**input_schema_filtered)
input_schema_filtered = {
k: v for k, v in _input_schema.items() if k in _allowed_properties
}
input_anthropic_schema: AnthropicInputSchema = AnthropicInputSchema(
**input_schema_filtered
)
_tool = AnthropicMessagesTool(
name=tool["function"]["name"],
@@ -158,6 +158,48 @@ class BaseBatchesConfig(ABC):
"""
pass
@abstractmethod
def transform_retrieve_batch_request(
self,
batch_id: str,
optional_params: dict,
litellm_params: dict,
) -> Union[bytes, str, Dict[str, Any]]:
"""
Transform the batch retrieval request to provider-specific format.
Args:
batch_id: Batch ID to retrieve
optional_params: Optional parameters
litellm_params: LiteLLM parameters
Returns:
Transformed request data
"""
pass
@abstractmethod
def transform_retrieve_batch_response(
self,
model: Optional[str],
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
) -> LiteLLMBatch:
"""
Transform provider-specific batch retrieval response to LiteLLM format.
Args:
model: Model name
raw_response: Raw HTTP response
logging_obj: Logging object
litellm_params: LiteLLM parameters
Returns:
LiteLLM batch object
"""
pass
@abstractmethod
def get_error_class(
self, error_message: str, status_code: int, headers: Union[Dict, Headers]
+206 -6
View File
@@ -7,7 +7,6 @@ from httpx import Headers, Response
from litellm.llms.base_llm.batches.transformation import BaseBatchesConfig
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.types.llms.bedrock import (
BedrockBatchJobStatus,
BedrockCreateBatchRequest,
BedrockCreateBatchResponse,
BedrockInputDataConfig,
@@ -200,19 +199,23 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig):
# Extract information from typed Bedrock response
job_arn = response_data.get("jobArn", "")
status: BedrockBatchJobStatus = response_data.get("status", "Submitted")
status_str: str = str(response_data.get("status", "Submitted"))
# Map Bedrock status to OpenAI-compatible status
status_mapping: Dict[BedrockBatchJobStatus, str] = {
status_mapping: Dict[str, str] = {
"Submitted": "validating",
"Validating": "validating",
"Scheduled": "in_progress",
"InProgress": "in_progress",
"PartiallyCompleted": "completed",
"Completed": "completed",
"Failed": "failed",
"Stopping": "cancelling",
"Stopped": "cancelled"
"Stopped": "cancelled",
"Expired": "expired",
}
openai_status = cast(Literal["validating", "failed", "in_progress", "finalizing", "completed", "expired", "cancelling", "cancelled"], status_mapping.get(status, "validating"))
openai_status = cast(Literal["validating", "failed", "in_progress", "finalizing", "completed", "expired", "cancelling", "cancelled"], status_mapping.get(status_str, "validating"))
# Get original request data from litellm_params if available
original_request = litellm_params.get("original_batch_request", {})
@@ -229,7 +232,7 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig):
output_file_id=None, # Will be populated when job completes
error_file_id=None,
created_at=int(time.time()),
in_progress_at=int(time.time()) if status == "InProgress" else None,
in_progress_at=int(time.time()) if status_str == "InProgress" else None,
expires_at=None,
finalizing_at=None,
completed_at=None,
@@ -241,6 +244,203 @@ class BedrockBatchesConfig(BaseAWSLLM, BaseBatchesConfig):
metadata=original_request.get("metadata", {}),
)
def transform_retrieve_batch_request(
self,
batch_id: str,
optional_params: dict,
litellm_params: dict,
) -> Dict[str, Any]:
"""
Transform batch retrieval request for Bedrock.
Args:
batch_id: Bedrock job ARN
optional_params: Optional parameters
litellm_params: LiteLLM parameters
Returns:
Transformed request data for Bedrock GetModelInvocationJob API
"""
# For Bedrock, batch_id should be the full job ARN
# The GetModelInvocationJob API expects the full ARN as the identifier
if not batch_id.startswith("arn:aws:bedrock:"):
raise ValueError(f"Invalid batch_id format. Expected ARN, got: {batch_id}")
# Extract the job identifier from the ARN - use the full ARN path part
# ARN format: arn:aws:bedrock:region:account:model-invocation-job/job-name
arn_parts = batch_id.split(":")
if len(arn_parts) < 6:
raise ValueError(f"Invalid ARN format: {batch_id}")
region = arn_parts[3]
# arn_parts[5] contains "model-invocation-job/{jobId}"
# Build the endpoint URL for GetModelInvocationJob
# AWS API format: GET /model-invocation-job/{jobIdentifier}
# Use the FULL ARN as jobIdentifier and URL-encode it (includes ':' and '/')
import urllib.parse as _ul
encoded_arn = _ul.quote(batch_id, safe="")
endpoint_url = f"https://bedrock.{region}.amazonaws.com/model-invocation-job/{encoded_arn}"
# Use common utility for AWS signing
signed_headers, _ = self.common_utils.sign_aws_request(
service_name="bedrock",
data={}, # GET request has no body
endpoint_url=endpoint_url,
optional_params=optional_params,
method="GET"
)
# Return pre-signed request format
return {
"method": "GET",
"url": endpoint_url,
"headers": signed_headers,
"data": None
}
def _parse_timestamps_and_status(self, response_data, status_str: str):
"""Helper to parse timestamps based on status."""
import datetime
def parse_timestamp(ts_str: Optional[str]) -> Optional[int]:
if not ts_str:
return None
try:
dt = datetime.datetime.fromisoformat(ts_str.replace('Z', '+00:00'))
return int(dt.timestamp())
except Exception:
return None
created_at = parse_timestamp(str(response_data.get("submitTime")) if response_data.get("submitTime") is not None else None)
in_progress_states = {"InProgress", "Validating", "Scheduled"}
in_progress_at = (
parse_timestamp(str(response_data.get("lastModifiedTime")) if response_data.get("lastModifiedTime") is not None else None)
if status_str in in_progress_states
else None
)
completed_at = parse_timestamp(str(response_data.get("endTime")) if response_data.get("endTime") is not None else None) if status_str in {"Completed", "PartiallyCompleted"} else None
failed_at = parse_timestamp(str(response_data.get("endTime")) if response_data.get("endTime") is not None else None) if status_str == "Failed" else None
cancelled_at = parse_timestamp(str(response_data.get("endTime")) if response_data.get("endTime") is not None else None) if status_str == "Stopped" else None
expires_at = parse_timestamp(str(response_data.get("jobExpirationTime")) if response_data.get("jobExpirationTime") is not None else None)
return created_at, in_progress_at, completed_at, failed_at, cancelled_at, expires_at
def _extract_file_configs(self, response_data):
"""Helper to extract input and output file configurations."""
# Extract input file ID
input_file_id = ""
input_data_config = response_data.get("inputDataConfig", {})
if isinstance(input_data_config, dict):
s3_input_config = input_data_config.get("s3InputDataConfig", {})
if isinstance(s3_input_config, dict):
input_file_id = s3_input_config.get("s3Uri", "")
# Extract output file ID
output_file_id = None
output_data_config = response_data.get("outputDataConfig", {})
if isinstance(output_data_config, dict):
s3_output_config = output_data_config.get("s3OutputDataConfig", {})
if isinstance(s3_output_config, dict):
output_file_id = s3_output_config.get("s3Uri", "")
return input_file_id, output_file_id
def _extract_errors_and_metadata(self, response_data, raw_response):
"""Helper to extract errors and enriched metadata."""
# Extract errors
message = response_data.get("message")
errors = None
if message:
from openai.types.batch import Errors
from openai.types.batch_error import BatchError
errors = Errors(
data=[BatchError(message=message, code=str(raw_response.status_code))],
object="list"
)
# Enrich metadata with useful Bedrock fields
enriched_metadata_raw: Dict[str, Any] = {
"jobName": response_data.get("jobName"),
"clientRequestToken": response_data.get("clientRequestToken"),
"modelId": response_data.get("modelId"),
"roleArn": response_data.get("roleArn"),
"timeoutDurationInHours": response_data.get("timeoutDurationInHours"),
"vpcConfig": response_data.get("vpcConfig"),
}
import json as _json
enriched_metadata: Dict[str, str] = {}
for _k, _v in enriched_metadata_raw.items():
if _v is None:
continue
if isinstance(_v, (dict, list)):
try:
enriched_metadata[_k] = _json.dumps(_v)
except Exception:
enriched_metadata[_k] = str(_v)
else:
enriched_metadata[_k] = str(_v)
return errors, enriched_metadata
def transform_retrieve_batch_response(
self,
model: Optional[str],
raw_response: Response,
logging_obj: Any,
litellm_params: dict,
) -> LiteLLMBatch:
"""
Transform Bedrock batch retrieval response to LiteLLM format.
"""
from litellm.types.llms.bedrock import BedrockGetBatchResponse
try:
response_data: BedrockGetBatchResponse = raw_response.json()
except Exception as e:
raise ValueError(f"Failed to parse Bedrock batch response: {e}")
job_arn = response_data.get("jobArn", "")
status_str: str = str(response_data.get("status", "Submitted"))
# Map Bedrock status to OpenAI-compatible status
status_mapping: Dict[str, str] = {
"Submitted": "validating", "Validating": "validating", "Scheduled": "in_progress",
"InProgress": "in_progress", "PartiallyCompleted": "completed", "Completed": "completed",
"Failed": "failed", "Stopping": "cancelling", "Stopped": "cancelled", "Expired": "expired"
}
openai_status = cast(Literal["validating", "failed", "in_progress", "finalizing", "completed", "expired", "cancelling", "cancelled"], status_mapping.get(status_str, "validating"))
# Parse timestamps
created_at, in_progress_at, completed_at, failed_at, cancelled_at, expires_at = self._parse_timestamps_and_status(response_data, status_str)
# Extract file configurations
input_file_id, output_file_id = self._extract_file_configs(response_data)
# Extract errors and metadata
errors, enriched_metadata = self._extract_errors_and_metadata(response_data, raw_response)
return LiteLLMBatch(
id=job_arn,
object="batch",
endpoint="/v1/chat/completions",
errors=errors,
input_file_id=input_file_id,
completion_window="24h",
status=openai_status,
output_file_id=output_file_id,
error_file_id=None,
created_at=created_at or int(time.time()),
in_progress_at=in_progress_at,
expires_at=expires_at,
finalizing_at=None,
completed_at=completed_at,
failed_at=failed_at,
expired_at=None,
cancelling_at=None,
cancelled_at=cancelled_at,
request_counts=None,
metadata=enriched_metadata,
)
def get_error_class(
self, error_message: str, status_code: int, headers: Union[Dict, Headers]
) -> BaseLLMException:
@@ -501,7 +501,6 @@ class AmazonConverseConfig(BaseConfig):
)
and not is_thinking_enabled
):
optional_params["tool_choice"] = ToolChoiceValuesBlock(
tool=SpecificToolChoiceBlock(name=RESPONSE_FORMAT_TOOL_NAME)
)
@@ -995,7 +994,9 @@ class AmazonConverseConfig(BaseConfig):
return message, returned_finish_reason
def _translate_message_content(self, content_blocks: List[ContentBlock]) -> Tuple[
def _translate_message_content(
self, content_blocks: List[ContentBlock]
) -> Tuple[
str,
List[ChatCompletionToolCallChunk],
Optional[List[BedrockConverseReasoningContentBlock]],
@@ -1010,9 +1011,9 @@ class AmazonConverseConfig(BaseConfig):
"""
content_str = ""
tools: List[ChatCompletionToolCallChunk] = []
reasoningContentBlocks: Optional[List[BedrockConverseReasoningContentBlock]] = (
None
)
reasoningContentBlocks: Optional[
List[BedrockConverseReasoningContentBlock]
] = None
for idx, content in enumerate(content_blocks):
"""
- Content is either a tool response or text
@@ -1133,9 +1134,9 @@ class AmazonConverseConfig(BaseConfig):
chat_completion_message: ChatCompletionResponseMessage = {"role": "assistant"}
content_str = ""
tools: List[ChatCompletionToolCallChunk] = []
reasoningContentBlocks: Optional[List[BedrockConverseReasoningContentBlock]] = (
None
)
reasoningContentBlocks: Optional[
List[BedrockConverseReasoningContentBlock]
] = None
if message is not None:
(
@@ -1148,12 +1149,12 @@ class AmazonConverseConfig(BaseConfig):
chat_completion_message["provider_specific_fields"] = {
"reasoningContentBlocks": reasoningContentBlocks,
}
chat_completion_message["reasoning_content"] = (
self._transform_reasoning_content(reasoningContentBlocks)
)
chat_completion_message["thinking_blocks"] = (
self._transform_thinking_blocks(reasoningContentBlocks)
)
chat_completion_message[
"reasoning_content"
] = self._transform_reasoning_content(reasoningContentBlocks)
chat_completion_message[
"thinking_blocks"
] = self._transform_thinking_blocks(reasoningContentBlocks)
chat_completion_message["content"] = content_str
if (
json_mode is True
@@ -1171,7 +1172,6 @@ class AmazonConverseConfig(BaseConfig):
# Bedrock returns the response wrapped in a "properties" object
# We need to extract the actual content from this wrapper
try:
response_data = json.loads(json_mode_content_str)
# If Bedrock wrapped the response in "properties", extract the content
+13 -8
View File
@@ -738,19 +738,24 @@ class CommonBatchFilesUtils:
)
# Prepare the request data
if isinstance(data, dict):
import json
request_data = json.dumps(data)
method_upper = method.upper()
if method_upper == "GET":
# GET requests should be signed with an empty payload
request_data = ""
headers = {}
else:
request_data = data
# Prepare headers
headers = {"Content-Type": "application/json"}
if isinstance(data, dict):
import json
request_data = json.dumps(data)
else:
request_data = data
# Prepare headers for non-GET requests
headers = {"Content-Type": "application/json"}
# Create AWS request and sign it
sigv4 = SigV4Auth(credentials, service_name, aws_region_name)
request = AWSRequest(
method=method.upper(), url=endpoint_url, data=request_data, headers=headers
method=method_upper, url=endpoint_url, data=request_data, headers=headers
)
sigv4.add_auth(request)
prepped = request.prepare()
@@ -2520,6 +2520,95 @@ class BaseLLMHTTPHandler:
litellm_params=litellm_params_with_request,
)
def retrieve_batch(
self,
batch_id: str,
litellm_params: dict,
provider_config: "BaseBatchesConfig",
headers: dict,
api_base: Optional[str],
api_key: Optional[str],
logging_obj: "LiteLLMLoggingObj",
_is_async: bool = False,
client: Optional[Union["HTTPHandler", "AsyncHTTPHandler"]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
model: Optional[str] = None,
) -> Union["LiteLLMBatch", Coroutine[Any, Any, "LiteLLMBatch"]]:
"""
Retrieve a batch using provider-specific configuration.
"""
# Transform the request using provider config
transformed_request = provider_config.transform_retrieve_batch_request(
batch_id=batch_id,
optional_params=litellm_params,
litellm_params=litellm_params,
)
if _is_async:
return self.async_retrieve_batch(
transformed_request=transformed_request,
litellm_params=litellm_params,
provider_config=provider_config,
headers=headers,
api_base=api_base,
logging_obj=logging_obj,
client=client,
timeout=timeout,
batch_id=batch_id,
model=model,
)
if client is None or not isinstance(client, HTTPHandler):
sync_httpx_client = _get_httpx_client()
else:
sync_httpx_client = client
try:
if (
isinstance(transformed_request, dict)
and "method" in transformed_request
):
# Handle pre-signed requests (e.g., from Bedrock with AWS auth)
method = transformed_request["method"].lower()
request_kwargs = {
"url": transformed_request["url"],
"headers": transformed_request["headers"],
}
# Only add data for non-GET requests
if method != "get" and transformed_request.get("data") is not None:
request_kwargs["data"] = transformed_request["data"]
batch_response = getattr(sync_httpx_client, method)(**request_kwargs)
elif isinstance(transformed_request, dict) and api_base:
# For other providers that use JSON requests
batch_response = sync_httpx_client.get(
url=api_base,
headers={**headers, "Content-Type": "application/json"},
params=transformed_request,
)
else:
# Handle other request types if needed
if not api_base:
raise ValueError("api_base is required for non-pre-signed requests")
batch_response = sync_httpx_client.get(
url=api_base,
headers=headers,
)
except Exception as e:
verbose_logger.exception(f"Error retrieving batch: {e}")
raise self._handle_error(
e=e,
provider_config=provider_config,
)
return provider_config.transform_retrieve_batch_response(
model=model,
raw_response=batch_response,
logging_obj=logging_obj,
litellm_params=litellm_params,
)
async def async_create_batch(
self,
transformed_request: Union[bytes, str, dict],
@@ -2606,6 +2695,89 @@ class BaseLLMHTTPHandler:
litellm_params=litellm_params_with_request,
)
async def async_retrieve_batch(
self,
transformed_request: Union[bytes, str, dict],
litellm_params: dict,
provider_config: "BaseBatchesConfig",
headers: dict,
api_base: Optional[str],
logging_obj: "LiteLLMLoggingObj",
client: Optional[Union["HTTPHandler", "AsyncHTTPHandler"]] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
batch_id: Optional[str] = None,
model: Optional[str] = None,
):
"""
Async version of retrieve_batch
"""
if client is None or not isinstance(client, AsyncHTTPHandler):
async_httpx_client = get_async_httpx_client(
llm_provider=provider_config.custom_llm_provider
)
else:
async_httpx_client = client
#########################################################
# Debug Logging
#########################################################
logging_obj.pre_call(
input="",
api_key="",
additional_args={
"complete_input_dict": transformed_request,
"api_base": api_base,
"headers": headers,
"batch_id": batch_id,
},
)
try:
if (
isinstance(transformed_request, dict)
and "method" in transformed_request
):
# Handle pre-signed requests (e.g., from Bedrock with AWS auth)
method = transformed_request["method"].lower()
request_kwargs = {
"url": transformed_request["url"],
"headers": transformed_request["headers"],
}
# Only add data for non-GET requests
if method != "get" and transformed_request.get("data") is not None:
request_kwargs["data"] = transformed_request["data"]
batch_response = await getattr(async_httpx_client, method)(**request_kwargs)
elif isinstance(transformed_request, dict) and api_base:
# For other providers that use JSON requests
batch_response = await async_httpx_client.get(
url=api_base,
headers={**headers, "Content-Type": "application/json"},
params=transformed_request,
)
else:
# Handle other request types if needed
if not api_base:
raise ValueError("api_base is required for non-pre-signed requests")
batch_response = await async_httpx_client.get(
url=api_base,
headers=headers,
)
except Exception as e:
verbose_logger.exception(f"Error retrieving batch: {e}")
raise self._handle_error(
e=e,
provider_config=provider_config,
)
return provider_config.transform_retrieve_batch_response(
model=model,
raw_response=batch_response,
logging_obj=logging_obj,
litellm_params=litellm_params,
)
def cancel_response_api_handler(
self,
response_id: str,
+1 -1
View File
@@ -378,7 +378,7 @@ class OCIChatConfig(BaseConfig):
or not oci_compartment_id
):
raise Exception(
"Missing required parameters: oci_user, oci_fingerprint, oci_tenancy, "
"Missing required parameters: oci_user, oci_fingerprint, oci_tenancy, oci_compartment_id "
"and at least one of oci_key or oci_key_file."
)
+17 -9
View File
@@ -239,6 +239,7 @@ class VertexBase:
stream=stream,
auth_header=None,
url=default_api_base,
model=model,
)
return api_base
@@ -292,6 +293,7 @@ class VertexBase:
stream: Optional[bool],
auth_header: Optional[str],
url: str,
model: Optional[str] = None,
) -> Tuple[Optional[str], str]:
"""
for cloudflare ai gateway - https://github.com/BerriAI/litellm/issues/4317
@@ -301,7 +303,12 @@ class VertexBase:
"""
if api_base:
if custom_llm_provider == "gemini":
url = "{}:{}".format(api_base, endpoint)
# For Gemini (Google AI Studio), construct the full path like other providers
if model is None:
raise ValueError(
"Model parameter is required for Gemini custom API base URLs"
)
url = "{}/models/{}:{}".format(api_base, model, endpoint)
if gemini_api_key is None:
raise ValueError(
"Missing gemini_api_key, please set `GEMINI_API_KEY`"
@@ -373,6 +380,7 @@ class VertexBase:
endpoint=endpoint,
stream=stream,
url=url,
model=model,
)
def _handle_reauthentication(
@@ -384,19 +392,19 @@ class VertexBase:
) -> Tuple[str, str]:
"""
Handle reauthentication when credentials refresh fails.
This method clears the cached credentials and attempts to reload them once.
It should only be called when "Reauthentication is needed" error occurs.
Args:
credentials: The original credentials
project_id: The project ID
credential_cache_key: The cache key to clear
error: The original error that triggered reauthentication
Returns:
Tuple of (access_token, project_id)
Raises:
The original error if reauthentication fails
"""
@@ -404,11 +412,11 @@ class VertexBase:
f"Handling reauthentication for project_id: {project_id}. "
f"Clearing cache and retrying once."
)
# Clear the cached credentials
if credential_cache_key in self._credentials_project_mapping:
del self._credentials_project_mapping[credential_cache_key]
# Retry once with _retry_reauth=True to prevent infinite recursion
try:
return self.get_access_token(
@@ -438,12 +446,12 @@ class VertexBase:
3. Check if loaded credentials have expired
4. If expired, refresh credentials
5. Return access token and project id
Args:
credentials: The credentials to use for authentication
project_id: The Google Cloud project ID
_retry_reauth: Internal flag to prevent infinite recursion during reauthentication
Returns:
Tuple of (access_token, project_id)
"""
+3 -3
View File
@@ -116,6 +116,7 @@ from litellm.utils import (
from ._logging import verbose_logger
from .caching.caching import disable_cache, enable_cache, update_cache
from .litellm_core_utils.core_helpers import safe_deep_copy
from .litellm_core_utils.fallback_utils import (
async_completion_with_fallbacks,
completion_with_fallbacks,
@@ -2847,8 +2848,7 @@ def completion( # type: ignore # noqa: PLR0915
)
api_base = api_base or litellm.api_base or get_secret("GEMINI_API_BASE")
new_params = deepcopy(optional_params)
new_params = safe_deep_copy(optional_params or {})
response = vertex_chat_completion.completion( # type: ignore
model=model,
messages=messages,
@@ -2892,7 +2892,7 @@ def completion( # type: ignore # noqa: PLR0915
api_base = api_base or litellm.api_base or get_secret("VERTEXAI_API_BASE")
new_params = deepcopy(optional_params)
new_params = safe_deep_copy(optional_params or {})
if vertex_partner_models_chat_completion.is_vertex_partner_model(model):
model_response = vertex_partner_models_chat_completion.completion(
model=model,
File diff suppressed because it is too large Load Diff
+8 -11
View File
@@ -469,16 +469,13 @@ async def get_end_user_object(
# check if in cache
cached_user_obj = await user_api_key_cache.async_get_cache(key=_key)
if cached_user_obj is not None:
if isinstance(cached_user_obj, dict):
return_obj = LiteLLM_EndUserTable(**cached_user_obj)
check_in_budget(end_user_obj=return_obj)
return return_obj
elif isinstance(cached_user_obj, LiteLLM_EndUserTable):
return_obj = cached_user_obj
check_in_budget(end_user_obj=return_obj)
return return_obj
# Convert cached dict to LiteLLM_EndUserTable instance
return_obj = LiteLLM_EndUserTable(**cached_user_obj)
check_in_budget(end_user_obj=return_obj)
return return_obj
# else, check db
try:
try:
response = await prisma_client.db.litellm_endusertable.find_unique(
where={"user_id": end_user_id},
include={"litellm_budget_table": True},
@@ -487,9 +484,9 @@ async def get_end_user_object(
if response is None:
raise Exception
# save the end-user object to cache
# save the end-user object to cache (always store as dict for consistency)
await user_api_key_cache.async_set_cache(
key="end_user_id:{}".format(end_user_id), value=response
key="end_user_id:{}".format(end_user_id), value=response.dict()
)
_response = LiteLLM_EndUserTable(**response.dict())
@@ -0,0 +1,125 @@
"""
Performance utilities for LiteLLM proxy server.
This module provides performance monitoring and profiling functionality for endpoint
performance analysis using cProfile with configurable sampling rates.
"""
import asyncio
import cProfile
import functools
import threading
from pathlib import Path as PathLib
from litellm._logging import verbose_proxy_logger
# Global profiling state
_profile_lock = threading.Lock()
_profiler = None
_last_profile_file_path = None
_sample_counter = 0
_sample_counter_lock = threading.Lock()
def _should_sample(profile_sampling_rate: float) -> bool:
"""Determine if current request should be sampled based on sampling rate."""
if profile_sampling_rate >= 1.0:
return True # Always sample
elif profile_sampling_rate <= 0.0:
return False # Never sample
# Use deterministic sampling based on counter for consistent rate
global _sample_counter
with _sample_counter_lock:
_sample_counter += 1
# Sample based on rate (e.g., 0.1 means sample every 10th request)
should_sample = (_sample_counter % int(1.0 / profile_sampling_rate)) == 0
return should_sample
def _start_profiling(profile_sampling_rate: float) -> None:
"""Start cProfile profiling once globally."""
global _profiler
with _profile_lock:
if _profiler is None:
_profiler = cProfile.Profile()
_profiler.enable()
verbose_proxy_logger.info(f"Profiling started with sampling rate: {profile_sampling_rate}")
def _start_profiling_for_request(profile_sampling_rate: float) -> bool:
"""Start profiling for a specific request (if sampling allows)."""
if _should_sample(profile_sampling_rate):
_start_profiling(profile_sampling_rate)
return True
return False
def _save_stats(profile_file: PathLib) -> None:
"""Save current stats directly to file."""
with _profile_lock:
if _profiler is None:
return
try:
# Disable profiler temporarily to dump stats
_profiler.disable()
_profiler.dump_stats(str(profile_file))
# Re-enable profiler to continue profiling
_profiler.enable()
verbose_proxy_logger.debug(f"Profiling stats saved to {profile_file}")
except Exception as e:
verbose_proxy_logger.error(f"Error saving profiling stats: {e}")
# Make sure profiler is re-enabled even if there's an error
try:
_profiler.enable()
except Exception:
pass
def profile_endpoint(sampling_rate: float = 1.0):
"""Decorator to sample endpoint hits and save to a profile file.
Args:
sampling_rate: Rate of requests to profile (0.0 to 1.0)
- 1.0: Profile all requests (100%)
- 0.1: Profile 1 in 10 requests (10%)
- 0.0: Profile no requests (0%)
"""
def decorator(func):
def set_last_profile_path(path: PathLib) -> None:
global _last_profile_file_path
_last_profile_file_path = path
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
is_sampling = _start_profiling_for_request(sampling_rate)
file_path_obj = PathLib("endpoint_profile.pstat")
set_last_profile_path(file_path_obj)
try:
result = await func(*args, **kwargs)
if is_sampling:
_save_stats(file_path_obj)
return result
except Exception:
if is_sampling:
_save_stats(file_path_obj)
raise
return async_wrapper
else:
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
is_sampling = _start_profiling_for_request(sampling_rate)
file_path_obj = PathLib("endpoint_profile.pstat")
set_last_profile_path(file_path_obj)
try:
result = func(*args, **kwargs)
if is_sampling:
_save_stats(file_path_obj)
return result
except Exception:
if is_sampling:
_save_stats(file_path_obj)
raise
return sync_wrapper
return decorator
@@ -832,7 +832,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
litellm_parent_otel_span: Union[Span, None] = (
_get_parent_otel_span_from_kwargs(kwargs)
)
user_api_key = kwargs["litellm_params"]["metadata"].get("user_api_key")
litellm_metadata = kwargs["litellm_params"]["metadata"]
user_api_key = litellm_metadata.get("user_api_key") if litellm_metadata else None
pipeline_operations: List[RedisPipelineIncrementOperation] = []
if user_api_key:
+17 -14
View File
@@ -169,12 +169,12 @@ def _get_dynamic_logging_metadata(
user_api_key_dict: UserAPIKeyAuth, proxy_config: ProxyConfig
) -> Optional[TeamCallbackMetadata]:
callback_settings_obj: Optional[TeamCallbackMetadata] = None
key_dynamic_logging_settings: Optional[dict] = (
KeyAndTeamLoggingSettings.get_key_dynamic_logging_settings(user_api_key_dict)
)
team_dynamic_logging_settings: Optional[dict] = (
KeyAndTeamLoggingSettings.get_team_dynamic_logging_settings(user_api_key_dict)
)
key_dynamic_logging_settings: Optional[
dict
] = KeyAndTeamLoggingSettings.get_key_dynamic_logging_settings(user_api_key_dict)
team_dynamic_logging_settings: Optional[
dict
] = KeyAndTeamLoggingSettings.get_team_dynamic_logging_settings(user_api_key_dict)
#########################################################################################
# Key-based callbacks
#########################################################################################
@@ -562,6 +562,8 @@ class LiteLLMProxyRequestSetup:
user_api_key_logged_metadata = StandardLoggingUserAPIKeyMetadata(
user_api_key_hash=user_api_key_dict.api_key, # just the hashed token
user_api_key_alias=user_api_key_dict.key_alias,
user_api_key_spend=user_api_key_dict.spend,
user_api_key_max_budget=user_api_key_dict.max_budget,
user_api_key_team_id=user_api_key_dict.team_id,
user_api_key_user_id=user_api_key_dict.user_id,
user_api_key_org_id=user_api_key_dict.org_id,
@@ -569,6 +571,7 @@ class LiteLLMProxyRequestSetup:
user_api_key_end_user_id=user_api_key_dict.end_user_id,
user_api_key_user_email=user_api_key_dict.user_email,
user_api_key_request_route=user_api_key_dict.request_route,
user_api_key_budget_reset_at=user_api_key_dict.budget_reset_at,
)
return user_api_key_logged_metadata
@@ -611,11 +614,11 @@ class LiteLLMProxyRequestSetup:
## KEY-LEVEL SPEND LOGS / TAGS
if "tags" in key_metadata and key_metadata["tags"] is not None:
data[_metadata_variable_name]["tags"] = (
LiteLLMProxyRequestSetup._merge_tags(
request_tags=data[_metadata_variable_name].get("tags"),
tags_to_add=key_metadata["tags"],
)
data[_metadata_variable_name][
"tags"
] = LiteLLMProxyRequestSetup._merge_tags(
request_tags=data[_metadata_variable_name].get("tags"),
tags_to_add=key_metadata["tags"],
)
if "spend_logs_metadata" in key_metadata and isinstance(
key_metadata["spend_logs_metadata"], dict
@@ -844,9 +847,9 @@ async def add_litellm_data_to_request( # noqa: PLR0915
data[_metadata_variable_name]["litellm_api_version"] = version
if general_settings is not None:
data[_metadata_variable_name]["global_max_parallel_requests"] = (
general_settings.get("global_max_parallel_requests", None)
)
data[_metadata_variable_name][
"global_max_parallel_requests"
] = general_settings.get("global_max_parallel_requests", None)
### KEY-LEVEL Controls
key_metadata = user_api_key_dict.metadata
@@ -474,6 +474,9 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
user_api_key_team_alias=user_api_key_dict.team_alias,
user_api_key_end_user_id=user_api_key_dict.end_user_id,
user_api_key_request_route=user_api_key_dict.request_route,
user_api_key_spend=user_api_key_dict.spend,
user_api_key_max_budget=user_api_key_dict.max_budget,
user_api_key_budget_reset_at=user_api_key_dict.budget_reset_at,
)
)
@@ -1003,7 +1006,7 @@ class InitPassThroughEndpointHelpers:
):
"""Add exact path route for pass-through endpoint"""
route_key = f"{endpoint_id}:exact:{path}"
# Check if this exact route is already registered
if route_key in _registered_pass_through_routes:
verbose_proxy_logger.debug(
@@ -1011,7 +1014,7 @@ class InitPassThroughEndpointHelpers:
path,
)
return
verbose_proxy_logger.debug(
"adding exact pass through endpoint: %s, dependencies: %s",
path,
@@ -1032,12 +1035,12 @@ class InitPassThroughEndpointHelpers:
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
dependencies=dependencies,
)
# Register the route to prevent duplicates
_registered_pass_through_routes[route_key] = {
"endpoint_id": endpoint_id,
"path": path,
"type": "exact"
"type": "exact",
}
@staticmethod
@@ -1055,7 +1058,7 @@ class InitPassThroughEndpointHelpers:
"""Add wildcard route for sub-paths"""
wildcard_path = f"{path}/{{subpath:path}}"
route_key = f"{endpoint_id}:subpath:{path}"
# Check if this subpath route is already registered
if route_key in _registered_pass_through_routes:
verbose_proxy_logger.debug(
@@ -1063,7 +1066,7 @@ class InitPassThroughEndpointHelpers:
wildcard_path,
)
return
verbose_proxy_logger.debug(
"adding wildcard pass through endpoint: %s, dependencies: %s",
wildcard_path,
@@ -1085,19 +1088,20 @@ class InitPassThroughEndpointHelpers:
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
dependencies=dependencies,
)
# Register the route to prevent duplicates
_registered_pass_through_routes[route_key] = {
"endpoint_id": endpoint_id,
"path": path,
"type": "subpath"
"type": "subpath",
}
@staticmethod
def remove_endpoint_routes(endpoint_id: str):
"""Remove all routes for a specific endpoint ID from the registry"""
keys_to_remove = [
key for key, value in _registered_pass_through_routes.items()
key
for key, value in _registered_pass_through_routes.items()
if value["endpoint_id"] == endpoint_id
]
for key in keys_to_remove:
@@ -1480,7 +1484,7 @@ async def delete_pass_through_endpoints(
pass_through_endpoint_data.pop(endpoint_index)
response_obj = found_endpoint
# Remove routes from registry
# Remove routes from registry
InitPassThroughEndpointHelpers.remove_endpoint_routes(endpoint_id)
## Update db
@@ -141,7 +141,7 @@ class ResponsesSessionHandler:
# Add Output messages for this Spend Log
############################################################
_response_output = spend_log.get("response", "{}")
if isinstance(_response_output, dict):
if isinstance(_response_output, dict) and _response_output and _response_output != {}:
# transform `ChatCompletion Response` to `ResponsesAPIResponse`
model_response = ModelResponse(**_response_output)
for choice in model_response.choices:
@@ -83,3 +83,10 @@ class DDLLMObsLatencyMetrics(TypedDict, total=False):
time_to_first_token_ms: float
litellm_overhead_time_ms: float
guardrail_overhead_time_ms: float
class DDLLMObsSpendMetrics(TypedDict, total=False):
response_cost: float
user_api_key_spend: float
user_api_key_max_budget: float
user_api_key_budget_reset_at: str
+18 -14
View File
@@ -3,14 +3,9 @@ from typing import Any, List, Literal, Optional, Union
from typing_extensions import (
TYPE_CHECKING,
Protocol,
Required,
Self,
TypedDict,
TypeGuard,
get_origin,
override,
runtime_checkable,
)
from .openai import ChatCompletionToolCallChunk
@@ -93,6 +88,12 @@ class BedrockConverseReasoningContentBlockDelta(TypedDict, total=False):
text: str
class GuardrailConverseContentBlock(TypedDict, total=False):
"""Content block for selective guardrail evaluation in Bedrock Converse API"""
text: str
class ContentBlock(TypedDict, total=False):
text: str
image: ImageBlock
@@ -102,6 +103,7 @@ class ContentBlock(TypedDict, total=False):
toolUse: ToolUseBlock
cachePoint: CachePointBlock
reasoningContent: BedrockConverseReasoningContentBlock
guardrailConverseContent: GuardrailConverseContentBlock
class MessageBlock(TypedDict):
@@ -581,30 +583,35 @@ class AmazonDeepSeekR1StreamingResponse(TypedDict):
class BedrockS3InputDataConfig(TypedDict):
"""S3 input data configuration for Bedrock batch jobs."""
s3Uri: str
class BedrockInputDataConfig(TypedDict):
"""Input data configuration for Bedrock batch jobs."""
s3InputDataConfig: BedrockS3InputDataConfig
class BedrockS3OutputDataConfig(TypedDict):
"""S3 output data configuration for Bedrock batch jobs."""
s3Uri: str
class BedrockOutputDataConfig(TypedDict):
"""Output data configuration for Bedrock batch jobs."""
s3OutputDataConfig: BedrockS3OutputDataConfig
class BedrockCreateBatchRequest(TypedDict, total=False):
"""
Request structure for creating a Bedrock batch inference job.
Reference: https://docs.aws.amazon.com/bedrock/latest/APIReference/API_CreateModelInvocationJob.html
"""
jobName: str
roleArn: str
modelId: str
@@ -616,21 +623,17 @@ class BedrockCreateBatchRequest(TypedDict, total=False):
BedrockBatchJobStatus = Literal[
"Submitted",
"InProgress",
"Completed",
"Failed",
"Stopping",
"Stopped"
"Submitted", "InProgress", "Completed", "Failed", "Stopping", "Stopped"
]
class BedrockCreateBatchResponse(TypedDict):
"""
Response structure from creating a Bedrock batch inference job.
Reference: https://docs.aws.amazon.com/bedrock/latest/APIReference/API_CreateModelInvocationJob.html
"""
jobArn: str
jobName: str
status: BedrockBatchJobStatus
@@ -639,9 +642,10 @@ class BedrockCreateBatchResponse(TypedDict):
class BedrockGetBatchResponse(TypedDict, total=False):
"""
Response structure from getting a Bedrock batch inference job.
Reference: https://docs.aws.amazon.com/bedrock/latest/APIReference/API_GetModelInvocationJob.html
"""
jobArn: str
jobName: str
modelId: str
+1
View File
@@ -723,6 +723,7 @@ ValidUserMessageContentTypes = [
"input_audio",
"audio_url",
"document",
"guarded_text",
"video_url",
"file",
] # used for validating user messages. Prevent users from accidentally sending anthropic messages.
+7 -1
View File
@@ -123,6 +123,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
max_output_tokens: Required[Optional[int]]
input_cost_per_token: Required[float]
cache_creation_input_token_cost: Optional[float]
cache_creation_input_token_cost_above_1hr: Optional[float]
cache_read_input_token_cost: Optional[float]
input_cost_per_character: Optional[float] # only for vertex ai models
input_cost_per_audio_token: Optional[float]
@@ -162,7 +163,9 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
SearchContextCostPerQuery
] # Cost for using web search tool
citation_cost_per_token: Optional[float] # Cost per citation token for Perplexity
tiered_pricing: Optional[List[Dict[str, Any]]] # Tiered pricing structure for models like Dashscope
tiered_pricing: Optional[
List[Dict[str, Any]]
] # Tiered pricing structure for models like Dashscope
litellm_provider: Required[str]
mode: Required[
Literal[
@@ -1807,6 +1810,9 @@ class AdapterCompletionStreamWrapper:
class StandardLoggingUserAPIKeyMetadata(TypedDict):
user_api_key_hash: Optional[str] # hash of the litellm virtual key used
user_api_key_alias: Optional[str]
user_api_key_spend: Optional[float]
user_api_key_max_budget: Optional[float]
user_api_key_budget_reset_at: Optional[str]
user_api_key_org_id: Optional[str]
user_api_key_team_id: Optional[str]
user_api_key_user_id: Optional[str]
File diff suppressed because it is too large Load Diff
@@ -75,6 +75,19 @@ async def test_async_file_and_batch():
)
print("CREATED BATCH RESPONSE=", create_batch_response)
# retrieve batch
retrieve_batch_response = await litellm.aretrieve_batch(
batch_id=create_batch_response.id,
custom_llm_provider="bedrock",
model="us.anthropic.claude-3-5-sonnet-20240620-v1:0",
)
print("RETRIEVED BATCH RESPONSE=", retrieve_batch_response)
# Validate the response
assert retrieve_batch_response.id == create_batch_response.id
assert retrieve_batch_response.object == "batch"
assert retrieve_batch_response.status in ["validating", "in_progress", "completed", "failed", "cancelled"]
@pytest.mark.asyncio()
async def test_mock_bedrock_file_url_mapping():
@@ -118,3 +131,65 @@ async def test_mock_bedrock_file_url_mapping():
expected_s3_uri, _ = bedrock_config._convert_https_url_to_s3_uri(captured_put_url)
assert file_obj.id == expected_s3_uri
@pytest.mark.asyncio()
async def test_bedrock_retrieve_batch():
"""
Test bedrock batch retrieval functionality, validating that input and output file IDs
are correctly extracted from the Bedrock response and included in the final transformed response.
"""
print("Testing bedrock batch retrieval")
# Mock bedrock batch response
mock_bedrock_response = {
"jobArn": "arn:aws:bedrock:us-west-2:123456789012:model-invocation-job/test-job-123",
"jobName": "test-job-123",
"modelId": "us.anthropic.claude-3-5-sonnet-20240620-v1:0",
"roleArn": "arn:aws:iam::123456789012:role/service-role/AmazonBedrockExecutionRoleForAgents_TEST",
"status": "InProgress",
"message": "Job is in progress",
"submitTime": "2024-01-01T12:00:00Z",
"lastModifiedTime": "2024-01-01T12:30:00Z",
"inputDataConfig": {
"s3InputDataConfig": {
"s3Uri": "s3://test-bucket/input/test-input.jsonl"
}
},
"outputDataConfig": {
"s3OutputDataConfig": {
"s3Uri": "s3://test-bucket/output/"
}
}
}
# Mock the HTTP response
mock_response = MagicMock()
mock_response.json.return_value = mock_bedrock_response
mock_response.status_code = 200
# Print the mock response to debug
print("MOCK RESPONSE DATA:", mock_bedrock_response)
with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get") as mock_get:
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
# Test retrieve batch
batch_response = await litellm.aretrieve_batch(
batch_id="arn:aws:bedrock:us-west-2:123456789012:model-invocation-job/test-job-123",
custom_llm_provider="bedrock",
model="us.anthropic.claude-3-5-sonnet-20240620-v1:0",
)
print("MOCKED BATCH RESPONSE=", batch_response)
# Validate the response
assert batch_response.id == "arn:aws:bedrock:us-west-2:123456789012:model-invocation-job/test-job-123"
assert batch_response.object == "batch"
assert batch_response.status == "in_progress" # Bedrock "InProgress" maps to "in_progress"
assert batch_response.endpoint == "/v1/chat/completions"
# Validate input and output file IDs in the final transformed response
assert batch_response.input_file_id == "s3://test-bucket/input/test-input.jsonl"
assert batch_response.output_file_id == "s3://test-bucket/output/"
@@ -1,203 +0,0 @@
import os
import sys
import traceback
from dotenv import load_dotenv
load_dotenv()
import io
import os
sys.path.insert(
0, os.path.abspath("../..")
) # Adds the parent directory to the system path
import json
import pytest
import litellm
from litellm import completion
from litellm.llms.cohere.completion.transformation import CohereTextConfig
def test_cohere_generate_api_completion():
try:
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from unittest.mock import patch, MagicMock
client = HTTPHandler()
litellm.set_verbose = True
messages = [
{"role": "system", "content": "You're a good bot"},
{
"role": "user",
"content": "Hey",
},
]
with patch.object(client, "post") as mock_client:
try:
completion(
model="cohere/command",
messages=messages,
max_tokens=10,
client=client,
)
except Exception as e:
print(e)
mock_client.assert_called_once()
print("mock_client.call_args.kwargs", mock_client.call_args.kwargs)
assert (
mock_client.call_args.kwargs["url"]
== "https://api.cohere.ai/v1/generate"
)
json_data = json.loads(mock_client.call_args.kwargs["data"])
assert json_data["model"] == "command"
assert json_data["prompt"] == "You're a good bot Hey"
assert json_data["max_tokens"] == 10
except Exception as e:
pytest.fail(f"Error occurred: {e}")
@pytest.mark.asyncio
async def test_cohere_generate_api_stream():
try:
litellm.set_verbose = True
messages = [
{"role": "system", "content": "You're a good bot"},
{
"role": "user",
"content": "Hey",
},
]
response = await litellm.acompletion(
model="cohere/command",
messages=messages,
max_tokens=10,
stream=True,
)
print("async cohere stream response", response)
async for chunk in response:
print(chunk)
except Exception as e:
pytest.fail(f"Error occurred: {e}")
def test_completion_cohere_stream_bad_key():
try:
api_key = "bad-key"
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{
"role": "user",
"content": "how does a court case get to the Supreme Court?",
},
]
completion(
model="command",
messages=messages,
stream=True,
max_tokens=50,
api_key=api_key,
)
except litellm.AuthenticationError as e:
pass
except Exception as e:
pytest.fail(f"Error occurred: {e}")
def test_cohere_transform_request():
try:
config = CohereTextConfig()
messages = [
{"role": "system", "content": "You're a helpful bot"},
{"role": "user", "content": "Hello"},
]
optional_params = {"max_tokens": 10, "temperature": 0.7}
headers = {}
transformed_request = config.transform_request(
model="command",
messages=messages,
optional_params=optional_params,
litellm_params={},
headers=headers,
)
print("transformed_request", json.dumps(transformed_request, indent=4))
assert transformed_request["model"] == "command"
assert transformed_request["prompt"] == "You're a helpful bot Hello"
assert transformed_request["max_tokens"] == 10
assert transformed_request["temperature"] == 0.7
except Exception as e:
pytest.fail(f"Error occurred: {e}")
def test_cohere_transform_request_with_tools():
try:
config = CohereTextConfig()
messages = [{"role": "user", "content": "What's the weather?"}]
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather information",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
},
},
}
]
optional_params = {"tools": tools}
transformed_request = config.transform_request(
model="command",
messages=messages,
optional_params=optional_params,
litellm_params={},
headers={},
)
print("transformed_request", json.dumps(transformed_request, indent=4))
assert "tools" in transformed_request
assert transformed_request["tools"] == {"tools": tools}
except Exception as e:
pytest.fail(f"Error occurred: {e}")
def test_cohere_map_openai_params():
try:
config = CohereTextConfig()
openai_params = {
"temperature": 0.7,
"max_tokens": 100,
"n": 2,
"top_p": 0.9,
"frequency_penalty": 0.5,
"presence_penalty": 0.5,
"stop": ["END"],
"stream": True,
}
mapped_params = config.map_openai_params(
non_default_params=openai_params,
optional_params={},
model="command",
drop_params=False,
)
assert mapped_params["temperature"] == 0.7
assert mapped_params["max_tokens"] == 100
assert mapped_params["num_generations"] == 2
assert mapped_params["p"] == 0.9
assert mapped_params["frequency_penalty"] == 0.5
assert mapped_params["presence_penalty"] == 0.5
assert mapped_params["stop_sequences"] == ["END"]
assert mapped_params["stream"] == True
except Exception as e:
pytest.fail(f"Error occurred: {e}")
@@ -300,6 +300,76 @@ async def test_anthropic_api_prompt_caching_basic():
)
@pytest.mark.asyncio()
async def test_anthropic_api_prompt_caching_basic_with_cache_creation():
from uuid import uuid4
random_id = uuid4()
litellm.set_verbose = True
response = await litellm.acompletion(
model="anthropic/claude-3-5-sonnet-20240620",
messages=[
# System Message
{
"role": "system",
"content": [
{
"type": "text",
"text": "Here is the full text of a complex legal agreement {}".format(
random_id
)
* 400,
"cache_control": {"type": "ephemeral"},
}
],
},
# marked for caching with the cache_control parameter, so that this checkpoint can read from the previous cache.
{
"role": "user",
"content": [
{
"type": "text",
"text": "What are the key terms and conditions in this agreement?",
"cache_control": {"type": "ephemeral"},
}
],
},
{
"role": "assistant",
"content": "Certainly! the key terms and conditions are the following: the contract is 1 year long for $10/mo",
},
# The final turn is marked with cache-control, for continuing in followups.
{
"role": "user",
"content": [
{
"type": "text",
"text": "What are the key terms and conditions in this agreement?",
"cache_control": {"type": "ephemeral"},
}
],
},
],
temperature=0.2,
max_tokens=10,
extra_headers={
"anthropic-version": "2023-06-01",
"anthropic-beta": "prompt-caching-2024-07-31",
},
)
print("response=", response)
assert "cache_read_input_tokens" in response.usage
assert "cache_creation_input_tokens" in response.usage
# Assert either a cache entry was created or cache was read - changes depending on the anthropic api ttl
assert (response.usage.cache_read_input_tokens > 0) or (
response.usage.cache_creation_input_tokens > 0
)
@pytest.mark.asyncio()
async def test_anthropic_api_prompt_caching_with_content_str():
system_message = [
@@ -145,9 +145,6 @@ def test_cancel_response():
# verify cancel response structure
assert hasattr(cancel_response, "id")
# Note: Cancel response returns ResponsesAPIResponse, not DeleteResponseResult
# The actual response structure depends on the provider implementation
assert isinstance(cancel_response, ResponsesAPIResponse)
except Exception as e:
if "Cannot cancel a completed response" in str(e):
pass
@@ -179,9 +176,6 @@ def test_cancel_streaming_response():
cancel_response = client.responses.cancel(response_id)
print("CANCEL streaming response=", cancel_response)
assert hasattr(cancel_response, "id")
# Note: Cancel response returns ResponsesAPIResponse, not DeleteResponseResult
# The actual response structure depends on the provider implementation
assert isinstance(cancel_response, ResponsesAPIResponse)
except Exception as e:
if "Cannot cancel a completed response" in str(e):
pass
+1 -1
View File
@@ -48,7 +48,7 @@ async def test_get_end_user_object(customer_spend, customer_budget):
)
_cache = DualCache()
_key = "end_user_id:{}".format(end_user_id)
_cache.set_cache(key=_key, value=end_user_obj)
_cache.set_cache(key=_key, value=end_user_obj.model_dump())
try:
await get_end_user_object(
end_user_id=end_user_id,
@@ -345,6 +345,130 @@ async def test_generationconfig_to_config_mapping(sample_request_payload):
print("✅ generationConfig to config mapping test passed")
@pytest.mark.asyncio
async def test_gemini_custom_api_base_proxy_integration():
"""
Test that Gemini models work correctly with custom API base URLs in proxy context.
This test verifies that when a custom api_base is provided for Gemini models,
the URL is correctly constructed using the _check_custom_proxy method.
"""
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
# Test the _check_custom_proxy method directly
vertex_base = VertexBase()
# Test case 1: Custom API base for Gemini
custom_api_base = "https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta"
model = "gemini-2.5-flash-lite"
endpoint = "generateContent"
auth_header, result_url = vertex_base._check_custom_proxy(
api_base=custom_api_base,
custom_llm_provider="gemini",
gemini_api_key="test-api-key",
endpoint=endpoint,
stream=False,
auth_header=None,
url=f"https://generativelanguage.googleapis.com/v1beta/models/{model}:{endpoint}",
model=model,
)
# Verify the URL is correctly constructed
expected_url = f"{custom_api_base}/models/{model}:{endpoint}"
assert result_url == expected_url, f"Expected {expected_url}, got {result_url}"
# Verify the auth header is set to the API key
assert auth_header == "test-api-key", f"Expected 'test-api-key', got {auth_header}"
print(f"✅ Custom API base URL construction test passed: {result_url}")
# Test case 2: Custom API base with streaming
auth_header_streaming, result_url_streaming = vertex_base._check_custom_proxy(
api_base=custom_api_base,
custom_llm_provider="gemini",
gemini_api_key="test-api-key",
endpoint=endpoint,
stream=True,
auth_header=None,
url=f"https://generativelanguage.googleapis.com/v1beta/models/{model}:{endpoint}",
model=model,
)
# Verify streaming URL has ?alt=sse parameter
expected_streaming_url = f"{custom_api_base}/models/{model}:{endpoint}?alt=sse"
assert result_url_streaming == expected_streaming_url, f"Expected {expected_streaming_url}, got {result_url_streaming}"
print(f"✅ Custom API base streaming URL test passed: {result_url_streaming}")
# Test case 3: Error handling - missing API key
with pytest.raises(ValueError, match="Missing gemini_api_key"):
vertex_base._check_custom_proxy(
api_base=custom_api_base,
custom_llm_provider="gemini",
gemini_api_key=None, # Missing API key
endpoint=endpoint,
stream=False,
auth_header=None,
url=f"https://generativelanguage.googleapis.com/v1beta/models/{model}:{endpoint}",
model=model,
)
print("✅ Missing API key error handling test passed")
@pytest.mark.asyncio
async def test_gemini_proxy_config_with_custom_api_base():
"""
Test that proxy configuration correctly handles custom API base for Gemini models.
This test simulates the proxy configuration scenario where a model is configured
with a custom api_base in the config.yaml file.
"""
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
# Simulate proxy configuration
model_config = {
"model_name": "byok-gemini/*",
"litellm_params": {
"model": "gemini/*",
"api_key": "dummy-key-for-testing",
"api_base": "https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta"
}
}
vertex_base = VertexBase()
# Test with different Gemini models
test_models = [
"gemini-2.5-flash-lite",
"gemini-2.5-pro",
"gemini-1.5-flash",
"gemini-1.5-pro"
]
for model in test_models:
# Test generateContent endpoint
auth_header, result_url = vertex_base._check_custom_proxy(
api_base=model_config["litellm_params"]["api_base"],
custom_llm_provider="gemini",
gemini_api_key=model_config["litellm_params"]["api_key"],
endpoint="generateContent",
stream=False,
auth_header=None,
url=f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent",
model=model,
)
expected_url = f"{model_config['litellm_params']['api_base']}/models/{model}:generateContent"
assert result_url == expected_url, f"Expected {expected_url}, got {result_url} for model {model}"
assert auth_header == model_config["litellm_params"]["api_key"], f"Expected API key, got {auth_header} for model {model}"
print(f"✅ Model {model} configuration test passed: {result_url}")
print("✅ Proxy configuration with custom API base test passed")
if __name__ == "__main__":
# Run the tests
pytest.main([__file__, "-v"])
@@ -1,7 +1,7 @@
import asyncio
import os
import sys
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Optional
from unittest.mock import Mock, patch, MagicMock
@@ -661,17 +661,24 @@ def create_standard_logging_payload_with_tool_calls() -> StandardLoggingPayload:
"""Create a StandardLoggingPayload object with tool calls for testing"""
return {
"id": "test-request-id-tool-calls",
"trace_id": "test-trace-id-tool-calls",
"call_type": "completion",
"stream": None,
"response_cost": 0.05,
"response_cost_failure_debug_info": None,
"status": "success",
"custom_llm_provider": "openai",
"total_tokens": 50,
"prompt_tokens": 20,
"completion_tokens": 30,
"startTime": 1234567890.0,
"endTime": 1234567891.0,
"completionStartTime": 1234567890.5,
"model_map_information": {"model_map_key": "gpt-4", "model_map_value": None},
"response_time": 1.0,
"model_map_information": {
"model_map_key": "gpt-4",
"model_map_value": None
},
"model": "gpt-4",
"model_id": "model-123",
"model_group": "openai-gpt",
@@ -746,6 +753,7 @@ def create_standard_logging_payload_with_tool_calls() -> StandardLoggingPayload:
]
},
"error_str": None,
"error_information": None,
"model_parameters": {"temperature": 0.7},
"hidden_params": {
"model_id": "model-123",
@@ -758,14 +766,9 @@ def create_standard_logging_payload_with_tool_calls() -> StandardLoggingPayload:
"litellm_model_name": None,
"usage_object": None,
},
"stream": None,
"response_time": 1.0,
"error_information": None,
"guardrail_information": None,
"standard_built_in_tools_params": None,
"trace_id": "test-trace-id-tool-calls",
"custom_llm_provider": "openai",
}
} # type: ignore
class TestDataDogLLMObsLoggerToolCalls:
@@ -897,3 +900,204 @@ class TestDataDogLLMObsLoggerToolCalls:
assert len(output_tool_calls) == 1
output_function_info = output_tool_calls[0].get("function", {})
assert output_function_info.get("name") == "format_response"
def create_standard_logging_payload_with_spend_metrics() -> StandardLoggingPayload:
"""Create a StandardLoggingPayload object with spend metrics for testing"""
from datetime import datetime, timezone
# Create a budget reset time 10 days from now (using "10d" format)
budget_reset_at = datetime.now(timezone.utc) + timedelta(days=10)
return {
"id": "test-request-id-spend",
"trace_id": "test-trace-id-spend",
"call_type": "completion",
"stream": None,
"response_cost": 0.15,
"response_cost_failure_debug_info": None,
"status": "success",
"custom_llm_provider": "openai",
"total_tokens": 30,
"prompt_tokens": 10,
"completion_tokens": 20,
"startTime": 1234567890.0,
"endTime": 1234567891.0,
"completionStartTime": 1234567890.5,
"response_time": 1.0,
"model_map_information": {
"model_map_key": "gpt-4",
"model_map_value": None
},
"model": "gpt-4",
"model_id": "model-123",
"model_group": "openai-gpt",
"api_base": "https://api.openai.com",
"metadata": {
"user_api_key_hash": "test_hash",
"user_api_key_org_id": None,
"user_api_key_alias": "test_alias",
"user_api_key_team_id": "test_team",
"user_api_key_user_id": "test_user",
"user_api_key_team_alias": "test_team_alias",
"user_api_key_user_email": None,
"user_api_key_end_user_id": None,
"user_api_key_request_route": None,
"user_api_key_spend": 0.67,
"user_api_key_max_budget": 10.0, # $10 max budget
"user_api_key_budget_reset_at": budget_reset_at.isoformat(), # ISO format: 2025-09-26T...
"spend_logs_metadata": None,
"requester_ip_address": "127.0.0.1",
"requester_metadata": None,
"requester_custom_headers": None,
"prompt_management_metadata": None,
"mcp_tool_call_metadata": None,
"vector_store_request_metadata": None,
"applied_guardrails": None,
"usage_object": None,
"cold_storage_object_key": None,
},
"cache_hit": False,
"cache_key": None,
"saved_cache_cost": 0.0,
"request_tags": [],
"end_user": None,
"requester_ip_address": "127.0.0.1",
"messages": [{"role": "user", "content": "Hello, world!"}],
"response": {"choices": [{"message": {"content": "Hi there!"}}]},
"error_str": None,
"error_information": None,
"model_parameters": {"stream": False},
"hidden_params": {
"model_id": "model-123",
"cache_key": None,
"api_base": "https://api.openai.com",
"response_cost": "0.15",
"litellm_overhead_time_ms": None,
"additional_headers": None,
"batch_models": None,
"litellm_model_name": None,
"usage_object": None,
},
"guardrail_information": None,
"standard_built_in_tools_params": None,
} # type: ignore
@pytest.mark.asyncio
async def test_datadog_llm_obs_spend_metrics(mock_env_vars):
"""Test that budget metrics are properly extracted and logged"""
datadog_llm_obs_logger = DataDogLLMObsLogger()
# Create a standard logging payload with spend metrics
payload = create_standard_logging_payload_with_spend_metrics()
# Show the budget reset time in ISO format
budget_reset_iso = payload["metadata"]["user_api_key_budget_reset_at"]
print(f"Budget reset time (ISO format): {budget_reset_iso}")
from datetime import datetime, timezone
print(f"Current time: {datetime.now(timezone.utc).isoformat()}")
# Test the _get_spend_metrics method
spend_metrics = datadog_llm_obs_logger._get_spend_metrics(payload)
# Verify budget metrics are present
assert "user_api_key_max_budget" in spend_metrics
assert spend_metrics["user_api_key_max_budget"] == 10.0
assert "user_api_key_budget_reset_at" in spend_metrics
# The budget reset should be a datetime string in ISO format
budget_reset = spend_metrics["user_api_key_budget_reset_at"]
assert isinstance(budget_reset, str)
print(f"Budget reset datetime: {budget_reset}")
# Should be close to 10 days from now
budget_reset_dt = datetime.fromisoformat(budget_reset.replace('Z', '+00:00'))
now = datetime.now(timezone.utc)
time_diff = (budget_reset_dt - now).total_seconds() / 86400 # days
assert 9.5 <= time_diff <= 10.5 # Should be close to 10 days
print(f"Spend metrics: {spend_metrics}")
@pytest.mark.asyncio
async def test_datadog_llm_obs_spend_metrics_no_budget(mock_env_vars):
"""Test that spend metrics work when no budget is set"""
datadog_llm_obs_logger = DataDogLLMObsLogger()
# Create a standard logging payload without budget metadata
payload = create_standard_logging_payload_with_spend_metrics()
# Remove budget-related metadata to test no-budget scenario
payload["metadata"].pop("user_api_key_max_budget", None)
payload["metadata"].pop("user_api_key_budget_reset_at", None)
# Test the _get_spend_metrics method
spend_metrics = datadog_llm_obs_logger._get_spend_metrics(payload)
# Verify only response cost is present
assert "response_cost" in spend_metrics
assert spend_metrics["response_cost"] == 0.15
# Budget metrics should not be present
assert "user_api_key_max_budget" not in spend_metrics
assert "user_api_key_budget_reset_at" not in spend_metrics
print(f"Spend metrics (no budget): {spend_metrics}")
@pytest.mark.asyncio
async def test_spend_metrics_in_datadog_payload(mock_env_vars):
"""Test that spend metrics are correctly included in DataDog LLM Observability payloads"""
from datetime import datetime
datadog_llm_obs_logger = DataDogLLMObsLogger()
standard_payload = create_standard_logging_payload_with_spend_metrics()
kwargs = {
"standard_logging_object": standard_payload,
"litellm_params": {"metadata": {}},
}
start_time = datetime.now()
end_time = datetime.now()
payload = datadog_llm_obs_logger.create_llm_obs_payload(kwargs, start_time, end_time)
# Verify basic payload structure
assert payload.get("name") == "litellm_llm_call"
assert payload.get("status") == "ok"
# Verify spend metrics are included in metadata
meta = payload.get("meta", {})
assert meta is not None, "Meta section should exist in payload"
metadata = meta.get("metadata", {})
assert metadata is not None, "Metadata section should exist in meta"
spend_metrics = metadata.get("spend_metrics", {})
assert spend_metrics, "Spend metrics should exist in metadata"
# Check that all metrics are present
assert "response_cost" in spend_metrics
assert "user_api_key_spend" in spend_metrics
assert "user_api_key_max_budget" in spend_metrics
assert "user_api_key_budget_reset_at" in spend_metrics
# Verify the values are correct
assert spend_metrics["response_cost"] == 0.15 # response_cost
assert spend_metrics["user_api_key_spend"] == 0.67 # lol
assert spend_metrics["user_api_key_max_budget"] == 10.0 # max budget
# Verify budget reset is a datetime string in ISO format
budget_reset = spend_metrics["user_api_key_budget_reset_at"]
assert isinstance(budget_reset, str)
print(f"Budget reset in payload: {budget_reset}") # In StandardLoggingUserAPIKeyMetadata
user_api_key_budget_reset_at: Optional[str] = None
# In DDLLMObsSpendMetrics
user_api_key_budget_reset_at: str
# Should be close to 10 days from now
from datetime import datetime, timezone
budget_reset_dt = datetime.fromisoformat(budget_reset.replace('Z', '+00:00'))
now = datetime.now(timezone.utc)
time_diff = (budget_reset_dt - now).total_seconds() / 86400 # days
assert 9.5 <= time_diff <= 10.5 # Should be close to 10 days
@@ -6,6 +6,8 @@ import pytest
from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger
from litellm.types.integrations.langfuse_otel import LangfuseOtelConfig
from litellm.types.llms.openai import ResponsesAPIResponse
from datetime import datetime
class TestLangfuseOtelIntegration:
@@ -241,6 +243,134 @@ class TestLangfuseOtelIntegration:
_ = LangfuseOtelLogger.get_langfuse_otel_config()
assert os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") == "https://otel-host.com/api/public/otel"
class TestLangfuseOtelResponsesAPI:
"""Test suite for Langfuse OTEL integration with ResponsesAPI"""
def test_langfuse_otel_with_responses_api(self):
"""Test that Langfuse OTEL logger works with ResponsesAPI responses and logs metadata."""
# Create a mock ResponsesAPIResponse
mock_response = ResponsesAPIResponse(
id="response-123",
created_at=1234567890,
output=[
{
"type": "message",
"content": [{"type": "text", "text": "Hello from responses API"}]
}
],
parallel_tool_calls=False,
tool_choice="auto",
tools=[],
top_p=1.0
)
# Create kwargs with metadata that should be logged
test_metadata = {
"user_id": "test123",
"session_id": "abc456",
"custom_field": "test_value",
"generation_name": "responses_test_generation",
"trace_name": "responses_api_trace"
}
kwargs = {
"call_type": "responses",
"messages": [{"role": "user", "content": "Hello"}],
"model": "gpt-4o",
"optional_params": {},
"litellm_params": {"metadata": test_metadata}
}
mock_span = MagicMock()
with patch('litellm.integrations.arize._utils.set_attributes') as mock_set_attributes:
with patch('litellm.integrations.arize._utils.safe_set_attribute') as mock_safe_set_attribute:
logger = LangfuseOtelLogger()
logger.set_langfuse_otel_attributes(mock_span, kwargs, mock_response)
# Verify that set_attributes was called for general attributes
mock_set_attributes.assert_called_once_with(mock_span, kwargs, mock_response)
# Verify that Langfuse-specific attributes were set
mock_safe_set_attribute.assert_any_call(
mock_span, "langfuse.generation.name", "responses_test_generation"
)
mock_safe_set_attribute.assert_any_call(
mock_span, "langfuse.trace.name", "responses_api_trace"
)
def test_responses_api_metadata_extraction(self):
"""Test that metadata is correctly extracted from ResponsesAPI kwargs."""
# Clean up any existing module mocks
import sys
if "litellm.integrations.langfuse.langfuse" in sys.modules:
original_module = sys.modules["litellm.integrations.langfuse.langfuse"]
test_metadata = {
"user_id": "responses_user_123",
"session_id": "responses_session_456",
"custom_metadata": {"key": "value"},
"generation_name": "responses_generation",
"trace_id": "custom_trace_id"
}
kwargs = {
"call_type": "responses",
"model": "gpt-4o",
"litellm_params": {"metadata": test_metadata}
}
extracted_metadata = LangfuseOtelLogger._extract_langfuse_metadata(kwargs)
# Verify all expected metadata was extracted (may have additional fields from header enrichment)
for key, value in test_metadata.items():
assert extracted_metadata[key] == value
assert extracted_metadata["user_id"] == "responses_user_123"
assert extracted_metadata["generation_name"] == "responses_generation"
assert extracted_metadata["trace_id"] == "custom_trace_id"
def test_responses_api_langfuse_specific_attributes(self):
"""Test that ResponsesAPI metadata maps correctly to Langfuse OTEL attributes."""
metadata = {
"generation_name": "responses_gen",
"generation_id": "resp_gen_123",
"trace_name": "responses_trace",
"trace_user_id": "resp_user_456",
"session_id": "resp_session_789",
"tags": ["responses", "api", "test"],
"trace_metadata": {"source": "responses_api", "version": "1.0"}
}
kwargs = {
"call_type": "responses",
"litellm_params": {"metadata": metadata}
}
mock_span = MagicMock()
with patch('litellm.integrations.arize._utils.safe_set_attribute') as mock_safe_set_attribute:
LangfuseOtelLogger._set_langfuse_specific_attributes(mock_span, kwargs)
# Verify specific attributes were set
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
expected_calls = [
(mock_span, LangfuseSpanAttributes.GENERATION_NAME.value, "responses_gen"),
(mock_span, LangfuseSpanAttributes.GENERATION_ID.value, "resp_gen_123"),
(mock_span, LangfuseSpanAttributes.TRACE_NAME.value, "responses_trace"),
(mock_span, LangfuseSpanAttributes.TRACE_USER_ID.value, "resp_user_456"),
(mock_span, LangfuseSpanAttributes.SESSION_ID.value, "resp_session_789"),
(mock_span, LangfuseSpanAttributes.TAGS.value, json.dumps(["responses", "api", "test"])),
(mock_span, LangfuseSpanAttributes.TRACE_METADATA.value,
json.dumps({"source": "responses_api", "version": "1.0"}))
]
for expected_call in expected_calls:
mock_safe_set_attribute.assert_any_call(*expected_call)
@@ -9,7 +9,11 @@ sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path
from litellm.litellm_core_utils.core_helpers import get_litellm_metadata_from_kwargs, safe_divide
from litellm.litellm_core_utils.core_helpers import (
get_litellm_metadata_from_kwargs,
safe_divide,
safe_deep_copy
)
def test_get_litellm_metadata_from_kwargs():
@@ -127,3 +131,43 @@ def test_safe_divide_weight_scenario():
expected_zero = [0, 0, 0]
assert normalized_zero_weights == expected_zero, f"Expected {expected_zero}, got {normalized_zero_weights}"
def test_safe_deep_copy_with_non_pickleables_and_span():
"""
Verify safe_deep_copy:
- does not crash when non-pickleables are present,
- preserves structure/keys,
- deep-copies JSON-y payloads (e.g., messages),
- keeps non-pickleables by reference,
- redacts OTEL span in the copy and restores it in the original.
"""
import threading
rlock = threading.RLock()
data = {
"metadata": {"litellm_parent_otel_span": rlock, "x": 1},
"messages": [{"role": "user", "content": "hi"}],
"optional_params": {"handle": rlock},
"ok": True,
}
copied = safe_deep_copy(data)
# Structure preserved
assert set(copied.keys()) == set(data.keys())
# Messages are deep-copied (new object, same content)
assert copied["messages"] is not data["messages"]
assert copied["messages"][0] == data["messages"][0]
# Non-pickleable subtree kept by reference (no crash)
assert copied["optional_params"] is data["optional_params"]
assert copied["optional_params"]["handle"] is rlock
# OTEL span: redacted in the copy, restored in original
assert copied["metadata"]["litellm_parent_otel_span"] == "placeholder"
assert data["metadata"]["litellm_parent_otel_span"] is rlock
# Other simple fields unchanged
assert copied["ok"] is True
assert copied["metadata"]["x"] == 1
@@ -438,6 +438,97 @@ async def test_e2e_generate_cold_storage_object_key_successful():
assert isinstance(result, str)
@pytest.mark.asyncio
async def test_e2e_generate_cold_storage_object_key_with_custom_logger_s3_path():
"""
Test that _generate_cold_storage_object_key uses s3_path from custom logger instance.
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
# Create test data
start_time = datetime(2025, 1, 15, 10, 30, 45, 123456, timezone.utc)
response_id = "chatcmpl-test-12345"
# Create mock custom logger with s3_path
mock_custom_logger = MagicMock()
mock_custom_logger.s3_path = "storage"
with patch("litellm.configured_cold_storage_logger", "s3_v2"), \
patch("litellm.logging_callback_manager.get_active_custom_logger_for_callback_name") as mock_get_logger, \
patch("litellm.integrations.s3.get_s3_object_key") as mock_get_s3_key:
# Setup mocks
mock_get_logger.return_value = mock_custom_logger
mock_get_s3_key.return_value = "storage/2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json"
# Call the function
result = StandardLoggingPayloadSetup._generate_cold_storage_object_key(
start_time=start_time,
response_id=response_id
)
# Verify logger was queried correctly
mock_get_logger.assert_called_once_with("s3_v2")
# Verify the S3 function was called with the custom logger's s3_path
mock_get_s3_key.assert_called_once_with(
s3_path="storage", # Should use custom logger's s3_path
team_alias_prefix="",
start_time=start_time,
s3_file_name="time-10-30-45-123456_chatcmpl-test-12345"
)
# Verify the result
assert result == "storage/2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json"
@pytest.mark.asyncio
async def test_e2e_generate_cold_storage_object_key_with_logger_no_s3_path():
"""
Test that _generate_cold_storage_object_key falls back to empty s3_path when logger has no s3_path.
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
# Create test data
start_time = datetime(2025, 1, 15, 10, 30, 45, 123456, timezone.utc)
response_id = "chatcmpl-test-12345"
# Create mock custom logger without s3_path
mock_custom_logger = MagicMock()
mock_custom_logger.s3_path = None # or could be missing attribute
with patch("litellm.configured_cold_storage_logger", "s3_v2"), \
patch("litellm.logging_callback_manager.get_active_custom_logger_for_callback_name") as mock_get_logger, \
patch("litellm.integrations.s3.get_s3_object_key") as mock_get_s3_key:
# Setup mocks
mock_get_logger.return_value = mock_custom_logger
mock_get_s3_key.return_value = "2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json"
# Call the function
result = StandardLoggingPayloadSetup._generate_cold_storage_object_key(
start_time=start_time,
response_id=response_id
)
# Verify the S3 function was called with empty s3_path (fallback)
mock_get_s3_key.assert_called_once_with(
s3_path="", # Should fall back to empty string
team_alias_prefix="",
start_time=start_time,
s3_file_name="time-10-30-45-123456_chatcmpl-test-12345"
)
# Verify the result
assert result == "2025-01-15/time-10-30-45-123456_chatcmpl-test-12345.json"
@pytest.mark.asyncio
async def test_e2e_generate_cold_storage_object_key_not_configured():
"""
@@ -1589,4 +1589,282 @@ async def test_no_cache_control_no_cache_point():
# Tool message should only have tool result, no cachePoint
tool_content = result[2]["content"]
assert len(tool_content) == 1
assert "toolResult" in tool_content[0]
assert "toolResult" in tool_content[0]
# ============================================================================
# Guarded Text Feature Tests
# ============================================================================
def test_guarded_text_wraps_in_guardrail_converse_content():
"""Test that guarded_text content type gets wrapped in guardrailConverseContent blocks."""
from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_converse_messages_pt
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Regular text content"},
{"type": "guarded_text", "text": "This should be guarded"},
{"type": "text", "text": "More regular text"}
]
}
]
result = _bedrock_converse_messages_pt(
messages=messages,
model="us.amazon.nova-pro-v1:0",
llm_provider="bedrock_converse"
)
# Should have 1 message
assert len(result) == 1
assert result[0]["role"] == "user"
# Should have 3 content blocks
content = result[0]["content"]
assert len(content) == 3
# First and third should be regular text
assert "text" in content[0]
assert content[0]["text"] == "Regular text content"
assert "text" in content[2]
assert content[2]["text"] == "More regular text"
# Second should be guardrailConverseContent
assert "guardrailConverseContent" in content[1]
assert content[1]["guardrailConverseContent"]["text"] == "This should be guarded"
def test_guarded_text_with_system_messages():
"""Test guarded_text with system messages using the full transformation."""
config = AmazonConverseConfig()
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{
"role": "user",
"content": [
{"type": "text", "text": "What is the main topic of this legal document?"},
{"type": "guarded_text", "text": "This is a set of very long instructions that you will follow. Here is a legal document that you will use to answer the user's question."}
]
}
]
optional_params = {
"guardrailConfig": {
"guardrailIdentifier": "gr-abc123",
"guardrailVersion": "DRAFT"
}
}
result = config._transform_request(
model="us.amazon.nova-pro-v1:0",
messages=messages,
optional_params=optional_params,
litellm_params={},
headers={}
)
# Should have system content blocks
assert "system" in result
assert len(result["system"]) == 1
assert result["system"][0]["text"] == "You are a helpful assistant."
# Should have 1 message (system messages are removed)
assert "messages" in result
assert len(result["messages"]) == 1
# User message should have both regular text and guarded text
user_message = result["messages"][0]
assert user_message["role"] == "user"
content = user_message["content"]
assert len(content) == 2
# First should be regular text
assert "text" in content[0]
assert content[0]["text"] == "What is the main topic of this legal document?"
# Second should be guardrailConverseContent
assert "guardrailConverseContent" in content[1]
assert content[1]["guardrailConverseContent"]["text"] == "This is a set of very long instructions that you will follow. Here is a legal document that you will use to answer the user's question."
def test_guarded_text_with_mixed_content_types():
"""Test guarded_text with mixed content types including images."""
from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_converse_messages_pt
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Look at this image"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,test"}},
{"type": "guarded_text", "text": "This sensitive content should be guarded"}
]
}
]
result = _bedrock_converse_messages_pt(
messages=messages,
model="us.amazon.nova-pro-v1:0",
llm_provider="bedrock_converse"
)
# Should have 1 message
assert len(result) == 1
assert result[0]["role"] == "user"
# Should have 3 content blocks
content = result[0]["content"]
assert len(content) == 3
# First should be regular text
assert "text" in content[0]
assert content[0]["text"] == "Look at this image"
# Second should be image
assert "image" in content[1]
# Third should be guardrailConverseContent
assert "guardrailConverseContent" in content[2]
assert content[2]["guardrailConverseContent"]["text"] == "This sensitive content should be guarded"
@pytest.mark.asyncio
async def test_async_guarded_text():
"""Test async version of guarded_text processing."""
from litellm.litellm_core_utils.prompt_templates.factory import BedrockConverseMessagesProcessor
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Hello"},
{"type": "guarded_text", "text": "This should be guarded"}
]
}
]
result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async(
messages=messages,
model="us.amazon.nova-pro-v1:0",
llm_provider="bedrock_converse"
)
# Should have 1 message
assert len(result) == 1
assert result[0]["role"] == "user"
# Should have 2 content blocks
content = result[0]["content"]
assert len(content) == 2
# First should be regular text
assert "text" in content[0]
assert content[0]["text"] == "Hello"
# Second should be guardrailConverseContent
assert "guardrailConverseContent" in content[1]
assert content[1]["guardrailConverseContent"]["text"] == "This should be guarded"
def test_guarded_text_with_tool_calls():
"""Test guarded_text with tool calls in the conversation."""
from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_converse_messages_pt
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What's the weather?"},
{"type": "guarded_text", "text": "Please be careful with sensitive information"}
]
},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {"name": "get_weather", "arguments": "{}"}
}
]
},
{
"role": "tool",
"tool_call_id": "call_123",
"content": "It's sunny and 25°C"
}
]
result = _bedrock_converse_messages_pt(
messages=messages,
model="us.amazon.nova-pro-v1:0",
llm_provider="bedrock_converse"
)
# Should have 3 messages
assert len(result) == 3
# First message (user) should have both text and guarded_text
user_message = result[0]
assert user_message["role"] == "user"
content = user_message["content"]
assert len(content) == 2
# First should be regular text
assert "text" in content[0]
assert content[0]["text"] == "What's the weather?"
# Second should be guardrailConverseContent
assert "guardrailConverseContent" in content[1]
assert content[1]["guardrailConverseContent"]["text"] == "Please be careful with sensitive information"
# Other messages should not have guardrailConverseContent
for i in range(1, 3):
content = result[i]["content"]
for block in content:
assert "guardrailConverseContent" not in block
def test_guarded_text_guardrail_config_preserved():
"""Test that guardrailConfig is preserved when using guarded_text."""
config = AmazonConverseConfig()
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Hello"},
{"type": "guarded_text", "text": "This should be guarded"}
]
}
]
optional_params = {
"guardrailConfig": {
"guardrailIdentifier": "gr-abc123",
"guardrailVersion": "DRAFT"
}
}
result = config._transform_request(
model="us.amazon.nova-pro-v1:0",
messages=messages,
optional_params=optional_params,
litellm_params={},
headers={}
)
# GuardrailConfig should be present at top level
assert "guardrailConfig" in result
assert result["guardrailConfig"]["guardrailIdentifier"] == "gr-abc123"
# GuardrailConfig should also be in inferenceConfig
assert "inferenceConfig" in result
assert "guardrailConfig" in result["inferenceConfig"]
assert result["inferenceConfig"]["guardrailConfig"]["guardrailIdentifier"] == "gr-abc123"
@@ -704,3 +704,177 @@ class TestVertexBase:
vertex_base.get_api_base(api_base=api_base, vertex_location=vertex_location)
== expected
), f"Expected {expected} with api_base {api_base} and vertex_location {vertex_location}"
@pytest.mark.parametrize(
"api_base, custom_llm_provider, gemini_api_key, endpoint, stream, auth_header, url, model, expected_auth_header, expected_url",
[
# Test case 1: Gemini with custom API base
(
"https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta",
"gemini",
"test-api-key",
"generateContent",
False,
None,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent",
"gemini-2.5-flash-lite",
"test-api-key",
"https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent"
),
# Test case 2: Gemini with custom API base and streaming
(
"https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta",
"gemini",
"test-api-key",
"generateContent",
True,
None,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent",
"gemini-2.5-flash-lite",
"test-api-key",
"https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?alt=sse"
),
# Test case 3: Non-Gemini provider with custom API base
(
"https://custom-vertex-api.com",
"vertex_ai",
None,
"generateContent",
False,
"Bearer token123",
"https://aiplatform.googleapis.com/v1/projects/test-project/locations/us-central1/publishers/google/models/gemini-pro:generateContent",
"gemini-pro",
"Bearer token123",
"https://custom-vertex-api.com:generateContent"
),
# Test case 4: No API base provided (should return original values)
(
None,
"gemini",
"test-api-key",
"generateContent",
False,
"Bearer token123",
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent",
"gemini-2.5-flash-lite",
"Bearer token123",
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent"
),
# Test case 5: Gemini without API key (should raise ValueError)
(
"https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta",
"gemini",
None,
"generateContent",
False,
None,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent",
"gemini-2.5-flash-lite",
None, # This should raise an exception
None
),
],
)
def test_check_custom_proxy(
self,
api_base,
custom_llm_provider,
gemini_api_key,
endpoint,
stream,
auth_header,
url,
model,
expected_auth_header,
expected_url
):
"""Test the _check_custom_proxy method for handling custom API base URLs"""
vertex_base = VertexBase()
if custom_llm_provider == "gemini" and api_base and gemini_api_key is None:
# Test case 5: Should raise ValueError for Gemini without API key
with pytest.raises(ValueError, match="Missing gemini_api_key"):
vertex_base._check_custom_proxy(
api_base=api_base,
custom_llm_provider=custom_llm_provider,
gemini_api_key=gemini_api_key,
endpoint=endpoint,
stream=stream,
auth_header=auth_header,
url=url,
model=model,
)
else:
# Test cases 1-4: Should work correctly
result_auth_header, result_url = vertex_base._check_custom_proxy(
api_base=api_base,
custom_llm_provider=custom_llm_provider,
gemini_api_key=gemini_api_key,
endpoint=endpoint,
stream=stream,
auth_header=auth_header,
url=url,
model=model,
)
assert result_auth_header == expected_auth_header, f"Expected auth_header {expected_auth_header}, got {result_auth_header}"
assert result_url == expected_url, f"Expected URL {expected_url}, got {result_url}"
def test_check_custom_proxy_gemini_url_construction(self):
"""Test that Gemini URLs are constructed correctly with custom API base"""
vertex_base = VertexBase()
# Test various Gemini models with custom API base
test_cases = [
("gemini-2.5-flash-lite", "generateContent", "https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent"),
("gemini-2.5-pro", "generateContent", "https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent"),
("gemini-1.5-flash", "streamGenerateContent", "https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:streamGenerateContent"),
]
for model, endpoint, expected_url in test_cases:
_, result_url = vertex_base._check_custom_proxy(
api_base="https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta",
custom_llm_provider="gemini",
gemini_api_key="test-api-key",
endpoint=endpoint,
stream=False,
auth_header=None,
url=f"https://generativelanguage.googleapis.com/v1beta/models/{model}:{endpoint}",
model=model,
)
assert result_url == expected_url, f"Expected {expected_url}, got {result_url} for model {model}"
def test_check_custom_proxy_streaming_parameter(self):
"""Test that streaming parameter correctly adds ?alt=sse to URLs"""
vertex_base = VertexBase()
# Test with streaming enabled
_, result_url_streaming = vertex_base._check_custom_proxy(
api_base="https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta",
custom_llm_provider="gemini",
gemini_api_key="test-api-key",
endpoint="generateContent",
stream=True,
auth_header=None,
url="https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent",
model="gemini-2.5-flash-lite",
)
expected_streaming_url = "https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?alt=sse"
assert result_url_streaming == expected_streaming_url, f"Expected {expected_streaming_url}, got {result_url_streaming}"
# Test with streaming disabled
_, result_url_no_streaming = vertex_base._check_custom_proxy(
api_base="https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta",
custom_llm_provider="gemini",
gemini_api_key="test-api-key",
endpoint="generateContent",
stream=False,
auth_header=None,
url="https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent",
model="gemini-2.5-flash-lite",
)
expected_no_streaming_url = "https://proxy.zapier.com/generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent"
assert result_url_no_streaming == expected_no_streaming_url, f"Expected {expected_no_streaming_url}, got {result_url_no_streaming}"
@@ -364,3 +364,53 @@ async def test_should_check_cold_storage_for_full_payload():
with patch.object(litellm, 'configured_cold_storage_logger', None):
result5 = ResponsesSessionHandler._should_check_cold_storage_for_full_payload(proxy_request_with_truncated_pdf)
assert result5 == False, "Should return False when cold storage is not configured, even with truncated content"
@pytest.mark.asyncio
async def test_get_chat_completion_message_history_empty_response_dict():
"""
Test that empty response dict is handled correctly without processing.
This tests the fix for response validation to check for empty dict responses.
"""
from unittest.mock import AsyncMock, patch
# Mock spend logs with empty response dict
mock_spend_logs = [
{
"request_id": "chatcmpl-test-empty-response",
"call_type": "aresponses",
"api_key": "test_key",
"spend": 0.001,
"total_tokens": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"startTime": "2025-01-15T10:30:00.000+00:00",
"endTime": "2025-01-15T10:30:01.000+00:00",
"model": "gpt-4",
"session_id": "test-session",
"proxy_server_request": {
"input": "test input",
"model": "gpt-4"
},
"response": {} # Empty dict - should not be processed
}
]
with patch.object(ResponsesSessionHandler, "get_all_spend_logs_for_previous_response_id") as mock_get_spend_logs:
mock_get_spend_logs.return_value = mock_spend_logs
# Call the function
result = await ResponsesSessionHandler.get_chat_completion_message_history_for_previous_response_id(
"chatcmpl-test-empty-response"
)
# Verify that user message was added but no assistant response
# Since response is empty dict, no assistant response should be processed
# But user input from proxy_server_request should still be included
messages = result["messages"]
assert len(messages) == 1 # Only user message, no assistant response
assert messages[0]["role"] == "user"
assert messages[0]["content"] == "test input"
# Verify the session was still created correctly
assert result["litellm_session_id"] == "test-session"
+6 -7
View File
@@ -508,6 +508,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"supports_computer_use": {"type": "boolean"},
"cache_creation_input_audio_token_cost": {"type": "number"},
"cache_creation_input_token_cost": {"type": "number"},
"cache_creation_input_token_cost_above_1hr": {"type": "number"},
"cache_creation_input_token_cost_above_200k_tokens": {"type": "number"},
"cache_read_input_token_cost": {"type": "number"},
"cache_read_input_token_cost_above_200k_tokens": {"type": "number"},
@@ -661,16 +662,16 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"type": "array",
"items": {"type": "number"},
"minItems": 2,
"maxItems": 2
"maxItems": 2,
},
"input_cost_per_token": {"type": "number"},
"output_cost_per_token": {"type": "number"},
"cache_read_input_token_cost": {"type": "number"},
"output_cost_per_reasoning_token": {"type": "number"}
"output_cost_per_reasoning_token": {"type": "number"},
},
"required": ["range"],
"additionalProperties": False
}
"additionalProperties": False,
},
},
},
"additionalProperties": False,
@@ -843,7 +844,6 @@ for commitment in BEDROCK_COMMITMENTS:
print("block_list", block_list)
def test_supports_computer_use_utility():
"""
Tests the litellm.utils.supports_computer_use utility function.
@@ -925,8 +925,7 @@ def test_pre_process_non_default_params(model, custom_llm_provider):
from litellm.utils import ProviderConfigManager, pre_process_non_default_params
provider_config = ProviderConfigManager.get_provider_chat_config(
model=model,
provider=LlmProviders(custom_llm_provider)
model=model, provider=LlmProviders(custom_llm_provider)
)
class ResponseFormat(BaseModel):
@@ -857,10 +857,6 @@ const CreateKey: React.FC<CreateKeyProps> = ({
className="mt-4"
help={premiumUser ? "Select existing guardrails or enter new ones" : "Premium feature - Upgrade to set guardrails by key"}
>
<Tooltip
title={!premiumUser ? "Setting guardrails by key is a premium feature" : ""}
placement="top"
>
<Select
mode="tags"
style={{ width: '100%' }}
@@ -872,7 +868,6 @@ const CreateKey: React.FC<CreateKeyProps> = ({
}
options={guardrailsList.map(name => ({ value: name, label: name }))}
/>
</Tooltip>
</Form.Item>
<Form.Item
label={
@@ -894,10 +889,6 @@ const CreateKey: React.FC<CreateKeyProps> = ({
className="mt-4"
help={premiumUser ? "Select existing prompts or enter new ones" : "Premium feature - Upgrade to set prompts by key"}
>
<Tooltip
title={!premiumUser ? "Setting prompts by key is a premium feature" : ""}
placement="top"
>
<Select
mode="tags"
style={{ width: '100%' }}
@@ -909,7 +900,6 @@ const CreateKey: React.FC<CreateKeyProps> = ({
}
options={promptsList.map(name => ({ value: name, label: name }))}
/>
</Tooltip>
</Form.Item>
<Form.Item
label={
@@ -11,6 +11,7 @@ import EditLoggingSettings from "../team/EditLoggingSettings"
import { extractLoggingSettings, formatMetadataForDisplay } from "../key_info_utils"
import { fetchMCPAccessGroups } from "../networking"
import { mapInternalToDisplayNames, mapDisplayToInternalNames } from "../callback_info_helpers"
import GuardrailSelector from "@/components/guardrails/GuardrailSelector"
interface KeyEditViewProps {
keyData: KeyResponse
@@ -220,20 +221,9 @@ export function KeyEditView({
</Form.Item>
<Form.Item label="Guardrails" name="guardrails">
<Tooltip title={!premiumUser ? "Setting guardrails by key is a premium feature" : ""} placement="top">
<Select
mode="tags"
style={{ width: "100%" }}
disabled={!premiumUser}
placeholder={
!premiumUser
? "Premium feature - Upgrade to set guardrails by key"
: Array.isArray(keyData.metadata?.guardrails) && keyData.metadata.guardrails.length > 0
? `Current: ${keyData.metadata.guardrails.join(", ")}`
: "Select or enter guardrails"
}
/>
</Tooltip>
{ accessToken &&
<GuardrailSelector onChange={(v) => {form.setFieldValue("guardrails", v)}} accessToken={accessToken} />
}
</Form.Item>
<Form.Item label="Prompts" name="prompts">