Merge branch 'BerriAI:main' into main

This commit is contained in:
AnilAren 2025-10-24 08:02:53 +05:30 committed by GitHub
commit 6d9c8153e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
186 changed files with 13624 additions and 715 deletions

View file

@ -1084,6 +1084,49 @@ jobs:
paths:
- ocr_coverage.xml
- ocr_coverage
search_testing:
docker:
- image: cimg/python:3.11
auth:
username: ${DOCKERHUB_USERNAME}
password: ${DOCKERHUB_PASSWORD}
working_directory: ~/project
steps:
- checkout
- setup_google_dns
- run:
name: Install Dependencies
command: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
pip install "pytest==7.3.1"
pip install "pytest-retry==1.6.3"
pip install "pytest-cov==5.0.0"
pip install "pytest-asyncio==0.21.1"
pip install "respx==0.22.0"
# Run pytest and generate JUnit XML report
- run:
name: Run tests
command: |
pwd
ls
python -m pytest -vv tests/search_tests --cov=litellm --cov-report=xml -x -s -v --junitxml=test-results/junit.xml --durations=5
no_output_timeout: 120m
- run:
name: Rename the coverage files
command: |
mv coverage.xml search_coverage.xml
mv .coverage search_coverage
# Store test results
- store_test_results:
path: test-results
- persist_to_workspace:
root: .
paths:
- search_coverage.xml
- search_coverage
litellm_mapped_tests:
docker:
- image: cimg/python:3.11
@ -2827,7 +2870,7 @@ jobs:
python -m venv venv
. venv/bin/activate
pip install coverage
coverage combine llm_translation_coverage llm_responses_api_coverage ocr_coverage mcp_coverage logging_coverage audio_coverage litellm_router_coverage local_testing_coverage litellm_assistants_api_coverage auth_ui_unit_tests_coverage langfuse_coverage caching_coverage litellm_proxy_unit_tests_coverage image_gen_coverage pass_through_unit_tests_coverage batches_coverage litellm_security_tests_coverage guardrails_coverage
coverage combine llm_translation_coverage llm_responses_api_coverage ocr_coverage search_coverage mcp_coverage logging_coverage audio_coverage litellm_router_coverage local_testing_coverage litellm_assistants_api_coverage auth_ui_unit_tests_coverage langfuse_coverage caching_coverage litellm_proxy_unit_tests_coverage image_gen_coverage pass_through_unit_tests_coverage batches_coverage litellm_security_tests_coverage guardrails_coverage
coverage xml
- codecov/upload:
file: ./coverage.xml
@ -3381,6 +3424,12 @@ workflows:
only:
- main
- /litellm_.*/
- search_testing:
filters:
branches:
only:
- main
- /litellm_.*/
- litellm_mapped_enterprise_tests:
filters:
branches:
@ -3437,6 +3486,7 @@ workflows:
- guardrails_testing
- llm_responses_api_testing
- ocr_testing
- search_testing
- litellm_mapped_tests
- litellm_mapped_enterprise_tests
- batches_testing
@ -3501,6 +3551,7 @@ workflows:
- google_generate_content_endpoint_testing
- llm_responses_api_testing
- ocr_testing
- search_testing
- litellm_mapped_tests
- litellm_mapped_enterprise_tests
- batches_testing

1
.gitignore vendored
View file

@ -99,3 +99,4 @@ litellm/proxy/to_delete_loadtest_work/*
update_model_cost_map.py
tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py
litellm/proxy/_experimental/out/guardrails/index.html
scripts/test_vertex_ai_search.py

View file

@ -0,0 +1,206 @@
# Call any LiteLLM model in your custom format
Use this to call any LiteLLM supported `.completion()` model, in your custom format. Useful if you have a custom API and want to support any LiteLLM supported model.
## How it works
Your request → Adapter translates to OpenAI format → LiteLLM processes it → Adapter translates response back → Your response
## Create an Adapter
Inherit from `CustomLogger` and implement 3 methods:
```python
from litellm.integrations.custom_logger import CustomLogger
from litellm.types.llms.openai import ChatCompletionRequest
from litellm.types.utils import ModelResponse
class MyAdapter(CustomLogger):
def translate_completion_input_params(self, kwargs) -> ChatCompletionRequest:
"""Convert your format → OpenAI format"""
# Example: Anthropic to OpenAI
return {
"model": kwargs["model"],
"messages": self._convert_messages(kwargs["messages"]),
"max_tokens": kwargs.get("max_tokens"),
}
def translate_completion_output_params(self, response: ModelResponse):
"""Convert OpenAI format → your format"""
# Return your provider's response format
return MyProviderResponse(
id=response.id,
content=response.choices[0].message.content,
usage=response.usage,
)
def translate_completion_output_params_streaming(self, completion_stream):
"""Handle streaming responses"""
return MyStreamWrapper(completion_stream)
```
## Register it
```python
import litellm
my_adapter = MyAdapter()
litellm.adapters = [{"id": "my_provider", "adapter": my_adapter}]
```
## Use it
```python
from litellm import adapter_completion
# Now you can use your provider's format with any LiteLLM model
response = adapter_completion(
adapter_id="my_provider",
model="gpt-4", # or any LiteLLM model
messages=[{"role": "user", "content": "hello"}],
max_tokens=100
)
```
### Streaming
```python
stream = adapter_completion(
adapter_id="my_provider",
model="gpt-4",
messages=[{"role": "user", "content": "hello"}],
stream=True
)
for chunk in stream:
print(chunk)
```
### Async
```python
from litellm import aadapter_completion
response = await aadapter_completion(
adapter_id="my_provider",
model="gpt-4",
messages=[{"role": "user", "content": "hello"}]
)
```
## Example: Anthropic Adapter
Here's how we translate Anthropic's format:
### Input Translation
```python
def translate_completion_input_params(self, kwargs):
model = kwargs.pop("model")
messages = kwargs.pop("messages")
# Convert Anthropic messages to OpenAI format
openai_messages = []
for msg in messages:
if msg["role"] == "user":
openai_messages.append({
"role": "user",
"content": msg["content"]
})
# Handle system message
if "system" in kwargs:
openai_messages.insert(0, {
"role": "system",
"content": kwargs.pop("system")
})
return {
"model": model,
"messages": openai_messages,
**kwargs # pass through other params
}
```
### Output Translation
```python
def translate_completion_output_params(self, response):
return AnthropicResponse(
id=response.id,
type="message",
role="assistant",
content=[{
"type": "text",
"text": response.choices[0].message.content
}],
usage={
"input_tokens": response.usage.prompt_tokens,
"output_tokens": response.usage.completion_tokens
}
)
```
### Streaming
```python
from litellm.types.utils import AdapterCompletionStreamWrapper
class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
def __init__(self, completion_stream, model):
super().__init__(completion_stream)
self.model = model
self.first_chunk = True
async def __anext__(self):
# First chunk
if self.first_chunk:
self.first_chunk = False
return {"type": "message_start", "message": {...}}
# Stream chunks
async for chunk in self.completion_stream:
return {
"type": "content_block_delta",
"delta": {"text": chunk.choices[0].delta.content}
}
# Last chunk
return {"type": "message_stop"}
def translate_completion_output_params_streaming(self, stream, model):
return AnthropicStreamWrapper(stream, model)
```
## Use with Proxy
Add to your proxy config:
```yaml
general_settings:
pass_through_endpoints:
- path: "/v1/messages"
target: "my_module.MyAdapter"
```
Then call it:
```bash
curl http://localhost:4000/v1/messages \
-H "Authorization: Bearer sk-1234" \
-d '{"model": "gpt-4", "messages": [...]}'
```
## Real Example
Check out the full Anthropic adapter:
- [transformation.py](https://github.com/BerriAI/litellm/blob/main/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py)
- [handler.py](https://github.com/BerriAI/litellm/blob/main/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py)
- [streaming_iterator.py](https://github.com/BerriAI/litellm/blob/main/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py)
## That's it
1. Create a class that inherits `CustomLogger`
2. Implement the 3 translation methods
3. Register with `litellm.adapters = [{"id": "...", "adapter": ...}]`
4. Call with `adapter_completion(adapter_id="...")`

View file

@ -1,7 +1,7 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Google AI generateContent
# /generateContent
Use LiteLLM to call Google AI's generateContent endpoints for text generation, multimodal interactions, and streaming responses.

View file

@ -75,6 +75,12 @@ It is recommended that you include the `project_id` or `project_name` to ensure
You can customize the span name in Braintrust logging by passing `span_name` in the metadata. By default, the span name is set to "Chat Completion".
### Custom Span Attributes
You can customize the span id, root span name and span parents in Braintrust logging by passing `span_id`, `root_span_id` and `span_parents` in the metadata.
`span_parents` should be a string containing a list of span ids, joined by ,
<Tabs>
<TabItem value="sdk" label="SDK">

View file

@ -61,6 +61,12 @@ print(response)
These options are useful for high-volume applications where sampling a subset of errors and transactions provides sufficient visibility while managing costs.
#### Sentry Environment
- **SENTRY_ENVIRONMENT**: Specifies the environment name for your Sentry events (e.g., "production", "staging", "development")
- Helps organize and filter errors by deployment environment in Sentry dashboard
- Example: `os.environ["SENTRY_ENVIRONMENT"] = "staging"`
- If not set, Sentry will use 'production' as the default environment
## Redacting Messages, Response Content from Sentry Logging
Set `litellm.turn_off_message_logging=True` This will prevent the messages and responses from being logged to sentry, but request metadata will still be logged.

View file

@ -5,6 +5,7 @@
| Cost Tracking | ✅ |
| Logging | ✅ (Basic Logging not supported) |
| Load Balancing | ✅ |
| Supported Providers | `mistral`, `azure_ai` |
:::tip
@ -260,4 +261,5 @@ The response follows Mistral's OCR format with the following structure:
| Provider | Link to Usage |
|-------------|--------------------|
| Mistral AI | [Usage](#quick-start) |
| Azure AI | [Usage](../docs/providers/azure_ocr) |

View file

@ -1,10 +1,17 @@
# Azure Text to Speech (tts)
Convert text to natural-sounding speech using Azure OpenAI's Text to Speech models. Supports multiple voices and audio formats.
## Overview
| Property | Details |
|-------|-------|
| Description | Convert text to natural-sounding speech using Azure OpenAI's Text to Speech models |
| Provider Route on LiteLLM | `azure/` |
| Supported Operations | `/audio/speech` |
| Link to Provider Doc | [Azure OpenAI TTS ↗](https://learn.microsoft.com/en-us/azure/ai-services/openai/text-to-speech-quickstart)
## Quick Start
**LiteLLM SDK**
### **LiteLLM SDK**
```python showLineNumbers title="SDK Usage"
from litellm import speech
@ -26,7 +33,7 @@ response = speech(
response.stream_to_file(speech_file_path)
```
**LiteLLM PROXY**
### **LiteLLM PROXY**
```yaml showLineNumbers title="proxy_config.yaml"
model_list:

View file

@ -136,6 +136,168 @@ response = speech(
| `wav` | riff-24khz-16bit-mono-pcm | 24kHz |
| `pcm` | raw-24khz-16bit-mono-pcm | 24kHz |
## Sending Azure-Specific Params
Azure AI Speech supports advanced SSML features through optional parameters:
- `style`: Speaking style (e.g., "cheerful", "sad", "angry", "whispering")
- `styledegree`: Style intensity (0.01 to 2)
- `role`: Voice role (e.g., "Girl", "Boy", "SeniorFemale", "SeniorMale")
- `lang`: Language code for multilingual voices (e.g., "es-ES", "fr-FR", "hi-IN")
### **LiteLLM SDK**
#### Custom Azure Voice
```python showLineNumbers title="Custom Azure Voice"
from litellm import speech
response = speech(
model="azure/speech/azure-tts",
voice="en-US-AndrewNeural", # Use Azure voice directly
input="Hello, this is a test",
api_base="https://eastus.tts.speech.microsoft.com",
api_key=os.environ["AZURE_TTS_API_KEY"],
response_format="mp3"
)
response.stream_to_file("speech.mp3")
```
#### Speaking Style
```python showLineNumbers title="Speaking Style"
from litellm import speech
response = speech(
model="azure/speech/azure-tts",
voice="en-US-JennyNeural", # Must be a voice that supports styles
input="Who are you? What is chicken dinner?",
api_base="https://eastus.tts.speech.microsoft.com",
api_key=os.environ["AZURE_TTS_API_KEY"],
style="whispering", # Azure-specific: cheerful, sad, angry, whispering, etc.
)
response.stream_to_file("speech.mp3")
```
#### Style with Degree and Role
```python showLineNumbers title="Style with Degree and Role"
from litellm import speech
response = speech(
model="azure/speech/azure-tts",
voice="en-US-AriaNeural",
input="Good morning! How are you today?",
api_base="https://eastus.tts.speech.microsoft.com",
api_key=os.environ["AZURE_TTS_API_KEY"],
style="cheerful", # Azure-specific: Speaking style
styledegree="2", # Azure-specific: 0.01 to 2 (intensity)
role="SeniorFemale", # Azure-specific: Girl, Boy, SeniorFemale, etc.
)
response.stream_to_file("speech.mp3")
```
#### Language Override for Multilingual Voices
```python showLineNumbers title="Language Override"
from litellm import speech
response = speech(
model="azure/speech/azure-tts",
voice="en-US-AvaMultilingualNeural", # Multilingual voice
input="आप कौन हैं? चिकन डिनर क्या है?", # Hindi text
api_base="https://eastus.tts.speech.microsoft.com",
api_key=os.environ["AZURE_TTS_API_KEY"],
lang="hi-IN", # Azure-specific: Override language
)
response.stream_to_file("speech.mp3")
```
### **LiteLLM AI Gateway (CURL)**
First, ensure you have set up your proxy config as shown in the [LiteLLM Proxy setup](#quick-start) above.
**Using the model name from your config:**
```yaml
model_list:
- model_name: azure-speech # This is what you'll use in your API calls
litellm_params:
model: azure/speech/azure-tts
api_base: https://eastus.tts.speech.microsoft.com
api_key: os.environ/AZURE_TTS_API_KEY
```
#### Custom Azure Voice
```bash
curl http://0.0.0.0:4000/v1/audio/speech \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"model": "azure-speech",
"voice": "en-US-AndrewNeural",
"input": "Hello, this is a test"
}' \
--output speech.mp3
```
#### Speaking Style
```bash
curl http://0.0.0.0:4000/v1/audio/speech \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"model": "azure-speech",
"input": "Who are you? What is chicken dinner?",
"voice": "en-US-JennyNeural",
"style": "whispering"
}' \
--output speech.mp3
```
#### Style with Degree and Role
```bash
curl http://0.0.0.0:4000/v1/audio/speech \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"model": "azure-speech",
"voice": "en-US-AriaNeural",
"input": "Good morning! How are you today?",
"style": "cheerful",
"styledegree": "2",
"role": "SeniorFemale"
}' \
--output speech.mp3
```
#### Language Override
```bash
curl http://0.0.0.0:4000/v1/audio/speech \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"model": "azure-speech",
"input": "आप कौन हैं? चिकन डिनर क्या है?",
"voice": "en-US-AvaMultilingualNeural",
"lang": "hi-IN"
}' \
--output speech.mp3
```
### Azure-Specific Parameters Reference
| Parameter | Description | Example Values | Notes |
|-----------|-------------|----------------|-------|
| `style` | Speaking style | `cheerful`, `sad`, `angry`, `excited`, `friendly`, `hopeful`, `shouting`, `terrified`, `unfriendly`, `whispering` | Only supported by certain voices. See [Azure voice styles documentation](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-synthesis-markup-voice#use-speaking-styles-and-roles) |
| `styledegree` | Style intensity | `0.01` to `2` | Higher values = more intense. Default is `1` |
| `role` | Voice role | `Girl`, `Boy`, `YoungAdultFemale`, `YoungAdultMale`, `OlderAdultFemale`, `OlderAdultMale`, `SeniorFemale`, `SeniorMale` | Only supported by certain voices |
| `lang` | Language code | `es-ES`, `fr-FR`, `de-DE`, `hi-IN`, etc. | For multilingual voices. Overrides the default language |
## Async Support
```python showLineNumbers title="Async Usage"

View file

@ -0,0 +1,154 @@
# Azure AI OCR
## Overview
| Property | Details |
|-------|-------|
| Description | Azure AI OCR provides document intelligence capabilities powered by Mistral, enabling text extraction from PDFs and images |
| Provider Route on LiteLLM | `azure_ai/` |
| Supported Operations | `/ocr` |
| Link to Provider Doc | [Azure AI ↗](https://ai.azure.com/)
Extract text from documents and images using Azure AI's OCR models, powered by Mistral.
## Quick Start
### **LiteLLM SDK**
```python showLineNumbers title="SDK Usage"
import litellm
import os
# Set environment variables
os.environ["AZURE_AI_API_KEY"] = ""
os.environ["AZURE_AI_API_BASE"] = ""
# OCR with PDF URL
response = litellm.ocr(
model="azure_ai/mistral-document-ai-2505",
document={
"type": "document_url",
"document_url": "https://example.com/document.pdf"
}
)
# Access extracted text
for page in response.pages:
print(page.text)
```
### **LiteLLM PROXY**
```yaml showLineNumbers title="proxy_config.yaml"
model_list:
- model_name: azure-ocr
litellm_params:
model: azure_ai/mistral-document-ai-2505
api_key: "os.environ/AZURE_AI_API_KEY"
api_base: "os.environ/AZURE_AI_API_BASE"
model_info:
mode: ocr
```
## Document Types
Azure AI OCR supports both PDFs and images.
### PDF Documents
```python showLineNumbers title="PDF OCR"
response = litellm.ocr(
model="azure_ai/mistral-document-ai-2505",
document={
"type": "document_url",
"document_url": "https://example.com/document.pdf"
}
)
```
### Image Documents
```python showLineNumbers title="Image OCR"
response = litellm.ocr(
model="azure_ai/mistral-document-ai-2505",
document={
"type": "image_url",
"image_url": "https://example.com/image.png"
}
)
```
### Base64 Encoded Documents
```python showLineNumbers title="Base64 PDF"
import base64
# Read and encode PDF
with open("document.pdf", "rb") as f:
pdf_base64 = base64.b64encode(f.read()).decode()
response = litellm.ocr(
model="azure_ai/mistral-document-ai-2505",
document={
"type": "document_url",
"document_url": f"data:application/pdf;base64,{pdf_base64}"
}
)
```
## Supported Parameters
```python showLineNumbers title="All Parameters"
response = litellm.ocr(
model="azure_ai/mistral-document-ai-2505",
document={ # Required: Document to process
"type": "document_url",
"document_url": "https://..."
},
include_image_base64=True, # Optional: Include base64 images
pages=[0, 1, 2], # Optional: Specific pages to process
image_limit=10 # Optional: Limit number of images
)
```
## Response Format
```python showLineNumbers title="Response Structure"
# Response has the following structure
response.pages # List of pages with extracted text
response.model # Model used
response.object # "ocr"
response.usage_info # Token usage information
# Access page content
for page in response.pages:
print(f"Page {page.page_number}:")
print(page.text)
```
## Async Support
```python showLineNumbers title="Async Usage"
import litellm
response = await litellm.aocr(
model="azure_ai/mistral-document-ai-2505",
document={
"type": "document_url",
"document_url": "https://example.com/document.pdf"
}
)
```
## Important Notes
:::info URL Conversion
Azure AI OCR endpoints don't have internet access. LiteLLM automatically converts public URLs to base64 data URIs before sending requests to Azure AI.
:::
## Supported Models
- `mistral-document-ai-2505` - Latest Mistral OCR model on Azure AI
Use the Azure AI provider prefix: `azure_ai/<model-name>`

View file

@ -4,6 +4,10 @@ import TabItem from '@theme/TabItem';
# OpenAI
LiteLLM supports OpenAI Chat + Embedding calls.
:::tip
**We recommend using `litellm.responses()` / Responses API** for the latest OpenAI models (GPT-5, gpt-5-codex, o3-mini, etc.)
:::
### Required API Keys
```python

View file

@ -340,6 +340,7 @@ router_settings:
| router_general_settings | RouterGeneralSettings | [SDK-Only] Router general settings - contains optimizations like 'async_only_mode'. [Docs](../routing.md#router-general-settings) |
| optional_pre_call_checks | List[str] | List of pre-call checks to add to the router. Currently supported: 'router_budget_limiting', 'prompt_caching' |
| ignore_invalid_deployments | boolean | If true, ignores invalid deployments. Default for proxy is True - to prevent invalid models from blocking other models from being loaded. |
| search_tools | List[SearchToolTypedDict] | List of search tool configurations for Search API integration. Each tool specifies a search_tool_name and litellm_params with search_provider, api_key, api_base, etc. [Further Docs](../search.md) |
### environment variables - Reference
@ -456,6 +457,7 @@ router_settings:
| DEFAULT_CLIENT_DISCONNECT_CHECK_TIMEOUT_SECONDS | Timeout in seconds for checking client disconnection. Default is 1
| DEFAULT_COOLDOWN_TIME_SECONDS | Duration in seconds to cooldown a model after failures. Default is 5
| DEFAULT_CRON_JOB_LOCK_TTL_SECONDS | Time-to-live for cron job locks in seconds. Default is 60 (1 minute)
| DEFAULT_DATAFORSEO_LOCATION_CODE | Default location code for DataForSEO search API. Default is 2250 (France)
| DEFAULT_FAILURE_THRESHOLD_PERCENT | Threshold percentage of failures to cool down a deployment. Default is 0.5 (50%)
| DEFAULT_FLUSH_INTERVAL_SECONDS | Default interval in seconds for flushing operations. Default is 5
| DEFAULT_HEALTH_CHECK_INTERVAL | Default interval in seconds for health checks. Default is 300 (5 minutes)
@ -558,6 +560,8 @@ router_settings:
| GITHUB_COPILOT_ACCESS_TOKEN_FILE | File to store GitHub Copilot access token for `github_copilot` llm provider
| GREENSCALE_API_KEY | API key for Greenscale service
| GREENSCALE_ENDPOINT | Endpoint URL for Greenscale service
| GRAYSWAN_API_BASE | Base URL for GraySwan API. Default is https://api.grayswan.ai
| GRAYSWAN_API_KEY | API key for GraySwan Cygnal service
| GOOGLE_APPLICATION_CREDENTIALS | Path to Google Cloud credentials JSON file
| GOOGLE_CLIENT_ID | Client ID for Google OAuth
| GOOGLE_CLIENT_SECRET | Client secret for Google OAuth

View file

@ -136,9 +136,16 @@ model_list:
litellm_settings:
callbacks: ["dynamic_rate_limiter_v3"]
priority_reservation:
"prod": 0.9 # 90% reserved for production (9 RPM)
"dev": 0.1 # 10% reserved for development (1 RPM)
priority_reservation:
"prod": 0.9 # 90% reserved for production (9 RPM)
"dev": 0.1 # 10% reserved for development (1 RPM)
# Alternative format:
# "prod":
# type: "rpm" # Reserve based on requests per minute
# value: 9 # 9 RPM = 90% of 10 RPM capacity
# "dev":
# type: "tpm" # Reserve based on tokens per minute
# value: 100 # 100 TPM
priority_reservation_settings:
default_priority: 0 # Weight (0%) assigned to keys without explicit priority metadata
saturation_threshold: 0.50 # A model is saturated if it has hit 50% of its RPM limit
@ -150,10 +157,12 @@ general_settings:
**Configuration Details:**
`priority_reservation`: Dict[str, float]
`priority_reservation`: Dict[str, Union[float, PriorityReservationDict]]
- **Key (str)**: Priority level name (can be any string like "prod", "dev", "critical", etc.)
- **Value (float)**: Percentage of total TPM/RPM to reserve (0.0 to 1.0)
- **Note**: Values should sum to 1.0 or less
- **Value**: Either a float (0.0-1.0) or dict with `type` and `value`
- Float: `0.9` = 90% of capacity
- Dict: `{"type": "rpm", "value": 9}` = 9 requests/min
- Supported types: `"percent"`, `"rpm"`, `"tpm"`
`priority_reservation_settings`: Object (Optional)
- **default_priority (float)**: Weight/percentage (0.0 to 1.0) assigned to API keys that have no priority metadata set (defaults to 0.5)

View file

@ -0,0 +1,147 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Gray Swan Cygnal Guardrail
Use [Gray Swan Cygnal](https://docs.grayswan.ai/cygnal/monitor-requests) to continuously monitor conversations for policy violations, indirect prompt injection (IPI), jailbreak attempts, and other safety risks.
Cygnal returns a `violation` score between `0` and `1` (higher means more likely to violate policy), plus metadata such as violated rule indices, mutation detection, and IPI flags. LiteLLM can automatically block or monitor requests based on this signal.
---
## Quick Start
### 1. Obtain Credentials
1. Create a Gray Swan account and generate a Cygnal API key.
2. Configure environment variables for the LiteLLM proxy host:
```bash
export GRAYSWAN_API_KEY="your-grayswan-key"
```
### 2. Configure `config.yaml`
Add a guardrail entry that references the Gray Swan integration. Below is a balanced example that monitors both input and output but only blocks once the violation score reaches the configured threshold.
```yaml
model_list:
- model_name: openai/gpt-4.1-mini
litellm_params:
model: openai/gpt-4.1-mini
api_key: os.environ/OPENAI_API_KEY
guardrails:
- guardrail_name: "cygnal-monitor"
litellm_params:
guardrail: grayswan
mode: [pre_call, post_call] # monitor both input and output
api_key: os.environ/GRAYSWAN_API_KEY
optional_params:
on_flagged_action: monitor # or "block"
violation_threshold: 0.5 # score >= threshold is flagged
reasoning_mode: hybrid # off | hybrid | thinking
categories:
safety: "Detect jailbreaks and policy violations"
policy_id: "your-cygnal-policy-id"
default_on: true
general_settings:
master_key: "your-litellm-master-key"
litellm_settings:
set_verbose: true
```
### 3. Launch the Proxy
```bash
litellm --config config.yaml --port 4000
```
---
## Choosing Guardrail Modes
Gray Swan can run during `pre_call`, `during_call`, and `post_call` stages. Combine modes based on your latency and coverage requirements.
| Mode | When it Runs | Protects | Typical Use Case |
|--------------|-------------------|-----------------------|------------------|
| `pre_call` | Before LLM call | User input only | Block prompt injection before it reaches the model |
| `during_call`| Parallel to call | User input only | Low-latency monitoring without blocking |
| `post_call` | After response | Full conversation | Scan output for policy violations, leaked secrets, or IPI |
<Tabs>
<TabItem value="monitor" label="Monitor Only">
```yaml
guardrails:
- guardrail_name: "cygnal-monitor-only"
litellm_params:
guardrail: grayswan
mode: "during_call"
api_key: os.environ/GRAYSWAN_API_KEY
optional_params:
on_flagged_action: monitor
violation_threshold: 0.6
default_on: true
```
Best for visibility without blocking. Alerts are logged via LiteLLMs standard logging callbacks.
</TabItem>
<TabItem value="block-input" label="Block Input">
```yaml
guardrails:
- guardrail_name: "cygnal-block-input"
litellm_params:
guardrail: grayswan
mode: "pre_call"
api_key: os.environ/GRAYSWAN_API_KEY
optional_params:
on_flagged_action: block
violation_threshold: 0.4
categories:
pii: "Detect sensitive data"
default_on: true
```
Stops malicious or sensitive prompts before any tokens are generated.
</TabItem>
<TabItem value="full-coverage" label="Full Coverage">
```yaml
guardrails:
- guardrail_name: "cygnal-full-coverage"
litellm_params:
guardrail: grayswan
mode: [pre_call, post_call]
api_key: os.environ/GRAYSWAN_API_KEY
optional_params:
on_flagged_action: block
violation_threshold: 0.5
reasoning_mode: thinking
policy_id: "policy-id-from-grayswan"
default_on: true
```
Provides the strongest enforcement by inspecting both prompts and responses.
</TabItem>
</Tabs>
---
## Configuration Reference
| Parameter | Type | Description |
|---------------------------------------|-----------------|-------------|
| `api_key` | string | Gray Swan Cygnal API key. Reads from `GRAYSWAN_API_KEY` if omitted. |
| `mode` | string or list | Guardrail stages (`pre_call`, `during_call`, `post_call`). |
| `optional_params.on_flagged_action` | string | `monitor` (log only) or `block` (raise `HTTPException`). |
| `.optional_params.violation_threshold`| number (0-1) | Scores at or above this value are considered violations. |
| `optional_params.reasoning_mode` | string | `off`, `hybrid`, or `thinking`. Enables Cygnals reasoning capabilities. |
| `optional_params.categories` | object | Map of custom category names to descriptions. |
| `optional_params.policy_id` | string | Gray Swan policy identifier. |

View file

@ -213,6 +213,20 @@ model_list:
mode: realtime
```
### OCR Models
To run OCR health checks, specify the mode as "ocr" in your config for the relevant model.
```yaml
model_list:
- model_name: mistral/mistral-ocr-latest
litellm_params:
model: mistral/mistral-ocr-latest
api_key: os.environ/MISTRAL_API_KEY
model_info:
mode: ocr
```
### Wildcard Routes
For wildcard routes, you can specify a `health_check_model` in your config.yaml. This model will be used for health checks for that wildcard route.

View file

@ -602,15 +602,15 @@ print(response)
Use this if you want to control which LiteLLM-specific fields are logged as tags by the LiteLLM proxy. By default LiteLLM Proxy logs no LiteLLM-specific fields
| LiteLLM specific field | Description | Example Value |
|---------------------------|-----------------------------------------------------------------------------------------|------------------------------------------------|
| `cache_hit` | Indicates whether a cache hit occurred (True) or not (False) | `true`, `false` |
| `cache_key` | The Cache key used for this request | `d2b758c****` |
| `proxy_base_url` | The base URL for the proxy server, the value of env var `PROXY_BASE_URL` on your server | `https://proxy.example.com` |
| `user_api_key_alias` | An alias for the LiteLLM Virtual Key. | `prod-app1` |
| `user_api_key_user_id` | The unique ID associated with a user's API key. | `user_123`, `user_456` |
| `user_api_key_user_email` | The email associated with a user's API key. | `user@example.com`, `admin@example.com` |
| `user_api_key_team_alias` | An alias for a team associated with an API key. | `team_alpha`, `dev_team` |
| LiteLLM specific field | Description | Example Value |
| ------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------- |
| `cache_hit` | Indicates whether a cache hit occurred (True) or not (False) | `true`, `false` |
| `cache_key` | The Cache key used for this request | `d2b758c****` |
| `proxy_base_url` | The base URL for the proxy server, the value of env var `PROXY_BASE_URL` on your server | `https://proxy.example.com` |
| `user_api_key_alias` | An alias for the LiteLLM Virtual Key. | `prod-app1` |
| `user_api_key_user_id` | The unique ID associated with a user's API key. | `user_123`, `user_456` |
| `user_api_key_user_email` | The email associated with a user's API key. | `user@example.com`, `admin@example.com` |
| `user_api_key_team_alias` | An alias for a team associated with an API key. | `team_alpha`, `dev_team` |
**Usage**
@ -1111,10 +1111,10 @@ Log LLM Logs to [Google Cloud Storage Buckets](https://cloud.google.com/storage?
:::
| Property | Details |
|----------|---------|
| Description | Log LLM Input/Output to cloud storage buckets |
| Load Test Benchmarks | [Benchmarks](https://docs.litellm.ai/docs/benchmarks) |
| Property | Details |
| ---------------------------- | -------------------------------------------------------------- |
| Description | Log LLM Input/Output to cloud storage buckets |
| Load Test Benchmarks | [Benchmarks](https://docs.litellm.ai/docs/benchmarks) |
| Google Docs on Cloud Storage | [Google Cloud Storage](https://cloud.google.com/storage?hl=en) |
@ -1196,8 +1196,8 @@ Log LLM Logs/SpendLogs to [Google Cloud Storage PubSub Topic](https://cloud.goog
:::
| Property | Details |
|----------|---------|
| Property | Details |
| ----------- | ------------------------------------------------------------------ |
| Description | Log LiteLLM `SpendLogs Table` to Google Cloud Storage PubSub Topic |
When to use `gcs_pubsub`?
@ -1388,10 +1388,10 @@ On s3 bucket, you will see the object key as `my-test-path/my-team-alias/...`
## AWS SQS
| Property | Details |
|----------|---------|
| Description | Log LLM Input/Output to AWS SQS Queue |
| AWS Docs on SQS | [AWS SQS](https://aws.amazon.com/sqs/) |
| Property | Details |
| -------------------- | ------------------------------------------------------------------------------------- |
| Description | Log LLM Input/Output to AWS SQS Queue |
| AWS Docs on SQS | [AWS SQS](https://aws.amazon.com/sqs/) |
| Fields Logged to SQS | LiteLLM [Standard Logging Payload is logged for each LLM call](../proxy/logging_spec) |
@ -1465,9 +1465,9 @@ Log LLM Logs to [Azure Data Lake Storage](https://learn.microsoft.com/en-us/azur
:::
| Property | Details |
|----------|---------|
| Description | Log LLM Input/Output to Azure Blob Storage (Bucket) |
| Property | Details |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Description | Log LLM Input/Output to Azure Blob Storage (Bucket) |
| Azure Docs on Data Lake Storage | [Azure Data Lake Storage](https://learn.microsoft.com/en-us/azure/storage/blobs/data-lake-storage-introduction) |
@ -1966,9 +1966,9 @@ This is an Enterprise only feature [Get Started with Enterprise here](https://gi
:::
| Property | Details |
|----------|---------|
| Description | Log LLM Input/Output to a custom API endpoint |
| Property | Details |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Description | Log LLM Input/Output to a custom API endpoint |
| Logged Payload | `List[StandardLoggingPayload]` LiteLLM logs a list of [`StandardLoggingPayload` objects](https://docs.litellm.ai/docs/proxy/logging_spec) to your endpoint |
@ -1995,10 +1995,10 @@ litellm_settings:
2. Set Environment Variables for the custom API endpoint
| Environment Variable | Details | Required |
|----------|---------|----------|
| `GENERIC_LOGGER_ENDPOINT` | The endpoint + route we should send callback logs to | Yes |
| `GENERIC_LOGGER_HEADERS` | Optional: Set headers to be sent to the custom API endpoint | No, this is optional |
| Environment Variable | Details | Required |
| ------------------------- | ----------------------------------------------------------- | -------------------- |
| `GENERIC_LOGGER_ENDPOINT` | The endpoint + route we should send callback logs to | Yes |
| `GENERIC_LOGGER_HEADERS` | Optional: Set headers to be sent to the custom API endpoint | No, this is optional |
```shell showLineNumbers title=".env"
GENERIC_LOGGER_ENDPOINT="https://webhook-test.com/30343bc33591bc5e6dc44217ceae3e0a"
@ -2428,6 +2428,7 @@ export SENTRY_DSN="your-sentry-dsn"
# Optional: Configure Sentry sampling rates
export SENTRY_API_SAMPLE_RATE="1.0" # Controls what percentage of errors are sent (default: 1.0 = 100%)
export SENTRY_API_TRACE_RATE="1.0" # Controls what percentage of transactions are sampled for performance monitoring (default: 1.0 = 100%)
export SENTRY_ENVIRONMENT="development" # Controls the Sentry Environment (default: production)
```
```yaml

View file

@ -0,0 +1,91 @@
# DataForSEO Search
**Get API Access:** [DataForSEO](https://dataforseo.com/)
## Setup
1. Go to [DataForSEO](https://dataforseo.com/) and create an account
2. Navigate to your account dashboard
3. Generate API credentials:
- You'll receive a **login** (username)
- You'll receive a **password**
4. Set up your environment variables:
- `DATAFORSEO_LOGIN` - Your DataForSEO login/username
- `DATAFORSEO_PASSWORD` - Your DataForSEO password
## LiteLLM Python SDK
```python showLineNumbers title="DataForSEO Search"
import os
from litellm import search
os.environ["DATAFORSEO_LOGIN"] = "your-login"
os.environ["DATAFORSEO_PASSWORD"] = "your-password"
response = search(
query="latest AI developments",
search_provider="dataforseo",
max_results=10
)
```
## LiteLLM AI Gateway
### 1. Setup config.yaml
```yaml showLineNumbers title="config.yaml"
model_list:
- model_name: gpt-4
litellm_params:
model: gpt-4
api_key: os.environ/OPENAI_API_KEY
search_tools:
- search_tool_name: dataforseo-search
litellm_params:
search_provider: dataforseo
api_key: "os.environ/DATAFORSEO_LOGIN:os.environ/DATAFORSEO_PASSWORD"
```
### 2. Start the proxy
```bash
litellm --config /path/to/config.yaml
# RUNNING on http://0.0.0.0:4000
```
### 3. Test the search endpoint
```bash showLineNumbers title="Test Request"
curl http://0.0.0.0:4000/v1/search/dataforseo-search \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"query": "latest AI developments",
"max_results": 10
}'
```
## Provider-specific Parameters
```python showLineNumbers title="DataForSEO Search with Provider-specific Parameters"
import os
from litellm import search
os.environ["DATAFORSEO_LOGIN"] = "your-login"
os.environ["DATAFORSEO_PASSWORD"] = "your-password"
response = search(
query="AI developments",
search_provider="dataforseo",
max_results=10,
# DataForSEO-specific parameters
country="United States", # Country name for location_name
language_code="en", # Language code
depth=20, # Number of results (max 700)
device="desktop", # Device type ('desktop', 'mobile', 'tablet')
os="windows" # Operating system
)
```

View file

@ -0,0 +1,77 @@
# Exa AI Search
**Get API Key:** [https://exa.ai](https://exa.ai)
## LiteLLM Python SDK
```python showLineNumbers title="Exa AI Search"
import os
from litellm import search
os.environ["EXA_API_KEY"] = "exa-..."
response = search(
query="latest AI developments",
search_provider="exa_ai",
max_results=5
)
```
## LiteLLM AI Gateway
### 1. Setup config.yaml
```yaml showLineNumbers title="config.yaml"
model_list:
- model_name: gpt-4
litellm_params:
model: gpt-4
api_key: os.environ/OPENAI_API_KEY
search_tools:
- search_tool_name: exa-search
litellm_params:
search_provider: exa_ai
api_key: os.environ/EXA_API_KEY
```
### 2. Start the proxy
```bash
litellm --config /path/to/config.yaml
# RUNNING on http://0.0.0.0:4000
```
### 3. Test the search endpoint
```bash showLineNumbers title="Test Request"
curl http://0.0.0.0:4000/v1/search/exa-search \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"query": "latest AI developments",
"max_results": 5
}'
```
## Provider-specific Parameters
```python showLineNumbers title="Exa AI Search with Provider-specific Parameters"
import os
from litellm import search
os.environ["EXA_API_KEY"] = "exa-..."
response = search(
query="AI research papers",
search_provider="exa_ai",
max_results=10,
search_domain_filter=["arxiv.org"],
# Exa-specific parameters
type="neural", # 'neural', 'keyword', or 'auto'
contents={"text": True}, # Request text content
use_autoprompt=True # Enable Exa's autoprompt
)
```

View file

@ -0,0 +1,101 @@
# Google Programmable Search Engine (PSE)
**Get API Key:** [Google Cloud Console](https://console.cloud.google.com/apis/credentials)
**Create Search Engine:** [Programmable Search Engine](https://programmablesearchengine.google.com/)
## Setup
1. Go to [Google Developers Programmable Search Engine](https://programmablesearchengine.google.com/) and log in or create an account
2. Click the **Add** button in the control panel
3. Enter a search engine name and configure properties:
- Choose which sites to search (entire web or specific sites)
- Set language and other preferences
- Verify you're not a robot
4. Click **Create** button
5. Once created, you'll see:
- **Search engine ID (cx)** - Copy this for `GOOGLE_PSE_ENGINE_ID`
- Instructions to get your API key
6. Generate API key:
- Go to [Google Cloud Console - Credentials](https://console.cloud.google.com/apis/credentials)
- Create a new API key or use existing one
- Enable **Custom Search API** for your project
- Copy the API key for `GOOGLE_PSE_API_KEY`
## LiteLLM Python SDK
```python showLineNumbers title="Google PSE Search"
import os
from litellm import search
os.environ["GOOGLE_PSE_API_KEY"] = "AIza..."
os.environ["GOOGLE_PSE_ENGINE_ID"] = "your-search-engine-id"
response = search(
query="latest AI developments",
search_provider="google_pse",
max_results=10
)
```
## LiteLLM AI Gateway
### 1. Setup config.yaml
```yaml showLineNumbers title="config.yaml"
model_list:
- model_name: gpt-4
litellm_params:
model: gpt-4
api_key: os.environ/OPENAI_API_KEY
search_tools:
- search_tool_name: google-search
litellm_params:
search_provider: google_pse
api_key: os.environ/GOOGLE_PSE_API_KEY
search_engine_id: os.environ/GOOGLE_PSE_ENGINE_ID
```
### 2. Start the proxy
```bash
litellm --config /path/to/config.yaml
# RUNNING on http://0.0.0.0:4000
```
### 3. Test the search endpoint
```bash showLineNumbers title="Test Request"
curl http://0.0.0.0:4000/v1/search/google-search \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"query": "latest AI developments",
"max_results": 10
}'
```
## Provider-specific Parameters
```python showLineNumbers title="Google PSE Search with Provider-specific Parameters"
import os
from litellm import search
os.environ["GOOGLE_PSE_API_KEY"] = "AIza..."
os.environ["GOOGLE_PSE_ENGINE_ID"] = "your-search-engine-id"
response = search(
query="latest AI research papers",
search_provider="google_pse",
max_results=10,
search_domain_filter=["arxiv.org"],
# Google PSE-specific parameters (use actual Google PSE API parameter names)
dateRestrict="m6", # 'm6' = last 6 months, 'd7' = last 7 days
lr="lang_en", # Language restriction (e.g., 'lang_en', 'lang_es')
safe="active", # Search safety level ('active' or 'off')
exactTerms="machine learning", # Phrase that all documents must contain
fileType="pdf" # File type to restrict results to
)
```

View file

@ -0,0 +1,272 @@
# Overview
| Feature | Supported |
|---------|-----------|
| Supported Providers | `perplexity`, `tavily`, `parallel_ai`, `exa_ai`, `google_pse`, `dataforseo` |
| Cost Tracking | ✅ |
| Logging | ✅ |
| Load Balancing | ❌ |
:::tip
LiteLLM follows the [Perplexity API request/response for the Search API](https://docs.perplexity.ai/api-reference/search-post)
:::
:::info
Supported from LiteLLM v1.78.7+
:::
## **LiteLLM Python SDK Usage**
### Quick Start
```python showLineNumbers title="Basic Search"
from litellm import search
import os
os.environ["PERPLEXITYAI_API_KEY"] = "pplx-..."
response = search(
query="latest AI developments in 2024",
search_provider="perplexity",
max_results=5
)
# Access search results
for result in response.results:
print(f"{result.title}: {result.url}")
print(f"Snippet: {result.snippet}\n")
```
### Async Usage
```python showLineNumbers title="Async Search"
from litellm import asearch
import os, asyncio
os.environ["PERPLEXITYAI_API_KEY"] = "pplx-..."
async def search_async():
response = await asearch(
query="machine learning research papers",
search_provider="perplexity",
max_results=10,
search_domain_filter=["arxiv.org", "nature.com"]
)
# Access search results
for result in response.results:
print(f"{result.title}: {result.url}")
print(f"Snippet: {result.snippet}")
asyncio.run(search_async())
```
### Optional Parameters
```python showLineNumbers title="Search with Options"
response = search(
query="AI developments",
search_provider="perplexity",
# Unified parameters (work across all providers)
max_results=10, # Maximum number of results (1-20)
search_domain_filter=["arxiv.org"], # Filter to specific domains
country="US", # Country code filter
max_tokens_per_page=1024 # Max tokens per page
)
```
## **LiteLLM AI Gateway Usage**
LiteLLM provides a Perplexity API compatible `/search` endpoint for search calls.
**Setup**
Add this to your litellm proxy config.yaml
```yaml showLineNumbers title="config.yaml"
model_list:
- model_name: gpt-4
litellm_params:
model: gpt-4
api_key: os.environ/OPENAI_API_KEY
search_tools:
- search_tool_name: perplexity-search
litellm_params:
search_provider: perplexity
api_key: os.environ/PERPLEXITYAI_API_KEY
- search_tool_name: tavily-search
litellm_params:
search_provider: tavily
api_key: os.environ/TAVILY_API_KEY
```
Start litellm
```bash
litellm --config /path/to/config.yaml
# RUNNING on http://0.0.0.0:4000
```
### Test Request
**Option 1: Search tool name in URL (Recommended - keeps body Perplexity-compatible)**
```bash showLineNumbers title="cURL Request"
curl http://0.0.0.0:4000/v1/search/perplexity-search \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"query": "latest AI developments 2024",
"max_results": 5,
"search_domain_filter": ["arxiv.org", "nature.com"],
"country": "US"
}'
```
**Option 2: Search tool name in body**
```bash showLineNumbers title="cURL Request with search_tool_name in body"
curl http://0.0.0.0:4000/v1/search \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"search_tool_name": "perplexity-search",
"query": "latest AI developments 2024",
"max_results": 5
}'
```
### Load Balancing
Configure multiple search providers for automatic load balancing and fallbacks:
```yaml showLineNumbers title="config.yaml with load balancing"
search_tools:
- search_tool_name: my-search
litellm_params:
search_provider: perplexity
api_key: os.environ/PERPLEXITYAI_API_KEY
- search_tool_name: my-search
litellm_params:
search_provider: tavily
api_key: os.environ/TAVILY_API_KEY
- search_tool_name: my-search
litellm_params:
search_provider: exa_ai
api_key: os.environ/EXA_API_KEY
router_settings:
routing_strategy: simple-shuffle # or 'least-busy', 'latency-based-routing'
```
Test with load balancing:
```bash
curl http://0.0.0.0:4000/v1/search/my-search \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"query": "AI developments",
"max_results": 10
}'
```
## **Request/Response Format**
:::info
LiteLLM follows the **Perplexity Search API specification**.
See the [official Perplexity Search documentation](https://docs.perplexity.ai/api-reference/search-post) for complete details.
:::
### Example Request
```json showLineNumbers title="Search Request"
{
"query": "latest AI developments 2024",
"max_results": 10,
"search_domain_filter": ["arxiv.org", "nature.com"],
"country": "US",
"max_tokens_per_page": 1024
}
```
### Request Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string or array | Yes | Search query. Can be a single string or array of strings |
| `search_provider` | string | Yes (SDK) | The search provider to use: `"perplexity"`, `"tavily"`, `"parallel_ai"`, `"exa_ai"`, or `"google_pse"` |
| `search_tool_name` | string | Yes (Proxy) | Name of the search tool configured in `config.yaml` |
| `max_results` | integer | No | Maximum number of results to return (1-20). Default: 10 |
| `search_domain_filter` | array | No | List of domains to filter results (max 20 domains) |
| `max_tokens_per_page` | integer | No | Maximum tokens per page to process. Default: 1024 |
| `country` | string | No | Country code filter (e.g., `"US"`, `"GB"`, `"DE"`) |
**Query Format Examples:**
```python
# Single query
query = "AI developments"
# Multiple queries
query = ["AI developments", "machine learning trends"]
```
### Response Format
The response follows Perplexity's search format with the following structure:
```json showLineNumbers title="Search Response"
{
"object": "search",
"results": [
{
"title": "Latest Advances in Artificial Intelligence",
"url": "https://arxiv.org/paper/example",
"snippet": "This paper discusses recent developments in AI...",
"date": "2024-01-15"
},
{
"title": "Machine Learning Breakthroughs",
"url": "https://nature.com/articles/ml-breakthrough",
"snippet": "Researchers have achieved new milestones...",
"date": "2024-01-10"
}
]
}
```
#### Response Fields
| Field | Type | Description |
|-------|------|-------------|
| `object` | string | Always `"search"` for search responses |
| `results` | array | List of search results |
| `results[].title` | string | Title of the search result |
| `results[].url` | string | URL of the search result |
| `results[].snippet` | string | Text snippet from the result |
| `results[].date` | string | Optional publication or last updated date |
## **Supported Providers**
| Provider | Environment Variable | `search_provider` Value |
|----------|---------------------|------------------------|
| Perplexity AI | `PERPLEXITYAI_API_KEY` | `perplexity` |
| Tavily | `TAVILY_API_KEY` | `tavily` |
| Exa AI | `EXA_API_KEY` | `exa_ai` |
| Parallel AI | `PARALLEL_AI_API_KEY` | `parallel_ai` |
| Google PSE | `GOOGLE_PSE_API_KEY`, `GOOGLE_PSE_ENGINE_ID` | `google_pse` |
| DataForSEO | `DATAFORSEO_LOGIN`, `DATAFORSEO_PASSWORD` | `dataforseo` |
See the individual provider documentation for detailed setup instructions and provider-specific parameters.

View file

@ -0,0 +1,75 @@
# Parallel AI Search
**Get API Key:** [https://www.parallel.ai](https://www.parallel.ai)
## LiteLLM Python SDK
```python showLineNumbers title="Parallel AI Search"
import os
from litellm import search
os.environ["PARALLEL_AI_API_KEY"] = "..."
response = search(
query="latest AI developments",
search_provider="parallel_ai",
max_results=5
)
```
## LiteLLM AI Gateway
### 1. Setup config.yaml
```yaml showLineNumbers title="config.yaml"
model_list:
- model_name: gpt-4
litellm_params:
model: gpt-4
api_key: os.environ/OPENAI_API_KEY
search_tools:
- search_tool_name: parallel-search
litellm_params:
search_provider: parallel_ai
api_key: os.environ/PARALLEL_AI_API_KEY
```
### 2. Start the proxy
```bash
litellm --config /path/to/config.yaml
# RUNNING on http://0.0.0.0:4000
```
### 3. Test the search endpoint
```bash showLineNumbers title="Test Request"
curl http://0.0.0.0:4000/v1/search/parallel-search \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"query": "latest AI developments",
"max_results": 5
}'
```
## Provider-specific Parameters
```python showLineNumbers title="Parallel AI Search with Provider-specific Parameters"
import os
from litellm import search
os.environ["PARALLEL_AI_API_KEY"] = "..."
response = search(
query="latest developments in quantum computing",
search_provider="parallel_ai",
max_results=5,
# Parallel AI-specific parameters
processor="pro", # 'base' or 'pro'
max_chars_per_result=500 # Max characters per result
)
```

View file

@ -0,0 +1,57 @@
# Perplexity AI Search
**Get API Key:** [https://www.perplexity.ai/settings/api](https://www.perplexity.ai/settings/api)
## LiteLLM Python SDK
```python showLineNumbers title="Perplexity Search"
import os
from litellm import search
os.environ["PERPLEXITYAI_API_KEY"] = "pplx-..."
response = search(
query="latest AI developments",
search_provider="perplexity",
max_results=5
)
```
## LiteLLM AI Gateway
### 1. Setup config.yaml
```yaml showLineNumbers title="config.yaml"
model_list:
- model_name: gpt-4
litellm_params:
model: gpt-4
api_key: os.environ/OPENAI_API_KEY
search_tools:
- search_tool_name: perplexity-search
litellm_params:
search_provider: perplexity
api_key: os.environ/PERPLEXITYAI_API_KEY
```
### 2. Start the proxy
```bash
litellm --config /path/to/config.yaml
# RUNNING on http://0.0.0.0:4000
```
### 3. Test the search endpoint
```bash showLineNumbers title="Test Request"
curl http://0.0.0.0:4000/v1/search/perplexity-search \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"query": "latest AI developments",
"max_results": 5
}'
```

View file

@ -0,0 +1,77 @@
# Tavily Search
**Get API Key:** [https://tavily.com](https://tavily.com)
## LiteLLM Python SDK
```python showLineNumbers title="Tavily Search"
import os
from litellm import search
os.environ["TAVILY_API_KEY"] = "tvly-..."
response = search(
query="latest AI developments",
search_provider="tavily",
max_results=5
)
```
## LiteLLM AI Gateway
### 1. Setup config.yaml
```yaml showLineNumbers title="config.yaml"
model_list:
- model_name: gpt-4
litellm_params:
model: gpt-4
api_key: os.environ/OPENAI_API_KEY
search_tools:
- search_tool_name: tavily-search
litellm_params:
search_provider: tavily
api_key: os.environ/TAVILY_API_KEY
```
### 2. Start the proxy
```bash
litellm --config /path/to/config.yaml
# RUNNING on http://0.0.0.0:4000
```
### 3. Test the search endpoint
```bash showLineNumbers title="Test Request"
curl http://0.0.0.0:4000/v1/search/tavily-search \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"query": "latest AI developments",
"max_results": 5
}'
```
## Provider-specific Parameters
```python showLineNumbers title="Tavily Search with Provider-specific Parameters"
import os
from litellm import search
os.environ["TAVILY_API_KEY"] = "tvly-..."
response = search(
query="latest tech news",
search_provider="tavily",
max_results=5,
# Tavily-specific parameters
topic="news", # 'general', 'news', 'finance'
search_depth="advanced", # 'basic', 'advanced'
include_answer=True, # Include AI-generated answer
include_raw_content=True # Include raw HTML content
)
```

View file

@ -37,6 +37,7 @@ const sidebars = {
"proxy/guardrails/azure_content_guardrail",
"proxy/guardrails/bedrock",
"proxy/guardrails/enkryptai",
"proxy/guardrails/grayswan",
"proxy/guardrails/lasso_security",
"proxy/guardrails/guardrails_ai",
"proxy/guardrails/lakera_ai",
@ -307,6 +308,7 @@ const sidebars = {
],
},
"text_completion",
"bedrock_converse",
"embedding/supported_embedding",
{
type: "category",
@ -326,6 +328,7 @@ const sidebars = {
},
"generateContent",
"apply_guardrail",
"bedrock_invoke",
{
type: "category",
label: "/images",
@ -346,9 +349,8 @@ const sidebars = {
"mcp_guardrail",
]
},
"anthropic_unified",
"moderation",
"bedrock_invoke",
"bedrock_converse",
"ocr",
{
type: "category",
@ -379,7 +381,19 @@ const sidebars = {
"realtime",
"rerank",
"response_api",
"anthropic_unified",
{
type: "category",
label: "/search",
items: [
"search/index",
"search/perplexity",
"search/tavily",
"search/exa_ai",
"search/parallel_ai",
"search/google_pse",
"search/dataforseo",
]
},
{
type: "category",
label: "/vector_stores",
@ -400,6 +414,11 @@ const sidebars = {
slug: "/providers",
},
items: [
{
type: "doc",
id: "provider_registration/index",
label: "Integrate as a Model Provider",
},
{
type: "category",
label: "OpenAI",
@ -426,6 +445,7 @@ const sidebars = {
label: "Azure AI",
items: [
"providers/azure_ai",
"providers/azure_ocr",
"providers/azure_ai_speech",
"providers/azure_ai_img",
]
@ -581,7 +601,8 @@ const sidebars = {
"guides/finetuned_models",
"guides/security_settings",
"proxy/veo_video_generation",
"reasoning_content"
"reasoning_content",
"extras/creating_adapters",
]
},
@ -739,11 +760,6 @@ const sidebars = {
"proxy_server",
],
},
{
type: "doc",
id: "provider_registration/index",
label: "Integrate as a Model Provider",
},
"troubleshoot",
],
};

View file

@ -214,6 +214,92 @@ response = completion(
</Tabs>
### Responses API
Use `litellm.responses()` for advanced models that support reasoning content like GPT-5, o3, etc.
<Tabs>
<TabItem value="openai-responses" label="OpenAI">
```python
from litellm import responses
import os
## set ENV variables
os.environ["OPENAI_API_KEY"] = "your-api-key"
response = responses(
model="gpt-5-mini",
messages=[{ "content": "What is the capital of France?","role": "user"}],
reasoning_effort="medium"
)
print(response)
print(response.choices[0].message.content) # response
print(response.choices[0].message.reasoning_content) # reasoning
```
</TabItem>
<TabItem value="anthropic-responses" label="Anthropic (Claude)">
```python
from litellm import responses
import os
## set ENV variables
os.environ["ANTHROPIC_API_KEY"] = "your-api-key"
response = responses(
model="claude-3.5-sonnet",
messages=[{ "content": "What is the capital of France?","role": "user"}]
)
```
</TabItem>
<TabItem value="vertex-responses" label="VertexAI">
```python
from litellm import responses
import os
# auth: run 'gcloud auth application-default'
os.environ["VERTEX_PROJECT"] = "jr-smith-386718"
os.environ["VERTEX_LOCATION"] = "us-central1"
response = responses(
model="chat-bison",
messages=[{ "content": "What is the capital of France?","role": "user"}]
)
```
</TabItem>
<TabItem value="azure-responses" label="Azure OpenAI">
```python
from litellm import responses
import os
## set ENV variables
os.environ["AZURE_API_KEY"] = ""
os.environ["AZURE_API_BASE"] = ""
os.environ["AZURE_API_VERSION"] = ""
# azure call
response = responses(
"azure/<your_deployment_name>",
messages = [{ "content": "What is the capital of France?","role": "user"}]
)
print(response)
```
</TabItem>
</Tabs>
### Streaming
Set `stream=True` in the `completion` args.
@ -504,6 +590,10 @@ model_list:
api_base: os.environ/AZURE_API_BASE # runs os.getenv("AZURE_API_BASE")
api_key: os.environ/AZURE_API_KEY # runs os.getenv("AZURE_API_KEY")
api_version: "2023-07-01-preview"
litellm_settings:
master_key: sk-1234
database_url: postgres://
```
### Step 2. RUN Docker Image
@ -524,6 +614,9 @@ docker run \
#### Step 2: Make ChatCompletions Request to Proxy
<Tabs>
<TabItem value="chat-completions" label="Chat Completions">
```python
import openai # openai v1.0.0+
client = openai.OpenAI(api_key="anything",base_url="http://0.0.0.0:4000") # set proxy to base_url
@ -538,6 +631,28 @@ response = client.chat.completions.create(model="gpt-3.5-turbo", messages = [
print(response)
```
</TabItem>
<TabItem value="responses-api" label="Responses API">
```python
from openai import OpenAI
client = OpenAI(
api_key="sk-1234",
base_url="http://0.0.0.0:4000"
)
response = client.responses.create(
model="gpt-5",
input="Tell me a three sentence bedtime story about a unicorn."
)
print(response)
```
</TabItem>
</Tabs>
## More details
- [exception mapping](../../docs/exception_mapping)

Binary file not shown.

View file

@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "LiteLLM_SearchToolsTable" (
"search_tool_id" TEXT NOT NULL,
"search_tool_name" TEXT NOT NULL,
"litellm_params" JSONB NOT NULL,
"search_tool_info" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LiteLLM_SearchToolsTable_pkey" PRIMARY KEY ("search_tool_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "LiteLLM_SearchToolsTable_search_tool_name_key" ON "LiteLLM_SearchToolsTable"("search_tool_name");

View file

@ -570,4 +570,14 @@ model LiteLLM_HealthCheckTable {
@@index([model_name])
@@index([checked_at])
@@index([status])
}
// Search Tools table for storing search tool configurations
model LiteLLM_SearchToolsTable {
search_tool_id String @id @default(uuid())
search_tool_name String @unique
litellm_params Json
search_tool_info Json?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "litellm-proxy-extras"
version = "0.2.27"
version = "0.2.29"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
authors = ["BerriAI"]
readme = "README.md"
@ -22,7 +22,7 @@ requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.commitizen]
version = "0.2.27"
version = "0.2.29"
version_files = [
"pyproject.toml:version",
"../requirements.txt:litellm-proxy-extras==",

View file

@ -28,6 +28,7 @@ from litellm.types.utils import (
all_litellm_params,
all_litellm_params as _litellm_completion_params,
CredentialItem,
PriorityReservationDict,
) # maintain backwards compatibility for root param
from litellm._logging import (
set_verbose,
@ -89,7 +90,7 @@ from litellm.types.proxy.management_endpoints.ui_sso import (
DefaultTeamSSOParams,
LiteLLM_UpperboundKeyGenerateParams,
)
from litellm.types.utils import StandardKeyGenerationConfig, LlmProviders
from litellm.types.utils import StandardKeyGenerationConfig, LlmProviders, SearchProviders
from litellm.types.utils import PriorityReservationSettings
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.logging_callback_manager import LoggingCallbackManager
@ -156,7 +157,7 @@ _custom_logger_compatible_callbacks_literal = Literal[
"cloudzero",
"posthog",
]
configured_cold_storage_logger: Optional[
cold_storage_custom_logger: Optional[
_custom_logger_compatible_callbacks_literal
] = None
logged_real_time_event_types: Optional[Union[List[str], Literal["*"]]] = None
@ -369,7 +370,7 @@ disable_copilot_system_to_assistant: bool = False # If false (default), convert
public_model_groups: Optional[List[str]] = None
public_model_groups_links: Dict[str, str] = {}
#### REQUEST PRIORITIZATION #######
priority_reservation: Optional[Dict[str, float]] = None
priority_reservation: Optional[Dict[str, Union[float, PriorityReservationDict]]] = None
priority_reservation_settings: "PriorityReservationSettings" = (
PriorityReservationSettings()
)
@ -1334,6 +1335,7 @@ from .rerank_api.main import *
from .llms.anthropic.experimental_pass_through.messages.handler import *
from .responses.main import *
from .ocr.main import *
from .search.main import *
from .realtime_api.main import _arealtime
from .fine_tuning.main import *
from .files.main import *

View file

@ -78,6 +78,7 @@ def _get_redis_cluster_kwargs(client=None):
available_args.append("redis_connect_func") # Needed for sync clusters and IAM detection
available_args.append("gcp_service_account")
available_args.append("gcp_ssl_ca_certs")
available_args.append("max_connections")
return available_args
@ -376,7 +377,7 @@ def get_redis_client(**env_overrides):
def get_redis_async_client(
**env_overrides,
connection_pool: Optional[async_redis.BlockingConnectionPool] = None, **env_overrides,
) -> Union[async_redis.Redis, async_redis.RedisCluster]:
redis_kwargs = _get_redis_client_logic(**env_overrides)
if "url" in redis_kwargs and redis_kwargs["url"] is not None:
@ -447,6 +448,10 @@ def get_redis_async_client(
if "sentinel_nodes" in redis_kwargs and "service_name" in redis_kwargs:
return _init_async_redis_sentinel(redis_kwargs)
_pretty_print_redis_config(redis_kwargs=redis_kwargs)
if connection_pool is not None:
redis_kwargs["connection_pool"] = connection_pool
return async_redis.Redis(
**redis_kwargs,
)

View file

@ -274,6 +274,11 @@ ANTHROPIC_WEB_SEARCH_TOOL_MAX_USES = {
}
DEFAULT_IMAGE_ENDPOINT_MODEL = "dall-e-2"
### DATAFORSEO CONSTANTS ###
DEFAULT_DATAFORSEO_LOCATION_CODE = int(
os.getenv("DEFAULT_DATAFORSEO_LOCATION_CODE", 2250)
) # Default to France (2250) - lower number, commonly used location
LITELLM_CHAT_PROVIDERS = [
"openai",
"openai_like",

View file

@ -29,6 +29,7 @@ from litellm.llms.anthropic.cost_calculation import (
from litellm.llms.azure.cost_calculation import (
cost_per_token as azure_openai_cost_per_token,
)
from litellm.llms.base_llm.search.transformation import SearchResponse
from litellm.llms.bedrock.cost_calculation import (
cost_per_token as bedrock_cost_per_token,
)
@ -315,6 +316,16 @@ def cost_per_token( # noqa: PLR0915
custom_llm_provider=custom_llm_provider,
duration=audio_transcription_file_duration,
)
elif call_type == "search" or call_type == "asearch":
# Search providers use per-query pricing
from litellm.search import search_provider_cost_per_query
return search_provider_cost_per_query(
model=model,
custom_llm_provider=custom_llm_provider,
number_of_queries=number_of_queries or 1,
optional_params=response._hidden_params if response and hasattr(response, "_hidden_params") else None
)
elif custom_llm_provider == "vertex_ai":
cost_router = google_cost_router(
model=model_without_prefix,
@ -1094,6 +1105,7 @@ def response_cost_calculator(
LiteLLMRealtimeStreamLoggingObject,
OpenAIModerationResponse,
Response,
SearchResponse,
],
model: str,
custom_llm_provider: Optional[str],
@ -1114,6 +1126,8 @@ def response_cost_calculator(
"speech",
"rerank",
"arerank",
"search",
"asearch",
],
optional_params: dict,
cache_hit: Optional[bool] = None,

View file

@ -711,6 +711,16 @@ def image_edit(
# add images / or return a single image
images = image if isinstance(image, list) else [image]
headers_from_kwargs = kwargs.get("headers")
merged_extra_headers: Dict[str, Any] = {}
if isinstance(headers_from_kwargs, dict):
merged_extra_headers.update(headers_from_kwargs)
if isinstance(extra_headers, dict):
merged_extra_headers.update(extra_headers)
if merged_extra_headers:
extra_headers = dict(merged_extra_headers)
# get llm provider logic
litellm_params = GenericLiteLLMParams(**kwargs)
model, custom_llm_provider, _, _ = get_llm_provider(

View file

@ -206,6 +206,20 @@ class BraintrustLogger(CustomLogger):
# Allow metadata override for span name
span_name = dynamic_metadata.get("span_name", "Chat Completion")
# Span parents is a special case
span_parents = dynamic_metadata.get("span_parents")
# Convert comma-separated string to list if present
if span_parents:
span_parents = [s.strip() for s in span_parents.split(",") if s.strip()]
# Add optional span attributes only if present
span_attributes = {
"span_id": dynamic_metadata.get("span_id"),
"root_span_id": dynamic_metadata.get("root_span_id"),
"span_parents": span_parents,
}
request_data = {
"id": litellm_call_id,
@ -214,6 +228,12 @@ class BraintrustLogger(CustomLogger):
"tags": tags,
"span_attributes": {"name": span_name, "type": "llm"},
}
# Only add those that are not None (or falsy)
for key, value in span_attributes.items():
if value:
request_data[key] = value
if choices is not None:
request_data["output"] = [choice.dict() for choice in choices]
else:

View file

@ -227,8 +227,9 @@ class OpenTelemetry(CustomLogger):
PeriodicExportingMetricReader,
)
normalized_endpoint = self._normalize_otel_endpoint(self.config.endpoint, 'metrics')
_metric_exporter = OTLPMetricExporter(
endpoint=self.config.endpoint,
endpoint=normalized_endpoint,
headers=OpenTelemetry._get_headers_dictionary(self.config.headers),
preferred_temporality={Histogram: AggregationTemporality.DELTA},
)
@ -268,22 +269,20 @@ class OpenTelemetry(CustomLogger):
return
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.sdk._logs import LoggerProvider as OTLoggerProvider
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
# set up log pipeline
if logger_provider is None:
logger_provider = OTLoggerProvider()
litellm_resource = _get_litellm_resource()
logger_provider = OTLoggerProvider(resource=litellm_resource)
# Only add OTLP exporter if we created the logger provider ourselves
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(
OTLPLogExporter(
endpoint=self.config.endpoint,
headers=self._get_headers_dictionary(self.config.headers),
)
log_exporter = self._get_log_exporter()
if log_exporter:
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(log_exporter) # type: ignore[arg-type]
)
)
set_logger_provider(logger_provider)
def log_success_event(self, kwargs, response_obj, start_time, end_time):
@ -658,10 +657,15 @@ class OpenTelemetry(CustomLogger):
if not self.config.enable_events:
return
from opentelemetry._logs import LogRecord, get_logger
from opentelemetry._logs import SeverityNumber, get_logger, get_logger_provider
from opentelemetry.sdk._logs import LogRecord as SdkLogRecord
otel_logger = get_logger(LITELLM_LOGGER_NAME)
# Get the resource from the logger provider
logger_provider = get_logger_provider()
resource = getattr(logger_provider, '_resource', None) or _get_litellm_resource()
parent_ctx = span.get_span_context()
provider = (kwargs.get("litellm_params") or {}).get(
"custom_llm_provider", "Unknown"
@ -676,15 +680,18 @@ class OpenTelemetry(CustomLogger):
if self.message_logging and msg.get("content"):
attrs["gen_ai.prompt"] = msg["content"]
otel_logger.emit(
LogRecord(
attributes=attrs,
body=msg.copy(),
trace_id=parent_ctx.trace_id,
span_id=parent_ctx.span_id,
trace_flags=parent_ctx.trace_flags,
)
log_record = SdkLogRecord(
timestamp=self._to_ns(datetime.now()),
trace_id=parent_ctx.trace_id,
span_id=parent_ctx.span_id,
trace_flags=parent_ctx.trace_flags,
severity_number=SeverityNumber.INFO,
severity_text="INFO",
body=msg.copy(),
resource=resource,
attributes=attrs,
)
otel_logger.emit(log_record)
# per-choice events
for idx, choice in enumerate(response_obj.get("choices", [])):
@ -705,15 +712,18 @@ class OpenTelemetry(CustomLogger):
if self.message_logging and body_msg.get("content"):
body["message"]["content"] = body_msg["content"]
otel_logger.emit(
LogRecord(
attributes=attrs,
body=body,
trace_id=parent_ctx.trace_id,
span_id=parent_ctx.span_id,
trace_flags=parent_ctx.trace_flags,
)
log_record = SdkLogRecord(
timestamp=self._to_ns(datetime.now()),
trace_id=parent_ctx.trace_id,
span_id=parent_ctx.span_id,
trace_flags=parent_ctx.trace_flags,
severity_number=SeverityNumber.INFO,
severity_text="INFO",
body=body,
resource=resource,
attributes=attrs,
)
otel_logger.emit(log_record)
def _create_guardrail_span(
self, kwargs: Optional[dict], context: Optional[Context]
@ -1292,9 +1302,10 @@ class OpenTelemetry(CustomLogger):
"OpenTelemetry: intiializing http exporter. Value of OTEL_EXPORTER: %s",
self.OTEL_EXPORTER,
)
normalized_endpoint = self._normalize_otel_endpoint(self.OTEL_ENDPOINT, 'traces')
return BatchSpanProcessor(
OTLPSpanExporterHTTP(
endpoint=self.OTEL_ENDPOINT, headers=_split_otel_headers
endpoint=normalized_endpoint, headers=_split_otel_headers
),
)
elif self.OTEL_EXPORTER == "otlp_grpc" or self.OTEL_EXPORTER == "grpc":
@ -1302,9 +1313,10 @@ class OpenTelemetry(CustomLogger):
"OpenTelemetry: intiializing grpc exporter. Value of OTEL_EXPORTER: %s",
self.OTEL_EXPORTER,
)
normalized_endpoint = self._normalize_otel_endpoint(self.OTEL_ENDPOINT, 'traces')
return BatchSpanProcessor(
OTLPSpanExporterGRPC(
endpoint=self.OTEL_ENDPOINT, headers=_split_otel_headers
endpoint=normalized_endpoint, headers=_split_otel_headers
),
)
else:
@ -1314,6 +1326,145 @@ class OpenTelemetry(CustomLogger):
)
return BatchSpanProcessor(ConsoleSpanExporter())
def _get_log_exporter(self):
"""
Get the appropriate log exporter based on the configuration.
"""
verbose_logger.debug(
"OpenTelemetry Logger, initializing log exporter \nself.OTEL_EXPORTER: %s\nself.OTEL_ENDPOINT: %s\nself.OTEL_HEADERS: %s",
self.OTEL_EXPORTER,
self.OTEL_ENDPOINT,
self.OTEL_HEADERS,
)
_split_otel_headers = OpenTelemetry._get_headers_dictionary(self.OTEL_HEADERS)
# Normalize endpoint for logs - ensure it points to /v1/logs instead of /v1/traces
normalized_endpoint = self._normalize_otel_endpoint(self.OTEL_ENDPOINT, 'logs')
verbose_logger.debug(
"OpenTelemetry: Log endpoint normalized from %s to %s",
self.OTEL_ENDPOINT,
normalized_endpoint,
)
if hasattr(self.OTEL_EXPORTER, "export"):
# Custom exporter provided
verbose_logger.debug(
"OpenTelemetry: Using custom log exporter. Value of OTEL_EXPORTER: %s",
self.OTEL_EXPORTER,
)
return self.OTEL_EXPORTER
if self.OTEL_EXPORTER == "console":
from opentelemetry.sdk._logs.export import ConsoleLogExporter
verbose_logger.debug(
"OpenTelemetry: Using console log exporter. Value of OTEL_EXPORTER: %s",
self.OTEL_EXPORTER,
)
return ConsoleLogExporter()
elif (
self.OTEL_EXPORTER == "otlp_http"
or self.OTEL_EXPORTER == "http/protobuf"
or self.OTEL_EXPORTER == "http/json"
):
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
verbose_logger.debug(
"OpenTelemetry: Using HTTP log exporter. Value of OTEL_EXPORTER: %s, endpoint: %s",
self.OTEL_EXPORTER,
normalized_endpoint,
)
return OTLPLogExporter(
endpoint=normalized_endpoint, headers=_split_otel_headers
)
elif self.OTEL_EXPORTER == "otlp_grpc" or self.OTEL_EXPORTER == "grpc":
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
verbose_logger.debug(
"OpenTelemetry: Using gRPC log exporter. Value of OTEL_EXPORTER: %s, endpoint: %s",
self.OTEL_EXPORTER,
normalized_endpoint,
)
return OTLPLogExporter(
endpoint=normalized_endpoint, headers=_split_otel_headers
)
else:
verbose_logger.warning(
"OpenTelemetry: Unknown log exporter '%s', defaulting to console. Supported: console, otlp_http, otlp_grpc",
self.OTEL_EXPORTER,
)
from opentelemetry.sdk._logs.export import ConsoleLogExporter
return ConsoleLogExporter()
def _normalize_otel_endpoint(
self,
endpoint: Optional[str],
signal_type: str
) -> Optional[str]:
"""
Normalize the endpoint URL for a specific OpenTelemetry signal type.
The OTLP exporters expect endpoints to use signal-specific paths:
- traces: /v1/traces
- metrics: /v1/metrics
- logs: /v1/logs
This method ensures the endpoint has the correct path for the given signal type.
Args:
endpoint: The endpoint URL to normalize
signal_type: The telemetry signal type ('traces', 'metrics', or 'logs')
Returns:
Normalized endpoint URL with the correct signal path
Examples:
_normalize_otel_endpoint("http://collector:4318/v1/traces", "logs")
-> "http://collector:4318/v1/logs"
_normalize_otel_endpoint("http://collector:4318", "traces")
-> "http://collector:4318/v1/traces"
_normalize_otel_endpoint("http://collector:4318/v1/logs", "metrics")
-> "http://collector:4318/v1/metrics"
"""
if not endpoint:
return endpoint
# Validate signal_type
valid_signals = {'traces', 'metrics', 'logs'}
if signal_type not in valid_signals:
verbose_logger.warning(
"Invalid signal_type '%s' provided to _normalize_otel_endpoint. "
"Valid values: %s. Returning endpoint unchanged.",
signal_type,
valid_signals
)
return endpoint
# Remove trailing slash
endpoint = endpoint.rstrip('/')
# Check if endpoint already ends with the correct signal path
target_path = f'/v1/{signal_type}'
if endpoint.endswith(target_path):
return endpoint
# Replace existing signal path with the target signal path
other_signals = valid_signals - {signal_type}
for other_signal in other_signals:
other_path = f'/v1/{other_signal}'
if endpoint.endswith(other_path):
endpoint = endpoint.rsplit('/', 1)[0] + f'/{signal_type}'
return endpoint
# No existing signal path found, append the target path
if not endpoint.endswith('/v1'):
endpoint = endpoint + target_path
else:
endpoint = endpoint + f'/{signal_type}'
return endpoint
@staticmethod
def _get_headers_dictionary(headers: Optional[Union[str, dict]]) -> Dict[str, str]:
"""

View file

@ -2,11 +2,14 @@
Helper functions for health check calls.
"""
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Callable, Dict, Literal, Optional
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging
# Minimal PDF for health checks - base64 encoded 1-page PDF with just "test"
TEST_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlIC9QYWdlCi9QYXJlbnQgMSAwIFIKL01lZGlhQm94IFswIDAgNjEyIDc5Ml0KL0NvbnRlbnRzIDQgMCBSCi9SZXNvdXJjZXMgPDwvRm9udCA8PC9GMSAyIDAgUj4+Pj4+PgplbmRvYmoKNCAwIG9iago8PC9MZW5ndGggNDQ+PgpzdHJlYW0KQlQKL0YxIDI0IFRmCjEwMCA3MDAgVGQKKHRlc3QpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKMiAwIG9iago8PC9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL0hlbHZldGljYT4+CmVuZG9iagoxIDAgb2JqCjw8L1R5cGUgL1BhZ2VzCi9LaWRzIFszIDAgUl0KL0NvdW50IDE+PgplbmRvYmoKNSAwIG9iago8PC9UeXBlIC9DYXRhbG9nCi9QYWdlcyAxIDAgUj4+CmVuZG9iagp0cmFpbGVyCjw8L1NpemUgNgovUm9vdCA1IDAgUj4+CnN0YXJ0eHJlZgozMjQKJSVFT0Y="
class HealthCheckHelpers:
@ -78,3 +81,108 @@ class HealthCheckHelpers:
return {
"tags": [LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME],
}
@staticmethod
def get_mode_handlers(
model: str,
custom_llm_provider: str,
model_params: dict,
prompt: Optional[str] = None,
input: Optional[list] = None,
) -> Dict[
Literal[
"chat",
"completion",
"embedding",
"audio_speech",
"audio_transcription",
"image_generation",
"rerank",
"realtime",
"batch",
"responses",
"ocr",
],
Callable,
]:
"""
Returns a dictionary of mode handlers for health check calls.
Mode Handlers are Callables that need to be run for execution of the health check call.
Args:
model: The model name
custom_llm_provider: The LLM provider
model_params: The model parameters
prompt: Optional prompt for health check
input: Optional input for health check
Returns:
Dictionary mapping mode names to their handler functions
"""
import litellm
from litellm.litellm_core_utils.audio_utils.utils import (
get_audio_file_for_health_check,
)
from litellm.litellm_core_utils.health_check_utils import _filter_model_params
from litellm.realtime_api.main import _realtime_health_check
return {
"chat": lambda: litellm.acompletion(
**model_params,
),
"completion": lambda: litellm.atext_completion(
**_filter_model_params(model_params=model_params),
prompt=prompt or "test",
),
"embedding": lambda: litellm.aembedding(
**_filter_model_params(model_params=model_params),
input=input or ["test"],
),
"audio_speech": lambda: litellm.aspeech(
**{
**_filter_model_params(model_params=model_params),
**(
{"voice": "alloy"}
if "voice"
not in _filter_model_params(model_params=model_params)
else {}
),
},
input=prompt or "test",
),
"audio_transcription": lambda: litellm.atranscription(
**_filter_model_params(model_params=model_params),
file=get_audio_file_for_health_check(),
),
"image_generation": lambda: litellm.aimage_generation(
**_filter_model_params(model_params=model_params),
prompt=prompt,
),
"rerank": lambda: litellm.arerank(
**_filter_model_params(model_params=model_params),
query=prompt or "",
documents=["my sample text"],
),
"realtime": lambda: _realtime_health_check(
model=model,
custom_llm_provider=custom_llm_provider,
api_base=model_params.get("api_base", None),
api_key=model_params.get("api_key", None),
api_version=model_params.get("api_version", None),
),
"batch": lambda: litellm.alist_batches(
**_filter_model_params(model_params=model_params),
),
"responses": lambda: litellm.aresponses(
**_filter_model_params(model_params=model_params),
input=prompt or "test",
),
"ocr": lambda: litellm.aocr(
**_filter_model_params(model_params=model_params),
document={
"type": "document_url",
"document_url": TEST_PDF_URL,
},
),
}

View file

@ -1197,7 +1197,7 @@ class Logging(LiteLLMLoggingBaseClass):
total_cost=total_cost,
tool_usage_cost=cost_for_built_in_tools_cost_usd_dollar,
)
# Store discount information if provided
if original_cost is not None:
self.cost_breakdown["original_cost"] = original_cost
@ -1205,7 +1205,7 @@ class Logging(LiteLLMLoggingBaseClass):
self.cost_breakdown["discount_percent"] = discount_percent
if discount_amount is not None:
self.cost_breakdown["discount_amount"] = discount_amount
def _response_cost_calculator(
@ -3103,7 +3103,7 @@ def _get_masked_values(
(
v[: unmasked_length // 2]
+ "*" * number_of_asterisks
+ v[-unmasked_length // 2 :]
+ v[-unmasked_length // 2:]
)
if (
isinstance(v, str)
@ -3114,7 +3114,7 @@ def _get_masked_values(
(
v[: unmasked_length // 2]
+ "*" * (len(v) - unmasked_length)
+ v[-unmasked_length // 2 :]
+ v[-unmasked_length // 2:]
)
if (isinstance(v, str) and len(v) > unmasked_length)
else ("*****" if isinstance(v, str) else v)
@ -3165,6 +3165,7 @@ def set_callbacks(callback_list, function_id=None): # noqa: PLR0915
event_scrubber=EventScrubber(
denylist=SENTRY_DENYLIST, pii_denylist=SENTRY_PII_DENYLIST
),
environment=os.environ.get("SENTRY_ENVIRONMENT", "production"),
)
capture_exception = sentry_sdk_instance.capture_exception
add_breadcrumb = sentry_sdk_instance.add_breadcrumb
@ -4280,8 +4281,8 @@ class StandardLoggingPayloadSetup:
from litellm.integrations.s3 import get_s3_object_key
# Only generate object key if cold storage is configured
configured_cold_storage_logger = litellm.configured_cold_storage_logger
if configured_cold_storage_logger is None:
cold_storage_custom_logger = litellm.cold_storage_custom_logger
if cold_storage_custom_logger is None:
return None
try:
@ -4294,7 +4295,7 @@ class StandardLoggingPayloadSetup:
# 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
cold_storage_custom_logger
)
if (
custom_logger
@ -4471,12 +4472,12 @@ def _get_status_fields(
) -> "StandardLoggingPayloadStatusFields":
"""
Determine status fields based on request status and guardrail information.
Args:
status: Overall request status ("success" or "failure")
guardrail_information: Guardrail information from metadata
error_str: Error string if any
Returns:
StandardLoggingPayloadStatusFields with llm_api_status and guardrail_status
"""
@ -4489,10 +4490,10 @@ def _get_status_fields(
"guardrail_failed_to_respond": "guardrail_failed_to_respond", # direct
"not_run": "not_run"
}
# Set LLM API status
llm_api_status: StandardLoggingPayloadStatus = status
#########################################################
# Map - guardrail_information.guardrail_status to guardrail_status

View file

@ -314,9 +314,23 @@ class StandardBuiltInToolCostTracking:
if isinstance(response_object, ModelResponse):
# chat completions only include url_citation annotations when a web search call is made
return StandardBuiltInToolCostTracking.response_includes_annotation_type(
has_url_citations = StandardBuiltInToolCostTracking.response_includes_annotation_type(
response_object=response_object, annotation_type="url_citation"
)
if has_url_citations:
return True
# Fallback: Check usage object for providers that use usage instead of annotations
# (e.g., Vertex AI Gemini uses usage.prompt_tokens_details.web_search_requests)
if usage is not None:
if (
hasattr(usage, "prompt_tokens_details")
and usage.prompt_tokens_details is not None
and isinstance(usage.prompt_tokens_details, PromptTokensDetailsWrapper)
and hasattr(usage.prompt_tokens_details, "web_search_requests")
and usage.prompt_tokens_details.web_search_requests is not None
):
return True
return False
elif isinstance(response_object, ResponsesAPIResponse):
# response api explicitly includes web_search_call in the output
return StandardBuiltInToolCostTracking.response_includes_output_type(

View file

@ -50,8 +50,6 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
try:
# Ensure required fields are present for ResponseReasoningItem
item_data = dict(item)
if "id" not in item_data:
item_data["id"] = f"rs_{hash(str(item_data))}"
if "summary" not in item_data:
item_data["summary"] = (
item_data.get("reasoning_content", "")[:100] + "..."

View file

@ -4,7 +4,7 @@ Azure AVA (Cognitive Services) Text-to-Speech transformation
Maps OpenAI TTS spec to Azure Cognitive Services TTS API
"""
from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Union
from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Tuple, Union
from urllib.parse import urlparse
import httpx
@ -32,6 +32,7 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig):
"""
# Azure endpoint domains
DEFAULT_VOICE = "en-US-AriaNeural"
COGNITIVE_SERVICES_DOMAIN = "api.cognitive.microsoft.com"
TTS_SPEECH_DOMAIN = "tts.speech.microsoft.com"
TTS_ENDPOINT_PATH = "/cognitiveservices/v1"
@ -134,6 +135,9 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig):
def get_supported_openai_params(self, model: str) -> list:
"""
Azure AVA TTS supports these OpenAI parameters
Note: Azure also supports additional SSML-specific parameters (style, styledegree, role)
which can be passed but are not part of the OpenAI spec
"""
return ["voice", "response_format", "speed"]
@ -154,28 +158,93 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig):
"""
rate_percentage = int((speed - 1.0) * 100)
return f"{rate_percentage:+d}%"
def _build_express_as_element(
self,
content: str,
style: Optional[str] = None,
styledegree: Optional[str] = None,
role: Optional[str] = None,
) -> str:
"""
Build mstts:express-as element with optional style, styledegree, and role attributes
Args:
content: The inner content to wrap
style: Speaking style (e.g., "cheerful", "sad", "angry")
styledegree: Style intensity (0.01 to 2)
role: Voice role (e.g., "Girl", "Boy", "SeniorFemale", "SeniorMale")
Returns:
Content wrapped in mstts:express-as if any attributes provided, otherwise raw content
"""
if not (style or styledegree or role):
return content
express_as_attrs = []
if style:
express_as_attrs.append(f"style='{style}'")
if styledegree:
express_as_attrs.append(f"styledegree='{styledegree}'")
if role:
express_as_attrs.append(f"role='{role}'")
express_as_attrs_str = " ".join(express_as_attrs)
return f"<mstts:express-as {express_as_attrs_str}>{content}</mstts:express-as>"
def _get_voice_language(
self,
voice_name: Optional[str],
explicit_lang: Optional[str] = None,
) -> Optional[str]:
"""
Get the language for the voice element's xml:lang attribute
Args:
voice_name: The Azure voice name (e.g., "en-US-AriaNeural")
explicit_lang: Explicitly provided language code (takes precedence)
Returns:
Language code if available (e.g., "es-ES"), or None
Examples:
- explicit_lang="es-ES" "es-ES" (explicit takes precedence)
- voice_name="en-US-AriaNeural", explicit_lang=None None (use default from voice)
- voice_name="en-US-AvaMultilingualNeural", explicit_lang="fr-FR" "fr-FR"
"""
# If explicit language is provided, use it (for multilingual voices)
if explicit_lang:
return explicit_lang
# For non-multilingual voices, we don't need to set xml:lang on the voice element
# The voice name already encodes the language (e.g., en-US-AriaNeural)
# Only return a language if explicitly set
return None
def map_openai_params(
self,
model: str,
optional_params: Dict,
drop_params: bool,
) -> Dict:
voice: Optional[Union[str, Dict]] = None,
drop_params: bool = False,
kwargs: Dict = {},
) -> Tuple[Optional[str], Dict]:
"""
Map OpenAI parameters to Azure AVA TTS parameters
"""
mapped_params = {}
##########################################################
# Map voice
if "voice" in optional_params:
voice = optional_params["voice"]
# If it's already an Azure voice, use it directly
if isinstance(voice, str):
if voice in self.VOICE_MAPPINGS:
mapped_params["voice"] = self.VOICE_MAPPINGS[voice]
else:
# Assume it's already an Azure voice name
mapped_params["voice"] = voice
# OpenAI uses voice as a required param, hence not in optional_params
##########################################################
# If it's already an Azure voice, use it directly
mapped_voice: Optional[str] = None
if isinstance(voice, str):
if voice in self.VOICE_MAPPINGS:
mapped_voice = self.VOICE_MAPPINGS[voice]
else:
# Assume it's already an Azure voice name
mapped_voice = voice
# Map response format
if "response_format" in optional_params:
@ -195,7 +264,19 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig):
if speed is not None:
mapped_params["rate"] = self._convert_speed_to_azure_rate(speed=speed)
return mapped_params
# Pass through Azure-specific SSML parameters
if "style" in kwargs:
mapped_params["style"] = kwargs["style"]
if "styledegree" in kwargs:
mapped_params["styledegree"] = kwargs["styledegree"]
if "role" in kwargs:
mapped_params["role"] = kwargs["role"]
if "lang" in kwargs:
mapped_params["lang"] = kwargs["lang"]
return mapped_voice, mapped_params
def validate_environment(
self,
@ -315,11 +396,17 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig):
Note: optional_params should already be mapped via map_openai_params in main.py
Supports Azure-specific SSML features:
- style: Speaking style (e.g., "cheerful", "sad", "angry")
- styledegree: Style intensity (0.01 to 2)
- role: Voice role (e.g., "Girl", "Boy", "SeniorFemale", "SeniorMale")
- lang: Language code for multilingual voices (e.g., "es-ES", "fr-FR")
Returns:
TextToSpeechRequestData: Contains SSML body and Azure-specific headers
"""
# Get voice (already mapped in main.py, or use default)
azure_voice = optional_params.get("voice", "en-US-AriaNeural")
azure_voice = voice or self.DEFAULT_VOICE
# Get output format (already mapped in main.py)
output_format = optional_params.get(
@ -329,6 +416,10 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig):
# Build SSML
rate = optional_params.get("rate", "0%")
style = optional_params.get("style")
styledegree = optional_params.get("styledegree")
role = optional_params.get("role")
lang = optional_params.get("lang")
# Escape XML special characters in input text
escaped_input = (
@ -339,15 +430,38 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig):
.replace("'", "&apos;")
)
ssml_body = f"""
<speak version='1.0' xml:lang='en-US'>
<voice name='{azure_voice}'>
<prosody rate='{rate}'>
{escaped_input}
</prosody>
</voice>
</speak>
"""
# Determine if we need mstts namespace (for express-as element)
use_mstts = style or role or styledegree
# Build the xmlns attributes
if use_mstts:
xmlns = "xmlns='http://www.w3.org/2001/10/synthesis' xmlns:mstts='https://www.w3.org/2001/mstts'"
else:
xmlns = "xmlns='http://www.w3.org/2001/10/synthesis'"
# Build the inner content with prosody
prosody_content = f"<prosody rate='{rate}'>{escaped_input}</prosody>"
# Wrap in mstts:express-as if style or role is specified
voice_content = self._build_express_as_element(
content=prosody_content,
style=style,
styledegree=styledegree,
role=role,
)
# Build voice element with optional xml:lang attribute
voice_lang = self._get_voice_language(
voice_name=azure_voice,
explicit_lang=lang,
)
voice_lang_attr = f" xml:lang='{voice_lang}'" if voice_lang else ""
ssml_body = f"""<speak version='1.0' {xmlns} xml:lang='en-US'>
<voice name='{azure_voice}'{voice_lang_attr}>
{voice_content}
</voice>
</speak>"""
return {
"ssml_body": ssml_body,

View file

@ -0,0 +1,15 @@
"""
Base Search API module.
"""
from litellm.llms.base_llm.search.transformation import (
BaseSearchConfig,
SearchResponse,
SearchResult,
)
__all__ = [
"BaseSearchConfig",
"SearchResponse",
"SearchResult",
]

View file

@ -0,0 +1,169 @@
"""
Base Search transformation configuration.
"""
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union
import httpx
from pydantic import PrivateAttr
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.types.llms.base import LiteLLMPydanticObjectBase
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
else:
LiteLLMLoggingObj = Any
class SearchResult(LiteLLMPydanticObjectBase):
"""Single search result."""
title: str
url: str
snippet: str
date: Optional[str] = None
last_updated: Optional[str] = None
model_config = {"extra": "allow"}
class SearchResponse(LiteLLMPydanticObjectBase):
"""
Standard Search response format.
Standardized to Perplexity Search format - other providers should transform to this format.
"""
results: List[SearchResult]
object: str = "search"
model_config = {"extra": "allow"}
# Define private attributes using PrivateAttr
_hidden_params: dict = PrivateAttr(default_factory=dict)
class BaseSearchConfig:
"""
Base configuration for Search transformations.
Handles provider-agnostic Search operations.
"""
def __init__(self) -> None:
pass
@staticmethod
def ui_friendly_name() -> str:
"""
UI-friendly name for the search provider.
Override in provider-specific implementations.
"""
return "Unknown Search Provider"
def get_http_method(self) -> Literal["GET", "POST"]:
"""
Get HTTP method for search requests.
Override in provider-specific implementations if needed.
Returns:
HTTP method ('GET' or 'POST'). Default is 'POST'.
"""
return "POST"
@staticmethod
def get_supported_perplexity_optional_params() -> set:
"""
Get the set of Perplexity unified search parameters.
These are the standard parameters that providers should transform from.
Returns:
Set of parameter names that are part of the unified spec
"""
return {
"max_results",
"search_domain_filter",
"country",
"max_tokens_per_page",
}
def validate_environment(
self,
headers: Dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
**kwargs,
) -> Dict:
"""
Validate environment and return headers.
Override in provider-specific implementations.
"""
return headers
def get_complete_url(
self,
api_base: Optional[str],
optional_params: dict,
data: Optional[Union[Dict, List[Dict]]] = None,
**kwargs,
) -> str:
"""
Get complete URL for Search endpoint.
Args:
api_base: Base URL for the API
optional_params: Optional parameters for the request
data: Transformed request body from transform_search_request().
Some providers (e.g., Google PSE) use GET requests and need
the request body to construct query parameters in the URL.
Can be a dict or list of dicts depending on provider.
**kwargs: Additional keyword arguments
Returns:
Complete URL for the search endpoint
Note:
Override in provider-specific implementations.
"""
raise NotImplementedError("get_complete_url must be implemented by provider")
def transform_search_request(
self,
query: Union[str, List[str]],
optional_params: dict,
**kwargs,
) -> Union[Dict, List[Dict]]:
"""
Transform Search request to provider-specific format.
Override in provider-specific implementations.
Args:
query: Search query (string or list of strings)
optional_params: Optional parameters for the request
Returns:
Dict with request data
"""
raise NotImplementedError("transform_search_request must be implemented by provider")
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
**kwargs,
) -> SearchResponse:
"""
Transform provider-specific Search response to standard format.
Override in provider-specific implementations.
"""
raise NotImplementedError("transform_search_response must be implemented by provider")
def get_error_class(
self,
error_message: str,
status_code: int,
headers: dict,
) -> Exception:
"""Get appropriate error class for the provider."""
return BaseLLMException(
status_code=status_code,
message=error_message,
headers=headers,
)

View file

@ -1,6 +1,6 @@
import types
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Dict, Optional, TypedDict
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, TypedDict, Union
import httpx
@ -68,8 +68,10 @@ class BaseTextToSpeechConfig(ABC):
self,
model: str,
optional_params: Dict,
drop_params: bool,
) -> Dict:
voice: Optional[Union[str, Dict]] = None,
drop_params: bool = False,
kwargs: Dict = {},
) -> Tuple[Optional[str], Dict]:
"""
Map OpenAI TTS parameters to provider-specific parameters
"""

View file

@ -123,10 +123,23 @@ class CohereEmbeddingConfig:
"""
embeddings = response_json["embeddings"]
output_data = []
for idx, embedding in enumerate(embeddings):
output_data.append(
{"object": "embedding", "index": idx, "embedding": embedding}
)
is_embeddings_by_type = response_json.get("response_type") == "embeddings_by_type"
if is_embeddings_by_type:
for embedding_type in embeddings:
for idx, embedding in enumerate(embeddings[embedding_type]):
output_data.append(
{
"object": "embedding",
"index": idx,
"embedding": embedding,
"type": embedding_type,
}
)
else:
for idx, embedding in enumerate(embeddings):
output_data.append(
{"object": "embedding", "index": idx, "embedding": embedding}
)
model_response.object = "list"
model_response.data = output_data
model_response.model = model

View file

@ -44,9 +44,8 @@ from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig, OCRResponse
from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig
from litellm.llms.base_llm.rerank.transformation import BaseRerankConfig
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
from litellm.llms.base_llm.text_to_speech.transformation import (
BaseTextToSpeechConfig,
)
from litellm.llms.base_llm.search.transformation import BaseSearchConfig, SearchResponse
from litellm.llms.base_llm.text_to_speech.transformation import BaseTextToSpeechConfig
from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
@ -1309,8 +1308,10 @@ class BaseLLMHTTPHandler:
# Data is always a dict for Mistral OCR format
if not isinstance(transformed_result.data, dict):
raise ValueError(f"Expected dict data for OCR request, got {type(transformed_result.data)}")
raise ValueError(
f"Expected dict data for OCR request, got {type(transformed_result.data)}"
)
data = transformed_result.data
## LOGGING
@ -1373,8 +1374,10 @@ class BaseLLMHTTPHandler:
# Data is always a dict for Mistral OCR format
if not isinstance(transformed_result.data, dict):
raise ValueError(f"Expected dict data for OCR request, got {type(transformed_result.data)}")
raise ValueError(
f"Expected dict data for OCR request, got {type(transformed_result.data)}"
)
data = transformed_result.data
## LOGGING
@ -1545,6 +1548,193 @@ class BaseLLMHTTPHandler:
logging_obj=logging_obj,
)
def search(
self,
query: Union[str, List[str]],
optional_params: dict,
timeout: Union[float, httpx.Timeout],
logging_obj: LiteLLMLoggingObj,
api_key: Optional[str],
api_base: Optional[str],
custom_llm_provider: str,
client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None,
asearch: bool = False,
headers: Optional[Dict[str, Any]] = None,
provider_config: Optional[BaseSearchConfig] = None,
) -> Union[SearchResponse, Coroutine[Any, Any, SearchResponse]]:
"""
Sync Search handler.
"""
if provider_config is None:
raise ValueError(
f"No provider config found for provider: {custom_llm_provider}"
)
if asearch is True:
return self.async_search(
query=query,
optional_params=optional_params,
timeout=timeout,
logging_obj=logging_obj,
api_key=api_key,
api_base=api_base,
custom_llm_provider=custom_llm_provider,
client=client,
headers=headers,
provider_config=provider_config,
)
# Validate environment and get headers
headers = provider_config.validate_environment(
api_key=api_key,
api_base=api_base,
headers=headers or {},
)
# Transform the request
data = provider_config.transform_search_request(
query=query,
optional_params=optional_params,
)
# Get complete URL (pass data for providers that need request body for URL construction)
complete_url = provider_config.get_complete_url(
api_base=api_base,
optional_params=optional_params,
data=data,
)
## LOGGING
logging_obj.pre_call(
input=query if isinstance(query, str) else str(query),
api_key=api_key,
additional_args={
"complete_input_dict": data,
"api_base": complete_url,
"headers": headers,
},
)
if client is None or not isinstance(client, HTTPHandler):
client = _get_httpx_client()
# Check HTTP method from provider config
http_method = provider_config.get_http_method()
try:
if http_method == "GET":
# Make GET request (URL already contains query params from get_complete_url)
# Note: timeout is set on the client itself, not per-request for GET
response = client.get(
url=complete_url,
headers=headers,
)
else:
# Make POST request with JSON data
response = client.post(
url=complete_url,
headers=headers,
json=data,
timeout=timeout,
)
except Exception as e:
raise self._handle_error(e=e, provider_config=provider_config)
return provider_config.transform_search_response(
raw_response=response,
logging_obj=logging_obj,
)
async def async_search(
self,
query: Union[str, List[str]],
optional_params: dict,
timeout: Union[float, httpx.Timeout],
logging_obj: LiteLLMLoggingObj,
api_key: Optional[str],
api_base: Optional[str],
custom_llm_provider: str,
client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None,
headers: Optional[Dict[str, Any]] = None,
provider_config: Optional[BaseSearchConfig] = None,
) -> SearchResponse:
"""
Async Search handler.
"""
if provider_config is None:
raise ValueError(
f"No provider config found for provider: {custom_llm_provider}"
)
# Validate environment and get headers
headers = provider_config.validate_environment(
api_key=api_key,
api_base=api_base,
headers=headers or {},
)
# Transform the request first
data = provider_config.transform_search_request(
query=query,
optional_params=optional_params,
)
# Get complete URL (pass data for providers that need request body for URL construction)
complete_url = provider_config.get_complete_url(
api_base=api_base,
optional_params=optional_params,
data=data,
)
## LOGGING
logging_obj.pre_call(
input=query if isinstance(query, str) else str(query),
api_key=api_key,
additional_args={
"complete_input_dict": data,
"api_base": complete_url,
"headers": headers,
},
)
if client is None or not isinstance(client, AsyncHTTPHandler):
# For search providers, use special Search provider type
from litellm.types.llms.custom_http import httpxSpecialProvider
async_httpx_client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.Search
)
else:
async_httpx_client = client
# Check HTTP method from provider config
http_method = provider_config.get_http_method().upper()
try:
if http_method == "GET":
# Make async GET request (URL already contains query params from get_complete_url)
# Note: timeout is set on the client itself, not per-request for GET
response = await async_httpx_client.get(
url=complete_url,
headers=headers,
)
else:
# Make async POST request with JSON data
response = await async_httpx_client.post(
url=complete_url,
headers=headers,
json=data,
timeout=timeout,
)
except Exception as e:
raise self._handle_error(e=e, provider_config=provider_config)
return provider_config.transform_search_response(
raw_response=response,
logging_obj=logging_obj,
)
async def async_anthropic_messages_handler(
self,
model: str,
@ -3285,6 +3475,7 @@ class BaseLLMHTTPHandler:
BaseAnthropicMessagesConfig,
BaseBatchesConfig,
BaseOCRConfig,
BaseSearchConfig,
BaseTextToSpeechConfig,
"BasePassthroughConfig",
],

View file

@ -43,6 +43,7 @@ from litellm.types.llms.openai import (
ChatCompletionThinkingBlock,
ChatCompletionToolChoiceFunctionParam,
ChatCompletionToolChoiceObjectParam,
ChatCompletionToolParam,
)
from litellm.types.utils import (
ChatCompletionMessageToolCall,
@ -217,6 +218,21 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
databricks_tool = self.convert_anthropic_tool_to_databricks_tool(tool)
return databricks_tool
def remove_cache_control_flag_from_messages_and_tools(
self,
model: str, # allows overrides to selectively run this
messages: List[AllMessageValues],
tools: Optional[List["ChatCompletionToolParam"]] = None,
) -> Tuple[List[AllMessageValues], Optional[List["ChatCompletionToolParam"]]]:
"""
Override the parent class method to preserve cache_control for models on Databricks.
Databricks supports Anthropic-style cache control for Claude models.
Databricks ignores the cache_control flag with other models.
"""
# TODO: Think about how to best design the request transformation so that
# every request doesn't have to be transformed for to OpenAI and Anthropic request formats.
return messages, tools
def map_openai_params(
self,
non_default_params: dict,

View file

@ -0,0 +1,11 @@
"""
DataForSEO Search Module
This module provides search functionality using DataForSEO's SERP API.
DataForSEO offers comprehensive search engine data with high accuracy.
"""
from .transformation import DataForSEOSearchConfig
__all__ = ["DataForSEOSearchConfig"]

View file

@ -0,0 +1,209 @@
"""
Calls DataForSEO SERP API to search the web.
DataForSEO API Reference: https://docs.dataforseo.com/v3/serp/google/organic/live/advanced/?bash
"""
from typing import Any, Dict, List, Literal, Optional, Union
import httpx
from litellm.constants import DEFAULT_DATAFORSEO_LOCATION_CODE
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.search.transformation import (
BaseSearchConfig,
SearchResponse,
SearchResult,
)
from litellm.secret_managers.main import get_secret_str
class DataForSEOSearchConfig(BaseSearchConfig):
"""
Configuration for DataForSEO SERP API search.
DataForSEO uses HTTP Basic Auth with login:password credentials.
API endpoint: https://api.dataforseo.com/v3/serp/google/organic/live/advanced
"""
DATAFORSEO_API_BASE = "https://api.dataforseo.com/v3/serp/google/organic/live/advanced"
@staticmethod
def ui_friendly_name() -> str:
return "DataForSEO"
def get_http_method(self) -> Literal["GET", "POST"]:
"""
DataForSEO uses POST requests with JSON body.
"""
return "POST"
def validate_environment(
self,
headers: Dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
**kwargs,
) -> Dict:
"""
Validate DataForSEO environment and set up authentication.
DataForSEO uses HTTP Basic Auth with login:password format.
The credentials should be in DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD env vars,
or passed as api_key in "login:password" format.
"""
import base64
# Get login and password
login = get_secret_str("DATAFORSEO_LOGIN")
password = get_secret_str("DATAFORSEO_PASSWORD")
# If api_key is provided in "login:password" format, use it
if api_key and ":" in api_key:
login, password = api_key.split(":", 1)
if not login:
raise ValueError("DATAFORSEO_LOGIN is not set. Set `DATAFORSEO_LOGIN` environment variable or pass credentials in api_key parameter.")
if not password:
raise ValueError("DATAFORSEO_PASSWORD is not set. Set `DATAFORSEO_PASSWORD` environment variable or pass credentials in api_key parameter.")
# Create Basic Auth header
credentials = f"{login}:{password}"
encoded_credentials = base64.b64encode(credentials.encode()).decode()
headers["Authorization"] = f"Basic {encoded_credentials}"
headers["Content-Type"] = "application/json"
return headers
def get_complete_url(
self,
api_base: Optional[str],
optional_params: dict,
data: Optional[Union[Dict, List[Dict]]] = None,
**kwargs,
) -> str:
"""
Get complete URL for DataForSEO SERP API endpoint.
DataForSEO uses POST requests, so no query parameters in URL.
"""
return api_base or get_secret_str("DATAFORSEO_API_BASE") or self.DATAFORSEO_API_BASE
def transform_search_request(
self,
query: Union[str, List[str]],
optional_params: dict,
api_key: Optional[str] = None,
**kwargs,
) -> Union[Dict, List[Dict]]:
"""
Transform Search request to DataForSEO SERP API format.
Args:
query: Search query (string or list of strings). DataForSEO supports single string queries.
optional_params: Optional parameters for the request
- max_results: Maximum number of search results maps to `depth` (max 700)
- country: Country name maps to `location_name`
- search_domain_filter: Domain to filter results maps to `domain`
- Plus any DataForSEO-specific parameters (location_code, language_code, device, os, etc.)
api_key: DataForSEO credentials (login:password format)
Returns:
List[Dict]: Request body for DataForSEO API (array of task objects as required by API)
"""
# DataForSEO expects an array of task objects
task: Dict[str, Any] = {}
# Convert query to string if it's a list
if isinstance(query, list):
query = query[0] if query else ""
# Required field: keyword
task["keyword"] = query
# Map unified parameters to DataForSEO parameters
if "max_results" in optional_params and optional_params["max_results"]:
# DataForSEO uses 'depth' for number of results (max 700)
depth = min(int(optional_params["max_results"]), 700)
task["depth"] = depth
if "country" in optional_params and optional_params["country"]:
# DataForSEO uses location_code (e.g., 2840 for USA)
# For simplicity, we'll use location_name which accepts country names
task["location_name"] = optional_params["country"]
if "search_domain_filter" in optional_params and optional_params["search_domain_filter"]:
# DataForSEO uses 'domain' parameter to filter by domain
task["domain"] = optional_params["search_domain_filter"]
# Add defaults if not specified
if "language_code" not in task and "language_name" not in task:
task["language_code"] = "en"
# DataForSEO requires a location - use default from constants if not specified
if "location_code" not in task and "location_name" not in task:
task["location_code"] = DEFAULT_DATAFORSEO_LOCATION_CODE
# Pass through all other parameters as-is
for param, value in optional_params.items():
if param not in self.get_supported_perplexity_optional_params() and param not in task:
task[param] = value
# DataForSEO API expects an array of tasks
return [task]
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
**kwargs,
) -> SearchResponse:
"""
Transform DataForSEO SERP API response to LiteLLM unified SearchResponse format.
DataForSEO LiteLLM mappings:
- tasks[0].result[*].items[*].title SearchResult.title
- tasks[0].result[*].items[*].url SearchResult.url
- tasks[0].result[*].items[*].description SearchResult.snippet
- No date/last_updated fields in standard response (set to None)
Args:
raw_response: Raw httpx response from DataForSEO API
logging_obj: Logging object for tracking
Returns:
SearchResponse with standardized format
"""
response_json = raw_response.json()
# Transform results to SearchResult objects
results = []
# DataForSEO wraps results in tasks array
if "tasks" in response_json and len(response_json["tasks"]) > 0:
task = response_json["tasks"][0]
# Check if task was successful
if task.get("status_code") == 20000 and "result" in task:
# Result is an array, take first element
if len(task["result"]) > 0:
result = task["result"][0]
# Items contain the actual search results
for item in result.get("items", []):
# Only process organic search results
if item.get("type") == "organic":
search_result = SearchResult(
title=item.get("title", ""),
url=item.get("url", ""),
snippet=item.get("description", ""),
date=None, # DataForSEO doesn't provide date in standard response
last_updated=None,
)
results.append(search_result)
return SearchResponse(
results=results,
object="search",
)

View file

@ -0,0 +1,7 @@
"""
Exa AI Search API module.
"""
from litellm.llms.exa_ai.search.transformation import ExaAISearchConfig
__all__ = ["ExaAISearchConfig"]

View file

@ -0,0 +1,188 @@
"""
Calls Exa AI's /search endpoint to search the web.
Exa AI API Reference: https://docs.exa.ai/reference/search
"""
from typing import Dict, List, Optional, TypedDict, Union
import httpx
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.search.transformation import (
BaseSearchConfig,
SearchResponse,
SearchResult,
)
from litellm.secret_managers.main import get_secret_str
class _ExaAISearchRequestRequired(TypedDict):
"""Required fields for Exa AI Search API request."""
query: str # Required - search query
class ExaAISearchRequest(_ExaAISearchRequestRequired, total=False):
"""
Exa AI Search API request format.
Based on: https://docs.exa.ai/reference/search
"""
type: str # Optional - search type ('keyword', 'neural', 'fast', 'auto'), default 'auto'
category: str # Optional - data category ('company', 'research paper', 'news', 'pdf', 'github', 'tweet', 'personal site', 'linkedin profile', 'financial report')
userLocation: str # Optional - two-letter ISO country code
numResults: int # Optional - number of results (max 100), default 10
includeDomains: List[str] # Optional - list of domains to include
excludeDomains: List[str] # Optional - list of domains to exclude
startCrawlDate: str # Optional - crawl date filter (ISO 8601 format)
endCrawlDate: str # Optional - crawl date filter (ISO 8601 format)
startPublishedDate: str # Optional - published date filter (ISO 8601 format)
endPublishedDate: str # Optional - published date filter (ISO 8601 format)
includeText: List[str] # Optional - strings that must be present in webpage text
excludeText: List[str] # Optional - strings that must not be present in webpage text
context: Union[bool, dict] # Optional - format results for LLMs
moderation: bool # Optional - enable content moderation, default false
contents: dict # Optional - content retrieval options
class ExaAISearchConfig(BaseSearchConfig):
EXA_AI_API_BASE = "https://api.exa.ai"
@staticmethod
def ui_friendly_name() -> str:
return "Exa AI"
def validate_environment(
self,
headers: Dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
**kwargs,
) -> Dict:
"""
Validate environment and return headers.
"""
api_key = api_key or get_secret_str("EXA_API_KEY")
if not api_key:
raise ValueError("EXA_API_KEY is not set. Set `EXA_API_KEY` environment variable.")
headers["x-api-key"] = api_key
headers["Content-Type"] = "application/json"
return headers
def get_complete_url(
self,
api_base: Optional[str],
optional_params: dict,
data: Optional[Union[Dict, List[Dict]]] = None,
**kwargs,
) -> str:
"""
Get complete URL for Search endpoint.
"""
api_base = api_base or get_secret_str("EXA_API_BASE") or self.EXA_AI_API_BASE
# Append "/search" to the api base if it's not already there
if not api_base.endswith("/search"):
api_base = f"{api_base}/search"
return api_base
def transform_search_request(
self,
query: Union[str, List[str]],
optional_params: dict,
**kwargs,
) -> Dict:
"""
Transform Search request to Exa AI API format.
Transforms Perplexity unified spec parameters:
- query query (same)
- max_results numResults
- search_domain_filter includeDomains
- country userLocation
- max_tokens_per_page (not applicable, ignored)
All other Exa-specific parameters are passed through as-is.
Args:
query: Search query (string or list of strings). Exa AI only supports single string queries.
optional_params: Optional parameters for the request
Returns:
Dict with typed request data following ExaAISearchRequest spec
"""
if isinstance(query, list):
# Exa AI only supports single string queries, join with spaces
query = " ".join(query)
request_data: ExaAISearchRequest = {
"query": query,
}
# Transform Perplexity unified spec parameters to Exa format
if "max_results" in optional_params:
request_data["numResults"] = optional_params["max_results"]
if "search_domain_filter" in optional_params:
request_data["includeDomains"] = optional_params["search_domain_filter"]
if "country" in optional_params:
request_data["userLocation"] = optional_params["country"]
# Convert to dict before dynamic key assignments
result_data = dict(request_data)
# pass through all other parameters as-is
for param, value in optional_params.items():
if param not in self.get_supported_perplexity_optional_params() and param not in result_data:
result_data[param] = value
# By default, request text content if not explicitly specified
# Exa AI doesn't return content/text unless explicitly requested
if "contents" not in result_data:
result_data["contents"] = {"text": True}
return result_data
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
**kwargs,
) -> SearchResponse:
"""
Transform Exa AI API response to LiteLLM unified SearchResponse format.
Exa AI LiteLLM mappings:
- results[].title SearchResult.title
- results[].url SearchResult.url
- results[].text SearchResult.snippet
- results[].publishedDate SearchResult.date
- No last_updated field in Exa AI response (set to None)
Args:
raw_response: Raw httpx response from Exa AI API
logging_obj: Logging object for tracking
Returns:
SearchResponse with standardized format
"""
response_json = raw_response.json()
# Transform results to SearchResult objects
results = []
for result in response_json.get("results", []):
search_result = SearchResult(
title=result.get("title", ""),
url=result.get("url", ""),
snippet=result.get("text", ""), # Exa AI uses "text" for content
date=result.get("publishedDate"), # ISO 8601 datetime string
last_updated=None, # Exa AI doesn't provide last_updated in response
)
results.append(search_result)
return SearchResponse(
results=results,
object="search",
)

View file

@ -0,0 +1,8 @@
"""
Google Programmable Search Engine (PSE) API module.
"""
from litellm.llms.google_pse.search.transformation import GooglePSESearchConfig
__all__ = ["GooglePSESearchConfig"]

View file

@ -0,0 +1,242 @@
"""
Calls Google Programmable Search Engine (PSE) API to search the web.
Google PSE API Reference: https://developers.google.com/custom-search/v1/reference/rest/v1/cse/list
"""
from typing import Dict, List, Literal, Optional, TypedDict, Union
import httpx
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.search.transformation import (
BaseSearchConfig,
SearchResponse,
SearchResult,
)
from litellm.secret_managers.main import get_secret_str
class _GooglePSESearchRequestRequired(TypedDict):
"""Required fields for Google PSE Search API request."""
q: str # Required - search query
cx: str # Required - Programmable Search Engine ID
key: str # Required - API key
class GooglePSESearchRequest(_GooglePSESearchRequestRequired, total=False):
"""
Google Programmable Search Engine API request format.
Based on: https://developers.google.com/custom-search/v1/reference/rest/v1/cse/list
"""
num: int # Optional - number of results (1-10), default 10
start: int # Optional - index of first result (default 1)
cr: str # Optional - country restrict (e.g., 'countryUS', 'countryGB')
dateRestrict: str # Optional - restricts results by date (e.g., 'd[number]', 'w[number]', 'm[number]', 'y[number]')
exactTerms: str # Optional - phrase that all documents must contain
excludeTerms: str # Optional - word or phrase to exclude
fileType: str # Optional - file type to restrict results to
filter: str # Optional - controls duplicate content filtering ('0'=off, '1'=on)
gl: str # Optional - geolocation of end user (2-letter country code)
hq: str # Optional - append query terms to query
imgSize: str # Optional - returns images of specified size
imgType: str # Optional - returns images of specified type
linkSite: str # Optional - specifies all search results should contain a link to a URL
lr: str # Optional - language restrict (e.g., 'lang_en', 'lang_es')
orTerms: str # Optional - provides additional search terms
relatedSite: str # Optional - specifies all search results should be pages related to URL
rights: str # Optional - filters based on licensing
safe: str # Optional - search safety level ('active', 'off')
searchType: str # Optional - specifies search type ('image')
siteSearch: str # Optional - restricts results to URLs from specified site
siteSearchFilter: str # Optional - controls whether to include or exclude siteSearch ('e'=exclude, 'i'=include)
sort: str # Optional - sort expression
class GooglePSESearchConfig(BaseSearchConfig):
GOOGLE_PSE_API_BASE = "https://www.googleapis.com/customsearch/v1"
@staticmethod
def ui_friendly_name() -> str:
return "Google PSE"
def get_http_method(self) -> Literal["GET", "POST"]:
"""
Google PSE uses GET requests with query parameters.
"""
return "GET"
def validate_environment(
self,
headers: Dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
**kwargs,
) -> Dict:
"""
Validate environment and return headers.
Google PSE uses API key as a query parameter, not in headers.
This method is called but headers are not used for authentication.
"""
api_key = api_key or get_secret_str("GOOGLE_PSE_API_KEY")
if not api_key:
raise ValueError("GOOGLE_PSE_API_KEY is not set. Set `GOOGLE_PSE_API_KEY` environment variable.")
# Also check for search engine ID
search_engine_id = kwargs.get("search_engine_id") or get_secret_str("GOOGLE_PSE_ENGINE_ID")
if not search_engine_id:
raise ValueError("GOOGLE_PSE_ENGINE_ID is not set. Set `GOOGLE_PSE_ENGINE_ID` environment variable or pass `search_engine_id` parameter.")
headers["Content-Type"] = "application/json"
return headers
def get_complete_url(
self,
api_base: Optional[str],
optional_params: dict,
data: Optional[Union[Dict, List[Dict]]] = None,
**kwargs,
) -> str:
"""
Get complete URL for Search endpoint with query parameters.
Google PSE uses GET requests, so we build the full URL with query params here.
The transformed request body (data) contains the parameters needed for the URL.
"""
from urllib.parse import urlencode
api_base = api_base or get_secret_str("GOOGLE_PSE_API_BASE") or self.GOOGLE_PSE_API_BASE
# Build query parameters from the transformed request body
if data and isinstance(data, dict) and "_google_pse_params" in data:
params = data["_google_pse_params"]
query_string = urlencode(params)
return f"{api_base}?{query_string}"
return api_base
def transform_search_request(
self,
query: Union[str, List[str]],
optional_params: dict,
api_key: Optional[str] = None,
search_engine_id: Optional[str] = None,
**kwargs,
) -> Dict:
"""
Transform Search request to Google PSE API format.
Transforms Perplexity unified spec parameters:
- query q (same)
- max_results num
- search_domain_filter siteSearch
- country gl
- max_tokens_per_page (not applicable, ignored)
All other Google PSE-specific parameters are passed through as-is.
Args:
query: Search query (string or list of strings). Google PSE supports single string queries.
optional_params: Optional parameters for the request
api_key: Google API key
search_engine_id: Google Programmable Search Engine ID (cx parameter)
Returns:
Dict with typed request data following GooglePSESearchRequest spec
"""
if isinstance(query, list):
# Google PSE only supports single string queries
query = " ".join(query)
# Get API credentials
api_key = api_key or get_secret_str("GOOGLE_PSE_API_KEY")
search_engine_id = search_engine_id or get_secret_str("GOOGLE_PSE_ENGINE_ID")
if not api_key:
raise ValueError("GOOGLE_PSE_API_KEY is required")
if not search_engine_id:
raise ValueError("GOOGLE_PSE_ENGINE_ID is required")
request_data: GooglePSESearchRequest = {
"q": query,
"cx": search_engine_id,
"key": api_key,
}
# Transform unified spec parameters to Google PSE format
if "max_results" in optional_params:
# Google PSE supports 1-10 results per request
num_results = min(optional_params["max_results"], 10)
request_data["num"] = num_results
if "search_domain_filter" in optional_params:
# Convert list to single domain (take first if multiple)
domains = optional_params["search_domain_filter"]
if isinstance(domains, list) and len(domains) > 0:
request_data["siteSearch"] = domains[0]
request_data["siteSearchFilter"] = "i" # include
elif isinstance(domains, str):
request_data["siteSearch"] = domains
request_data["siteSearchFilter"] = "i" # include
if "country" in optional_params:
# Google PSE uses 2-letter country codes for gl parameter
request_data["gl"] = optional_params["country"].upper()
# Convert to dict before dynamic key assignments
result_data = dict(request_data)
# Pass through all other parameters as-is
for param, value in optional_params.items():
if param not in self.get_supported_perplexity_optional_params() and param not in result_data:
result_data[param] = value
# Store params in special key for URL building (Google PSE uses GET not POST)
# Return a wrapper dict that stores params for get_complete_url to use
return {
"_google_pse_params": result_data,
}
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
**kwargs,
) -> SearchResponse:
"""
Transform Google PSE API response to LiteLLM unified SearchResponse format.
Google PSE LiteLLM mappings:
- items[].title SearchResult.title
- items[].link SearchResult.url
- items[].snippet SearchResult.snippet
- No date/last_updated fields in Google PSE response (set to None)
Args:
raw_response: Raw httpx response from Google PSE API
logging_obj: Logging object for tracking
Returns:
SearchResponse with standardized format
"""
response_json = raw_response.json()
# Transform results to SearchResult objects
results = []
for item in response_json.get("items", []):
search_result = SearchResult(
title=item.get("title", ""),
url=item.get("link", ""),
snippet=item.get("snippet", ""),
date=None, # Google PSE doesn't provide date in standard response
last_updated=None, # Google PSE doesn't provide last_updated in response
)
results.append(search_result)
return SearchResponse(
results=results,
object="search",
)

View file

@ -188,7 +188,7 @@ class OllamaChatConfig(BaseConfig):
if model.startswith("gpt-oss"):
optional_params["think"] = value
else:
optional_params["think"] = True
optional_params["think"] = value in {"low", "medium", "high"}
### FUNCTION CALLING LOGIC ###
if param == "tools":
## CHECK IF MODEL SUPPORTS TOOL CALLING ##

View file

@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, List, Optional,
from httpx._models import Headers, Response
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_str_from_messages,
)
@ -183,7 +184,7 @@ class OllamaConfig(BaseConfig):
if model.startswith("gpt-oss"):
optional_params["think"] = value
else:
optional_params["think"] = True
optional_params["think"] = value in {"low", "medium", "high"}
elif param == "response_format" and isinstance(value, dict):
if value["type"] == "json_object":
optional_params["format"] = "json"
@ -577,6 +578,18 @@ class OllamaTextCompletionResponseIterator(BaseModelResponseIterator):
]
)
else:
raise Exception(f"Unable to parse ollama chunk - {chunk}")
# In this case, 'thinking' is not present in the chunk, chunk["done"] is false,
# and chunk["response"] is falsy (None or empty string),
# but Ollama is just starting to stream, so it should be processed as a normal dict
return ModelResponseStream(
choices=[
StreamingChoices(
index=0,
delta=Delta(reasoning_content=""),
)
]
)
# raise Exception(f"Unable to parse ollama chunk - {chunk}")
except Exception as e:
verbose_proxy_logger.error(f"Unable to parse ollama chunk - {chunk}")
raise e

View file

@ -123,8 +123,6 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
try:
# Ensure required fields are present for ResponseReasoningItem
item_data = dict(item)
if "id" not in item_data:
item_data["id"] = f"rs_{hash(str(item_data))}"
if "summary" not in item_data:
item_data["summary"] = (
item_data.get("reasoning_content", "")[:100] + "..."

View file

@ -0,0 +1,7 @@
"""
Parallel AI Search API module.
"""
from litellm.llms.parallel_ai.search.transformation import ParallelAISearchConfig
__all__ = ["ParallelAISearchConfig"]

View file

@ -0,0 +1,201 @@
"""
Calls Parallel AI's /search endpoint to search the web.
Parallel AI API Reference: https://docs.parallel.ai/api-reference/search-and-extract-api-beta/search
"""
from typing import Dict, List, Optional, TypedDict, Union
import httpx
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.search.transformation import (
BaseSearchConfig,
SearchResponse,
SearchResult,
)
from litellm.secret_managers.main import get_secret_str
class _ParallelAISourcePolicy(TypedDict, total=False):
"""Source policy for Parallel AI search results."""
allowed_domains: List[str] # Optional - list of allowed domains
disallowed_domains: List[str] # Optional - list of disallowed domains
class _ParallelAISearchRequestRequired(TypedDict):
"""Required fields for Parallel AI Search API request."""
# Note: At least one of objective or search_queries must be provided
pass
class ParallelAISearchRequest(_ParallelAISearchRequestRequired, total=False):
"""
Parallel AI Search API request format.
Based on: https://docs.parallel.ai/api-reference/search-and-extract-api-beta/search
"""
objective: str # Optional - natural-language description of search goal
search_queries: List[str] # Optional - list of keyword search queries
processor: str # Optional - search processor ('base', 'pro'), default 'base'
max_results: int # Optional - maximum number of results, default 10
max_chars_per_result: int # Optional - max characters per result excerpt
source_policy: _ParallelAISourcePolicy # Optional - source policy for allowed/disallowed domains
class ParallelAISearchConfig(BaseSearchConfig):
PARALLEL_AI_API_BASE = "https://api.parallel.ai"
PARALLEL_HEADER_SEARCH_EXTRACT_VALUE = "search-extract-2025-10-10"
@staticmethod
def ui_friendly_name() -> str:
return "Parallel AI"
def validate_environment(
self,
headers: Dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
**kwargs,
) -> Dict:
"""
Validate environment and return headers.
"""
api_key = api_key or get_secret_str("PARALLEL_AI_API_KEY") or get_secret_str("PARALLEL_API_KEY")
if not api_key:
raise ValueError("PARALLEL_API_KEY is not set. Set `PARALLEL_API_KEY` environment variable.")
headers["x-api-key"] = api_key
headers["Content-Type"] = "application/json"
headers["parallel-beta"] = self.PARALLEL_HEADER_SEARCH_EXTRACT_VALUE
return headers
def get_complete_url(
self,
api_base: Optional[str],
optional_params: dict,
data: Optional[Union[Dict, List[Dict]]] = None,
**kwargs,
) -> str:
"""
Get complete URL for Search endpoint.
"""
api_base = api_base or get_secret_str("PARALLEL_AI_API_BASE") or self.PARALLEL_AI_API_BASE
# Parallel AI search endpoint is at /v1beta/search
if not api_base.endswith("/v1beta/search"):
if api_base.endswith("/"):
api_base = f"{api_base}v1beta/search"
else:
api_base = f"{api_base}/v1beta/search"
return api_base
def _transform_query_to_objective(self, query: Union[str, List[str]]) -> str:
"""
Transform query to objective.
"""
if isinstance(query, list):
return " ".join(query)
return query
def transform_search_request(
self,
query: Union[str, List[str]],
optional_params: dict,
**kwargs,
) -> Dict:
"""
Transform Search request to Parallel AI API format.
Args:
query: Search query (string or list of strings)
- If string: maps to `objective` (natural language)
- If list: maps to `search_queries` (keyword queries)
optional_params: Optional parameters for the request
- max_results: Maximum number of search results (default 10)
- search_domain_filter: List of domains to include -> maps to `source_policy.allowed_domains`
- exclude_domains: List of domains to exclude -> maps to `source_policy.disallowed_domains`
- processor: Search processor ('base', 'pro')
- max_chars_per_result: Max characters per result excerpt
Returns:
Dict with typed request data following ParallelAISearchRequest spec
"""
request_data: ParallelAISearchRequest = {}
# Map query to objective (string or list both become objective)
if isinstance(query, list):
request_data["objective"] = self._transform_query_to_objective(query)
else:
request_data["objective"] = query
# Transform Perplexity unified spec parameters to Parallel AI format
if "max_results" in optional_params:
request_data["max_results"] = optional_params["max_results"]
# Map domain filters to source_policy
source_policy: _ParallelAISourcePolicy = {}
if "search_domain_filter" in optional_params:
source_policy["allowed_domains"] = optional_params["search_domain_filter"]
if "exclude_domains" in optional_params:
source_policy["disallowed_domains"] = optional_params["exclude_domains"]
if source_policy:
request_data["source_policy"] = source_policy
# Convert to dict before dynamic key assignments
result_data = dict(request_data)
# pass through all other parameters as-is
for param, value in optional_params.items():
if param not in self.get_supported_perplexity_optional_params() and param not in result_data:
result_data[param] = value
return result_data
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
**kwargs,
) -> SearchResponse:
"""
Transform Parallel AI API response to LiteLLM unified SearchResponse format.
Parallel AI LiteLLM mappings:
- results[].title SearchResult.title
- results[].url SearchResult.url
- results[].excerpts (array) SearchResult.snippet (joined string)
- No date/last_updated fields in Parallel AI response (set to None)
Args:
raw_response: Raw httpx response from Parallel AI API
logging_obj: Logging object for tracking
Returns:
SearchResponse with standardized format
"""
response_json = raw_response.json()
# Transform results to SearchResult objects
results = []
for result in response_json.get("results", []):
# Join excerpts array into a single snippet string
excerpts = result.get("excerpts", [])
snippet = " ... ".join(excerpts) if excerpts else ""
search_result = SearchResult(
title=result.get("title", ""),
url=result.get("url", ""),
snippet=snippet,
date=None, # Parallel AI doesn't provide date in response
last_updated=None, # Parallel AI doesn't provide last_updated in response
)
results.append(search_result)
return SearchResponse(
results=results,
object="search",
)

View file

@ -0,0 +1,159 @@
"""
Calls Perplexity's /search endpoint to search the web.
"""
from typing import Dict, List, Optional, TypedDict, Union
import httpx
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.search.transformation import (
BaseSearchConfig,
SearchResponse,
SearchResult,
)
from litellm.secret_managers.main import get_secret_str
class _PerplexitySearchRequestRequired(TypedDict):
"""Required fields for Perplexity Search API request."""
query: Union[str, List[str]] # Required - search query or queries
class PerplexitySearchRequest(_PerplexitySearchRequestRequired, total=False):
"""
Perplexity Search API request format.
Based on: https://docs.perplexity.ai/api-reference/search-post
"""
max_results: int # Optional - maximum number of results (1-20), default 10
search_domain_filter: List[str] # Optional - list of domains to filter (max 20)
max_tokens_per_page: int # Optional - max tokens per page, default 1024
country: str # Optional - country code filter (e.g., 'US', 'GB', 'DE')
class PerplexitySearchConfig(BaseSearchConfig):
PERPLEXITY_API_BASE = "https://api.perplexity.ai"
@staticmethod
def ui_friendly_name() -> str:
return "Perplexity"
def validate_environment(
self,
headers: Dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
**kwargs,
) -> Dict:
"""
Validate environment and return headers.
"""
api_key = api_key or get_secret_str("PERPLEXITYAI_API_KEY")
if not api_key:
raise ValueError("PERPLEXITYAI_API_KEY is not set. Set `PERPLEXITYAI_API_KEY` environment variable.")
headers["Authorization"] = f"Bearer {api_key}"
headers["Content-Type"] = "application/json"
return headers
def get_complete_url(
self,
api_base: Optional[str],
optional_params: dict,
data: Optional[Union[Dict, List[Dict]]] = None,
**kwargs,
) -> str:
"""
Get complete URL for Search endpoint.
"""
api_base = api_base or get_secret_str("PERPLEXITY_API_BASE") or self.PERPLEXITY_API_BASE
# append "/search" to the api base if it's not already there
if not api_base.endswith("/search"):
api_base = f"{api_base}/search"
return api_base
def transform_search_request(
self,
query: Union[str, List[str]],
optional_params: dict,
**kwargs,
) -> Dict:
"""
Transform Search request to Perplexity API format.
Note: LiteLLM's native spec is the perplexity search spec.
There's no transformation needed for the request data.
https://docs.perplexity.ai/api-reference/search-post
Args:
query: Search query (string or list of strings)
optional_params: Optional parameters for the request
- max_results: Maximum number of search results (1-20)
- search_domain_filter: List of domains to filter (max 20)
- max_tokens_per_page: Max tokens per page (default 1024)
- country: Country code filter (e.g., 'US', 'GB', 'DE')
Returns:
Dict with typed request data following PerplexitySearchRequest spec
"""
request_data: PerplexitySearchRequest = {
"query": query,
}
# Add optional parameters following Perplexity API spec (only if not None)
max_results = optional_params.get("max_results")
if max_results is not None:
request_data["max_results"] = max_results
search_domain_filter = optional_params.get("search_domain_filter")
if search_domain_filter is not None:
request_data["search_domain_filter"] = search_domain_filter
max_tokens_per_page = optional_params.get("max_tokens_per_page")
if max_tokens_per_page is not None:
request_data["max_tokens_per_page"] = max_tokens_per_page
country = optional_params.get("country")
if country is not None:
request_data["country"] = country
return dict(request_data)
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
**kwargs,
) -> SearchResponse:
"""
Transform Perplexity API response to standard SearchResponse format.
Args:
raw_response: Raw httpx response from Perplexity API
logging_obj: Logging object for tracking
Returns:
SearchResponse with standardized format
"""
response_json = raw_response.json()
# Transform results to SearchResult objects
results = []
for result in response_json.get("results", []):
search_result = SearchResult(
title=result.get("title", ""),
url=result.get("url", ""),
snippet=result.get("snippet", ""),
date=result.get("date"),
last_updated=result.get("last_updated"),
)
results.append(search_result)
return SearchResponse(
results=results,
object="search",
)

View file

@ -0,0 +1,7 @@
"""
Tavily Search API module.
"""
from litellm.llms.tavily.search.transformation import TavilySearchConfig
__all__ = ["TavilySearchConfig"]

View file

@ -0,0 +1,187 @@
"""
Calls Tavily's /search endpoint to search the web.
Tavily API Reference: https://docs.tavily.com/documentation/api-reference/endpoint/search
"""
from typing import Dict, List, Optional, TypedDict, Union
import httpx
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.search.transformation import (
BaseSearchConfig,
SearchResponse,
SearchResult,
)
from litellm.secret_managers.main import get_secret_str
class _TavilySearchRequestRequired(TypedDict):
"""Required fields for Tavily Search API request."""
query: str # Required - search query
class TavilySearchRequest(_TavilySearchRequestRequired, total=False):
"""
Tavily Search API request format.
Based on: https://docs.tavily.com/documentation/api-reference/endpoint/search
"""
max_results: int # Optional - maximum number of results (0-20), default 5
include_domains: List[str] # Optional - list of domains to include (max 300)
exclude_domains: List[str] # Optional - list of domains to exclude (max 150)
topic: str # Optional - category of search ('general', 'news', 'finance'), default 'general'
search_depth: str # Optional - depth of search ('basic', 'advanced'), default 'basic'
include_answer: Union[bool, str] # Optional - include LLM-generated answer
include_raw_content: Union[bool, str] # Optional - include raw HTML content
include_images: bool # Optional - perform image search
include_image_descriptions: bool # Optional - add descriptions for images
include_favicon: bool # Optional - include favicon URL
time_range: str # Optional - time range filter ('day', 'week', 'month', 'year', 'd', 'w', 'm', 'y')
start_date: str # Optional - start date filter (YYYY-MM-DD)
end_date: str # Optional - end date filter (YYYY-MM-DD)
country: str # Optional - country code filter (e.g., 'US', 'GB', 'DE')
class TavilySearchConfig(BaseSearchConfig):
TAVILY_API_BASE = "https://api.tavily.com"
@staticmethod
def ui_friendly_name() -> str:
return "Tavily"
def validate_environment(
self,
headers: Dict,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
**kwargs,
) -> Dict:
"""
Validate environment and return headers.
"""
api_key = api_key or get_secret_str("TAVILY_API_KEY")
if not api_key:
raise ValueError("TAVILY_API_KEY is not set. Set `TAVILY_API_KEY` environment variable.")
headers["Authorization"] = f"Bearer {api_key}"
headers["Content-Type"] = "application/json"
return headers
def get_complete_url(
self,
api_base: Optional[str],
optional_params: dict,
data: Optional[Union[Dict, List[Dict]]] = None,
**kwargs,
) -> str:
"""
Get complete URL for Search endpoint.
"""
api_base = api_base or get_secret_str("TAVILY_API_BASE") or self.TAVILY_API_BASE
# Append "/search" to the api base if it's not already there
if not api_base.endswith("/search"):
api_base = f"{api_base}/search"
return api_base
def transform_search_request(
self,
query: Union[str, List[str]],
optional_params: dict,
**kwargs,
) -> Dict:
"""
Transform Search request to Tavily API format.
Args:
query: Search query (string or list of strings). Tavily only supports single string queries.
optional_params: Optional parameters for the request
- max_results: Maximum number of search results (0-20)
- search_domain_filter: List of domains to include (max 300) -> maps to `include_domains`
- exclude_domains: List of domains to exclude (max 150)
- topic: Category of search ('general', 'news', 'finance')
- search_depth: Depth of search ('basic', 'advanced')
- include_answer: Include LLM-generated answer (bool or 'basic', 'advanced')
- include_raw_content: Include raw HTML content (bool or 'markdown', 'text')
- include_images: Perform image search (bool)
- include_image_descriptions: Add descriptions for images (bool)
- include_favicon: Include favicon URL (bool)
- time_range: Time range filter ('day', 'week', 'month', 'year', 'd', 'w', 'm', 'y')
- start_date: Start date filter (YYYY-MM-DD)
- end_date: End date filter (YYYY-MM-DD)
- country: Country code filter (e.g., 'US', 'GB', 'DE')
Returns:
Dict with typed request data following TavilySearchRequest spec
"""
if isinstance(query, list):
# Tavily only supports single string queries
query = " ".join(query)
request_data: TavilySearchRequest = {
"query": query,
}
# Transform Perplexity unified spec parameters to Tavily format
if "max_results" in optional_params:
request_data["max_results"] = optional_params["max_results"]
if "search_domain_filter" in optional_params:
request_data["include_domains"] = optional_params["search_domain_filter"]
if "country" in optional_params:
# Tavily expects lowercase country names
request_data["country"] = optional_params["country"].lower()
# Convert to dict before dynamic key assignments
result_data = dict(request_data)
# pass through all other parameters as-is
for param, value in optional_params.items():
if param not in self.get_supported_perplexity_optional_params() and param not in result_data:
result_data[param] = value
return result_data
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
**kwargs,
) -> SearchResponse:
"""
Transform Tavily API response to LiteLLM unified SearchResponse format.
Tavily LiteLLM mappings:
- results[].title SearchResult.title
- results[].url SearchResult.url
- results[].content SearchResult.snippet
- No date/last_updated fields in Tavily response (set to None)
Args:
raw_response: Raw httpx response from Tavily API
logging_obj: Logging object for tracking
Returns:
SearchResponse with standardized format
"""
response_json = raw_response.json()
# Transform results to SearchResult objects
results = []
for result in response_json.get("results", []):
search_result = SearchResult(
title=result.get("title", ""),
url=result.get("url", ""),
snippet=result.get("content", ""), # Tavily uses "content" instead of "snippet"
date=None, # Tavily doesn't provide date in response
last_updated=None, # Tavily doesn't provide last_updated in response
)
results.append(search_result)
return SearchResponse(
results=results,
object="search",
)

View file

@ -1,3 +1,4 @@
from .transformation import VertexVectorStoreConfig
from .rag_api.transformation import VertexVectorStoreConfig
from .search_api.transformation import VertexSearchAPIVectorStoreConfig
__all__ = ["VertexVectorStoreConfig"]
__all__ = ["VertexVectorStoreConfig", "VertexSearchAPIVectorStoreConfig"]

View file

@ -0,0 +1,241 @@
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
import httpx
from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
from litellm.types.router import GenericLiteLLMParams
from litellm.types.vector_stores import (
VectorStoreCreateOptionalRequestParams,
VectorStoreCreateResponse,
VectorStoreResultContent,
VectorStoreSearchOptionalRequestParams,
VectorStoreSearchResponse,
VectorStoreSearchResult,
)
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
LiteLLMLoggingObj = _LiteLLMLoggingObj
else:
LiteLLMLoggingObj = Any
class VertexSearchAPIVectorStoreConfig(BaseVectorStoreConfig, VertexBase):
"""
Configuration for Vertex AI Search API Vector Store
This implementation uses the Vertex AI Search API for vector store operations.
"""
def __init__(self):
super().__init__()
def validate_environment(
self, headers: dict, litellm_params: Optional[GenericLiteLLMParams]
) -> dict:
"""
Validate and set up authentication for Vertex AI RAG API
"""
litellm_params = litellm_params or GenericLiteLLMParams()
# Get credentials and project info
vertex_credentials = self.get_vertex_ai_credentials(dict(litellm_params))
vertex_project = self.get_vertex_ai_project(dict(litellm_params))
# Get access token using the base class method
access_token, project_id = self._ensure_access_token(
credentials=vertex_credentials,
project_id=vertex_project,
custom_llm_provider="vertex_ai",
)
headers.update(
{
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
)
return headers
def get_complete_url(
self,
api_base: Optional[str],
litellm_params: dict,
) -> str:
"""
Get the Base endpoint for Vertex AI Search API
"""
vertex_location = self.get_vertex_ai_location(litellm_params)
vertex_project = self.get_vertex_ai_project(litellm_params)
engine_id = litellm_params.get("vector_store_id")
collection_id = (
litellm_params.get("vertex_collection_id") or "default_collection"
)
if api_base:
return api_base.rstrip("/")
# Vertex AI Search API endpoint for search
return (
f"https://discoveryengine.googleapis.com/v1/"
f"projects/{vertex_project}/locations/{vertex_location}/"
f"collections/{collection_id}/engines/{engine_id}/servingConfigs/default_config"
)
def transform_search_vector_store_request(
self,
vector_store_id: str,
query: Union[str, List[str]],
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
api_base: str,
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
) -> Tuple[str, Dict[str, Any]]:
"""
Transform search request for Vertex AI RAG API
"""
# Convert query to string if it's a list
if isinstance(query, list):
query = " ".join(query)
# Vertex AI RAG API endpoint for retrieving contexts
url = f"{api_base}:search"
# Construct full rag corpus path
# Build the request body for Vertex AI Search API
request_body = {"query": query, "pageSize": 10}
#########################################################
# Update logging object with details of the request
#########################################################
litellm_logging_obj.model_call_details["query"] = query
return url, request_body
def transform_search_vector_store_response(
self, response: httpx.Response, litellm_logging_obj: LiteLLMLoggingObj
) -> VectorStoreSearchResponse:
"""
Transform Vertex AI Search API response to standard vector store search response
Handles the format from Discovery Engine Search API which returns:
{
"results": [
{
"id": "...",
"document": {
"derivedStructData": {
"title": "...",
"link": "...",
"snippets": [...]
}
}
}
]
}
"""
try:
response_json = response.json()
# Extract results from Vertex AI Search API response
results = response_json.get("results", [])
# Transform results to standard format
search_results: List[VectorStoreSearchResult] = []
for result in results:
document = result.get("document", {})
derived_data = document.get("derivedStructData", {})
# Extract text content from snippets
snippets = derived_data.get("snippets", [])
text_content = ""
if snippets:
# Combine all snippets into one text
text_parts = [
snippet.get("snippet", snippet.get("htmlSnippet", ""))
for snippet in snippets
]
text_content = " ".join(text_parts)
# If no snippets, use title as fallback
if not text_content:
text_content = derived_data.get("title", "")
content = [
VectorStoreResultContent(
text=text_content,
type="text",
)
]
# Extract file/document information
document_link = derived_data.get("link", "")
document_title = derived_data.get("title", "")
document_id = result.get("id", "")
# Use link as file_id if available, otherwise use document ID
file_id = document_link if document_link else document_id
filename = document_title if document_title else "Unknown Document"
# Build attributes with available metadata
attributes = {
"document_id": document_id,
}
if document_link:
attributes["link"] = document_link
if document_title:
attributes["title"] = document_title
# Add display link if available
display_link = derived_data.get("displayLink", "")
if display_link:
attributes["displayLink"] = display_link
# Add formatted URL if available
formatted_url = derived_data.get("formattedUrl", "")
if formatted_url:
attributes["formattedUrl"] = formatted_url
# Note: Search API doesn't provide explicit scores in the response
# You can use the position/rank as an implicit score
score = 1.0 / (
float(search_results.__len__() + 1)
) # Decreasing score based on position
result_obj = VectorStoreSearchResult(
score=score,
content=content,
file_id=file_id,
filename=filename,
attributes=attributes,
)
search_results.append(result_obj)
return VectorStoreSearchResponse(
object="vector_store.search_results.page",
search_query=litellm_logging_obj.model_call_details.get("query", ""),
data=search_results,
)
except Exception as e:
raise self.get_error_class(
error_message=str(e),
status_code=response.status_code,
headers=response.headers,
)
def transform_create_vector_store_request(
self,
vector_store_create_optional_params: VectorStoreCreateOptionalRequestParams,
api_base: str,
) -> Tuple[str, Dict]:
raise NotImplementedError
def transform_create_vector_store_response(
self, response: httpx.Response
) -> VectorStoreCreateResponse:
raise NotImplementedError

View file

@ -5706,17 +5706,19 @@ def speech( # noqa: PLR0915
# Map OpenAI params to provider-specific params if config exists
if text_to_speech_provider_config is not None:
optional_params = text_to_speech_provider_config.map_openai_params(
voice, optional_params = text_to_speech_provider_config.map_openai_params(
model=model,
optional_params=optional_params,
voice=voice,
drop_params=False,
kwargs=kwargs,
)
logging_obj: Logging = cast(Logging, kwargs.get("litellm_logging_obj"))
logging_obj.update_environment_variables(
model=model,
user=user,
optional_params={},
optional_params=optional_params,
litellm_params={
"litellm_call_id": litellm_call_id,
"proxy_server_request": proxy_server_request,
@ -5960,6 +5962,7 @@ async def ahealth_check(
"rerank",
"realtime",
"responses",
"ocr",
]
] = "chat",
prompt: Optional[str] = None,
@ -6022,57 +6025,13 @@ async def ahealth_check(
litellm_logging_obj=litellm_logging_obj,
)
mode_handlers = {
"chat": lambda: litellm.acompletion(
**model_params,
),
"completion": lambda: litellm.atext_completion(
**_filter_model_params(model_params),
prompt=prompt or "test",
),
"embedding": lambda: litellm.aembedding(
**_filter_model_params(model_params),
input=input or ["test"],
),
"audio_speech": lambda: litellm.aspeech(
**{
**_filter_model_params(model_params),
**(
{"voice": "alloy"}
if "voice" not in _filter_model_params(model_params)
else {}
),
},
input=prompt or "test",
),
"audio_transcription": lambda: litellm.atranscription(
**_filter_model_params(model_params),
file=get_audio_file_for_health_check(),
),
"image_generation": lambda: litellm.aimage_generation(
**_filter_model_params(model_params),
prompt=prompt,
),
"rerank": lambda: litellm.arerank(
**_filter_model_params(model_params),
query=prompt or "",
documents=["my sample text"],
),
"realtime": lambda: _realtime_health_check(
model=model,
custom_llm_provider=custom_llm_provider,
api_base=model_params.get("api_base", None),
api_key=model_params.get("api_key", None),
api_version=model_params.get("api_version", None),
),
"batch": lambda: litellm.alist_batches(
**_filter_model_params(model_params),
),
"responses": lambda: litellm.aresponses(
**_filter_model_params(model_params),
input=prompt or "test",
),
}
mode_handlers = HealthCheckHelpers.get_mode_handlers(
model=model,
custom_llm_provider=custom_llm_provider,
model_params=model_params,
prompt=prompt,
input=input,
)
if mode in mode_handlers:
_response = await mode_handlers[mode]()

View file

@ -1,4 +1,44 @@
{
"sample_spec": {
"code_interpreter_cost_per_session": 0.0,
"computer_use_input_cost_per_1k_tokens": 0.0,
"computer_use_output_cost_per_1k_tokens": 0.0,
"deprecation_date": "date when the model becomes deprecated in the format YYYY-MM-DD",
"file_search_cost_per_1k_calls": 0.0,
"file_search_cost_per_gb_per_day": 0.0,
"input_cost_per_audio_token": 0.0,
"input_cost_per_token": 0.0,
"litellm_provider": "one of https://docs.litellm.ai/docs/providers",
"max_input_tokens": "max input tokens, if the provider specifies it. if not default to max_tokens",
"max_output_tokens": "max output tokens, if the provider specifies it. if not default to max_tokens",
"max_tokens": "LEGACY parameter. set to max_output_tokens if provider specifies it. IF not set to max_input_tokens, if provider specifies it.",
"mode": "one of: chat, embedding, completion, image_generation, audio_transcription, audio_speech, image_generation, moderation, rerank, search",
"output_cost_per_reasoning_token": 0.0,
"output_cost_per_token": 0.0,
"search_context_cost_per_query": {
"search_context_size_high": 0.0,
"search_context_size_low": 0.0,
"search_context_size_medium": 0.0
},
"supported_regions": [
"global",
"us-west-2",
"eu-west-1",
"ap-southeast-1",
"ap-northeast-1"
],
"supports_audio_input": true,
"supports_audio_output": true,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_vision": true,
"supports_web_search": true,
"vector_store_cost_per_gb_per_day": 0.0
},
"1024-x-1024/50-steps/bedrock/amazon.nova-canvas-v1:0": {
"litellm_provider": "bedrock",
"max_input_tokens": 2600,
@ -471,6 +511,26 @@
"supports_tool_choice": true,
"supports_vision": true
},
"anthropic.claude-3-7-sonnet-20240620-v1:0": {
"cache_creation_input_token_cost": 4.5e-06,
"cache_read_input_token_cost": 3.6e-07,
"input_cost_per_token": 3.6e-06,
"litellm_provider": "bedrock",
"max_input_tokens": 200000,
"max_output_tokens": 8192,
"max_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 1.8e-05,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"anthropic.claude-3-7-sonnet-20250219-v1:0": {
"cache_creation_input_token_cost": 3.75e-06,
"cache_read_input_token_cost": 3e-07,
@ -4284,6 +4344,26 @@
"mode": "chat",
"output_cost_per_token": 1.5e-06
},
"bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0": {
"cache_creation_input_token_cost": 4.5e-06,
"cache_read_input_token_cost": 3.6e-07,
"input_cost_per_token": 3.6e-06,
"litellm_provider": "bedrock",
"max_input_tokens": 200000,
"max_output_tokens": 8192,
"max_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 1.8e-05,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true
},
"bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0": {
"input_cost_per_token": 3.6e-06,
"litellm_provider": "bedrock",
@ -6380,6 +6460,11 @@
"source": "https://www.databricks.com/product/pricing/foundation-model-serving",
"supports_tool_choice": true
},
"dataforseo/search": {
"input_cost_per_query": 0.003,
"litellm_provider": "dataforseo",
"mode": "search"
},
"davinci-002": {
"input_cost_per_token": 2e-06,
"litellm_provider": "text-completion-openai",
@ -7720,6 +7805,31 @@
"output_cost_per_token": 0.0,
"output_vector_size": 2560
},
"exa_ai/search": {
"litellm_provider": "exa_ai",
"mode": "search",
"tiered_pricing": [
{
"input_cost_per_query": 5e-03,
"max_results_range": [
0,
25
]
},
{
"input_cost_per_query": 25e-03,
"max_results_range": [
26,
100
]
}
]
},
"perplexity/search": {
"input_cost_per_query": 5e-03,
"litellm_provider": "perplexity",
"mode": "search"
},
"elevenlabs/scribe_v1": {
"input_cost_per_second": 6.11e-05,
"litellm_provider": "elevenlabs",
@ -12131,6 +12241,11 @@
"video"
]
},
"google_pse/search": {
"input_cost_per_query": 0.005,
"litellm_provider": "google_pse",
"mode": "search"
},
"global.anthropic.claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 3.75e-06,
"cache_read_input_token_cost": 3e-07,
@ -18722,6 +18837,16 @@
"output_cost_per_token": 1.25e-07,
"source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models"
},
"parallel_ai/search": {
"input_cost_per_query": 0.004,
"litellm_provider": "parallel_ai",
"mode": "search"
},
"parallel_ai/search-pro": {
"input_cost_per_query": 0.009,
"litellm_provider": "parallel_ai",
"mode": "search"
},
"perplexity/codellama-34b-instruct": {
"input_cost_per_token": 3.5e-07,
"litellm_provider": "perplexity",
@ -19501,46 +19626,7 @@
"supports_reasoning": true,
"source": "https://cloud.sambanova.ai/plans/pricing"
},
"sample_spec": {
"code_interpreter_cost_per_session": 0.0,
"computer_use_input_cost_per_1k_tokens": 0.0,
"computer_use_output_cost_per_1k_tokens": 0.0,
"deprecation_date": "date when the model becomes deprecated in the format YYYY-MM-DD",
"file_search_cost_per_1k_calls": 0.0,
"file_search_cost_per_gb_per_day": 0.0,
"input_cost_per_audio_token": 0.0,
"input_cost_per_token": 0.0,
"litellm_provider": "one of https://docs.litellm.ai/docs/providers",
"max_input_tokens": "max input tokens, if the provider specifies it. if not default to max_tokens",
"max_output_tokens": "max output tokens, if the provider specifies it. if not default to max_tokens",
"max_tokens": "LEGACY parameter. set to max_output_tokens if provider specifies it. IF not set to max_input_tokens, if provider specifies it.",
"mode": "one of: chat, embedding, completion, image_generation, audio_transcription, audio_speech, image_generation, moderation, rerank",
"output_cost_per_reasoning_token": 0.0,
"output_cost_per_token": 0.0,
"search_context_cost_per_query": {
"search_context_size_high": 0.0,
"search_context_size_low": 0.0,
"search_context_size_medium": 0.0
},
"supported_regions": [
"global",
"us-west-2",
"eu-west-1",
"ap-southeast-1",
"ap-northeast-1"
],
"supports_audio_input": true,
"supports_audio_output": true,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_vision": true,
"supports_web_search": true,
"vector_store_cost_per_gb_per_day": 0.0
},
"snowflake/claude-3-5-sonnet": {
"litellm_provider": "snowflake",
"max_input_tokens": 18000,
@ -19771,6 +19857,16 @@
"mode": "image_generation",
"output_cost_per_pixel": 0.0
},
"tavily/search": {
"input_cost_per_query": 0.008,
"litellm_provider": "tavily",
"mode": "search"
},
"tavily/search-advanced": {
"input_cost_per_query": 0.016,
"litellm_provider": "tavily",
"mode": "search"
},
"text-bison": {
"input_cost_per_character": 2.5e-07,
"litellm_provider": "vertex_ai-text-models",

View file

@ -1,5 +1,5 @@
import json
from typing import Optional, Tuple
from typing import Optional
from urllib.parse import urlencode, urlparse, urlunparse
from fastapi import APIRouter, Form, HTTPException, Request
@ -19,32 +19,47 @@ router = APIRouter(
)
def encode_state_with_base_url(base_url: str, original_state: str) -> str:
def encode_state_with_base_url(
base_url: str,
original_state: str,
code_challenge: Optional[str] = None,
code_challenge_method: Optional[str] = None,
client_redirect_uri: Optional[str] = None,
) -> str:
"""
Encode the base_url and original state using encryption.
Encode the base_url, original state, and PKCE parameters using encryption.
Args:
base_url: The base URL to encode
original_state: The original state parameter
code_challenge: PKCE code challenge from client
code_challenge_method: PKCE code challenge method from client
client_redirect_uri: Original redirect_uri from client
Returns:
An encrypted string that encodes both values
An encrypted string that encodes all values
"""
state_data = {"base_url": base_url, "original_state": original_state}
state_data = {
"base_url": base_url,
"original_state": original_state,
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"client_redirect_uri": client_redirect_uri,
}
state_json = json.dumps(state_data, sort_keys=True)
encrypted_state = encrypt_value_helper(state_json)
return encrypted_state
def decode_state_hash(encrypted_state: str) -> Tuple[str, str]:
def decode_state_hash(encrypted_state: str) -> dict:
"""
Decode an encrypted state to retrieve the base_url and original state.
Decode an encrypted state to retrieve all OAuth session data.
Args:
encrypted_state: The encrypted string to decode
Returns:
A tuple of (base_url, original_state)
A dict containing base_url, original_state, and optional PKCE parameters
Raises:
Exception: If decryption fails or data is malformed
@ -54,7 +69,7 @@ def decode_state_hash(encrypted_state: str) -> Tuple[str, str]:
raise ValueError("Failed to decrypt state parameter")
state_data = json.loads(decrypted_json)
return state_data["base_url"], state_data["original_state"]
return state_data
@router.get("/{mcp_server_name}/authorize")
@ -65,8 +80,12 @@ async def authorize(
redirect_uri: str,
state: str = "",
mcp_server_name: Optional[str] = None,
code_challenge: Optional[str] = None,
code_challenge_method: Optional[str] = None,
response_type: Optional[str] = None,
scope: Optional[str] = None,
):
# Redirect to real GitHub OAuth
# Redirect to real OAuth provider with PKCE support
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
@ -90,15 +109,30 @@ async def authorize(
base_url = urlunparse(parsed._replace(query=""))
request_base_url = str(request.base_url).rstrip("/")
# Encode the base_url and original state in a unique hash
encoded_state = encode_state_with_base_url(base_url, state)
# Encode the base_url, original state, PKCE params, and client redirect_uri in encrypted state
encoded_state = encode_state_with_base_url(
base_url=base_url,
original_state=state,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
client_redirect_uri=redirect_uri,
)
# Build params for upstream OAuth provider
params = {
"client_id": mcp_server.client_id,
"redirect_uri": f"{request_base_url}/callback",
"scope": " ".join(mcp_server.scopes),
"scope": scope or " ".join(mcp_server.scopes),
"state": encoded_state,
"response_type": response_type or "code",
}
# Forward PKCE parameters if present
if code_challenge:
params["code_challenge"] = code_challenge
if code_challenge_method:
params["code_challenge_method"] = code_challenge_method
return RedirectResponse(f"{mcp_server.authorization_url}?{urlencode(params)}")
@ -110,15 +144,16 @@ async def token_endpoint(
redirect_uri: str = Form(None),
client_id: str = Form(...),
client_secret: str = Form(...),
code_verifier: str = Form(None),
):
"""
Accept the authorization code from Claude and exchange it for GitHub token.
Forward the GitHub token back to Claude in standard OAuth format.
Accept the authorization code from client and exchange it for OAuth token.
Supports PKCE flow by forwarding code_verifier to upstream provider.
1. Call the token endpoint
2. Store the user's PAT in the db - and generate a LiteLLM virtual key
2. Return the token
3. Return a virtual key in this response
1. Call the token endpoint with PKCE parameters
2. Store the user's token in the db - and generate a LiteLLM virtual key
3. Return the token
4. Return a virtual key in this response
"""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
@ -136,41 +171,60 @@ async def token_endpoint(
proxy_base_url = str(request.base_url).rstrip("/")
# Exchange code for real GitHub token
# Build token request data
token_data = {
"grant_type": "authorization_code",
"client_id": mcp_server.client_id,
"client_secret": mcp_server.client_secret,
"code": code,
"redirect_uri": f"{proxy_base_url}/callback",
}
# Forward PKCE code_verifier if present
if code_verifier:
token_data["code_verifier"] = code_verifier
# Exchange code for real OAuth token
async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check)
response = await async_client.post(
mcp_server.token_url,
headers={"Accept": "application/json"},
data={
"client_id": mcp_server.client_id,
"client_secret": mcp_server.client_secret,
"code": code,
"redirect_uri": f"{proxy_base_url}/callback",
},
data=token_data,
)
response.raise_for_status()
github_token = response.json()["access_token"]
token_response = response.json()
access_token = token_response["access_token"]
# Return to Claude in expected OAuth 2 format
# Return to client in expected OAuth 2 format
# Only include fields that have values
result = {
"access_token": access_token,
"token_type": token_response.get("token_type", "Bearer"),
"expires_in": token_response.get("expires_in", 3600),
}
### return a virtual key in this response
# Add optional fields only if they exist
if "refresh_token" in token_response and token_response["refresh_token"]:
result["refresh_token"] = token_response["refresh_token"]
if "scope" in token_response and token_response["scope"]:
result["scope"] = token_response["scope"]
return JSONResponse(
{"access_token": github_token, "token_type": "Bearer", "expires_in": 3600}
)
return JSONResponse(result)
@router.get("/callback")
async def callback(code: str, state: str):
try:
# Decode the state hash to get base_url and original state
base_url, original_state = decode_state_hash(state)
# Decode the state hash to get base_url, original state, and PKCE params
state_data = decode_state_hash(state)
base_url = state_data["base_url"]
original_state = state_data["original_state"]
# Exchange code for token with GitHub
# Forward code and original state back to client
params = {"code": code, "state": original_state}
# Forward token to Claude ephemeral endpoint
# Forward to client's callback endpoint
complete_returned_url = f"{base_url}?{urlencode(params)}"
return RedirectResponse(url=complete_returned_url, status_code=302)

View file

@ -1092,12 +1092,18 @@ class MCPServerManager:
GuardrailRaisedException: If guardrails block the call
HTTPException: If an HTTP error occurs
"""
# Get server-specific auth header if available
# Get server-specific auth header if available (case-insensitive)
# FIX: Added case-insensitive matching to handle auth header keys that may not match
# the exact case of server alias/name (e.g., '1litellmagcgateway' vs '1LiteLLMAGCGateway')
server_auth_header: Optional[Union[Dict[str, str], str]] = None
if mcp_server_auth_headers and mcp_server.alias:
server_auth_header = mcp_server_auth_headers.get(mcp_server.alias)
elif mcp_server_auth_headers and mcp_server.server_name:
server_auth_header = mcp_server_auth_headers.get(mcp_server.server_name)
if mcp_server_auth_headers:
# Normalize keys for case-insensitive lookup
normalized_headers = {k.lower(): v for k, v in mcp_server_auth_headers.items()}
if mcp_server.alias:
server_auth_header = normalized_headers.get(mcp_server.alias.lower())
if server_auth_header is None and mcp_server.server_name:
server_auth_header = normalized_headers.get(mcp_server.server_name.lower())
# Fall back to deprecated mcp_auth_header if no server-specific header found
if server_auth_header is None:

View file

@ -212,6 +212,7 @@ if MCP_AVAILABLE:
from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException
from litellm.proxy.proxy_server import add_litellm_data_to_request, proxy_config
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler
try:
data = await request.json()
@ -221,7 +222,23 @@ if MCP_AVAILABLE:
user_api_key_dict=user_api_key_dict,
proxy_config=proxy_config,
)
return await call_mcp_tool(**data)
# FIX: Extract MCP auth headers from request
# The UI sends bearer token in x-mcp-auth header and server-specific headers,
# but they weren't being extracted and passed to call_mcp_tool.
# This fix ensures auth headers are properly extracted from the HTTP request
# and passed through to the MCP server for authentication.
mcp_auth_header = MCPRequestHandler._get_mcp_auth_header_from_headers(request.headers)
mcp_server_auth_headers = MCPRequestHandler._get_mcp_server_auth_headers_from_headers(request.headers)
# Add extracted headers to data dict to pass to call_mcp_tool
if mcp_auth_header:
data["mcp_auth_header"] = mcp_auth_header
if mcp_server_auth_headers:
data["mcp_server_auth_headers"] = mcp_server_auth_headers
result = await call_mcp_tool(**data)
return result
except BlockedPiiEntityError as e:
verbose_logger.error(f"BlockedPiiEntityError in MCP tool call: {str(e)}")
raise HTTPException(

View file

@ -3,5 +3,34 @@ model_list:
litellm_params:
model: bedrock/global.anthropic.claude-sonnet-4-5-20250929-v1:0
litellm_settings:
callbacks: ["otel"]
vector_store_registry:
- vector_store_name: "vertex-ai-litellm-website-knowledgebase"
litellm_params:
vector_store_id: "test-litellm-app_1761094730750"
custom_llm_provider: "vertex_ai/search_api"
vertex_project: "test-litellm-app"
vertex_location: "us-central1"
vector_store_description: "Vertex AI vector store for the Litellm website knowledgebase"
vector_store_metadata:
source: "https://www.litellm.com/docs"
mcp_servers:
github_mcp:
url: "https://api.githubcopilot.com/mcp"
auth_type: oauth2
authorization_url: https://github.com/login/oauth/authorize
token_url: https://github.com/login/oauth/access_token
client_id: os.environ/GITHUB_OAUTH_CLIENT_ID
client_secret: os.environ/GITHUB_OAUTH_CLIENT_SECRET
scopes: ["public_repo", "user:email"]
general_settings:
pass_through_endpoints:
- path: "/fake-openai-proxy-10" # Route on LiteLLM Proxy
target: "https://webhook.site/74bbcc59-a61f-4028-81e2-9e06814e81fe" # Target endpoint
headers: # Headers to forward
Authorization: "bearer sk-1234"
content-type: application/json
accept: application/json
auth: true
include_subpath: true
cost_per_request: 0

View file

@ -323,6 +323,14 @@ class LiteLLMRoutes(enum.Enum):
"/v1/vector_stores",
"/vector_stores/{vector_store_id}/search",
"/v1/vector_stores/{vector_store_id}/search",
# search
"/search",
"/v1/search",
# OCR
"/ocr",
"/v1/ocr",
]
mapped_pass_through_routes = [
@ -1620,6 +1628,10 @@ class PassThroughGenericEndpoint(LiteLLMPydanticObjectBase):
default=0.0,
description="The USD cost per request to the target endpoint. This is used to calculate the cost of the request to the target endpoint.",
)
auth: bool = Field(
default=False,
description="Whether authentication is required for the pass-through endpoint. If True, requests to the endpoint will require a valid LiteLLM API key.",
)
class PassThroughEndpointResponse(LiteLLMPydanticObjectBase):

View file

@ -998,7 +998,7 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
)
# Check 4. Token Spend is under budget
if route in LiteLLMRoutes.llm_api_routes.value:
if RouteChecks.is_llm_api_route(route=route):
await _virtual_key_max_budget_check(
valid_token=valid_token,
proxy_logging_obj=proxy_logging_obj,

View file

@ -311,6 +311,7 @@ class ProxyBaseLLMRequestProcessing:
"avector_store_search",
"avector_store_create",
"aocr",
"asearch",
],
version: Optional[str] = None,
user_model: Optional[str] = None,
@ -402,6 +403,7 @@ class ProxyBaseLLMRequestProcessing:
"avector_store_search",
"avector_store_create",
"aocr",
"asearch",
],
proxy_logging_obj: ProxyLogging,
general_settings: dict,

View file

@ -809,6 +809,105 @@ def _get_list_element_options(field_annotation: Any) -> Optional[List[str]]:
return None
def _should_skip_optional_params(field_name: str, field_annotation: Any) -> bool:
"""Check if optional_params field should be skipped (not meaningfully overridden)."""
if field_name != "optional_params":
return False
if field_annotation is None:
return True
# Check if the annotation is still a generic TypeVar (not specialized)
if isinstance(field_annotation, TypeVar) or (
hasattr(field_annotation, "__origin__")
and field_annotation.__origin__ is TypeVar
):
return True
# Also skip if it's a generic type that wasn't specialized
if hasattr(field_annotation, "__name__") and field_annotation.__name__ in (
"T",
"TypeVar",
):
return True
# Handle Optional[T] where T is still a TypeVar
if hasattr(field_annotation, "__args__"):
non_none_args = [arg for arg in field_annotation.__args__ if arg is not type(None)]
if non_none_args and isinstance(non_none_args[0], TypeVar):
return True
return False
def _unwrap_optional_type(field_annotation: Any) -> Any:
"""Unwrap Optional types to get the actual type."""
if (
hasattr(field_annotation, "__origin__")
and field_annotation.__origin__ is Union
and hasattr(field_annotation, "__args__")
):
# For Optional[BaseModel], get the non-None type
args = field_annotation.__args__
non_none_args = [arg for arg in args if arg is not type(None)]
if non_none_args:
return non_none_args[0]
return field_annotation
def _build_field_dict(
field: Any,
field_annotation: Any,
description: str,
required: bool,
) -> Dict[str, Any]:
"""Build field dictionary for non-nested fields."""
# Determine the field type from annotation
field_type = _get_field_type_from_annotation(field_annotation)
# Check for custom UI type override
field_json_schema_extra = getattr(field, "json_schema_extra", {})
if field_json_schema_extra and "ui_type" in field_json_schema_extra:
field_type = field_json_schema_extra["ui_type"].value
elif field_json_schema_extra and "type" in field_json_schema_extra:
field_type = field_json_schema_extra["type"]
# Add the field to the dictionary
field_dict = {
"description": description,
"required": required,
"type": field_type,
}
# Extract options from type annotations
if field_type == "dict":
# For Dict[Literal[...], T] types, extract key options
dict_key_options = _get_dict_key_options(field_annotation)
if dict_key_options:
field_dict["dict_key_options"] = dict_key_options
# Extract value type for the dict values
dict_value_type = _get_dict_value_type(field_annotation)
field_dict["dict_value_type"] = dict_value_type
elif field_type == "array":
# For List[Literal[...]] types, extract element options
list_element_options = _get_list_element_options(field_annotation)
if list_element_options:
field_dict["options"] = list_element_options
field_dict["type"] = "multiselect"
# Add options if they exist in json_schema_extra (this takes precedence)
if field_json_schema_extra and "options" in field_json_schema_extra:
field_dict["options"] = field_json_schema_extra["options"]
# Add default value if it exists
if field.default is not None and field.default is not ...:
field_dict["default_value"] = field.default
return field_dict
def _extract_fields_recursive(
model: Type[BaseModel],
depth: int = 0,
@ -823,48 +922,21 @@ def _extract_fields_recursive(
fields = {}
for field_name, field in model.model_fields.items():
# Skip optional_params if it's not meaningfully overridden
if field_name == "optional_params":
field_annotation = field.annotation
if field_annotation is None:
continue
# Check if the annotation is still a generic TypeVar (not specialized)
if isinstance(field_annotation, TypeVar) or (
hasattr(field_annotation, "__origin__")
and field_annotation.__origin__ is TypeVar
):
# Skip this field as it's not meaningfully overridden
continue
# Also skip if it's a generic type that wasn't specialized
if hasattr(field_annotation, "__name__") and field_annotation.__name__ in (
"T",
"TypeVar",
):
continue
# Get field metadata
description = field.description or field_name
# Check if this field is required
required = field.is_required()
# Check if the field annotation is a BaseModel subclass
field_annotation = field.annotation
# Skip optional_params if it's not meaningfully overridden
if _should_skip_optional_params(field_name=field_name, field_annotation=field_annotation):
continue
# Handle Optional types and get the actual type
if field_annotation is None:
continue
if (
hasattr(field_annotation, "__origin__")
and field_annotation.__origin__ is Union
and hasattr(field_annotation, "__args__")
):
# For Optional[BaseModel], get the non-None type
args = field_annotation.__args__
non_none_args = [arg for arg in args if arg is not type(None)]
if non_none_args:
field_annotation = non_none_args[0]
field_annotation = _unwrap_optional_type(field_annotation=field_annotation)
# Get field metadata
description = field.description or field_name
required = field.is_required()
# Check if this is a BaseModel subclass
is_basemodel_subclass = (
@ -885,50 +957,12 @@ def _extract_fields_recursive(
"fields": nested_fields,
}
else:
# Determine the field type from annotation
field_type = _get_field_type_from_annotation(field_annotation)
# Check for custom UI type override
field_json_schema_extra = getattr(field, "json_schema_extra", {})
if field_json_schema_extra and "ui_type" in field_json_schema_extra:
field_type = field_json_schema_extra["ui_type"].value
elif field_json_schema_extra and "type" in field_json_schema_extra:
field_type = field_json_schema_extra["type"]
# Add the field to the dictionary
field_dict = {
"description": description,
"required": required,
"type": field_type,
}
# Extract options from type annotations
if field_type == "dict":
# For Dict[Literal[...], T] types, extract key options
dict_key_options = _get_dict_key_options(field_annotation)
if dict_key_options:
field_dict["dict_key_options"] = dict_key_options
# Extract value type for the dict values
dict_value_type = _get_dict_value_type(field_annotation)
field_dict["dict_value_type"] = dict_value_type
elif field_type == "array":
# For List[Literal[...]] types, extract element options
list_element_options = _get_list_element_options(field_annotation)
if list_element_options:
field_dict["options"] = list_element_options
field_dict["type"] = "multiselect"
# Add options if they exist in json_schema_extra (this takes precedence)
if field_json_schema_extra and "options" in field_json_schema_extra:
field_dict["options"] = field_json_schema_extra["options"]
# Add default value if it exists
if field.default is not None and field.default is not ...:
field_dict["default_value"] = field.default
fields[field_name] = field_dict
fields[field_name] = _build_field_dict(
field=field,
field_annotation=field_annotation,
description=description,
required=required,
)
return fields

View file

@ -0,0 +1,74 @@
"""Gray Swan Cygnal guardrail integration for LiteLLM."""
from typing import TYPE_CHECKING
from litellm.types.guardrails import SupportedGuardrailIntegrations
from .grayswan import (
GraySwanGuardrail,
GraySwanGuardrailAPIError,
GraySwanGuardrailMissingSecrets,
)
if TYPE_CHECKING:
from litellm.types.guardrails import Guardrail, LitellmParams
def initialize_guardrail(
litellm_params: "LitellmParams", guardrail: "Guardrail"
) -> GraySwanGuardrail:
import litellm
guardrail_name = guardrail.get("guardrail_name")
if not guardrail_name:
raise ValueError("Gray Swan guardrail requires a guardrail_name")
optional_params = getattr(litellm_params, "optional_params", None)
grayswan_guardrail = GraySwanGuardrail(
guardrail_name=guardrail_name,
api_key=litellm_params.api_key,
api_base=litellm_params.api_base,
on_flagged_action=_get_config_value(
litellm_params, optional_params, "on_flagged_action"
),
violation_threshold=_get_config_value(
litellm_params, optional_params, "violation_threshold"
),
reasoning_mode=_get_config_value(
litellm_params, optional_params, "reasoning_mode"
),
categories=_get_config_value(litellm_params, optional_params, "categories"),
policy_id=_get_config_value(litellm_params, optional_params, "policy_id"),
event_hook=litellm_params.mode,
default_on=litellm_params.default_on,
)
litellm.logging_callback_manager.add_litellm_callback(grayswan_guardrail)
return grayswan_guardrail
def _get_config_value(litellm_params, optional_params, attribute_name):
if optional_params is not None:
value = getattr(optional_params, attribute_name, None)
if value is not None:
return value
return getattr(litellm_params, attribute_name, None)
guardrail_initializer_registry = {
SupportedGuardrailIntegrations.GRAYSWAN.value: initialize_guardrail,
}
guardrail_class_registry = {
SupportedGuardrailIntegrations.GRAYSWAN.value: GraySwanGuardrail,
}
__all__ = [
"GraySwanGuardrail",
"GraySwanGuardrailAPIError",
"GraySwanGuardrailMissingSecrets",
"initialize_guardrail",
]

View file

@ -0,0 +1,365 @@
"""Gray Swan Cygnal guardrail integration."""
import os
from typing import Any, Dict, Literal, Optional, Union
from fastapi import HTTPException
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_guardrail import (
CustomGuardrail,
log_guardrail_information,
)
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.callback_utils import (
add_guardrail_to_applied_guardrails_header,
)
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import LLMResponseTypes
class GraySwanGuardrailMissingSecrets(Exception):
"""Raised when the Gray Swan API key is missing."""
class GraySwanGuardrailAPIError(Exception):
"""Raised when the Gray Swan API returns an error."""
class GraySwanGuardrail(CustomGuardrail):
"""
Guardrail that calls Gray Swan's Cygnal monitoring endpoint.
see: https://docs.grayswan.ai/cygnal/monitor-requests
"""
SUPPORTED_ON_FLAGGED_ACTIONS = {"block", "monitor"}
DEFAULT_ON_FLAGGED_ACTION = "monitor"
BASE_API_URL = "https://api.grayswan.ai"
MONITOR_PATH = "/cygnal/monitor"
SUPPORTED_REASONING_MODES = {"off", "hybrid", "thinking"}
def __init__(
self,
guardrail_name: Optional[str] = "grayswan",
api_key: Optional[str] = None,
api_base: Optional[str] = None,
on_flagged_action: Optional[str] = None,
violation_threshold: Optional[float] = None,
reasoning_mode: Optional[str] = None,
categories: Optional[Dict[str, str]] = None,
policy_id: Optional[str] = None,
**kwargs: Any,
) -> None:
self.async_handler = get_async_httpx_client(
llm_provider=httpxSpecialProvider.GuardrailCallback
)
api_key_value = api_key or os.getenv("GRAYSWAN_API_KEY")
if not api_key_value:
raise GraySwanGuardrailMissingSecrets(
"Gray Swan API key missing. Set `GRAYSWAN_API_KEY` or pass `api_key`."
)
self.api_key: str = api_key_value
base = api_base or os.getenv("GRAYSWAN_API_BASE") or self.BASE_API_URL
self.api_base = base.rstrip("/")
self.monitor_url = f"{self.api_base}{self.MONITOR_PATH}"
action = on_flagged_action
if action and action.lower() in self.SUPPORTED_ON_FLAGGED_ACTIONS:
self.on_flagged_action = action.lower()
else:
if action:
verbose_proxy_logger.warning(
"Gray Swan Guardrail: Unsupported on_flagged_action '%s', defaulting to '%s'.",
action,
self.DEFAULT_ON_FLAGGED_ACTION,
)
self.on_flagged_action = self.DEFAULT_ON_FLAGGED_ACTION
self.violation_threshold = self._resolve_threshold(violation_threshold)
self.reasoning_mode = self._resolve_reasoning_mode(reasoning_mode)
self.categories = categories
self.policy_id = policy_id
supported_event_hooks = [
GuardrailEventHooks.pre_call,
GuardrailEventHooks.during_call,
GuardrailEventHooks.post_call,
]
super().__init__(
guardrail_name=guardrail_name,
supported_event_hooks=supported_event_hooks,
**kwargs,
)
# ------------------------------------------------------------------
# Guardrail hook entry points
# ------------------------------------------------------------------
@log_guardrail_information
async def async_pre_call_hook(
self,
user_api_key_dict: UserAPIKeyAuth,
cache,
data: dict,
call_type: Literal[
"completion",
"text_completion",
"embeddings",
"image_generation",
"moderation",
"audio_transcription",
"pass_through_endpoint",
"rerank",
"mcp_call",
"anthropic_messages",
],
) -> Optional[Union[Exception, str, dict]]:
if (
self.should_run_guardrail(
data=data, event_type=GuardrailEventHooks.pre_call
)
is not True
):
return data
verbose_proxy_logger.debug("Gray Swan Guardrail: pre-call hook triggered")
messages = data.get("messages")
if not messages:
verbose_proxy_logger.debug("Gray Swan Guardrail: No messages in data")
return data
dynamic_body = self.get_guardrail_dynamic_request_body_params(data) or {}
payload = self._prepare_payload(messages, dynamic_body)
if payload is None:
verbose_proxy_logger.debug(
"Gray Swan Guardrail: no content to scan; skipping request"
)
return data
await self.run_grayswan_guardrail(payload)
add_guardrail_to_applied_guardrails_header(
request_data=data, guardrail_name=self.guardrail_name
)
return data
@log_guardrail_information
async def async_moderation_hook(
self,
data: dict,
user_api_key_dict: UserAPIKeyAuth,
call_type: Literal[
"completion",
"embeddings",
"image_generation",
"moderation",
"audio_transcription",
"responses",
"mcp_call",
"anthropic_messages",
],
) -> Optional[Union[Exception, str, dict]]:
if (
self.should_run_guardrail(
data=data, event_type=GuardrailEventHooks.during_call
)
is not True
):
return data
verbose_proxy_logger.debug("GraySwan Guardrail: during-call hook triggered")
messages = data.get("messages")
if not messages:
verbose_proxy_logger.debug("Gray Swan Guardrail: No messages in data")
return data
dynamic_body = self.get_guardrail_dynamic_request_body_params(data) or {}
payload = self._prepare_payload(messages, dynamic_body)
if payload is None:
verbose_proxy_logger.debug(
"Gray Swan Guardrail: no content to scan; skipping request"
)
return data
await self.run_grayswan_guardrail(payload)
add_guardrail_to_applied_guardrails_header(
request_data=data, guardrail_name=self.guardrail_name
)
return data
@log_guardrail_information
async def async_post_call_success_hook(
self,
data: dict,
user_api_key_dict: UserAPIKeyAuth,
response: LLMResponseTypes,
) -> LLMResponseTypes:
if (
self.should_run_guardrail(
data=data, event_type=GuardrailEventHooks.post_call
)
is not True
):
return response
verbose_proxy_logger.debug("GraySwan Guardrail: post-call hook triggered")
response_dict = response.model_dump() if hasattr(response, "model_dump") else {}
response_messages = [
msg if isinstance(msg, dict) else msg.model_dump()
for choice in response_dict.get("choices", [])
if isinstance(choice, dict)
for msg in [choice.get("message")]
if msg is not None
]
if not response_messages:
verbose_proxy_logger.debug(
"Gray Swan Guardrail: no response messages detected; skipping post-call scan"
)
return response
dynamic_body = self.get_guardrail_dynamic_request_body_params(data) or {}
payload = self._prepare_payload(response_messages, dynamic_body)
if payload is None:
verbose_proxy_logger.debug(
"Gray Swan Guardrail: no content to scan; skipping request"
)
return response
await self.run_grayswan_guardrail(payload)
add_guardrail_to_applied_guardrails_header(
request_data=data, guardrail_name=self.guardrail_name
)
return response
# ------------------------------------------------------------------
# Core GraySwan interaction
# ------------------------------------------------------------------
async def run_grayswan_guardrail(self, payload: dict):
headers = self._prepare_headers()
try:
response = await self.async_handler.post(
url=self.monitor_url,
headers=headers,
json=payload,
timeout=30.0,
)
response.raise_for_status()
result = response.json()
verbose_proxy_logger.debug(
"Gray Swan Guardrail: monitor response %s", safe_dumps(result)
)
except HTTPException:
raise
except Exception as exc: # pragma: no cover - depends on HTTP client behaviour
verbose_proxy_logger.exception(
"Gray Swan Guardrail: API request failed: %s", exc
)
raise GraySwanGuardrailAPIError(str(exc)) from exc
self._process_grayswan_response(result)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _prepare_headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"grayswan-api-key": self.api_key,
}
def _prepare_payload(
self, messages: list[dict], dynamic_body: dict
) -> Optional[Dict[str, Any]]:
payload: Dict[str, Any] = {}
payload["messages"] = messages
categories = dynamic_body.get("categories") or self.categories
if categories:
payload["categories"] = categories
policy_id = dynamic_body.get("policy_id") or self.policy_id
if policy_id:
payload["policy_id"] = policy_id
reasoning_mode = dynamic_body.get("reasoning_mode") or self.reasoning_mode
if reasoning_mode:
payload["reasoning_mode"] = reasoning_mode
return payload
def _process_grayswan_response(self, response_json: Dict[str, Any]) -> None:
violation_score = float(response_json.get("violation", 0.0) or 0.0)
violated_rules = response_json.get("violated_rules", [])
mutation_detected = response_json.get("mutation")
ipi_detected = response_json.get("ipi")
flagged = violation_score >= self.violation_threshold
if not flagged:
verbose_proxy_logger.debug(
"Gray Swan Guardrail: request passed (score=%s, rules=%s)",
violation_score,
violated_rules,
)
return
verbose_proxy_logger.warning(
"Gray Swan Guardrail: violation score %.3f exceeds threshold %.3f",
violation_score,
self.violation_threshold,
)
if self.on_flagged_action == "block":
raise HTTPException(
status_code=400,
detail={
"error": "Blocked by Gray Swan Guardrail",
"violation": violation_score,
"violated_rules": violated_rules,
"mutation": mutation_detected,
"ipi": ipi_detected,
},
)
def _resolve_threshold(self, threshold: Optional[float]) -> float:
if threshold is not None:
return min(max(threshold, 0.0), 1.0)
return 0.5
def _resolve_reasoning_mode(self, candidate: Optional[str]) -> Optional[str]:
if candidate is None:
return None
normalised = candidate.strip().lower()
if normalised in self.SUPPORTED_REASONING_MODES:
return normalised
verbose_proxy_logger.warning(
"Gray Swan Guardrail: ignoring unsupported reasoning_mode '%s'",
candidate,
)
return None
@staticmethod
def get_config_model():
from litellm.types.proxy.guardrails.guardrail_hooks.grayswan import (
GraySwanGuardrailConfigModel,
)
return GraySwanGuardrailConfigModel

View file

@ -138,6 +138,7 @@ def _update_litellm_params_for_health_check(
- gets a short `messages` param for health check
- updates the `model` param with the `health_check_model` if it exists Doc: https://docs.litellm.ai/docs/proxy/health#wildcard-routes
- updates the `voice` param with the `health_check_voice` for `audio_speech` mode if it exists Doc: https://docs.litellm.ai/docs/proxy/health#text-to-speech-models
- updates the `model` param with the Bedrock base model name if it is a Bedrock model
"""
litellm_params["messages"] = _get_random_llm_message()
_health_check_model = model_info.get("health_check_model", None)
@ -145,6 +146,9 @@ def _update_litellm_params_for_health_check(
litellm_params["model"] = _health_check_model
if model_info.get("mode", None) == "audio_speech":
litellm_params["voice"] = model_info.get("health_check_voice", "alloy")
if "bedrock" in litellm_params["model"]:
from litellm.llms.bedrock.common_utils import BedrockModelInfo
litellm_params["model"] = BedrockModelInfo.get_base_model(litellm_params["model"])
return litellm_params

View file

@ -620,7 +620,7 @@ async def shared_health_check_status_endpoint(
Returns information about Redis connectivity, lock status, and cache status.
"""
from litellm.proxy.proxy_server import use_shared_health_check, redis_usage_cache
from litellm.proxy.proxy_server import redis_usage_cache, use_shared_health_check
if not use_shared_health_check:
return {
@ -636,7 +636,9 @@ async def shared_health_check_status_endpoint(
}
try:
from litellm.proxy.health_check_utils.shared_health_check_manager import SharedHealthCheckManager
from litellm.proxy.health_check_utils.shared_health_check_manager import (
SharedHealthCheckManager,
)
shared_health_manager = SharedHealthCheckManager(
redis_cache=redis_usage_cache,
@ -910,6 +912,7 @@ async def test_model_connection(
"batch",
"rerank",
"realtime",
"ocr",
]
] = fastapi.Body("chat", description="The mode to test the model with"),
litellm_params: Dict = fastapi.Body(

View file

@ -17,6 +17,8 @@ from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.router import ModelGroupInfo
from litellm.utils import get_utc_datetime
from .rate_limiter_utils import convert_priority_to_percent
class DynamicRateLimiterCache:
"""
@ -99,6 +101,11 @@ class _PROXY_DynamicRateLimitHandler(CustomLogger):
- active_projects: int or null
"""
try:
# Get model info first for conversion
model_group_info: Optional[
ModelGroupInfo
] = self.llm_router.get_model_group_info(model_group=model)
weight: float = 1
if (
litellm.priority_reservation is None
@ -115,7 +122,8 @@ class _PROXY_DynamicRateLimitHandler(CustomLogger):
"PREMIUM FEATURE: Reserving tpm/rpm by priority is a premium feature. Please add a 'LITELLM_LICENSE' to your .env to enable this.\nGet a license: https://docs.litellm.ai/docs/proxy/enterprise."
)
else:
weight = litellm.priority_reservation[priority]
value = litellm.priority_reservation[priority]
weight = convert_priority_to_percent(value, model_group_info)
active_projects = await self.internal_usage_cache.async_get_cache(
model=model
@ -124,9 +132,6 @@ class _PROXY_DynamicRateLimitHandler(CustomLogger):
current_model_tpm,
current_model_rpm,
) = await self.llm_router.get_model_group_usage(model_group=model)
model_group_info: Optional[
ModelGroupInfo
] = self.llm_router.get_model_group_info(model_group=model)
total_model_tpm: Optional[int] = None
total_model_rpm: Optional[int] = None
if model_group_info is not None:

View file

@ -18,6 +18,7 @@ from litellm.proxy.hooks.parallel_request_limiter_v3 import (
RateLimitDescriptorRateLimitObject,
_PROXY_MaxParallelRequestsHandler_v3,
)
from litellm.proxy.hooks.rate_limiter_utils import convert_priority_to_percent
from litellm.proxy.utils import InternalUsageCache
from litellm.types.router import ModelGroupInfo
@ -48,7 +49,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
def update_variables(self, llm_router: Router):
self.llm_router = llm_router
def _get_priority_weight(self, priority: Optional[str]) -> float:
def _get_priority_weight(self, priority: Optional[str], model_info: Optional[ModelGroupInfo] = None) -> float:
"""Get the weight for a given priority from litellm.priority_reservation"""
weight: float = litellm.priority_reservation_settings.default_priority
if (
@ -64,19 +65,25 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
"PREMIUM FEATURE: Reserving tpm/rpm by priority is a premium feature. Please add a 'LITELLM_LICENSE' to your .env to enable this.\nGet a license: https://docs.litellm.ai/docs/proxy/enterprise."
)
else:
weight = litellm.priority_reservation[priority]
value = litellm.priority_reservation[priority]
weight = convert_priority_to_percent(value, model_info)
return weight
def _normalize_priority_weights(self) -> Dict[str, float]:
def _normalize_priority_weights(self, model_info: ModelGroupInfo) -> Dict[str, float]:
"""
Normalize priority weights if they sum to > 1.0
Handles over-allocation: {key_a: 0.60, key_b: 0.80} -> {key_a: 0.43, key_b: 0.57}
Converts absolute rpm/tpm values to percentages based on model capacity.
"""
if litellm.priority_reservation is None:
return {}
weights = dict(litellm.priority_reservation)
# Convert all values to percentages first
weights: Dict[str, float] = {}
for k, v in litellm.priority_reservation.items():
weights[k] = convert_priority_to_percent(v, model_info)
total_weight = sum(weights.values())
if total_weight > 1.0:
@ -93,6 +100,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
model: str,
priority: Optional[str],
normalized_weights: Dict[str, float],
model_info: Optional[ModelGroupInfo] = None,
) -> tuple[float, str]:
"""
Get priority weight and pool key for a given priority.
@ -104,6 +112,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
model: Model name
priority: Priority level (None for default)
normalized_weights: Pre-computed normalized weights
model_info: Model configuration (optional, for fallback conversion)
Returns:
tuple: (priority_weight, priority_key)
@ -117,7 +126,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
if has_explicit_priority and priority is not None:
# Explicit priority: get its specific allocation
priority_weight = normalized_weights.get(priority, self._get_priority_weight(priority))
priority_weight = normalized_weights.get(priority, self._get_priority_weight(priority, model_info))
# Use unique key per priority level
priority_key = f"{model}:{priority}"
else:
@ -232,11 +241,12 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
return descriptors
# Get normalized priority weight and pool key
normalized_weights = self._normalize_priority_weights()
normalized_weights = self._normalize_priority_weights(model_group_info)
priority_weight, priority_key = self._get_priority_allocation(
model=model,
priority=priority,
normalized_weights=normalized_weights,
model_info=model_group_info,
)
rate_limit_config: RateLimitDescriptorRateLimitObject = {}

View file

@ -0,0 +1,55 @@
"""
Shared utility functions for rate limiter hooks.
"""
from typing import Optional, Union
from litellm.types.router import ModelGroupInfo
from litellm.types.utils import PriorityReservationDict
def convert_priority_to_percent(
value: Union[float, PriorityReservationDict], model_info: Optional[ModelGroupInfo]
) -> float:
"""
Convert priority reservation value to percentage (0.0-1.0).
Supports three formats:
1. Plain float/int: 0.9 -> 0.9 (90%)
2. Dict with percent: {"type": "percent", "value": 0.9} -> 0.9
3. Dict with rpm: {"type": "rpm", "value": 900} -> 900/model_rpm
4. Dict with tpm: {"type": "tpm", "value": 900000} -> 900000/model_tpm
Args:
value: Priority value as float or dict with type/value keys
model_info: Model configuration containing rpm/tpm limits
Returns:
float: Percentage value between 0.0 and 1.0
"""
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, dict):
val_type = value.get("type", "percent")
val_num = value.get("value", 1.0)
if val_type == "percent":
return float(val_num)
elif (
val_type == "rpm"
and model_info
and model_info.rpm
and model_info.rpm > 0
):
return float(val_num) / model_info.rpm
elif (
val_type == "tpm"
and model_info
and model_info.tpm
and model_info.tpm > 0
):
return float(val_num) / model_info.tpm
# Fallback: treat as percent
return float(val_num)

View file

@ -545,12 +545,23 @@ async def handle_bedrock_passthrough_router_model(
request: Request,
request_body: dict,
llm_router: litellm.Router,
user_api_key_dict: UserAPIKeyAuth,
proxy_logging_obj,
general_settings: dict,
proxy_config,
select_data_generator,
user_model: Optional[str],
user_temperature: Optional[float],
user_request_timeout: Optional[float],
user_max_tokens: Optional[int],
user_api_base: Optional[str],
version: Optional[str],
) -> Union[Response, StreamingResponse]:
"""
Handle Bedrock passthrough for router models (models defined in config.yaml).
This helper delegates to llm_router.allm_passthrough_route for proper credential
and configuration management from the router.
Uses the same common processing path as non-router models to ensure
metadata and hooks are properly initialized.
Args:
model: The router model name (e.g., "aws/anthropic/bedrock-claude-3-5-sonnet-v1")
@ -558,10 +569,16 @@ async def handle_bedrock_passthrough_router_model(
request: The FastAPI request object
request_body: The parsed request body
llm_router: The LiteLLM router instance
user_api_key_dict: The user API key authentication dictionary
(additional args for common processing)
Returns:
Response or StreamingResponse depending on endpoint type
"""
from fastapi import Response as FastAPIResponse
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
# Detect streaming based on endpoint
is_streaming = any(action in endpoint for action in BEDROCK_STREAMING_ACTIONS)
@ -569,83 +586,45 @@ async def handle_bedrock_passthrough_router_model(
f"Bedrock router passthrough: model='{model}', endpoint='{endpoint}', streaming={is_streaming}"
)
# Call router passthrough
# Use the common processing path (same as non-router models)
# This ensures all metadata, hooks, and logging are properly initialized
data: Dict[str, Any] = {}
base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data)
data["model"] = model
data["method"] = request.method
data["endpoint"] = endpoint
data["data"] = request_body
data["custom_llm_provider"] = "bedrock"
# Use the common passthrough processing to handle metadata and hooks
# This also handles all response formatting (streaming/non-streaming) and exceptions
try:
result = await llm_router.allm_passthrough_route(
result = await base_llm_response_processor.base_passthrough_process_llm_request(
request=request,
fastapi_response=FastAPIResponse(),
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
llm_router=llm_router,
general_settings=general_settings,
proxy_config=proxy_config,
select_data_generator=select_data_generator,
model=model,
method=request.method,
endpoint=endpoint,
request_query_params=request.query_params,
request_headers=dict(request.headers),
stream=is_streaming,
content=None,
data=None,
files=None,
json=(
request_body
if request.headers.get("content-type") == "application/json"
else None
),
params=None,
headers=None,
cookies=None,
)
except httpx.HTTPStatusError as e:
# Handle HTTP errors from the provider by converting to HTTPException
error_body = await e.response.aread()
error_text = error_body.decode("utf-8")
raise HTTPException(
status_code=e.response.status_code,
detail={"error": error_text},
user_model=user_model,
user_temperature=user_temperature,
user_request_timeout=user_request_timeout,
user_max_tokens=user_max_tokens,
user_api_base=user_api_base,
version=version,
)
return result
except Exception as e:
from litellm.llms.base_llm.chat.transformation import BaseLLMException
# If it's a BaseLLMException (from non-HTTP errors), convert to HTTPException
if isinstance(e, BaseLLMException):
raise HTTPException(
status_code=e.status_code,
detail={"error": e.message},
)
# Re-raise any other exceptions
raise e
# Handle streaming response
if is_streaming:
import inspect
if inspect.isasyncgen(result):
# AsyncGenerator case
return StreamingResponse(
content=result,
status_code=200,
headers={"content-type": "application/vnd.amazon.eventstream"},
)
else:
# httpx.Response case
result = cast(httpx.Response, result)
return StreamingResponse(
content=result.aiter_bytes(),
status_code=result.status_code,
headers=HttpPassThroughEndpointHelpers.get_response_headers(
headers=result.headers,
custom_headers=None,
),
)
# Handle non-streaming response
result = cast(httpx.Response, result)
content = await result.aread()
return Response(
content=content,
status_code=result.status_code,
headers=HttpPassThroughEndpointHelpers.get_response_headers(
headers=result.headers,
custom_headers=None,
),
)
# Use common exception handling
raise await base_llm_response_processor._handle_llm_api_exception(
e=e,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
)
async def handle_bedrock_count_tokens(
@ -778,6 +757,7 @@ async def bedrock_llm_proxy_route(
)
# If router model, use dedicated router passthrough handler
# This uses the same common processing path as non-router models
if is_router_model and llm_router:
return await handle_bedrock_passthrough_router_model(
model=model,
@ -785,6 +765,17 @@ async def bedrock_llm_proxy_route(
request=request,
request_body=request_body,
llm_router=llm_router,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
general_settings=general_settings,
proxy_config=proxy_config,
select_data_generator=select_data_generator,
user_model=user_model,
user_temperature=user_temperature,
user_request_timeout=user_request_timeout,
user_max_tokens=user_max_tokens,
user_api_base=user_api_base,
version=version,
)
# Fall back to existing implementation for direct Bedrock models

View file

@ -9,12 +9,15 @@ model_list:
model: bedrock/us.anthropic.claude-3-5-sonnet-20240620-v1:0
aws_region_name: us-west-2
custom_llm_provider: bedrock
guardrails:
- guardrail_name: "bedrock-pre-guard"
# like MCPs/vector stores
search_tools:
- search_tool_name: litellm-search
litellm_params:
guardrail: bedrock # supported values: "aporia", "bedrock", "lakera"
mode: "pre_call"
guardrailIdentifier: ff6ujrregl1q
guardrailVersion: "DRAFT"
search_provider: perplexity
api_key: os.environ/PERPLEXITYAI_API_KEY
- search_tool_name: exa-search
litellm_params:
search_provider: exa_ai
api_key: os.environ/EXA_API_KEY

View file

@ -262,7 +262,9 @@ from litellm.proxy.management_endpoints.customer_endpoints import (
from litellm.proxy.management_endpoints.internal_user_endpoints import (
router as internal_user_router,
)
from litellm.proxy.management_endpoints.internal_user_endpoints import user_update
from litellm.proxy.management_endpoints.internal_user_endpoints import (
user_update,
)
from litellm.proxy.management_endpoints.key_management_endpoints import (
delete_verification_tokens,
duration_in_seconds,
@ -310,7 +312,9 @@ from litellm.proxy.ocr_endpoints.endpoints import router as ocr_router
from litellm.proxy.openai_files_endpoints.files_endpoints import (
router as openai_files_router,
)
from litellm.proxy.openai_files_endpoints.files_endpoints import set_files_config
from litellm.proxy.openai_files_endpoints.files_endpoints import (
set_files_config,
)
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
passthrough_endpoint_router,
)
@ -331,6 +335,10 @@ from litellm.proxy.public_endpoints import router as public_endpoints_router
from litellm.proxy.rerank_endpoints.endpoints import router as rerank_router
from litellm.proxy.response_api_endpoints.endpoints import router as response_router
from litellm.proxy.route_llm_request import route_request
from litellm.proxy.search_endpoints.endpoints import router as search_router
from litellm.proxy.search_endpoints.search_tool_management import (
router as search_tool_management_router,
)
from litellm.proxy.spend_tracking.cloudzero_endpoints import router as cloudzero_router
from litellm.proxy.spend_tracking.spend_management_endpoints import (
router as spend_management_router,
@ -392,9 +400,15 @@ from litellm.types.proxy.management_endpoints.ui_sso import (
LiteLLM_UpperboundKeyGenerateParams,
)
from litellm.types.realtime import RealtimeQueryParams
from litellm.types.router import DeploymentTypedDict
from litellm.types.router import (
DeploymentTypedDict,
)
from litellm.types.router import ModelInfo as RouterModelInfo
from litellm.types.router import RouterGeneralSettings, updateDeployment
from litellm.types.router import (
RouterGeneralSettings,
SearchToolTypedDict,
updateDeployment,
)
from litellm.types.scheduler import DefaultPriorities
from litellm.types.secret_managers.main import (
KeyManagementSettings,
@ -1870,6 +1884,45 @@ class ProxyConfig:
credential_list = [CredentialItem(**cred) for cred in credential_list_dict]
return credential_list
def parse_search_tools(self, config: dict) -> Optional[List[SearchToolTypedDict]]:
"""
Parse and validate search tools from config.
Loads environment variables and casts to SearchToolTypedDict.
Args:
config: Config dictionary containing search_tools
Returns:
List of validated SearchToolTypedDict or None if not configured
"""
search_tools_raw = config.get("search_tools", None)
if not search_tools_raw:
return None
search_tools_parsed: List[SearchToolTypedDict] = []
print( # noqa
"\033[32mLiteLLM: Proxy initialized with Search Tools:\033[0m"
) # noqa
for search_tool in search_tools_raw:
# Display loaded search tool
search_tool_name = search_tool.get("search_tool_name", "")
search_provider = search_tool.get("litellm_params", {}).get("search_provider", "")
print(f"\033[32m {search_tool_name} ({search_provider})\033[0m") # noqa
# Cast to SearchToolTypedDict for type safety
try:
search_tool_typed: SearchToolTypedDict = SearchToolTypedDict(**search_tool) # type: ignore
search_tools_parsed.append(search_tool_typed)
except Exception as e:
verbose_proxy_logger.error(
f"Error parsing search tool {search_tool_name}: {str(e)}"
)
continue
return search_tools_parsed if search_tools_parsed else None
def _load_environment_variables(self, config: dict):
## ENVIRONMENT VARIABLES
global premium_user
@ -2372,6 +2425,9 @@ class ProxyConfig:
assistant_settings["litellm_params"][k] = v
assistants_config = AssistantsTypedDict(**assistant_settings) # type: ignore
## SEARCH TOOLS SETTINGS
search_tools: Optional[List[SearchToolTypedDict]] = self.parse_search_tools(config)
## /fine_tuning/jobs endpoints config
finetuning_config = config.get("finetune_settings", None)
set_fine_tuning_config(config=finetuning_config)
@ -2391,10 +2447,11 @@ class ProxyConfig:
if router_settings and isinstance(router_settings, dict):
arg_spec = inspect.getfullargspec(litellm.Router)
# model list already set
# model list and search_tools already set
exclude_args = {
"self",
"model_list",
"search_tools",
}
available_args = [x for x in arg_spec.args if x not in exclude_args]
@ -2405,6 +2462,7 @@ class ProxyConfig:
router = litellm.Router(
**router_params,
assistants_config=assistants_config,
search_tools=search_tools,
router_general_settings=RouterGeneralSettings(
async_only_mode=True # only init async clients
),
@ -3267,6 +3325,9 @@ class ProxyConfig:
if self._should_load_db_object(object_type="prompts"):
await self._init_prompts_in_db(prisma_client=prisma_client)
if self._should_load_db_object(object_type="search_tools"):
await self._init_search_tools_in_db(prisma_client=prisma_client)
if self._should_load_db_object(object_type="model_cost_map"):
await self._check_and_reload_model_cost_map(prisma_client=prisma_client)
@ -3460,7 +3521,44 @@ class ProxyConfig:
str(e)
)
)
async def _init_search_tools_in_db(self, prisma_client: PrismaClient):
"""
Initialize search tools from database into the router on startup.
"""
global llm_router
from litellm.proxy.search_endpoints.search_tool_registry import SearchToolRegistry
from litellm.router_utils.search_api_router import SearchAPIRouter
try:
search_tools = await SearchToolRegistry.get_all_search_tools_from_db(prisma_client=prisma_client)
verbose_proxy_logger.info(
f"Loading {len(search_tools)} search tool(s) from database into router"
)
if llm_router is not None:
# Add search tools to the router
await SearchAPIRouter.update_router_search_tools(
router_instance=llm_router,
search_tools=search_tools
)
verbose_proxy_logger.info(
f"Successfully loaded {len(search_tools)} search tool(s) into router"
)
else:
verbose_proxy_logger.debug(
"Router not initialized yet, search tools will be added when router is created"
)
except Exception as e:
verbose_proxy_logger.exception(
"litellm.proxy.proxy_server.py::ProxyConfig:_init_search_tools_in_db - {}".format(
str(e)
)
)
async def _init_pass_through_endpoints_in_db(self):
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
initialize_pass_through_endpoints_in_db,
@ -9800,6 +9898,7 @@ app.include_router(batches_router)
app.include_router(public_endpoints_router)
app.include_router(rerank_router)
app.include_router(ocr_router)
app.include_router(search_router)
app.include_router(image_router)
app.include_router(fine_tuning_router)
app.include_router(vector_store_router)
@ -9823,6 +9922,7 @@ app.include_router(cloudzero_router)
app.include_router(caching_router)
app.include_router(analytics_router)
app.include_router(guardrails_router)
app.include_router(search_tool_management_router)
app.include_router(prompts_router)
app.include_router(callback_management_endpoints_router)
app.include_router(debugging_endpoints_router)

View file

@ -26,6 +26,7 @@ ROUTE_ENDPOINT_MAPPING = {
"aimage_edit": "/images/edits",
"acancel_responses": "/responses/{response_id}/cancel",
"aocr": "/ocr",
"asearch": "/search",
}
@ -100,6 +101,7 @@ async def route_request(
"avector_store_search",
"avector_store_create",
"aocr",
"asearch",
],
):
"""
@ -182,6 +184,7 @@ async def route_request(
"alist_input_items",
"avector_store_create",
"avector_store_search",
"asearch"
]:
# moderation endpoint does not require `model` parameter
return getattr(llm_router, f"{route_type}")(**data)

View file

@ -570,4 +570,14 @@ model LiteLLM_HealthCheckTable {
@@index([model_name])
@@index([checked_at])
@@index([status])
}
// Search Tools table for storing search tool configurations
model LiteLLM_SearchToolsTable {
search_tool_id String @id @default(uuid())
search_tool_name String @unique
litellm_params Json
search_tool_info Json?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}

View file

@ -0,0 +1,8 @@
# litellm/proxy/search_endpoints/__init__.py
from .search_tool_registry import SearchToolRegistry
__all__ = [
"SearchToolRegistry",
]

View file

@ -0,0 +1,162 @@
#### Search Endpoints #####
import orjson
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import ORJSONResponse
from litellm.proxy._types import *
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
router = APIRouter()
@router.post(
"/v1/search/{search_tool_name}",
dependencies=[Depends(user_api_key_auth)],
response_class=ORJSONResponse,
tags=["search"],
)
@router.post(
"/search/{search_tool_name}",
dependencies=[Depends(user_api_key_auth)],
response_class=ORJSONResponse,
tags=["search"],
)
@router.post(
"/v1/search",
dependencies=[Depends(user_api_key_auth)],
response_class=ORJSONResponse,
tags=["search"],
)
@router.post(
"/search",
dependencies=[Depends(user_api_key_auth)],
response_class=ORJSONResponse,
tags=["search"],
)
async def search(
request: Request,
fastapi_response: Response,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
search_tool_name: Optional[str] = None,
):
"""
Search endpoint for performing web searches.
Follows the Perplexity Search API spec:
https://docs.perplexity.ai/api-reference/search-post
The search_tool_name can be passed either:
1. In the URL path: /v1/search/{search_tool_name}
2. In the request body: {"search_tool_name": "..."}
Example with search_tool_name in URL (recommended - keeps body Perplexity-compatible):
```bash
curl -X POST "http://localhost:4000/v1/search/litellm-search" \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"query": "latest AI developments 2024",
"max_results": 5,
"search_domain_filter": ["arxiv.org", "nature.com"],
"country": "US"
}'
```
Example with search_tool_name in body:
```bash
curl -X POST "http://localhost:4000/v1/search" \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"search_tool_name": "litellm-search",
"query": "latest AI developments 2024",
"max_results": 5,
"search_domain_filter": ["arxiv.org", "nature.com"],
"country": "US"
}'
```
Request Body Parameters (when search_tool_name not in URL):
- search_tool_name (str, required if not in URL): Name of the search tool configured in router
- query (str or list[str], required): Search query
- max_results (int, optional): Maximum number of results (1-20), default 10
- search_domain_filter (list[str], optional): List of domains to filter (max 20)
- max_tokens_per_page (int, optional): Max tokens per page, default 1024
- country (str, optional): Country code filter (e.g., 'US', 'GB', 'DE')
When using URL path parameter, only Perplexity-compatible parameters are needed in body:
- query (str or list[str], required): Search query
- max_results (int, optional): Maximum number of results (1-20), default 10
- search_domain_filter (list[str], optional): List of domains to filter (max 20)
- max_tokens_per_page (int, optional): Max tokens per page, default 1024
- country (str, optional): Country code filter (e.g., 'US', 'GB', 'DE')
Response follows Perplexity Search API format:
```json
{
"object": "search",
"results": [
{
"title": "Result title",
"url": "https://example.com",
"snippet": "Result snippet...",
"date": "2024-01-01",
"last_updated": "2024-01-01"
}
]
}
```
"""
from litellm.proxy.proxy_server import (
general_settings,
llm_router,
proxy_config,
proxy_logging_obj,
select_data_generator,
user_api_base,
user_max_tokens,
user_model,
user_request_timeout,
user_temperature,
version,
)
# Read request body
body = await request.body()
data = orjson.loads(body)
# If search_tool_name is provided in URL path, use it (takes precedence over body)
if search_tool_name is not None:
data["search_tool_name"] = search_tool_name
# Process request using ProxyBaseLLMRequestProcessing
processor = ProxyBaseLLMRequestProcessing(data=data)
try:
return await processor.base_process_llm_request(
request=request,
fastapi_response=fastapi_response,
user_api_key_dict=user_api_key_dict,
route_type="asearch",
proxy_logging_obj=proxy_logging_obj,
llm_router=llm_router,
general_settings=general_settings,
proxy_config=proxy_config,
select_data_generator=select_data_generator,
model=None,
user_model=user_model,
user_temperature=user_temperature,
user_request_timeout=user_request_timeout,
user_max_tokens=user_max_tokens,
user_api_base=user_api_base,
version=version,
)
except Exception as e:
raise await processor._handle_llm_api_exception(
e=e,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
version=version,
)

View file

@ -0,0 +1,559 @@
"""
CRUD ENDPOINTS FOR SEARCH TOOLS
"""
from datetime import datetime
from typing import Any, Dict, List, Union
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from litellm._logging import verbose_proxy_logger
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.search_endpoints.search_tool_registry import SearchToolRegistry
from litellm.types.search import (
ListSearchToolsResponse,
SearchTool,
SearchToolInfoResponse,
)
from litellm.types.utils import SearchProviders
#### SEARCH TOOLS ENDPOINTS ####
router = APIRouter()
SEARCH_TOOL_REGISTRY = SearchToolRegistry()
def _convert_datetime_to_str(value: Union[datetime, str, None]) -> Union[str, None]:
"""
Convert datetime object to ISO format string.
Args:
value: datetime object, string, or None
Returns:
ISO format string or original value if already string or None
"""
if value is None:
return None
if isinstance(value, datetime):
return value.isoformat()
return value
@router.get(
"/search_tools/list",
tags=["Search Tools"],
dependencies=[Depends(user_api_key_auth)],
response_model=ListSearchToolsResponse,
)
async def list_search_tools():
"""
List all search tools that are available in the database.
Example Request:
```bash
curl -X GET "http://localhost:4000/search_tools/list" -H "Authorization: Bearer <your_api_key>"
```
Example Response:
```json
{
"search_tools": [
{
"search_tool_id": "123e4567-e89b-12d3-a456-426614174000",
"search_tool_name": "litellm-search",
"litellm_params": {
"search_provider": "perplexity",
"api_key": "sk-***",
"api_base": "https://api.perplexity.ai"
},
"search_tool_info": {
"description": "Perplexity search tool"
},
"created_at": "2023-11-09T12:34:56.789Z",
"updated_at": "2023-11-09T12:34:56.789Z"
}
]
}
```
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(status_code=500, detail="Prisma client not initialized")
try:
search_tools = await SEARCH_TOOL_REGISTRY.get_all_search_tools_from_db(
prisma_client=prisma_client
)
search_tool_configs: List[SearchToolInfoResponse] = []
for search_tool in search_tools:
search_tool_configs.append(
SearchToolInfoResponse(
search_tool_id=search_tool.get("search_tool_id"),
search_tool_name=search_tool.get("search_tool_name", ""),
litellm_params=dict(search_tool.get("litellm_params", {})),
search_tool_info=search_tool.get("search_tool_info"),
created_at=_convert_datetime_to_str(search_tool.get("created_at")),
updated_at=_convert_datetime_to_str(search_tool.get("updated_at")),
)
)
return ListSearchToolsResponse(search_tools=search_tool_configs)
except Exception as e:
verbose_proxy_logger.exception(f"Error getting search tools from db: {e}")
raise HTTPException(status_code=500, detail=str(e))
class CreateSearchToolRequest(BaseModel):
search_tool: SearchTool
@router.post(
"/search_tools",
tags=["Search Tools"],
dependencies=[Depends(user_api_key_auth)],
)
async def create_search_tool(request: CreateSearchToolRequest):
"""
Create a new search tool.
Example Request:
```bash
curl -X POST "http://localhost:4000/search_tools" \\
-H "Authorization: Bearer <your_api_key>" \\
-H "Content-Type: application/json" \\
-d '{
"search_tool": {
"search_tool_name": "litellm-search",
"litellm_params": {
"search_provider": "perplexity",
"api_key": "sk-..."
},
"search_tool_info": {
"description": "Perplexity search tool"
}
}
}'
```
Example Response:
```json
{
"search_tool_id": "123e4567-e89b-12d3-a456-426614174000",
"search_tool_name": "litellm-search",
"litellm_params": {
"search_provider": "perplexity",
"api_key": "sk-..."
},
"search_tool_info": {
"description": "Perplexity search tool"
},
"created_at": "2023-11-09T12:34:56.789Z",
"updated_at": "2023-11-09T12:34:56.789Z"
}
```
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(status_code=500, detail="Prisma client not initialized")
try:
result = await SEARCH_TOOL_REGISTRY.add_search_tool_to_db(
search_tool=request.search_tool, prisma_client=prisma_client
)
verbose_proxy_logger.debug(
f"Successfully added search tool '{result.get('search_tool_name')}' to database. "
f"Router will be updated by the cron job."
)
return result
except Exception as e:
verbose_proxy_logger.exception(f"Error adding search tool to db: {e}")
raise HTTPException(status_code=500, detail=str(e))
class UpdateSearchToolRequest(BaseModel):
search_tool: SearchTool
@router.put(
"/search_tools/{search_tool_id}",
tags=["Search Tools"],
dependencies=[Depends(user_api_key_auth)],
)
async def update_search_tool(search_tool_id: str, request: UpdateSearchToolRequest):
"""
Update an existing search tool.
Example Request:
```bash
curl -X PUT "http://localhost:4000/search_tools/123e4567-e89b-12d3-a456-426614174000" \\
-H "Authorization: Bearer <your_api_key>" \\
-H "Content-Type: application/json" \\
-d '{
"search_tool": {
"search_tool_name": "updated-search",
"litellm_params": {
"search_provider": "perplexity",
"api_key": "sk-new-key"
},
"search_tool_info": {
"description": "Updated search tool"
}
}
}'
```
Example Response:
```json
{
"search_tool_id": "123e4567-e89b-12d3-a456-426614174000",
"search_tool_name": "updated-search",
"litellm_params": {
"search_provider": "perplexity",
"api_key": "sk-new-key"
},
"search_tool_info": {
"description": "Updated search tool"
},
"created_at": "2023-11-09T12:34:56.789Z",
"updated_at": "2023-11-09T13:45:12.345Z"
}
```
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(status_code=500, detail="Prisma client not initialized")
try:
# Check if search tool exists
existing_tool = await SEARCH_TOOL_REGISTRY.get_search_tool_by_id_from_db(
search_tool_id=search_tool_id, prisma_client=prisma_client
)
if existing_tool is None:
raise HTTPException(
status_code=404,
detail=f"Search tool with ID {search_tool_id} not found",
)
result = await SEARCH_TOOL_REGISTRY.update_search_tool_in_db(
search_tool_id=search_tool_id,
search_tool=request.search_tool,
prisma_client=prisma_client,
)
verbose_proxy_logger.debug(
f"Successfully updated search tool '{result.get('search_tool_name')}' in database. "
f"Router will be updated by the cron job."
)
return result
except HTTPException as e:
raise e
except Exception as e:
verbose_proxy_logger.exception(f"Error updating search tool: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete(
"/search_tools/{search_tool_id}",
tags=["Search Tools"],
dependencies=[Depends(user_api_key_auth)],
)
async def delete_search_tool(search_tool_id: str):
"""
Delete a search tool.
Example Request:
```bash
curl -X DELETE "http://localhost:4000/search_tools/123e4567-e89b-12d3-a456-426614174000" \\
-H "Authorization: Bearer <your_api_key>"
```
Example Response:
```json
{
"message": "Search tool 123e4567-e89b-12d3-a456-426614174000 deleted successfully",
"search_tool_name": "litellm-search"
}
```
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(status_code=500, detail="Prisma client not initialized")
try:
# Check if search tool exists
existing_tool = await SEARCH_TOOL_REGISTRY.get_search_tool_by_id_from_db(
search_tool_id=search_tool_id, prisma_client=prisma_client
)
if existing_tool is None:
raise HTTPException(
status_code=404,
detail=f"Search tool with ID {search_tool_id} not found",
)
result = await SEARCH_TOOL_REGISTRY.delete_search_tool_from_db(
search_tool_id=search_tool_id, prisma_client=prisma_client
)
verbose_proxy_logger.debug(
"Successfully deleted search tool from database. "
"Router will be updated by the cron job."
)
return result
except HTTPException as e:
raise e
except Exception as e:
verbose_proxy_logger.exception(f"Error deleting search tool: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/search_tools/{search_tool_id}",
tags=["Search Tools"],
dependencies=[Depends(user_api_key_auth)],
)
async def get_search_tool_info(search_tool_id: str):
"""
Get detailed information about a specific search tool by ID.
Example Request:
```bash
curl -X GET "http://localhost:4000/search_tools/123e4567-e89b-12d3-a456-426614174000" \\
-H "Authorization: Bearer <your_api_key>"
```
Example Response:
```json
{
"search_tool_id": "123e4567-e89b-12d3-a456-426614174000",
"search_tool_name": "litellm-search",
"litellm_params": {
"search_provider": "perplexity",
"api_key": "sk-***"
},
"search_tool_info": {
"description": "Perplexity search tool"
},
"created_at": "2023-11-09T12:34:56.789Z",
"updated_at": "2023-11-09T12:34:56.789Z"
}
```
"""
from litellm.litellm_core_utils.litellm_logging import _get_masked_values
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(status_code=500, detail="Prisma client not initialized")
try:
result = await SEARCH_TOOL_REGISTRY.get_search_tool_by_id_from_db(
search_tool_id=search_tool_id, prisma_client=prisma_client
)
if result is None:
raise HTTPException(
status_code=404,
detail=f"Search tool with ID {search_tool_id} not found",
)
# Mask sensitive data
litellm_params_dict = dict(result.get("litellm_params", {}))
masked_litellm_params_dict = _get_masked_values(
litellm_params_dict,
unmasked_length=4,
number_of_asterisks=4,
)
return SearchToolInfoResponse(
search_tool_id=result.get("search_tool_id"),
search_tool_name=result.get("search_tool_name", ""),
litellm_params=masked_litellm_params_dict,
search_tool_info=result.get("search_tool_info"),
created_at=_convert_datetime_to_str(result.get("created_at")),
updated_at=_convert_datetime_to_str(result.get("updated_at")),
)
except HTTPException as e:
raise e
except Exception as e:
verbose_proxy_logger.exception(f"Error getting search tool info: {e}")
raise HTTPException(status_code=500, detail=str(e))
class TestSearchToolConnectionRequest(BaseModel):
litellm_params: Dict[str, Any]
@router.post(
"/search_tools/test_connection",
tags=["Search Tools"],
dependencies=[Depends(user_api_key_auth)],
)
async def test_search_tool_connection(request: TestSearchToolConnectionRequest):
"""
Test connection to a search provider with the given configuration.
Makes a simple test search query to verify the API key and configuration are valid.
Example Request:
```bash
curl -X POST "http://localhost:4000/search_tools/test_connection" \\
-H "Authorization: Bearer <your_api_key>" \\
-H "Content-Type: application/json" \\
-d '{
"litellm_params": {
"search_provider": "perplexity",
"api_key": "sk-..."
}
}'
```
Example Response (Success):
```json
{
"status": "success",
"message": "Successfully connected to perplexity search provider",
"test_query": "test",
"results_count": 5
}
```
Example Response (Failure):
```json
{
"status": "error",
"message": "Authentication failed: Invalid API key",
"error_type": "AuthenticationError"
}
```
"""
try:
from litellm.search import asearch
# Extract params from request
litellm_params = request.litellm_params
search_provider = litellm_params.get("search_provider")
api_key = litellm_params.get("api_key")
api_base = litellm_params.get("api_base")
if not search_provider:
raise HTTPException(
status_code=400,
detail="search_provider is required in litellm_params"
)
verbose_proxy_logger.debug(
f"Testing connection to search provider: {search_provider}"
)
# Make a simple test search query with max_results=1 to minimize cost
test_query = "test"
response = await asearch(
query=test_query,
search_provider=search_provider,
api_key=api_key,
api_base=api_base,
max_results=1, # Minimize results to reduce cost
timeout=10.0, # 10 second timeout for test
)
verbose_proxy_logger.debug(
f"Successfully tested connection to {search_provider} search provider"
)
return {
"status": "success",
"message": f"Successfully connected to {search_provider} search provider",
"test_query": test_query,
"results_count": len(response.results) if response and response.results else 0,
}
except Exception as e:
error_message = str(e)
error_type = type(e).__name__
verbose_proxy_logger.exception(
f"Failed to connect to search provider: {error_message}"
)
# Return error details in a structured format
return {
"status": "error",
"message": error_message,
"error_type": error_type,
}
@router.get(
"/search_tools/ui/available_providers",
tags=["Search Tools"],
dependencies=[Depends(user_api_key_auth)],
)
async def get_available_search_providers():
"""
Get the list of available search providers with their configuration fields.
Auto-discovers search providers and their UI-friendly names from transformation configs.
Example Request:
```bash
curl -X GET "http://localhost:4000/search_tools/ui/available_providers" \\
-H "Authorization: Bearer <your_api_key>"
```
Example Response:
```json
{
"providers": [
{
"provider_name": "perplexity",
"ui_friendly_name": "Perplexity"
},
{
"provider_name": "tavily",
"ui_friendly_name": "Tavily"
}
]
}
```
"""
try:
from litellm.utils import ProviderConfigManager
available_providers = []
# Auto-discover providers from SearchProviders enum
for provider in SearchProviders:
try:
# Get the config class for this provider
config = ProviderConfigManager.get_provider_search_config(provider=provider)
if config is not None:
# Get the UI-friendly name from the config class
ui_name = config.ui_friendly_name()
available_providers.append({
"provider_name": provider.value,
"ui_friendly_name": ui_name,
})
except Exception as e:
verbose_proxy_logger.debug(
f"Could not get config for search provider {provider.value}: {e}"
)
continue
return {"providers": available_providers}
except Exception as e:
verbose_proxy_logger.exception(f"Error getting available search providers: {e}")
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1,241 @@
"""
Search Tool Registry for managing search tool configurations.
"""
from datetime import datetime, timezone
from typing import List, Optional
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.proxy.utils import PrismaClient
from litellm.types.search import SearchTool
class SearchToolRegistry:
"""
Handles adding, removing, and getting search tools in DB + in memory.
"""
def __init__(self):
pass
@staticmethod
def _convert_prisma_to_dict(prisma_obj) -> dict:
"""
Convert Prisma result to dict with datetime objects as ISO format strings.
Args:
prisma_obj: Prisma model instance
Returns:
Dict with datetime fields converted to ISO strings
"""
result = dict(prisma_obj)
# Convert datetime objects to ISO format strings
if "created_at" in result and result["created_at"]:
result["created_at"] = result["created_at"].isoformat()
if "updated_at" in result and result["updated_at"]:
result["updated_at"] = result["updated_at"].isoformat()
return result
###########################################################
########### DB management helpers for search tools ########
###########################################################
async def add_search_tool_to_db(
self, search_tool: SearchTool, prisma_client: PrismaClient
):
"""
Add a search tool to the database.
Args:
search_tool: Search tool configuration
prisma_client: Prisma client instance
Returns:
Dict with created search tool data
"""
try:
search_tool_name = search_tool.get("search_tool_name")
litellm_params: str = safe_dumps(dict(search_tool.get("litellm_params", {})))
search_tool_info: str = safe_dumps(search_tool.get("search_tool_info", {}))
# Create search tool in DB
created_search_tool = await prisma_client.db.litellm_searchtoolstable.create(
data={
"search_tool_name": search_tool_name,
"litellm_params": litellm_params,
"search_tool_info": search_tool_info,
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
)
# Add search_tool_id to the returned search tool object
search_tool_dict = dict(search_tool)
search_tool_dict["search_tool_id"] = created_search_tool.search_tool_id
search_tool_dict["created_at"] = created_search_tool.created_at.isoformat()
search_tool_dict["updated_at"] = created_search_tool.updated_at.isoformat()
return search_tool_dict
except Exception as e:
verbose_proxy_logger.exception(f"Error adding search tool to DB: {str(e)}")
raise Exception(f"Error adding search tool to DB: {str(e)}")
async def delete_search_tool_from_db(
self, search_tool_id: str, prisma_client: PrismaClient
):
"""
Delete a search tool from the database.
Args:
search_tool_id: ID of search tool to delete
prisma_client: Prisma client instance
Returns:
Dict with success message
"""
try:
# Get search tool before deletion for response
existing_tool = await prisma_client.db.litellm_searchtoolstable.find_unique(
where={"search_tool_id": search_tool_id}
)
if not existing_tool:
raise Exception(f"Search tool with ID {search_tool_id} not found")
# Delete from DB
await prisma_client.db.litellm_searchtoolstable.delete(
where={"search_tool_id": search_tool_id}
)
return {
"message": f"Search tool {search_tool_id} deleted successfully",
"search_tool_name": existing_tool.search_tool_name,
}
except Exception as e:
verbose_proxy_logger.exception(f"Error deleting search tool from DB: {str(e)}")
raise Exception(f"Error deleting search tool from DB: {str(e)}")
async def update_search_tool_in_db(
self, search_tool_id: str, search_tool: SearchTool, prisma_client: PrismaClient
):
"""
Update a search tool in the database.
Args:
search_tool_id: ID of search tool to update
search_tool: Updated search tool configuration
prisma_client: Prisma client instance
Returns:
Dict with updated search tool data
"""
try:
search_tool_name = search_tool.get("search_tool_name")
litellm_params: str = safe_dumps(dict(search_tool.get("litellm_params", {})))
search_tool_info: str = safe_dumps(search_tool.get("search_tool_info", {}))
# Update in DB
updated_search_tool = await prisma_client.db.litellm_searchtoolstable.update(
where={"search_tool_id": search_tool_id},
data={
"search_tool_name": search_tool_name,
"litellm_params": litellm_params,
"search_tool_info": search_tool_info,
"updated_at": datetime.now(timezone.utc),
},
)
# Convert to dict with ISO formatted datetimes
return self._convert_prisma_to_dict(updated_search_tool)
except Exception as e:
verbose_proxy_logger.exception(f"Error updating search tool in DB: {str(e)}")
raise Exception(f"Error updating search tool in DB: {str(e)}")
@staticmethod
async def get_all_search_tools_from_db(
prisma_client: PrismaClient,
) -> List[SearchTool]:
"""
Get all search tools from the database.
Args:
prisma_client: Prisma client instance
Returns:
List of search tool configurations
"""
try:
search_tools_from_db = (
await prisma_client.db.litellm_searchtoolstable.find_many(
order={"created_at": "desc"},
)
)
search_tools: List[SearchTool] = []
for search_tool in search_tools_from_db:
# Convert Prisma result to dict with ISO formatted datetimes
search_tool_dict = SearchToolRegistry._convert_prisma_to_dict(search_tool)
search_tools.append(SearchTool(**search_tool_dict)) # type: ignore
return search_tools
except Exception as e:
verbose_proxy_logger.exception(f"Error getting search tools from DB: {str(e)}")
raise Exception(f"Error getting search tools from DB: {str(e)}")
async def get_search_tool_by_id_from_db(
self, search_tool_id: str, prisma_client: PrismaClient
) -> Optional[SearchTool]:
"""
Get a search tool by its ID from the database.
Args:
search_tool_id: ID of search tool to retrieve
prisma_client: Prisma client instance
Returns:
Search tool configuration or None if not found
"""
try:
search_tool = await prisma_client.db.litellm_searchtoolstable.find_unique(
where={"search_tool_id": search_tool_id}
)
if not search_tool:
return None
# Convert Prisma result to dict with ISO formatted datetimes
search_tool_dict = self._convert_prisma_to_dict(search_tool)
return SearchTool(**search_tool_dict) # type: ignore
except Exception as e:
verbose_proxy_logger.exception(f"Error getting search tool from DB: {str(e)}")
raise Exception(f"Error getting search tool from DB: {str(e)}")
async def get_search_tool_by_name_from_db(
self, search_tool_name: str, prisma_client: PrismaClient
) -> Optional[SearchTool]:
"""
Get a search tool by its name from the database.
Args:
search_tool_name: Name of search tool to retrieve
prisma_client: Prisma client instance
Returns:
Search tool configuration or None if not found
"""
try:
search_tool = await prisma_client.db.litellm_searchtoolstable.find_unique(
where={"search_tool_name": search_tool_name}
)
if not search_tool:
return None
# Convert Prisma result to dict with ISO formatted datetimes
search_tool_dict = self._convert_prisma_to_dict(search_tool)
return SearchTool(**search_tool_dict) # type: ignore
except Exception as e:
verbose_proxy_logger.exception(f"Error getting search tool from DB: {str(e)}")
raise Exception(f"Error getting search tool from DB: {str(e)}")

View file

@ -56,6 +56,6 @@ class ColdStorageHandler:
def _select_custom_logger_for_cold_storage(
self,
) -> Optional[_custom_logger_compatible_callbacks_literal]:
cold_storage_custom_logger: Optional[_custom_logger_compatible_callbacks_literal] = litellm.configured_cold_storage_logger
cold_storage_custom_logger: Optional[_custom_logger_compatible_callbacks_literal] = litellm.cold_storage_custom_logger
return cold_storage_custom_logger

View file

@ -3628,8 +3628,14 @@ def join_paths(base_path: str, route: str) -> str:
if not route:
return base_path
# Join with single slash
return f"{base_path}/{route}"
# Check if base_path already ends with the route to avoid duplication
if base_path.endswith(f"/{route}"):
final_path = base_path
else:
# Join with single slash
final_path = f"{base_path}/{route}"
return final_path
def get_custom_url(request_base_url: str, route: Optional[str] = None) -> str:

View file

@ -236,10 +236,10 @@ class ResponsesSessionHandler:
"""
Only check cold storage when both are true
1. `LITELLM_TRUNCATED_PAYLOAD_FIELD` is in the proxy server request dict
2. `litellm.configured_cold_storage_logger` is not None
2. `litellm.cold_storage_custom_logger` is not None
"""
from litellm.constants import LITELLM_TRUNCATED_PAYLOAD_FIELD
configured_cold_storage_custom_logger = litellm.configured_cold_storage_logger
configured_cold_storage_custom_logger = litellm.cold_storage_custom_logger
if configured_cold_storage_custom_logger is None:
return False
if proxy_server_request_dict is None:

View file

@ -140,6 +140,7 @@ from litellm.types.router import (
RouterRateLimitError,
RouterRateLimitErrorBasic,
RoutingStrategy,
SearchToolTypedDict,
)
from litellm.types.services import ServiceTypes
from litellm.types.utils import (
@ -204,6 +205,8 @@ class Router:
] = None,
## ASSISTANTS API ##
assistants_config: Optional[AssistantsTypedDict] = None,
## SEARCH API ##
search_tools: Optional[List[SearchToolTypedDict]] = None,
## CACHING ##
redis_url: Optional[str] = None,
redis_host: Optional[str] = None,
@ -363,6 +366,7 @@ class Router:
)
self.assistants_config = assistants_config
self.search_tools = search_tools or []
self.deployment_names: List = (
[]
) # names of models under litellm_params. ex. azure/chatgpt-v-2
@ -879,6 +883,13 @@ class Router:
self.aocr = self.factory_function(aocr, call_type="aocr")
self.ocr = self.factory_function(ocr, call_type="ocr")
# Search routes
#########################################################
from litellm.search import asearch, search
self.asearch = self.factory_function(asearch, call_type="asearch")
self.search = self.factory_function(search, call_type="search")
def validate_fallbacks(self, fallback_param: Optional[List]):
"""
Validate the fallbacks parameter.
@ -2705,6 +2716,37 @@ class Router:
self.fail_calls[model] += 1
raise e
async def _asearch_with_fallbacks(
self, original_function: Callable, **kwargs
):
"""
Helper function to make a search API call through the router with load balancing and fallbacks.
Reuses the router's retry/fallback infrastructure.
"""
from litellm.router_utils.search_api_router import SearchAPIRouter
return await SearchAPIRouter.async_search_with_fallbacks(
router_instance=self,
original_function=original_function,
**kwargs,
)
async def _asearch_with_fallbacks_helper(
self, model: str, original_generic_function: Callable, **kwargs
):
"""
Helper function for search API calls - selects a search tool and calls the original function.
Called by async_function_with_fallbacks for each retry attempt.
"""
from litellm.router_utils.search_api_router import SearchAPIRouter
return await SearchAPIRouter.async_search_with_fallbacks_helper(
router_instance=self,
model=model,
original_generic_function=original_generic_function,
**kwargs,
)
async def _ageneric_api_call_with_fallbacks(
self, model: str, original_function: Callable, **kwargs
):
@ -3580,6 +3622,8 @@ class Router:
"vector_store_create",
"aocr",
"ocr",
"asearch",
"search",
"aadapter_generate_content"
] = "assistants",
):
@ -3598,6 +3642,7 @@ class Router:
"vector_store_search",
"vector_store_create",
"ocr",
"search",
):
def sync_wrapper(
@ -3628,6 +3673,11 @@ class Router:
return await self._pass_through_moderation_endpoint_factory(
original_function=original_function, **kwargs
)
elif call_type in ("asearch", "search"):
return await self._asearch_with_fallbacks(
original_function=original_function,
**kwargs,
)
elif call_type in (
"anthropic_messages",
"aresponses",

View file

@ -0,0 +1,212 @@
"""
Router utilities for Search API integration.
Handles search tool selection, load balancing, and fallback logic for search requests.
"""
import asyncio
import random
import traceback
from functools import partial
from typing import Any, Callable
from litellm._logging import verbose_router_logger
class SearchAPIRouter:
"""
Static utility class for routing search API calls through the LiteLLM router.
Provides methods for search tool selection, load balancing, and fallback handling.
"""
@staticmethod
async def update_router_search_tools(router_instance: Any, search_tools: list):
"""
Update the router with search tools from the database.
This method is called by a cron job to sync search tools from DB to router.
Args:
router_instance: The Router instance to update
search_tools: List of search tool configurations from the database
"""
try:
from litellm.types.router import SearchToolTypedDict
verbose_router_logger.debug(f"Adding {len(search_tools)} search tools to router")
# Convert search tools to the format expected by the router
router_search_tools: list = []
for tool in search_tools:
# Create dict that matches SearchToolTypedDict structure
router_search_tool: SearchToolTypedDict = { # type: ignore
"search_tool_id": tool.get("search_tool_id"),
"search_tool_name": tool.get("search_tool_name"),
"litellm_params": tool.get("litellm_params", {}),
"search_tool_info": tool.get("search_tool_info"),
}
router_search_tools.append(router_search_tool)
# Update the router's search_tools list
router_instance.search_tools = router_search_tools
verbose_router_logger.info(
f"Successfully updated router with {len(router_search_tools)} search tool(s)"
)
except Exception as e:
verbose_router_logger.exception(
f"Error updating router with search tools: {str(e)}"
)
raise e
@staticmethod
def get_matching_search_tools(
router_instance: Any,
search_tool_name: str,
) -> list:
"""
Get all search tools matching the given name.
Args:
router_instance: The Router instance
search_tool_name: Name of the search tool to find
Returns:
List of matching search tool configurations
Raises:
ValueError: If no matching search tools are found
"""
matching_tools = [
tool for tool in router_instance.search_tools
if tool.get("search_tool_name") == search_tool_name
]
if not matching_tools:
raise ValueError(f"Search tool '{search_tool_name}' not found in router.search_tools")
return matching_tools
@staticmethod
async def async_search_with_fallbacks(
router_instance: Any,
original_function: Callable,
**kwargs,
):
"""
Helper function to make a search API call through the router with load balancing and fallbacks.
Reuses the router's retry/fallback infrastructure.
Args:
router_instance: The Router instance
original_function: The original litellm.asearch function
**kwargs: Search parameters including search_tool_name, query, etc.
Returns:
SearchResponse from the search API
"""
try:
search_tool_name = kwargs.get("search_tool_name", kwargs.get("model"))
if not search_tool_name:
raise ValueError("search_tool_name or model parameter is required for search")
# Set up kwargs for the fallback system
kwargs["model"] = search_tool_name # Use model field for compatibility with fallback system
kwargs["original_generic_function"] = original_function
# Bind router_instance to the helper method using partial
kwargs["original_function"] = partial(
SearchAPIRouter.async_search_with_fallbacks_helper,
router_instance=router_instance,
)
# Update kwargs before fallbacks (for logging, metadata, etc)
router_instance._update_kwargs_before_fallbacks(
model=search_tool_name, kwargs=kwargs, metadata_variable_name="litellm_metadata"
)
verbose_router_logger.debug(
f"Inside SearchAPIRouter.async_search_with_fallbacks() - search_tool_name: {search_tool_name}; kwargs: {kwargs}"
)
# Use the existing retry/fallback infrastructure
response = await router_instance.async_function_with_fallbacks(**kwargs)
return response
except Exception as e:
from litellm.router_utils.handle_error import send_llm_exception_alert
asyncio.create_task(
send_llm_exception_alert(
litellm_router_instance=router_instance,
request_kwargs=kwargs,
error_traceback_str=traceback.format_exc(),
original_exception=e,
)
)
raise e
@staticmethod
async def async_search_with_fallbacks_helper(
router_instance: Any,
model: str,
original_generic_function: Callable,
**kwargs,
):
"""
Helper function for search API calls - selects a search tool and calls the original function.
Called by async_function_with_fallbacks for each retry attempt.
Args:
router_instance: The Router instance
model: The search tool name (passed as model for compatibility)
original_generic_function: The original litellm.asearch function
**kwargs: Search parameters
Returns:
SearchResponse from the selected search provider
"""
search_tool_name = model # model field contains the search_tool_name
try:
# Find matching search tools
matching_tools = SearchAPIRouter.get_matching_search_tools(
router_instance=router_instance,
search_tool_name=search_tool_name,
)
# Simple random selection for load balancing across multiple providers with same name
# For search tools, we use simple random choice since they don't have TPM/RPM constraints
selected_tool = random.choice(matching_tools)
# Extract search provider and other params from litellm_params
litellm_params = selected_tool.get("litellm_params", {})
search_provider = litellm_params.get("search_provider")
api_key = litellm_params.get("api_key")
api_base = litellm_params.get("api_base")
if not search_provider:
raise ValueError(f"search_provider not found in litellm_params for search tool '{search_tool_name}'")
verbose_router_logger.debug(
f"Selected search tool with provider: {search_provider}"
)
# Call the original search function with the provider config
response = await original_generic_function(
search_provider=search_provider,
api_key=api_key,
api_base=api_base,
**kwargs,
)
return response
except Exception as e:
verbose_router_logger.error(
f"Error in SearchAPIRouter.async_search_with_fallbacks_helper for {search_tool_name}: {str(e)}"
)
raise e

View file

@ -0,0 +1,8 @@
"""
LiteLLM Search API module.
"""
from litellm.search.cost_calculator import search_provider_cost_per_query
from litellm.search.main import asearch, search
__all__ = ["search", "asearch", "search_provider_cost_per_query"]

View file

@ -0,0 +1,52 @@
"""
Cost calculation for search providers.
"""
from typing import Optional, Tuple
from litellm.utils import get_model_info
def search_provider_cost_per_query(
model: str,
custom_llm_provider: Optional[str] = None,
number_of_queries: int = 1,
optional_params: Optional[dict] = None,
) -> Tuple[float, float]:
"""
Calculate cost for search-only providers.
Returns (input_cost, output_cost) where input_cost = queries * cost_per_query
Supports tiered pricing based on max_results parameter.
Args:
model: Model name (e.g., "exa_ai/search", "tavily/search")
custom_llm_provider: Provider name (e.g., "exa_ai", "tavily")
number_of_queries: Number of search queries performed (default: 1)
optional_params: Optional parameters including max_results for tiered pricing
Returns:
Tuple of (input_cost, output_cost) where output_cost is always 0.0
"""
model_info = get_model_info(model=model, custom_llm_provider=custom_llm_provider)
# Check for tiered pricing (e.g., Exa AI based on max_results)
tiered_pricing = model_info.get("tiered_pricing")
if tiered_pricing and isinstance(tiered_pricing, list):
max_results = (optional_params or {}).get("max_results", 10) # default 10 results
cost_per_query = 0.0
for tier in tiered_pricing:
range_min, range_max = tier["max_results_range"]
if range_min <= max_results <= range_max:
cost_per_query = tier["input_cost_per_query"]
break
else:
# Fallback to highest tier if out of range
cost_per_query = tiered_pricing[-1]["input_cost_per_query"]
else:
# Simple flat rate
cost_per_query = float(model_info.get("input_cost_per_query") or 0.0)
total_cost = number_of_queries * cost_per_query
return (total_cost, 0.0) # (input_cost, output_cost)

325
litellm/search/main.py Normal file
View file

@ -0,0 +1,325 @@
"""
Main Search function for LiteLLM.
"""
import asyncio
import contextvars
from functools import partial
from typing import Any, Coroutine, Dict, List, Optional, Union
import httpx
import litellm
from litellm._logging import verbose_logger
from litellm.constants import request_timeout
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.search.transformation import BaseSearchConfig, SearchResponse
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.types.utils import SearchProviders
from litellm.utils import ProviderConfigManager, client, filter_out_litellm_params
####### ENVIRONMENT VARIABLES ###################
base_llm_http_handler = BaseLLMHTTPHandler()
#################################################
def _build_search_optional_params(
max_results: Optional[int] = None,
search_domain_filter: Optional[List[str]] = None,
max_tokens_per_page: Optional[int] = None,
country: Optional[str] = None,
) -> Dict[str, Any]:
"""
Helper function to build optional_params dict from Perplexity Search API parameters.
Args:
max_results: Maximum number of results (1-20)
search_domain_filter: List of domains to filter (max 20)
max_tokens_per_page: Max tokens per page
country: Country code filter
Returns:
Dict with non-None optional parameters
"""
optional_params: Dict[str, Any] = {}
if max_results is not None:
optional_params["max_results"] = max_results
if search_domain_filter is not None:
optional_params["search_domain_filter"] = search_domain_filter
if max_tokens_per_page is not None:
optional_params["max_tokens_per_page"] = max_tokens_per_page
if country is not None:
optional_params["country"] = country
return optional_params
@client
async def asearch(
query: Union[str, List[str]],
search_provider: str,
max_results: Optional[int] = None,
search_domain_filter: Optional[List[str]] = None,
max_tokens_per_page: Optional[int] = None,
country: Optional[str] = None,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
extra_headers: Optional[Dict[str, Any]] = None,
**kwargs,
) -> SearchResponse:
"""
Async Search function.
Args:
query: Search query (string or list of strings)
search_provider: Provider name (e.g., "perplexity")
max_results: Optional maximum number of results (1-20), default 10
search_domain_filter: Optional list of domains to filter (max 20)
max_tokens_per_page: Optional max tokens per page, default 1024
country: Optional country code filter (e.g., 'US', 'GB', 'DE')
api_key: Optional API key
api_base: Optional API base URL
timeout: Optional timeout
extra_headers: Optional extra headers
**kwargs: Additional parameters
Returns:
SearchResponse with results list following Perplexity format
Example:
```python
import litellm
# Basic search
response = await litellm.asearch(
query="latest AI developments 2024",
search_provider="perplexity"
)
# Search with options
response = await litellm.asearch(
query="AI developments",
search_provider="perplexity",
max_results=10,
search_domain_filter=["arxiv.org", "nature.com"],
max_tokens_per_page=1024,
country="US"
)
# Access results
for result in response.results:
print(f"{result.title}: {result.url}")
print(f"Snippet: {result.snippet}")
```
"""
local_vars = locals()
try:
loop = asyncio.get_event_loop()
kwargs["asearch"] = True
func = partial(
search,
query=query,
search_provider=search_provider,
max_results=max_results,
search_domain_filter=search_domain_filter,
max_tokens_per_page=max_tokens_per_page,
country=country,
api_key=api_key,
api_base=api_base,
timeout=timeout,
extra_headers=extra_headers,
**kwargs,
)
ctx = contextvars.copy_context()
func_with_context = partial(ctx.run, func)
init_response = await loop.run_in_executor(None, func_with_context)
if asyncio.iscoroutine(init_response):
response = await init_response
else:
response = init_response
if response is None:
raise ValueError(
f"Got an unexpected None response from the Search API: {response}"
)
return response
except Exception as e:
model_name = f"{search_provider}/search"
raise litellm.exception_type(
model=model_name,
custom_llm_provider=search_provider,
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
)
@client
def search(
query: Union[str, List[str]],
search_provider: str,
max_results: Optional[int] = None,
search_domain_filter: Optional[List[str]] = None,
max_tokens_per_page: Optional[int] = None,
country: Optional[str] = None,
api_key: Optional[str] = None,
api_base: Optional[str] = None,
timeout: Optional[Union[float, httpx.Timeout]] = None,
extra_headers: Optional[Dict[str, Any]] = None,
**kwargs,
) -> Union[SearchResponse, Coroutine[Any, Any, SearchResponse]]:
"""
Synchronous Search function.
Args:
query: Search query (string or list of strings)
search_provider: Provider name (e.g., "perplexity")
max_results: Optional maximum number of results (1-20), default 10
search_domain_filter: Optional list of domains to filter (max 20)
max_tokens_per_page: Optional max tokens per page, default 1024
country: Optional country code filter (e.g., 'US', 'GB', 'DE')
api_key: Optional API key
api_base: Optional API base URL
timeout: Optional timeout
extra_headers: Optional extra headers
**kwargs: Additional parameters
Returns:
SearchResponse with results list following Perplexity format
Example:
```python
import litellm
# Basic search
response = litellm.search(
query="latest AI developments 2024",
search_provider="perplexity"
)
# Search with options
response = litellm.search(
query="AI developments",
search_provider="perplexity",
max_results=10,
search_domain_filter=["arxiv.org", "nature.com"],
max_tokens_per_page=1024,
country="US"
)
# Multi-query search
response = litellm.search(
query=["AI developments", "machine learning trends"],
search_provider="perplexity"
)
# Access results
for result in response.results:
print(f"{result.title}: {result.url}")
print(f"Snippet: {result.snippet}")
if result.date:
print(f"Date: {result.date}")
```
"""
local_vars = locals()
try:
litellm_logging_obj: LiteLLMLoggingObj = kwargs.pop("litellm_logging_obj") # type: ignore
litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None)
_is_async = kwargs.pop("asearch", False) is True
# Validate query parameter
if not isinstance(query, (str, list)):
raise ValueError(f"query must be a string or list of strings, got {type(query)}")
if isinstance(query, list) and not all(isinstance(q, str) for q in query):
raise ValueError("All items in query list must be strings")
# Get provider config
search_provider_config: Optional[BaseSearchConfig] = (
ProviderConfigManager.get_provider_search_config(
provider=SearchProviders(search_provider),
)
)
if search_provider_config is None:
raise ValueError(
f"Search is not supported for provider: {search_provider}"
)
verbose_logger.debug(
f"Search call - provider: {search_provider}"
)
# Build optional_params from explicit parameters
optional_params = _build_search_optional_params(
max_results=max_results,
search_domain_filter=search_domain_filter,
max_tokens_per_page=max_tokens_per_page,
country=country,
)
# Filter out internal LiteLLM parameters from kwargs
filtered_kwargs = filter_out_litellm_params(kwargs=kwargs)
# Add remaining kwargs to optional_params (for provider-specific params)
for key, value in filtered_kwargs.items():
if key not in optional_params:
optional_params[key] = value
verbose_logger.debug(f"Search optional_params: {optional_params}")
# Validate environment and get headers
headers = search_provider_config.validate_environment(
api_key=api_key,
api_base=api_base,
headers=extra_headers or {},
)
# Get complete URL
complete_url = search_provider_config.get_complete_url(
api_base=api_base,
optional_params=optional_params,
)
# Pre Call logging
model_name = f"{search_provider}/search"
litellm_logging_obj.update_environment_variables(
model=model_name,
optional_params=optional_params,
litellm_params={
"litellm_call_id": litellm_call_id,
"api_base": complete_url,
},
custom_llm_provider=search_provider,
)
# Call the handler
response = base_llm_http_handler.search(
query=query,
optional_params=optional_params,
timeout=timeout or request_timeout,
logging_obj=litellm_logging_obj,
api_key=api_key,
api_base=complete_url,
custom_llm_provider=search_provider,
asearch=_is_async,
headers=headers,
provider_config=search_provider_config,
)
return response
except Exception as e:
model_name = f"{search_provider}/search"
raise litellm.exception_type(
model=model_name,
custom_llm_provider=search_provider,
original_exception=e,
completion_kwargs=local_vars,
extra_kwargs=kwargs,
)

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