chore: resolve merge conflicts with upstream/main

Accept upstream's new models and re-apply removal of 133 deprecated models.
This commit is contained in:
Chesars
2026-03-11 14:24:53 -03:00
30 changed files with 14895 additions and 12426 deletions
+11 -4
View File
@@ -1,12 +1,19 @@
name: "LiteLLM CodeQL config"
# Exclude queries that produce result sets > 2 GiB on this codebase,
# causing 49+ minute runs that fail and block CI resources.
# Use security-extended suite instead of security-and-quality to avoid
# result sets > 2 GiB on this codebase that cause fatal OOM failures.
queries:
- uses: security-extended
# These two queries are security queries included in security-extended that
# individually produce result sets > 2 GiB on this codebase, causing fatal
# OOM failures. Exclude them as a safety net until CI confirms they no longer
# OOM; drop these exclusions in a follow-up once verified.
query-filters:
- exclude:
id: py/clear-text-logging-sensitive-data # CWE-312/CleartextLogging.ql — result set > 2 GiB
id: py/clear-text-logging-sensitive-data # CWE-312 — > 2 GiB result set
- exclude:
id: py/polynomial-redos # CWE-730/PolynomialReDoS.ql — result set > 2 GiB
id: py/polynomial-redos # CWE-730 — > 2 GiB result set
paths-ignore:
- tests
@@ -0,0 +1,169 @@
---
slug: gemini_embedding_2_multimodal
title: "Gemini Embedding 2 Preview: Multimodal Embeddings on LiteLLM"
date: 2025-03-11T10:00:00
authors:
- name: Sameer Kankute
title: SWE @ LiteLLM (LLM Translation)
url: https://www.linkedin.com/in/sameer-kankute/
image_url: https://pbs.twimg.com/profile_images/2001352686994907136/ONgNuSk5_400x400.jpg
description: "Generate embeddings from text, images, audio, video, and PDFs with gemini-embedding-2-preview on LiteLLM via Gemini API and Vertex AI."
tags: [gemini, embeddings, multimodal, vertex ai]
hide_table_of_contents: false
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
# Gemini Embedding 2 Preview: Multimodal Embeddings
LiteLLM now supports **multimodal embeddings** with `gemini-embedding-2-preview`—generating a single embedding from a mix of text, images, audio, video, and PDF content. Available via both the **Gemini API** (API key) and **Vertex AI** (GCP credentials).
## Supported Input Types
| Modality | Supported Formats |
|----------|-------------------|
| **Text** | Plain text |
| **Image** | PNG, JPEG |
| **Audio** | MP3, WAV |
| **Video** | MP4, MOV |
| **Documents** | PDF |
## Input Formats
LiteLLM accepts three input formats for multimodal content:
1. **Data URIs** Base64-encoded inline: `data:image/png;base64,<encoded_data>`
2. **GCS URLs** Cloud Storage paths (Vertex AI): `gs://bucket/path/to/file.png`
3. **Gemini File References** Pre-uploaded files (Gemini API): `files/abc123`
## Quick Start
<Tabs>
<TabItem value="gemini" label="Gemini API">
```python
from litellm import embedding
import os
os.environ["GEMINI_API_KEY"] = "your-api-key"
# Text + Image (base64)
response = embedding(
model="gemini/gemini-embedding-2-preview",
input=[
"The food was delicious and the waiter...",
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII"
],
)
print(response)
```
</TabItem>
<TabItem value="vertex" label="Vertex AI">
```python
import litellm
from litellm import embedding
litellm.vertex_project = "your-project-id"
litellm.vertex_location = "us-central1"
# Text + Image (GCS URL)
response = embedding(
model="vertex_ai/gemini-embedding-2-preview",
input=[
"Describe this image",
"gs://my-bucket/images/photo.png"
],
)
print(response)
```
</TabItem>
<TabItem value="proxy" label="LiteLLM Proxy">
**1. Config (config.yaml)**
```yaml
model_list:
- model_name: gemini-embedding-2-preview
litellm_params:
model: gemini/gemini-embedding-2-preview
api_key: os.environ/GEMINI_API_KEY
- model_name: vertex-gemini-embedding-2-preview
litellm_params:
model: vertex_ai/gemini-embedding-2-preview
vertex_project: os.environ/VERTEXAI_PROJECT
vertex_location: os.environ/VERTEXAI_LOCATION
general_settings:
master_key: sk-1234
```
**2. Start proxy**
```bash
litellm --config config.yaml
```
**3. Call embeddings**
```bash
curl -X POST http://localhost:4000/embeddings \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"model": "gemini-embedding-2-preview",
"input": [
"The food was delicious and the waiter...",
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII"
]
}'
```
</TabItem>
</Tabs>
## Input Format Examples
| Format | Example | Provider |
|--------|---------|----------|
| **Data URI** | `data:image/png;base64,...` | Gemini, Vertex AI |
| **GCS URL** | `gs://bucket/path/image.png` | Vertex AI |
| **File reference** | `files/abc123` | Gemini API only |
### Supported MIME Types for Data URIs
- **Images:** `image/png`, `image/jpeg`
- **Audio:** `audio/mpeg`, `audio/wav`
- **Video:** `video/mp4`, `video/quicktime`
- **Documents:** `application/pdf`
### GCS URL MIME Inference
For Vertex AI, MIME types are inferred from file extensions:
- `.png``image/png`
- `.jpg` / `.jpeg``image/jpeg`
- `.mp3``audio/mpeg`
- `.wav``audio/wav`
- `.mp4``video/mp4`
- `.mov``video/quicktime`
- `.pdf``application/pdf`
## Optional Parameters
| Parameter | Description | Maps to |
|-----------|-------------|---------|
| `dimensions` | Output embedding size | `outputDimensionality` |
```python
response = embedding(
model="gemini/gemini-embedding-2-preview",
input=["text to embed"],
dimensions=768, # Optional: control output vector size
)
```
@@ -514,6 +514,57 @@ All models listed [here](https://ai.google.dev/gemini-api/docs/models/gemini) ar
| Model Name | Function Call |
| :--- | :--- |
| text-embedding-004 | `embedding(model="gemini/text-embedding-004", input)` |
| gemini-embedding-2-preview | `embedding(model="gemini/gemini-embedding-2-preview", input)` | [Multimodal docs](#gemini-embedding-2-preview-multimodal) |
### Gemini Embedding 2 Preview (Multimodal)
`gemini-embedding-2-preview` supports **multimodal embeddings**—text, images, audio, video, and PDF in a single request. See [blog post](/blog/gemini_embedding_2_multimodal) for details.
**Input formats:**
- **Data URIs:** `data:image/png;base64,<encoded_data>`
- **Gemini file references:** `files/abc123` (pre-uploaded via Gemini Files API)
**Supported MIME types:** `image/png`, `image/jpeg`, `audio/mpeg`, `audio/wav`, `video/mp4`, `video/quicktime`, `application/pdf`
<Tabs>
<TabItem value="sdk" label="SDK">
```python
from litellm import embedding
import os
os.environ["GEMINI_API_KEY"] = ""
# Text + Image (base64)
response = embedding(
model="gemini/gemini-embedding-2-preview",
input=[
"The food was delicious and the waiter...",
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII"
],
)
print(response)
```
</TabItem>
<TabItem value="proxy" label="PROXY">
```bash
curl -X POST http://localhost:4000/embeddings \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"model": "gemini-embedding-2-preview",
"input": [
"The food was delicious and the waiter...",
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII"
]
}'
```
</TabItem>
</Tabs>
**Optional:** `dimensions` maps to Gemini's `outputDimensionality`.
## Vertex AI Embedding Models
+15
View File
@@ -133,6 +133,21 @@ LiteLLM attempts [OAuth 2.0 Authorization Server Discovery](https://datatracker.
<br/>
### AWS SigV4 Authentication
For MCP servers hosted on [AWS Bedrock AgentCore](https://docs.aws.amazon.com/bedrock/latest/userguide/agentcore.html), select **AWS SigV4** as the authentication type. LiteLLM will sign every outgoing MCP request with your AWS credentials using [Signature Version 4](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html).
<Image
img={require('../img/mcp_aws_sigv4_ui.png')}
style={{width: '80%', display: 'block', margin: '0'}}
/>
Fill in your AWS region, service name (defaults to `bedrock-agentcore`), and optionally your AWS access key and secret. If credentials are omitted, LiteLLM falls back to the boto3 credential chain (IAM roles, environment variables, etc.).
[**See full SigV4 setup guide**](./mcp_aws_sigv4.md)
<br/>
### Static Headers
Sometimes your MCP server needs specific headers on every request. Maybe it's an API key, maybe it's a custom header the server expects. Instead of configuring auth, you can just set them directly.
+39 -2
View File
@@ -1,3 +1,7 @@
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import Image from '@theme/IdealImage';
# MCP - AWS SigV4 Auth
Use AWS SigV4 authentication to connect LiteLLM to MCP servers hosted on [AWS Bedrock AgentCore](https://docs.aws.amazon.com/bedrock/latest/userguide/agentcore.html).
@@ -10,6 +14,36 @@ LiteLLM's `aws_sigv4` auth type handles this automatically: every outgoing MCP r
## Quick Start
<Tabs>
<TabItem value="ui" label="LiteLLM UI">
1. Navigate to **MCP Servers** and click **Add New MCP Server**
2. Set the transport to **Streamable HTTP**
3. Select **AWS SigV4** as the authentication type
4. Fill in your AWS credentials:
<Image
img={require('../img/mcp_aws_sigv4_ui.png')}
style={{width: '80%', display: 'block', margin: '0'}}
/>
<br/>
| Field | Required | Description |
|-------|----------|-------------|
| **AWS Region** | Yes | AWS region for SigV4 signing (e.g., `us-east-1`) |
| **AWS Service Name** | No | Defaults to `bedrock-agentcore` |
| **AWS Access Key ID** | No | Falls back to boto3 credential chain if blank |
| **AWS Secret Access Key** | No | Required if Access Key ID is provided |
| **AWS Session Token** | No | Only needed for temporary STS credentials |
Once created, LiteLLM will sign every outgoing MCP request with SigV4. The server's tools appear automatically in the MCP Tools list.
**Editing credentials:** When editing an existing SigV4 server, leave credential fields blank to keep the current values. Only fields you fill in will be updated.
</TabItem>
<TabItem value="config" label="config.yaml">
### 1. Set AWS credentials
```bash
@@ -60,9 +94,12 @@ arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3A123456789012%3Aruntime%2Fmy-mcp-serv
litellm --config config.yaml
```
### 4. Use the MCP tools
</TabItem>
</Tabs>
Once started, your AgentCore MCP tools are available through LiteLLM like any other MCP server:
## Use the MCP tools
Once configured, your AgentCore MCP tools are available through LiteLLM like any other MCP server:
```bash title="List available tools"
curl http://localhost:4000/mcp-rest/tools/list \
@@ -79,6 +79,7 @@ All models listed [here](https://github.com/BerriAI/litellm/blob/57f37f743886a02
| textembedding-gecko@003 | `embedding(model="vertex_ai/textembedding-gecko@003", input)` |
| text-embedding-preview-0409 | `embedding(model="vertex_ai/text-embedding-preview-0409", input)` |
| text-multilingual-embedding-preview-0409 | `embedding(model="vertex_ai/text-multilingual-embedding-preview-0409", input)` |
| gemini-embedding-2-preview | `embedding(model="vertex_ai/gemini-embedding-2-preview", input)` | [Multimodal docs](#gemini-embedding-2-preview-multimodal) |
| Fine-tuned OR Custom Embedding models | `embedding(model="vertex_ai/<your-model-id>", input)` |
### Supported OpenAI (Unified) Params
@@ -257,6 +258,71 @@ model_list:
## **Multi-Modal Embeddings**
### Gemini Embedding 2 Preview (Multimodal)
`gemini-embedding-2-preview` supports **unified multimodal embeddings**—text, images, audio, video, and PDF in a single request. See [blog post](/blog/gemini_embedding_2_multimodal) for details.
**Input formats:**
- **Data URIs:** `data:image/png;base64,<encoded_data>`
- **GCS URLs:** `gs://bucket/path/to/file.png` (MIME type inferred from extension)
**Supported MIME types:** `image/png`, `image/jpeg`, `audio/mpeg`, `audio/wav`, `video/mp4`, `video/quicktime`, `application/pdf`
<Tabs>
<TabItem value="sdk" label="SDK">
```python
import litellm
from litellm import embedding
litellm.vertex_project = "your-project-id"
litellm.vertex_location = "us-central1"
# Text + Image (GCS URL)
response = embedding(
model="vertex_ai/gemini-embedding-2-preview",
input=[
"Describe this image",
"gs://my-bucket/images/photo.png"
],
)
# Text + Image (base64)
response = embedding(
model="vertex_ai/gemini-embedding-2-preview",
input=[
"The food was delicious",
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII"
],
)
```
</TabItem>
<TabItem value="proxy" label="LiteLLM PROXY">
```yaml
model_list:
- model_name: vertex-gemini-embedding-2-preview
litellm_params:
model: vertex_ai/gemini-embedding-2-preview
vertex_project: "your-project-id"
vertex_location: "us-central1"
```
```bash
curl -X POST http://localhost:4000/embeddings \
-H "Authorization: Bearer sk-1234" \
-H "Content-Type: application/json" \
-d '{
"model": "vertex-gemini-embedding-2-preview",
"input": ["Describe this", "gs://bucket/image.png"]
}'
```
</TabItem>
</Tabs>
### multimodalembedding@001 (Legacy)
Known Limitations:
- Only supports 1 image / video / image per request
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+14 -10
View File
@@ -247,23 +247,27 @@ def _get_embedding_url(
- bge/endpoint_id -> strips to endpoint_id for endpoints/ routing
- numeric model -> routes to endpoints/
- regular model -> routes to publishers/google/models/
"""
endpoint = "predict"
# Strip routing prefixes (bge/, gemma/, etc.) for endpoint URL construction
- models with uses_embed_content flag -> use embedContent endpoint instead of predict
"""
original_model = model
model = get_vertex_base_model_name(model=model)
# Get base URL (handles global vs regional)
try:
model_info = litellm.get_model_info(
model=original_model,
custom_llm_provider="vertex_ai",
)
uses_embed_content = model_info.get("uses_embed_content", False)
except Exception:
uses_embed_content = False
endpoint = "embedContent" if uses_embed_content else "predict"
base_url = get_vertex_base_url(vertex_location)
if model.isdigit():
# https://us-central1-aiplatform.googleapis.com/v1/projects/$PROJECT_ID/locations/us-central1/endpoints/$ENDPOINT_ID:predict
# https://aiplatform.googleapis.com/v1/projects/$PROJECT_ID/locations/global/endpoints/$ENDPOINT_ID:predict
url = f"{base_url}/{vertex_api_version}/projects/{vertex_project}/locations/{vertex_location}/endpoints/{model}:{endpoint}"
else:
# Regular model -> publisher model
# https://us-central1-aiplatform.googleapis.com/v1/projects/$PROJECT_ID/locations/us-central1/publishers/google/models/{model}:predict
# https://aiplatform.googleapis.com/v1/projects/$PROJECT_ID/locations/global/publishers/google/models/{model}:predict
url = f"{base_url}/v1/projects/{vertex_project}/locations/{vertex_location}/publishers/google/models/{model}:{endpoint}"
return url, endpoint
@@ -3,12 +3,11 @@ Google AI Studio /batchEmbedContents Embeddings Endpoint
"""
import json
from typing import Any, Literal, Optional, Union
from typing import Any, Dict, Literal, Optional, Union
import httpx
import litellm
from litellm.types.utils import EmbeddingResponse
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
HTTPHandler,
@@ -19,15 +18,98 @@ from litellm.types.llms.vertex_ai import (
VertexAIBatchEmbeddingsRequestBody,
VertexAIBatchEmbeddingsResponseObject,
)
from litellm.types.utils import EmbeddingResponse
from ..gemini.vertex_and_google_ai_studio_gemini import VertexLLM
from .batch_embed_content_transformation import (
_is_file_reference,
_is_multimodal_input,
process_embed_content_response,
process_response,
transform_openai_input_gemini_content,
transform_openai_input_gemini_embed_content,
)
class GoogleBatchEmbeddings(VertexLLM):
def _resolve_file_references(
self,
input: EmbeddingInput,
api_key: str,
sync_handler: HTTPHandler,
) -> Dict[str, Dict[str, str]]:
"""
Resolve Gemini file references (files/...) to get mime_type and uri.
Args:
input: EmbeddingInput that may contain file references
api_key: Gemini API key
sync_handler: HTTP client
Returns:
Dict mapping file name to {mime_type, uri}
"""
input_list = [input] if isinstance(input, str) else input
resolved_files: Dict[str, Dict[str, str]] = {}
for element in input_list:
if isinstance(element, str) and _is_file_reference(element):
url = f"https://generativelanguage.googleapis.com/v1beta/{element}"
headers = {"x-goog-api-key": api_key}
response = sync_handler.get(url=url, headers=headers)
if response.status_code != 200:
raise Exception(
f"Error fetching file {element}: {response.status_code} {response.text}"
)
file_data = response.json()
resolved_files[element] = {
"mime_type": file_data.get("mimeType", ""),
"uri": file_data.get("uri", element),
}
return resolved_files
async def _async_resolve_file_references(
self,
input: EmbeddingInput,
api_key: str,
async_handler: AsyncHTTPHandler,
) -> Dict[str, Dict[str, str]]:
"""
Async version of _resolve_file_references.
Args:
input: EmbeddingInput that may contain file references
api_key: Gemini API key
async_handler: Async HTTP client
Returns:
Dict mapping file name to {mime_type, uri}
"""
input_list = [input] if isinstance(input, str) else input
resolved_files: Dict[str, Dict[str, str]] = {}
for element in input_list:
if isinstance(element, str) and _is_file_reference(element):
url = f"https://generativelanguage.googleapis.com/v1beta/{element}"
headers = {"x-goog-api-key": api_key}
response = await async_handler.get(url=url, headers=headers)
if response.status_code != 200:
raise Exception(
f"Error fetching file {element}: {response.status_code} {response.text}"
)
file_data = response.json()
resolved_files[element] = {
"mime_type": file_data.get("mimeType", ""),
"uri": file_data.get("uri", element),
}
return resolved_files
def batch_embeddings(
self,
model: str,
@@ -54,20 +136,6 @@ class GoogleBatchEmbeddings(VertexLLM):
custom_llm_provider=custom_llm_provider,
)
auth_header, url = self._get_token_and_url(
model=model,
auth_header=_auth_header,
gemini_api_key=api_key,
vertex_project=vertex_project,
vertex_location=vertex_location,
vertex_credentials=vertex_credentials,
stream=None,
custom_llm_provider=custom_llm_provider,
api_base=api_base,
should_use_v1beta1_features=False,
mode="batch_embedding",
)
if client is None:
_params = {}
if timeout is not None:
@@ -83,9 +151,25 @@ class GoogleBatchEmbeddings(VertexLLM):
optional_params = optional_params or {}
### TRANSFORMATION ###
request_data = transform_openai_input_gemini_content(
input=input, model=model, optional_params=optional_params
is_multimodal = _is_multimodal_input(input)
use_embed_content = is_multimodal or (custom_llm_provider == "vertex_ai")
if use_embed_content:
mode = "embedding"
else:
mode = "batch_embedding"
auth_header, url = self._get_token_and_url(
model=model,
auth_header=_auth_header,
gemini_api_key=api_key,
vertex_project=vertex_project,
vertex_location=vertex_location,
vertex_credentials=vertex_credentials,
stream=None,
custom_llm_provider=custom_llm_provider,
api_base=api_base,
should_use_v1beta1_features=False,
mode=mode,
)
headers = {
@@ -93,14 +177,46 @@ class GoogleBatchEmbeddings(VertexLLM):
}
if auth_header is not None:
if isinstance(auth_header, dict):
# For Gemini with custom api_base: auth_header is {"x-goog-api-key": "..."}
headers.update(auth_header)
else:
# For Vertex AI: auth_header is a Bearer token string
headers["Authorization"] = f"Bearer {auth_header}"
if extra_headers is not None:
headers.update(extra_headers)
if aembedding is True:
return self.async_batch_embeddings( # type: ignore
model=model,
api_base=api_base,
url=url,
data=None,
model_response=model_response,
timeout=timeout,
headers=headers,
input=input,
use_embed_content=use_embed_content,
api_key=api_key,
optional_params=optional_params,
logging_obj=logging_obj,
)
### TRANSFORMATION (sync path) ###
if use_embed_content:
resolved_files = {}
if api_key:
resolved_files = self._resolve_file_references(
input=input, api_key=api_key, sync_handler=sync_handler
)
request_data = transform_openai_input_gemini_embed_content(
input=input,
model=model,
optional_params=optional_params,
resolved_files=resolved_files,
)
else:
request_data = transform_openai_input_gemini_content(
input=input, model=model, optional_params=optional_params
)
## LOGGING
logging_obj.pre_call(
input=input,
@@ -112,18 +228,6 @@ class GoogleBatchEmbeddings(VertexLLM):
},
)
if aembedding is True:
return self.async_batch_embeddings( # type: ignore
model=model,
api_base=api_base,
url=url,
data=request_data,
model_response=model_response,
timeout=timeout,
headers=headers,
input=input,
)
response = sync_handler.post(
url=url,
headers=headers,
@@ -134,26 +238,38 @@ class GoogleBatchEmbeddings(VertexLLM):
raise Exception(f"Error: {response.status_code} {response.text}")
_json_response = response.json()
_predictions = VertexAIBatchEmbeddingsResponseObject(**_json_response) # type: ignore
return process_response(
model=model,
model_response=model_response,
_predictions=_predictions,
input=input,
)
if use_embed_content:
return process_embed_content_response(
input=input,
model_response=model_response,
model=model,
response_json=_json_response,
)
else:
_predictions = VertexAIBatchEmbeddingsResponseObject(**_json_response) # type: ignore
return process_response(
model=model,
model_response=model_response,
_predictions=_predictions,
input=input,
)
async def async_batch_embeddings(
self,
model: str,
api_base: Optional[str],
url: str,
data: VertexAIBatchEmbeddingsRequestBody,
data: Optional[Union[VertexAIBatchEmbeddingsRequestBody, dict]],
model_response: EmbeddingResponse,
input: EmbeddingInput,
timeout: Optional[Union[float, httpx.Timeout]],
headers={},
client: Optional[AsyncHTTPHandler] = None,
use_embed_content: bool = False,
api_key: Optional[str] = None,
optional_params: Optional[dict] = None,
logging_obj: Optional[Any] = None,
) -> EmbeddingResponse:
if client is None:
_params = {}
@@ -171,6 +287,36 @@ class GoogleBatchEmbeddings(VertexLLM):
else:
async_handler = client # type: ignore
### TRANSFORMATION (async path) ###
if use_embed_content:
resolved_files = {}
if api_key:
resolved_files = await self._async_resolve_file_references(
input=input, api_key=api_key, async_handler=async_handler
)
data = transform_openai_input_gemini_embed_content(
input=input,
model=model,
optional_params=optional_params or {},
resolved_files=resolved_files,
)
else:
data = transform_openai_input_gemini_content(
input=input, model=model, optional_params=optional_params or {}
)
## LOGGING
if logging_obj is not None:
logging_obj.pre_call(
input=input,
api_key="",
additional_args={
"complete_input_dict": data,
"api_base": url,
"headers": headers,
},
)
response = await async_handler.post(
url=url,
headers=headers,
@@ -181,11 +327,19 @@ class GoogleBatchEmbeddings(VertexLLM):
raise Exception(f"Error: {response.status_code} {response.text}")
_json_response = response.json()
_predictions = VertexAIBatchEmbeddingsResponseObject(**_json_response) # type: ignore
return process_response(
model=model,
model_response=model_response,
_predictions=_predictions,
input=input,
)
if use_embed_content:
return process_embed_content_response(
input=input,
model_response=model_response,
model=model,
response_json=_json_response,
)
else:
_predictions = VertexAIBatchEmbeddingsResponseObject(**_json_response) # type: ignore
return process_response(
model=model,
model_response=model_response,
_predictions=_predictions,
input=input,
)
@@ -4,20 +4,142 @@ Transformation logic from OpenAI /v1/embeddings format to Google AI Studio /batc
Why separate file? Make it easy to see how transformation works
"""
from typing import List
from typing import Dict, List, Optional, Tuple
from litellm.types.utils import EmbeddingResponse
from litellm.types.llms.openai import EmbeddingInput
from litellm.types.llms.vertex_ai import (
BlobType,
ContentType,
EmbedContentRequest,
FileDataType,
PartType,
VertexAIBatchEmbeddingsRequestBody,
VertexAIBatchEmbeddingsResponseObject,
)
from litellm.types.utils import Embedding, Usage
from litellm.types.utils import Embedding, EmbeddingResponse, Usage
from litellm.utils import get_formatted_prompt, token_counter
SUPPORTED_EMBEDDING_MIME_TYPES = {
"image/png",
"image/jpeg",
"audio/mpeg",
"audio/wav",
"video/mp4",
"video/quicktime",
"application/pdf",
}
def _is_file_reference(s: str) -> bool:
"""Check if string is a Gemini file reference (files/...)."""
return isinstance(s, str) and s.startswith("files/")
def _is_gcs_url(s: str) -> bool:
"""Check if string is a GCS URL (gs://...)."""
return isinstance(s, str) and s.startswith("gs://")
def _infer_mime_type_from_gcs_url(gcs_url: str) -> str:
"""
Infer MIME type from GCS URL file extension.
Args:
gcs_url: GCS URL like gs://bucket/path/to/file.png
Returns:
str: Inferred MIME type
Raises:
ValueError: If file extension is not supported
"""
extension_to_mime = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".mp4": "video/mp4",
".mov": "video/quicktime",
".pdf": "application/pdf",
}
gcs_url_lower = gcs_url.lower()
for ext, mime_type in extension_to_mime.items():
if gcs_url_lower.endswith(ext):
return mime_type
raise ValueError(
f"Unable to infer MIME type from GCS URL: {gcs_url}. "
f"Supported extensions: {', '.join(extension_to_mime.keys())}"
)
def _parse_data_url(data_url: str) -> Tuple[str, str]:
"""
Parse a data URL to extract the media type and base64 data.
Args:
data_url: Data URL in format: data:image/jpeg;base64,/9j/4AAQ...
Returns:
tuple: (media_type, base64_data)
media_type: e.g., "image/jpeg", "video/mp4", "audio/mpeg"
base64_data: The base64-encoded data without the prefix
Raises:
ValueError: If data URL format is invalid or MIME type is unsupported
"""
if not data_url.startswith("data:"):
raise ValueError(f"Invalid data URL format: {data_url[:50]}...")
if "," not in data_url:
raise ValueError(f"Invalid data URL format (missing comma): {data_url[:50]}...")
metadata, base64_data = data_url.split(",", 1)
metadata = metadata[5:]
if ";" in metadata:
media_type = metadata.split(";")[0]
else:
media_type = metadata
if media_type not in SUPPORTED_EMBEDDING_MIME_TYPES:
raise ValueError(
f"Unsupported MIME type for embedding: {media_type}. "
f"Supported types: {', '.join(sorted(SUPPORTED_EMBEDDING_MIME_TYPES))}"
)
return media_type, base64_data
def _is_multimodal_input(input: EmbeddingInput) -> bool:
"""
Check if the input contains multimodal data (data URIs, file references, or GCS URLs).
Args:
input: EmbeddingInput (str or List[str])
Returns:
bool: True if any element is a data URI, file reference, or GCS URL
"""
if isinstance(input, str):
input_list = [input]
else:
input_list = input
for element in input_list:
if isinstance(element, str):
if element.startswith("data:") and ";base64," in element:
return True
if _is_file_reference(element):
return True
if _is_gcs_url(element):
return True
return False
def transform_openai_input_gemini_content(
input: EmbeddingInput, model: str, optional_params: dict
@@ -26,12 +148,17 @@ def transform_openai_input_gemini_content(
The content to embed. Only the parts.text fields will be counted.
"""
gemini_model_name = "models/{}".format(model)
gemini_params = optional_params.copy()
if "dimensions" in gemini_params:
gemini_params["outputDimensionality"] = gemini_params.pop("dimensions")
requests: List[EmbedContentRequest] = []
if isinstance(input, str):
request = EmbedContentRequest(
model=gemini_model_name,
content=ContentType(parts=[PartType(text=input)]),
**optional_params
**gemini_params
)
requests.append(request)
else:
@@ -39,13 +166,119 @@ def transform_openai_input_gemini_content(
request = EmbedContentRequest(
model=gemini_model_name,
content=ContentType(parts=[PartType(text=i)]),
**optional_params
**gemini_params
)
requests.append(request)
return VertexAIBatchEmbeddingsRequestBody(requests=requests)
def transform_openai_input_gemini_embed_content(
input: EmbeddingInput,
model: str,
optional_params: dict,
resolved_files: Optional[Dict[str, Dict[str, str]]] = None,
) -> dict:
"""
Transform OpenAI embedding input to Gemini embedContent format (multimodal).
Args:
input: EmbeddingInput (str or List[str]) with text, data URIs, or file references
model: Model name
optional_params: Additional parameters (taskType, outputDimensionality, etc.)
resolved_files: Dict mapping file names (files/abc) to {mime_type, uri}
Returns:
dict: Gemini embedContent request body with content.parts
"""
resolved_files = resolved_files or {}
gemini_params = optional_params.copy()
if "dimensions" in gemini_params:
gemini_params["outputDimensionality"] = gemini_params.pop("dimensions")
input_list = [input] if isinstance(input, str) else input
parts: List[PartType] = []
for element in input_list:
if not isinstance(element, str):
raise ValueError(f"Unsupported input type: {type(element)}")
if element.startswith("data:") and ";base64," in element:
mime_type, base64_data = _parse_data_url(element)
blob: BlobType = {"mime_type": mime_type, "data": base64_data}
parts.append(PartType(inline_data=blob))
elif _is_gcs_url(element):
mime_type = _infer_mime_type_from_gcs_url(element)
file_data: FileDataType = {
"mime_type": mime_type,
"file_uri": element,
}
parts.append(PartType(file_data=file_data))
elif _is_file_reference(element):
if element not in resolved_files:
raise ValueError(f"File reference {element} not resolved")
file_info = resolved_files[element]
file_data_ref: FileDataType = {
"mime_type": file_info["mime_type"],
"file_uri": file_info["uri"],
}
parts.append(PartType(file_data=file_data_ref))
else:
parts.append(PartType(text=element))
request_body: dict = {
"content": ContentType(parts=parts),
**gemini_params,
}
return request_body
def process_embed_content_response(
input: EmbeddingInput,
model_response: EmbeddingResponse,
model: str,
response_json: dict,
) -> EmbeddingResponse:
"""
Process Gemini embedContent response (single embedding for multimodal input).
Args:
input: Original input
model_response: EmbeddingResponse to populate
model: Model name
response_json: Raw JSON response from embedContent endpoint
Returns:
EmbeddingResponse with single embedding
"""
if "embedding" not in response_json:
raise ValueError(f"embedContent response missing 'embedding' field: {response_json}")
embedding_data = response_json["embedding"]
openai_embedding = Embedding(
embedding=embedding_data["values"],
index=0,
object="embedding",
)
model_response.data = [openai_embedding]
model_response.model = model
if _is_multimodal_input(input):
prompt_tokens = 0
else:
input_text = get_formatted_prompt(data={"input": input}, call_type="embedding")
prompt_tokens = token_counter(model=model, text=input_text)
model_response.usage = Usage(
prompt_tokens=prompt_tokens, total_tokens=prompt_tokens
)
return model_response
def process_response(
input: EmbeddingInput,
model_response: EmbeddingResponse,
+27 -2
View File
@@ -132,6 +132,7 @@ from litellm.utils import (
create_tokenizer,
get_api_key,
get_llm_provider,
get_model_info,
get_non_default_completion_params,
get_non_default_transcription_params,
get_optional_params_embeddings,
@@ -5190,13 +5191,37 @@ def embedding( # noqa: PLR0915
or get_secret_str("VERTEX_API_BASE")
)
if (
try:
model_info = get_model_info(model=model, custom_llm_provider="vertex_ai")
uses_embed_content = model_info.get("uses_embed_content", False)
except Exception:
uses_embed_content = False
if uses_embed_content:
response = google_batch_embeddings.batch_embeddings( # type: ignore
model=model,
input=input,
encoding=_get_encoding(),
logging_obj=logging,
optional_params=optional_params,
model_response=EmbeddingResponse(),
vertex_project=vertex_ai_project,
vertex_location=vertex_ai_location,
vertex_credentials=vertex_credentials,
aembedding=aembedding,
print_verbose=print_verbose,
custom_llm_provider="vertex_ai",
api_key=None,
api_base=api_base,
client=client,
extra_headers=headers,
)
elif (
"image" in optional_params
or "video" in optional_params
or model
in vertex_multimodal_embedding.SUPPORTED_MULTIMODAL_EMBEDDING_MODELS
):
# multimodal embedding is supported on vertex httpx
response = vertex_multimodal_embedding.multimodal_embedding(
model=model,
input=input,
@@ -2565,6 +2565,19 @@
"supports_parallel_function_calling": true,
"supports_tool_choice": true
},
"azure/gpt-35-turbo-1106": {
"deprecation_date": "2025-03-31",
"input_cost_per_token": 1e-06,
"litellm_provider": "azure",
"max_input_tokens": 16384,
"max_output_tokens": 4096,
"max_tokens": 4096,
"mode": "chat",
"output_cost_per_token": 2e-06,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_tool_choice": true
},
"azure/gpt-35-turbo-16k": {
"input_cost_per_token": 3e-06,
"litellm_provider": "azure",
@@ -8045,6 +8058,20 @@
"supports_reasoning": true,
"supports_tool_choice": true
},
"cerebras/zai-glm-4.6": {
"deprecation_date": "2026-01-20",
"input_cost_per_token": 2.25e-06,
"litellm_provider": "cerebras",
"max_input_tokens": 128000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.75e-06,
"source": "https://www.cerebras.ai/pricing",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_tool_choice": true
},
"cerebras/zai-glm-4.7": {
"input_cost_per_token": 2.25e-06,
"litellm_provider": "cerebras",
@@ -13697,6 +13724,96 @@
"supports_vision": true,
"supports_web_search": true
},
"gemini-live-2.5-flash-preview-native-audio-09-2025": {
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_audio_token": 3e-06,
"input_cost_per_token": 3e-07,
"litellm_provider": "vertex_ai-language-models",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65535,
"max_pdf_size_mb": 30,
"max_tokens": 65535,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "realtime",
"output_cost_per_audio_token": 1.2e-05,
"output_cost_per_token": 2e-06,
"source": "https://ai.google.dev/gemini-api/docs/pricing",
"supported_endpoints": [
"/vertex_ai/live"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text",
"audio"
],
"supports_audio_input": true,
"supports_audio_output": true,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": 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/gemini-live-2.5-flash-preview-native-audio-09-2025": {
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_audio_token": 3e-06,
"input_cost_per_token": 3e-07,
"litellm_provider": "gemini",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65535,
"max_pdf_size_mb": 30,
"max_tokens": 65535,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "realtime",
"output_cost_per_audio_token": 1.2e-05,
"output_cost_per_token": 2e-06,
"rpm": 100000,
"source": "https://ai.google.dev/gemini-api/docs/pricing",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text",
"audio"
],
"supports_audio_input": true,
"supports_audio_output": true,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true,
"tpm": 8000000
},
"gemini-2.5-flash-lite-preview-06-17": {
"deprecation_date": "2025-11-18",
"cache_read_input_token_cost": 2.5e-08,
@@ -14321,6 +14438,32 @@
"output_vector_size": 3072,
"source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models"
},
"gemini-embedding-2-preview": {
"input_cost_per_audio_per_second": 0.00016,
"input_cost_per_image": 0.00012,
"input_cost_per_token": 2e-07,
"input_cost_per_video_per_second": 0.0237,
"litellm_provider": "vertex_ai-embedding-models",
"max_input_tokens": 8192,
"max_tokens": 8192,
"mode": "embedding",
"output_cost_per_token": 0,
"output_vector_size": 3072,
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing",
"uses_embed_content": true
},
"vertex_ai/gemini-embedding-2-preview": {
"input_cost_per_token": 1.5e-07,
"litellm_provider": "vertex_ai",
"max_input_tokens": 8192,
"max_tokens": 8192,
"mode": "embedding",
"output_cost_per_token": 0,
"output_vector_size": 3072,
"source": "https://ai.google.dev/gemini-api/docs/embeddings#multimodal",
"supports_multimodal": true,
"uses_embed_content": true
},
"gemini/gemini-embedding-001": {
"input_cost_per_token": 1.5e-07,
"litellm_provider": "gemini",
@@ -14333,6 +14476,19 @@
"source": "https://ai.google.dev/gemini-api/docs/embeddings#model-versions",
"tpm": 10000000
},
"gemini/gemini-embedding-2-preview": {
"input_cost_per_token": 1.5e-07,
"litellm_provider": "gemini",
"max_input_tokens": 8192,
"max_tokens": 8192,
"mode": "embedding",
"output_cost_per_token": 0,
"output_vector_size": 3072,
"rpm": 10000,
"source": "https://ai.google.dev/gemini-api/docs/embeddings#multimodal",
"supports_multimodal": true,
"tpm": 10000000
},
"gemini/gemini-2.0-flash": {
"cache_read_input_token_cost": 2.5e-08,
"deprecation_date": "2026-06-01",
@@ -16588,6 +16744,20 @@
"supports_system_messages": true,
"supports_tool_choice": true
},
"gpt-4-0613": {
"deprecation_date": "2025-06-06",
"input_cost_per_token": 3e-05,
"litellm_provider": "openai",
"max_input_tokens": 8192,
"max_output_tokens": 4096,
"max_tokens": 4096,
"mode": "chat",
"output_cost_per_token": 6e-05,
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_system_messages": true,
"supports_tool_choice": true
},
"gpt-4-1106-preview": {
"deprecation_date": "2026-03-26",
"input_cost_per_token": 1e-05,
@@ -27107,6 +27277,16 @@
"output_cost_per_token": 0.0,
"output_vector_size": 1536
},
"text-embedding-ada-002-v2": {
"input_cost_per_token": 1e-07,
"input_cost_per_token_batches": 5e-08,
"litellm_provider": "openai",
"max_input_tokens": 8191,
"max_tokens": 8191,
"mode": "embedding",
"output_cost_per_token": 0.0,
"output_cost_per_token_batches": 0.0
},
"text-embedding-large-exp-03-07": {
"input_cost_per_character": 2.5e-08,
"input_cost_per_token": 1e-07,
@@ -36686,32 +36866,5 @@
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true
},
"azure/gpt-35-turbo-1106": {
"deprecation_date": "2025-03-31",
"input_cost_per_token": 1e-06,
"litellm_provider": "azure",
"max_input_tokens": 16384,
"max_output_tokens": 4096,
"max_tokens": 4096,
"mode": "chat",
"output_cost_per_token": 2e-06,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_tool_choice": true
},
"cerebras/zai-glm-4.6": {
"deprecation_date": "2026-01-20",
"input_cost_per_token": 2.25e-06,
"litellm_provider": "cerebras",
"max_input_tokens": 128000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.75e-06,
"source": "https://www.cerebras.ai/pricing",
"supports_function_calling": true,
"supports_reasoning": true,
"supports_tool_choice": true
}
}
+97 -1
View File
@@ -100,6 +100,50 @@ def encrypt_credentials(
value=client_secret,
new_encryption_key=encryption_key,
)
# AWS SigV4 credential fields
aws_access_key_id = credentials.get("aws_access_key_id")
if aws_access_key_id is not None:
credentials["aws_access_key_id"] = encrypt_value_helper(
value=aws_access_key_id,
new_encryption_key=encryption_key,
)
aws_secret_access_key = credentials.get("aws_secret_access_key")
if aws_secret_access_key is not None:
credentials["aws_secret_access_key"] = encrypt_value_helper(
value=aws_secret_access_key,
new_encryption_key=encryption_key,
)
aws_session_token = credentials.get("aws_session_token")
if aws_session_token is not None:
credentials["aws_session_token"] = encrypt_value_helper(
value=aws_session_token,
new_encryption_key=encryption_key,
)
# aws_region_name and aws_service_name are NOT secrets — stored as-is
return credentials
def decrypt_credentials(
credentials: MCPCredentials,
) -> MCPCredentials:
"""Decrypt all secret fields in an MCPCredentials dict using the global salt key."""
secret_fields = [
"auth_value",
"client_id",
"client_secret",
"aws_access_key_id",
"aws_secret_access_key",
"aws_session_token",
]
for field in secret_fields:
value = credentials.get(field)
if value is not None:
credentials[field] = decrypt_value_helper(
value=value,
key=field,
exception_type="debug",
return_original_value=True,
)
return credentials
@@ -350,9 +394,57 @@ async def update_mcp_server(
"""
Update a new mcp server record in the db
"""
import json
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
# Use helper to prepare data with proper JSON serialization
data_dict = _prepare_mcp_server_data(data)
# Pre-fetch existing record once if we need it for auth_type or credential logic
existing = None
has_credentials = "credentials" in data_dict and data_dict["credentials"] is not None
if data.auth_type or has_credentials:
existing = await prisma_client.db.litellm_mcpservertable.find_unique(
where={"server_id": data.server_id}
)
# Clear stale credentials when auth_type changes but no new credentials provided
if (
data.auth_type
and "credentials" not in data_dict
and existing
and existing.auth_type is not None
and existing.auth_type != data.auth_type
):
data_dict["credentials"] = None
# Merge credentials: preserve existing fields not present in the update.
# Without this, a partial credential update (e.g. changing only region)
# would wipe encrypted secrets that the UI cannot display back.
if "credentials" in data_dict and data_dict["credentials"] is not None:
if existing and existing.credentials:
# Only merge when auth_type is unchanged. Switching auth types
# (e.g. oauth2 → api_key) should replace credentials entirely
# to avoid stale secrets from the previous auth type lingering.
auth_type_unchanged = (
data.auth_type is None or data.auth_type == existing.auth_type
)
if auth_type_unchanged:
existing_creds = (
json.loads(existing.credentials)
if isinstance(existing.credentials, str)
else dict(existing.credentials)
)
new_creds = (
json.loads(data_dict["credentials"])
if isinstance(data_dict["credentials"], str)
else dict(data_dict["credentials"])
)
# New values override existing; existing keys not in update are preserved
merged = {**existing_creds, **new_creds}
data_dict["credentials"] = safe_dumps(merged)
# Add audit fields
data_dict["updated_by"] = touched_by
@@ -374,8 +466,12 @@ async def rotate_mcp_server_credentials_master_key(
continue
credentials_copy = dict(credentials)
encrypted_credentials = encrypt_credentials(
# Decrypt with current key first, then re-encrypt with new key
decrypted_credentials = decrypt_credentials(
credentials=cast(MCPCredentials, credentials_copy),
)
encrypted_credentials = encrypt_credentials(
credentials=decrypted_credentials,
encryption_key=new_master_key,
)
@@ -597,9 +597,10 @@ class MCPServerManager:
else:
client_secret_value = encrypted_client_secret
# TODO: Add AWS SigV4 credential decryption here when DB-stored
# SigV4 MCP servers are supported. Requires corresponding changes
# to encrypt_credentials() in db.py and MCPCredentials TypedDict.
# AWS SigV4 credential fields
aws_creds = self._extract_aws_credentials(
credentials_dict, credentials_are_encrypted
)
scopes: Optional[List[str]] = None
if credentials_dict:
@@ -679,6 +680,12 @@ class MCPServerManager:
is_byok=bool(getattr(mcp_server, "is_byok", False)),
byok_description=getattr(mcp_server, "byok_description", None) or [],
byok_api_key_help_url=getattr(mcp_server, "byok_api_key_help_url", None),
# AWS SigV4 fields
aws_access_key_id=aws_creds.get("aws_access_key_id"),
aws_secret_access_key=aws_creds.get("aws_secret_access_key"),
aws_session_token=aws_creds.get("aws_session_token"),
aws_region_name=aws_creds.get("aws_region_name"),
aws_service_name=aws_creds.get("aws_service_name"),
)
return new_server
@@ -1518,6 +1525,52 @@ class MCPServerManager:
return None
@staticmethod
def _decrypt_credential_field(
encrypted_value: Optional[str],
key: str,
credentials_are_encrypted: bool,
) -> Optional[str]:
"""Decrypt a single credential field, or return as-is if not encrypted."""
if not encrypted_value:
return None
if credentials_are_encrypted:
return decrypt_value_helper(
value=encrypted_value,
key=key,
exception_type="debug",
return_original_value=True,
)
return encrypted_value
def _extract_aws_credentials(
self,
credentials_dict: Optional[Dict[str, str]],
credentials_are_encrypted: bool,
) -> Dict[str, Optional[str]]:
"""Extract and decrypt AWS SigV4 credential fields from credentials dict."""
if not credentials_dict:
return {}
return {
"aws_access_key_id": self._decrypt_credential_field(
credentials_dict.get("aws_access_key_id"),
"aws_access_key_id",
credentials_are_encrypted,
),
"aws_secret_access_key": self._decrypt_credential_field(
credentials_dict.get("aws_secret_access_key"),
"aws_secret_access_key",
credentials_are_encrypted,
),
"aws_session_token": self._decrypt_credential_field(
credentials_dict.get("aws_session_token"),
"aws_session_token",
credentials_are_encrypted,
),
"aws_region_name": credentials_dict.get("aws_region_name"),
"aws_service_name": credentials_dict.get("aws_service_name"),
}
def _extract_scopes(self, scopes_value: Any) -> Optional[List[str]]:
if isinstance(scopes_value, str):
scopes = [s.strip() for s in scopes_value.split() if s.strip()]
@@ -412,6 +412,17 @@ if MCP_AVAILABLE:
inherited_credentials["client_secret"] = existing_server.client_secret
if existing_server.scopes:
inherited_credentials["scopes"] = existing_server.scopes
# AWS SigV4 fields
if existing_server.aws_access_key_id:
inherited_credentials["aws_access_key_id"] = existing_server.aws_access_key_id
if existing_server.aws_secret_access_key:
inherited_credentials["aws_secret_access_key"] = existing_server.aws_secret_access_key
if existing_server.aws_session_token:
inherited_credentials["aws_session_token"] = existing_server.aws_session_token
if existing_server.aws_region_name:
inherited_credentials["aws_region_name"] = existing_server.aws_region_name
if existing_server.aws_service_name:
inherited_credentials["aws_service_name"] = existing_server.aws_service_name
if not inherited_credentials:
return payload
@@ -404,7 +404,9 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
headers=headers,
params=requested_query_params,
)
elif HttpPassThroughEndpointHelpers.is_multipart(request) is True:
elif HttpPassThroughEndpointHelpers.is_multipart(request) is True and not _parsed_body:
# Only use multipart handler if we don't have a parsed body
# (parsed body means it was JSON despite multipart content-type header)
return await HttpPassThroughEndpointHelpers.make_multipart_http_request(
request=request,
async_client=async_client,
@@ -677,8 +679,15 @@ async def pass_through_request( # noqa: PLR0915
str(url)
)
# Skip body parsing for multipart requests - make_multipart_http_request will handle it
# But if custom_body is provided (e.g., JSON parsed despite multipart content-type), use it
is_multipart = HttpPassThroughEndpointHelpers.is_multipart(request) and not custom_body
if custom_body:
_parsed_body = custom_body
elif is_multipart:
# Don't parse multipart body here - it will be handled by make_multipart_http_request
_parsed_body = {}
else:
_parsed_body = await _read_request_body(request)
verbose_proxy_logger.debug(
@@ -1043,30 +1052,22 @@ async def _parse_request_data_by_content_type(
# Handle requests with no body (e.g., DELETE requests)
pass
elif "multipart/form-data" in content_type:
# ✅ Handle multipart form-data
form = await request.form()
if "query_params" in form:
form_value = form["query_params"]
if isinstance(form_value, str):
try:
query_params_data = json.loads(form_value)
except Exception:
query_params_data = form_value
else:
query_params_data = form_value
if "custom_body" in form:
form_value = form["custom_body"]
if isinstance(form_value, str):
try:
custom_body_data = json.loads(form_value)
except Exception:
custom_body_data = form_value
else:
custom_body_data = form_value
if "file" in form:
file_data = form["file"] # this is a Starlette UploadFile object
# ✅ Try to parse as JSON first (handles misconfigured clients sending JSON with multipart content-type)
# If that fails, skip parsing - pass_through_request will handle actual multipart
try:
body = await request.json()
# Successfully parsed as JSON - treat as JSON body
query_params_data = body.get("query_params")
custom_body_data = body.get("custom_body")
stream = body.get("stream")
# If custom_body is not set, use the entire body
if custom_body_data is None and body:
custom_body_data = body
except (json.JSONDecodeError, Exception):
# Not JSON - this is actual multipart data
# Skip parsing here to avoid consuming the request body stream
# make_multipart_http_request will handle it
pass
elif "application/x-www-form-urlencoded" in content_type:
# ✅ Handle URL-encoded form data
@@ -1132,7 +1133,6 @@ def create_pass_through_route(
fastapi_response: Response,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
subpath: str = "", # captures sub-paths when include_subpath=True
custom_body: Optional[dict] = None,
):
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
@@ -1208,12 +1208,9 @@ def create_pass_through_route(
)
if query_params:
final_query_params.update(query_params)
# When a caller (e.g. bedrock_proxy_route) supplies a pre-built
# body, use it instead of the body parsed from the raw request.
# Use the body parsed from the raw request
final_custom_body: Optional[dict] = None
if custom_body is not None:
final_custom_body = custom_body
elif isinstance(custom_body_data, dict):
if isinstance(custom_body_data, dict):
final_custom_body = custom_body_data
return await pass_through_request( # type: ignore
+6 -1
View File
@@ -853,7 +853,12 @@ def run_server( # noqa: PLR0915
):
check_prisma_schema_diff(db_url=None)
else:
PrismaManager.setup_database(use_migrate=not use_prisma_db_push)
if not PrismaManager.setup_database(use_migrate=not use_prisma_db_push):
print( # noqa
"\033[1;31mLiteLLM Proxy: Database setup failed after multiple retries. "
"The proxy cannot start safely. Please check your database connection and migration status.\033[0m"
)
sys.exit(1)
else:
print( # noqa
f"Unable to connect to DB. DATABASE_URL found in environment, but prisma package not found." # noqa
+11
View File
@@ -557,6 +557,17 @@ class VertexAIBatchEmbeddingsResponseObject(TypedDict):
embeddings: List[ContentEmbeddings]
class GeminiEmbedContentRequestBody(TypedDict, total=False):
content: Required[ContentType]
taskType: TaskTypeEnum
title: str
outputDimensionality: int
class GeminiEmbedContentResponseObject(TypedDict):
embedding: ContentEmbeddings
# Vertex AI Batch Prediction
+16
View File
@@ -95,6 +95,22 @@ class MCPCredentials(TypedDict, total=False):
OAuth 2.0 scopes to request when exchanging the client credentials
"""
# AWS SigV4 fields
aws_access_key_id: Optional[str]
"""AWS access key ID for SigV4 signing. Optional — falls back to boto3 credential chain."""
aws_secret_access_key: Optional[str]
"""AWS secret access key for SigV4 signing. Optional — falls back to boto3 credential chain."""
aws_session_token: Optional[str]
"""AWS session token for temporary STS credentials. Optional."""
aws_region_name: Optional[str]
"""AWS region for SigV4 signing (e.g., 'us-east-1'). Not a secret — stored unencrypted."""
aws_service_name: Optional[str]
"""AWS service name for SigV4 signing (e.g., 'bedrock-agentcore'). Not a secret — stored unencrypted."""
class MCPServerCostInfo(TypedDict, total=False):
default_cost_per_query: Optional[float]
+1
View File
@@ -253,6 +253,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
tpm: Optional[int]
rpm: Optional[int]
provider_specific_entry: Optional[Dict[str, float]]
uses_embed_content: Optional[bool]
class ModelInfo(ModelInfoBase, total=False):
+1
View File
@@ -5779,6 +5779,7 @@ def _get_model_info_helper( # noqa: PLR0915
provider_specific_entry=_model_info.get(
"provider_specific_entry", None
),
uses_embed_content=_model_info.get("uses_embed_content", None),
)
except Exception as e:
verbose_logger.debug(f"Error getting model info: {e}")
File diff suppressed because it is too large Load Diff
@@ -15,8 +15,16 @@ from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.abspath("../../../.."))
import pytest
import litellm
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.vertex_ai.gemini_embeddings.batch_embed_content_transformation import (
_is_multimodal_input,
_parse_data_url,
process_embed_content_response,
transform_openai_input_gemini_embed_content,
)
from litellm.types.utils import EmbeddingResponse
def test_gemini_batch_embeddings_with_custom_api_base_and_auth_header():
@@ -47,11 +55,9 @@ def test_gemini_batch_embeddings_with_custom_api_base_and_auth_header():
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"predictions": [
"embeddings": [
{
"embeddings": {
"values": [0.1, 0.2, 0.3, 0.4, 0.5]
}
"values": [0.1, 0.2, 0.3, 0.4, 0.5]
}
]
}
@@ -109,11 +115,9 @@ def test_gemini_batch_embeddings_with_extra_headers():
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"predictions": [
"embeddings": [
{
"embeddings": {
"values": [0.1, 0.2, 0.3]
}
"values": [0.1, 0.2, 0.3]
}
]
}
@@ -143,3 +147,380 @@ def test_gemini_batch_embeddings_with_extra_headers():
assert "X-Custom" in headers
assert headers["X-Custom"] == "custom-value"
def test_is_multimodal_input_detection():
"""Test that _is_multimodal_input correctly detects multimodal inputs."""
assert _is_multimodal_input("plain text") is False
assert _is_multimodal_input(["text1", "text2"]) is False
assert _is_multimodal_input("data:image/png;base64,iVBORw0KGgo=") is True
assert _is_multimodal_input(["text", "data:image/png;base64,abc"]) is True
assert _is_multimodal_input("files/abc123") is True
assert _is_multimodal_input(["text", "files/myfile"]) is True
def test_parse_data_url():
"""Test that _parse_data_url correctly extracts MIME type and base64 data."""
mime_type, base64_data = _parse_data_url("data:image/png;base64,iVBORw0KGgo=")
assert mime_type == "image/png"
assert base64_data == "iVBORw0KGgo="
mime_type, base64_data = _parse_data_url("data:audio/mpeg;base64,SUQzBAA=")
assert mime_type == "audio/mpeg"
assert base64_data == "SUQzBAA="
mime_type, base64_data = _parse_data_url("data:video/mp4;base64,AAAAIGZ0eXA=")
assert mime_type == "video/mp4"
assert base64_data == "AAAAIGZ0eXA="
mime_type, base64_data = _parse_data_url("data:application/pdf;base64,JVBERi0=")
assert mime_type == "application/pdf"
assert base64_data == "JVBERi0="
def test_mime_type_validation():
"""Test that unsupported MIME types raise ValueError."""
with pytest.raises(ValueError, match="Unsupported MIME type"):
_parse_data_url("data:text/plain;base64,SGVsbG8=")
with pytest.raises(ValueError, match="Unsupported MIME type"):
_parse_data_url("data:application/json;base64,e30=")
def test_parse_data_url_invalid_format():
"""Test that invalid data URL formats raise ValueError."""
with pytest.raises(ValueError, match="Invalid data URL format"):
_parse_data_url("not-a-data-url")
with pytest.raises(ValueError, match="missing comma"):
_parse_data_url("data:image/png;base64")
def test_transform_multimodal_text_and_image():
"""Test transformation of mixed text and image input."""
input_data = [
"The food was delicious",
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
]
result = transform_openai_input_gemini_embed_content(
input=input_data,
model="gemini-embedding-2-preview",
optional_params={},
resolved_files=None,
)
assert "content" in result
assert "parts" in result["content"]
parts = result["content"]["parts"]
assert len(parts) == 2
assert parts[0]["text"] == "The food was delicious"
assert "inline_data" in parts[1]
assert parts[1]["inline_data"]["mime_type"] == "image/png"
assert "data" in parts[1]["inline_data"]
def test_transform_multimodal_with_file_reference():
"""Test transformation with Gemini file reference."""
input_data = ["Some text", "files/abc123"]
resolved_files = {
"files/abc123": {
"mime_type": "image/jpeg",
"uri": "https://generativelanguage.googleapis.com/v1beta/files/abc123"
}
}
result = transform_openai_input_gemini_embed_content(
input=input_data,
model="gemini-embedding-2-preview",
optional_params={},
resolved_files=resolved_files,
)
assert "content" in result
parts = result["content"]["parts"]
assert len(parts) == 2
assert parts[0]["text"] == "Some text"
assert "file_data" in parts[1]
assert parts[1]["file_data"]["mime_type"] == "image/jpeg"
assert parts[1]["file_data"]["file_uri"] == "https://generativelanguage.googleapis.com/v1beta/files/abc123"
def test_embed_content_response_processing():
"""Test processing of embedContent response (single embedding)."""
response_json = {
"embedding": {
"values": [0.1, 0.2, 0.3, 0.4, 0.5]
}
}
model_response = EmbeddingResponse()
result = process_embed_content_response(
input=["test input"],
model_response=model_response,
model="gemini-embedding-2-preview",
response_json=response_json,
)
assert len(result.data) == 1
assert result.data[0].embedding == [0.1, 0.2, 0.3, 0.4, 0.5]
assert result.data[0].index == 0
assert result.data[0].object == "embedding"
assert result.model == "gemini-embedding-2-preview"
assert result.usage.prompt_tokens > 0
def test_embed_content_response_multimodal_sets_prompt_tokens_zero():
"""Test that multimodal input sets prompt_tokens=0 (cannot accurately count)."""
response_json = {
"embedding": {
"values": [0.1, 0.2, 0.3, 0.4, 0.5]
}
}
model_response = EmbeddingResponse()
result = process_embed_content_response(
input=["text", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="],
model_response=model_response,
model="gemini-embedding-2-preview",
response_json=response_json,
)
assert result.usage.prompt_tokens == 0
def test_gemini_multimodal_embedding_e2e():
"""Test end-to-end multimodal embedding call through litellm.embedding()."""
client = HTTPHandler()
def mock_auth_token(*args, **kwargs):
return None, "test-project"
with patch.object(client, "post") as mock_post, patch(
"litellm.llms.vertex_ai.gemini_embeddings.batch_embed_content_handler.GoogleBatchEmbeddings._ensure_access_token",
side_effect=mock_auth_token
), patch(
"litellm.llms.vertex_ai.gemini_embeddings.batch_embed_content_handler.GoogleBatchEmbeddings._get_token_and_url"
) as mock_get_token:
mock_get_token.return_value = (
{"x-goog-api-key": "test-key"},
"https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-2-preview:embedContent?key=test-key"
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"embedding": {
"values": [0.1, 0.2, 0.3, 0.4, 0.5]
}
}
mock_post.return_value = mock_response
response = litellm.embedding(
model="gemini/gemini-embedding-2-preview",
input=["The food was delicious", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="],
api_key="test-key",
client=client
)
mock_post.assert_called_once()
call_args = mock_post.call_args
kwargs = call_args.kwargs if hasattr(call_args, 'kwargs') else call_args[1]
request_body = json.loads(kwargs.get("data", "{}"))
assert "content" in request_body
assert "parts" in request_body["content"]
parts = request_body["content"]["parts"]
assert len(parts) == 2
assert parts[0]["text"] == "The food was delicious"
assert "inline_data" in parts[1]
assert parts[1]["inline_data"]["mime_type"] == "image/png"
assert len(response.data) == 1
assert response.data[0].embedding == [0.1, 0.2, 0.3, 0.4, 0.5]
def test_gemini_multimodal_embedding_with_audio():
"""Test multimodal embedding with audio input."""
input_data = ["Audio description", "data:audio/mpeg;base64,SUQzBAAAAAA="]
result = transform_openai_input_gemini_embed_content(
input=input_data,
model="gemini-embedding-2-preview",
optional_params={},
resolved_files=None,
)
parts = result["content"]["parts"]
assert len(parts) == 2
assert parts[0]["text"] == "Audio description"
assert parts[1]["inline_data"]["mime_type"] == "audio/mpeg"
def test_gemini_multimodal_embedding_with_video():
"""Test multimodal embedding with video input."""
input_data = ["data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAA"]
result = transform_openai_input_gemini_embed_content(
input=input_data,
model="gemini-embedding-2-preview",
optional_params={},
resolved_files=None,
)
parts = result["content"]["parts"]
assert len(parts) == 1
assert parts[0]["inline_data"]["mime_type"] == "video/mp4"
def test_transform_with_optional_params():
"""Test that optional params like outputDimensionality are passed through."""
input_data = ["test text"]
result = transform_openai_input_gemini_embed_content(
input=input_data,
model="gemini-embedding-2-preview",
optional_params={"outputDimensionality": 768, "taskType": "SEMANTIC_SIMILARITY"},
resolved_files=None,
)
assert result["outputDimensionality"] == 768
assert result["taskType"] == "SEMANTIC_SIMILARITY"
def test_dimensions_mapped_to_output_dimensionality():
"""Test that OpenAI 'dimensions' param is mapped to Gemini 'outputDimensionality'."""
input_data = ["test text"]
result = transform_openai_input_gemini_embed_content(
input=input_data,
model="gemini-embedding-2-preview",
optional_params={"dimensions": 768},
resolved_files=None,
)
assert "outputDimensionality" in result
assert result["outputDimensionality"] == 768
assert "dimensions" not in result
def test_is_gcs_url():
"""Test GCS URL detection."""
from litellm.llms.vertex_ai.gemini_embeddings.batch_embed_content_transformation import (
_is_gcs_url,
)
assert _is_gcs_url("gs://my-bucket/path/to/file.png") is True
assert _is_gcs_url("gs://bucket/image.jpg") is True
assert _is_gcs_url("https://storage.googleapis.com/bucket/file.png") is False
assert _is_gcs_url("files/abc123") is False
assert _is_gcs_url("data:image/png;base64,abc") is False
assert _is_gcs_url("regular text") is False
def test_infer_mime_type_from_gcs_url():
"""Test MIME type inference from GCS URL."""
from litellm.llms.vertex_ai.gemini_embeddings.batch_embed_content_transformation import (
_infer_mime_type_from_gcs_url,
)
assert _infer_mime_type_from_gcs_url("gs://bucket/image.png") == "image/png"
assert _infer_mime_type_from_gcs_url("gs://bucket/photo.jpg") == "image/jpeg"
assert _infer_mime_type_from_gcs_url("gs://bucket/photo.JPEG") == "image/jpeg"
assert _infer_mime_type_from_gcs_url("gs://bucket/audio.mp3") == "audio/mpeg"
assert _infer_mime_type_from_gcs_url("gs://bucket/audio.wav") == "audio/wav"
assert _infer_mime_type_from_gcs_url("gs://bucket/video.mp4") == "video/mp4"
assert _infer_mime_type_from_gcs_url("gs://bucket/video.mov") == "video/quicktime"
assert _infer_mime_type_from_gcs_url("gs://bucket/doc.pdf") == "application/pdf"
with pytest.raises(ValueError, match="Unable to infer MIME type"):
_infer_mime_type_from_gcs_url("gs://bucket/file.txt")
def test_transform_multimodal_with_gcs_url():
"""Test transformation with GCS URL."""
input_data = [
"Describe this image",
"gs://my-bucket/images/photo.png"
]
result = transform_openai_input_gemini_embed_content(
input=input_data,
model="gemini-embedding-2-preview",
optional_params={},
resolved_files=None,
)
parts = result["content"]["parts"]
assert len(parts) == 2
assert parts[0]["text"] == "Describe this image"
assert parts[1]["file_data"]["mime_type"] == "image/png"
assert parts[1]["file_data"]["file_uri"] == "gs://my-bucket/images/photo.png"
def test_multimodal_input_detection_with_gcs():
"""Test that GCS URLs are detected as multimodal."""
from litellm.llms.vertex_ai.gemini_embeddings.batch_embed_content_transformation import (
_is_multimodal_input,
)
assert _is_multimodal_input(["text", "gs://bucket/file.png"]) is True
assert _is_multimodal_input("gs://bucket/video.mp4") is True
assert _is_multimodal_input(["just text", "more text"]) is False
def test_vertex_ai_text_only_embedding_uses_embed_content():
"""
Test that vertex_ai/gemini-embedding-2-preview with text-only input uses
embedContent endpoint (not batchEmbedContents) and returns a single embedding.
"""
client = HTTPHandler()
embed_content_url = "https://us-central1-aiplatform.googleapis.com/v1/projects/test/locations/us-central1/publishers/google/models/gemini-embedding-2-preview:embedContent"
def mock_auth_token(*args, **kwargs):
return "Bearer test-token", "test-project"
with patch.object(client, "post") as mock_post, patch(
"litellm.llms.vertex_ai.gemini_embeddings.batch_embed_content_handler.GoogleBatchEmbeddings._ensure_access_token",
side_effect=mock_auth_token,
), patch(
"litellm.llms.vertex_ai.gemini_embeddings.batch_embed_content_handler.GoogleBatchEmbeddings._get_token_and_url"
) as mock_get_token:
mock_get_token.return_value = (
{"Authorization": "Bearer test-token"},
embed_content_url,
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"embedding": {"values": [0.1, 0.2, 0.3, 0.4, 0.5]}
}
mock_post.return_value = mock_response
response = litellm.embedding(
model="vertex_ai/gemini-embedding-2-preview",
input=["Hello, world!"],
vertex_project="test-project",
vertex_location="us-central1",
client=client,
)
mock_post.assert_called_once()
call_args = mock_post.call_args
post_url = call_args.kwargs.get("url", call_args.args[0] if call_args.args else "")
assert "embedContent" in str(post_url)
data = json.loads(call_args.kwargs["data"])
assert "content" in data
assert "parts" in data["content"]
assert len(data["content"]["parts"]) == 1
assert data["content"]["parts"][0]["text"] == "Hello, world!"
assert len(response.data) == 1
@@ -2,11 +2,14 @@
Tests for AWS SigV4 authentication in MCP client.
Tests the MCPSigV4Auth httpx.Auth subclass that enables per-request
SigV4 signing for Bedrock AgentCore MCP servers.
SigV4 signing for Bedrock AgentCore MCP servers, plus DB/UI path
tests for credential encryption, merge-on-update, and build_from_table.
"""
import json
import pytest
from unittest.mock import patch, MagicMock
from unittest.mock import patch, MagicMock, AsyncMock
import httpx
@@ -315,3 +318,568 @@ class TestMCPServerManagerSigV4:
client = await manager._create_mcp_client(server=server)
assert client._aws_auth is None
class TestSigV4CredentialEncryption:
"""Test encrypt/decrypt round-trip for AWS SigV4 credentials."""
def test_encrypt_credentials_handles_aws_fields(self):
"""AWS credential fields are encrypted in the credentials dict."""
from litellm.proxy._experimental.mcp_server.db import encrypt_credentials
creds = {
"aws_access_key_id": "AKIAIOSFODNN7EXAMPLE",
"aws_secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"aws_session_token": "FwoGZX...",
"aws_region_name": "us-east-1",
"aws_service_name": "bedrock-agentcore",
}
with patch(
"litellm.proxy._experimental.mcp_server.db.encrypt_value_helper",
side_effect=lambda value, new_encryption_key: f"enc:{value}",
):
result = encrypt_credentials(credentials=creds, encryption_key="test-key")
# Secrets should be encrypted
assert result["aws_access_key_id"] == "enc:AKIAIOSFODNN7EXAMPLE"
assert (
result["aws_secret_access_key"]
== "enc:wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
)
assert result["aws_session_token"] == "enc:FwoGZX..."
# Non-secrets should be unchanged
assert result["aws_region_name"] == "us-east-1"
assert result["aws_service_name"] == "bedrock-agentcore"
def test_encrypt_credentials_skips_absent_aws_fields(self):
"""encrypt_credentials does not fail when AWS fields are absent."""
from litellm.proxy._experimental.mcp_server.db import encrypt_credentials
creds = {"auth_value": "some-token"}
with patch(
"litellm.proxy._experimental.mcp_server.db.encrypt_value_helper",
side_effect=lambda value, new_encryption_key: f"enc:{value}",
):
result = encrypt_credentials(credentials=creds, encryption_key="test-key")
assert result["auth_value"] == "enc:some-token"
assert "aws_access_key_id" not in result
class TestCredentialMergeOnUpdate:
"""Test that partial credential updates preserve existing fields."""
@pytest.mark.asyncio
async def test_partial_update_preserves_existing_credentials(self):
"""Updating only aws_region_name should not wipe aws_secret_access_key."""
from litellm.proxy._experimental.mcp_server.db import update_mcp_server
from litellm.proxy._types import UpdateMCPServerRequest
existing_record = MagicMock()
existing_record.auth_type = "aws_sigv4"
existing_record.credentials = json.dumps(
{
"aws_access_key_id": "enc:AKI",
"aws_secret_access_key": "enc:SAK",
"aws_region_name": "us-east-1",
}
)
mock_prisma = MagicMock()
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(
return_value=existing_record
)
mock_prisma.db.litellm_mcpservertable.update = AsyncMock(
return_value=MagicMock()
)
data = UpdateMCPServerRequest(
server_id="test-server",
auth_type="aws_sigv4",
credentials={"aws_region_name": "eu-west-1"},
)
with patch(
"litellm.proxy._experimental.mcp_server.db._get_salt_key",
return_value=None,
), patch(
"litellm.proxy._experimental.mcp_server.db.encrypt_value_helper",
side_effect=lambda value, new_encryption_key: value,
):
await update_mcp_server(mock_prisma, data, "test-user")
# Grab the data dict passed to prisma update
update_call = mock_prisma.db.litellm_mcpservertable.update
assert update_call.called
data_dict = update_call.call_args[1]["data"]
merged_creds = json.loads(data_dict["credentials"])
# Existing encrypted secrets should be preserved
assert merged_creds["aws_access_key_id"] == "enc:AKI"
assert merged_creds["aws_secret_access_key"] == "enc:SAK"
# New region value should be updated
assert merged_creds["aws_region_name"] == "eu-west-1"
@pytest.mark.asyncio
async def test_update_without_credentials_preserves_all(self):
"""Update with no credentials field should not touch existing credentials."""
from litellm.proxy._experimental.mcp_server.db import update_mcp_server
from litellm.proxy._types import UpdateMCPServerRequest
mock_prisma = MagicMock()
mock_prisma.db.litellm_mcpservertable.update = AsyncMock(
return_value=MagicMock()
)
data = UpdateMCPServerRequest(
server_id="test-server",
description="Updated description",
)
with patch(
"litellm.proxy._experimental.mcp_server.db._get_salt_key",
return_value=None,
):
await update_mcp_server(mock_prisma, data, "test-user")
data_dict = mock_prisma.db.litellm_mcpservertable.update.call_args[1]["data"]
assert "credentials" not in data_dict
@pytest.mark.asyncio
async def test_update_new_server_no_merge(self):
"""Update with credentials on a server that has no existing credentials."""
from litellm.proxy._experimental.mcp_server.db import update_mcp_server
from litellm.proxy._types import UpdateMCPServerRequest
existing_record = MagicMock()
existing_record.auth_type = "aws_sigv4"
existing_record.credentials = None
mock_prisma = MagicMock()
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(
return_value=existing_record
)
mock_prisma.db.litellm_mcpservertable.update = AsyncMock(
return_value=MagicMock()
)
data = UpdateMCPServerRequest(
server_id="test-server",
auth_type="aws_sigv4",
credentials={"aws_region_name": "us-east-1"},
)
with patch(
"litellm.proxy._experimental.mcp_server.db._get_salt_key",
return_value=None,
), patch(
"litellm.proxy._experimental.mcp_server.db.encrypt_value_helper",
side_effect=lambda value, new_encryption_key: value,
):
await update_mcp_server(mock_prisma, data, "test-user")
data_dict = mock_prisma.db.litellm_mcpservertable.update.call_args[1]["data"]
stored_creds = json.loads(data_dict["credentials"])
assert stored_creds == {"aws_region_name": "us-east-1"}
@pytest.mark.asyncio
async def test_auth_type_change_replaces_credentials_entirely(self):
"""Switching auth_type should replace credentials, not merge."""
from litellm.proxy._experimental.mcp_server.db import update_mcp_server
from litellm.proxy._types import UpdateMCPServerRequest
existing_record = MagicMock()
existing_record.auth_type = "aws_sigv4"
existing_record.credentials = json.dumps(
{
"aws_access_key_id": "enc:AKI",
"aws_secret_access_key": "enc:SAK",
"aws_region_name": "us-east-1",
}
)
mock_prisma = MagicMock()
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(
return_value=existing_record
)
mock_prisma.db.litellm_mcpservertable.update = AsyncMock(
return_value=MagicMock()
)
data = UpdateMCPServerRequest(
server_id="test-server",
auth_type="api_key",
credentials={"auth_value": "my-key"},
)
with patch(
"litellm.proxy._experimental.mcp_server.db._get_salt_key",
return_value=None,
), patch(
"litellm.proxy._experimental.mcp_server.db.encrypt_value_helper",
side_effect=lambda value, new_encryption_key: f"enc:{value}",
):
await update_mcp_server(mock_prisma, data, "test-user")
data_dict = mock_prisma.db.litellm_mcpservertable.update.call_args[1]["data"]
stored_creds = json.loads(data_dict["credentials"])
# Should only have the new api_key credential, no stale aws_* fields
assert stored_creds == {"auth_value": "enc:my-key"}
@pytest.mark.asyncio
async def test_same_auth_type_merges_credentials(self):
"""Same auth_type should merge credentials (preserve untouched fields)."""
from litellm.proxy._experimental.mcp_server.db import update_mcp_server
from litellm.proxy._types import UpdateMCPServerRequest
existing_record = MagicMock()
existing_record.auth_type = "oauth2"
existing_record.credentials = json.dumps(
{
"client_id": "enc:id",
"client_secret": "enc:secret",
"scopes": ["read"],
}
)
mock_prisma = MagicMock()
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(
return_value=existing_record
)
mock_prisma.db.litellm_mcpservertable.update = AsyncMock(
return_value=MagicMock()
)
data = UpdateMCPServerRequest(
server_id="test-server",
auth_type="oauth2",
credentials={"scopes": ["read", "write"]},
)
with patch(
"litellm.proxy._experimental.mcp_server.db._get_salt_key",
return_value=None,
), patch(
"litellm.proxy._experimental.mcp_server.db.encrypt_value_helper",
side_effect=lambda value, new_encryption_key: value,
):
await update_mcp_server(mock_prisma, data, "test-user")
data_dict = mock_prisma.db.litellm_mcpservertable.update.call_args[1]["data"]
merged_creds = json.loads(data_dict["credentials"])
assert merged_creds["client_id"] == "enc:id"
assert merged_creds["client_secret"] == "enc:secret"
assert merged_creds["scopes"] == ["read", "write"]
class TestSigV4BuildFromTable:
"""Test build_mcp_server_from_table correctly loads AWS SigV4 credentials."""
@pytest.mark.asyncio
async def test_build_mcp_server_from_table_with_sigv4_credentials(self):
"""SigV4 credentials from DB are decrypted and mapped to MCPServer fields."""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
)
table_record = MagicMock()
table_record.server_id = "test-sigv4-server"
table_record.server_name = "sigv4_server"
table_record.alias = None
table_record.description = None
table_record.url = "https://bedrock-agentcore.us-east-1.amazonaws.com/invocations"
table_record.spec_path = None
table_record.transport = "http"
table_record.auth_type = "aws_sigv4"
table_record.mcp_info = {"server_name": "sigv4_server"}
table_record.credentials = json.dumps(
{
"aws_access_key_id": "enc:AKIAEXAMPLE",
"aws_secret_access_key": "enc:SECRET",
"aws_session_token": "enc:TOKEN",
"aws_region_name": "us-east-1",
"aws_service_name": "bedrock-agentcore",
}
)
table_record.extra_headers = None
table_record.static_headers = None
table_record.command = None
table_record.args = []
table_record.env = None
table_record.mcp_access_groups = []
table_record.allowed_tools = []
table_record.disallowed_tools = None
table_record.allow_all_keys = False
table_record.available_on_public_internet = True
table_record.authorization_url = None
table_record.token_url = None
table_record.registration_url = None
table_record.created_at = None
table_record.updated_at = None
table_record.client_id = None
table_record.client_secret = None
table_record.tool_name_to_display_name = None
table_record.tool_name_to_description = None
table_record.byok_api_key_help_url = None
table_record.oauth2_flow = None
manager = MCPServerManager()
with patch(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.decrypt_value_helper",
side_effect=lambda value, key, exception_type, return_original_value: value.replace(
"enc:", ""
),
):
server = await manager.build_mcp_server_from_table(table_record)
assert server.auth_type == "aws_sigv4"
assert server.aws_access_key_id == "AKIAEXAMPLE"
assert server.aws_secret_access_key == "SECRET"
assert server.aws_session_token == "TOKEN"
assert server.aws_region_name == "us-east-1"
assert server.aws_service_name == "bedrock-agentcore"
@pytest.mark.asyncio
async def test_build_mcp_server_from_table_without_sigv4_credentials(self):
"""Non-SigV4 servers still work — AWS fields default to None."""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
)
table_record = MagicMock()
table_record.server_id = "test-bearer-server"
table_record.server_name = "bearer_server"
table_record.alias = None
table_record.description = None
table_record.url = "https://example.com/mcp"
table_record.spec_path = None
table_record.transport = "http"
table_record.auth_type = "bearer_token"
table_record.mcp_info = {"server_name": "bearer_server"}
table_record.credentials = json.dumps({"auth_value": "enc:tok"})
table_record.extra_headers = None
table_record.static_headers = None
table_record.command = None
table_record.args = []
table_record.env = None
table_record.mcp_access_groups = []
table_record.allowed_tools = []
table_record.disallowed_tools = None
table_record.allow_all_keys = False
table_record.available_on_public_internet = True
table_record.authorization_url = None
table_record.token_url = None
table_record.registration_url = None
table_record.created_at = None
table_record.updated_at = None
table_record.client_id = None
table_record.client_secret = None
table_record.tool_name_to_display_name = None
table_record.tool_name_to_description = None
table_record.byok_api_key_help_url = None
table_record.oauth2_flow = None
manager = MCPServerManager()
with patch(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.decrypt_value_helper",
side_effect=lambda value, key, exception_type, return_original_value: value.replace(
"enc:", ""
),
):
server = await manager.build_mcp_server_from_table(table_record)
assert server.auth_type == "bearer_token"
assert server.aws_access_key_id is None
assert server.aws_secret_access_key is None
assert server.aws_session_token is None
assert server.aws_region_name is None
assert server.aws_service_name is None
class TestDecryptCredentials:
"""Test decrypt_credentials helper."""
def test_decrypt_credentials_handles_all_secret_fields(self):
"""All secret fields are decrypted; non-secret fields are left as-is."""
from litellm.proxy._experimental.mcp_server.db import decrypt_credentials
creds = {
"auth_value": "enc:tok",
"client_id": "enc:cid",
"client_secret": "enc:csec",
"aws_access_key_id": "enc:AKI",
"aws_secret_access_key": "enc:SAK",
"aws_session_token": "enc:TOK",
"aws_region_name": "us-east-1",
"aws_service_name": "bedrock-agentcore",
}
with patch(
"litellm.proxy._experimental.mcp_server.db.decrypt_value_helper",
side_effect=lambda value, key, exception_type="error", return_original_value=False: value.replace("enc:", ""),
):
result = decrypt_credentials(credentials=creds)
assert result["auth_value"] == "tok"
assert result["client_id"] == "cid"
assert result["client_secret"] == "csec"
assert result["aws_access_key_id"] == "AKI"
assert result["aws_secret_access_key"] == "SAK"
assert result["aws_session_token"] == "TOK"
# Non-secrets untouched
assert result["aws_region_name"] == "us-east-1"
assert result["aws_service_name"] == "bedrock-agentcore"
def test_decrypt_credentials_skips_absent_fields(self):
"""Absent fields are not touched."""
from litellm.proxy._experimental.mcp_server.db import decrypt_credentials
creds = {"aws_access_key_id": "enc:AKI"}
with patch(
"litellm.proxy._experimental.mcp_server.db.decrypt_value_helper",
side_effect=lambda value, key, exception_type="error", return_original_value=False: value.replace("enc:", ""),
):
result = decrypt_credentials(credentials=creds)
assert result["aws_access_key_id"] == "AKI"
assert "aws_secret_access_key" not in result
class TestRotateCredentials:
"""Test rotate_mcp_server_credentials_master_key decrypts before re-encrypting."""
@pytest.mark.asyncio
async def test_rotation_decrypts_then_reencrypts(self):
"""Key rotation should decrypt with old key then encrypt with new key."""
from litellm.proxy._experimental.mcp_server.db import (
rotate_mcp_server_credentials_master_key,
)
server = MagicMock()
server.server_id = "srv-1"
server.credentials = {
"aws_access_key_id": "enc_old:AKI",
"aws_secret_access_key": "enc_old:SAK",
"aws_region_name": "us-east-1",
}
mock_prisma = MagicMock()
mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(
return_value=[server]
)
mock_prisma.db.litellm_mcpservertable.update = AsyncMock()
with patch(
"litellm.proxy._experimental.mcp_server.db._get_salt_key",
return_value="old-key",
), patch(
"litellm.proxy._experimental.mcp_server.db.decrypt_value_helper",
side_effect=lambda value, key, exception_type="error", return_original_value=False: value.replace("enc_old:", ""),
), patch(
"litellm.proxy._experimental.mcp_server.db.encrypt_value_helper",
side_effect=lambda value, new_encryption_key: f"enc_new:{value}",
):
await rotate_mcp_server_credentials_master_key(
mock_prisma, "admin", "new-key"
)
update_call = mock_prisma.db.litellm_mcpservertable.update
assert update_call.called
stored_creds = json.loads(update_call.call_args[1]["data"]["credentials"])
# Should be decrypted from old, then encrypted with new
assert stored_creds["aws_access_key_id"] == "enc_new:AKI"
assert stored_creds["aws_secret_access_key"] == "enc_new:SAK"
# Non-secret fields should pass through unchanged
assert stored_creds["aws_region_name"] == "us-east-1"
class TestAuthTypeSwitchClearsCredentials:
"""Test that switching auth_type without credentials clears stale secrets."""
@pytest.mark.asyncio
async def test_auth_type_change_without_credentials_clears_stale(self):
"""Changing auth_type without providing credentials should clear old ones."""
from litellm.proxy._experimental.mcp_server.db import update_mcp_server
from litellm.proxy._types import UpdateMCPServerRequest
existing_record = MagicMock()
existing_record.auth_type = "oauth2"
existing_record.credentials = json.dumps(
{"client_id": "enc:cid", "client_secret": "enc:csec"}
)
mock_prisma = MagicMock()
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(
return_value=existing_record
)
mock_prisma.db.litellm_mcpservertable.update = AsyncMock(
return_value=MagicMock()
)
data = UpdateMCPServerRequest(
server_id="test-server",
auth_type="aws_sigv4",
# No credentials provided
)
with patch(
"litellm.proxy._experimental.mcp_server.db._get_salt_key",
return_value=None,
):
await update_mcp_server(mock_prisma, data, "test-user")
data_dict = mock_prisma.db.litellm_mcpservertable.update.call_args[1]["data"]
# Credentials should be cleared (set to None)
assert data_dict.get("credentials") is None
class TestInheritCredentials:
"""Test _inherit_credentials_from_existing_server copies AWS fields."""
def test_inherits_sigv4_credentials(self):
"""SigV4 fields are copied from existing server to inherited credentials."""
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_inherit_credentials_from_existing_server,
)
from litellm.proxy._types import NewMCPServerRequest
from litellm.types.mcp_server.mcp_server_manager import MCPServer
existing = MCPServer(
server_id="existing-sigv4",
name="sigv4_server",
server_name="sigv4_server",
url="https://bedrock-agentcore.us-east-1.amazonaws.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.aws_sigv4,
aws_access_key_id="AKIAEXAMPLE",
aws_secret_access_key="SECRET",
aws_session_token="TOKEN",
aws_region_name="us-east-1",
aws_service_name="bedrock-agentcore",
)
payload = NewMCPServerRequest(
server_id="existing-sigv4",
server_name="sigv4_server",
url="https://bedrock-agentcore.us-east-1.amazonaws.com/mcp",
transport="http",
auth_type="aws_sigv4",
)
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager"
) as mock_manager:
mock_manager.get_mcp_server_by_id.return_value = existing
result = _inherit_credentials_from_existing_server(payload)
assert result.credentials is not None
assert result.credentials["aws_access_key_id"] == "AKIAEXAMPLE"
assert result.credentials["aws_secret_access_key"] == "SECRET"
assert result.credentials["aws_session_token"] == "TOKEN"
assert result.credentials["aws_region_name"] == "us-east-1"
assert result.credentials["aws_service_name"] == "bedrock-agentcore"
@@ -2369,3 +2369,107 @@ def test_get_registered_pass_through_route_with_custom_root():
# Clean up
_registered_pass_through_routes.clear()
def test_mapped_pass_through_routes_with_server_root_path():
"""
Mapped passthrough routes (vertex_ai, bedrock, etc) should match
even when SERVER_ROOT_PATH is set and the incoming route is prefixed.
Regression test for https://github.com/BerriAI/litellm/issues/22272
"""
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
)
with patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_server_root_path"
) as mock_get_root:
mock_get_root.return_value = "/litellm"
# prefixed route should match mapped routes like /vertex_ai
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
"/litellm/vertex_ai/v1/projects/foo"
)
is True
)
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
"/litellm/bedrock/model/invoke"
)
is True
)
# bare route without prefix should not match when root is set
assert (
InitPassThroughEndpointHelpers.is_registered_pass_through_route(
"/vertex_ai/v1/projects/foo"
)
is False
)
@pytest.mark.asyncio
async def test_multipart_passthrough_preserves_boundary():
"""
Test that multipart/form-data requests through passthrough preserve the boundary
and can be correctly parsed by the upstream server.
Regression test for multipart boundary stripping issue.
"""
from io import BytesIO
# Mock the httpx request to verify files are passed correctly
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = httpx.Headers({"content-type": "application/json"})
mock_response.aread = AsyncMock(return_value=b'{"filename": "test.txt", "size": 17}')
mock_response.text = '{"filename": "test.txt", "size": 17}'
async def mock_httpx_request(method, url, **kwargs):
# Verify that files parameter is passed (not json)
assert "files" in kwargs, "Files should be passed for multipart requests"
assert "file" in kwargs["files"], "File field should be in files dict"
# Verify content-type is NOT in headers (httpx will set it with correct boundary)
headers = kwargs.get("headers", {})
assert "content-type" not in headers, "content-type should be removed for multipart"
filename, content, content_type = kwargs["files"]["file"]
assert filename == "test.txt"
assert content == b"test file content"
assert content_type == "text/plain"
return mock_response
async_client = MagicMock()
async_client.request = AsyncMock(side_effect=mock_httpx_request)
# Create mock request
request = MagicMock(spec=Request)
request.method = "POST"
request.headers = Headers({"content-type": "multipart/form-data; boundary=test123"})
# Mock form data
file_content = b"test file content"
file = BytesIO(file_content)
headers = Headers({"content-type": "text/plain"})
upload_file = UploadFile(file=file, filename="test.txt", headers=headers)
upload_file.read = AsyncMock(return_value=file_content)
form_data = {"file": upload_file}
request.form = AsyncMock(return_value=form_data)
# Test the multipart handler directly
response = await HttpPassThroughEndpointHelpers.make_multipart_http_request(
request=request,
async_client=async_client,
url=httpx.URL("http://test.com/upload"),
headers={},
requested_query_params=None,
)
# Verify the response
assert response.status_code == 200
async_client.request.assert_called_once()
@@ -664,6 +664,64 @@ class TestHealthAppFactory:
)
mock_setup_database.assert_called_with(use_migrate=False)
@patch("subprocess.run")
@patch("atexit.register")
@patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database")
@patch("litellm.proxy.db.check_migration.check_prisma_schema_diff")
@patch("litellm.proxy.db.prisma_client.should_update_prisma_schema")
def test_startup_fails_when_db_setup_fails(
self,
mock_should_update_schema,
mock_check_schema_diff,
mock_setup_database,
mock_atexit_register,
mock_subprocess_run,
):
"""Test that proxy exits with code 1 when PrismaManager.setup_database returns False"""
from litellm.proxy.proxy_cli import run_server
mock_subprocess_run.return_value = MagicMock(returncode=0)
mock_should_update_schema.return_value = True
mock_setup_database.return_value = False
mock_proxy_module = MagicMock(
app=MagicMock(),
ProxyConfig=MagicMock(),
KeyManagementSettings=MagicMock(),
save_worker_config=MagicMock(),
)
clean_env = {
k: v
for k, v in os.environ.items()
if k not in ("DATABASE_URL", "DIRECT_URL")
}
clean_env["DATABASE_URL"] = "postgresql://test:test@localhost:5432/test"
with patch.dict(
os.environ, clean_env, clear=True
), patch.dict(
"sys.modules",
{
"proxy_server": mock_proxy_module,
"litellm.proxy.proxy_server": mock_proxy_module,
},
), patch(
"litellm.proxy.proxy_cli.ProxyInitializationHelpers._get_default_unvicorn_init_args"
) as mock_get_args:
mock_get_args.return_value = {
"app": "litellm.proxy.proxy_server:app",
"host": "localhost",
"port": 8000,
}
with pytest.raises(SystemExit) as exc_info:
run_server.main(
["--local", "--skip_server_startup"], standalone_mode=False
)
assert exc_info.value.code == 1
mock_setup_database.assert_called_once_with(use_migrate=True)
# --- Module-level helpers for worker startup hook tests ---
@@ -1,4 +1,5 @@
export type { MCPEvent } from "../mcp_tools/types";
import type { MCPEvent } from "../mcp_tools/types";
export type { MCPEvent };
export interface ChatMessage {
id: string;
@@ -33,7 +33,7 @@ interface CreateMCPServerProps {
}
const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOKEN, AUTH_TYPE.TOKEN, AUTH_TYPE.BASIC];
const AUTH_TYPES_REQUIRING_CREDENTIALS = [...AUTH_TYPES_REQUIRING_AUTH_VALUE, AUTH_TYPE.OAUTH2];
const AUTH_TYPES_REQUIRING_CREDENTIALS = [...AUTH_TYPES_REQUIRING_AUTH_VALUE, AUTH_TYPE.OAUTH2, AUTH_TYPE.AWS_SIGV4];
const CREATE_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-create-state";
const reduceStaticHeaders = (list: unknown): Record<string, string> => {
@@ -85,6 +85,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const authType = formValues.auth_type as string | undefined;
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M;
const persistCreateUiState = () => {
@@ -767,6 +768,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
<Select.Option value="token">Token</Select.Option>
<Select.Option value="basic">Basic Auth</Select.Option>
<Select.Option value="oauth2">OAuth</Select.Option>
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
</Select>
</Form.Item>
@@ -818,6 +820,122 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
/>
)}
{transportType !== "stdio" && transportType !== "" && isAwsSigV4AuthType && (
<>
<p className="text-sm text-gray-500 mb-2">
For MCP servers hosted on AWS Bedrock AgentCore.{" "}
<a href="https://docs.litellm.ai/docs/mcp_aws_sigv4" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:text-blue-700">
View docs &rarr;
</a>
</p>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Region
<Tooltip title="AWS region for SigV4 signing (e.g., us-east-1)">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_region_name"]}
rules={[{ required: true, message: "AWS region is required for SigV4 auth" }]}
>
<Input
placeholder="us-east-1"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Service Name
<Tooltip title="AWS service name for SigV4 signing. Defaults to 'bedrock-agentcore'.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_service_name"]}
>
<Input
placeholder="bedrock-agentcore"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Access Key ID
<Tooltip title="Optional. If not provided, falls back to the boto3 credential chain (IAM role, env vars, etc.).">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_access_key_id"]}
dependencies={[["credentials", "aws_secret_access_key"]]}
rules={[
({ getFieldValue }) => ({
validator(_, value) {
const secretKey = getFieldValue(["credentials", "aws_secret_access_key"]);
if (secretKey && !value) {
return Promise.reject(new Error("Access Key ID is required when Secret Access Key is provided"));
}
return Promise.resolve();
},
}),
]}
>
<Input.Password
placeholder="AKIA... (optional — uses IAM role if blank)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Secret Access Key
<Tooltip title="Optional. Required if AWS Access Key ID is provided.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_secret_access_key"]}
dependencies={[["credentials", "aws_access_key_id"]]}
rules={[
({ getFieldValue }) => ({
validator(_, value) {
const accessKeyId = getFieldValue(["credentials", "aws_access_key_id"]);
if (accessKeyId && !value) {
return Promise.reject(new Error("Secret Access Key is required when Access Key ID is provided"));
}
return Promise.resolve();
},
}),
]}
>
<Input.Password
placeholder="Enter secret key (optional — uses IAM role if blank)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Session Token
<Tooltip title="Optional. Only needed for temporary STS credentials.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_session_token"]}
>
<Input.Password
placeholder="Enter session token (optional)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
</>
)}
{/* Stdio Configuration - only show for stdio transport */}
<StdioConfiguration isVisible={transportType === "stdio"} />
</div>
@@ -22,7 +22,7 @@ interface MCPServerEditProps {
}
const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOKEN, AUTH_TYPE.TOKEN, AUTH_TYPE.BASIC];
const AUTH_TYPES_REQUIRING_CREDENTIALS = [...AUTH_TYPES_REQUIRING_AUTH_VALUE, AUTH_TYPE.OAUTH2];
const AUTH_TYPES_REQUIRING_CREDENTIALS = [...AUTH_TYPES_REQUIRING_AUTH_VALUE, AUTH_TYPE.OAUTH2, AUTH_TYPE.AWS_SIGV4];
const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
const MCPServerEdit: React.FC<MCPServerEditProps> = ({
@@ -50,6 +50,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
const isMCPTransport = !isStdioTransport && !isOpenAPITransport;
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
const oauthFlowTypeValue = Form.useWatch("oauth_flow_type", form) as string | undefined;
const isM2MFlow = isOAuthAuthType && oauthFlowTypeValue === OAUTH_FLOW.M2M;
@@ -665,6 +666,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
<Select.Option value="token">Token</Select.Option>
<Select.Option value="basic">Basic Auth</Select.Option>
<Select.Option value="oauth2">OAuth</Select.Option>
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
</Select>
</Form.Item>
)}
@@ -883,6 +885,100 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
</>
)}
{!isStdioTransport && isAwsSigV4AuthType && (
<>
<p className="text-sm text-gray-500 mb-2">
For MCP servers hosted on AWS Bedrock AgentCore.{" "}
<a href="https://docs.litellm.ai/docs/mcp_aws_sigv4" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:text-blue-700">
View docs &rarr;
</a>
</p>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Region
<Tooltip title="AWS region for SigV4 signing (e.g., us-east-1)">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_region_name"]}
rules={[]}
>
<Input
placeholder="us-east-1 (leave blank to keep existing)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Service Name
<Tooltip title="AWS service name for SigV4 signing. Defaults to 'bedrock-agentcore'.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_service_name"]}
>
<Input
placeholder="bedrock-agentcore (leave blank to keep existing)"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Access Key ID
<Tooltip title="Optional. If not provided, falls back to the boto3 credential chain (IAM role, env vars, etc.).">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_access_key_id"]}
rules={[]}
>
<Input.Password
placeholder="Leave blank to keep existing"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Secret Access Key
<Tooltip title="Optional. Required if AWS Access Key ID is provided.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_secret_access_key"]}
rules={[]}
>
<Input.Password
placeholder="Leave blank to keep existing"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
AWS Session Token
<Tooltip title="Optional. Only needed for temporary STS credentials.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name={["credentials", "aws_session_token"]}
>
<Input.Password
placeholder="Leave blank to keep existing"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
</>
)}
{/* Permission Management / Access Control Section */}
<div className="mt-6">
<MCPPermissionManagement
@@ -39,6 +39,7 @@ export const AUTH_TYPE = {
TOKEN: "token",
BASIC: "basic",
OAUTH2: "oauth2",
AWS_SIGV4: "aws_sigv4",
};
export const OAUTH_FLOW = {