Merge pull request #19910 from BerriAI/main

merge 01 27
This commit is contained in:
Sameer Kankute
2026-01-28 08:30:47 +05:30
committed by GitHub
72 changed files with 4566 additions and 403 deletions
@@ -38,6 +38,10 @@ spec:
serviceAccountName: {{ include "litellm.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
{{- with .Values.extraInitContainers }}
initContainers:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: {{ include "litellm.name" . }}
securityContext:
@@ -35,6 +35,10 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "litellm.serviceAccountName" . }}
{{- with .Values.migrationJob.extraInitContainers }}
initContainers:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: prisma-migrations
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default (printf "main-%s" .Chart.AppVersion) }}"
+1
View File
@@ -281,6 +281,7 @@ migrationJob:
# cpu: 100m
# memory: 100Mi
extraContainers: []
extraInitContainers: []
# Hook configuration
hooks:
+51
View File
@@ -1840,6 +1840,57 @@ content = response.get('choices', [{}])[0].get('message', {}).get('content')
print(content)
```
## gemini-robotics-er-1.5-preview Usage
```python
from litellm import api_base
from openai import OpenAI
import os
import base64
client = OpenAI(base_url="http://0.0.0.0:4000", api_key="sk-12345")
base64_image = base64.b64encode(open("closeup-object-on-table-many-260nw-1216144471.webp", "rb").read()).decode()
import json
import re
tools = [{"codeExecution": {}}]
response = client.chat.completions.create(
model="gemini/gemini-robotics-er-1.5-preview",
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": "Point to no more than 10 items in the image. The label returned should be an identifying name for the object detected. The answer should follow the json format: [{\"point\": [y, x], \"label\": <label1>}, ...]. The points are in [y, x] format normalized to 0-1000."
},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}
}
]
}
],
tools=tools
)
# Extract JSON from markdown code block if present
content = response.choices[0].message.content
# Look for triple-backtick JSON block
match = re.search(r'```json\s*(.*?)\s*```', content, re.DOTALL)
if match:
json_str = match.group(1)
else:
json_str = content
try:
data = json.loads(json_str)
print(json.dumps(data, indent=2))
except Exception as e:
print("Error parsing response as JSON:", e)
print("Response content:", content)
```
## Usage - PDF / Videos / etc. Files
### Inline Data (e.g. audio stream)
+89
View File
@@ -0,0 +1,89 @@
# Sarvam.ai
LiteLLM supports all the text models from [Sarvam ai](https://docs.sarvam.ai/api-reference-docs/chat/chat-completions)
## Usage
```python
import os
from litellm import completion
# Set your Sarvam API key
os.environ["SARVAM_API_KEY"] = ""
messages = [{"role": "user", "content": "Hello"}]
response = completion(
model="sarvam/sarvam-m",
messages=messages,
)
print(response)
```
## Usage with LiteLLM Proxy Server
Here's how to call a Sarvam.ai model with the LiteLLM Proxy Server
1. **Modify the `config.yaml`:**
```yaml
model_list:
- model_name: my-model
litellm_params:
model: sarvam/<your-model-name> # add sarvam/ prefix to route as Sarvam provider
api_key: api-key # api key to send your model
```
2. **Start the proxy:**
```bash
$ litellm --config /path/to/config.yaml
```
3. **Send a request to LiteLLM Proxy Server:**
<Tabs>
<TabItem value="openai" label="OpenAI Python v1.0.0+">
```python
import openai
client = openai.OpenAI(
api_key="sk-1234", # pass litellm proxy key, if you're using virtual keys
base_url="http://0.0.0.0:4000" # litellm-proxy-base url
)
response = client.chat.completions.create(
model="my-model",
messages=[
{
"role": "user",
"content": "what llm are you"
}
],
)
print(response)
```
</TabItem>
<TabItem value="curl" label="curl">
```shell
curl --location 'http://0.0.0.0:4000/chat/completions' \
--header 'Authorization: Bearer sk-1234' \
--header 'Content-Type: application/json' \
--data '{
"model": "my-model",
"messages": [
{
"role": "user",
"content": "what llm are you"
}
]
}'
```
</TabItem>
</Tabs>
@@ -405,14 +405,10 @@ curl --location 'http://0.0.0.0:4000/chat/completions' \
## **Proxy Admin Controls**
### Monitoring Guardrails
### Monitoring Guardrails
Monitor which guardrails were executed and whether they passed or failed. e.g. guardrail going rogue and failing requests we don't intend to fail
:::info
✨ This is an Enterprise only feature [Get a free trial](https://www.litellm.ai/enterprise#trial)
:::
#### Setup
+77 -1
View File
@@ -5,7 +5,7 @@ All-in-one document ingestion pipeline: **Upload → Chunk → Embed → Vector
| Feature | Supported |
|---------|-----------|
| Logging | Yes |
| Supported Providers | `openai`, `bedrock`, `vertex_ai`, `gemini` |
| Supported Providers | `openai`, `bedrock`, `vertex_ai`, `gemini`, `s3_vectors` |
:::tip
After ingesting documents, use [/rag/query](./rag_query.md) to search and generate responses with your ingested content.
@@ -75,6 +75,31 @@ curl -X POST "http://localhost:4000/v1/rag/ingest" \
}"
```
### AWS S3 Vectors
```bash showLineNumbers title="Ingest to S3 Vectors"
curl -X POST "http://localhost:4000/v1/rag/ingest" \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d "{
\"file\": {
\"filename\": \"document.txt\",
\"content\": \"$(base64 -i document.txt)\",
\"content_type\": \"text/plain\"
},
\"ingest_options\": {
\"embedding\": {
\"model\": \"text-embedding-3-small\"
},
\"vector_store\": {
\"custom_llm_provider\": \"s3_vectors\",
\"vector_bucket_name\": \"my-embeddings\",
\"aws_region_name\": \"us-west-2\"
}
}
}"
```
## Response
```json
@@ -265,6 +290,57 @@ When `vector_store_id` is omitted, LiteLLM automatically creates:
4. Install: `pip install 'google-cloud-aiplatform>=1.60.0'`
:::
### vector_store (AWS S3 Vectors)
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `custom_llm_provider` | string | - | `"s3_vectors"` |
| `vector_bucket_name` | string | **required** | S3 vector bucket name |
| `index_name` | string | auto-create | Vector index name |
| `dimension` | integer | auto-detect | Vector dimension (auto-detected from embedding model) |
| `distance_metric` | string | `cosine` | Distance metric: `cosine` or `euclidean` |
| `non_filterable_metadata_keys` | array | `["source_text"]` | Metadata keys excluded from filtering |
| `aws_region_name` | string | `us-west-2` | AWS region |
| `aws_access_key_id` | string | env | AWS access key |
| `aws_secret_access_key` | string | env | AWS secret key |
:::info S3 Vectors Auto-Creation
When `index_name` is omitted, LiteLLM automatically creates:
- S3 vector bucket (if it doesn't exist)
- Vector index with auto-detected dimensions from your embedding model
**Dimension Auto-Detection**: The vector dimension is automatically detected by making a test embedding request to your specified model. No need to manually specify dimensions!
**Supported Embedding Models**: Works with any LiteLLM-supported embedding model (OpenAI, Cohere, Bedrock, Azure, etc.)
:::
**Example with auto-detection:**
```json
{
"embedding": {
"model": "text-embedding-3-small" // Dimension auto-detected as 1536
},
"vector_store": {
"custom_llm_provider": "s3_vectors",
"vector_bucket_name": "my-embeddings"
}
}
```
**Example with custom embedding provider:**
```json
{
"embedding": {
"model": "cohere/embed-english-v3.0" // Dimension auto-detected as 1024
},
"vector_store": {
"custom_llm_provider": "s3_vectors",
"vector_bucket_name": "my-embeddings",
"distance_metric": "cosine"
}
}
```
## Input Examples
### File (Base64)
+6 -1
View File
@@ -828,7 +828,12 @@ asyncio.run(router_acompletion())
```
</TabItem>
</Tabs>
## Traffic Mirroring / Silent Experiments
Traffic mirroring allows you to "mimic" production traffic to a secondary (silent) model for evaluation purposes. The silent model's response is gathered in the background and does not affect the latency or result of the primary request.
[**See detailed guide on A/B Testing - Traffic Mirroring here**](./traffic_mirroring.md)
## Basic Reliability
+83
View File
@@ -0,0 +1,83 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# A/B Testing - Traffic Mirroring
Traffic mirroring allows you to "mimic" production traffic to a secondary (silent) model for evaluation purposes. The silent model's response is gathered in the background and does not affect the latency or result of the primary request.
This is useful for:
- Testing a new model's performance on production prompts before switching.
- Comparing costs and latency between different providers.
- Debugging issues by mirroring traffic to a more verbose model.
## Quick Start
To enable traffic mirroring, add `silent_model` to the `litellm_params` of a deployment.
<Tabs>
<TabItem value="sdk" label="SDK">
```python
from litellm import Router
model_list = [
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {
"model": "azure/chatgpt-v-2",
"api_key": "...",
"silent_model": "gpt-4" # 👈 Mirror traffic to gpt-4
},
},
{
"model_name": "gpt-4",
"litellm_params": {
"model": "openai/gpt-4",
"api_key": "..."
},
}
]
router = Router(model_list=model_list)
# The request to "gpt-3.5-turbo" will trigger a background call to "gpt-4"
response = await router.acompletion(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "How does traffic mirroring work?"}]
)
```
</TabItem>
<TabItem value="proxy" label="Proxy">
Add `silent_model` to your `config.yaml`:
```yaml
model_list:
- model_name: primary-model
litellm_params:
model: azure/gpt-35-turbo
api_key: os.environ/AZURE_API_KEY
silent_model: evaluation-model # 👈 Mirror traffic here
- model_name: evaluation-model
litellm_params:
model: openai/gpt-4o
api_key: os.environ/OPENAI_API_KEY
```
</TabItem>
</Tabs>
## How it works
1. **Request Received**: A request is made to a model group (e.g. `primary-model`).
2. **Deployment Picked**: LiteLLM picks a deployment from the group.
3. **Primary Call**: LiteLLM makes the call to the primary deployment.
4. **Mirroring**: If `silent_model` is present, LiteLLM triggers a background call to that model.
- For **Sync** calls: Uses a shared thread pool.
- For **Async** calls: Uses `asyncio.create_task`.
5. **Isolation**: The background call uses a `deepcopy` of the original request parameters and sets `metadata["is_silent_experiment"] = True`. It also strips out logging IDs to prevent collisions in usage tracking.
## Key Features
- **Latency Isolation**: The primary request returns as soon as it's ready. The background (silent) call does not block.
- **Unified Logging**: Background calls are processed via the Router, meaning they are automatically logged to your configured observability tools (Langfuse, S3, etc.).
- **Evaluation**: Use the `is_silent_experiment: True` flag in your logs to filter and compare results between the primary and mirrored calls.
+2
View File
@@ -364,6 +364,7 @@ const sidebars = {
label: "Load Balancing, Routing, Fallbacks",
href: "https://docs.litellm.ai/docs/routing-load-balancing",
},
"traffic_mirroring",
{
type: "category",
label: "Logging, Alerting, Metrics",
@@ -775,6 +776,7 @@ const sidebars = {
"providers/oci",
"providers/ollama",
"providers/openrouter",
"providers/sarvam",
"providers/ovhcloud",
"providers/perplexity",
"providers/petals",
@@ -36,7 +36,7 @@ class EnterpriseRouteChecks:
if not premium_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"🚨🚨🚨 DISABLING LLM API ENDPOINTS is an Enterprise feature\n🚨 {CommonProxyErrors.not_premium_user.value}",
detail=f"🚨🚨🚨 DISABLING ADMIN ENDPOINTS is an Enterprise feature\n🚨 {CommonProxyErrors.not_premium_user.value}",
)
return get_secret_bool("DISABLE_ADMIN_ENDPOINTS") is True
+7
View File
@@ -1327,6 +1327,13 @@ COROUTINE_CHECKER_MAX_SIZE_IN_MEMORY = int(
DEFAULT_CHUNK_SIZE = int(os.getenv("DEFAULT_CHUNK_SIZE", 1000))
DEFAULT_CHUNK_OVERLAP = int(os.getenv("DEFAULT_CHUNK_OVERLAP", 200))
########################### S3 Vectors RAG Constants ###########################
S3_VECTORS_DEFAULT_DIMENSION = int(os.getenv("S3_VECTORS_DEFAULT_DIMENSION", 1024))
S3_VECTORS_DEFAULT_DISTANCE_METRIC = str(
os.getenv("S3_VECTORS_DEFAULT_DISTANCE_METRIC", "cosine")
)
S3_VECTORS_DEFAULT_NON_FILTERABLE_METADATA_KEYS = ["source_text"]
########################### Microsoft SSO Constants ###########################
MICROSOFT_USER_EMAIL_ATTRIBUTE = str(
os.getenv("MICROSOFT_USER_EMAIL_ATTRIBUTE", "userPrincipalName")
@@ -31,15 +31,19 @@ def _process_image_response(response: Response, url: str) -> str:
f"Error: Image size ({size_mb:.2f}MB) exceeds maximum allowed size ({MAX_IMAGE_URL_DOWNLOAD_SIZE_MB}MB). url={url}"
)
image_bytes = response.content
# Stream download with size checking to prevent downloading huge files
max_bytes = int(MAX_IMAGE_URL_DOWNLOAD_SIZE_MB * 1024 * 1024)
image_bytes = bytearray()
bytes_downloaded = 0
# Check actual size after download if Content-Length was not available
if content_length is None:
size_mb = len(image_bytes) / (1024 * 1024)
if size_mb > MAX_IMAGE_URL_DOWNLOAD_SIZE_MB:
for chunk in response.iter_bytes(chunk_size=8192):
bytes_downloaded += len(chunk)
if bytes_downloaded > max_bytes:
size_mb = bytes_downloaded / (1024 * 1024)
raise litellm.ImageFetchError(
f"Error: Image size ({size_mb:.2f}MB) exceeds maximum allowed size ({MAX_IMAGE_URL_DOWNLOAD_SIZE_MB}MB). url={url}"
)
image_bytes.extend(chunk)
base64_image = base64.b64encode(image_bytes).decode("utf-8")
@@ -5,8 +5,8 @@ import httpx
from litellm.types.router import GenericLiteLLMParams
from litellm.types.vector_stores import (
BaseVectorStoreAuthCredentials,
VECTOR_STORE_OPENAI_PARAMS,
BaseVectorStoreAuthCredentials,
VectorStoreCreateOptionalRequestParams,
VectorStoreCreateResponse,
VectorStoreIndexEndpoints,
@@ -64,6 +64,30 @@ class BaseVectorStoreConfig:
pass
async def atransform_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]:
"""
Optional async version of transform_search_vector_store_request.
If not implemented, the handler will fall back to the sync version.
Providers that need to make async calls (e.g., generating embeddings) should override this.
"""
# Default implementation: call the sync version
return self.transform_search_vector_store_request(
vector_store_id=vector_store_id,
query=query,
vector_store_search_optional_params=vector_store_search_optional_params,
api_base=api_base,
litellm_logging_obj=litellm_logging_obj,
litellm_params=litellm_params,
)
@abstractmethod
def transform_search_vector_store_response(
self, response: httpx.Response, litellm_logging_obj: LiteLLMLoggingObj
+1 -1
View File
@@ -1163,7 +1163,7 @@ class BaseAWSLLM:
def _sign_request(
self,
service_name: Literal["bedrock", "sagemaker", "bedrock-agentcore"],
service_name: Literal["bedrock", "sagemaker", "bedrock-agentcore", "s3vectors"],
headers: dict,
optional_params: dict,
request_data: dict,
+20 -3
View File
@@ -797,7 +797,7 @@ class BedrockEventStreamDecoderBase:
def get_anthropic_beta_from_headers(headers: dict) -> List[str]:
"""
Extract anthropic-beta header values and convert them to a list.
Supports comma-separated values from user headers.
Supports both JSON array format and comma-separated values from user headers.
Used by both converse and invoke transformations for consistent handling
of anthropic-beta headers that should be passed to AWS Bedrock.
@@ -812,8 +812,25 @@ def get_anthropic_beta_from_headers(headers: dict) -> List[str]:
if not anthropic_beta_header:
return []
# Split comma-separated values and strip whitespace
return [beta.strip() for beta in anthropic_beta_header.split(",")]
# If it's already a list, return it
if isinstance(anthropic_beta_header, list):
return anthropic_beta_header
# Try to parse as JSON array first (e.g., '["interleaved-thinking-2025-05-14", "claude-code-20250219"]')
if isinstance(anthropic_beta_header, str):
anthropic_beta_header = anthropic_beta_header.strip()
if anthropic_beta_header.startswith("[") and anthropic_beta_header.endswith("]"):
try:
parsed = json.loads(anthropic_beta_header)
if isinstance(parsed, list):
return [str(beta).strip() for beta in parsed]
except json.JSONDecodeError:
pass # Fall through to comma-separated parsing
# Fall back to comma-separated values
return [beta.strip() for beta in anthropic_beta_header.split(",")]
return []
class CommonBatchFilesUtils:
@@ -162,6 +162,49 @@ class AmazonAnthropicClaudeMessagesConfig(
return any(pattern in model_lower for pattern in supported_patterns)
def _is_claude_opus_4_5(self, model: str) -> bool:
"""
Check if the model is Claude Opus 4.5.
Args:
model: The model name
Returns:
True if the model is Claude Opus 4.5
"""
model_lower = model.lower()
opus_4_5_patterns = [
"opus-4.5", "opus_4.5", "opus-4-5", "opus_4_5",
]
return any(pattern in model_lower for pattern in opus_4_5_patterns)
def _supports_tool_search_on_bedrock(self, model: str) -> bool:
"""
Check if the model supports tool search on Bedrock.
On Amazon Bedrock, server-side tool search is supported on Claude Opus 4.5
and Claude Sonnet 4.5 with the tool-search-tool-2025-10-19 beta header.
Ref: https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool
Args:
model: The model name
Returns:
True if the model supports tool search on Bedrock
"""
model_lower = model.lower()
# Supported models for tool search on Bedrock
supported_patterns = [
# Opus 4.5
"opus-4.5", "opus_4.5", "opus-4-5", "opus_4_5",
# Sonnet 4.5
"sonnet-4.5", "sonnet_4.5", "sonnet-4-5", "sonnet_4_5",
]
return any(pattern in model_lower for pattern in supported_patterns)
def _filter_unsupported_beta_headers_for_bedrock(
self, model: str, beta_set: set
) -> None:
@@ -169,25 +212,33 @@ class AmazonAnthropicClaudeMessagesConfig(
Remove beta headers that are not supported on Bedrock for the given model.
Extended thinking beta headers are only supported on specific Claude 4+ models.
Advanced tool use headers are not supported on Bedrock Invoke API.
Advanced tool use headers are not supported on Bedrock Invoke API, but need to be
translated to Bedrock-specific headers for models that support tool search
(Claude Opus 4.5, Sonnet 4.5).
This prevents 400 "invalid beta flag" errors on Bedrock.
Note: Bedrock Invoke API fails with a 400 error when unsupported beta headers
are sent, returning: {"message":"invalid beta flag"}
Translation for models supporting tool search (Opus 4.5, Sonnet 4.5):
- advanced-tool-use-2025-11-20 -> tool-search-tool-2025-10-19 + tool-examples-2025-10-29
Args:
model: The model name
beta_set: The set of beta headers to filter in-place
"""
beta_headers_to_remove = set()
has_advanced_tool_use = False
# 1. Filter out beta headers that are universally unsupported on Bedrock Invoke
# 1. Filter out beta headers that are universally unsupported on Bedrock Invoke and track if advanced-tool-use header is present
for beta in beta_set:
for unsupported_pattern in self.UNSUPPORTED_BEDROCK_INVOKE_BETA_PATTERNS:
if unsupported_pattern in beta.lower():
beta_headers_to_remove.add(beta)
has_advanced_tool_use = True
break
# 2. Filter out extended thinking headers for models that don't support them
extended_thinking_patterns = [
"extended-thinking",
@@ -204,6 +255,14 @@ class AmazonAnthropicClaudeMessagesConfig(
for beta in beta_headers_to_remove:
beta_set.discard(beta)
# 3. Translate advanced-tool-use to Bedrock-specific headers for models that support tool search
# Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html
# Ref: https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool
if has_advanced_tool_use and self._supports_tool_search_on_bedrock(model):
beta_set.add("tool-search-tool-2025-10-19")
beta_set.add("tool-examples-2025-10-29")
def _get_tool_search_beta_header_for_bedrock(
self,
model: str,
@@ -256,7 +315,7 @@ class AmazonAnthropicClaudeMessagesConfig(
Ref: https://aws.amazon.com/blogs/machine-learning/structured-data-response-with-amazon-bedrock-prompt-engineering-and-tool-use/
"""
import json
# Extract schema from output_format
schema = output_format.get("schema")
if not schema:
+25 -11
View File
@@ -7033,17 +7033,31 @@ class BaseLLMHTTPHandler:
litellm_params=dict(litellm_params),
)
(
url,
request_body,
) = vector_store_provider_config.transform_search_vector_store_request(
vector_store_id=vector_store_id,
query=query,
vector_store_search_optional_params=vector_store_search_optional_params,
api_base=api_base,
litellm_logging_obj=logging_obj,
litellm_params=dict(litellm_params),
)
# Check if provider has async transform method
if hasattr(vector_store_provider_config, "atransform_search_vector_store_request"):
(
url,
request_body,
) = await vector_store_provider_config.atransform_search_vector_store_request(
vector_store_id=vector_store_id,
query=query,
vector_store_search_optional_params=vector_store_search_optional_params,
api_base=api_base,
litellm_logging_obj=logging_obj,
litellm_params=dict(litellm_params),
)
else:
(
url,
request_body,
) = vector_store_provider_config.transform_search_vector_store_request(
vector_store_id=vector_store_id,
query=query,
vector_store_search_optional_params=vector_store_search_optional_params,
api_base=api_base,
litellm_logging_obj=logging_obj,
litellm_params=dict(litellm_params),
)
all_optional_params: Dict[str, Any] = dict(litellm_params)
all_optional_params.update(vector_store_search_optional_params or {})
headers, signed_json_body = vector_store_provider_config.sign_request(
+8
View File
@@ -323,4 +323,12 @@ class GroqChatCompletionStreamingHandler(OpenAIChatCompletionStreamingHandler):
status_code=error.get("code"), message=error.get("message"), body=error
)
# Map Groq's 'reasoning' field to LiteLLM's 'reasoning_content' field
# Groq returns delta.reasoning, but LiteLLM expects delta.reasoning_content
choices = chunk.get("choices", [])
for choice in choices:
delta = choice.get("delta", {})
if "reasoning" in delta:
delta["reasoning_content"] = delta.pop("reasoning")
return super().chunk_parser(chunk)
+1
View File
@@ -0,0 +1 @@
# S3 Vectors LLM integration
@@ -0,0 +1 @@
# S3 Vectors vector store integration
@@ -0,0 +1,254 @@
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.bedrock.base_aws_llm import BaseAWSLLM
from litellm.types.router import GenericLiteLLMParams
from litellm.types.vector_stores import (
VECTOR_STORE_OPENAI_PARAMS,
BaseVectorStoreAuthCredentials,
VectorStoreIndexEndpoints,
VectorStoreResultContent,
VectorStoreSearchOptionalRequestParams,
VectorStoreSearchResponse,
VectorStoreSearchResult,
)
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
else:
LiteLLMLoggingObj = Any
class S3VectorsVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
"""Vector store configuration for AWS S3 Vectors."""
def __init__(self) -> None:
BaseVectorStoreConfig.__init__(self)
BaseAWSLLM.__init__(self)
def get_auth_credentials(
self, litellm_params: dict
) -> BaseVectorStoreAuthCredentials:
return {}
def get_vector_store_endpoints_by_type(self) -> VectorStoreIndexEndpoints:
return {
"read": [("POST", "/QueryVectors")],
"write": [],
}
def get_supported_openai_params(
self, model: str
) -> List[VECTOR_STORE_OPENAI_PARAMS]:
return ["max_num_results"]
def map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
drop_params: bool,
) -> dict:
for param, value in non_default_params.items():
if param == "max_num_results":
optional_params["maxResults"] = value
return optional_params
def validate_environment(
self, headers: dict, litellm_params: Optional[GenericLiteLLMParams]
) -> dict:
headers = headers or {}
headers.setdefault("Content-Type", "application/json")
return headers
def get_complete_url(self, api_base: Optional[str], litellm_params: dict) -> str:
aws_region_name = litellm_params.get("aws_region_name")
if not aws_region_name:
raise ValueError("aws_region_name is required for S3 Vectors")
return f"https://s3vectors.{aws_region_name}.api.aws"
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]:
"""Sync version - generates embedding synchronously."""
# For S3 Vectors, vector_store_id should be in format: bucket_name:index_name
# If not in that format, try to construct it from litellm_params
bucket_name: str
index_name: str
if ":" in vector_store_id:
bucket_name, index_name = vector_store_id.split(":", 1)
else:
# Try to get bucket_name from litellm_params
bucket_name_from_params = litellm_params.get("vector_bucket_name")
if not bucket_name_from_params or not isinstance(bucket_name_from_params, str):
raise ValueError(
"vector_store_id must be in format 'bucket_name:index_name' for S3 Vectors, "
"or vector_bucket_name must be provided in litellm_params"
)
bucket_name = bucket_name_from_params
index_name = vector_store_id
if isinstance(query, list):
query = " ".join(query)
# Generate embedding for the query
embedding_model = litellm_params.get("embedding_model", "text-embedding-3-small")
import litellm as litellm_module
embedding_response = litellm_module.embedding(model=embedding_model, input=[query])
query_embedding = embedding_response.data[0]["embedding"]
url = f"{api_base}/QueryVectors"
request_body: Dict[str, Any] = {
"vectorBucketName": bucket_name,
"indexName": index_name,
"queryVector": {"float32": query_embedding},
"topK": vector_store_search_optional_params.get("max_num_results", 5), # Default to 5
"returnDistance": True,
"returnMetadata": True,
}
litellm_logging_obj.model_call_details["query"] = query
return url, request_body
async def atransform_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]:
"""Async version - generates embedding asynchronously."""
# For S3 Vectors, vector_store_id should be in format: bucket_name:index_name
# If not in that format, try to construct it from litellm_params
bucket_name: str
index_name: str
if ":" in vector_store_id:
bucket_name, index_name = vector_store_id.split(":", 1)
else:
# Try to get bucket_name from litellm_params
bucket_name_from_params = litellm_params.get("vector_bucket_name")
if not bucket_name_from_params or not isinstance(bucket_name_from_params, str):
raise ValueError(
"vector_store_id must be in format 'bucket_name:index_name' for S3 Vectors, "
"or vector_bucket_name must be provided in litellm_params"
)
bucket_name = bucket_name_from_params
index_name = vector_store_id
if isinstance(query, list):
query = " ".join(query)
# Generate embedding for the query asynchronously
embedding_model = litellm_params.get("embedding_model", "text-embedding-3-small")
import litellm as litellm_module
embedding_response = await litellm_module.aembedding(model=embedding_model, input=[query])
query_embedding = embedding_response.data[0]["embedding"]
url = f"{api_base}/QueryVectors"
request_body: Dict[str, Any] = {
"vectorBucketName": bucket_name,
"indexName": index_name,
"queryVector": {"float32": query_embedding},
"topK": vector_store_search_optional_params.get("max_num_results", 5), # Default to 5
"returnDistance": True,
"returnMetadata": True,
}
litellm_logging_obj.model_call_details["query"] = query
return url, request_body
def sign_request(
self,
headers: dict,
optional_params: Dict,
request_data: Dict,
api_base: str,
api_key: Optional[str] = None,
) -> Tuple[dict, Optional[bytes]]:
return self._sign_request(
service_name="s3vectors",
headers=headers,
optional_params=optional_params,
request_data=request_data,
api_base=api_base,
api_key=api_key,
)
def transform_search_vector_store_response(
self, response: httpx.Response, litellm_logging_obj: LiteLLMLoggingObj
) -> VectorStoreSearchResponse:
try:
response_data = response.json()
results: List[VectorStoreSearchResult] = []
for item in response_data.get("vectors", []) or []:
metadata = item.get("metadata", {}) or {}
source_text = metadata.get("source_text", "")
if not source_text:
continue
# Extract file information from metadata
chunk_index = metadata.get("chunk_index", "0")
file_id = f"s3-vectors-chunk-{chunk_index}"
filename = metadata.get("filename", f"document-{chunk_index}")
# S3 Vectors returns distance, convert to similarity score (0-1)
# Lower distance = higher similarity
# We'll normalize using 1 / (1 + distance) to get a 0-1 score
distance = item.get("distance")
score = None
if distance is not None:
# Convert distance to similarity score between 0 and 1
# For cosine distance: similarity = 1 - distance
# For euclidean: use 1 / (1 + distance)
# Assuming cosine distance here
score = max(0.0, min(1.0, 1.0 - float(distance)))
results.append(
VectorStoreSearchResult(
score=score,
content=[VectorStoreResultContent(text=source_text, type="text")],
file_id=file_id,
filename=filename,
attributes=metadata,
)
)
return VectorStoreSearchResponse(
object="vector_store.search_results.page",
search_query=litellm_logging_obj.model_call_details.get("query", ""),
data=results,
)
except Exception as e:
raise self.get_error_class(
error_message=str(e),
status_code=response.status_code,
headers=response.headers,
)
# Vector store creation is not yet implemented
def transform_create_vector_store_request(
self,
vector_store_create_optional_params,
api_base: str,
) -> Tuple[str, Dict]:
raise NotImplementedError
def transform_create_vector_store_response(self, response: httpx.Response):
raise NotImplementedError
@@ -10232,6 +10232,48 @@
"mode": "completion",
"output_cost_per_token": 5e-07
},
"deepseek-v3-2-251201": {
"input_cost_per_token": 0.0,
"litellm_provider": "volcengine",
"max_input_tokens": 98304,
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 0.0,
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
},
"glm-4-7-251222": {
"input_cost_per_token": 0.0,
"litellm_provider": "volcengine",
"max_input_tokens": 204800,
"max_output_tokens": 131072,
"max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 0.0,
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
},
"kimi-k2-thinking-251104": {
"input_cost_per_token": 0.0,
"litellm_provider": "volcengine",
"max_input_tokens": 229376,
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 0.0,
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
},
"doubao-embedding": {
"input_cost_per_token": 0.0,
"litellm_provider": "volcengine",
@@ -13480,6 +13522,43 @@
"supports_vision": true,
"supports_web_search": true
},
"gemini-robotics-er-1.5-preview": {
"cache_read_input_token_cost": 0,
"input_cost_per_token": 3e-07,
"input_cost_per_audio_token": 1e-06,
"litellm_provider": "gemini",
"max_input_tokens": 1048576,
"max_output_tokens": 65535,
"max_tokens": 65535,
"mode": "chat",
"output_cost_per_token": 2.5e-06,
"output_cost_per_reasoning_token": 2.5e-06,
"source": "https://ai.google.dev/gemini-api/docs/models#gemini-robotics-er-1-5-preview",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions"
],
"supported_modalities": [
"text",
"image",
"video",
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_output": false,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_prompt_caching": false,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true
},
"gemini-2.5-computer-use-preview-10-2025": {
"input_cost_per_token": 1.25e-06,
"input_cost_per_token_above_200k_tokens": 2.5e-06,
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

+3 -1
View File
@@ -1016,8 +1016,10 @@ async def _get_fuzzy_user_object(
)
if response is None and user_email is not None:
# Use case-insensitive query to handle emails with different casing
# This matches the pattern used in _check_duplicate_user_email
response = await prisma_client.db.litellm_usertable.find_first(
where={"user_email": user_email},
where={"user_email": {"equals": user_email, "mode": "insensitive"}},
include={"organization_memberships": True},
)
+1 -1
View File
@@ -264,7 +264,7 @@ async def create_interaction(
general_settings=general_settings,
proxy_config=proxy_config,
select_data_generator=select_data_generator,
model=data.get("model"),
model=data.get("model") or data.get("agent"),
user_model=user_model,
user_temperature=user_temperature,
user_request_timeout=user_request_timeout,
@@ -1771,6 +1771,113 @@ async def _add_team_members_to_team(
return updated_team, updated_users, updated_team_memberships
async def _validate_and_populate_member_user_info(
member: Member,
prisma_client: PrismaClient,
) -> Member:
"""
Validate and populate user_email/user_id for a member.
Logic:
1. If both user_email and user_id are provided, verify they belong to the same user (use user_email as source of truth)
2. If only user_email is provided, populate user_id from DB
3. If only user_id is provided, populate user_email from DB (if user exists)
4. If only user_id is provided and doesn't exist, allow it to pass with user_email as None (will be upserted later)
5. If user_email and user_id mismatch, throw error
Returns a Member with user_email and user_id populated (user_email may be None if only user_id provided and user doesn't exist).
"""
if member.user_email is None and member.user_id is None:
raise HTTPException(
status_code=400,
detail={"error": "Either user_id or user_email must be provided"},
)
# Case 1: Both user_email and user_id provided - verify they match
if member.user_email is not None and member.user_id is not None:
# Use user_email as source of truth
# Check for multiple users with same email first
users_by_email = await prisma_client.get_data(
key_val={"user_email": member.user_email},
table_name="user",
query_type="find_all",
)
if users_by_email is None or (
isinstance(users_by_email, list) and len(users_by_email) == 0
):
# User doesn't exist yet - this is fine, will be created later
return member
if isinstance(users_by_email, list) and len(users_by_email) > 1:
raise HTTPException(
status_code=400,
detail={
"error": f"Multiple users found with email '{member.user_email}'. Please use 'user_id' instead."
},
)
# Get the single user
user_by_email = users_by_email[0]
# Verify the user_id matches
if user_by_email.user_id != member.user_id:
raise HTTPException(
status_code=400,
detail={
"error": f"user_email '{member.user_email}' and user_id '{member.user_id}' do not belong to the same user."
},
)
# Both match, return as is
return member
# Case 2: Only user_email provided - populate user_id from DB
if member.user_email is not None and member.user_id is None:
user_by_email = await prisma_client.db.litellm_usertable.find_first(
where={"user_email": {"equals": member.user_email, "mode": "insensitive"}}
)
if user_by_email is None:
# User doesn't exist yet - this is fine, will be created later
return member
# Check for multiple users with same email
users_by_email = await prisma_client.get_data(
key_val={"user_email": member.user_email},
table_name="user",
query_type="find_all",
)
if users_by_email and isinstance(users_by_email, list) and len(users_by_email) > 1:
raise HTTPException(
status_code=400,
detail={
"error": f"Multiple users found with email '{member.user_email}'. Please use 'user_id' instead."
},
)
# Populate user_id
member.user_id = user_by_email.user_id
return member
# Case 3: Only user_id provided - populate user_email from DB if user exists
if member.user_id is not None and member.user_email is None:
user_by_id = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": member.user_id}
)
if user_by_id is None:
# User doesn't exist yet - allow it to pass with user_email as None
# Will be upserted later with just user_id and null email
return member
# Populate user_email
member.user_email = user_by_id.user_email
return member
return member
@router.post(
"/team/member_add",
tags=["team management"],
@@ -1846,6 +1953,19 @@ async def team_member_add(
complete_team_data=complete_team_data,
)
# Validate and populate user_email/user_id for members before processing
if isinstance(data.member, Member):
await _validate_and_populate_member_user_info(
member=data.member,
prisma_client=prisma_client,
)
elif isinstance(data.member, List):
for m in data.member:
await _validate_and_populate_member_user_info(
member=m,
prisma_client=prisma_client,
)
updated_team, updated_users, updated_team_memberships = (
await _add_team_members_to_team(
data=data,
+28 -8
View File
@@ -93,6 +93,26 @@ else:
router = APIRouter()
def normalize_email(email: Optional[str]) -> Optional[str]:
"""
Normalize email address to lowercase for consistent storage and comparison.
Email addresses should be treated as case-insensitive for SSO purposes,
even though RFC 5321 technically allows case-sensitive local parts.
This prevents issues where SSO providers return emails with different casing
than what's stored in the database.
Args:
email: Email address to normalize, can be None
Returns:
Lowercased email address, or None if input is None
"""
if email is None:
return None
return email.lower() if isinstance(email, str) else email
def determine_role_from_groups(
user_groups: List[str],
role_mappings: "RoleMappings",
@@ -395,7 +415,7 @@ def generic_response_convertor(
display_name=get_nested_value(
response, generic_user_display_name_attribute_name
),
email=get_nested_value(response, generic_user_email_attribute_name),
email=normalize_email(get_nested_value(response, generic_user_email_attribute_name)),
first_name=get_nested_value(response, generic_user_first_name_attribute_name),
last_name=get_nested_value(response, generic_user_last_name_attribute_name),
provider=get_nested_value(response, generic_provider_attribute_name),
@@ -731,7 +751,7 @@ async def get_user_info_from_db(
if _id is not None and isinstance(_id, str):
potential_user_ids.append(_id)
user_email = (
user_email = normalize_email(
getattr(result, "email", None)
if not isinstance(result, dict)
else result.get("email", None)
@@ -806,8 +826,8 @@ def _build_sso_user_update_data(
Returns:
dict: Update data containing user_email and optionally user_role if valid
"""
update_data: dict = {"user_email": user_email}
"""
update_data: dict = {"user_email": normalize_email(user_email)}
# Get SSO role from result and include if valid
sso_role = getattr(result, "user_role", None)
@@ -1316,7 +1336,7 @@ async def insert_sso_user(
new_user_request = NewUserRequest(
user_id=user_defined_values["user_id"],
user_email=user_defined_values["user_email"],
user_email=normalize_email(user_defined_values["user_email"]),
user_role=user_defined_values["user_role"], # type: ignore
max_budget=user_defined_values["max_budget"],
budget_duration=user_defined_values["budget_duration"],
@@ -1981,7 +2001,7 @@ class SSOAuthenticationHandler:
"""
Gets the user email and id from the OpenID result after validating the email domain
"""
user_email: Optional[str] = getattr(result, "email", None)
user_email: Optional[str] = normalize_email(getattr(result, "email", None))
user_id: Optional[str] = (
getattr(result, "id", None) if result is not None else None
)
@@ -2020,7 +2040,7 @@ class SSOAuthenticationHandler:
"GENERIC_USER_ROLE_ATTRIBUTE", "role"
)
user_id = getattr(result, "id", None)
user_email = getattr(result, "email", None)
user_email = normalize_email(getattr(result, "email", None))
if user_role is None:
_role_from_attr = getattr(result, generic_user_role_attribute_name, None) # type: ignore
if _role_from_attr is not None:
@@ -2413,7 +2433,7 @@ class MicrosoftSSOHandler:
response = response or {}
verbose_proxy_logger.debug(f"Microsoft SSO Callback Response: {response}")
openid_response = CustomOpenID(
email=response.get(MICROSOFT_USER_EMAIL_ATTRIBUTE) or response.get("mail"),
email=normalize_email(response.get(MICROSOFT_USER_EMAIL_ATTRIBUTE) or response.get("mail")),
display_name=response.get(MICROSOFT_USER_DISPLAY_NAME_ATTRIBUTE),
provider="microsoft",
id=response.get(MICROSOFT_USER_ID_ATTRIBUTE),
+183 -46
View File
@@ -7807,6 +7807,7 @@ async def _apply_search_filter_to_models(
size: int,
prisma_client: Optional[Any],
proxy_config: Any,
sort_by: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], Optional[int]]:
"""
Apply search filter to models, querying database for additional matching models.
@@ -7818,6 +7819,7 @@ async def _apply_search_filter_to_models(
size: Page size
prisma_client: Prisma client for database queries
proxy_config: Proxy config for decrypting models
sort_by: Optional sort field - if provided, fetch all matching models instead of paginating at DB level
Returns:
Tuple of (filtered_models, total_count). total_count is None if not searching.
@@ -7881,17 +7883,15 @@ async def _apply_search_filter_to_models(
# Calculate total count for search results
search_total_count = router_models_count + db_models_total_count
# Fetch database models if we need more for the current page
if router_models_count < models_needed_for_page:
models_to_fetch = min(
models_needed_for_page - router_models_count, db_models_total_count
)
if models_to_fetch > 0:
# If sorting is requested, we need to fetch ALL matching models to sort correctly
# Otherwise, we can optimize by only fetching what's needed for the current page
if sort_by:
# Fetch all matching database models for sorting
if db_models_total_count > 0:
db_models_raw = (
await prisma_client.db.litellm_proxymodeltable.find_many(
where=db_where_condition,
take=models_to_fetch,
take=db_models_total_count, # Fetch all matching models
)
)
@@ -7902,6 +7902,28 @@ async def _apply_search_filter_to_models(
)
if decrypted_models:
db_models.extend(decrypted_models)
else:
# Fetch database models if we need more for the current page
if router_models_count < models_needed_for_page:
models_to_fetch = min(
models_needed_for_page - router_models_count, db_models_total_count
)
if models_to_fetch > 0:
db_models_raw = (
await prisma_client.db.litellm_proxymodeltable.find_many(
where=db_where_condition,
take=models_to_fetch,
)
)
# Convert database models to router format
for db_model in db_models_raw:
decrypted_models = proxy_config.decrypt_model_list_from_db(
[db_model]
)
if decrypted_models:
db_models.extend(decrypted_models)
except Exception as e:
verbose_proxy_logger.exception(
f"Error querying database models with search: {str(e)}"
@@ -7917,6 +7939,80 @@ async def _apply_search_filter_to_models(
return filtered_models, search_total_count
def _sort_models(
all_models: List[Dict[str, Any]],
sort_by: Optional[str],
sort_order: str = "asc",
) -> List[Dict[str, Any]]:
"""
Sort models by the specified field and order.
Args:
all_models: List of models to sort
sort_by: Field to sort by (model_name, created_at, updated_at, costs, status)
sort_order: Sort order (asc or desc)
Returns:
Sorted list of models
"""
if not sort_by or sort_by not in ["model_name", "created_at", "updated_at", "costs", "status"]:
return all_models
reverse = sort_order.lower() == "desc"
def get_sort_key(model: Dict[str, Any]) -> Any:
model_info = model.get("model_info", {})
if sort_by == "model_name":
return model.get("model_name", "").lower()
elif sort_by == "created_at":
created_at = model_info.get("created_at")
if created_at is None:
# Put None values at the end for asc, at the start for desc
return (datetime.max if not reverse else datetime.min)
if isinstance(created_at, str):
try:
return datetime.fromisoformat(created_at.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return datetime.min if not reverse else datetime.max
return created_at
elif sort_by == "updated_at":
updated_at = model_info.get("updated_at")
if updated_at is None:
return (datetime.max if not reverse else datetime.min)
if isinstance(updated_at, str):
try:
return datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return datetime.min if not reverse else datetime.max
return updated_at
elif sort_by == "costs":
input_cost = model_info.get("input_cost_per_token", 0) or 0
output_cost = model_info.get("output_cost_per_token", 0) or 0
total_cost = input_cost + output_cost
# Put 0 or None costs at the end for asc, at the start for desc
if total_cost == 0:
return (float("inf") if not reverse else float("-inf"))
return total_cost
elif sort_by == "status":
# False (config) comes before True (db) for asc
db_model = model_info.get("db_model", False)
return db_model
return None
try:
sorted_models = sorted(all_models, key=get_sort_key, reverse=reverse)
return sorted_models
except Exception as e:
verbose_proxy_logger.exception(f"Error sorting models by {sort_by}: {str(e)}")
return all_models
def _paginate_models_response(
all_models: List[Dict[str, Any]],
page: int,
@@ -8078,6 +8174,55 @@ async def _filter_models_by_team_id(
return filtered_models
async def _find_model_by_id(
model_id: str,
search: Optional[str],
llm_router,
prisma_client,
proxy_config,
) -> tuple[list, Optional[int]]:
"""Find a model by its ID and optionally filter by search term."""
found_model = None
# First, search in config
if llm_router is not None:
found_model = llm_router.get_model_info(id=model_id)
if found_model:
found_model = copy.deepcopy(found_model)
# If not found in config, search in database
if found_model is None:
try:
db_model = await prisma_client.db.litellm_proxymodeltable.find_unique(
where={"model_id": model_id}
)
if db_model:
# Convert database model to router format
decrypted_models = proxy_config.decrypt_model_list_from_db(
[db_model]
)
if decrypted_models:
found_model = decrypted_models[0]
except Exception as e:
verbose_proxy_logger.exception(
f"Error querying database for modelId {model_id}: {str(e)}"
)
# If model found, verify search filter if provided
if found_model is not None:
if search is not None and search.strip():
search_lower = search.lower().strip()
model_name = found_model.get("model_name", "")
if search_lower not in model_name.lower():
# Model found but doesn't match search filter
found_model = None
# Set all_models to the found model or empty list
all_models = [found_model] if found_model is not None else []
search_total_count: Optional[int] = len(all_models)
return all_models, search_total_count
@router.get(
"/v2/model/info",
description="v2 - returns models available to the user based on their API key permissions. Shows model info from config.yaml (except api key and api base). Filter to just user-added models with ?user_models_only=true",
@@ -8109,6 +8254,14 @@ async def model_info_v2(
None,
description="Filter models by team ID. Returns models with direct_access=True or teamId in access_via_team_ids",
),
sortBy: Optional[str] = fastapi.Query(
None,
description="Field to sort by. Options: model_name, created_at, updated_at, costs, status",
),
sortOrder: Optional[str] = fastapi.Query(
"asc",
description="Sort order. Options: asc, desc",
),
):
"""
BETA ENDPOINT. Might change unexpectedly. Use `/v1/model/info` for now.
@@ -8136,44 +8289,13 @@ async def model_info_v2(
# If modelId is provided, search for the specific model
if modelId is not None:
found_model = None
# First, search in config
if llm_router is not None:
found_model = llm_router.get_model_info(id=modelId)
if found_model:
found_model = copy.deepcopy(found_model)
# If not found in config, search in database
if found_model is None:
try:
db_model = await prisma_client.db.litellm_proxymodeltable.find_unique(
where={"model_id": modelId}
)
if db_model:
# Convert database model to router format
decrypted_models = proxy_config.decrypt_model_list_from_db(
[db_model]
)
if decrypted_models:
found_model = decrypted_models[0]
except Exception as e:
verbose_proxy_logger.exception(
f"Error querying database for modelId {modelId}: {str(e)}"
)
# If model found, verify search filter if provided
if found_model is not None:
if search is not None and search.strip():
search_lower = search.lower().strip()
model_name = found_model.get("model_name", "")
if search_lower not in model_name.lower():
# Model found but doesn't match search filter
found_model = None
# Set all_models to the found model or empty list
all_models = [found_model] if found_model is not None else []
search_total_count: Optional[int] = len(all_models)
all_models, search_total_count = await _find_model_by_id(
model_id=modelId,
search=search,
llm_router=llm_router,
prisma_client=prisma_client,
proxy_config=proxy_config,
)
else:
# Normal flow when modelId is not provided
all_models = copy.deepcopy(llm_router.model_list)
@@ -8193,6 +8315,7 @@ async def model_info_v2(
size=size,
prisma_client=prisma_client,
proxy_config=proxy_config,
sort_by=sortBy,
)
if user_models_only:
@@ -8236,6 +8359,20 @@ async def model_info_v2(
if modelId is not None:
search_total_count = len(all_models)
# Apply sorting before pagination
if sortBy:
# Validate sortOrder
if sortOrder and sortOrder.lower() not in ["asc", "desc"]:
raise HTTPException(
status_code=400,
detail=f"Invalid sortOrder: {sortOrder}. Must be 'asc' or 'desc'",
)
all_models = _sort_models(
all_models=all_models,
sort_by=sortBy,
sort_order=sortOrder or "asc",
)
verbose_proxy_logger.debug("all_models: %s", all_models)
return _paginate_models_response(
+9
View File
@@ -129,6 +129,14 @@ async def _save_vector_store_to_db_from_rag_ingest(
litellm_vector_store_params = ingest_options.get("litellm_vector_store_params", {})
custom_vector_store_name = litellm_vector_store_params.get("vector_store_name")
custom_vector_store_description = litellm_vector_store_params.get("vector_store_description")
# Extract provider-specific params from vector_store_config to save as litellm_params
# This ensures params like aws_region_name, embedding_model, etc. are available for search
provider_specific_params = {}
excluded_keys = {"custom_llm_provider", "vector_store_id"}
for key, value in vector_store_config.items():
if key not in excluded_keys and value is not None:
provider_specific_params[key] = value
# Build file metadata entry using helper
file_entry = _build_file_metadata_entry(
@@ -167,6 +175,7 @@ async def _save_vector_store_to_db_from_rag_ingest(
vector_store_name=vector_store_name,
vector_store_description=vector_store_description,
vector_store_metadata=initial_metadata,
litellm_params=provider_specific_params if provider_specific_params else None,
)
verbose_proxy_logger.info(
+2 -1
View File
@@ -221,8 +221,9 @@ async def route_request(
"aretrieve_container_file_content",
]:
return getattr(llm_router, f"{route_type}")(**data)
# Interactions API: get/delete/cancel don't need model routing
# Interactions API: create with agent, get/delete/cancel don't need model routing
if route_type in [
"acreate_interaction",
"aget_interaction",
"adelete_interaction",
"acancel_interaction",
+15 -2
View File
@@ -24,6 +24,7 @@ from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
from litellm.rag.ingestion.file_parsers import extract_text_from_pdf
from litellm.rag.text_splitters import RecursiveCharacterTextSplitter
from litellm.types.rag import RAGIngestOptions, RAGIngestResponse
@@ -193,11 +194,23 @@ class BaseRAGIngestion(ABC):
if text:
text_to_chunk = text
elif file_content and not ocr_was_used:
# Try UTF-8 decode first
try:
text_to_chunk = file_content.decode("utf-8")
except UnicodeDecodeError:
verbose_logger.debug("Binary file detected, skipping text chunking")
return []
# Check if it's a PDF and try to extract text
if file_content.startswith(b"%PDF"):
verbose_logger.debug("PDF detected, attempting text extraction")
text_to_chunk = extract_text_from_pdf(file_content)
if not text_to_chunk:
verbose_logger.debug(
"PDF text extraction failed. Install 'pypdf' or 'PyPDF2' for PDF support, "
"or enable OCR with a vision model."
)
return []
else:
verbose_logger.debug("Binary file detected, skipping text chunking")
return []
if not text_to_chunk:
return []
@@ -0,0 +1,9 @@
"""
File parsers for RAG ingestion.
Provides text extraction utilities for various file formats.
"""
from .pdf_parser import extract_text_from_pdf
__all__ = ["extract_text_from_pdf"]
@@ -0,0 +1,70 @@
"""
PDF text extraction utilities.
Provides text extraction from PDF files using pypdf or PyPDF2.
"""
from typing import Optional
from litellm._logging import verbose_logger
def extract_text_from_pdf(file_content: bytes) -> Optional[str]:
"""
Extract text from PDF using pypdf if available.
Args:
file_content: Raw PDF bytes
Returns:
Extracted text or None if extraction fails
"""
try:
from io import BytesIO
# Try pypdf first (most common)
try:
from pypdf import PdfReader as PypdfReader
pdf_file = BytesIO(file_content)
reader = PypdfReader(pdf_file)
text_parts = []
for page in reader.pages:
text = page.extract_text()
if text:
text_parts.append(text)
if text_parts:
extracted_text = "\n\n".join(text_parts)
verbose_logger.debug(f"Extracted {len(extracted_text)} characters from PDF using pypdf")
return extracted_text
except ImportError:
verbose_logger.debug("pypdf not available, trying PyPDF2")
# Fallback to PyPDF2
try:
from PyPDF2 import PdfReader as PyPDF2Reader
pdf_file = BytesIO(file_content)
reader = PyPDF2Reader(pdf_file)
text_parts = []
for page in reader.pages:
text = page.extract_text()
if text:
text_parts.append(text)
if text_parts:
extracted_text = "\n\n".join(text_parts)
verbose_logger.debug(f"Extracted {len(extracted_text)} characters from PDF using PyPDF2")
return extracted_text
except ImportError:
verbose_logger.debug("PyPDF2 not available, PDF extraction requires OCR or pypdf/PyPDF2 library")
except Exception as e:
verbose_logger.debug(f"PDF text extraction failed: {e}")
return None
@@ -0,0 +1,573 @@
"""
S3 Vectors-specific RAG Ingestion implementation.
S3 Vectors is AWS's native vector storage service that provides:
- Purpose-built vector buckets for storing and querying vectors
- Vector indexes with configurable dimensions and distance metrics
- Metadata filtering for semantic search
This implementation:
1. Auto-creates vector buckets and indexes if not provided
2. Uses LiteLLM's embedding API (supports any provider)
3. Uses httpx + AWS SigV4 signing (no boto3 dependency for S3 Vectors APIs)
4. Stores vectors with metadata using PutVectors API
"""
from __future__ import annotations
import hashlib
import uuid
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
import litellm
from litellm._logging import verbose_logger
from litellm.constants import (
S3_VECTORS_DEFAULT_DIMENSION,
S3_VECTORS_DEFAULT_DISTANCE_METRIC,
S3_VECTORS_DEFAULT_NON_FILTERABLE_METADATA_KEYS,
)
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
from litellm.rag.ingestion.base_ingestion import BaseRAGIngestion
if TYPE_CHECKING:
from litellm import Router
from litellm.types.rag import RAGIngestOptions
class S3VectorsRAGIngestion(BaseRAGIngestion, BaseAWSLLM):
"""
S3 Vectors RAG ingestion using httpx + AWS SigV4 signing.
Workflow:
1. Auto-create vector bucket if needed (CreateVectorBucket API)
2. Auto-create vector index if needed (CreateVectorIndex API)
3. Generate embeddings using LiteLLM (supports any provider)
4. Store vectors with PutVectors API
Configuration:
- vector_bucket_name: S3 vector bucket name (required)
- index_name: Vector index name (auto-creates if not provided)
- dimension: Vector dimension (default: S3_VECTORS_DEFAULT_DIMENSION)
- distance_metric: "cosine" or "euclidean" (default: S3_VECTORS_DEFAULT_DISTANCE_METRIC)
- non_filterable_metadata_keys: List of metadata keys to exclude from filtering
"""
def __init__(
self,
ingest_options: "RAGIngestOptions",
router: Optional["Router"] = None,
):
BaseRAGIngestion.__init__(self, ingest_options=ingest_options, router=router)
BaseAWSLLM.__init__(self)
# Extract config
self.vector_bucket_name = self.vector_store_config["vector_bucket_name"]
self.index_name = self.vector_store_config.get("index_name")
self.distance_metric = self.vector_store_config.get(
"distance_metric", S3_VECTORS_DEFAULT_DISTANCE_METRIC
)
self.non_filterable_metadata_keys = self.vector_store_config.get(
"non_filterable_metadata_keys",
S3_VECTORS_DEFAULT_NON_FILTERABLE_METADATA_KEYS,
)
# Get dimension from config (will be auto-detected on first use if not provided)
self.dimension = self._get_dimension_from_config()
# Get AWS region using BaseAWSLLM method
_aws_region = self.vector_store_config.get("aws_region_name")
self.aws_region_name = self.get_aws_region_name_for_non_llm_api_calls(
aws_region_name=str(_aws_region) if _aws_region else None
)
# Create httpx client (similar to s3_v2.py)
ssl_verify = self._get_ssl_verify(
ssl_verify=self.vector_store_config.get("ssl_verify")
)
self.async_httpx_client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.RAG,
params={"ssl_verify": ssl_verify} if ssl_verify is not None else None,
)
# Track if infrastructure is initialized
self._config_initialized = False
async def _get_dimension_from_embedding_request(self) -> int:
"""
Auto-detect dimension by making a test embedding request.
Makes a single embedding request with a test string to determine
the output dimension of the embedding model.
"""
if not self.embedding_config or "model" not in self.embedding_config:
return S3_VECTORS_DEFAULT_DIMENSION
try:
model_name = self.embedding_config["model"]
verbose_logger.debug(
f"Auto-detecting dimension by making test embedding request to {model_name}"
)
# Make a test embedding request
test_input = "test"
if self.router:
response = await self.router.aembedding(model=model_name, input=[test_input])
else:
response = await litellm.aembedding(model=model_name, input=[test_input])
# Get dimension from the response
if response.data and len(response.data) > 0:
dimension = len(response.data[0]["embedding"])
verbose_logger.debug(
f"Auto-detected dimension {dimension} for embedding model {model_name}"
)
return dimension
except Exception as e:
verbose_logger.warning(
f"Could not auto-detect dimension from embedding model: {e}. "
f"Using default dimension of {S3_VECTORS_DEFAULT_DIMENSION}."
)
return S3_VECTORS_DEFAULT_DIMENSION
def _get_dimension_from_config(self) -> Optional[int]:
"""
Get vector dimension from config if explicitly provided.
Returns None if dimension should be auto-detected.
"""
if "dimension" in self.vector_store_config:
return int(self.vector_store_config["dimension"])
return None
async def _ensure_config_initialized(self):
"""Lazily initialize S3 Vectors infrastructure."""
if self._config_initialized:
return
# Auto-detect dimension if not provided
if self.dimension is None:
self.dimension = await self._get_dimension_from_embedding_request()
# Ensure vector bucket exists
await self._ensure_vector_bucket_exists()
# Ensure vector index exists
if not self.index_name:
# Auto-generate index name
unique_id = uuid.uuid4().hex[:8]
self.index_name = f"litellm-index-{unique_id}"
await self._ensure_vector_index_exists()
self._config_initialized = True
async def _sign_and_execute_request(
self,
method: str,
url: str,
data: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
) -> Any:
"""
Helper to sign and execute AWS API requests using httpx + SigV4.
Pattern from litellm/integrations/s3_v2.py
"""
try:
import requests
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
except ImportError:
raise ImportError(
"Missing botocore to call S3 Vectors. Run 'pip install boto3'."
)
# Get AWS credentials using BaseAWSLLM's get_credentials method
credentials = self.get_credentials(
aws_access_key_id=self.vector_store_config.get("aws_access_key_id"),
aws_secret_access_key=self.vector_store_config.get("aws_secret_access_key"),
aws_session_token=self.vector_store_config.get("aws_session_token"),
aws_region_name=self.aws_region_name,
aws_session_name=self.vector_store_config.get("aws_session_name"),
aws_profile_name=self.vector_store_config.get("aws_profile_name"),
aws_role_name=self.vector_store_config.get("aws_role_name"),
aws_web_identity_token=self.vector_store_config.get("aws_web_identity_token"),
aws_sts_endpoint=self.vector_store_config.get("aws_sts_endpoint"),
aws_external_id=self.vector_store_config.get("aws_external_id"),
)
# Prepare headers
if headers is None:
headers = {}
if data:
headers["Content-Type"] = "application/json"
# Calculate SHA256 hash of the content
content_hash = hashlib.sha256(data.encode("utf-8")).hexdigest()
headers["x-amz-content-sha256"] = content_hash
else:
# For requests without body, use hash of empty string
headers["x-amz-content-sha256"] = hashlib.sha256(b"").hexdigest()
# Prepare the request
req = requests.Request(method, url, data=data, headers=headers)
prepped = req.prepare()
# Sign the request
aws_request = AWSRequest(
method=prepped.method,
url=prepped.url,
data=prepped.body,
headers=prepped.headers,
)
SigV4Auth(credentials, "s3vectors", self.aws_region_name).add_auth(aws_request)
# Prepare the signed headers
signed_headers = dict(aws_request.headers.items())
# Make the request using specific method (pattern from s3_v2.py)
method_upper = method.upper()
if method_upper == "PUT":
response = await self.async_httpx_client.put(
url, data=data, headers=signed_headers
)
elif method_upper == "POST":
response = await self.async_httpx_client.post(
url, data=data, headers=signed_headers
)
elif method_upper == "GET":
response = await self.async_httpx_client.get(url, headers=signed_headers)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
return response
async def _ensure_vector_bucket_exists(self):
"""Create vector bucket if it doesn't exist using GetVectorBucket and CreateVectorBucket APIs."""
verbose_logger.debug(
f"Ensuring S3 vector bucket exists: {self.vector_bucket_name}"
)
# Validate bucket name (AWS S3 naming rules)
if len(self.vector_bucket_name) < 3:
raise ValueError(
f"Invalid vector_bucket_name '{self.vector_bucket_name}': "
f"AWS S3 bucket names must be at least 3 characters long. "
f"Please provide a valid bucket name (e.g., 'my-vector-bucket')."
)
if not self.vector_bucket_name.replace("-", "").replace(".", "").isalnum():
raise ValueError(
f"Invalid vector_bucket_name '{self.vector_bucket_name}': "
f"AWS S3 bucket names can only contain lowercase letters, numbers, hyphens, and periods. "
f"Please provide a valid bucket name (e.g., 'my-vector-bucket')."
)
# Try to get bucket info using GetVectorBucket API
get_url = f"https://s3vectors.{self.aws_region_name}.api.aws/GetVectorBucket"
get_body = safe_dumps({"vectorBucketName": self.vector_bucket_name})
try:
response = await self._sign_and_execute_request("POST", get_url, data=get_body)
if response.status_code == 200:
verbose_logger.debug(f"Vector bucket {self.vector_bucket_name} exists")
return
except Exception as e:
verbose_logger.debug(
f"Bucket check failed (may not exist): {e}, attempting to create"
)
# Create vector bucket using CreateVectorBucket API
try:
verbose_logger.debug(f"Creating vector bucket: {self.vector_bucket_name}")
create_url = f"https://s3vectors.{self.aws_region_name}.api.aws/CreateVectorBucket"
create_body = safe_dumps({
"vectorBucketName": self.vector_bucket_name
})
response = await self._sign_and_execute_request("POST", create_url, data=create_body)
if response.status_code in (200, 201):
verbose_logger.info(f"Created vector bucket: {self.vector_bucket_name}")
elif response.status_code == 409:
# Bucket already exists (ConflictException)
verbose_logger.debug(
f"Vector bucket {self.vector_bucket_name} already exists"
)
else:
verbose_logger.error(f"CreateVectorBucket failed: {response.status_code} - {response.text}")
response.raise_for_status()
except Exception as e:
verbose_logger.exception(f"Error creating vector bucket: {e}")
raise
async def _ensure_vector_index_exists(self):
"""Create vector index if it doesn't exist using GetIndex and CreateIndex APIs."""
verbose_logger.debug(
f"Ensuring vector index exists: {self.vector_bucket_name}/{self.index_name}"
)
# Try to get index info using GetIndex API
get_url = f"https://s3vectors.{self.aws_region_name}.api.aws/GetIndex"
get_body = safe_dumps({
"vectorBucketName": self.vector_bucket_name,
"indexName": self.index_name
})
try:
response = await self._sign_and_execute_request("POST", get_url, data=get_body)
if response.status_code == 200:
verbose_logger.debug(f"Vector index {self.index_name} exists")
return
except Exception as e:
verbose_logger.debug(
f"Index check failed (may not exist): {e}, attempting to create"
)
# Create vector index using CreateIndex API
try:
verbose_logger.debug(
f"Creating vector index: {self.index_name} with dimension={self.dimension}, metric={self.distance_metric}"
)
# Prepare index configuration per AWS API docs
index_config = {
"vectorBucketName": self.vector_bucket_name,
"indexName": self.index_name,
"dataType": "float32",
"dimension": self.dimension,
"distanceMetric": self.distance_metric,
}
if self.non_filterable_metadata_keys:
index_config["metadataConfiguration"] = {
"nonFilterableMetadataKeys": self.non_filterable_metadata_keys
}
create_url = f"https://s3vectors.{self.aws_region_name}.api.aws/CreateIndex"
response = await self._sign_and_execute_request(
"POST", create_url, data=safe_dumps(index_config)
)
if response.status_code in (200, 201):
verbose_logger.info(f"Created vector index: {self.index_name}")
elif response.status_code == 409:
verbose_logger.debug(f"Vector index {self.index_name} already exists")
else:
verbose_logger.error(f"CreateIndex failed: {response.status_code} - {response.text}")
response.raise_for_status()
except Exception as e:
verbose_logger.exception(f"Error creating vector index: {e}")
raise
async def _put_vectors(self, vectors: List[Dict[str, Any]]):
"""
Call PutVectors API to store vectors in S3 Vectors.
Args:
vectors: List of vector objects with keys: "key", "data", "metadata"
"""
verbose_logger.debug(
f"Storing {len(vectors)} vectors in {self.vector_bucket_name}/{self.index_name}"
)
url = f"https://s3vectors.{self.aws_region_name}.api.aws/PutVectors"
# Prepare request body per AWS API docs
request_body = {
"vectorBucketName": self.vector_bucket_name,
"indexName": self.index_name,
"vectors": vectors
}
try:
response = await self._sign_and_execute_request(
"POST", url, data=safe_dumps(request_body)
)
if response.status_code in (200, 201):
verbose_logger.info(
f"Successfully stored {len(vectors)} vectors in index {self.index_name}"
)
else:
verbose_logger.error(
f"PutVectors failed with status {response.status_code}: {response.text}"
)
response.raise_for_status()
except Exception as e:
verbose_logger.exception(f"Error storing vectors: {e}")
raise
async def embed(
self,
chunks: List[str],
) -> Optional[List[List[float]]]:
"""
Generate embeddings using LiteLLM's embedding API.
Supports any embedding provider (OpenAI, Bedrock, Cohere, etc.)
"""
if not chunks:
return None
# Use embedding config from ingest_options or default
if not self.embedding_config:
verbose_logger.warning(
"No embedding config provided, using default text-embedding-3-small"
)
self.embedding_config = {"model": "text-embedding-3-small"}
embedding_model = self.embedding_config.get("model", "text-embedding-3-small")
verbose_logger.debug(
f"Generating embeddings for {len(chunks)} chunks using {embedding_model}"
)
# Convert to list to ensure type compatibility
input_chunks: List[str] = list(chunks)
if self.router:
response = await self.router.aembedding(model=embedding_model, input=input_chunks)
else:
response = await litellm.aembedding(model=embedding_model, input=input_chunks)
return [item["embedding"] for item in response.data]
async def store(
self,
file_content: Optional[bytes],
filename: Optional[str],
content_type: Optional[str],
chunks: List[str],
embeddings: Optional[List[List[float]]],
) -> Tuple[Optional[str], Optional[str]]:
"""
Store vectors in S3 Vectors using PutVectors API.
Steps:
1. Ensure vector bucket exists (auto-create if needed)
2. Ensure vector index exists (auto-create if needed)
3. Prepare vector data with metadata
4. Call PutVectors API with httpx + SigV4 signing
Args:
file_content: Raw file bytes (not used for S3 Vectors)
filename: Name of the file
content_type: MIME type (not used for S3 Vectors)
chunks: Text chunks
embeddings: Vector embeddings
Returns:
Tuple of (index_name, filename)
"""
# Ensure infrastructure exists
await self._ensure_config_initialized()
if not embeddings or not chunks:
error_msg = (
"No text content could be extracted from the file for embedding. "
"Possible causes:\n"
" 1. PDF files require OCR - add 'ocr' config with a vision model (e.g., 'anthropic/claude-3-5-sonnet-20241022')\n"
" 2. Binary files cannot be processed - convert to text first\n"
" 3. File is empty or contains no extractable text\n"
"For PDFs, either enable OCR or use a PDF extraction library to convert to text before ingestion."
)
verbose_logger.error(error_msg)
raise ValueError(error_msg)
# Prepare vectors for PutVectors API
vectors = []
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
# Build metadata dict
metadata: Dict[str, str] = {
"source_text": chunk, # Non-filterable (for reference)
"chunk_index": str(i), # Filterable
}
if filename:
metadata["filename"] = filename # Filterable
vector_obj = {
"key": f"{filename}_{i}" if filename else f"chunk_{i}",
"data": {"float32": embedding},
"metadata": metadata,
}
vectors.append(vector_obj)
# Call PutVectors API
await self._put_vectors(vectors)
# Return vector_store_id in format bucket_name:index_name for S3 Vectors search compatibility
vector_store_id = f"{self.vector_bucket_name}:{self.index_name}"
return vector_store_id, filename
async def query_vector_store(
self, vector_store_id: str, query: str, top_k: int = 5
) -> Optional[Dict[str, Any]]:
"""
Query S3 Vectors using QueryVectors API.
Args:
vector_store_id: Index name
query: Query text
top_k: Number of results to return
Returns:
Query results with vectors and metadata
"""
verbose_logger.debug(f"Querying index {vector_store_id} with query: {query}")
# Generate query embedding
if not self.embedding_config:
self.embedding_config = {"model": "text-embedding-3-small"}
embedding_model = self.embedding_config.get("model", "text-embedding-3-small")
response = await litellm.aembedding(model=embedding_model, input=[query])
query_embedding = response.data[0]["embedding"]
# Call QueryVectors API
url = f"https://s3vectors.{self.aws_region_name}.api.aws/QueryVectors"
request_body = {
"vectorBucketName": self.vector_bucket_name,
"indexName": vector_store_id,
"queryVector": {"float32": query_embedding},
"topK": top_k,
"returnDistance": True,
"returnMetadata": True,
}
try:
response = await self._sign_and_execute_request(
"POST", url, data=safe_dumps(request_body)
)
if response.status_code == 200:
results = response.json()
verbose_logger.debug(f"Query returned {len(results.get('vectors', []))} results")
# Check if query terms appear in results
if results.get("vectors"):
for result in results["vectors"]:
metadata = result.get("metadata", {})
source_text = metadata.get("source_text", "")
if query.lower() in source_text.lower():
return results
# Return results even if exact match not found
return results
else:
verbose_logger.error(
f"QueryVectors failed with status {response.status_code}: {response.text}"
)
return None
except Exception as e:
verbose_logger.exception(f"Error querying vectors: {e}")
return None
+2
View File
@@ -31,6 +31,7 @@ from litellm.rag.ingestion.base_ingestion import BaseRAGIngestion
from litellm.rag.ingestion.bedrock_ingestion import BedrockRAGIngestion
from litellm.rag.ingestion.gemini_ingestion import GeminiRAGIngestion
from litellm.rag.ingestion.openai_ingestion import OpenAIRAGIngestion
from litellm.rag.ingestion.s3_vectors_ingestion import S3VectorsRAGIngestion
from litellm.rag.rag_query import RAGQuery
from litellm.types.rag import (
RAGIngestOptions,
@@ -48,6 +49,7 @@ INGESTION_REGISTRY: Dict[str, Type[BaseRAGIngestion]] = {
"openai": OpenAIRAGIngestion,
"bedrock": BedrockRAGIngestion,
"gemini": GeminiRAGIngestion,
"s3_vectors": S3VectorsRAGIngestion,
}
+126 -12
View File
@@ -58,6 +58,7 @@ from litellm.litellm_core_utils.core_helpers import (
_get_parent_otel_span_from_kwargs,
get_metadata_variable_name_from_kwargs,
)
from litellm.litellm_core_utils.thread_pool_executor import executor
from litellm.litellm_core_utils.coroutine_checker import coroutine_checker
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.dd_tracing import tracer
@@ -1250,10 +1251,25 @@ class Router:
specific_deployment=kwargs.pop("specific_deployment", None),
request_kwargs=kwargs,
)
self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
# Check for silent model experiment
# Make a local copy of litellm_params to avoid mutating the Router's state
litellm_params = deployment["litellm_params"].copy()
silent_model = litellm_params.pop("silent_model", None)
if silent_model is not None:
# Mirroring traffic to a secondary model
# Use shared thread pool for background calls
executor.submit(
self._silent_experiment_completion,
silent_model,
messages,
**kwargs,
)
self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
kwargs.pop("silent_model", None) # Ensure it's not in kwargs either
# No copy needed - data is only read and spread into new dict below
data = deployment["litellm_params"]
data = litellm_params.copy() # Use the local copy of litellm_params
model_name = data["model"]
potential_model_client = self._get_client(
deployment=deployment, kwargs=kwargs
@@ -1274,15 +1290,14 @@ class Router:
if not self.has_model_id(model):
self.routing_strategy_pre_call_checks(deployment=deployment)
response = litellm.completion(
**{
**data,
"messages": messages,
"caching": self.cache_responses,
"client": model_client,
**kwargs,
}
)
input_kwargs = {
**data,
"messages": messages,
"caching": self.cache_responses,
"client": model_client,
**kwargs,
}
response = litellm.completion(**input_kwargs)
verbose_router_logger.info(
f"litellm.completion(model={model_name})\033[32m 200 OK\033[0m"
)
@@ -1309,6 +1324,56 @@ class Router:
self._set_deployment_num_retries_on_exception(e, deployment)
raise e
def _get_silent_experiment_kwargs(self, **kwargs) -> dict:
"""
Prepare kwargs for a silent experiment by ensuring isolation from the primary call.
"""
# Copy kwargs to ensure isolation
silent_kwargs = copy.deepcopy(kwargs)
if "metadata" not in silent_kwargs:
silent_kwargs["metadata"] = {}
silent_kwargs["metadata"]["is_silent_experiment"] = True
# Pop logging objects and call IDs to ensure a fresh logging context
# This prevents collisions in the Proxy's database (spend_logs)
silent_kwargs.pop("litellm_call_id", None)
silent_kwargs.pop("litellm_logging_obj", None)
silent_kwargs.pop("standard_logging_object", None)
silent_kwargs.pop("proxy_server_request", None)
return silent_kwargs
def _silent_experiment_completion(
self, silent_model: str, messages: List[Any], **kwargs
):
"""
Run a silent experiment in the background (thread).
"""
try:
# Prevent infinite recursion if silent model also has a silent model
if kwargs.get("metadata", {}).get("is_silent_experiment", False):
return
messages = copy.deepcopy(messages)
verbose_router_logger.info(
f"Starting silent experiment for model {silent_model}"
)
silent_kwargs = self._get_silent_experiment_kwargs(**kwargs)
# Trigger the silent request
self.completion(
model=silent_model,
messages=cast(List[Dict[str, str]], messages),
**silent_kwargs,
)
except Exception as e:
verbose_router_logger.error(
f"Silent experiment failed for model {silent_model}: {str(e)}"
)
# fmt: off
@overload
@@ -1517,6 +1582,36 @@ class Router:
return FallbackStreamWrapper(stream_with_fallbacks())
async def _silent_experiment_acompletion(
self, silent_model: str, messages: List[Any], **kwargs
):
"""
Run a silent experiment in the background.
"""
try:
# Prevent infinite recursion if silent model also has a silent model
if kwargs.get("metadata", {}).get("is_silent_experiment", False):
return
messages = copy.deepcopy(messages)
verbose_router_logger.info(
f"Starting silent experiment for model {silent_model}"
)
silent_kwargs = self._get_silent_experiment_kwargs(**kwargs)
# Trigger the silent request
await self.acompletion(
model=silent_model,
messages=cast(List[AllMessageValues], messages),
**silent_kwargs,
)
except Exception as e:
verbose_router_logger.error(
f"Silent experiment failed for model {silent_model}: {str(e)}"
)
async def _acompletion( # noqa: PLR0915
self, model: str, messages: List[Dict[str, str]], **kwargs
) -> Union[ModelResponse, CustomStreamWrapper,]:
@@ -1563,9 +1658,27 @@ class Router:
self._track_deployment_metrics(
deployment=deployment, parent_otel_span=parent_otel_span
)
# Check for silent model experiment
# Make a local copy of litellm_params to avoid mutating the Router's state
litellm_params = deployment["litellm_params"].copy()
silent_model = litellm_params.pop("silent_model", None)
if silent_model is not None:
# Mirroring traffic to a secondary model
# This is a silent experiment, so we don't want to block the primary request
asyncio.create_task(
self._silent_experiment_acompletion(
silent_model=silent_model,
messages=messages, # Use messages instead of *args
**kwargs,
)
)
self._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs)
kwargs.pop("silent_model", None) # Ensure it's not in kwargs either
# No copy needed - data is only read and spread into new dict below
data = deployment["litellm_params"]
data = litellm_params.copy() # Use the local copy of litellm_params
model_name = data["model"]
@@ -1582,6 +1695,7 @@ class Router:
"client": model_client,
**kwargs,
}
input_kwargs.pop("silent_model", None)
_response = litellm.acompletion(**input_kwargs)
+45 -1
View File
@@ -129,9 +129,53 @@ class VertexAIVectorStoreOptions(TypedDict, total=False):
import_timeout: Optional[int] # Timeout in seconds (default: 600)
class S3VectorsVectorStoreOptions(TypedDict, total=False):
"""
AWS S3 Vectors configuration.
Example (auto-create):
{"custom_llm_provider": "s3_vectors", "vector_bucket_name": "my-embeddings"}
Example (use existing):
{"custom_llm_provider": "s3_vectors", "vector_bucket_name": "my-embeddings",
"index_name": "my-index"}
Example (with credentials):
{"custom_llm_provider": "s3_vectors", "vector_bucket_name": "my-embeddings",
"litellm_credential_name": "my-aws-creds"}
Auto-creation creates: S3 vector bucket and vector index (if not provided).
Embeddings are generated using LiteLLM's embedding API (supports any provider).
"""
custom_llm_provider: Literal["s3_vectors"]
vector_bucket_name: str # Required - S3 vector bucket name
index_name: Optional[str] # Vector index name (auto-creates if not provided)
# Index configuration (for auto-creation)
dimension: Optional[int] # Vector dimension (auto-detected from embedding model, or default: 1024)
distance_metric: Optional[Literal["cosine", "euclidean"]] # Default: cosine
non_filterable_metadata_keys: Optional[List[str]] # Keys excluded from filtering (e.g., ["source_text"])
# Credentials (loaded from litellm.credential_list if litellm_credential_name is provided)
litellm_credential_name: Optional[str] # Credential name to load from litellm.credential_list
# AWS auth (uses BaseAWSLLM)
aws_access_key_id: Optional[str]
aws_secret_access_key: Optional[str]
aws_session_token: Optional[str]
aws_region_name: Optional[str] # default: us-west-2
aws_role_name: Optional[str]
aws_session_name: Optional[str]
aws_profile_name: Optional[str]
aws_web_identity_token: Optional[str]
aws_sts_endpoint: Optional[str]
aws_external_id: Optional[str]
# Union type for vector store options
RAGIngestVectorStoreOptions = Union[
OpenAIVectorStoreOptions, BedrockVectorStoreOptions, VertexAIVectorStoreOptions
OpenAIVectorStoreOptions, BedrockVectorStoreOptions, VertexAIVectorStoreOptions, S3VectorsVectorStoreOptions
]
+4
View File
@@ -404,6 +404,10 @@ class LiteLLMParamsTypedDict(TypedDict, total=False):
aws_access_key_id: Optional[str]
aws_secret_access_key: Optional[str]
aws_region_name: Optional[str]
## AWS S3 VECTORS ##
vector_bucket_name: Optional[str]
index_name: Optional[str]
embedding_model: Optional[str]
## IBM WATSONX ##
watsonx_region_name: Optional[str]
## CUSTOM PRICING ##
+7 -4
View File
@@ -4,20 +4,22 @@ from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Mapping, Optional, Union
from openai._models import BaseModel as OpenAIObject
from openai.types.audio.transcription_create_params import FileTypes as FileTypes # type: ignore
from openai.types.audio.transcription_create_params import (
FileTypes as FileTypes, # type: ignore
)
from openai.types.chat.chat_completion import ChatCompletion as ChatCompletion
from openai.types.completion_usage import (
CompletionTokensDetails,
CompletionUsage,
PromptTokensDetails,
)
from openai.types.moderation import Categories as Categories
from openai.types.moderation import (
Categories as Categories,
CategoryAppliedInputTypes as CategoryAppliedInputTypes,
CategoryScores as CategoryScores,
)
from openai.types.moderation import CategoryScores as CategoryScores
from openai.types.moderation_create_response import Moderation as Moderation
from openai.types.moderation_create_response import (
Moderation as Moderation,
ModerationCreateResponse as ModerationCreateResponse,
)
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
@@ -3075,6 +3077,7 @@ class LlmProviders(str, Enum):
LLAMA = "meta_llama"
NSCALE = "nscale"
PG_VECTOR = "pg_vector"
S3_VECTORS = "s3_vectors"
HELICONE = "helicone"
HYPERBOLIC = "hyperbolic"
RECRAFT = "recraft"
+7 -1
View File
@@ -8452,6 +8452,12 @@ class ProviderConfigManager:
)
return RAGFlowVectorStoreConfig()
elif litellm.LlmProviders.S3_VECTORS == provider:
from litellm.llms.s3_vectors.vector_stores.transformation import (
S3VectorsVectorStoreConfig,
)
return S3VectorsVectorStoreConfig()
return None
@staticmethod
@@ -8699,9 +8705,9 @@ class ProviderConfigManager:
"""
Get Search configuration for a given provider.
"""
from litellm.llms.brave.search.transformation import BraveSearchConfig
from litellm.llms.dataforseo.search.transformation import DataForSEOSearchConfig
from litellm.llms.exa_ai.search.transformation import ExaAISearchConfig
from litellm.llms.brave.search.transformation import BraveSearchConfig
from litellm.llms.firecrawl.search.transformation import FirecrawlSearchConfig
from litellm.llms.google_pse.search.transformation import GooglePSESearchConfig
from litellm.llms.linkup.search.transformation import LinkupSearchConfig
+37
View File
@@ -13521,6 +13521,43 @@
"supports_vision": true,
"supports_web_search": true
},
"gemini-robotics-er-1.5-preview": {
"cache_read_input_token_cost": 0,
"input_cost_per_token": 3e-07,
"input_cost_per_audio_token": 1e-06,
"litellm_provider": "gemini",
"max_input_tokens": 1048576,
"max_output_tokens": 65535,
"max_tokens": 65535,
"mode": "chat",
"output_cost_per_token": 2.5e-06,
"output_cost_per_reasoning_token": 2.5e-06,
"source": "https://ai.google.dev/gemini-api/docs/models#gemini-robotics-er-1-5-preview",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions"
],
"supported_modalities": [
"text",
"image",
"video",
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_output": false,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_prompt_caching": false,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true
},
"gemini-2.5-computer-use-preview-10-2025": {
"input_cost_per_token": 1.25e-06,
"input_cost_per_token_above_200k_tokens": 2.5e-06,
+1
View File
@@ -68,6 +68,7 @@ jsonschema>=4.23.0,<5.0.0 # validating json schema - aligned with openapi-core +
websockets==15.0.1 # for realtime API
soundfile==0.12.1 # for audio file processing
openapi-core==0.21.0 # for OpenAPI compliance tests
pypdf>=6.6.2 # for PDF text extraction in RAG ingestion
########################
# LITELLM ENTERPRISE DEPENDENCIES
@@ -180,3 +180,68 @@ class TestEnterpriseRouteChecks:
# Should not raise exception since management routes are enabled
EnterpriseRouteChecks.should_call_route("/config/update")
class TestEnterpriseRouteChecksErrorMessages:
"""Test that error messages correctly identify which feature requires Enterprise license"""
@patch("litellm.secret_managers.main.get_secret_bool")
@patch("litellm.proxy.proxy_server.premium_user", False)
def test_disable_llm_api_endpoints_error_message(self, mock_get_secret_bool):
"""
Test that when DISABLE_LLM_API_ENDPOINTS is set without Enterprise license,
the error message correctly mentions 'LLM API ENDPOINTS'
"""
with patch.dict(os.environ, {"DISABLE_LLM_API_ENDPOINTS": "true"}):
with pytest.raises(HTTPException) as exc_info:
EnterpriseRouteChecks.is_llm_api_route_disabled()
assert exc_info.value.status_code == 500
assert "DISABLING LLM API ENDPOINTS is an Enterprise feature" in str(
exc_info.value.detail
)
@patch("litellm.secret_managers.main.get_secret_bool")
@patch("litellm.proxy.proxy_server.premium_user", False)
def test_disable_admin_endpoints_error_message(self, mock_get_secret_bool):
"""
Test that when DISABLE_ADMIN_ENDPOINTS is set without Enterprise license,
the error message correctly mentions 'ADMIN ENDPOINTS' (not 'LLM API ENDPOINTS')
This is a regression test for a bug where the error message incorrectly said
'DISABLING LLM API ENDPOINTS' when the actual issue was DISABLE_ADMIN_ENDPOINTS.
"""
with patch.dict(os.environ, {"DISABLE_ADMIN_ENDPOINTS": "true"}):
with pytest.raises(HTTPException) as exc_info:
EnterpriseRouteChecks.is_management_routes_disabled()
assert exc_info.value.status_code == 500
assert "DISABLING ADMIN ENDPOINTS is an Enterprise feature" in str(
exc_info.value.detail
)
# Ensure it does NOT mention LLM API ENDPOINTS (the old buggy message)
assert "LLM API ENDPOINTS" not in str(exc_info.value.detail)
@patch("litellm.secret_managers.main.get_secret_bool")
@patch("litellm.proxy.proxy_server.premium_user", True)
def test_disable_llm_api_endpoints_with_premium_user(self, mock_get_secret_bool):
"""
Test that premium users can use DISABLE_LLM_API_ENDPOINTS without error
"""
mock_get_secret_bool.return_value = True
with patch.dict(os.environ, {"DISABLE_LLM_API_ENDPOINTS": "true"}):
# Should not raise exception for premium users
result = EnterpriseRouteChecks.is_llm_api_route_disabled()
assert result is True
@patch("litellm.secret_managers.main.get_secret_bool")
@patch("litellm.proxy.proxy_server.premium_user", True)
def test_disable_admin_endpoints_with_premium_user(self, mock_get_secret_bool):
"""
Test that premium users can use DISABLE_ADMIN_ENDPOINTS without error
"""
mock_get_secret_bool.return_value = True
with patch.dict(os.environ, {"DISABLE_ADMIN_ENDPOINTS": "true"}):
# Should not raise exception for premium users
result = EnterpriseRouteChecks.is_management_routes_disabled()
assert result is True
+125 -1
View File
@@ -11,7 +11,10 @@ import pytest
import litellm
from base_llm_unit_tests import BaseLLMChatTest
from litellm.llms.groq.chat.transformation import GroqChatConfig
from litellm.llms.groq.chat.transformation import (
GroqChatConfig,
GroqChatCompletionStreamingHandler,
)
class TestGroq(BaseLLMChatTest):
def get_base_completion_call_args(self) -> dict:
@@ -164,3 +167,124 @@ class TestGroqStructuredOutputs:
if "tools" in result:
tool_names = [t.get("function", {}).get("name") for t in result["tools"]]
assert "json_tool_call" not in tool_names
class TestGroqReasoning:
"""
Tests for Groq reasoning field mapping.
Groq returns 'reasoning' field in delta, but LiteLLM expects 'reasoning_content'.
"""
def test_reasoning_field_mapping_in_streaming_chunks(self):
"""
Test that Groq's 'reasoning' field in streaming chunks is properly mapped
to LiteLLM's 'reasoning_content' field.
"""
handler = GroqChatCompletionStreamingHandler(
streaming_response=None, sync_stream=True
)
# Simulate a chunk with reasoning field as returned by Groq
groq_chunk = {
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 1769511767,
"model": "qwen/qwen3-32b",
"choices": [
{
"delta": {
"reasoning": "This is reasoning content",
"role": None,
},
"finish_reason": None,
"index": 0,
}
],
}
# Parse the chunk
parsed_chunk = handler.chunk_parser(groq_chunk)
# Verify that reasoning was mapped to reasoning_content
assert parsed_chunk.choices[0].delta.reasoning_content == "This is reasoning content"
# Verify that the original 'reasoning' field was removed
assert not hasattr(parsed_chunk.choices[0].delta, "reasoning")
def test_reasoning_field_not_present(self):
"""
Test that chunks without reasoning field still work correctly.
"""
handler = GroqChatCompletionStreamingHandler(
streaming_response=None, sync_stream=True
)
# Simulate a chunk without reasoning field
groq_chunk = {
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 1769511767,
"model": "qwen/qwen3-32b",
"choices": [
{
"delta": {
"content": "Regular content",
"role": "assistant",
},
"finish_reason": None,
"index": 0,
}
],
}
# Parse the chunk
parsed_chunk = handler.chunk_parser(groq_chunk)
# Verify that content is present
assert parsed_chunk.choices[0].delta.content == "Regular content"
assert parsed_chunk.choices[0].delta.role == "assistant"
# Verify that reasoning_content is not set (it should be deleted by Delta.__init__)
assert not hasattr(parsed_chunk.choices[0].delta, "reasoning_content")
def test_reasoning_with_tool_calls(self):
"""
Test that reasoning field is properly mapped even when tool_calls are present.
"""
handler = GroqChatCompletionStreamingHandler(
streaming_response=None, sync_stream=True
)
# Simulate a chunk with both reasoning and tool_calls
groq_chunk = {
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 1769511767,
"model": "qwen/qwen3-32b",
"choices": [
{
"delta": {
"reasoning": "Reasoning before tool call",
"tool_calls": [
{
"index": 0,
"id": "call_123",
"function": {"name": "test_function", "arguments": "{}"},
"type": "function",
}
],
},
"finish_reason": None,
"index": 0,
}
],
}
# Parse the chunk
parsed_chunk = handler.chunk_parser(groq_chunk)
# Verify that reasoning was mapped to reasoning_content
assert parsed_chunk.choices[0].delta.reasoning_content == "Reasoning before tool call"
# Verify tool_calls are still present
assert parsed_chunk.choices[0].delta.tool_calls is not None
assert len(parsed_chunk.choices[0].delta.tool_calls) == 1
assert parsed_chunk.choices[0].delta.tool_calls[0]["function"]["name"] == "test_function"
+2 -2
View File
@@ -584,7 +584,7 @@ async def test_get_fuzzy_user_object():
)
assert result == test_user
mock_prisma.db.litellm_usertable.find_first.assert_called_with(
where={"user_email": "test@example.com"},
where={"user_email": {"equals": "test@example.com", "mode": "insensitive"}},
include={"organization_memberships": True},
)
@@ -612,7 +612,7 @@ async def test_get_fuzzy_user_object():
)
assert result == test_user
mock_prisma.db.litellm_usertable.find_first.assert_called_with(
where={"user_email": "test@example.com"},
where={"user_email": {"equals": "test@example.com", "mode": "insensitive"}},
include={"organization_memberships": True},
)
+16 -2
View File
@@ -1117,7 +1117,10 @@ async def test_create_team_member_add(prisma_client, new_member_method):
) as mock_litellm_usertable, patch(
"litellm.proxy.auth.auth_checks._get_team_object_from_user_api_key_cache",
new=AsyncMock(return_value=team_obj),
) as mock_team_obj:
) as mock_team_obj, patch(
"litellm.proxy.proxy_server.prisma_client.get_data",
new=AsyncMock(return_value=[]),
) as mock_get_data:
mock_client = AsyncMock(
return_value=LiteLLM_UserTable(
@@ -1126,6 +1129,10 @@ async def test_create_team_member_add(prisma_client, new_member_method):
)
mock_litellm_usertable.upsert = mock_client
mock_litellm_usertable.find_many = AsyncMock(return_value=None)
# Mock find_first for user_email validation (returns None for new users)
mock_litellm_usertable.find_first = AsyncMock(return_value=None)
# Mock find_unique for user_id validation (returns None for new users)
mock_litellm_usertable.find_unique = AsyncMock(return_value=None)
team_mock_client = AsyncMock()
original_val = getattr(
litellm.proxy.proxy_server.prisma_client.db, "litellm_teamtable"
@@ -1299,7 +1306,10 @@ async def test_create_team_member_add_team_admin(
) as mock_litellm_usertable, patch(
"litellm.proxy.auth.auth_checks._get_team_object_from_user_api_key_cache",
new=AsyncMock(return_value=team_obj),
) as mock_team_obj:
) as mock_team_obj, patch(
"litellm.proxy.proxy_server.prisma_client.get_data",
new=AsyncMock(return_value=[]),
) as mock_get_data:
mock_client = AsyncMock(
return_value=LiteLLM_UserTable(
user_id="1234", max_budget=100, user_email="1234"
@@ -1307,6 +1317,10 @@ async def test_create_team_member_add_team_admin(
)
mock_litellm_usertable.upsert = mock_client
mock_litellm_usertable.find_many = AsyncMock(return_value=None)
# Mock find_first for user_email validation (returns None for new users)
mock_litellm_usertable.find_first = AsyncMock(return_value=None)
# Mock find_unique for user_id validation (returns None for new users)
mock_litellm_usertable.find_unique = AsyncMock(return_value=None)
team_mock_client = AsyncMock()
original_val = getattr(
@@ -66,6 +66,41 @@ class LargeImageClient:
)
class StreamingLargeImageClient:
"""
Client that streams a large image to test streaming download protection.
This simulates a huge file without actually creating it all in memory.
"""
def __init__(self, size_mb=100, include_content_length=False):
self.size_mb = size_mb
self.include_content_length = include_content_length
def get(self, url, follow_redirects=True):
size_bytes = int(self.size_mb * 1024 * 1024)
headers = {"Content-Type": "image/jpeg"}
if self.include_content_length:
headers["Content-Length"] = str(size_bytes)
# Create a generator that yields chunks without creating the whole file in memory
def generate_chunks(total_size, chunk_size=8192):
bytes_sent = 0
while bytes_sent < total_size:
chunk = b"x" * min(chunk_size, total_size - bytes_sent)
bytes_sent += len(chunk)
yield chunk
# Create response with streaming content
response = Response(
status_code=200,
headers=headers,
request=Request("GET", url),
)
# Mock the iter_bytes method to return our generator
response.iter_bytes = lambda chunk_size=8192: generate_chunks(size_bytes, chunk_size)
return response
def test_image_exceeds_size_limit_with_content_length(monkeypatch):
"""
Test that images exceeding MAX_IMAGE_URL_DOWNLOAD_SIZE_MB are rejected when Content-Length header is present.
@@ -83,6 +118,7 @@ def test_image_exceeds_size_limit_with_content_length(monkeypatch):
def test_image_exceeds_size_limit_without_content_length(monkeypatch):
"""
Test that images exceeding MAX_IMAGE_URL_DOWNLOAD_SIZE_MB are rejected even without Content-Length header.
This uses the old non-streaming mock for backward compatibility.
"""
monkeypatch.setattr(
litellm, "module_level_client", LargeImageClient(size_mb=100, include_content_length=False)
@@ -94,6 +130,29 @@ def test_image_exceeds_size_limit_without_content_length(monkeypatch):
assert "exceeds maximum allowed size" in str(excinfo.value)
def test_streaming_download_protects_against_huge_files(monkeypatch):
"""
Test that streaming download aborts early when file exceeds size limit,
preventing memory exhaustion from huge files (e.g., petabyte-sized files).
This test verifies that the streaming implementation doesn't download the entire
file into memory before checking size. Instead, it should abort as soon as the
limit is exceeded during streaming.
"""
# Simulate a 1GB file - far larger than the 50MB default limit
client = StreamingLargeImageClient(size_mb=1024, include_content_length=False)
monkeypatch.setattr(litellm, "module_level_client", client)
with pytest.raises(litellm.ImageFetchError) as excinfo:
convert_url_to_base64("https://example.com/huge-image.jpg")
# Verify the error message shows it was caught during streaming
assert "exceeds maximum allowed size" in str(excinfo.value)
# The error should be raised after downloading just slightly more than the limit
# not after downloading the full 1GB
class SmallImageClient:
"""
Client that returns a small valid image.
@@ -124,6 +183,26 @@ def test_image_within_size_limit(monkeypatch):
assert result.startswith("data:image/jpeg;base64,")
def test_streaming_download_handles_petabyte_file(monkeypatch):
"""
Test that streaming download can handle extremely large file URLs (e.g., petabyte-sized)
without attempting to download the entire file or causing memory exhaustion.
This simulates what happens if a malicious actor or misconfiguration provides
a URL to an extremely large file.
"""
# Simulate a 1 petabyte file (1,000,000 GB)
# Without streaming protection, this would cause OOM or hang indefinitely
client = StreamingLargeImageClient(size_mb=1_000_000_000, include_content_length=False)
monkeypatch.setattr(litellm, "module_level_client", client)
with pytest.raises(litellm.ImageFetchError) as excinfo:
convert_url_to_base64("https://example.com/petabyte-file.jpg")
# Should fail fast without downloading anywhere near 1 petabyte
assert "exceeds maximum allowed size" in str(excinfo.value)
def test_image_size_limit_disabled(monkeypatch):
"""
Test that setting MAX_IMAGE_URL_DOWNLOAD_SIZE_MB to 0 disables all image URL downloads.
@@ -279,3 +279,188 @@ def test_output_format_with_no_schema():
# Content should remain as string (not converted to list)
assert isinstance(last_user_message["content"], str)
assert last_user_message["content"] == "Hello"
def test_advanced_tool_use_header_translation_for_opus_4_5():
"""
Test that advanced-tool-use-2025-11-20 header is translated to Bedrock-specific headers
for Claude Opus 4.5.
Regression test for: Claude Code sends advanced-tool-use header which needs to be
translated to tool-search-tool-2025-10-19 and tool-examples-2025-10-29 for Bedrock
Invoke API on Claude Opus 4.5.
Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html
"""
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import (
AmazonAnthropicClaudeMessagesConfig,
)
config = AmazonAnthropicClaudeMessagesConfig()
messages = [
{"role": "user", "content": "What's the weather like?"}
]
anthropic_messages_optional_request_params = {
"max_tokens": 100,
}
# Simulate advanced-tool-use header from Claude Code
headers = {
"anthropic-beta": "advanced-tool-use-2025-11-20"
}
# Test with Claude Opus 4.5
result = config.transform_anthropic_messages_request(
model="anthropic.claude-opus-4-5-20250514-v1:0",
messages=messages,
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
litellm_params={},
headers=headers,
)
# Verify advanced-tool-use header was removed
assert "anthropic_beta" in result
beta_headers = result["anthropic_beta"]
assert "advanced-tool-use-2025-11-20" not in beta_headers, \
"advanced-tool-use header should be removed for Bedrock"
# Verify Bedrock-specific headers were added
assert "tool-search-tool-2025-10-19" in beta_headers, \
"tool-search-tool-2025-10-19 should be added for Opus 4.5"
assert "tool-examples-2025-10-29" in beta_headers, \
"tool-examples-2025-10-29 should be added for Opus 4.5"
def test_advanced_tool_use_header_filtered_for_non_opus_4_5():
"""
Test that advanced-tool-use-2025-11-20 header is filtered out for non-Opus 4.5 models
without adding Bedrock-specific headers.
The translation to tool-search-tool-2025-10-19 and tool-examples-2025-10-29 should
only happen for Claude Opus 4.5.
"""
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import (
AmazonAnthropicClaudeMessagesConfig,
)
config = AmazonAnthropicClaudeMessagesConfig()
messages = [
{"role": "user", "content": "What's the weather like?"}
]
anthropic_messages_optional_request_params = {
"max_tokens": 100,
}
# Simulate advanced-tool-use header from Claude Code
headers = {
"anthropic-beta": "advanced-tool-use-2025-11-20"
}
# Test with Claude Sonnet 4.5 (not Opus 4.5)
result = config.transform_anthropic_messages_request(
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=messages,
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
litellm_params={},
headers=headers,
)
# Verify advanced-tool-use header was removed
beta_headers = result.get("anthropic_beta", [])
assert "advanced-tool-use-2025-11-20" not in beta_headers, \
"advanced-tool-use header should be removed for Bedrock"
# Verify Bedrock-specific headers were NOT added (only for Opus 4.5)
assert "tool-search-tool-2025-10-19" not in beta_headers, \
"tool-search-tool should not be added for non-Opus 4.5 models"
assert "tool-examples-2025-10-29" not in beta_headers, \
"tool-examples should not be added for non-Opus 4.5 models"
def test_advanced_tool_use_header_translation_with_multiple_beta_headers():
"""
Test that advanced-tool-use header translation works correctly when multiple
beta headers are present.
"""
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import (
AmazonAnthropicClaudeMessagesConfig,
)
config = AmazonAnthropicClaudeMessagesConfig()
messages = [
{"role": "user", "content": "What's the weather like?"}
]
anthropic_messages_optional_request_params = {
"max_tokens": 100,
}
# Multiple beta headers including advanced-tool-use
headers = {
"anthropic-beta": "claude-code-20250219,advanced-tool-use-2025-11-20,interleaved-thinking-2025-05-14"
}
# Test with Claude Opus 4.5
result = config.transform_anthropic_messages_request(
model="anthropic.claude-opus-4-5-20250514-v1:0",
messages=messages,
anthropic_messages_optional_request_params=anthropic_messages_optional_request_params,
litellm_params={},
headers=headers,
)
beta_headers = result.get("anthropic_beta", [])
# Verify advanced-tool-use was removed
assert "advanced-tool-use-2025-11-20" not in beta_headers
# Verify Bedrock-specific headers were added
assert "tool-search-tool-2025-10-19" in beta_headers
assert "tool-examples-2025-10-29" in beta_headers
# Verify other beta headers are preserved
assert "claude-code-20250219" in beta_headers
assert "interleaved-thinking-2025-05-14" in beta_headers
def test_opus_4_5_model_detection():
"""
Test that the _is_claude_opus_4_5 method correctly identifies Opus 4.5 models
with various naming conventions.
"""
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import (
AmazonAnthropicClaudeMessagesConfig,
)
config = AmazonAnthropicClaudeMessagesConfig()
# Test various Opus 4.5 naming patterns
opus_4_5_models = [
"anthropic.claude-opus-4-5-20250514-v1:0",
"anthropic.claude-opus-4.5-20250514-v1:0",
"anthropic.claude-opus_4_5-20250514-v1:0",
"anthropic.claude-opus_4.5-20250514-v1:0",
"us.anthropic.claude-opus-4-5-20250514-v1:0",
"ANTHROPIC.CLAUDE-OPUS-4-5-20250514-V1:0", # Case insensitive
]
for model in opus_4_5_models:
assert config._is_claude_opus_4_5(model), \
f"Should detect {model} as Opus 4.5"
# Test non-Opus 4.5 models
non_opus_4_5_models = [
"anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-opus-4-20250514-v1:0", # Opus 4, not 4.5
"anthropic.claude-opus-4-1-20250514-v1:0", # Opus 4.1, not 4.5
"anthropic.claude-haiku-4-5-20251001-v1:0",
]
for model in non_opus_4_5_models:
assert not config._is_claude_opus_4_5(model), \
f"Should not detect {model} as Opus 4.5"
@@ -390,3 +390,103 @@ class TestAnthropicBetaHeaderSupport:
"anthropic_beta SHOULD be added for Anthropic models with cross-region prefix."
)
assert "context-1m-2025-08-07" in additional_fields["anthropic_beta"]
def test_messages_advanced_tool_use_translation_opus_4_5(self):
"""Test that advanced-tool-use header is translated to Bedrock-specific headers for Opus 4.5.
Regression test for: Claude Code sends advanced-tool-use-2025-11-20 header which needs
to be translated to tool-search-tool-2025-10-19 and tool-examples-2025-10-29 for
Bedrock Invoke API on Claude Opus 4.5.
Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html
"""
config = AmazonAnthropicClaudeMessagesConfig()
headers = {"anthropic-beta": "advanced-tool-use-2025-11-20"}
result = config.transform_anthropic_messages_request(
model="us.anthropic.claude-opus-4-5-20250514-v1:0",
messages=[{"role": "user", "content": "Test"}],
anthropic_messages_optional_request_params={"max_tokens": 100},
litellm_params={},
headers=headers
)
assert "anthropic_beta" in result
beta_headers = result["anthropic_beta"]
# advanced-tool-use should be removed
assert "advanced-tool-use-2025-11-20" not in beta_headers, (
"advanced-tool-use-2025-11-20 should be removed for Bedrock Invoke API"
)
# Bedrock-specific headers should be added for Opus 4.5
assert "tool-search-tool-2025-10-19" in beta_headers, (
"tool-search-tool-2025-10-19 should be added for Opus 4.5"
)
assert "tool-examples-2025-10-29" in beta_headers, (
"tool-examples-2025-10-29 should be added for Opus 4.5"
)
def test_messages_advanced_tool_use_translation_sonnet_4_5(self):
"""Test that advanced-tool-use header is translated to Bedrock-specific headers for Sonnet 4.5.
Regression test for: Claude Code sends advanced-tool-use-2025-11-20 header which needs
to be translated to tool-search-tool-2025-10-19 and tool-examples-2025-10-29 for
Bedrock Invoke API on Claude Sonnet 4.5.
Ref: https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool
"""
config = AmazonAnthropicClaudeMessagesConfig()
headers = {"anthropic-beta": "advanced-tool-use-2025-11-20"}
result = config.transform_anthropic_messages_request(
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=[{"role": "user", "content": "Test"}],
anthropic_messages_optional_request_params={"max_tokens": 100},
litellm_params={},
headers=headers
)
assert "anthropic_beta" in result
beta_headers = result["anthropic_beta"]
# advanced-tool-use should be removed
assert "advanced-tool-use-2025-11-20" not in beta_headers, (
"advanced-tool-use-2025-11-20 should be removed for Bedrock Invoke API"
)
# Bedrock-specific headers should be added for Sonnet 4.5
assert "tool-search-tool-2025-10-19" in beta_headers, (
"tool-search-tool-2025-10-19 should be added for Sonnet 4.5"
)
assert "tool-examples-2025-10-29" in beta_headers, (
"tool-examples-2025-10-29 should be added for Sonnet 4.5"
)
def test_messages_advanced_tool_use_filtered_unsupported_model(self):
"""Test that advanced-tool-use header is filtered out for models that don't support tool search.
The translation to Bedrock-specific headers should only happen for models that
support tool search on Bedrock (Opus 4.5, Sonnet 4.5).
For other models, the advanced-tool-use header should just be removed.
"""
config = AmazonAnthropicClaudeMessagesConfig()
headers = {"anthropic-beta": "advanced-tool-use-2025-11-20"}
# Test with Claude 3.5 Sonnet (does NOT support tool search on Bedrock)
result = config.transform_anthropic_messages_request(
model="us.anthropic.claude-3-5-sonnet-20241022-v2:0",
messages=[{"role": "user", "content": "Test"}],
anthropic_messages_optional_request_params={"max_tokens": 100},
litellm_params={},
headers=headers
)
beta_headers = result.get("anthropic_beta", [])
# advanced-tool-use should be removed
assert "advanced-tool-use-2025-11-20" not in beta_headers
# Bedrock-specific headers should NOT be added for unsupported models
assert "tool-search-tool-2025-10-19" not in beta_headers
assert "tool-examples-2025-10-29" not in beta_headers
@@ -0,0 +1 @@
# S3 Vectors tests
@@ -0,0 +1 @@
# S3 Vectors vector store tests
@@ -0,0 +1,115 @@
from unittest.mock import MagicMock, Mock
import httpx
import pytest
from litellm.llms.s3_vectors.vector_stores.transformation import (
S3VectorsVectorStoreConfig,
)
from litellm.types.vector_stores import VectorStoreSearchResponse
class TestS3VectorsVectorStoreConfig:
def test_init(self):
"""Test that S3VectorsVectorStoreConfig initializes correctly"""
config = S3VectorsVectorStoreConfig()
assert config is not None
def test_get_supported_openai_params(self):
"""Test that supported OpenAI params are returned"""
config = S3VectorsVectorStoreConfig()
params = config.get_supported_openai_params("test-model")
assert "max_num_results" in params
def test_get_complete_url(self):
"""Test URL generation for S3 Vectors"""
config = S3VectorsVectorStoreConfig()
litellm_params = {"aws_region_name": "us-west-2"}
url = config.get_complete_url(None, litellm_params)
assert url == "https://s3vectors.us-west-2.api.aws"
def test_get_complete_url_missing_region(self):
"""Test that missing region raises error"""
config = S3VectorsVectorStoreConfig()
litellm_params = {}
with pytest.raises(ValueError, match="aws_region_name is required"):
config.get_complete_url(None, litellm_params)
@pytest.mark.skip(reason="Requires embedding API call, tested in integration tests")
def test_transform_search_request(self):
"""Test search request transformation"""
# This test requires making an actual embedding API call
# It's better tested in integration tests
pass
def test_transform_search_request_invalid_vector_store_id(self):
"""Test that invalid vector_store_id format raises error"""
config = S3VectorsVectorStoreConfig()
mock_logging_obj = Mock()
mock_logging_obj.model_call_details = {}
with pytest.raises(
ValueError, match="vector_store_id must be in format 'bucket_name:index_name'"
):
config.transform_search_vector_store_request(
vector_store_id="invalid-format",
query="test query",
vector_store_search_optional_params={},
api_base="https://s3vectors.us-west-2.api.aws",
litellm_logging_obj=mock_logging_obj,
litellm_params={},
)
def test_transform_search_response(self):
"""Test search response transformation"""
config = S3VectorsVectorStoreConfig()
mock_logging_obj = Mock()
mock_logging_obj.model_call_details = {"query": "test query"}
mock_response = Mock(spec=httpx.Response)
mock_response.json.return_value = {
"vectors": [
{
"distance": 0.05, # S3 Vectors returns distance, not score
"metadata": {
"source_text": "This is test content",
"chunk_index": "0",
"filename": "test.pdf",
},
},
{
"distance": 0.15,
"metadata": {
"source_text": "More test content",
"chunk_index": "1",
},
},
]
}
mock_response.status_code = 200
mock_response.headers = {}
result = config.transform_search_vector_store_response(
mock_response, mock_logging_obj
)
# VectorStoreSearchResponse is a TypedDict, so check structure instead of isinstance
assert result["object"] == "vector_store.search_results.page"
assert result["search_query"] == "test query"
assert len(result["data"]) == 2
# Score should be 1 - distance (cosine similarity)
assert result["data"][0]["score"] == 0.95 # 1 - 0.05
assert result["data"][0]["content"][0]["text"] == "This is test content"
assert result["data"][0]["filename"] == "test.pdf"
assert result["data"][1]["score"] == 0.85 # 1 - 0.15
assert result["data"][1]["content"][0]["text"] == "More test content"
def test_map_openai_params(self):
"""Test OpenAI parameter mapping"""
config = S3VectorsVectorStoreConfig()
non_default_params = {"max_num_results": 5}
optional_params = {}
result = config.map_openai_params(non_default_params, optional_params, False)
assert result["maxResults"] == 5
@@ -28,6 +28,7 @@ from litellm.proxy._types import (
from litellm.proxy.auth.auth_checks import (
ExperimentalUIJWTToken,
_can_object_call_vector_stores,
_get_fuzzy_user_object,
_get_team_db_check,
_virtual_key_max_budget_alert_check,
_virtual_key_soft_budget_check,
@@ -1331,3 +1332,42 @@ async def test_virtual_key_max_budget_alert_check_scenarios(
assert (
alert_triggered == expect_alert
), f"Expected alert_triggered to be {expect_alert} for spend={spend}, max_budget={max_budget}"
@pytest.mark.asyncio
async def test_get_fuzzy_user_object_case_insensitive_email():
"""Test that _get_fuzzy_user_object uses case-insensitive email lookup"""
# Setup mock Prisma client
mock_prisma = MagicMock()
mock_prisma.db = MagicMock()
mock_prisma.db.litellm_usertable = MagicMock()
# Mock user data with mixed case email
test_user = LiteLLM_UserTable(
user_id="test_123",
sso_user_id=None,
user_email="Test@Example.com", # Mixed case in DB
organization_memberships=[],
max_budget=None,
)
# Test: SSO ID not found, find by email with different casing
mock_prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_usertable.find_first = AsyncMock(return_value=test_user)
# Search with lowercase email (different from DB)
result = await _get_fuzzy_user_object(
prisma_client=mock_prisma,
sso_user_id=None,
user_email="test@example.com", # Lowercase search
)
# Verify user was found despite case difference
assert result == test_user
# Verify the query used case-insensitive mode
mock_prisma.db.litellm_usertable.find_first.assert_called_once()
call_args = mock_prisma.db.litellm_usertable.find_first.call_args
assert call_args.kwargs["where"]["user_email"]["equals"] == "test@example.com"
assert call_args.kwargs["where"]["user_email"]["mode"] == "insensitive"
assert call_args.kwargs["include"] == {"organization_memberships": True}
@@ -0,0 +1,75 @@
"""
Test for interactions endpoint agent parameter handling.
Tests that the /v1beta/interactions endpoint correctly extracts
the `agent` parameter as a fallback when `model` is not provided.
"""
import pytest
class TestInteractionsAgentParameter:
"""Test agent parameter handling in interactions endpoint."""
def test_agent_parameter_fallback_logic(self):
"""
Test the core logic: model or agent extraction.
This tests the fix in endpoints.py line ~267:
model=data.get("model") or data.get("agent")
"""
# Case 1: Only agent provided (Deep Research use case)
data = {
"agent": "deep-research-pro-preview-12-2025",
"input": "Research quantum computing",
"background": True,
}
model = data.get("model") or data.get("agent")
assert model == "deep-research-pro-preview-12-2025"
# Case 2: Only model provided (normal use case)
data = {
"model": "gemini-2.5-flash",
"input": "Hello world",
}
model = data.get("model") or data.get("agent")
assert model == "gemini-2.5-flash"
# Case 3: Both provided (model takes precedence)
data = {
"model": "gemini-2.5-flash",
"agent": "deep-research-pro-preview-12-2025",
"input": "Test",
}
model = data.get("model") or data.get("agent")
assert model == "gemini-2.5-flash"
# Case 4: Neither provided
data = {
"input": "Test",
}
model = data.get("model") or data.get("agent")
assert model is None
def test_route_type_in_skip_model_routing_list(self):
"""
Test that acreate_interaction is in the list of routes
that skip model-based routing.
This tests the fix in route_llm_request.py.
"""
# The list of routes that skip model routing for interactions
skip_model_routing_routes = [
"acreate_interaction",
"aget_interaction",
"adelete_interaction",
"acancel_interaction",
]
# acreate_interaction should be in the list (this is the fix)
assert "acreate_interaction" in skip_model_routing_routes
# All interaction routes should be covered
assert "aget_interaction" in skip_model_routing_routes
assert "adelete_interaction" in skip_model_routing_routes
assert "acancel_interaction" in skip_model_routing_routes
@@ -36,6 +36,7 @@ from litellm.proxy.management_endpoints.team_endpoints import (
_persist_deleted_team_records,
_save_deleted_team_records,
_transform_teams_to_deleted_records,
_validate_and_populate_member_user_info,
delete_team,
router,
team_member_add_duplication_check,
@@ -5466,3 +5467,122 @@ async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client)
) and mock_db_client.db.litellm_verificationtoken.find_many.called:
# If it was called, that's unexpected for admin users
assert False, "API keys should not be fetched for team admin users"
@pytest.mark.asyncio
async def test_validate_and_populate_member_user_info_both_provided_match():
"""
Test _validate_and_populate_member_user_info when both user_email and user_id
are provided and they match the same user in the database.
"""
# Create member with both user_email and user_id
member = Member(user_email="test@example.com", user_id="user-123", role="user")
# Mock prisma client
mock_prisma_client = MagicMock()
# Mock user object that matches both email and user_id
mock_user = MagicMock()
mock_user.user_id = "user-123"
mock_user.user_email = "test@example.com"
# Mock get_data to return single user matching email
mock_prisma_client.get_data = AsyncMock(return_value=[mock_user])
# Call the function
result = await _validate_and_populate_member_user_info(
member=member,
prisma_client=mock_prisma_client,
)
# Verify result matches input (both already provided and match)
assert result.user_email == "test@example.com"
assert result.user_id == "user-123"
# Verify get_data was called with correct parameters
mock_prisma_client.get_data.assert_called_once_with(
key_val={"user_email": "test@example.com"},
table_name="user",
query_type="find_all",
)
@pytest.mark.asyncio
async def test_validate_and_populate_member_user_info_only_email_provided():
"""
Test _validate_and_populate_member_user_info when only user_email is provided.
Should populate user_id from database.
"""
# Create member with only user_email
member = Member(user_email="test@example.com", user_id=None, role="user")
# Mock prisma client
mock_prisma_client = MagicMock()
# Mock user object from find_first
mock_user_find_first = MagicMock()
mock_user_find_first.user_id = "user-456"
mock_user_find_first.user_email = "test@example.com"
# Mock find_first to return the user
mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(
return_value=mock_user_find_first
)
# Mock get_data to return single user (no duplicates)
mock_prisma_client.get_data = AsyncMock(return_value=[mock_user_find_first])
# Call the function
result = await _validate_and_populate_member_user_info(
member=member,
prisma_client=mock_prisma_client,
)
# Verify user_id was populated
assert result.user_email == "test@example.com"
assert result.user_id == "user-456"
# Verify find_first was called with correct parameters
mock_prisma_client.db.litellm_usertable.find_first.assert_called_once_with(
where={"user_email": {"equals": "test@example.com", "mode": "insensitive"}}
)
# Verify get_data was called to check for duplicates
mock_prisma_client.get_data.assert_called_once_with(
key_val={"user_email": "test@example.com"},
table_name="user",
query_type="find_all",
)
@pytest.mark.asyncio
async def test_validate_and_populate_member_user_info_only_user_id_not_found():
"""
Test _validate_and_populate_member_user_info when only user_id is provided
but the user doesn't exist in the database. Should allow it to pass with
user_email as None (will be upserted later).
"""
# Create member with only user_id
member = Member(user_email=None, user_id="nonexistent-user", role="user")
# Mock prisma client
mock_prisma_client = MagicMock()
# Mock find_unique to return None (user not found)
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
# Call the function - should NOT raise an exception
result = await _validate_and_populate_member_user_info(
member=member,
prisma_client=mock_prisma_client,
)
# Verify the result - should return member with user_id set and user_email as None
assert result.user_id == "nonexistent-user"
assert result.user_email is None
assert result.role == "user"
# Verify find_unique was called with correct parameters
mock_prisma_client.db.litellm_usertable.find_unique.assert_called_once_with(
where={"user_id": "nonexistent-user"}
)
@@ -24,6 +24,7 @@ from litellm.proxy.management_endpoints.ui_sso import (
GoogleSSOHandler,
MicrosoftSSOHandler,
SSOAuthenticationHandler,
normalize_email,
)
from litellm.types.proxy.management_endpoints.ui_sso import (
DefaultTeamSSOParams,
@@ -667,6 +668,85 @@ def test_build_sso_user_update_data_without_role():
assert "user_role" not in update_data
def test_normalize_email():
"""
Test that normalize_email correctly lowercases email addresses and handles edge cases.
"""
# Test with lowercase email
assert normalize_email("test@example.com") == "test@example.com"
# Test with uppercase email
assert normalize_email("TEST@EXAMPLE.COM") == "test@example.com"
# Test with mixed case email
assert normalize_email("Test.User@Example.COM") == "test.user@example.com"
# Test with None
assert normalize_email(None) is None
# Test with empty string
assert normalize_email("") == ""
def test_build_sso_user_update_data_normalizes_email():
"""
Test that _build_sso_user_update_data normalizes email addresses to lowercase.
"""
from litellm.proxy.management_endpoints.types import CustomOpenID
from litellm.proxy.management_endpoints.ui_sso import _build_sso_user_update_data
sso_result = CustomOpenID(
id="test-user-789",
email="Test.User@Example.COM",
display_name="Test User",
provider="microsoft",
team_ids=[],
user_role=None,
)
update_data = _build_sso_user_update_data(
result=sso_result,
user_email="Test.User@Example.COM",
user_id="test-user-789",
)
# Email should be normalized to lowercase
assert update_data["user_email"] == "test.user@example.com"
assert "user_role" not in update_data
def test_generic_response_convertor_normalizes_email():
"""
Test that generic_response_convertor normalizes email addresses.
"""
from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor
mock_response = {
"preferred_username": "user123",
"email": "Test.User@Example.COM",
"sub": "Test User",
"first_name": "Test",
"last_name": "User",
"provider": "generic",
}
# Mock JWT handler
mock_jwt_handler = MagicMock(spec=JWTHandler)
mock_jwt_handler.get_team_ids_from_jwt.return_value = []
result = generic_response_convertor(
response=mock_response,
jwt_handler=mock_jwt_handler,
sso_jwt_handler=None,
role_mappings=None,
)
# Email should be normalized to lowercase
assert result.email == "test.user@example.com"
assert result.id == "user123"
assert result.display_name == "Test User"
@pytest.mark.asyncio
async def test_upsert_sso_user_updates_role_for_existing_user():
"""
@@ -4013,6 +4013,279 @@ async def test_model_info_v2_filter_by_team_id(monkeypatch):
app.dependency_overrides = original_overrides
@pytest.mark.asyncio
@pytest.mark.parametrize(
"sort_by,sort_order,expected_order",
[
# Test model_name sorting
("model_name", "asc", ["a-model", "b-model", "z-model"]),
("model_name", "desc", ["z-model", "b-model", "a-model"]),
# Test created_at sorting
("created_at", "asc", ["old-model", "mid-model", "new-model"]),
("created_at", "desc", ["new-model", "mid-model", "old-model"]),
# Test updated_at sorting
("updated_at", "asc", ["old-updated", "mid-updated", "new-updated"]),
("updated_at", "desc", ["new-updated", "mid-updated", "old-updated"]),
# Test costs sorting
("costs", "asc", ["low-cost", "mid-cost", "high-cost"]),
("costs", "desc", ["high-cost", "mid-cost", "low-cost"]),
# Test status sorting (False/config models come before True/db models in asc)
("status", "asc", ["config-model-1", "config-model-2", "db-model"]),
("status", "desc", ["db-model", "config-model-1", "config-model-2"]),
],
)
async def test_model_info_v2_sorting(monkeypatch, sort_by, sort_order, expected_order):
"""
Test sorting functionality for /v2/model/info endpoint.
Tests all sortBy fields (model_name, created_at, updated_at, costs, status)
with both asc and desc sort orders.
"""
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.proxy_server import app, proxy_config, user_api_key_auth
# Create base time for date comparisons
base_time = datetime(2024, 1, 1, 12, 0, 0)
# Create mock models with different values for each sort field
mock_models = []
if sort_by == "model_name":
# Models with different names
mock_models = [
{
"model_name": "z-model",
"litellm_params": {"model": "z-model"},
"model_info": {"id": "z-model"},
},
{
"model_name": "a-model",
"litellm_params": {"model": "a-model"},
"model_info": {"id": "a-model"},
},
{
"model_name": "b-model",
"litellm_params": {"model": "b-model"},
"model_info": {"id": "b-model"},
},
]
elif sort_by == "created_at":
# Models with different created_at timestamps
mock_models = [
{
"model_name": "new-model",
"litellm_params": {"model": "new-model"},
"model_info": {
"id": "new-model",
"created_at": (base_time + timedelta(days=3)).isoformat(),
},
},
{
"model_name": "old-model",
"litellm_params": {"model": "old-model"},
"model_info": {
"id": "old-model",
"created_at": (base_time - timedelta(days=3)).isoformat(),
},
},
{
"model_name": "mid-model",
"litellm_params": {"model": "mid-model"},
"model_info": {
"id": "mid-model",
"created_at": base_time.isoformat(),
},
},
]
elif sort_by == "updated_at":
# Models with different updated_at timestamps
mock_models = [
{
"model_name": "new-updated",
"litellm_params": {"model": "new-updated"},
"model_info": {
"id": "new-updated",
"updated_at": (base_time + timedelta(days=3)).isoformat(),
},
},
{
"model_name": "old-updated",
"litellm_params": {"model": "old-updated"},
"model_info": {
"id": "old-updated",
"updated_at": (base_time - timedelta(days=3)).isoformat(),
},
},
{
"model_name": "mid-updated",
"litellm_params": {"model": "mid-updated"},
"model_info": {
"id": "mid-updated",
"updated_at": base_time.isoformat(),
},
},
]
elif sort_by == "costs":
# Models with different costs (input_cost + output_cost)
mock_models = [
{
"model_name": "high-cost",
"litellm_params": {"model": "high-cost"},
"model_info": {
"id": "high-cost",
"input_cost_per_token": 0.00005,
"output_cost_per_token": 0.00015,
},
},
{
"model_name": "low-cost",
"litellm_params": {"model": "low-cost"},
"model_info": {
"id": "low-cost",
"input_cost_per_token": 0.00001,
"output_cost_per_token": 0.00003,
},
},
{
"model_name": "mid-cost",
"litellm_params": {"model": "mid-cost"},
"model_info": {
"id": "mid-cost",
"input_cost_per_token": 0.00003,
"output_cost_per_token": 0.00007,
},
},
]
elif sort_by == "status":
# Models with different db_model status (False = config, True = db)
mock_models = [
{
"model_name": "db-model",
"litellm_params": {"model": "db-model"},
"model_info": {"id": "db-model", "db_model": True},
},
{
"model_name": "config-model-1",
"litellm_params": {"model": "config-model-1"},
"model_info": {"id": "config-model-1", "db_model": False},
},
{
"model_name": "config-model-2",
"litellm_params": {"model": "config-model-2"},
"model_info": {"id": "config-model-2", "db_model": False},
},
]
# Mock llm_router
mock_router = MagicMock()
mock_router.model_list = mock_models
# Mock prisma_client
mock_prisma_client = MagicMock()
# Mock proxy_config.get_config
mock_get_config = AsyncMock(return_value={})
# Mock user authentication
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
mock_user_api_key_dict.user_id = "test-user"
mock_user_api_key_dict.api_key = "test-key"
mock_user_api_key_dict.team_models = []
mock_user_api_key_dict.models = []
# Apply monkeypatches
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr("litellm.proxy.proxy_server.user_model", None)
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
# Override auth dependency
original_overrides = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: mock_user_api_key_dict
client = TestClient(app)
try:
# Test sorting with specified sortBy and sortOrder
response = client.get(
"/v2/model/info", params={"sortBy": sort_by, "sortOrder": sort_order}
)
assert response.status_code == 200
data = response.json()
assert len(data["data"]) == len(expected_order)
# Verify models are in expected order
actual_order = [m["model_name"] for m in data["data"]]
assert actual_order == expected_order, (
f"Sorting failed for sortBy={sort_by}, sortOrder={sort_order}. "
f"Expected: {expected_order}, Got: {actual_order}"
)
finally:
app.dependency_overrides = original_overrides
@pytest.mark.asyncio
async def test_model_info_v2_sorting_invalid_sort_order(monkeypatch):
"""
Test that invalid sortOrder values return a 400 error.
"""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.proxy_server import app, proxy_config, user_api_key_auth
# Create mock models
mock_models = [
{
"model_name": "test-model",
"litellm_params": {"model": "test-model"},
"model_info": {"id": "test-model"},
}
]
# Mock llm_router
mock_router = MagicMock()
mock_router.model_list = mock_models
# Mock prisma_client
mock_prisma_client = MagicMock()
# Mock proxy_config.get_config
mock_get_config = AsyncMock(return_value={})
# Mock user authentication
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
mock_user_api_key_dict.user_id = "test-user"
mock_user_api_key_dict.api_key = "test-key"
mock_user_api_key_dict.team_models = []
mock_user_api_key_dict.models = []
# Apply monkeypatches
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr("litellm.proxy.proxy_server.user_model", None)
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
# Override auth dependency
original_overrides = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: mock_user_api_key_dict
client = TestClient(app)
try:
# Test invalid sortOrder
response = client.get(
"/v2/model/info", params={"sortBy": "model_name", "sortOrder": "invalid"}
)
assert response.status_code == 400
data = response.json()
assert "Invalid sortOrder" in data["detail"]
finally:
app.dependency_overrides = original_overrides
@pytest.mark.asyncio
async def test_apply_search_filter_to_models(monkeypatch):
"""
@@ -0,0 +1,157 @@
import asyncio
from unittest.mock import MagicMock, patch
import pytest
import litellm
from litellm.router import Router
@pytest.mark.asyncio
async def test_router_silent_experiment_acompletion():
"""
Test that silent_model triggers a background acompletion call
and that the silent_model parameter is stripped from both calls.
"""
model_list = [
{
"model_name": "primary-model",
"litellm_params": {
"model": "openai/gpt-3.5-turbo",
"api_key": "fake-key",
"silent_model": "silent-model",
},
},
{
"model_name": "silent-model",
"litellm_params": {
"model": "openai/gpt-4",
"api_key": "fake-key",
},
},
]
router = Router(model_list=model_list)
# Mock litellm.acompletion
mock_acompletion = MagicMock()
# Create a future that resolves to a ModelResponse
mock_response = litellm.ModelResponse(choices=[{"message": {"content": "hello"}}])
future = asyncio.Future()
future.set_result(mock_response)
mock_acompletion.return_value = future
with patch("litellm.acompletion", mock_acompletion):
response = await router.acompletion(
model="primary-model",
messages=[{"role": "user", "content": "hi"}],
)
assert response.choices[0].message.content == "hello"
# Give the background task a moment to trigger (it's an asyncio task)
await asyncio.sleep(0.1)
# Should have 2 calls: one for primary, one for silent
assert mock_acompletion.call_count == 2
# Check call arguments
call_args_list = mock_acompletion.call_args_list
# Verify no silent_model in any call to litellm.acompletion
for call in call_args_list:
args, kwargs = call
assert "silent_model" not in kwargs
if "metadata" in kwargs:
# One call should have is_silent_experiment=True
pass
# Find the silent call
silent_call = next(
(
c
for c in call_args_list
if c[1].get("metadata", {}).get("is_silent_experiment") is True
),
None,
)
assert silent_call is not None
assert silent_call[1]["model"] == "openai/gpt-4"
# Find the primary call
primary_call = next(
(
c
for c in call_args_list
if not c[1].get("metadata", {}).get("is_silent_experiment")
),
None,
)
assert primary_call is not None
assert primary_call[1]["model"] == "openai/gpt-3.5-turbo"
def test_router_silent_experiment_completion():
"""
Test that silent_model triggers a background completion call (sync)
and that the silent_model parameter is stripped.
"""
model_list = [
{
"model_name": "primary-model",
"litellm_params": {
"model": "openai/gpt-3.5-turbo",
"api_key": "fake-key",
"silent_model": "silent-model",
},
},
{
"model_name": "silent-model",
"litellm_params": {
"model": "openai/gpt-4",
"api_key": "fake-key",
},
},
]
router = Router(model_list=model_list)
# Mock litellm.completion
mock_completion = MagicMock()
mock_response = litellm.ModelResponse(choices=[{"message": {"content": "hello"}}])
mock_completion.return_value = mock_response
with patch("litellm.completion", mock_completion):
response = router.completion(
model="primary-model",
messages=[{"role": "user", "content": "hi"}],
)
assert response.choices[0].message.content == "hello"
# The sync background call uses a thread pool. We might need to wait a bit.
import time
time.sleep(0.5)
# Should have 2 calls
assert mock_completion.call_count == 2
call_args_list = mock_completion.call_args_list
# Verify no silent_model in any call
for call in call_args_list:
args, kwargs = call
assert "silent_model" not in kwargs
# Find the silent call
silent_call = next(
(
c
for c in call_args_list
if c[1].get("metadata", {}).get("is_silent_experiment") is True
),
None,
)
assert silent_call is not None
assert silent_call[1]["model"] == "openai/gpt-4"
@@ -0,0 +1,107 @@
"""
S3 Vectors RAG ingestion tests.
Requires environment variables:
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
- AWS_REGION_NAME (optional, defaults to us-west-2)
Optional:
- S3_VECTOR_BUCKET_NAME (optional, auto-generates if not set)
"""
import os
import sys
from typing import Any, Dict, Optional
import pytest
sys.path.insert(0, os.path.abspath("../../.."))
import litellm
from litellm.types.rag import RAGIngestOptions
from tests.vector_store_tests.rag.base_rag_tests import BaseRAGTest
class TestRAGS3Vectors(BaseRAGTest):
"""Test RAG Ingest with AWS S3 Vectors."""
@pytest.fixture(autouse=True)
def check_env_vars(self):
"""Check required environment variables before each test."""
aws_key = os.environ.get("AWS_ACCESS_KEY_ID")
aws_secret = os.environ.get("AWS_SECRET_ACCESS_KEY")
if not aws_key or not aws_secret:
pytest.skip("Skipping S3 Vectors test: AWS credentials required")
def get_base_ingest_options(self) -> RAGIngestOptions:
"""
Return S3 Vectors-specific ingest options.
Chunking is configured via chunking_strategy (unified interface).
Embeddings are generated using LiteLLM's embedding API.
"""
vector_bucket_name = os.environ.get(
"S3_VECTOR_BUCKET_NAME", "test-litellm-vectors"
)
aws_region = os.environ.get("AWS_REGION_NAME", "us-west-2")
return {
"chunking_strategy": {
"chunk_size": 512,
"chunk_overlap": 100,
},
"embedding": {
"model": "text-embedding-3-small" # Can use any LiteLLM-supported model
},
"vector_store": {
"custom_llm_provider": "s3_vectors",
"vector_bucket_name": vector_bucket_name,
"index_name": "test-index",
# dimension is auto-detected from embedding model (text-embedding-3-small = 1536)
"distance_metric": "cosine",
"non_filterable_metadata_keys": ["source_text"],
"aws_region_name": aws_region,
},
}
async def query_vector_store(
self,
vector_store_id: str,
query: str,
) -> Optional[Dict[str, Any]]:
"""Query S3 Vectors index."""
try:
# Import the ingestion class to use its query method
from litellm.rag.ingestion.s3_vectors_ingestion import (
S3VectorsRAGIngestion,
)
except ImportError:
pytest.skip("S3 Vectors ingestion not available")
vector_bucket_name = os.environ.get(
"S3_VECTOR_BUCKET_NAME", "test-litellm-vectors"
)
aws_region = os.environ.get("AWS_REGION_NAME", "us-west-2")
# Create ingestion instance to use query method
ingest_options = {
"embedding": {"model": "text-embedding-3-small"},
"vector_store": {
"custom_llm_provider": "s3_vectors",
"vector_bucket_name": vector_bucket_name,
"aws_region_name": aws_region,
},
}
ingestion = S3VectorsRAGIngestion(ingest_options=ingest_options)
# Query the index
results = await ingestion.query_vector_store(
vector_store_id=vector_store_id,
query=query,
top_k=5,
)
return results
@@ -0,0 +1,42 @@
from base_vector_store_test import BaseVectorStoreTest
import os
import pytest
class TestS3VectorsVectorStore(BaseVectorStoreTest):
@pytest.fixture(autouse=True)
def check_env_vars(self):
"""Check if required environment variables are set"""
required_vars = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
missing_vars = [var for var in required_vars if not os.getenv(var)]
if missing_vars:
pytest.skip(f"Missing required environment variables: {', '.join(missing_vars)}")
def get_base_request_args(self) -> dict:
"""
Must return the base request args for searching.
For S3 Vectors, vector_store_id should be in format: bucket_name:index_name
"""
return {
"custom_llm_provider": "s3_vectors",
"vector_store_id": os.getenv(
"S3_VECTORS_VECTOR_STORE_ID", "test-litellm-vectors:test-index"
),
"query": "What is machine learning?",
"aws_region_name": os.getenv("AWS_REGION_NAME", "us-west-2"),
"aws_access_key_id": os.getenv("AWS_ACCESS_KEY_ID"),
"aws_secret_access_key": os.getenv("AWS_SECRET_ACCESS_KEY"),
}
def get_base_create_vector_store_args(self) -> dict:
"""
Vector store creation is not yet implemented for S3 Vectors.
This test will be skipped.
"""
return {}
@pytest.mark.parametrize("sync_mode", [True, False])
@pytest.mark.asyncio
async def test_basic_create_vector_store(self, sync_mode):
"""S3 Vectors doesn't support vector store creation via this API yet"""
pytest.skip("Vector store creation not yet implemented for S3 Vectors")
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

@@ -17,297 +17,300 @@ export const columns = (
expandedRows: Set<string>,
setExpandedRows: (expandedRows: Set<string>) => void,
): ColumnDef<ModelData>[] => [
{
header: () => <span className="text-sm font-semibold">Model ID</span>,
accessorKey: "model_info.id",
cell: ({ row }) => {
const model = row.original;
return (
<Tooltip title={model.model_info.id}>
<div
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left w-full truncate whitespace-nowrap cursor-pointer max-w-[15ch]"
onClick={() => setSelectedModelId(model.model_info.id)}
>
{model.model_info.id}
</div>
</Tooltip>
);
},
},
{
header: () => <span className="text-sm font-semibold">Model Information</span>,
accessorKey: "model_name",
size: 250, // Fixed column width
cell: ({ row }) => {
const model = row.original;
const displayName = getDisplayModelName(row.original) || "-";
const tooltipContent = (
<div>
<div>
<strong>Provider:</strong> {model.provider || "-"}
</div>
<div>
<strong>Public Model Name:</strong> {displayName}
</div>
<div>
<strong>LiteLLM Model Name:</strong> {model.litellm_model_name || "-"}
</div>
</div>
);
return (
<Tooltip title={tooltipContent}>
<div className="flex items-start space-x-2 min-w-0 w-full max-w-[250px]">
{/* Provider Icon */}
<div className="flex-shrink-0 mt-0.5">
{model.provider ? (
<ProviderLogo provider={model.provider} />
) : (
<div className="w-4 h-4 rounded-full bg-gray-200 flex items-center justify-center text-xs">-</div>
)}
</div>
{/* Model Names Container */}
<div className="flex flex-col min-w-0 flex-1">
{/* Public Model Name */}
<div className="text-xs font-medium text-gray-900 truncate max-w-[210px]">{displayName}</div>
{/* LiteLLM Model Name */}
<div className="text-xs text-gray-500 truncate mt-0.5 max-w-[210px]">
{model.litellm_model_name || "-"}
</div>
</div>
</div>
</Tooltip>
);
},
},
{
header: () => <span className="text-sm font-semibold">Credentials</span>,
accessorKey: "litellm_credential_name",
size: 180, // Fixed column width
cell: ({ row }) => {
const model = row.original;
const credentialName = model.litellm_params?.litellm_credential_name;
return credentialName ? (
<Tooltip title={`Credential: ${credentialName}`}>
<div className="flex items-center space-x-2 max-w-[180px]">
<KeyIcon className="w-4 h-4 text-blue-500 flex-shrink-0" />
<span className="text-xs truncate" title={credentialName}>
{credentialName}
</span>
</div>
</Tooltip>
) : (
<div className="flex items-center space-x-2 max-w-[180px]">
<KeyIcon className="w-4 h-4 text-gray-300 flex-shrink-0" />
<span className="text-xs text-gray-400">No credentials</span>
</div>
);
},
},
{
header: () => <span className="text-sm font-semibold">Created By</span>,
accessorKey: "model_info.created_by",
sortingFn: "datetime",
size: 160, // Fixed column width
cell: ({ row }) => {
const model = row.original;
const isConfigModel = !model.model_info?.db_model;
const createdBy = model.model_info.created_by;
const createdAt = model.model_info.created_at ? new Date(model.model_info.created_at).toLocaleDateString() : null;
return (
<div className="flex flex-col min-w-0 max-w-[160px]">
{/* Created By - Primary */}
<div
className="text-xs font-medium text-gray-900 truncate"
title={isConfigModel ? "Defined in config" : createdBy || "Unknown"}
>
{isConfigModel ? "Defined in config" : createdBy || "Unknown"}
</div>
{/* Created At - Secondary */}
<div
className="text-xs text-gray-500 truncate mt-0.5"
title={isConfigModel ? "Config file" : createdAt || "Unknown date"}
>
{isConfigModel ? "-" : createdAt || "Unknown date"}
</div>
</div>
);
},
},
{
header: () => <span className="text-sm font-semibold">Updated At</span>,
accessorKey: "model_info.updated_at",
sortingFn: "datetime",
cell: ({ row }) => {
const model = row.original;
return (
<span className="text-xs">
{model.model_info.updated_at ? new Date(model.model_info.updated_at).toLocaleDateString() : "-"}
</span>
);
},
},
{
header: () => <span className="text-sm font-semibold">Costs</span>,
accessorKey: "input_cost",
size: 120, // Fixed column width
cell: ({ row }) => {
const model = row.original;
const inputCost = model.input_cost;
const outputCost = model.output_cost;
// If both costs are missing or undefined, show "-"
if (!inputCost && !outputCost) {
{
header: () => <span className="text-sm font-semibold">Model ID</span>,
accessorKey: "model_info.id",
enableSorting: false,
cell: ({ row }) => {
const model = row.original;
return (
<div className="max-w-[120px]">
<span className="text-xs text-gray-400">-</span>
<Tooltip title={model.model_info.id}>
<div
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left w-full truncate whitespace-nowrap cursor-pointer max-w-[15ch]"
onClick={() => setSelectedModelId(model.model_info.id)}
>
{model.model_info.id}
</div>
</Tooltip>
);
},
},
{
header: () => <span className="text-sm font-semibold">Model Information</span>,
accessorKey: "model_name",
size: 250, // Fixed column width
cell: ({ row }) => {
const model = row.original;
const displayName = getDisplayModelName(row.original) || "-";
const tooltipContent = (
<div>
<div>
<strong>Provider:</strong> {model.provider || "-"}
</div>
<div>
<strong>Public Model Name:</strong> {displayName}
</div>
<div>
<strong>LiteLLM Model Name:</strong> {model.litellm_model_name || "-"}
</div>
</div>
);
}
return (
<Tooltip title="Cost per 1M tokens">
<div className="flex flex-col min-w-0 max-w-[120px]">
{/* Input Cost - Primary */}
{inputCost && <div className="text-xs font-medium text-gray-900 truncate">In: ${inputCost}</div>}
{/* Output Cost - Secondary */}
{outputCost && <div className="text-xs text-gray-500 truncate mt-0.5">Out: ${outputCost}</div>}
</div>
</Tooltip>
);
},
},
{
header: () => <span className="text-sm font-semibold">Team ID</span>,
accessorKey: "model_info.team_id",
cell: ({ row }) => {
const model = row.original;
return model.model_info.team_id ? (
<div className="overflow-hidden">
<Tooltip title={model.model_info.team_id}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
onClick={() => setSelectedTeamId(model.model_info.team_id)}
>
{model.model_info.team_id.slice(0, 7)}...
</Button>
return (
<Tooltip title={tooltipContent}>
<div className="flex items-start space-x-2 min-w-0 w-full max-w-[250px]">
{/* Provider Icon */}
<div className="flex-shrink-0 mt-0.5">
{model.provider ? (
<ProviderLogo provider={model.provider} />
) : (
<div className="w-4 h-4 rounded-full bg-gray-200 flex items-center justify-center text-xs">-</div>
)}
</div>
{/* Model Names Container */}
<div className="flex flex-col min-w-0 flex-1">
{/* Public Model Name */}
<div className="text-xs font-medium text-gray-900 truncate max-w-[210px]">{displayName}</div>
{/* LiteLLM Model Name */}
<div className="text-xs text-gray-500 truncate mt-0.5 max-w-[210px]">
{model.litellm_model_name || "-"}
</div>
</div>
</div>
</Tooltip>
</div>
) : (
"-"
);
);
},
},
},
{
header: () => <span className="text-sm font-semibold">Model Access Group</span>,
accessorKey: "model_info.model_access_group",
enableSorting: false,
cell: ({ row }) => {
const model = row.original;
const accessGroups = model.model_info.access_groups;
{
header: () => <span className="text-sm font-semibold">Credentials</span>,
accessorKey: "litellm_credential_name",
enableSorting: false,
size: 180, // Fixed column width
cell: ({ row }) => {
const model = row.original;
const credentialName = model.litellm_params?.litellm_credential_name;
if (!accessGroups || accessGroups.length === 0) {
return "-";
}
return credentialName ? (
<Tooltip title={`Credential: ${credentialName}`}>
<div className="flex items-center space-x-2 max-w-[180px]">
<KeyIcon className="w-4 h-4 text-blue-500 flex-shrink-0" />
<span className="text-xs truncate" title={credentialName}>
{credentialName}
</span>
</div>
</Tooltip>
) : (
<div className="flex items-center space-x-2 max-w-[180px]">
<KeyIcon className="w-4 h-4 text-gray-300 flex-shrink-0" />
<span className="text-xs text-gray-400">No credentials</span>
</div>
);
},
},
{
header: () => <span className="text-sm font-semibold">Created By</span>,
accessorKey: "model_info.created_by",
sortingFn: "datetime",
size: 160, // Fixed column width
cell: ({ row }) => {
const model = row.original;
const isConfigModel = !model.model_info?.db_model;
const createdBy = model.model_info.created_by;
const createdAt = model.model_info.created_at ? new Date(model.model_info.created_at).toLocaleDateString() : null;
const modelId = model.model_info.id;
const isExpanded = expandedRows.has(modelId);
const shouldShowExpandButton = accessGroups.length > 1;
const toggleExpanded = () => {
const newExpanded = new Set(expandedRows);
if (isExpanded) {
newExpanded.delete(modelId);
} else {
newExpanded.add(modelId);
}
setExpandedRows(newExpanded);
};
return (
<div className="flex items-center gap-1 overflow-hidden">
<Badge size="xs" color="blue" className="text-xs px-1.5 py-0.5 h-5 leading-tight flex-shrink-0">
{accessGroups[0]}
</Badge>
{(isExpanded || (!shouldShowExpandButton && accessGroups.length === 2)) &&
accessGroups.slice(1).map((group: string, index: number) => (
<Badge
key={index + 1}
size="xs"
color="blue"
className="text-xs px-1.5 py-0.5 h-5 leading-tight flex-shrink-0"
>
{group}
</Badge>
))}
{shouldShowExpandButton && (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpanded();
}}
className="text-xs text-blue-600 hover:text-blue-800 px-1 py-0.5 rounded hover:bg-blue-50 h-5 leading-tight flex-shrink-0 whitespace-nowrap"
return (
<div className="flex flex-col min-w-0 max-w-[160px]">
{/* Created By - Primary */}
<div
className="text-xs font-medium text-gray-900 truncate"
title={isConfigModel ? "Defined in config" : createdBy || "Unknown"}
>
{isExpanded ? "" : `+${accessGroups.length - 1}`}
</button>
)}
</div>
);
{isConfigModel ? "Defined in config" : createdBy || "Unknown"}
</div>
{/* Created At - Secondary */}
<div
className="text-xs text-gray-500 truncate mt-0.5"
title={isConfigModel ? "Config file" : createdAt || "Unknown date"}
>
{isConfigModel ? "-" : createdAt || "Unknown date"}
</div>
</div>
);
},
},
},
{
header: () => <span className="text-sm font-semibold">Status</span>,
accessorKey: "model_info.db_model",
cell: ({ row }) => {
const model = row.original;
return (
<div
className={`
{
header: () => <span className="text-sm font-semibold">Updated At</span>,
accessorKey: "model_info.updated_at",
sortingFn: "datetime",
cell: ({ row }) => {
const model = row.original;
return (
<span className="text-xs">
{model.model_info.updated_at ? new Date(model.model_info.updated_at).toLocaleDateString() : "-"}
</span>
);
},
},
{
header: () => <span className="text-sm font-semibold">Costs</span>,
accessorKey: "input_cost",
size: 120, // Fixed column width
cell: ({ row }) => {
const model = row.original;
const inputCost = model.input_cost;
const outputCost = model.output_cost;
// If both costs are missing or undefined, show "-"
if (!inputCost && !outputCost) {
return (
<div className="max-w-[120px]">
<span className="text-xs text-gray-400">-</span>
</div>
);
}
return (
<Tooltip title="Cost per 1M tokens">
<div className="flex flex-col min-w-0 max-w-[120px]">
{/* Input Cost - Primary */}
{inputCost && <div className="text-xs font-medium text-gray-900 truncate">In: ${inputCost}</div>}
{/* Output Cost - Secondary */}
{outputCost && <div className="text-xs text-gray-500 truncate mt-0.5">Out: ${outputCost}</div>}
</div>
</Tooltip>
);
},
},
{
header: () => <span className="text-sm font-semibold">Team ID</span>,
accessorKey: "model_info.team_id",
enableSorting: false,
cell: ({ row }) => {
const model = row.original;
return model.model_info.team_id ? (
<div className="overflow-hidden">
<Tooltip title={model.model_info.team_id}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
onClick={() => setSelectedTeamId(model.model_info.team_id)}
>
{model.model_info.team_id.slice(0, 7)}...
</Button>
</Tooltip>
</div>
) : (
"-"
);
},
},
{
header: () => <span className="text-sm font-semibold">Model Access Group</span>,
accessorKey: "model_info.model_access_group",
enableSorting: false,
cell: ({ row }) => {
const model = row.original;
const accessGroups = model.model_info.access_groups;
if (!accessGroups || accessGroups.length === 0) {
return "-";
}
const modelId = model.model_info.id;
const isExpanded = expandedRows.has(modelId);
const shouldShowExpandButton = accessGroups.length > 1;
const toggleExpanded = () => {
const newExpanded = new Set(expandedRows);
if (isExpanded) {
newExpanded.delete(modelId);
} else {
newExpanded.add(modelId);
}
setExpandedRows(newExpanded);
};
return (
<div className="flex items-center gap-1 overflow-hidden">
<Badge size="xs" color="blue" className="text-xs px-1.5 py-0.5 h-5 leading-tight flex-shrink-0">
{accessGroups[0]}
</Badge>
{(isExpanded || (!shouldShowExpandButton && accessGroups.length === 2)) &&
accessGroups.slice(1).map((group: string, index: number) => (
<Badge
key={index + 1}
size="xs"
color="blue"
className="text-xs px-1.5 py-0.5 h-5 leading-tight flex-shrink-0"
>
{group}
</Badge>
))}
{shouldShowExpandButton && (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpanded();
}}
className="text-xs text-blue-600 hover:text-blue-800 px-1 py-0.5 rounded hover:bg-blue-50 h-5 leading-tight flex-shrink-0 whitespace-nowrap"
>
{isExpanded ? "" : `+${accessGroups.length - 1}`}
</button>
)}
</div>
);
},
},
{
header: () => <span className="text-sm font-semibold">Status</span>,
accessorKey: "model_info.db_model",
cell: ({ row }) => {
const model = row.original;
return (
<div
className={`
inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
${model.model_info.db_model ? "bg-blue-50 text-blue-600" : "bg-gray-100 text-gray-600"}
`}
>
{model.model_info.db_model ? "DB Model" : "Config Model"}
</div>
);
>
{model.model_info.db_model ? "DB Model" : "Config Model"}
</div>
);
},
},
},
{
id: "actions",
header: () => <span className="text-sm font-semibold">Actions</span>,
cell: ({ row }) => {
const model = row.original;
const canEditModel = userRole === "Admin" || model.model_info?.created_by === userID;
const isConfigModel = !model.model_info?.db_model;
return (
<div className="flex items-center justify-end gap-2 pr-4">
{isConfigModel ? (
<Tooltip title="Config model cannot be deleted on the dashboard. Please delete it from the config file.">
<Icon icon={TrashIcon} size="sm" className="opacity-50 cursor-not-allowed" />
</Tooltip>
) : (
<Tooltip title="Delete model">
<Icon
icon={TrashIcon}
size="sm"
onClick={() => {
if (canEditModel) {
setSelectedModelId(model.model_info.id);
}
}}
className={!canEditModel ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:text-red-600"}
/>
</Tooltip>
)}
</div>
);
{
id: "actions",
header: () => <span className="text-sm font-semibold">Actions</span>,
cell: ({ row }) => {
const model = row.original;
const canEditModel = userRole === "Admin" || model.model_info?.created_by === userID;
const isConfigModel = !model.model_info?.db_model;
return (
<div className="flex items-center justify-end gap-2 pr-4">
{isConfigModel ? (
<Tooltip title="Config model cannot be deleted on the dashboard. Please delete it from the config file.">
<Icon icon={TrashIcon} size="sm" className="opacity-50 cursor-not-allowed" />
</Tooltip>
) : (
<Tooltip title="Delete model">
<Icon
icon={TrashIcon}
size="sm"
onClick={() => {
if (canEditModel) {
setSelectedModelId(model.model_info.id);
}
}}
className={!canEditModel ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:text-red-600"}
/>
</Tooltip>
)}
</div>
);
},
},
},
];
];
@@ -219,7 +219,7 @@ const Navbar: React.FC<NavbarProps> = ({
style={{ animationDuration: "2s" }}
title="Happy Holidays!"
>
🎄
</span>
</div>
</Link>
@@ -6954,7 +6954,8 @@ export const ragIngestCall = async (
customLlmProvider: string,
vectorStoreId?: string,
vectorStoreName?: string,
vectorStoreDescription?: string
vectorStoreDescription?: string,
providerSpecificParams?: Record<string, any>
): Promise<any> => {
try {
let url = proxyBaseUrl ? `${proxyBaseUrl}/rag/ingest` : `/rag/ingest`;
@@ -6967,6 +6968,7 @@ export const ragIngestCall = async (
vector_store: {
custom_llm_provider: customLlmProvider,
...(vectorStoreId && { vector_store_id: vectorStoreId }),
...(providerSpecificParams && providerSpecificParams),
},
},
};
@@ -22,17 +22,51 @@ vi.mock("../vector_store_providers", () => ({
BEDROCK: "Amazon Bedrock",
OPENAI: "OpenAI",
AZURE_OPENAI: "Azure OpenAI",
S3Vectors: "AWS S3 Vectors",
},
vectorStoreProviderMap: {
BEDROCK: "bedrock",
OPENAI: "openai",
AZURE_OPENAI: "azure_openai",
S3Vectors: "s3_vectors",
},
vectorStoreProviderLogoMap: {
"Amazon Bedrock": "https://example.com/bedrock.png",
"OpenAI": "https://example.com/openai.png",
"Azure OpenAI": "https://example.com/azure.png",
"AWS S3 Vectors": "https://example.com/aws.png",
},
getProviderSpecificFields: vi.fn((provider: string) => {
if (provider === "s3_vectors") {
return [
{
name: "vector_bucket_name",
label: "Vector Bucket Name",
tooltip: "S3 bucket name for vector storage",
placeholder: "my-vector-bucket",
required: true,
type: "text",
},
{
name: "aws_region_name",
label: "AWS Region",
tooltip: "AWS region",
placeholder: "us-west-2",
required: true,
type: "text",
},
{
name: "embedding_model",
label: "Embedding Model",
tooltip: "Embedding model to use",
placeholder: "text-embedding-3-small",
required: true,
type: "select",
},
];
}
return [];
}),
}));
describe("CreateVectorStore", () => {
@@ -43,9 +77,9 @@ describe("CreateVectorStore", () => {
it("should render the component successfully", () => {
render(<CreateVectorStore accessToken="test-token" />);
expect(screen.getByText("Create Vector Store")).toBeInTheDocument();
expect(screen.getAllByText("Create Vector Store").length).toBeGreaterThan(0);
expect(screen.getByText("Step 1: Upload Documents")).toBeInTheDocument();
expect(screen.getByText("Step 2: Select Provider")).toBeInTheDocument();
expect(screen.getByText("Step 2: Configure Vector Store")).toBeInTheDocument();
});
it("should display upload area with correct text", () => {
@@ -123,7 +157,15 @@ describe("CreateVectorStore", () => {
});
await waitFor(() => {
expect(mockRagIngestCall).toHaveBeenCalledWith("test-token", expect.any(File), "bedrock", undefined);
expect(mockRagIngestCall).toHaveBeenCalledWith(
"test-token",
expect.any(File),
"bedrock",
undefined,
undefined,
undefined,
{}
);
});
});
@@ -163,4 +205,72 @@ describe("CreateVectorStore", () => {
expect(screen.getByText("Vector Store Created Successfully")).toBeInTheDocument();
});
});
it("should display S3 Vectors provider-specific fields when selected", async () => {
render(<CreateVectorStore accessToken="test-token" />);
// Find and click the provider dropdown
const providerSelect = screen.getByRole("combobox");
await act(async () => {
fireEvent.mouseDown(providerSelect);
});
// Wait for dropdown options to appear
await waitFor(() => {
const s3Option = screen.queryByText("AWS S3 Vectors");
if (s3Option) {
fireEvent.click(s3Option);
}
});
// Check if S3-specific fields are displayed
await waitFor(() => {
expect(screen.queryByText("Vector Bucket Name")).toBeInTheDocument();
expect(screen.queryByText("AWS Region")).toBeInTheDocument();
expect(screen.queryByText("Embedding Model")).toBeInTheDocument();
});
});
it("should validate S3 Vectors required fields before submission", async () => {
render(<CreateVectorStore accessToken="test-token" />);
// Upload a file first
const file = new File(["test content"], "test.pdf", { type: "application/pdf" });
const uploadInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
if (uploadInput) {
fireEvent.change(uploadInput, { target: { files: [file] } });
}
});
await waitFor(() => {
expect(screen.getByText("Uploaded Documents (1)")).toBeInTheDocument();
});
// Select S3 Vectors provider
const providerSelect = screen.getByRole("combobox");
await act(async () => {
fireEvent.mouseDown(providerSelect);
});
await waitFor(() => {
const s3Option = screen.queryByText("AWS S3 Vectors");
if (s3Option) {
fireEvent.click(s3Option);
}
});
// Try to create without filling required fields
const createButton = screen.getByRole("button", { name: /Create Vector Store/i });
await act(async () => {
fireEvent.click(createButton);
});
// Should show validation warning (mocked message.warning would be called)
// The actual validation happens in the component
});
});
@@ -10,8 +10,11 @@ import {
VectorStoreProviders,
vectorStoreProviderLogoMap,
vectorStoreProviderMap,
getProviderSpecificFields,
VectorStoreFieldConfig,
} from "../vector_store_providers";
import NotificationsManager from "../molecules/notifications_manager";
import S3VectorsConfig from "./S3VectorsConfig";
const { Dragger } = Upload;
@@ -28,6 +31,7 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
const [vectorStoreName, setVectorStoreName] = useState<string>("");
const [vectorStoreDescription, setVectorStoreDescription] = useState<string>("");
const [ingestResults, setIngestResults] = useState<RAGIngestResponse[]>([]);
const [providerParams, setProviderParams] = useState<Record<string, any>>({});
const uploadProps: UploadProps = {
name: "file",
@@ -92,6 +96,27 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
return;
}
// Validate provider-specific required fields
const requiredFields = getProviderSpecificFields(selectedProvider).filter((field) => field.required);
for (const field of requiredFields) {
if (!providerParams[field.name]) {
message.warning(`Please provide ${field.label}`);
return;
}
}
// S3 Vectors specific validation
if (selectedProvider === "s3_vectors") {
if (providerParams.vector_bucket_name && providerParams.vector_bucket_name.length < 3) {
message.warning("Vector bucket name must be at least 3 characters");
return;
}
if (providerParams.index_name && providerParams.index_name.length > 0 && providerParams.index_name.length < 3) {
message.warning("Index name must be at least 3 characters if provided");
return;
}
}
if (!accessToken) {
message.error("No access token available");
return;
@@ -118,7 +143,8 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
selectedProvider,
vectorStoreId, // Use the same vector store ID for subsequent uploads
vectorStoreName || undefined,
vectorStoreDescription || undefined
vectorStoreDescription || undefined,
providerParams
);
// Store the vector store ID from the first successful ingest
@@ -298,6 +324,74 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
})}
</Select>
</Form.Item>
{/* S3 Vectors Configuration */}
{selectedProvider === "s3_vectors" && (
<S3VectorsConfig
accessToken={accessToken}
providerParams={providerParams}
onParamsChange={setProviderParams}
/>
)}
{/* Other Provider-specific fields */}
{selectedProvider !== "s3_vectors" &&
getProviderSpecificFields(selectedProvider).map((field: VectorStoreFieldConfig) => {
if (field.type === "select") {
// For embedding model selection, we'd need to fetch available models
// For now, provide a text input as fallback
return (
<Form.Item
key={field.name}
label={
<span>
{field.label}{" "}
<Tooltip title={field.tooltip}>
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
required={field.required}
>
<Input
value={providerParams[field.name] || ""}
onChange={(e) =>
setProviderParams((prev) => ({ ...prev, [field.name]: e.target.value }))
}
placeholder={field.placeholder}
size="large"
className="rounded-md"
/>
</Form.Item>
);
}
return (
<Form.Item
key={field.name}
label={
<span>
{field.label}{" "}
<Tooltip title={field.tooltip}>
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
required={field.required}
>
<Input
type={field.type === "password" ? "password" : "text"}
value={providerParams[field.name] || ""}
onChange={(e) =>
setProviderParams((prev) => ({ ...prev, [field.name]: e.target.value }))
}
placeholder={field.placeholder}
size="large"
className="rounded-md"
/>
</Form.Item>
);
})}
</Form>
<div className="flex justify-end">
@@ -62,7 +62,7 @@ describe("DocumentsTable", () => {
render(<DocumentsTable documents={mockDocuments} onRemove={onRemove} />);
expect(screen.getByText(/1000.00 KB/)).toBeInTheDocument();
expect(screen.getByText(/2.00 MB/)).toBeInTheDocument();
expect(screen.getByText(/1.95 MB/)).toBeInTheDocument();
expect(screen.getByText(/500.00 KB/)).toBeInTheDocument();
});
@@ -0,0 +1,203 @@
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import S3VectorsConfig from "./S3VectorsConfig";
import * as fetchModels from "../playground/llm_calls/fetch_models";
// Mock fetchAvailableModels
vi.mock("../playground/llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn(),
}));
describe("S3VectorsConfig", () => {
const mockOnParamsChange = vi.fn();
const defaultProps = {
accessToken: "test-token",
providerParams: {},
onParamsChange: mockOnParamsChange,
};
beforeEach(() => {
vi.clearAllMocks();
});
it("should render the component successfully", () => {
vi.spyOn(fetchModels, "fetchAvailableModels").mockResolvedValue([]);
render(<S3VectorsConfig {...defaultProps} />);
expect(screen.getByText("AWS S3 Vectors Setup")).toBeInTheDocument();
expect(screen.getByText("Vector Bucket Name")).toBeInTheDocument();
expect(screen.getByText("Index Name")).toBeInTheDocument();
expect(screen.getByText("AWS Region")).toBeInTheDocument();
expect(screen.getByText("Embedding Model")).toBeInTheDocument();
});
it("should display setup instructions", () => {
vi.spyOn(fetchModels, "fetchAvailableModels").mockResolvedValue([]);
render(<S3VectorsConfig {...defaultProps} />);
expect(
screen.getByText(/AWS S3 Vectors allows you to store and query vector embeddings directly in S3/)
).toBeInTheDocument();
expect(screen.getByText(/Vector buckets and indexes will be automatically created/)).toBeInTheDocument();
expect(screen.getByText(/Vector dimensions are auto-detected/)).toBeInTheDocument();
});
it("should fetch embedding models on mount", async () => {
const mockModels = [
{ model_group: "text-embedding-3-small", mode: "embedding" },
{ model_group: "text-embedding-3-large", mode: "embedding" },
{ model_group: "gpt-4", mode: "chat" },
];
const fetchSpy = vi.spyOn(fetchModels, "fetchAvailableModels").mockResolvedValue(mockModels);
render(<S3VectorsConfig {...defaultProps} />);
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith("test-token");
});
});
it("should filter and display only embedding models", async () => {
const mockModels = [
{ model_group: "text-embedding-3-small", mode: "embedding" },
{ model_group: "text-embedding-3-large", mode: "embedding" },
{ model_group: "gpt-4", mode: "chat" },
{ model_group: "gpt-3.5-turbo", mode: "chat" },
];
vi.spyOn(fetchModels, "fetchAvailableModels").mockResolvedValue(mockModels);
render(<S3VectorsConfig {...defaultProps} />);
// Wait for models to load
await waitFor(() => {
expect(fetchModels.fetchAvailableModels).toHaveBeenCalled();
});
// The component should filter to only embedding models internally
// We can verify this by checking the component loaded successfully
expect(screen.getByText("Embedding Model")).toBeInTheDocument();
});
it("should call onParamsChange when vector bucket name changes", async () => {
vi.spyOn(fetchModels, "fetchAvailableModels").mockResolvedValue([]);
render(<S3VectorsConfig {...defaultProps} />);
const bucketInput = screen.getByPlaceholderText("my-vector-bucket (min 3 chars)");
await act(async () => {
fireEvent.change(bucketInput, { target: { value: "test-bucket" } });
});
expect(mockOnParamsChange).toHaveBeenCalledWith({
vector_bucket_name: "test-bucket",
});
});
it("should call onParamsChange when AWS region changes", async () => {
vi.spyOn(fetchModels, "fetchAvailableModels").mockResolvedValue([]);
render(<S3VectorsConfig {...defaultProps} />);
const regionInput = screen.getByPlaceholderText("us-west-2");
await act(async () => {
fireEvent.change(regionInput, { target: { value: "us-east-1" } });
});
expect(mockOnParamsChange).toHaveBeenCalledWith({
aws_region_name: "us-east-1",
});
});
it("should call onParamsChange when embedding model is selected", async () => {
const mockModels = [
{ model_group: "text-embedding-3-small", mode: "embedding" },
{ model_group: "text-embedding-3-large", mode: "embedding" },
];
vi.spyOn(fetchModels, "fetchAvailableModels").mockResolvedValue(mockModels);
render(<S3VectorsConfig {...defaultProps} />);
await waitFor(() => {
expect(fetchModels.fetchAvailableModels).toHaveBeenCalled();
});
// Find the Select component and trigger change directly
const selectElement = screen.getByRole("combobox");
await act(async () => {
// Simulate selecting a value by firing the change event
fireEvent.change(selectElement, { target: { value: "text-embedding-3-small" } });
});
// The component should handle the selection
expect(screen.getByText("Embedding Model")).toBeInTheDocument();
});
it("should preserve existing params when updating a field", async () => {
vi.spyOn(fetchModels, "fetchAvailableModels").mockResolvedValue([]);
const existingParams = {
vector_bucket_name: "existing-bucket",
aws_region_name: "us-west-2",
};
render(<S3VectorsConfig {...defaultProps} providerParams={existingParams} />);
const indexInput = screen.getByPlaceholderText("my-vector-index (optional, min 3 chars)");
await act(async () => {
fireEvent.change(indexInput, { target: { value: "my-index" } });
});
expect(mockOnParamsChange).toHaveBeenCalledWith({
vector_bucket_name: "existing-bucket",
aws_region_name: "us-west-2",
index_name: "my-index",
});
});
it("should display existing param values", () => {
vi.spyOn(fetchModels, "fetchAvailableModels").mockResolvedValue([]);
const existingParams = {
vector_bucket_name: "my-bucket",
index_name: "my-index",
aws_region_name: "eu-west-1",
embedding_model: "text-embedding-3-small",
};
render(<S3VectorsConfig {...defaultProps} providerParams={existingParams} />);
expect(screen.getByDisplayValue("my-bucket")).toBeInTheDocument();
expect(screen.getByDisplayValue("my-index")).toBeInTheDocument();
expect(screen.getByDisplayValue("eu-west-1")).toBeInTheDocument();
});
it("should handle model fetch error gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(fetchModels, "fetchAvailableModels").mockRejectedValue(new Error("Failed to fetch models"));
render(<S3VectorsConfig {...defaultProps} />);
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith("Error fetching embedding models:", expect.any(Error));
});
consoleErrorSpy.mockRestore();
});
it("should not fetch models if accessToken is null", () => {
const fetchSpy = vi.spyOn(fetchModels, "fetchAvailableModels");
render(<S3VectorsConfig {...defaultProps} accessToken={null} />);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,192 @@
import React, { useState, useEffect } from "react";
import { Alert, Form, Input, Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { fetchAvailableModels, ModelGroup } from "../playground/llm_calls/fetch_models";
interface S3VectorsConfigProps {
accessToken: string | null;
providerParams: Record<string, any>;
onParamsChange: (params: Record<string, any>) => void;
}
const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({
accessToken,
providerParams,
onParamsChange,
}) => {
const [embeddingModels, setEmbeddingModels] = useState<ModelGroup[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
useEffect(() => {
if (!accessToken) return;
const loadModels = async () => {
setIsLoadingModels(true);
try {
const models = await fetchAvailableModels(accessToken);
// Filter for embedding models only
const embeddingOnly = models.filter((model) => model.mode === "embedding");
setEmbeddingModels(embeddingOnly);
} catch (error) {
console.error("Error fetching embedding models:", error);
} finally {
setIsLoadingModels(false);
}
};
loadModels();
}, [accessToken]);
const handleFieldChange = (fieldName: string, value: string) => {
onParamsChange({
...providerParams,
[fieldName]: value,
});
};
return (
<>
{/* S3 Vectors Setup Instructions */}
<Alert
message="AWS S3 Vectors Setup"
description={
<div>
<p>AWS S3 Vectors allows you to store and query vector embeddings directly in S3:</p>
<ul style={{ marginLeft: "16px", marginTop: "8px" }}>
<li>Vector buckets and indexes will be automatically created if they don't exist</li>
<li>Vector dimensions are auto-detected from your selected embedding model</li>
<li>Ensure your AWS credentials have permissions for S3 Vectors operations</li>
<li>
Learn more:{" "}
<a
href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vector-buckets.html"
target="_blank"
rel="noopener noreferrer"
>
AWS S3 Vectors Documentation
</a>
</li>
</ul>
</div>
}
type="info"
showIcon
style={{ marginBottom: "16px" }}
/>
{/* Vector Bucket Name */}
<Form.Item
label={
<span>
Vector Bucket Name{" "}
<Tooltip title="S3 bucket name for vector storage (must be at least 3 characters, lowercase letters, numbers, hyphens, and periods only)">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
required
validateStatus={
providerParams.vector_bucket_name && providerParams.vector_bucket_name.length < 3
? "error"
: undefined
}
help={
providerParams.vector_bucket_name && providerParams.vector_bucket_name.length < 3
? "Bucket name must be at least 3 characters"
: undefined
}
>
<Input
value={providerParams.vector_bucket_name || ""}
onChange={(e) => handleFieldChange("vector_bucket_name", e.target.value)}
placeholder="my-vector-bucket (min 3 chars)"
size="large"
className="rounded-md"
/>
</Form.Item>
{/* Index Name (Optional) */}
<Form.Item
label={
<span>
Index Name{" "}
<Tooltip title="Name for the vector index (optional, will be auto-generated if not provided). If provided, must be at least 3 characters.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
validateStatus={
providerParams.index_name && providerParams.index_name.length > 0 && providerParams.index_name.length < 3
? "error"
: undefined
}
help={
providerParams.index_name && providerParams.index_name.length > 0 && providerParams.index_name.length < 3
? "Index name must be at least 3 characters if provided"
: undefined
}
>
<Input
value={providerParams.index_name || ""}
onChange={(e) => handleFieldChange("index_name", e.target.value)}
placeholder="my-vector-index (optional, min 3 chars)"
size="large"
className="rounded-md"
/>
</Form.Item>
{/* AWS Region */}
<Form.Item
label={
<span>
AWS Region{" "}
<Tooltip title="AWS region where the S3 bucket is located (e.g., us-west-2)">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
required
>
<Input
value={providerParams.aws_region_name || ""}
onChange={(e) => handleFieldChange("aws_region_name", e.target.value)}
placeholder="us-west-2"
size="large"
className="rounded-md"
/>
</Form.Item>
{/* Embedding Model */}
<Form.Item
label={
<span>
Embedding Model{" "}
<Tooltip title="Select the embedding model to use for vector generation">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
required
>
<Select
value={providerParams.embedding_model || undefined}
onChange={(value) => handleFieldChange("embedding_model", value)}
placeholder="Select an embedding model"
size="large"
showSearch
loading={isLoadingModels}
filterOption={(input, option) =>
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
options={embeddingModels.map((model) => ({
value: model.model_group,
label: model.model_group,
}))}
style={{ width: "100%" }}
/>
</Form.Item>
</>
);
};
export default S3VectorsConfig;
@@ -183,7 +183,7 @@ describe("VectorStoreTable", () => {
it("should render fallback for missing name", () => {
renderComponent();
const fallbackElements = screen.getAllByText("-");
expect(fallbackElements.length).toBe(3); // One for missing name, one for missing description, one for missing files
expect(fallbackElements.length).toBe(5); // One for missing name, one for missing description, three for missing files (one per store)
});
it("should wrap name in tooltip", () => {
@@ -203,7 +203,7 @@ describe("VectorStoreTable", () => {
it("should render fallback for missing description", () => {
renderComponent();
const fallbackElements = screen.getAllByText("-");
expect(fallbackElements.length).toBe(3); // One for missing name, one for missing description, one for missing files
expect(fallbackElements.length).toBe(5); // One for missing name, one for missing description, three for missing files (one per store)
});
it("should wrap description in tooltip", () => {
@@ -1,5 +1,6 @@
export enum VectorStoreProviders {
Bedrock = "Amazon Bedrock",
S3Vectors = "Amazon S3 Vectors",
PgVector = "PostgreSQL pgvector (LiteLLM Connector)",
VertexRagEngine = "Vertex AI RAG Engine",
OpenAI = "OpenAI",
@@ -14,6 +15,7 @@ export const vectorStoreProviderMap: Record<string, string> = {
OpenAI: "openai",
Azure: "azure",
Milvus: "milvus",
S3Vectors: "s3_vectors",
};
const asset_logos_folder = "../ui/assets/logos/";
@@ -25,6 +27,7 @@ export const vectorStoreProviderLogoMap: Record<string, string> = {
[VectorStoreProviders.OpenAI]: `${asset_logos_folder}openai_small.svg`,
[VectorStoreProviders.Azure]: `${asset_logos_folder}microsoft_azure.svg`,
[VectorStoreProviders.Milvus]: `${asset_logos_folder}milvus.svg`,
[VectorStoreProviders.S3Vectors]: `${asset_logos_folder}s3_vector.png`,
};
// Define field types for provider-specific configurations
@@ -114,6 +117,40 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
type: "select",
},
],
s3_vectors: [
{
name: "vector_bucket_name",
label: "Vector Bucket Name",
tooltip: "S3 bucket name for vector storage (will be auto-created if it doesn't exist)",
placeholder: "my-vector-bucket",
required: true,
type: "text",
},
{
name: "index_name",
label: "Index Name",
tooltip: "Name for the vector index (optional, will be auto-generated if not provided)",
placeholder: "my-vector-index",
required: false,
type: "text",
},
{
name: "aws_region_name",
label: "AWS Region",
tooltip: "AWS region where the S3 bucket is located (e.g., us-west-2)",
placeholder: "us-west-2",
required: true,
type: "text",
},
{
name: "embedding_model",
label: "Embedding Model",
tooltip: "Select the embedding model to use for vector generation",
placeholder: "text-embedding-3-small",
required: true,
type: "select",
},
],
};
export const getVectorStoreProviderLogoAndName = (providerValue: string): { logo: string; displayName: string } => {