mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-05 22:25:05 +00:00
@@ -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) }}"
|
||||
|
||||
@@ -281,6 +281,7 @@ migrationJob:
|
||||
# cpu: 100m
|
||||
# memory: 100Mi
|
||||
extraContainers: []
|
||||
extraInitContainers: []
|
||||
|
||||
# Hook configuration
|
||||
hooks:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
+63
-4
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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 ##
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+185
@@ -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),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+113
-3
@@ -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;
|
||||
+2
-2
@@ -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 } => {
|
||||
|
||||
Reference in New Issue
Block a user