mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
Merge branch 'BerriAI:main' into main
This commit is contained in:
commit
6d9c8153e3
186 changed files with 13624 additions and 715 deletions
|
|
@ -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
1
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
206
docs/my-website/docs/extras/creating_adapters.md
Normal file
206
docs/my-website/docs/extras/creating_adapters.md
Normal 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="...")`
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
154
docs/my-website/docs/providers/azure_ocr.md
Normal file
154
docs/my-website/docs/providers/azure_ocr.md
Normal 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>`
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
147
docs/my-website/docs/proxy/guardrails/grayswan.md
Normal file
147
docs/my-website/docs/proxy/guardrails/grayswan.md
Normal 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 LiteLLM’s 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 Cygnal’s reasoning capabilities. |
|
||||
| `optional_params.categories` | object | Map of custom category names to descriptions. |
|
||||
| `optional_params.policy_id` | string | Gray Swan policy identifier. |
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
91
docs/my-website/docs/search/dataforseo.md
Normal file
91
docs/my-website/docs/search/dataforseo.md
Normal 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
|
||||
)
|
||||
```
|
||||
|
||||
77
docs/my-website/docs/search/exa_ai.md
Normal file
77
docs/my-website/docs/search/exa_ai.md
Normal 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
|
||||
)
|
||||
```
|
||||
|
||||
101
docs/my-website/docs/search/google_pse.md
Normal file
101
docs/my-website/docs/search/google_pse.md
Normal 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
|
||||
)
|
||||
```
|
||||
|
||||
272
docs/my-website/docs/search/index.md
Normal file
272
docs/my-website/docs/search/index.md
Normal 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.
|
||||
|
||||
75
docs/my-website/docs/search/parallel_ai.md
Normal file
75
docs/my-website/docs/search/parallel_ai.md
Normal 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
|
||||
)
|
||||
```
|
||||
|
||||
57
docs/my-website/docs/search/perplexity.md
Normal file
57
docs/my-website/docs/search/perplexity.md
Normal 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
|
||||
}'
|
||||
```
|
||||
|
||||
77
docs/my-website/docs/search/tavily.md
Normal file
77
docs/my-website/docs/search/tavily.md
Normal 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
|
||||
)
|
||||
```
|
||||
|
||||
|
|
@ -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",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.2.28-py3-none-any.whl
vendored
Normal file
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.2.28-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.2.29-py3-none-any.whl
vendored
Normal file
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.2.29-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.2.29.tar.gz
vendored
Normal file
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.2.29.tar.gz
vendored
Normal file
Binary file not shown.
|
|
@ -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");
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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==",
|
||||
|
|
|
|||
|
|
@ -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 *
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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] + "..."
|
||||
|
|
|
|||
|
|
@ -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("'", "'")
|
||||
)
|
||||
|
||||
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,
|
||||
|
|
|
|||
15
litellm/llms/base_llm/search/__init__.py
Normal file
15
litellm/llms/base_llm/search/__init__.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""
|
||||
Base Search API module.
|
||||
"""
|
||||
from litellm.llms.base_llm.search.transformation import (
|
||||
BaseSearchConfig,
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BaseSearchConfig",
|
||||
"SearchResponse",
|
||||
"SearchResult",
|
||||
]
|
||||
|
||||
169
litellm/llms/base_llm/search/transformation.py
Normal file
169
litellm/llms/base_llm/search/transformation.py
Normal 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,
|
||||
)
|
||||
|
||||
|
|
@ -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
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
11
litellm/llms/dataforseo/search/__init__.py
Normal file
11
litellm/llms/dataforseo/search/__init__.py
Normal 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"]
|
||||
|
||||
209
litellm/llms/dataforseo/search/transformation.py
Normal file
209
litellm/llms/dataforseo/search/transformation.py
Normal 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",
|
||||
)
|
||||
|
||||
7
litellm/llms/exa_ai/search/__init__.py
Normal file
7
litellm/llms/exa_ai/search/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
Exa AI Search API module.
|
||||
"""
|
||||
from litellm.llms.exa_ai.search.transformation import ExaAISearchConfig
|
||||
|
||||
__all__ = ["ExaAISearchConfig"]
|
||||
|
||||
188
litellm/llms/exa_ai/search/transformation.py
Normal file
188
litellm/llms/exa_ai/search/transformation.py
Normal 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",
|
||||
)
|
||||
|
||||
8
litellm/llms/google_pse/search/__init__.py
Normal file
8
litellm/llms/google_pse/search/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""
|
||||
Google Programmable Search Engine (PSE) API module.
|
||||
"""
|
||||
from litellm.llms.google_pse.search.transformation import GooglePSESearchConfig
|
||||
|
||||
__all__ = ["GooglePSESearchConfig"]
|
||||
|
||||
|
||||
242
litellm/llms/google_pse/search/transformation.py
Normal file
242
litellm/llms/google_pse/search/transformation.py
Normal 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",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -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 ##
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] + "..."
|
||||
|
|
|
|||
7
litellm/llms/parallel_ai/search/__init__.py
Normal file
7
litellm/llms/parallel_ai/search/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
Parallel AI Search API module.
|
||||
"""
|
||||
from litellm.llms.parallel_ai.search.transformation import ParallelAISearchConfig
|
||||
|
||||
__all__ = ["ParallelAISearchConfig"]
|
||||
|
||||
201
litellm/llms/parallel_ai/search/transformation.py
Normal file
201
litellm/llms/parallel_ai/search/transformation.py
Normal 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",
|
||||
)
|
||||
|
||||
159
litellm/llms/perplexity/search/transformation.py
Normal file
159
litellm/llms/perplexity/search/transformation.py
Normal 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",
|
||||
)
|
||||
|
||||
7
litellm/llms/tavily/search/__init__.py
Normal file
7
litellm/llms/tavily/search/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
Tavily Search API module.
|
||||
"""
|
||||
from litellm.llms.tavily.search.transformation import TavilySearchConfig
|
||||
|
||||
__all__ = ["TavilySearchConfig"]
|
||||
|
||||
187
litellm/llms/tavily/search/transformation.py
Normal file
187
litellm/llms/tavily/search/transformation.py
Normal 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",
|
||||
)
|
||||
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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]()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
365
litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py
Normal file
365
litellm/proxy/guardrails/guardrail_hooks/grayswan/grayswan.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
55
litellm/proxy/hooks/rate_limiter_utils.py
Normal file
55
litellm/proxy/hooks/rate_limiter_utils.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
8
litellm/proxy/search_endpoints/__init__.py
Normal file
8
litellm/proxy/search_endpoints/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# litellm/proxy/search_endpoints/__init__.py
|
||||
|
||||
from .search_tool_registry import SearchToolRegistry
|
||||
|
||||
__all__ = [
|
||||
"SearchToolRegistry",
|
||||
]
|
||||
|
||||
162
litellm/proxy/search_endpoints/endpoints.py
Normal file
162
litellm/proxy/search_endpoints/endpoints.py
Normal 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,
|
||||
)
|
||||
|
||||
559
litellm/proxy/search_endpoints/search_tool_management.py
Normal file
559
litellm/proxy/search_endpoints/search_tool_management.py
Normal 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))
|
||||
|
||||
241
litellm/proxy/search_endpoints/search_tool_registry.py
Normal file
241
litellm/proxy/search_endpoints/search_tool_registry.py
Normal 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)}")
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
212
litellm/router_utils/search_api_router.py
Normal file
212
litellm/router_utils/search_api_router.py
Normal 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
|
||||
|
||||
8
litellm/search/__init__.py
Normal file
8
litellm/search/__init__.py
Normal 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"]
|
||||
|
||||
52
litellm/search/cost_calculator.py
Normal file
52
litellm/search/cost_calculator.py
Normal 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
325
litellm/search/main.py
Normal 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
Loading…
Add table
Reference in a new issue