From 4714128da5f59e993a21f53518dc77a6eca5dd9c Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Tue, 10 Mar 2026 18:05:13 +0530 Subject: [PATCH 01/14] fix: fail proxy startup if prisma migrate fails Co-Authored-By: Claude Haiku 4.5 --- litellm/proxy/proxy_cli.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 921d86c35c..5f6ccfd9ba 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -853,7 +853,14 @@ 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): + import sys + + 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 From aae2deb839c8f39f82a58140f2ffa97b42d97a61 Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Tue, 10 Mar 2026 18:15:11 +0530 Subject: [PATCH 02/14] fix: remove redundant import and add test for startup failure - Remove redundant `import sys` (already imported at module level) - Add test_startup_fails_when_db_setup_fails verifying sys.exit(1) when PrismaManager.setup_database returns False Co-Authored-By: Claude Opus 4.6 --- litellm/proxy/proxy_cli.py | 2 - tests/test_litellm/proxy/test_proxy_cli.py | 57 ++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 5f6ccfd9ba..be2f5ac7c1 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -854,8 +854,6 @@ def run_server( # noqa: PLR0915 check_prisma_schema_diff(db_url=None) else: if not PrismaManager.setup_database(use_migrate=not use_prisma_db_push): - import sys - 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" diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index cf6511c18a..bed77b43c1 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -664,6 +664,63 @@ 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 + # --- Module-level helpers for worker startup hook tests --- From 2f5a553a7d8e8cad9035f3b3c8e22cd6b16d1732 Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Tue, 10 Mar 2026 18:31:04 +0530 Subject: [PATCH 03/14] test: assert setup_database called with correct args Co-Authored-By: Claude Opus 4.6 --- tests/test_litellm/proxy/test_proxy_cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index bed77b43c1..642d21a42f 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -720,6 +720,7 @@ class TestHealthAppFactory: ["--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 --- From b108c02fd780abd2247bd248340afaf43b3c5177 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 11 Mar 2026 10:08:17 +0530 Subject: [PATCH 04/14] Add support for gemini multimodal embedings --- .../batch_embed_content_handler.py | 253 +++++++++++++---- .../batch_embed_content_transformation.py | 191 ++++++++++++- litellm/types/llms/vertex_ai.py | 11 + .../vertex_ai/test_gemini_batch_embeddings.py | 264 +++++++++++++++++- 4 files changed, 656 insertions(+), 63 deletions(-) diff --git a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py index 07f57a4a7f..1447eb4b92 100644 --- a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py +++ b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py @@ -3,12 +3,11 @@ Google AI Studio /batchEmbedContents Embeddings Endpoint """ import json -from typing import Any, Literal, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Union import httpx import litellm -from litellm.types.utils import EmbeddingResponse from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, @@ -16,18 +15,100 @@ from litellm.llms.custom_httpx.http_handler import ( ) from litellm.types.llms.openai import EmbeddingInput from litellm.types.llms.vertex_ai import ( + GeminiEmbedContentResponseObject, 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}?key={api_key}" + response = sync_handler.get(url=url) + + 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}?key={api_key}" + response = await async_handler.get(url=url) + + 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 +135,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 +150,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) + + if is_multimodal: + 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 +176,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, + is_multimodal=is_multimodal, + api_key=api_key, + optional_params=optional_params, + logging_obj=logging_obj, + ) + + ### TRANSFORMATION (sync path) ### + if is_multimodal: + 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 +227,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 +237,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 is_multimodal: + 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, + is_multimodal: 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 +286,36 @@ class GoogleBatchEmbeddings(VertexLLM): else: async_handler = client # type: ignore + ### TRANSFORMATION (async path) ### + if is_multimodal: + 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 +326,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 is_multimodal: + 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, + ) diff --git a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py index 455ec1d18f..6070c70677 100644 --- a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py +++ b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py @@ -4,20 +4,100 @@ 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 _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 or file references). + + Args: + input: EmbeddingInput (str or List[str]) + + Returns: + bool: True if any element is a data URI or file reference + """ + 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 + + return False + def transform_openai_input_gemini_content( input: EmbeddingInput, model: str, optional_params: dict @@ -26,12 +106,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 +124,109 @@ 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_file_reference(element): + if element not in resolved_files: + raise ValueError(f"File reference {element} not resolved") + file_info = resolved_files[element] + file_data: FileDataType = { + "mime_type": file_info["mime_type"], + "file_uri": file_info["uri"], + } + parts.append(PartType(file_data=file_data)) + 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 + + 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, diff --git a/litellm/types/llms/vertex_ai.py b/litellm/types/llms/vertex_ai.py index 190e680b7b..81de09595a 100644 --- a/litellm/types/llms/vertex_ai.py +++ b/litellm/types/llms/vertex_ai.py @@ -556,6 +556,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 diff --git a/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py b/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py index 7047be4241..ba741d6c2b 100644 --- a/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py +++ b/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py @@ -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,247 @@ 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" + + +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 + From 2c4a495619fd95cf203bb9b60485ebf6fb2a2377 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 11 Mar 2026 10:39:34 +0530 Subject: [PATCH 05/14] Add support for vertex ai gemini multimodal embedings --- litellm/llms/vertex_ai/common_utils.py | 24 ++++++++++++--------- litellm/main.py | 29 ++++++++++++++++++++++++-- litellm/types/utils.py | 1 + litellm/utils.py | 1 + 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 3c5cbb6543..c02d63414c 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -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 diff --git a/litellm/main.py b/litellm/main.py index 364519e1fe..529b998810 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -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, diff --git a/litellm/types/utils.py b/litellm/types/utils.py index b5d5c06924..52221c47de 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -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): diff --git a/litellm/utils.py b/litellm/utils.py index 4367ec789b..eebcdb3ec5 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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}") From d25b8e6d009a52dce7d6ed0906ccee8deedc905d Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 11 Mar 2026 10:58:04 +0530 Subject: [PATCH 06/14] Add support for gcs url for vertex ai embeddings --- .../batch_embed_content_transformation.py | 57 ++++++++++++++-- .../vertex_ai/test_gemini_batch_embeddings.py | 65 +++++++++++++++++++ 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py index 6070c70677..b2bf2c6eb5 100644 --- a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py +++ b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py @@ -35,6 +35,46 @@ def _is_file_reference(s: str) -> bool: 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. @@ -76,13 +116,13 @@ def _parse_data_url(data_url: str) -> Tuple[str, str]: def _is_multimodal_input(input: EmbeddingInput) -> bool: """ - Check if the input contains multimodal data (data URIs or file references). + 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 or file reference + bool: True if any element is a data URI, file reference, or GCS URL """ if isinstance(input, str): input_list = [input] @@ -95,6 +135,8 @@ def _is_multimodal_input(input: EmbeddingInput) -> bool: return True if _is_file_reference(element): return True + if _is_gcs_url(element): + return True return False @@ -166,15 +208,22 @@ def transform_openai_input_gemini_embed_content( 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: FileDataType = { + file_data_ref: FileDataType = { "mime_type": file_info["mime_type"], "file_uri": file_info["uri"], } - parts.append(PartType(file_data=file_data)) + parts.append(PartType(file_data=file_data_ref)) else: parts.append(PartType(text=element)) diff --git a/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py b/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py index ba741d6c2b..302facefcb 100644 --- a/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py +++ b/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py @@ -391,3 +391,68 @@ def test_dimensions_mapped_to_output_dimensionality(): 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 + From 8c5478df705b05c52aa5c2958b75822e3bf3f9cf Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 11 Mar 2026 10:58:50 +0530 Subject: [PATCH 07/14] Add embedding model in model map --- ...odel_prices_and_context_window_backup.json | 39 +++++++++++++++++++ model_prices_and_context_window.json | 39 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d3dd6b3d99..b53e1e14d7 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -15962,6 +15962,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-flash-experimental": { "input_cost_per_character": 0, "input_cost_per_token": 0, @@ -16039,6 +16065,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-1.5-flash": { "deprecation_date": "2025-09-29", "input_cost_per_token": 7.5e-08, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d3dd6b3d99..b53e1e14d7 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -15962,6 +15962,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-flash-experimental": { "input_cost_per_character": 0, "input_cost_per_token": 0, @@ -16039,6 +16065,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-1.5-flash": { "deprecation_date": "2025-09-29", "input_cost_per_token": 7.5e-08, From 1c144fc8961a850d8e84ca7c2feff1906bb9fab3 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 11 Mar 2026 11:02:49 +0530 Subject: [PATCH 08/14] Add embedding model documentation --- .../gemini_embedding_2_multimodal/index.md | 169 ++++++++++++++++++ .../docs/embedding/supported_embedding.md | 51 ++++++ .../docs/providers/vertex_embedding.md | 66 +++++++ 3 files changed, 286 insertions(+) create mode 100644 docs/my-website/blog/gemini_embedding_2_multimodal/index.md diff --git a/docs/my-website/blog/gemini_embedding_2_multimodal/index.md b/docs/my-website/blog/gemini_embedding_2_multimodal/index.md new file mode 100644 index 0000000000..8c09432e3b --- /dev/null +++ b/docs/my-website/blog/gemini_embedding_2_multimodal/index.md @@ -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,` +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 + + + + +```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) +``` + + + + + +```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) +``` + + + + + +**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" + ] + }' +``` + + + + +## 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 +) +``` diff --git a/docs/my-website/docs/embedding/supported_embedding.md b/docs/my-website/docs/embedding/supported_embedding.md index 11ca4da48a..87acd0b33a 100644 --- a/docs/my-website/docs/embedding/supported_embedding.md +++ b/docs/my-website/docs/embedding/supported_embedding.md @@ -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,` +- **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` + + + + +```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) +``` + + + + +```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" + ] + }' +``` + + + + +**Optional:** `dimensions` maps to Gemini's `outputDimensionality`. ## Vertex AI Embedding Models diff --git a/docs/my-website/docs/providers/vertex_embedding.md b/docs/my-website/docs/providers/vertex_embedding.md index 5656ade337..9b530f2ae0 100644 --- a/docs/my-website/docs/providers/vertex_embedding.md +++ b/docs/my-website/docs/providers/vertex_embedding.md @@ -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/", 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,` +- **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` + + + + +```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" + ], +) +``` + + + + +```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"] + }' +``` + + + + +### multimodalembedding@001 (Legacy) Known Limitations: - Only supports 1 image / video / image per request From 2a9bcf2530e61ef573db9d6955586caa06789eed Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 11 Mar 2026 11:41:29 +0530 Subject: [PATCH 09/14] Fix greptile reviews --- .../batch_embed_content_handler.py | 26 +++---- .../batch_embed_content_transformation.py | 7 +- .../vertex_ai/test_gemini_batch_embeddings.py | 68 +++++++++++++++++++ 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py index 1447eb4b92..25c3465807 100644 --- a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py +++ b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py @@ -55,8 +55,9 @@ class GoogleBatchEmbeddings(VertexLLM): for element in input_list: if isinstance(element, str) and _is_file_reference(element): - url = f"https://generativelanguage.googleapis.com/v1beta/{element}?key={api_key}" - response = sync_handler.get(url=url) + 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( @@ -93,8 +94,9 @@ class GoogleBatchEmbeddings(VertexLLM): for element in input_list: if isinstance(element, str) and _is_file_reference(element): - url = f"https://generativelanguage.googleapis.com/v1beta/{element}?key={api_key}" - response = await async_handler.get(url=url) + 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( @@ -151,8 +153,8 @@ class GoogleBatchEmbeddings(VertexLLM): optional_params = optional_params or {} is_multimodal = _is_multimodal_input(input) - - if is_multimodal: + use_embed_content = is_multimodal or (custom_llm_provider == "vertex_ai") + if use_embed_content: mode = "embedding" else: mode = "batch_embedding" @@ -192,14 +194,14 @@ class GoogleBatchEmbeddings(VertexLLM): timeout=timeout, headers=headers, input=input, - is_multimodal=is_multimodal, + use_embed_content=use_embed_content, api_key=api_key, optional_params=optional_params, logging_obj=logging_obj, ) ### TRANSFORMATION (sync path) ### - if is_multimodal: + if use_embed_content: resolved_files = {} if api_key: resolved_files = self._resolve_file_references( @@ -238,7 +240,7 @@ class GoogleBatchEmbeddings(VertexLLM): _json_response = response.json() - if is_multimodal: + if use_embed_content: return process_embed_content_response( input=input, model_response=model_response, @@ -265,7 +267,7 @@ class GoogleBatchEmbeddings(VertexLLM): timeout: Optional[Union[float, httpx.Timeout]], headers={}, client: Optional[AsyncHTTPHandler] = None, - is_multimodal: bool = False, + use_embed_content: bool = False, api_key: Optional[str] = None, optional_params: Optional[dict] = None, logging_obj: Optional[Any] = None, @@ -287,7 +289,7 @@ class GoogleBatchEmbeddings(VertexLLM): async_handler = client # type: ignore ### TRANSFORMATION (async path) ### - if is_multimodal: + if use_embed_content: resolved_files = {} if api_key: resolved_files = await self._async_resolve_file_references( @@ -327,7 +329,7 @@ class GoogleBatchEmbeddings(VertexLLM): _json_response = response.json() - if is_multimodal: + if use_embed_content: return process_embed_content_response( input=input, model_response=model_response, diff --git a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py index b2bf2c6eb5..41f477d9db 100644 --- a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py +++ b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_transformation.py @@ -267,8 +267,11 @@ def process_embed_content_response( model_response.data = [openai_embedding] model_response.model = model - input_text = get_formatted_prompt(data={"input": input}, call_type="embedding") - prompt_tokens = token_counter(model=model, text=input_text) + 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 ) diff --git a/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py b/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py index 302facefcb..1ed1de01b5 100644 --- a/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py +++ b/tests/litellm/llms/vertex_ai/test_gemini_batch_embeddings.py @@ -271,6 +271,26 @@ def test_embed_content_response_processing(): 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(): @@ -456,3 +476,51 @@ def test_multimodal_input_detection_with_gcs(): 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 + From e394914d34d962c8b348b09652bf9d866a99090b Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 11 Mar 2026 11:49:02 +0530 Subject: [PATCH 10/14] Fix code qa --- .../vertex_ai/gemini_embeddings/batch_embed_content_handler.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py index 25c3465807..68901340c7 100644 --- a/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py +++ b/litellm/llms/vertex_ai/gemini_embeddings/batch_embed_content_handler.py @@ -3,7 +3,7 @@ Google AI Studio /batchEmbedContents Embeddings Endpoint """ import json -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Dict, Literal, Optional, Union import httpx @@ -15,7 +15,6 @@ from litellm.llms.custom_httpx.http_handler import ( ) from litellm.types.llms.openai import EmbeddingInput from litellm.types.llms.vertex_ai import ( - GeminiEmbedContentResponseObject, VertexAIBatchEmbeddingsRequestBody, VertexAIBatchEmbeddingsResponseObject, ) From c2fca1124bb5f80af16b94f175a4dc5d977c90dd Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 11 Mar 2026 16:41:58 +0530 Subject: [PATCH 11/14] fix(proxy): preserve multipart/form-data boundary in passthrough endpoints Fixes issue where multipart file uploads through passthrough endpoints failed with RequestValidationError. The proxy was consuming the request body stream and FastAPI was trying to parse multipart bodies as JSON dicts. Changes: - Try JSON parsing first for multipart content-type (handles misconfigured clients) - Skip multipart parsing if JSON succeeds to avoid stream consumption - Remove custom_body parameter from endpoint_func to prevent FastAPI auto-parsing - Check for parsed body before using multipart handler - Add regression test for multipart boundary preservation Handles both actual multipart uploads and JSON bodies with incorrect multipart content-type headers. Made-with: Cursor --- .../pass_through_endpoints.py | 59 ++++++++--------- .../test_pass_through_endpoints.py | 65 +++++++++++++++++++ 2 files changed, 93 insertions(+), 31 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 4d95fda0a4..338a198d00 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -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 diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index 71420c23ad..5af24f9612 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -2408,3 +2408,68 @@ def test_mapped_pass_through_routes_with_server_root_path(): ) 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() From 7d2cc4a3bf3d86b5472ee4ba8fed9df88ce476c6 Mon Sep 17 00:00:00 2001 From: Joe Reyna Date: Wed, 11 Mar 2026 07:37:24 -0700 Subject: [PATCH 12/14] fix(ui): import MCPEvent type into local scope in chat/types.ts (#23330) --- ui/litellm-dashboard/src/components/chat/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/chat/types.ts b/ui/litellm-dashboard/src/components/chat/types.ts index 4c0da7a095..9b657306db 100644 --- a/ui/litellm-dashboard/src/components/chat/types.ts +++ b/ui/litellm-dashboard/src/components/chat/types.ts @@ -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; From cbbd51a5ce1fdf8546e9424d1d4ed66a3c51c23f Mon Sep 17 00:00:00 2001 From: Joe Reyna Date: Wed, 11 Mar 2026 07:38:01 -0700 Subject: [PATCH 13/14] fix(codeql): switch to security-extended to fix OOM failures (#23226) * fix(codeql): switch to security-extended query suite The security-and-quality suite produces result sets > 2 GiB on this codebase, causing fatal OOM failures and blocking CI. Switching to security-extended reduces query scope to security-only checks, which still complete successfully. Quality/maintainability checks are already covered by the existing lint pipeline. * fix(codeql): exclude OOM queries from security-extended --- .github/codeql/codeql-config.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 9b6be27ab8..20807685e1 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -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 From 24ad510617a27d8fb34c8e7d257f5fbc910b1eb1 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Wed, 11 Mar 2026 17:43:28 +0100 Subject: [PATCH 14/14] feat(mcp): add AWS SigV4 auth support in UI and fix credential merge on edit (#23282) --- docs/my-website/docs/mcp.md | 15 + docs/my-website/docs/mcp_aws_sigv4.md | 41 +- docs/my-website/img/mcp_aws_sigv4_ui.png | Bin 0 -> 73624 bytes litellm/proxy/_experimental/mcp_server/db.py | 98 ++- .../mcp_server/mcp_server_manager.py | 59 +- .../mcp_management_endpoints.py | 11 + litellm/types/mcp.py | 16 + .../mcp_server/test_mcp_sigv4_auth.py | 572 +++++++++++++++++- .../mcp_tools/create_mcp_server.tsx | 120 +++- .../components/mcp_tools/mcp_server_edit.tsx | 98 ++- .../src/components/mcp_tools/types.tsx | 1 + 11 files changed, 1021 insertions(+), 10 deletions(-) create mode 100644 docs/my-website/img/mcp_aws_sigv4_ui.png diff --git a/docs/my-website/docs/mcp.md b/docs/my-website/docs/mcp.md index 600f69547d..b805cce4d7 100644 --- a/docs/my-website/docs/mcp.md +++ b/docs/my-website/docs/mcp.md @@ -133,6 +133,21 @@ LiteLLM attempts [OAuth 2.0 Authorization Server Discovery](https://datatracker.
+### 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). + + + +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) + +
+ ### 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. diff --git a/docs/my-website/docs/mcp_aws_sigv4.md b/docs/my-website/docs/mcp_aws_sigv4.md index e00cee4fd5..9dc60bce06 100644 --- a/docs/my-website/docs/mcp_aws_sigv4.md +++ b/docs/my-website/docs/mcp_aws_sigv4.md @@ -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 + + + +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: + + + +
+ +| 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. + +
+ + ### 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 + +
-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 \ diff --git a/docs/my-website/img/mcp_aws_sigv4_ui.png b/docs/my-website/img/mcp_aws_sigv4_ui.png new file mode 100644 index 0000000000000000000000000000000000000000..17016d3ae1246e6ebae5b9d0497075d41c200443 GIT binary patch literal 73624 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 z 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 &{+cDN#NRJF#r80gJ-Q7v+oAnqlwRfLFzv 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 CCnxm) literal 0 HcmV?d00001 diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index 93580b5430..c6af4be314 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -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, ) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 8769d3952a..b10bfde491 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -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()] diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 08f452859f..7eeb2a8300 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -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 diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index 33e55f9bed..af91926de2 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -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] diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sigv4_auth.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sigv4_auth.py index 715bb8e8ae..a2295e1271 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sigv4_auth.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sigv4_auth.py @@ -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" diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 74945731a2..e684540289 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -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 => { @@ -85,6 +85,7 @@ const CreateMCPServer: React.FC = ({ 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 = ({ Token Basic Auth OAuth + AWS SigV4 (Bedrock AgentCore MCPs) @@ -818,6 +820,122 @@ const CreateMCPServer: React.FC = ({ /> )} + {transportType !== "stdio" && transportType !== "" && isAwsSigV4AuthType && ( + <> +

+ For MCP servers hosted on AWS Bedrock AgentCore.{" "} + + View docs → + +

+ + AWS Region + + + + + } + name={["credentials", "aws_region_name"]} + rules={[{ required: true, message: "AWS region is required for SigV4 auth" }]} + > + + + + AWS Service Name + + + + + } + name={["credentials", "aws_service_name"]} + > + + + + AWS Access Key ID + + + + + } + 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(); + }, + }), + ]} + > + + + + AWS Secret Access Key + + + + + } + 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(); + }, + }), + ]} + > + + + + AWS Session Token + + + + + } + name={["credentials", "aws_session_token"]} + > + + + + )} + {/* Stdio Configuration - only show for stdio transport */} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index eadf93d8a9..04cce34303 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -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 = ({ @@ -50,6 +50,7 @@ const MCPServerEdit: React.FC = ({ 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 = ({ Token Basic Auth OAuth + AWS SigV4 (Bedrock AgentCore MCPs) )} @@ -883,6 +885,100 @@ const MCPServerEdit: React.FC = ({ )} + {!isStdioTransport && isAwsSigV4AuthType && ( + <> +

+ For MCP servers hosted on AWS Bedrock AgentCore.{" "} + + View docs → + +

+ + AWS Region + + + + + } + name={["credentials", "aws_region_name"]} + rules={[]} + > + + + + AWS Service Name + + + + + } + name={["credentials", "aws_service_name"]} + > + + + + AWS Access Key ID + + + + + } + name={["credentials", "aws_access_key_id"]} + rules={[]} + > + + + + AWS Secret Access Key + + + + + } + name={["credentials", "aws_secret_access_key"]} + rules={[]} + > + + + + AWS Session Token + + + + + } + name={["credentials", "aws_session_token"]} + > + + + + )} + {/* Permission Management / Access Control Section */}