From 5ec925b02b4ebe8094349003ee9282d40bcc2c85 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:03:56 -0700 Subject: [PATCH 01/10] chore(providers): guard URL-valued model destinations --- litellm/llms/gemini/files/transformation.py | 30 ++++++-- .../llms/huggingface/chat/transformation.py | 15 ++-- litellm/llms/huggingface/common_utils.py | 14 ++++ litellm/llms/huggingface/embedding/handler.py | 8 +- .../huggingface/embedding/transformation.py | 13 +++- litellm/llms/oobabooga/chat/oobabooga.py | 12 ++- litellm/llms/oobabooga/common_utils.py | 13 ++++ .../files/test_gemini_files_transformation.py | 35 ++++++++- .../test_huggingface_model_url_guard.py | 76 +++++++++++++++++++ .../test_oobabooga_model_url_guard.py | 46 +++++++++++ 10 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py create mode 100644 tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py diff --git a/litellm/llms/gemini/files/transformation.py b/litellm/llms/gemini/files/transformation.py index 401d7bb9f4..f21296ef26 100644 --- a/litellm/llms/gemini/files/transformation.py +++ b/litellm/llms/gemini/files/transformation.py @@ -29,6 +29,8 @@ from litellm.types.utils import LlmProviders from ..common_utils import GeminiModelInfo +_GEMINI_FILES_HOST = "generativelanguage.googleapis.com" + class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): def __init__(self): @@ -248,6 +250,13 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): """ if file_id.startswith(("http://", "https://")): parsed = urlparse(file_id) + if ( + parsed.scheme != "https" + or parsed.hostname != _GEMINI_FILES_HOST + or parsed.username is not None + or parsed.password is not None + ): + raise ValueError("Invalid Gemini file URL") path = parsed.path.lstrip("/") files_index = path.find("files/") if files_index != -1: @@ -260,9 +269,22 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): normalized_file_id = normalized_file_id.strip("/") if not normalized_file_id.startswith("files/"): normalized_file_id = f"files/{normalized_file_id}" + self._validate_gemini_file_name(normalized_file_id) return normalized_file_id + @staticmethod + def _validate_gemini_file_name(file_name: str) -> None: + parts = file_name.split("/") + if ( + len(parts) != 2 + or parts[0] != "files" + or not parts[1] + or parts[1] in {".", ".."} + or any(char in parts[1] for char in ("\\", "?", "#")) + ): + raise ValueError("Invalid Gemini file name") + def transform_retrieve_file_response( self, raw_response: httpx.Response, @@ -337,13 +359,7 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): if not api_key: raise ValueError("api_key is required") - # Extract file name from URI if full URI is provided - # file_id could be "files/abc123" or "https://generativelanguage.googleapis.com/v1beta/files/abc123" - if file_id.startswith("http"): - # Extract the file path from full URI - file_name = file_id.split("/v1beta/")[-1] - else: - file_name = file_id if file_id.startswith("files/") else f"files/{file_id}" + file_name = self._normalize_gemini_file_id(file_id) # Construct the delete URL url = f"{api_base}/v1beta/{file_name}" diff --git a/litellm/llms/huggingface/chat/transformation.py b/litellm/llms/huggingface/chat/transformation.py index 557aa48550..1591ce0e88 100644 --- a/litellm/llms/huggingface/chat/transformation.py +++ b/litellm/llms/huggingface/chat/transformation.py @@ -16,7 +16,11 @@ else: from litellm.llms.base_llm.chat.transformation import BaseLLMException from ...openai.chat.gpt_transformation import OpenAIGPTConfig -from ..common_utils import HuggingFaceError, _fetch_inference_provider_mapping +from ..common_utils import ( + HuggingFaceError, + _fetch_inference_provider_mapping, + validate_huggingface_model_identifier, +) logger = logging.getLogger(__name__) @@ -76,9 +80,8 @@ class HuggingFaceChatConfig(OpenAIGPTConfig): Do not add the chat/embedding/rerank extension here. Let the handler do this. """ - if model.startswith(("http://", "https://")): - base_url = model - elif base_url is None: + validate_huggingface_model_identifier(model) + if base_url is None: base_url = os.getenv("HF_API_BASE") or os.getenv("HUGGINGFACE_API_BASE", "") return base_url @@ -95,6 +98,7 @@ class HuggingFaceChatConfig(OpenAIGPTConfig): Get the complete URL for the API call. For provider-specific routing through huggingface """ + validate_huggingface_model_identifier(model) # Check if api_base is provided if api_base is not None: complete_url = api_base @@ -103,9 +107,6 @@ class HuggingFaceChatConfig(OpenAIGPTConfig): complete_url = str(os.getenv("HF_API_BASE")) or str( os.getenv("HUGGINGFACE_API_BASE") ) - elif model.startswith(("http://", "https://")): - complete_url = model - complete_url = _build_chat_completion_url(complete_url) # Default construction with provider else: # Parse provider and model diff --git a/litellm/llms/huggingface/common_utils.py b/litellm/llms/huggingface/common_utils.py index 9ab4367c9b..2d5f583873 100644 --- a/litellm/llms/huggingface/common_utils.py +++ b/litellm/llms/huggingface/common_utils.py @@ -9,6 +9,20 @@ from litellm.llms.base_llm.chat.transformation import BaseLLMException HF_HUB_URL = "https://huggingface.co" +def validate_huggingface_model_identifier(model: str) -> None: + """Reject URL-valued model identifiers before provider credentials are added.""" + if "://" not in model: + return + raise HuggingFaceError( + status_code=400, + message=( + "Invalid Hugging Face model identifier. Configure custom endpoints with " + "api_base or HF_API_BASE/HUGGINGFACE_API_BASE instead of passing a URL " + "as the model." + ), + ) + + class HuggingFaceError(BaseLLMException): def __init__( self, diff --git a/litellm/llms/huggingface/embedding/handler.py b/litellm/llms/huggingface/embedding/handler.py index 226f6b2eba..4e3ac0ec0b 100644 --- a/litellm/llms/huggingface/embedding/handler.py +++ b/litellm/llms/huggingface/embedding/handler.py @@ -14,7 +14,7 @@ from litellm.llms.custom_httpx.http_handler import ( from litellm.types.utils import EmbeddingResponse from ...base import BaseLLM -from ..common_utils import HuggingFaceError +from ..common_utils import HuggingFaceError, validate_huggingface_model_identifier from .transformation import HuggingFaceEmbeddingConfig config = HuggingFaceEmbeddingConfig() @@ -154,6 +154,7 @@ class HuggingFaceEmbedding(BaseLLM): embed_url: str, ) -> dict: data: Dict = {} + validate_huggingface_model_identifier(model) ## TRANSFORMATION ## if "sentence-transformers" in model: @@ -334,6 +335,7 @@ class HuggingFaceEmbedding(BaseLLM): headers={}, ) -> EmbeddingResponse: super().embedding() + validate_huggingface_model_identifier(model) headers = config.validate_environment( api_key=api_key, headers=headers, @@ -348,9 +350,7 @@ class HuggingFaceEmbedding(BaseLLM): ) # print_verbose(f"{model}, {task}") embed_url = "" - if "https" in model: - embed_url = model - elif api_base: + if api_base: embed_url = api_base elif "HF_API_BASE" in os.environ: embed_url = os.getenv("HF_API_BASE", "") diff --git a/litellm/llms/huggingface/embedding/transformation.py b/litellm/llms/huggingface/embedding/transformation.py index 88d42cfcdc..3a17b0a114 100644 --- a/litellm/llms/huggingface/embedding/transformation.py +++ b/litellm/llms/huggingface/embedding/transformation.py @@ -21,7 +21,13 @@ from litellm.types.llms.openai import AllMessageValues from litellm.types.utils import Choices, Message, ModelResponse, Usage from litellm.utils import token_counter -from ..common_utils import HuggingFaceError, hf_task_list, hf_tasks, output_parser +from ..common_utils import ( + HuggingFaceError, + hf_task_list, + hf_tasks, + output_parser, + validate_huggingface_model_identifier, +) if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -336,9 +342,8 @@ class HuggingFaceEmbeddingConfig(BaseConfig): Do not add the chat/embedding/rerank extension here. Let the handler do this. """ - if "https" in model: - completion_url = model - elif api_base is not None: + validate_huggingface_model_identifier(model) + if api_base is not None: completion_url = api_base elif "HF_API_BASE" in os.environ: completion_url = os.getenv("HF_API_BASE", "") diff --git a/litellm/llms/oobabooga/chat/oobabooga.py b/litellm/llms/oobabooga/chat/oobabooga.py index 5eb68a03d4..bcd3584efb 100644 --- a/litellm/llms/oobabooga/chat/oobabooga.py +++ b/litellm/llms/oobabooga/chat/oobabooga.py @@ -5,7 +5,7 @@ import litellm from litellm.llms.custom_httpx.http_handler import _get_httpx_client from litellm.utils import EmbeddingResponse, ModelResponse, Usage -from ..common_utils import OobaboogaError +from ..common_utils import OobaboogaError, validate_oobabooga_model_identifier from .transformation import OobaboogaConfig oobabooga_config = OobaboogaConfig() @@ -26,6 +26,7 @@ def completion( logger_fn=None, default_max_tokens_to_sample=None, ): + validate_oobabooga_model_identifier(model) headers = oobabooga_config.validate_environment( api_key=api_key, headers={}, @@ -34,9 +35,7 @@ def completion( optional_params=optional_params, litellm_params=litellm_params, ) - if "https" in model: - completion_url = model - elif api_base: + if api_base: completion_url = api_base else: raise OobaboogaError( @@ -96,9 +95,8 @@ def embedding( encoding=None, ): # Create completion URL - if "https" in model: - embeddings_url = model - elif api_base: + validate_oobabooga_model_identifier(model) + if api_base: embeddings_url = f"{api_base}/v1/embeddings" else: raise OobaboogaError( diff --git a/litellm/llms/oobabooga/common_utils.py b/litellm/llms/oobabooga/common_utils.py index 82f8cda951..69f2cb519b 100644 --- a/litellm/llms/oobabooga/common_utils.py +++ b/litellm/llms/oobabooga/common_utils.py @@ -13,3 +13,16 @@ class OobaboogaError(BaseLLMException): headers: Optional[Union[dict, httpx.Headers]] = None, ): super().__init__(status_code=status_code, message=message, headers=headers) + + +def validate_oobabooga_model_identifier(model: str) -> None: + """Oobabooga endpoints must be configured with api_base, not model URLs.""" + if "://" not in model: + return + raise OobaboogaError( + status_code=400, + message=( + "Invalid Oobabooga model identifier. Configure the endpoint with " + "api_base instead of passing a URL as the model." + ), + ) diff --git a/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py b/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py index 2431c9a9c4..41006fb916 100644 --- a/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py +++ b/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py @@ -2,7 +2,6 @@ Test Google AI Studio (Gemini) files transformation functionality """ -import os from unittest.mock import Mock, patch import httpx @@ -93,6 +92,27 @@ class TestGoogleAIStudioFilesTransformation: assert "key=" not in url assert params == {} + def test_transform_retrieve_file_request_rejects_untrusted_full_url(self): + file_id = "https://attacker.example/v1beta/files/test123" + litellm_params = {"api_key": "test-api-key"} + + with pytest.raises(ValueError, match="Invalid Gemini file URL"): + self.handler.transform_retrieve_file_request( + file_id=file_id, + optional_params={}, + litellm_params=litellm_params, + ) + + def test_transform_retrieve_file_request_rejects_traversal_name(self): + litellm_params = {"api_key": "test-api-key"} + + with pytest.raises(ValueError, match="Invalid Gemini file name"): + self.handler.transform_retrieve_file_request( + file_id="files/../secrets", + optional_params={}, + litellm_params=litellm_params, + ) + @patch.dict("os.environ", {}, clear=True) @patch("litellm.llms.gemini.common_utils.get_secret_str", return_value=None) def test_transform_retrieve_file_request_missing_api_key(self, mock_get_secret): @@ -322,3 +342,16 @@ class TestGoogleAIStudioFilesTransformation: assert file_id in url assert "generativelanguage.googleapis.com" in url assert params == {} + + def test_transform_delete_file_request_rejects_untrusted_full_url(self): + litellm_params = { + "api_key": "test-api-key", + "api_base": "https://generativelanguage.googleapis.com", + } + + with pytest.raises(ValueError, match="Invalid Gemini file URL"): + self.handler.transform_delete_file_request( + file_id="https://attacker.example/v1beta/files/test123", + optional_params={}, + litellm_params=litellm_params, + ) diff --git a/tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py b/tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py new file mode 100644 index 0000000000..c0bb56c171 --- /dev/null +++ b/tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py @@ -0,0 +1,76 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from litellm.llms.huggingface.chat.transformation import HuggingFaceChatConfig +from litellm.llms.huggingface.common_utils import HuggingFaceError +from litellm.llms.huggingface.embedding.handler import HuggingFaceEmbedding +from litellm.llms.huggingface.embedding.transformation import ( + HuggingFaceEmbeddingConfig, +) + + +def test_huggingface_chat_rejects_url_valued_model(): + config = HuggingFaceChatConfig() + + with pytest.raises(HuggingFaceError) as exc_info: + config.get_complete_url( + api_base=None, + api_key="hf-secret", + model="https://attacker.example/v1", + optional_params={}, + litellm_params={}, + ) + + assert exc_info.value.status_code == 400 + + +def test_huggingface_chat_keeps_explicit_api_base_for_custom_endpoints(): + config = HuggingFaceChatConfig() + + complete_url = config.get_complete_url( + api_base="https://admin-configured.example", + api_key="hf-secret", + model="huggingface/mistral", + optional_params={}, + litellm_params={}, + ) + + assert complete_url == "https://admin-configured.example/v1/chat/completions" + + +def test_huggingface_embedding_config_rejects_url_valued_model(): + config = HuggingFaceEmbeddingConfig() + + with pytest.raises(HuggingFaceError) as exc_info: + config.get_api_base( + api_base=None, + model="prefixhttps://attacker.example/embeddings", + ) + + assert exc_info.value.status_code == 400 + + +def test_huggingface_embedding_handler_rejects_before_task_lookup(): + handler = HuggingFaceEmbedding() + logging_obj = MagicMock() + encoding = MagicMock() + encoding.encode.return_value = [] + + with patch( + "litellm.llms.huggingface.embedding.handler.get_hf_task_embedding_for_model" + ) as mock_task_lookup: + with pytest.raises(HuggingFaceError) as exc_info: + handler.embedding( + model="https://attacker.example/embeddings", + input=["hello"], + model_response=MagicMock(), + optional_params={}, + litellm_params={}, + logging_obj=logging_obj, + encoding=encoding, + api_key="hf-secret", + ) + + assert exc_info.value.status_code == 400 + mock_task_lookup.assert_not_called() diff --git a/tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py b/tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py new file mode 100644 index 0000000000..dab02637e5 --- /dev/null +++ b/tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py @@ -0,0 +1,46 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from litellm.llms.oobabooga.chat.oobabooga import completion, embedding +from litellm.llms.oobabooga.common_utils import OobaboogaError + + +def test_oobabooga_completion_rejects_url_valued_model_before_request(): + with patch("litellm.llms.oobabooga.chat.oobabooga._get_httpx_client") as mock_get: + with pytest.raises(OobaboogaError) as exc_info: + completion( + model="https://attacker.example/v1", + messages=[], + api_base="https://admin-configured.example", + model_response=MagicMock(), + print_verbose=MagicMock(), + encoding=MagicMock(), + api_key="ooba-secret", + logging_obj=MagicMock(), + optional_params={}, + litellm_params={}, + ) + + assert exc_info.value.status_code == 400 + mock_get.assert_not_called() + + +def test_oobabooga_embedding_rejects_url_valued_model_before_request(): + with patch( + "litellm.llms.oobabooga.chat.oobabooga.litellm.module_level_client.post" + ) as mock_post: + with pytest.raises(OobaboogaError) as exc_info: + embedding( + model="prefixhttps://attacker.example/embeddings", + input=["hello"], + model_response=MagicMock(), + api_key="ooba-secret", + api_base="https://admin-configured.example", + logging_obj=MagicMock(), + optional_params={}, + encoding=MagicMock(), + ) + + assert exc_info.value.status_code == 400 + mock_post.assert_not_called() From d75f62a17c8b5dfa0b19dbdfc6eee7faee33679f Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:17:09 -0700 Subject: [PATCH 02/10] chore(providers): add guarded URL model migration path --- litellm/__init__.py | 3 ++ litellm/llms/gemini/files/transformation.py | 14 +++++-- .../llms/huggingface/chat/transformation.py | 8 +++- litellm/llms/huggingface/common_utils.py | 15 +++++++- litellm/llms/huggingface/embedding/handler.py | 28 +++++++++++--- .../huggingface/embedding/transformation.py | 5 ++- litellm/llms/oobabooga/chat/oobabooga.py | 14 +++++-- litellm/llms/oobabooga/common_utils.py | 16 +++++++- .../files/test_gemini_files_transformation.py | 33 +++++++++++++++++ .../test_huggingface_model_url_guard.py | 32 ++++++++++++++++ .../test_oobabooga_model_url_guard.py | 37 +++++++++++++++++++ 11 files changed, 189 insertions(+), 16 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 77fa48625d..16ae730384 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -280,6 +280,9 @@ ssl_security_level: Optional[str] = None ssl_certificate: Optional[str] = None user_url_validation: bool = True user_url_allowed_hosts: List[str] = [] +reject_url_model_destinations: bool = os.getenv( + "LITELLM_REJECT_URL_MODEL_DESTINATIONS", "true" +).lower() not in ("false", "0") ssl_ecdh_curve: Optional[str] = ( None # Set to 'X25519' to disable PQC and improve performance ) diff --git a/litellm/llms/gemini/files/transformation.py b/litellm/llms/gemini/files/transformation.py index f21296ef26..fe7f81ab95 100644 --- a/litellm/llms/gemini/files/transformation.py +++ b/litellm/llms/gemini/files/transformation.py @@ -6,7 +6,7 @@ For vertex ai, check out the vertex_ai/files/handler.py file. import time from typing import Any, List, Literal, Optional -from urllib.parse import urlparse +from urllib.parse import unquote, urlparse import httpx from openai.types.file_deleted import FileDeleted @@ -276,12 +276,20 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): @staticmethod def _validate_gemini_file_name(file_name: str) -> None: parts = file_name.split("/") + decoded_file_id = "" + if len(parts) == 2: + decoded_file_id = parts[1] + for _ in range(3): + next_decoded_file_id = unquote(decoded_file_id) + if next_decoded_file_id == decoded_file_id: + break + decoded_file_id = next_decoded_file_id if ( len(parts) != 2 or parts[0] != "files" or not parts[1] - or parts[1] in {".", ".."} - or any(char in parts[1] for char in ("\\", "?", "#")) + or decoded_file_id in {".", ".."} + or any(char in decoded_file_id for char in ("/", "\\", "?", "#")) ): raise ValueError("Invalid Gemini file name") diff --git a/litellm/llms/huggingface/chat/transformation.py b/litellm/llms/huggingface/chat/transformation.py index 1591ce0e88..d138ade51e 100644 --- a/litellm/llms/huggingface/chat/transformation.py +++ b/litellm/llms/huggingface/chat/transformation.py @@ -19,6 +19,7 @@ from ...openai.chat.gpt_transformation import OpenAIGPTConfig from ..common_utils import ( HuggingFaceError, _fetch_inference_provider_mapping, + is_url_model_destination, validate_huggingface_model_identifier, ) @@ -81,7 +82,9 @@ class HuggingFaceChatConfig(OpenAIGPTConfig): Do not add the chat/embedding/rerank extension here. Let the handler do this. """ validate_huggingface_model_identifier(model) - if base_url is None: + if is_url_model_destination(model): + base_url = model + elif base_url is None: base_url = os.getenv("HF_API_BASE") or os.getenv("HUGGINGFACE_API_BASE", "") return base_url @@ -107,6 +110,9 @@ class HuggingFaceChatConfig(OpenAIGPTConfig): complete_url = str(os.getenv("HF_API_BASE")) or str( os.getenv("HUGGINGFACE_API_BASE") ) + elif is_url_model_destination(model): + complete_url = model + complete_url = _build_chat_completion_url(complete_url) # Default construction with provider else: # Parse provider and model diff --git a/litellm/llms/huggingface/common_utils.py b/litellm/llms/huggingface/common_utils.py index 2d5f583873..1e94e28d11 100644 --- a/litellm/llms/huggingface/common_utils.py +++ b/litellm/llms/huggingface/common_utils.py @@ -9,16 +9,29 @@ from litellm.llms.base_llm.chat.transformation import BaseLLMException HF_HUB_URL = "https://huggingface.co" +def is_url_model_destination(model: str) -> bool: + return model.startswith(("http://", "https://")) + + +def _should_reject_url_model_destinations() -> bool: + import litellm + + return getattr(litellm, "reject_url_model_destinations", True) is True + + def validate_huggingface_model_identifier(model: str) -> None: """Reject URL-valued model identifiers before provider credentials are added.""" if "://" not in model: return + if is_url_model_destination(model) and not _should_reject_url_model_destinations(): + return raise HuggingFaceError( status_code=400, message=( "Invalid Hugging Face model identifier. Configure custom endpoints with " "api_base or HF_API_BASE/HUGGINGFACE_API_BASE instead of passing a URL " - "as the model." + "as the model. To keep legacy URL-valued models for trusted inputs, set " + "litellm.reject_url_model_destinations=False." ), ) diff --git a/litellm/llms/huggingface/embedding/handler.py b/litellm/llms/huggingface/embedding/handler.py index 4e3ac0ec0b..2f7232a726 100644 --- a/litellm/llms/huggingface/embedding/handler.py +++ b/litellm/llms/huggingface/embedding/handler.py @@ -14,7 +14,11 @@ from litellm.llms.custom_httpx.http_handler import ( from litellm.types.utils import EmbeddingResponse from ...base import BaseLLM -from ..common_utils import HuggingFaceError, validate_huggingface_model_identifier +from ..common_utils import ( + HuggingFaceError, + is_url_model_destination, + validate_huggingface_model_identifier, +) from .transformation import HuggingFaceEmbeddingConfig config = HuggingFaceEmbeddingConfig() @@ -155,6 +159,7 @@ class HuggingFaceEmbedding(BaseLLM): ) -> dict: data: Dict = {} validate_huggingface_model_identifier(model) + model_uses_url_destination = is_url_model_destination(model) ## TRANSFORMATION ## if "sentence-transformers" in model: @@ -170,8 +175,12 @@ class HuggingFaceEmbedding(BaseLLM): task_type = optional_params.pop("input_type", None) if call_type == "sync": - hf_task = get_hf_task_embedding_for_model( - model=model, task_type=task_type, api_base=HF_HUB_URL + hf_task = ( + task_type + if model_uses_url_destination + else get_hf_task_embedding_for_model( + model=model, task_type=task_type, api_base=HF_HUB_URL + ) ) elif call_type == "async": return self._async_transform_input( @@ -345,12 +354,19 @@ class HuggingFaceEmbedding(BaseLLM): litellm_params=litellm_params, ) task_type = optional_params.get("input_type", None) - task = get_hf_task_embedding_for_model( - model=model, task_type=task_type, api_base=HF_HUB_URL + model_uses_url_destination = is_url_model_destination(model) + task = ( + task_type + if model_uses_url_destination + else get_hf_task_embedding_for_model( + model=model, task_type=task_type, api_base=HF_HUB_URL + ) ) # print_verbose(f"{model}, {task}") embed_url = "" - if api_base: + if model_uses_url_destination: + embed_url = model + elif api_base: embed_url = api_base elif "HF_API_BASE" in os.environ: embed_url = os.getenv("HF_API_BASE", "") diff --git a/litellm/llms/huggingface/embedding/transformation.py b/litellm/llms/huggingface/embedding/transformation.py index 3a17b0a114..ce8319e82d 100644 --- a/litellm/llms/huggingface/embedding/transformation.py +++ b/litellm/llms/huggingface/embedding/transformation.py @@ -25,6 +25,7 @@ from ..common_utils import ( HuggingFaceError, hf_task_list, hf_tasks, + is_url_model_destination, output_parser, validate_huggingface_model_identifier, ) @@ -343,7 +344,9 @@ class HuggingFaceEmbeddingConfig(BaseConfig): Do not add the chat/embedding/rerank extension here. Let the handler do this. """ validate_huggingface_model_identifier(model) - if api_base is not None: + if is_url_model_destination(model): + completion_url = model + elif api_base is not None: completion_url = api_base elif "HF_API_BASE" in os.environ: completion_url = os.getenv("HF_API_BASE", "") diff --git a/litellm/llms/oobabooga/chat/oobabooga.py b/litellm/llms/oobabooga/chat/oobabooga.py index bcd3584efb..090ee11d1a 100644 --- a/litellm/llms/oobabooga/chat/oobabooga.py +++ b/litellm/llms/oobabooga/chat/oobabooga.py @@ -5,7 +5,11 @@ import litellm from litellm.llms.custom_httpx.http_handler import _get_httpx_client from litellm.utils import EmbeddingResponse, ModelResponse, Usage -from ..common_utils import OobaboogaError, validate_oobabooga_model_identifier +from ..common_utils import ( + OobaboogaError, + is_url_model_destination, + validate_oobabooga_model_identifier, +) from .transformation import OobaboogaConfig oobabooga_config = OobaboogaConfig() @@ -35,7 +39,9 @@ def completion( optional_params=optional_params, litellm_params=litellm_params, ) - if api_base: + if is_url_model_destination(model): + completion_url = model + elif api_base: completion_url = api_base else: raise OobaboogaError( @@ -96,7 +102,9 @@ def embedding( ): # Create completion URL validate_oobabooga_model_identifier(model) - if api_base: + if is_url_model_destination(model): + embeddings_url = model + elif api_base: embeddings_url = f"{api_base}/v1/embeddings" else: raise OobaboogaError( diff --git a/litellm/llms/oobabooga/common_utils.py b/litellm/llms/oobabooga/common_utils.py index 69f2cb519b..d09f03f6a6 100644 --- a/litellm/llms/oobabooga/common_utils.py +++ b/litellm/llms/oobabooga/common_utils.py @@ -15,14 +15,28 @@ class OobaboogaError(BaseLLMException): super().__init__(status_code=status_code, message=message, headers=headers) +def is_url_model_destination(model: str) -> bool: + return model.startswith(("http://", "https://")) + + +def _should_reject_url_model_destinations() -> bool: + import litellm + + return getattr(litellm, "reject_url_model_destinations", True) is True + + def validate_oobabooga_model_identifier(model: str) -> None: """Oobabooga endpoints must be configured with api_base, not model URLs.""" if "://" not in model: return + if is_url_model_destination(model) and not _should_reject_url_model_destinations(): + return raise OobaboogaError( status_code=400, message=( "Invalid Oobabooga model identifier. Configure the endpoint with " - "api_base instead of passing a URL as the model." + "api_base instead of passing a URL as the model. To keep legacy " + "URL-valued models for trusted inputs, set " + "litellm.reject_url_model_destinations=False." ), ) diff --git a/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py b/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py index 41006fb916..93188da9c9 100644 --- a/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py +++ b/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py @@ -113,6 +113,26 @@ class TestGoogleAIStudioFilesTransformation: litellm_params=litellm_params, ) + def test_transform_retrieve_file_request_rejects_encoded_traversal_name(self): + litellm_params = {"api_key": "test-api-key"} + + with pytest.raises(ValueError, match="Invalid Gemini file name"): + self.handler.transform_retrieve_file_request( + file_id="files/..%2Fsecrets", + optional_params={}, + litellm_params=litellm_params, + ) + + def test_transform_retrieve_file_request_rejects_double_encoded_separator(self): + litellm_params = {"api_key": "test-api-key"} + + with pytest.raises(ValueError, match="Invalid Gemini file name"): + self.handler.transform_retrieve_file_request( + file_id="files/..%252Fsecrets", + optional_params={}, + litellm_params=litellm_params, + ) + @patch.dict("os.environ", {}, clear=True) @patch("litellm.llms.gemini.common_utils.get_secret_str", return_value=None) def test_transform_retrieve_file_request_missing_api_key(self, mock_get_secret): @@ -355,3 +375,16 @@ class TestGoogleAIStudioFilesTransformation: optional_params={}, litellm_params=litellm_params, ) + + def test_transform_delete_file_request_rejects_encoded_traversal_url(self): + litellm_params = { + "api_key": "test-api-key", + "api_base": "https://generativelanguage.googleapis.com", + } + + with pytest.raises(ValueError, match="Invalid Gemini file name"): + self.handler.transform_delete_file_request( + file_id="https://generativelanguage.googleapis.com/v1beta/files/..%2Fsecrets", + optional_params={}, + litellm_params=litellm_params, + ) diff --git a/tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py b/tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py index c0bb56c171..f4c2c37200 100644 --- a/tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py +++ b/tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch import pytest +import litellm from litellm.llms.huggingface.chat.transformation import HuggingFaceChatConfig from litellm.llms.huggingface.common_utils import HuggingFaceError from litellm.llms.huggingface.embedding.handler import HuggingFaceEmbedding @@ -25,6 +26,23 @@ def test_huggingface_chat_rejects_url_valued_model(): assert exc_info.value.status_code == 400 +def test_huggingface_chat_allows_legacy_url_model_when_rejection_disabled( + monkeypatch, +): + monkeypatch.setattr(litellm, "reject_url_model_destinations", False) + config = HuggingFaceChatConfig() + + complete_url = config.get_complete_url( + api_base=None, + api_key="hf-secret", + model="https://trusted.example", + optional_params={}, + litellm_params={}, + ) + + assert complete_url == "https://trusted.example/v1/chat/completions" + + def test_huggingface_chat_keeps_explicit_api_base_for_custom_endpoints(): config = HuggingFaceChatConfig() @@ -51,6 +69,20 @@ def test_huggingface_embedding_config_rejects_url_valued_model(): assert exc_info.value.status_code == 400 +def test_huggingface_embedding_config_allows_legacy_url_model_when_rejection_disabled( + monkeypatch, +): + monkeypatch.setattr(litellm, "reject_url_model_destinations", False) + config = HuggingFaceEmbeddingConfig() + + api_base = config.get_api_base( + api_base=None, + model="https://trusted.example/embeddings", + ) + + assert api_base == "https://trusted.example/embeddings" + + def test_huggingface_embedding_handler_rejects_before_task_lookup(): handler = HuggingFaceEmbedding() logging_obj = MagicMock() diff --git a/tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py b/tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py index dab02637e5..2e43c01f38 100644 --- a/tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py +++ b/tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch import pytest +import litellm from litellm.llms.oobabooga.chat.oobabooga import completion, embedding from litellm.llms.oobabooga.common_utils import OobaboogaError @@ -26,6 +27,42 @@ def test_oobabooga_completion_rejects_url_valued_model_before_request(): mock_get.assert_not_called() +def test_oobabooga_completion_allows_legacy_url_model_when_rejection_disabled( + monkeypatch, +): + monkeypatch.setattr(litellm, "reject_url_model_destinations", False) + response = MagicMock() + client = MagicMock() + client.post.return_value = response + + with patch( + "litellm.llms.oobabooga.chat.oobabooga._get_httpx_client", + return_value=client, + ): + with patch( + "litellm.llms.oobabooga.chat.oobabooga.oobabooga_config.transform_response", + return_value="ok", + ): + result = completion( + model="https://trusted.example", + messages=[], + api_base=None, + model_response=MagicMock(), + print_verbose=MagicMock(), + encoding=MagicMock(), + api_key="ooba-secret", + logging_obj=MagicMock(), + optional_params={}, + litellm_params={}, + ) + + assert result == "ok" + client.post.assert_called_once() + assert ( + client.post.call_args.args[0] == "https://trusted.example/v1/chat/completions" + ) + + def test_oobabooga_embedding_rejects_url_valued_model_before_request(): with patch( "litellm.llms.oobabooga.chat.oobabooga.litellm.module_level_client.post" From ec07fbbb4b85014c0c51e8ce1577516a3ee8d7d8 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:20:41 -0700 Subject: [PATCH 03/10] chore(providers): keep URL model guard config-only --- litellm/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 16ae730384..b740c22844 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -280,9 +280,7 @@ ssl_security_level: Optional[str] = None ssl_certificate: Optional[str] = None user_url_validation: bool = True user_url_allowed_hosts: List[str] = [] -reject_url_model_destinations: bool = os.getenv( - "LITELLM_REJECT_URL_MODEL_DESTINATIONS", "true" -).lower() not in ("false", "0") +reject_url_model_destinations: bool = True ssl_ecdh_curve: Optional[str] = ( None # Set to 'X25519' to disable PQC and improve performance ) From fd409f84b14e1d8efe9db3a7008b12b3e45fe78a Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:24:06 -0700 Subject: [PATCH 04/10] chore(proxy): refresh lazy openapi snapshot --- litellm/proxy/_lazy_openapi_snapshot.json | 34 +++++++++++------------ 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 8331f748c6..f8f58b46d6 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3572,7 +3572,7 @@ "/anthropic/{endpoint}": { "delete": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__put", + "operationId": "anthropic_proxy_route_anthropic__endpoint__get", "parameters": [ { "in": "path", @@ -3616,7 +3616,7 @@ }, "get": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__put", + "operationId": "anthropic_proxy_route_anthropic__endpoint__get", "parameters": [ { "in": "path", @@ -3660,7 +3660,7 @@ }, "patch": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__put", + "operationId": "anthropic_proxy_route_anthropic__endpoint__get", "parameters": [ { "in": "path", @@ -3704,7 +3704,7 @@ }, "post": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__put", + "operationId": "anthropic_proxy_route_anthropic__endpoint__get", "parameters": [ { "in": "path", @@ -3748,7 +3748,7 @@ }, "put": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__put", + "operationId": "anthropic_proxy_route_anthropic__endpoint__get", "parameters": [ { "in": "path", @@ -13260,7 +13260,7 @@ "/langfuse/{endpoint}": { "delete": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__put", + "operationId": "langfuse_proxy_route_langfuse__endpoint__get", "parameters": [ { "in": "path", @@ -13299,7 +13299,7 @@ }, "get": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__put", + "operationId": "langfuse_proxy_route_langfuse__endpoint__get", "parameters": [ { "in": "path", @@ -13338,7 +13338,7 @@ }, "patch": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__put", + "operationId": "langfuse_proxy_route_langfuse__endpoint__get", "parameters": [ { "in": "path", @@ -13377,7 +13377,7 @@ }, "post": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__put", + "operationId": "langfuse_proxy_route_langfuse__endpoint__get", "parameters": [ { "in": "path", @@ -13416,7 +13416,7 @@ }, "put": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__put", + "operationId": "langfuse_proxy_route_langfuse__endpoint__get", "parameters": [ { "in": "path", @@ -26883,7 +26883,7 @@ "/toolset/{toolset_name}/mcp": { "delete": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_put", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", "parameters": [ { "in": "path", @@ -26922,7 +26922,7 @@ }, "get": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_put", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", "parameters": [ { "in": "path", @@ -26961,7 +26961,7 @@ }, "head": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_put", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", "parameters": [ { "in": "path", @@ -27000,7 +27000,7 @@ }, "options": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_put", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", "parameters": [ { "in": "path", @@ -27039,7 +27039,7 @@ }, "patch": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_put", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", "parameters": [ { "in": "path", @@ -27078,7 +27078,7 @@ }, "post": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_put", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", "parameters": [ { "in": "path", @@ -27117,7 +27117,7 @@ }, "put": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_put", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", "parameters": [ { "in": "path", From eca1f252e21c04b3edd2a50cbaac31b71fc0cbb2 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:27:29 -0700 Subject: [PATCH 05/10] chore(proxy): stabilize lazy openapi snapshot ids --- litellm/proxy/_lazy_openapi_snapshot.json | 34 +++++++++++------------ litellm/proxy/_lazy_openapi_snapshot.py | 13 +++++++++ 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index f8f58b46d6..b8e9eb6c26 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3572,7 +3572,7 @@ "/anthropic/{endpoint}": { "delete": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__get", + "operationId": "anthropic_proxy_route_anthropic__endpoint__delete", "parameters": [ { "in": "path", @@ -3616,7 +3616,7 @@ }, "get": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__get", + "operationId": "anthropic_proxy_route_anthropic__endpoint__delete", "parameters": [ { "in": "path", @@ -3660,7 +3660,7 @@ }, "patch": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__get", + "operationId": "anthropic_proxy_route_anthropic__endpoint__delete", "parameters": [ { "in": "path", @@ -3704,7 +3704,7 @@ }, "post": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__get", + "operationId": "anthropic_proxy_route_anthropic__endpoint__delete", "parameters": [ { "in": "path", @@ -3748,7 +3748,7 @@ }, "put": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__get", + "operationId": "anthropic_proxy_route_anthropic__endpoint__delete", "parameters": [ { "in": "path", @@ -13260,7 +13260,7 @@ "/langfuse/{endpoint}": { "delete": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__get", + "operationId": "langfuse_proxy_route_langfuse__endpoint__delete", "parameters": [ { "in": "path", @@ -13299,7 +13299,7 @@ }, "get": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__get", + "operationId": "langfuse_proxy_route_langfuse__endpoint__delete", "parameters": [ { "in": "path", @@ -13338,7 +13338,7 @@ }, "patch": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__get", + "operationId": "langfuse_proxy_route_langfuse__endpoint__delete", "parameters": [ { "in": "path", @@ -13377,7 +13377,7 @@ }, "post": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__get", + "operationId": "langfuse_proxy_route_langfuse__endpoint__delete", "parameters": [ { "in": "path", @@ -13416,7 +13416,7 @@ }, "put": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__get", + "operationId": "langfuse_proxy_route_langfuse__endpoint__delete", "parameters": [ { "in": "path", @@ -26883,7 +26883,7 @@ "/toolset/{toolset_name}/mcp": { "delete": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", "parameters": [ { "in": "path", @@ -26922,7 +26922,7 @@ }, "get": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", "parameters": [ { "in": "path", @@ -26961,7 +26961,7 @@ }, "head": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", "parameters": [ { "in": "path", @@ -27000,7 +27000,7 @@ }, "options": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", "parameters": [ { "in": "path", @@ -27039,7 +27039,7 @@ }, "patch": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", "parameters": [ { "in": "path", @@ -27078,7 +27078,7 @@ }, "post": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", "parameters": [ { "in": "path", @@ -27117,7 +27117,7 @@ }, "put": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", "parameters": [ { "in": "path", diff --git a/litellm/proxy/_lazy_openapi_snapshot.py b/litellm/proxy/_lazy_openapi_snapshot.py index 315f6a9742..4b137cb70d 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.py +++ b/litellm/proxy/_lazy_openapi_snapshot.py @@ -8,10 +8,13 @@ any drift as a neutral check. """ import json +import re import sys from pathlib import Path from typing import Dict, Optional +from fastapi.routing import APIRoute + SNAPSHOT_FILE = Path(__file__).parent / "_lazy_openapi_snapshot.json" @@ -25,6 +28,13 @@ def load_snapshot() -> Optional[Dict[str, Dict]]: return None +def _stable_unique_id(route: APIRoute) -> str: + operation_id = f"{route.name}{route.path_format}" + operation_id = re.sub(r"\W", "_", operation_id) + method = sorted(route.methods or [""])[0].lower() + return f"{operation_id}_{method}" + + def generate_snapshot() -> Dict[str, Dict]: import importlib @@ -51,6 +61,9 @@ def generate_snapshot() -> Dict[str, Dict]: ] if not feat_routes: continue + for route in feat_routes: + if isinstance(route, APIRoute): + route.unique_id = _stable_unique_id(route) full = get_openapi(title=app.title, version=app.version, routes=feat_routes) # Group all of a feature's routes under one tag. for path_ops in full.get("paths", {}).values(): From 6ca6220679da5bb9d324cf21efd1f6cca3120739 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:41:38 -0700 Subject: [PATCH 06/10] chore(proxy): split lazy openapi multi-method routes --- litellm/proxy/_lazy_openapi_snapshot.json | 28 ++++++------ litellm/proxy/_lazy_openapi_snapshot.py | 44 +++++++++++++++---- .../proxy/test_lazy_openapi_snapshot.py | 39 ++++++++++++++++ 3 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 tests/test_litellm/proxy/test_lazy_openapi_snapshot.py diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index b8e9eb6c26..46a514c087 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3616,7 +3616,7 @@ }, "get": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__delete", + "operationId": "anthropic_proxy_route_anthropic__endpoint__get", "parameters": [ { "in": "path", @@ -3660,7 +3660,7 @@ }, "patch": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__delete", + "operationId": "anthropic_proxy_route_anthropic__endpoint__patch", "parameters": [ { "in": "path", @@ -3704,7 +3704,7 @@ }, "post": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__delete", + "operationId": "anthropic_proxy_route_anthropic__endpoint__post", "parameters": [ { "in": "path", @@ -3748,7 +3748,7 @@ }, "put": { "description": "[Docs](https://docs.litellm.ai/docs/pass_through/anthropic_completion)", - "operationId": "anthropic_proxy_route_anthropic__endpoint__delete", + "operationId": "anthropic_proxy_route_anthropic__endpoint__put", "parameters": [ { "in": "path", @@ -13299,7 +13299,7 @@ }, "get": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__delete", + "operationId": "langfuse_proxy_route_langfuse__endpoint__get", "parameters": [ { "in": "path", @@ -13338,7 +13338,7 @@ }, "patch": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__delete", + "operationId": "langfuse_proxy_route_langfuse__endpoint__patch", "parameters": [ { "in": "path", @@ -13377,7 +13377,7 @@ }, "post": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__delete", + "operationId": "langfuse_proxy_route_langfuse__endpoint__post", "parameters": [ { "in": "path", @@ -13416,7 +13416,7 @@ }, "put": { "description": "Call Langfuse via LiteLLM proxy. Works with Langfuse SDK.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/langfuse)", - "operationId": "langfuse_proxy_route_langfuse__endpoint__delete", + "operationId": "langfuse_proxy_route_langfuse__endpoint__put", "parameters": [ { "in": "path", @@ -26922,7 +26922,7 @@ }, "get": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_get", "parameters": [ { "in": "path", @@ -26961,7 +26961,7 @@ }, "head": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_head", "parameters": [ { "in": "path", @@ -27000,7 +27000,7 @@ }, "options": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_options", "parameters": [ { "in": "path", @@ -27039,7 +27039,7 @@ }, "patch": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_patch", "parameters": [ { "in": "path", @@ -27078,7 +27078,7 @@ }, "post": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_post", "parameters": [ { "in": "path", @@ -27117,7 +27117,7 @@ }, "put": { "description": "Namespace a toolset as its own MCP endpoint.\n\nConnecting to /toolset//mcp exposes exactly the tools defined in\nthe toolset. Access is enforced: non-admin API keys must have the toolset\nlisted in their object_permission.mcp_toolsets grant list, or the request\nwill be rejected with a 403.", - "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_delete", + "operationId": "toolset_mcp_route_toolset__toolset_name__mcp_put", "parameters": [ { "in": "path", diff --git a/litellm/proxy/_lazy_openapi_snapshot.py b/litellm/proxy/_lazy_openapi_snapshot.py index 4b137cb70d..dca54eb7fa 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.py +++ b/litellm/proxy/_lazy_openapi_snapshot.py @@ -10,10 +10,12 @@ any drift as a neutral check. import json import re import sys +from copy import copy from pathlib import Path -from typing import Dict, Optional +from typing import Dict, List, Optional from fastapi.routing import APIRoute +from starlette.routing import BaseRoute SNAPSHOT_FILE = Path(__file__).parent / "_lazy_openapi_snapshot.json" @@ -28,11 +30,36 @@ def load_snapshot() -> Optional[Dict[str, Dict]]: return None -def _stable_unique_id(route: APIRoute) -> str: +def _stable_unique_id(route: APIRoute, method: str) -> str: operation_id = f"{route.name}{route.path_format}" operation_id = re.sub(r"\W", "_", operation_id) - method = sorted(route.methods or [""])[0].lower() - return f"{operation_id}_{method}" + return f"{operation_id}_{method.lower()}" + + +def _routes_with_stable_unique_ids(routes: List[BaseRoute]) -> List[BaseRoute]: + stable_routes: List[BaseRoute] = [] + for route in routes: + if not isinstance(route, APIRoute) or not route.methods: + stable_routes.append(route) + continue + + methods = sorted(route.methods) + has_multiple_methods = len(methods) > 1 + for method in methods: + method_route = copy(route) + method_route.methods = {method} + if route.operation_id is not None: + method_route.operation_id = ( + f"{route.operation_id}_{method.lower()}" + if has_multiple_methods + else route.operation_id + ) + method_route.unique_id = method_route.operation_id + else: + method_route.unique_id = _stable_unique_id(route, method) + stable_routes.append(method_route) + + return stable_routes def generate_snapshot() -> Dict[str, Dict]: @@ -61,10 +88,11 @@ def generate_snapshot() -> Dict[str, Dict]: ] if not feat_routes: continue - for route in feat_routes: - if isinstance(route, APIRoute): - route.unique_id = _stable_unique_id(route) - full = get_openapi(title=app.title, version=app.version, routes=feat_routes) + full = get_openapi( + title=app.title, + version=app.version, + routes=_routes_with_stable_unique_ids(feat_routes), + ) # Group all of a feature's routes under one tag. for path_ops in full.get("paths", {}).values(): for op in path_ops.values(): diff --git a/tests/test_litellm/proxy/test_lazy_openapi_snapshot.py b/tests/test_litellm/proxy/test_lazy_openapi_snapshot.py new file mode 100644 index 0000000000..a5605a281f --- /dev/null +++ b/tests/test_litellm/proxy/test_lazy_openapi_snapshot.py @@ -0,0 +1,39 @@ +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi +from fastapi.routing import APIRoute + +from litellm.proxy._lazy_openapi_snapshot import _routes_with_stable_unique_ids + + +def test_routes_with_stable_unique_ids_splits_multi_method_routes() -> None: + app = FastAPI() + + async def proxy_route() -> dict: + return {} + + app.add_api_route( + "/proxy/{endpoint:path}", + proxy_route, + methods=["GET", "POST", "DELETE"], + ) + + routes = [route for route in app.routes if isinstance(route, APIRoute)] + stable_routes = _routes_with_stable_unique_ids(routes) + + assert [route.methods for route in stable_routes] == [ + {"DELETE"}, + {"GET"}, + {"POST"}, + ] + + openapi = get_openapi(title="test", version="1", routes=stable_routes) + path_ops = openapi["paths"]["/proxy/{endpoint}"] + + operation_ids = { + method: operation["operationId"] for method, operation in path_ops.items() + } + assert operation_ids == { + "delete": "proxy_route_proxy__endpoint__delete", + "get": "proxy_route_proxy__endpoint__get", + "post": "proxy_route_proxy__endpoint__post", + } From 19f8c1013b690153882fff996c0c50d6911ecad4 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:50:15 -0700 Subject: [PATCH 07/10] fix(gemini): validate fully decoded file names --- litellm/llms/gemini/files/transformation.py | 2 +- .../gemini/files/test_gemini_files_transformation.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/litellm/llms/gemini/files/transformation.py b/litellm/llms/gemini/files/transformation.py index fe7f81ab95..d282878a4a 100644 --- a/litellm/llms/gemini/files/transformation.py +++ b/litellm/llms/gemini/files/transformation.py @@ -279,7 +279,7 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): decoded_file_id = "" if len(parts) == 2: decoded_file_id = parts[1] - for _ in range(3): + while True: next_decoded_file_id = unquote(decoded_file_id) if next_decoded_file_id == decoded_file_id: break diff --git a/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py b/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py index 93188da9c9..334c691fdb 100644 --- a/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py +++ b/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py @@ -133,6 +133,16 @@ class TestGoogleAIStudioFilesTransformation: litellm_params=litellm_params, ) + def test_transform_retrieve_file_request_rejects_deeply_encoded_separator(self): + litellm_params = {"api_key": "test-api-key"} + + with pytest.raises(ValueError, match="Invalid Gemini file name"): + self.handler.transform_retrieve_file_request( + file_id="files/..%2525252Fsecrets", + optional_params={}, + litellm_params=litellm_params, + ) + @patch.dict("os.environ", {}, clear=True) @patch("litellm.llms.gemini.common_utils.get_secret_str", return_value=None) def test_transform_retrieve_file_request_missing_api_key(self, mock_get_secret): From 87849b74b9b726dbaf1502c7a9c26fc064eb57f9 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:03:54 -0700 Subject: [PATCH 08/10] test(gemini): cover decoded delete file names --- .../files/test_gemini_files_transformation.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py b/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py index 334c691fdb..88010d0145 100644 --- a/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py +++ b/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py @@ -398,3 +398,16 @@ class TestGoogleAIStudioFilesTransformation: optional_params={}, litellm_params=litellm_params, ) + + def test_transform_delete_file_request_rejects_deeply_encoded_traversal_url(self): + litellm_params = { + "api_key": "test-api-key", + "api_base": "https://generativelanguage.googleapis.com", + } + + with pytest.raises(ValueError, match="Invalid Gemini file name"): + self.handler.transform_delete_file_request( + file_id="https://generativelanguage.googleapis.com/v1beta/files/..%2525252Fsecrets", + optional_params={}, + litellm_params=litellm_params, + ) From 7784b7f4ad8d139164e004a3ee9cd9f48c70ea8b Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Fri, 1 May 2026 15:18:31 -0700 Subject: [PATCH 09/10] chore(providers): allowlist URL model destinations --- litellm/__init__.py | 2 +- litellm/litellm_core_utils/url_utils.py | 83 ++++++++++++++++++- litellm/llms/gemini/files/transformation.py | 34 ++++++-- litellm/llms/huggingface/common_utils.py | 13 +-- litellm/llms/oobabooga/common_utils.py | 13 +-- .../litellm_core_utils/test_url_utils.py | 42 ++++++++++ .../files/test_gemini_files_transformation.py | 53 ++++++++++++ .../test_huggingface_model_url_guard.py | 34 +++++++- .../test_oobabooga_model_url_guard.py | 32 ++++++- 9 files changed, 278 insertions(+), 28 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index b740c22844..f4b109957f 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -280,7 +280,7 @@ ssl_security_level: Optional[str] = None ssl_certificate: Optional[str] = None user_url_validation: bool = True user_url_allowed_hosts: List[str] = [] -reject_url_model_destinations: bool = True +provider_url_destination_allowed_hosts: List[str] = [] ssl_ecdh_curve: Optional[str] = ( None # Set to 'X25519' to disable PQC and improve performance ) diff --git a/litellm/litellm_core_utils/url_utils.py b/litellm/litellm_core_utils/url_utils.py index a65d0892aa..d1649452f7 100644 --- a/litellm/litellm_core_utils/url_utils.py +++ b/litellm/litellm_core_utils/url_utils.py @@ -21,7 +21,7 @@ Admins can opt out via two ``litellm`` globals (wired from proxy config): import socket from ipaddress import ip_address, ip_network -from typing import Any, List, Set, Tuple +from typing import Any, List, Optional, Set, Tuple from urllib.parse import urlparse, urlunparse import httpx @@ -70,6 +70,85 @@ def _normalize_host(host: str) -> str: return host.lower().rstrip(".") +def _default_port_for_scheme(scheme: str) -> int: + return 443 if scheme == "https" else 80 + + +def _parse_url_destination_allowlist_entry( + entry: str, +) -> Optional[Tuple[str, Optional[str], Optional[int]]]: + """Parse an admin allowlist entry into host, optional scheme, optional port. + + Entries may be bare hosts (``api.example.com``), host+port + (``api.example.com:8443``), or origins (``https://api.example.com``). + URL paths are intentionally ignored so admins can paste an api_base value. + """ + entry = entry.strip() + if not entry: + return None + + has_scheme = "://" in entry + parsed = urlparse(entry if has_scheme else f"//{entry}") + if has_scheme and parsed.scheme not in _ALLOWED_SCHEMES: + return None + if parsed.username is not None or parsed.password is not None: + return None + if not parsed.hostname: + return None + + try: + port = parsed.port + except ValueError: + return None + + scheme: Optional[str] = parsed.scheme if has_scheme else None + if scheme is not None and port is None: + port = _default_port_for_scheme(scheme) + + return _normalize_host(parsed.hostname), scheme, port + + +def is_url_destination_allowed_by_host(url: str, allowed_hosts: List[str]) -> bool: + """Return True when a credential-bearing provider URL is admin-allowlisted. + + This does not fetch, resolve, or rewrite URLs. It only answers whether the + destination origin is explicitly trusted by configuration. Use ``safe_get`` + for user-controlled content fetches that require SSRF protection. + """ + parsed = urlparse(url) + if parsed.scheme not in _ALLOWED_SCHEMES: + return False + if parsed.username is not None or parsed.password is not None: + return False + if not parsed.hostname: + return False + + try: + effective_port = parsed.port or _default_port_for_scheme(parsed.scheme) + except ValueError: + return False + + normalized_host = _normalize_host(parsed.hostname) + configured_entries = ( + [allowed_hosts] if isinstance(allowed_hosts, str) else allowed_hosts + ) + for entry in configured_entries or []: + if not isinstance(entry, str): + continue + parsed_entry = _parse_url_destination_allowlist_entry(entry) + if parsed_entry is None: + continue + allowed_host, allowed_scheme, allowed_port = parsed_entry + if allowed_host != normalized_host: + continue + if allowed_scheme is not None and allowed_scheme != parsed.scheme: + continue + if allowed_port is not None and allowed_port != effective_port: + continue + return True + return False + + def _format_host_header(hostname: str, port: int, default_port: int) -> str: """Build an RFC 7230 Host header value, bracketing IPv6 literals.""" bracketed = f"[{hostname}]" if ":" in hostname else hostname @@ -145,7 +224,7 @@ def validate_url(url: str) -> Tuple[str, str]: raise SSRFError("URL has no hostname") port = parsed.port - default_port = 443 if parsed.scheme == "https" else 80 + default_port = _default_port_for_scheme(parsed.scheme) effective_port = port if port is not None else default_port host_header = _format_host_header(hostname, effective_port, default_port) diff --git a/litellm/llms/gemini/files/transformation.py b/litellm/llms/gemini/files/transformation.py index d282878a4a..3804145eb5 100644 --- a/litellm/llms/gemini/files/transformation.py +++ b/litellm/llms/gemini/files/transformation.py @@ -13,6 +13,7 @@ from openai.types.file_deleted import FileDeleted from litellm._logging import verbose_logger from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data +from litellm.litellm_core_utils.url_utils import is_url_destination_allowed_by_host from litellm.llms.base_llm.files.transformation import ( BaseFilesConfig, LiteLLMLoggingObj, @@ -29,8 +30,6 @@ from litellm.types.utils import LlmProviders from ..common_utils import GeminiModelInfo -_GEMINI_FILES_HOST = "generativelanguage.googleapis.com" - class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): def __init__(self): @@ -226,20 +225,21 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): if not api_key: raise ValueError("api_key is required") - file_part = self._normalize_gemini_file_id(file_id) - api_base = ( self.get_api_base(litellm_params.get("api_base")) or "https://generativelanguage.googleapis.com" ) api_base = api_base.rstrip("/") + file_part = self._normalize_gemini_file_id(file_id, api_base=api_base) url = f"{api_base}/v1beta/{file_part}" # API key is passed via x-goog-api-key header (set in validate_environment) return url, {} - def _normalize_gemini_file_id(self, file_id: str) -> str: + def _normalize_gemini_file_id( + self, file_id: str, api_base: Optional[str] = None + ) -> str: """ Normalize file identifier into `files/{id}` form. @@ -251,10 +251,11 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): if file_id.startswith(("http://", "https://")): parsed = urlparse(file_id) if ( - parsed.scheme != "https" - or parsed.hostname != _GEMINI_FILES_HOST - or parsed.username is not None + parsed.username is not None or parsed.password is not None + or not self._is_allowed_gemini_file_url( + file_url=file_id, api_base=api_base + ) ): raise ValueError("Invalid Gemini file URL") path = parsed.path.lstrip("/") @@ -273,6 +274,20 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): return normalized_file_id + @staticmethod + def _is_allowed_gemini_file_url( + file_url: str, api_base: Optional[str] = None + ) -> bool: + import litellm + + allowed_hosts: List[str] = [] + if api_base: + allowed_hosts.append(api_base) + allowed_hosts.extend( + getattr(litellm, "provider_url_destination_allowed_hosts", []) or [] + ) + return is_url_destination_allowed_by_host(file_url, allowed_hosts) + @staticmethod def _validate_gemini_file_name(file_name: str) -> None: parts = file_name.split("/") @@ -367,7 +382,8 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): if not api_key: raise ValueError("api_key is required") - file_name = self._normalize_gemini_file_id(file_id) + api_base = api_base.rstrip("/") + file_name = self._normalize_gemini_file_id(file_id, api_base=api_base) # Construct the delete URL url = f"{api_base}/v1beta/{file_name}" diff --git a/litellm/llms/huggingface/common_utils.py b/litellm/llms/huggingface/common_utils.py index 1e94e28d11..9a8c4895c9 100644 --- a/litellm/llms/huggingface/common_utils.py +++ b/litellm/llms/huggingface/common_utils.py @@ -4,6 +4,7 @@ from typing import Literal, Optional, Union import httpx +from litellm.litellm_core_utils.url_utils import is_url_destination_allowed_by_host from litellm.llms.base_llm.chat.transformation import BaseLLMException HF_HUB_URL = "https://huggingface.co" @@ -13,25 +14,27 @@ def is_url_model_destination(model: str) -> bool: return model.startswith(("http://", "https://")) -def _should_reject_url_model_destinations() -> bool: +def _is_url_model_destination_allowed(model: str) -> bool: import litellm - return getattr(litellm, "reject_url_model_destinations", True) is True + allowed_hosts = getattr(litellm, "provider_url_destination_allowed_hosts", []) or [] + return is_url_destination_allowed_by_host(model, allowed_hosts) def validate_huggingface_model_identifier(model: str) -> None: """Reject URL-valued model identifiers before provider credentials are added.""" if "://" not in model: return - if is_url_model_destination(model) and not _should_reject_url_model_destinations(): + if is_url_model_destination(model) and _is_url_model_destination_allowed(model): return raise HuggingFaceError( status_code=400, message=( "Invalid Hugging Face model identifier. Configure custom endpoints with " "api_base or HF_API_BASE/HUGGINGFACE_API_BASE instead of passing a URL " - "as the model. To keep legacy URL-valued models for trusted inputs, set " - "litellm.reject_url_model_destinations=False." + "as the model. To keep legacy URL-valued models for trusted endpoints, " + "add the destination host or origin to " + "`provider_url_destination_allowed_hosts` in litellm_settings." ), ) diff --git a/litellm/llms/oobabooga/common_utils.py b/litellm/llms/oobabooga/common_utils.py index d09f03f6a6..6c863ea555 100644 --- a/litellm/llms/oobabooga/common_utils.py +++ b/litellm/llms/oobabooga/common_utils.py @@ -2,6 +2,7 @@ from typing import Optional, Union import httpx +from litellm.litellm_core_utils.url_utils import is_url_destination_allowed_by_host from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -19,24 +20,26 @@ def is_url_model_destination(model: str) -> bool: return model.startswith(("http://", "https://")) -def _should_reject_url_model_destinations() -> bool: +def _is_url_model_destination_allowed(model: str) -> bool: import litellm - return getattr(litellm, "reject_url_model_destinations", True) is True + allowed_hosts = getattr(litellm, "provider_url_destination_allowed_hosts", []) or [] + return is_url_destination_allowed_by_host(model, allowed_hosts) def validate_oobabooga_model_identifier(model: str) -> None: """Oobabooga endpoints must be configured with api_base, not model URLs.""" if "://" not in model: return - if is_url_model_destination(model) and not _should_reject_url_model_destinations(): + if is_url_model_destination(model) and _is_url_model_destination_allowed(model): return raise OobaboogaError( status_code=400, message=( "Invalid Oobabooga model identifier. Configure the endpoint with " "api_base instead of passing a URL as the model. To keep legacy " - "URL-valued models for trusted inputs, set " - "litellm.reject_url_model_destinations=False." + "URL-valued models for trusted endpoints, add the destination host " + "or origin to `provider_url_destination_allowed_hosts` in " + "litellm_settings." ), ) diff --git a/tests/test_litellm/litellm_core_utils/test_url_utils.py b/tests/test_litellm/litellm_core_utils/test_url_utils.py index 4579c20321..efe3f2b67a 100644 --- a/tests/test_litellm/litellm_core_utils/test_url_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_url_utils.py @@ -394,3 +394,45 @@ class TestHostAllowlist: monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) validate_url("http://internal.corp/") + + +class TestProviderUrlDestinationAllowlist: + def test_host_entry_matches_any_scheme_and_port(self): + assert url_utils.is_url_destination_allowed_by_host( + "https://trusted.example/v1/chat/completions", + ["trusted.example"], + ) + assert url_utils.is_url_destination_allowed_by_host( + "http://trusted.example:8080/v1/chat/completions", + ["trusted.example"], + ) + + def test_origin_entry_matches_scheme_and_default_port(self): + assert url_utils.is_url_destination_allowed_by_host( + "https://trusted.example/v1/chat/completions", + ["https://trusted.example"], + ) + assert not url_utils.is_url_destination_allowed_by_host( + "http://trusted.example/v1/chat/completions", + ["https://trusted.example"], + ) + + def test_port_entry_only_matches_same_effective_port(self): + assert url_utils.is_url_destination_allowed_by_host( + "https://trusted.example/v1/chat/completions", + ["trusted.example:443"], + ) + assert not url_utils.is_url_destination_allowed_by_host( + "https://trusted.example:8443/v1/chat/completions", + ["trusted.example:443"], + ) + + def test_rejects_userinfo_and_invalid_port(self): + assert not url_utils.is_url_destination_allowed_by_host( + "https://user:pass@trusted.example/v1/chat/completions", + ["trusted.example"], + ) + assert not url_utils.is_url_destination_allowed_by_host( + "https://trusted.example:99999/v1/chat/completions", + ["trusted.example"], + ) diff --git a/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py b/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py index 88010d0145..9cadba972e 100644 --- a/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py +++ b/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py @@ -7,6 +7,7 @@ from unittest.mock import Mock, patch import httpx import pytest +import litellm from litellm.llms.gemini.files.transformation import GoogleAIStudioFilesHandler from litellm.types.llms.openai import OpenAIFileObject @@ -103,6 +104,43 @@ class TestGoogleAIStudioFilesTransformation: litellm_params=litellm_params, ) + def test_transform_retrieve_file_request_allows_full_url_matching_api_base(self): + file_id = "https://custom-gemini.example/v1beta/files/test123" + litellm_params = { + "api_key": "test-api-key", + "api_base": "https://custom-gemini.example", + } + + url, params = self.handler.transform_retrieve_file_request( + file_id=file_id, + optional_params={}, + litellm_params=litellm_params, + ) + + assert url == "https://custom-gemini.example/v1beta/files/test123" + assert params == {} + + def test_transform_retrieve_file_request_allows_full_url_when_host_allowlisted( + self, + monkeypatch, + ): + monkeypatch.setattr( + litellm, + "provider_url_destination_allowed_hosts", + ["trusted-gemini.example"], + ) + file_id = "https://trusted-gemini.example/v1beta/files/test123" + litellm_params = {"api_key": "test-api-key"} + + url, params = self.handler.transform_retrieve_file_request( + file_id=file_id, + optional_params={}, + litellm_params=litellm_params, + ) + + assert url == "https://generativelanguage.googleapis.com/v1beta/files/test123" + assert params == {} + def test_transform_retrieve_file_request_rejects_traversal_name(self): litellm_params = {"api_key": "test-api-key"} @@ -386,6 +424,21 @@ class TestGoogleAIStudioFilesTransformation: litellm_params=litellm_params, ) + def test_transform_delete_file_request_allows_full_url_matching_api_base(self): + litellm_params = { + "api_key": "test-api-key", + "api_base": "https://custom-gemini.example", + } + + url, params = self.handler.transform_delete_file_request( + file_id="https://custom-gemini.example/v1beta/files/test123", + optional_params={}, + litellm_params=litellm_params, + ) + + assert url == "https://custom-gemini.example/v1beta/files/test123" + assert params == {} + def test_transform_delete_file_request_rejects_encoded_traversal_url(self): litellm_params = { "api_key": "test-api-key", diff --git a/tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py b/tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py index f4c2c37200..27e3b855de 100644 --- a/tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py +++ b/tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py @@ -26,10 +26,12 @@ def test_huggingface_chat_rejects_url_valued_model(): assert exc_info.value.status_code == 400 -def test_huggingface_chat_allows_legacy_url_model_when_rejection_disabled( +def test_huggingface_chat_allows_legacy_url_model_when_host_is_allowlisted( monkeypatch, ): - monkeypatch.setattr(litellm, "reject_url_model_destinations", False) + monkeypatch.setattr( + litellm, "provider_url_destination_allowed_hosts", ["trusted.example"] + ) config = HuggingFaceChatConfig() complete_url = config.get_complete_url( @@ -43,6 +45,26 @@ def test_huggingface_chat_allows_legacy_url_model_when_rejection_disabled( assert complete_url == "https://trusted.example/v1/chat/completions" +def test_huggingface_chat_rejects_url_model_when_host_is_not_allowlisted( + monkeypatch, +): + monkeypatch.setattr( + litellm, "provider_url_destination_allowed_hosts", ["trusted.example"] + ) + config = HuggingFaceChatConfig() + + with pytest.raises(HuggingFaceError) as exc_info: + config.get_complete_url( + api_base=None, + api_key="hf-secret", + model="https://other.example", + optional_params={}, + litellm_params={}, + ) + + assert exc_info.value.status_code == 400 + + def test_huggingface_chat_keeps_explicit_api_base_for_custom_endpoints(): config = HuggingFaceChatConfig() @@ -69,10 +91,14 @@ def test_huggingface_embedding_config_rejects_url_valued_model(): assert exc_info.value.status_code == 400 -def test_huggingface_embedding_config_allows_legacy_url_model_when_rejection_disabled( +def test_huggingface_embedding_config_allows_legacy_url_model_when_origin_is_allowlisted( monkeypatch, ): - monkeypatch.setattr(litellm, "reject_url_model_destinations", False) + monkeypatch.setattr( + litellm, + "provider_url_destination_allowed_hosts", + ["https://trusted.example"], + ) config = HuggingFaceEmbeddingConfig() api_base = config.get_api_base( diff --git a/tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py b/tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py index 2e43c01f38..5ed0cdca03 100644 --- a/tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py +++ b/tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py @@ -27,10 +27,12 @@ def test_oobabooga_completion_rejects_url_valued_model_before_request(): mock_get.assert_not_called() -def test_oobabooga_completion_allows_legacy_url_model_when_rejection_disabled( +def test_oobabooga_completion_allows_legacy_url_model_when_host_is_allowlisted( monkeypatch, ): - monkeypatch.setattr(litellm, "reject_url_model_destinations", False) + monkeypatch.setattr( + litellm, "provider_url_destination_allowed_hosts", ["trusted.example"] + ) response = MagicMock() client = MagicMock() client.post.return_value = response @@ -63,6 +65,32 @@ def test_oobabooga_completion_allows_legacy_url_model_when_rejection_disabled( ) +def test_oobabooga_completion_rejects_url_model_when_host_is_not_allowlisted( + monkeypatch, +): + monkeypatch.setattr( + litellm, "provider_url_destination_allowed_hosts", ["trusted.example"] + ) + + with patch("litellm.llms.oobabooga.chat.oobabooga._get_httpx_client") as mock_get: + with pytest.raises(OobaboogaError) as exc_info: + completion( + model="https://other.example", + messages=[], + api_base=None, + model_response=MagicMock(), + print_verbose=MagicMock(), + encoding=MagicMock(), + api_key="ooba-secret", + logging_obj=MagicMock(), + optional_params={}, + litellm_params={}, + ) + + assert exc_info.value.status_code == 400 + mock_get.assert_not_called() + + def test_oobabooga_embedding_rejects_url_valued_model_before_request(): with patch( "litellm.llms.oobabooga.chat.oobabooga.litellm.module_level_client.post" From 001253c47865c5409fedecfdb09efe36d6837178 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Sat, 2 May 2026 02:17:46 +0000 Subject: [PATCH 10/10] chore(proxy): move URL-valued model/file_id guard from SDK to proxy The previous per-provider guards in HuggingFace, Oobabooga, and Gemini files lived in the SDK layer, breaking SDK callers who legitimately pass URL-valued model identifiers. Move the check to the proxy boundary in add_litellm_data_to_request so SDK users keep working while proxy users default-deny URL-valued model and file_id, with admin opt-in via litellm.provider_url_destination_allowed_hosts. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/llms/gemini/files/transformation.py | 38 +---- .../llms/huggingface/chat/transformation.py | 13 +- litellm/llms/huggingface/common_utils.py | 30 ---- litellm/llms/huggingface/embedding/handler.py | 28 +--- .../huggingface/embedding/transformation.py | 12 +- litellm/llms/oobabooga/chat/oobabooga.py | 12 +- litellm/llms/oobabooga/common_utils.py | 30 ---- litellm/proxy/litellm_pre_call_utils.py | 43 +++++- .../files/test_gemini_files_transformation.py | 77 ---------- .../test_huggingface_model_url_guard.py | 134 ----------------- .../test_oobabooga_model_url_guard.py | 111 -------------- .../test_provider_url_destination_guard.py | 139 ++++++++++++++++++ 12 files changed, 201 insertions(+), 466 deletions(-) delete mode 100644 tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py delete mode 100644 tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py create mode 100644 tests/test_litellm/proxy/test_provider_url_destination_guard.py diff --git a/litellm/llms/gemini/files/transformation.py b/litellm/llms/gemini/files/transformation.py index 00822da43e..63a383ebd3 100644 --- a/litellm/llms/gemini/files/transformation.py +++ b/litellm/llms/gemini/files/transformation.py @@ -12,11 +12,8 @@ import httpx from openai.types.file_deleted import FileDeleted from litellm._logging import verbose_logger +from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data -from litellm.litellm_core_utils.url_utils import ( - encode_url_path_segment, - is_url_destination_allowed_by_host, -) from litellm.llms.base_llm.files.transformation import ( BaseFilesConfig, LiteLLMLoggingObj, @@ -228,21 +225,20 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): if not api_key: raise ValueError("api_key is required") + file_part = self._normalize_gemini_file_id(file_id) + api_base = ( self.get_api_base(litellm_params.get("api_base")) or "https://generativelanguage.googleapis.com" ) api_base = api_base.rstrip("/") - file_part = self._normalize_gemini_file_id(file_id, api_base=api_base) url = f"{api_base}/v1beta/{file_part}" # API key is passed via x-goog-api-key header (set in validate_environment) return url, {} - def _normalize_gemini_file_id( - self, file_id: str, api_base: Optional[str] = None - ) -> str: + def _normalize_gemini_file_id(self, file_id: str) -> str: """ Normalize file identifier into `files/{id}` form. @@ -253,14 +249,6 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): """ if file_id.startswith(("http://", "https://")): parsed = urlparse(file_id) - if ( - parsed.username is not None - or parsed.password is not None - or not self._is_allowed_gemini_file_url( - file_url=file_id, api_base=api_base - ) - ): - raise ValueError("Invalid Gemini file URL") path = parsed.path.lstrip("/") files_index = path.find("files/") if files_index != -1: @@ -280,20 +268,6 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): return f"files/{encoded_file_id}" - @staticmethod - def _is_allowed_gemini_file_url( - file_url: str, api_base: Optional[str] = None - ) -> bool: - import litellm - - allowed_hosts: List[str] = [] - if api_base: - allowed_hosts.append(api_base) - allowed_hosts.extend( - getattr(litellm, "provider_url_destination_allowed_hosts", []) or [] - ) - return is_url_destination_allowed_by_host(file_url, allowed_hosts) - def transform_retrieve_file_response( self, raw_response: httpx.Response, @@ -368,8 +342,8 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig): if not api_key: raise ValueError("api_key is required") - api_base = api_base.rstrip("/") - file_name = self._normalize_gemini_file_id(file_id, api_base=api_base) + # Normalize and encode the file name before interpolating it into the URL. + file_name = self._normalize_gemini_file_id(file_id) # Construct the delete URL url = f"{api_base}/v1beta/{file_name}" diff --git a/litellm/llms/huggingface/chat/transformation.py b/litellm/llms/huggingface/chat/transformation.py index d138ade51e..557aa48550 100644 --- a/litellm/llms/huggingface/chat/transformation.py +++ b/litellm/llms/huggingface/chat/transformation.py @@ -16,12 +16,7 @@ else: from litellm.llms.base_llm.chat.transformation import BaseLLMException from ...openai.chat.gpt_transformation import OpenAIGPTConfig -from ..common_utils import ( - HuggingFaceError, - _fetch_inference_provider_mapping, - is_url_model_destination, - validate_huggingface_model_identifier, -) +from ..common_utils import HuggingFaceError, _fetch_inference_provider_mapping logger = logging.getLogger(__name__) @@ -81,8 +76,7 @@ class HuggingFaceChatConfig(OpenAIGPTConfig): Do not add the chat/embedding/rerank extension here. Let the handler do this. """ - validate_huggingface_model_identifier(model) - if is_url_model_destination(model): + if model.startswith(("http://", "https://")): base_url = model elif base_url is None: base_url = os.getenv("HF_API_BASE") or os.getenv("HUGGINGFACE_API_BASE", "") @@ -101,7 +95,6 @@ class HuggingFaceChatConfig(OpenAIGPTConfig): Get the complete URL for the API call. For provider-specific routing through huggingface """ - validate_huggingface_model_identifier(model) # Check if api_base is provided if api_base is not None: complete_url = api_base @@ -110,7 +103,7 @@ class HuggingFaceChatConfig(OpenAIGPTConfig): complete_url = str(os.getenv("HF_API_BASE")) or str( os.getenv("HUGGINGFACE_API_BASE") ) - elif is_url_model_destination(model): + elif model.startswith(("http://", "https://")): complete_url = model complete_url = _build_chat_completion_url(complete_url) # Default construction with provider diff --git a/litellm/llms/huggingface/common_utils.py b/litellm/llms/huggingface/common_utils.py index 9a8c4895c9..9ab4367c9b 100644 --- a/litellm/llms/huggingface/common_utils.py +++ b/litellm/llms/huggingface/common_utils.py @@ -4,41 +4,11 @@ from typing import Literal, Optional, Union import httpx -from litellm.litellm_core_utils.url_utils import is_url_destination_allowed_by_host from litellm.llms.base_llm.chat.transformation import BaseLLMException HF_HUB_URL = "https://huggingface.co" -def is_url_model_destination(model: str) -> bool: - return model.startswith(("http://", "https://")) - - -def _is_url_model_destination_allowed(model: str) -> bool: - import litellm - - allowed_hosts = getattr(litellm, "provider_url_destination_allowed_hosts", []) or [] - return is_url_destination_allowed_by_host(model, allowed_hosts) - - -def validate_huggingface_model_identifier(model: str) -> None: - """Reject URL-valued model identifiers before provider credentials are added.""" - if "://" not in model: - return - if is_url_model_destination(model) and _is_url_model_destination_allowed(model): - return - raise HuggingFaceError( - status_code=400, - message=( - "Invalid Hugging Face model identifier. Configure custom endpoints with " - "api_base or HF_API_BASE/HUGGINGFACE_API_BASE instead of passing a URL " - "as the model. To keep legacy URL-valued models for trusted endpoints, " - "add the destination host or origin to " - "`provider_url_destination_allowed_hosts` in litellm_settings." - ), - ) - - class HuggingFaceError(BaseLLMException): def __init__( self, diff --git a/litellm/llms/huggingface/embedding/handler.py b/litellm/llms/huggingface/embedding/handler.py index 2f7232a726..226f6b2eba 100644 --- a/litellm/llms/huggingface/embedding/handler.py +++ b/litellm/llms/huggingface/embedding/handler.py @@ -14,11 +14,7 @@ from litellm.llms.custom_httpx.http_handler import ( from litellm.types.utils import EmbeddingResponse from ...base import BaseLLM -from ..common_utils import ( - HuggingFaceError, - is_url_model_destination, - validate_huggingface_model_identifier, -) +from ..common_utils import HuggingFaceError from .transformation import HuggingFaceEmbeddingConfig config = HuggingFaceEmbeddingConfig() @@ -158,8 +154,6 @@ class HuggingFaceEmbedding(BaseLLM): embed_url: str, ) -> dict: data: Dict = {} - validate_huggingface_model_identifier(model) - model_uses_url_destination = is_url_model_destination(model) ## TRANSFORMATION ## if "sentence-transformers" in model: @@ -175,12 +169,8 @@ class HuggingFaceEmbedding(BaseLLM): task_type = optional_params.pop("input_type", None) if call_type == "sync": - hf_task = ( - task_type - if model_uses_url_destination - else get_hf_task_embedding_for_model( - model=model, task_type=task_type, api_base=HF_HUB_URL - ) + hf_task = get_hf_task_embedding_for_model( + model=model, task_type=task_type, api_base=HF_HUB_URL ) elif call_type == "async": return self._async_transform_input( @@ -344,7 +334,6 @@ class HuggingFaceEmbedding(BaseLLM): headers={}, ) -> EmbeddingResponse: super().embedding() - validate_huggingface_model_identifier(model) headers = config.validate_environment( api_key=api_key, headers=headers, @@ -354,17 +343,12 @@ class HuggingFaceEmbedding(BaseLLM): litellm_params=litellm_params, ) task_type = optional_params.get("input_type", None) - model_uses_url_destination = is_url_model_destination(model) - task = ( - task_type - if model_uses_url_destination - else get_hf_task_embedding_for_model( - model=model, task_type=task_type, api_base=HF_HUB_URL - ) + task = get_hf_task_embedding_for_model( + model=model, task_type=task_type, api_base=HF_HUB_URL ) # print_verbose(f"{model}, {task}") embed_url = "" - if model_uses_url_destination: + if "https" in model: embed_url = model elif api_base: embed_url = api_base diff --git a/litellm/llms/huggingface/embedding/transformation.py b/litellm/llms/huggingface/embedding/transformation.py index ce8319e82d..88d42cfcdc 100644 --- a/litellm/llms/huggingface/embedding/transformation.py +++ b/litellm/llms/huggingface/embedding/transformation.py @@ -21,14 +21,7 @@ from litellm.types.llms.openai import AllMessageValues from litellm.types.utils import Choices, Message, ModelResponse, Usage from litellm.utils import token_counter -from ..common_utils import ( - HuggingFaceError, - hf_task_list, - hf_tasks, - is_url_model_destination, - output_parser, - validate_huggingface_model_identifier, -) +from ..common_utils import HuggingFaceError, hf_task_list, hf_tasks, output_parser if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -343,8 +336,7 @@ class HuggingFaceEmbeddingConfig(BaseConfig): Do not add the chat/embedding/rerank extension here. Let the handler do this. """ - validate_huggingface_model_identifier(model) - if is_url_model_destination(model): + if "https" in model: completion_url = model elif api_base is not None: completion_url = api_base diff --git a/litellm/llms/oobabooga/chat/oobabooga.py b/litellm/llms/oobabooga/chat/oobabooga.py index 090ee11d1a..5eb68a03d4 100644 --- a/litellm/llms/oobabooga/chat/oobabooga.py +++ b/litellm/llms/oobabooga/chat/oobabooga.py @@ -5,11 +5,7 @@ import litellm from litellm.llms.custom_httpx.http_handler import _get_httpx_client from litellm.utils import EmbeddingResponse, ModelResponse, Usage -from ..common_utils import ( - OobaboogaError, - is_url_model_destination, - validate_oobabooga_model_identifier, -) +from ..common_utils import OobaboogaError from .transformation import OobaboogaConfig oobabooga_config = OobaboogaConfig() @@ -30,7 +26,6 @@ def completion( logger_fn=None, default_max_tokens_to_sample=None, ): - validate_oobabooga_model_identifier(model) headers = oobabooga_config.validate_environment( api_key=api_key, headers={}, @@ -39,7 +34,7 @@ def completion( optional_params=optional_params, litellm_params=litellm_params, ) - if is_url_model_destination(model): + if "https" in model: completion_url = model elif api_base: completion_url = api_base @@ -101,8 +96,7 @@ def embedding( encoding=None, ): # Create completion URL - validate_oobabooga_model_identifier(model) - if is_url_model_destination(model): + if "https" in model: embeddings_url = model elif api_base: embeddings_url = f"{api_base}/v1/embeddings" diff --git a/litellm/llms/oobabooga/common_utils.py b/litellm/llms/oobabooga/common_utils.py index 6c863ea555..82f8cda951 100644 --- a/litellm/llms/oobabooga/common_utils.py +++ b/litellm/llms/oobabooga/common_utils.py @@ -2,7 +2,6 @@ from typing import Optional, Union import httpx -from litellm.litellm_core_utils.url_utils import is_url_destination_allowed_by_host from litellm.llms.base_llm.chat.transformation import BaseLLMException @@ -14,32 +13,3 @@ class OobaboogaError(BaseLLMException): headers: Optional[Union[dict, httpx.Headers]] = None, ): super().__init__(status_code=status_code, message=message, headers=headers) - - -def is_url_model_destination(model: str) -> bool: - return model.startswith(("http://", "https://")) - - -def _is_url_model_destination_allowed(model: str) -> bool: - import litellm - - allowed_hosts = getattr(litellm, "provider_url_destination_allowed_hosts", []) or [] - return is_url_destination_allowed_by_host(model, allowed_hosts) - - -def validate_oobabooga_model_identifier(model: str) -> None: - """Oobabooga endpoints must be configured with api_base, not model URLs.""" - if "://" not in model: - return - if is_url_model_destination(model) and _is_url_model_destination_allowed(model): - return - raise OobaboogaError( - status_code=400, - message=( - "Invalid Oobabooga model identifier. Configure the endpoint with " - "api_base instead of passing a URL as the model. To keep legacy " - "URL-valued models for trusted endpoints, add the destination host " - "or origin to `provider_url_destination_allowed_hosts` in " - "litellm_settings." - ), - ) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 3077efe116..52bdb95fad 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -5,7 +5,7 @@ import time from collections import OrderedDict from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union -from fastapi import Request +from fastapi import HTTPException, Request from pydantic import ValidationError as PydanticValidationError from starlette.datastructures import Headers @@ -14,6 +14,7 @@ from litellm._logging import verbose_logger, verbose_proxy_logger from litellm._service_logger import ServiceLogging from litellm.litellm_core_utils.credential_accessor import CredentialAccessor from litellm.litellm_core_utils.safe_json_loads import safe_json_loads +from litellm.litellm_core_utils.url_utils import is_url_destination_allowed_by_host from litellm.proxy._types import ( AddTeamCallback, CommonProxyErrors, @@ -154,6 +155,45 @@ _ALLOW_CLIENT_MESSAGE_REDACTION_OPT_OUT_METADATA_KEY = ( "allow_client_message_redaction_opt_out" ) +# Request fields whose value, when URL-valued, becomes the outbound destination +# for a provider call. Letting a proxy caller pin the destination is an SSRF +# primitive (HuggingFace/Oobabooga `model`, Gemini files `file_id`); guard +# them centrally so SDK users keep working but proxy users default-deny. +_URL_DESTINATION_REQUEST_FIELDS = ("model", "file_id") + + +def _reject_url_valued_destinations(data: Dict[str, Any]) -> None: + """Reject URL-valued ``model``/``file_id`` unless admin-allowlisted. + + Some providers (HuggingFace, Oobabooga, Gemini files) accept a URL in the + identifier field and use it as the outbound destination. On the proxy that + is an SSRF primitive — a low-privilege caller can point traffic at any + host the proxy can reach, including internal services. Reject here at the + proxy boundary so SDK users (who legitimately pass URL-valued identifiers) + are unaffected, while admins can opt specific hosts back in via + ``litellm.provider_url_destination_allowed_hosts``. + """ + allowed_hosts = getattr(litellm, "provider_url_destination_allowed_hosts", []) or [] + for field in _URL_DESTINATION_REQUEST_FIELDS: + value = data.get(field) + if not isinstance(value, str) or not value.startswith(("http://", "https://")): + continue + if is_url_destination_allowed_by_host(value, allowed_hosts): + continue + raise HTTPException( + status_code=400, + detail={ + "error": "invalid_request", + "param": field, + "message": ( + f"URL-valued '{field}' is not allowed. Configure custom " + "endpoints with api_base instead, or add the destination " + "host to `provider_url_destination_allowed_hosts` in " + "litellm_settings." + ), + }, + ) + def _strip_untrusted_request_header_controls( headers: Any, @@ -1105,6 +1145,7 @@ async def add_litellm_data_to_request( # noqa: PLR0915 if _allow_client_mock_response and _internal_key in _CLIENT_MOCK_CONTROL_FIELDS: continue data.pop(_internal_key, None) + _reject_url_valued_destinations(data) # Strip spoofable auth metadata from user-supplied metadata dict _user_metadata = data.get("metadata") if isinstance(_user_metadata, dict): diff --git a/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py b/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py index 33fe5d24d7..a2f9572468 100644 --- a/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py +++ b/tests/test_litellm/llms/gemini/files/test_gemini_files_transformation.py @@ -7,7 +7,6 @@ from unittest.mock import Mock, patch import httpx import pytest -import litellm from litellm.llms.gemini.files.transformation import GoogleAIStudioFilesHandler from litellm.types.llms.openai import OpenAIFileObject @@ -93,54 +92,6 @@ class TestGoogleAIStudioFilesTransformation: assert "key=" not in url assert params == {} - def test_transform_retrieve_file_request_rejects_untrusted_full_url(self): - file_id = "https://attacker.example/v1beta/files/test123" - litellm_params = {"api_key": "test-api-key"} - - with pytest.raises(ValueError, match="Invalid Gemini file URL"): - self.handler.transform_retrieve_file_request( - file_id=file_id, - optional_params={}, - litellm_params=litellm_params, - ) - - def test_transform_retrieve_file_request_allows_full_url_matching_api_base(self): - file_id = "https://custom-gemini.example/v1beta/files/test123" - litellm_params = { - "api_key": "test-api-key", - "api_base": "https://custom-gemini.example", - } - - url, params = self.handler.transform_retrieve_file_request( - file_id=file_id, - optional_params={}, - litellm_params=litellm_params, - ) - - assert url == "https://custom-gemini.example/v1beta/files/test123" - assert params == {} - - def test_transform_retrieve_file_request_allows_full_url_when_host_allowlisted( - self, - monkeypatch, - ): - monkeypatch.setattr( - litellm, - "provider_url_destination_allowed_hosts", - ["trusted-gemini.example"], - ) - file_id = "https://trusted-gemini.example/v1beta/files/test123" - litellm_params = {"api_key": "test-api-key"} - - url, params = self.handler.transform_retrieve_file_request( - file_id=file_id, - optional_params={}, - litellm_params=litellm_params, - ) - - assert url == "https://generativelanguage.googleapis.com/v1beta/files/test123" - assert params == {} - def test_transform_retrieve_file_request_encodes_file_id_path_segment(self): file_id = "files/../../models/gemini-pro?x=1#frag" litellm_params = {"api_key": "test-api-key"} @@ -393,34 +344,6 @@ class TestGoogleAIStudioFilesTransformation: assert "generativelanguage.googleapis.com" in url assert params == {} - def test_transform_delete_file_request_rejects_untrusted_full_url(self): - litellm_params = { - "api_key": "test-api-key", - "api_base": "https://generativelanguage.googleapis.com", - } - - with pytest.raises(ValueError, match="Invalid Gemini file URL"): - self.handler.transform_delete_file_request( - file_id="https://attacker.example/v1beta/files/test123", - optional_params={}, - litellm_params=litellm_params, - ) - - def test_transform_delete_file_request_allows_full_url_matching_api_base(self): - litellm_params = { - "api_key": "test-api-key", - "api_base": "https://custom-gemini.example", - } - - url, params = self.handler.transform_delete_file_request( - file_id="https://custom-gemini.example/v1beta/files/test123", - optional_params={}, - litellm_params=litellm_params, - ) - - assert url == "https://custom-gemini.example/v1beta/files/test123" - assert params == {} - def test_transform_delete_file_request_encodes_file_id_path_segment(self): file_id = "files/../../models/gemini-pro?x=1#frag" litellm_params = { diff --git a/tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py b/tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py deleted file mode 100644 index 27e3b855de..0000000000 --- a/tests/test_litellm/llms/huggingface/test_huggingface_model_url_guard.py +++ /dev/null @@ -1,134 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest - -import litellm -from litellm.llms.huggingface.chat.transformation import HuggingFaceChatConfig -from litellm.llms.huggingface.common_utils import HuggingFaceError -from litellm.llms.huggingface.embedding.handler import HuggingFaceEmbedding -from litellm.llms.huggingface.embedding.transformation import ( - HuggingFaceEmbeddingConfig, -) - - -def test_huggingface_chat_rejects_url_valued_model(): - config = HuggingFaceChatConfig() - - with pytest.raises(HuggingFaceError) as exc_info: - config.get_complete_url( - api_base=None, - api_key="hf-secret", - model="https://attacker.example/v1", - optional_params={}, - litellm_params={}, - ) - - assert exc_info.value.status_code == 400 - - -def test_huggingface_chat_allows_legacy_url_model_when_host_is_allowlisted( - monkeypatch, -): - monkeypatch.setattr( - litellm, "provider_url_destination_allowed_hosts", ["trusted.example"] - ) - config = HuggingFaceChatConfig() - - complete_url = config.get_complete_url( - api_base=None, - api_key="hf-secret", - model="https://trusted.example", - optional_params={}, - litellm_params={}, - ) - - assert complete_url == "https://trusted.example/v1/chat/completions" - - -def test_huggingface_chat_rejects_url_model_when_host_is_not_allowlisted( - monkeypatch, -): - monkeypatch.setattr( - litellm, "provider_url_destination_allowed_hosts", ["trusted.example"] - ) - config = HuggingFaceChatConfig() - - with pytest.raises(HuggingFaceError) as exc_info: - config.get_complete_url( - api_base=None, - api_key="hf-secret", - model="https://other.example", - optional_params={}, - litellm_params={}, - ) - - assert exc_info.value.status_code == 400 - - -def test_huggingface_chat_keeps_explicit_api_base_for_custom_endpoints(): - config = HuggingFaceChatConfig() - - complete_url = config.get_complete_url( - api_base="https://admin-configured.example", - api_key="hf-secret", - model="huggingface/mistral", - optional_params={}, - litellm_params={}, - ) - - assert complete_url == "https://admin-configured.example/v1/chat/completions" - - -def test_huggingface_embedding_config_rejects_url_valued_model(): - config = HuggingFaceEmbeddingConfig() - - with pytest.raises(HuggingFaceError) as exc_info: - config.get_api_base( - api_base=None, - model="prefixhttps://attacker.example/embeddings", - ) - - assert exc_info.value.status_code == 400 - - -def test_huggingface_embedding_config_allows_legacy_url_model_when_origin_is_allowlisted( - monkeypatch, -): - monkeypatch.setattr( - litellm, - "provider_url_destination_allowed_hosts", - ["https://trusted.example"], - ) - config = HuggingFaceEmbeddingConfig() - - api_base = config.get_api_base( - api_base=None, - model="https://trusted.example/embeddings", - ) - - assert api_base == "https://trusted.example/embeddings" - - -def test_huggingface_embedding_handler_rejects_before_task_lookup(): - handler = HuggingFaceEmbedding() - logging_obj = MagicMock() - encoding = MagicMock() - encoding.encode.return_value = [] - - with patch( - "litellm.llms.huggingface.embedding.handler.get_hf_task_embedding_for_model" - ) as mock_task_lookup: - with pytest.raises(HuggingFaceError) as exc_info: - handler.embedding( - model="https://attacker.example/embeddings", - input=["hello"], - model_response=MagicMock(), - optional_params={}, - litellm_params={}, - logging_obj=logging_obj, - encoding=encoding, - api_key="hf-secret", - ) - - assert exc_info.value.status_code == 400 - mock_task_lookup.assert_not_called() diff --git a/tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py b/tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py deleted file mode 100644 index 5ed0cdca03..0000000000 --- a/tests/test_litellm/llms/oobabooga/test_oobabooga_model_url_guard.py +++ /dev/null @@ -1,111 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest - -import litellm -from litellm.llms.oobabooga.chat.oobabooga import completion, embedding -from litellm.llms.oobabooga.common_utils import OobaboogaError - - -def test_oobabooga_completion_rejects_url_valued_model_before_request(): - with patch("litellm.llms.oobabooga.chat.oobabooga._get_httpx_client") as mock_get: - with pytest.raises(OobaboogaError) as exc_info: - completion( - model="https://attacker.example/v1", - messages=[], - api_base="https://admin-configured.example", - model_response=MagicMock(), - print_verbose=MagicMock(), - encoding=MagicMock(), - api_key="ooba-secret", - logging_obj=MagicMock(), - optional_params={}, - litellm_params={}, - ) - - assert exc_info.value.status_code == 400 - mock_get.assert_not_called() - - -def test_oobabooga_completion_allows_legacy_url_model_when_host_is_allowlisted( - monkeypatch, -): - monkeypatch.setattr( - litellm, "provider_url_destination_allowed_hosts", ["trusted.example"] - ) - response = MagicMock() - client = MagicMock() - client.post.return_value = response - - with patch( - "litellm.llms.oobabooga.chat.oobabooga._get_httpx_client", - return_value=client, - ): - with patch( - "litellm.llms.oobabooga.chat.oobabooga.oobabooga_config.transform_response", - return_value="ok", - ): - result = completion( - model="https://trusted.example", - messages=[], - api_base=None, - model_response=MagicMock(), - print_verbose=MagicMock(), - encoding=MagicMock(), - api_key="ooba-secret", - logging_obj=MagicMock(), - optional_params={}, - litellm_params={}, - ) - - assert result == "ok" - client.post.assert_called_once() - assert ( - client.post.call_args.args[0] == "https://trusted.example/v1/chat/completions" - ) - - -def test_oobabooga_completion_rejects_url_model_when_host_is_not_allowlisted( - monkeypatch, -): - monkeypatch.setattr( - litellm, "provider_url_destination_allowed_hosts", ["trusted.example"] - ) - - with patch("litellm.llms.oobabooga.chat.oobabooga._get_httpx_client") as mock_get: - with pytest.raises(OobaboogaError) as exc_info: - completion( - model="https://other.example", - messages=[], - api_base=None, - model_response=MagicMock(), - print_verbose=MagicMock(), - encoding=MagicMock(), - api_key="ooba-secret", - logging_obj=MagicMock(), - optional_params={}, - litellm_params={}, - ) - - assert exc_info.value.status_code == 400 - mock_get.assert_not_called() - - -def test_oobabooga_embedding_rejects_url_valued_model_before_request(): - with patch( - "litellm.llms.oobabooga.chat.oobabooga.litellm.module_level_client.post" - ) as mock_post: - with pytest.raises(OobaboogaError) as exc_info: - embedding( - model="prefixhttps://attacker.example/embeddings", - input=["hello"], - model_response=MagicMock(), - api_key="ooba-secret", - api_base="https://admin-configured.example", - logging_obj=MagicMock(), - optional_params={}, - encoding=MagicMock(), - ) - - assert exc_info.value.status_code == 400 - mock_post.assert_not_called() diff --git a/tests/test_litellm/proxy/test_provider_url_destination_guard.py b/tests/test_litellm/proxy/test_provider_url_destination_guard.py new file mode 100644 index 0000000000..51cd76105d --- /dev/null +++ b/tests/test_litellm/proxy/test_provider_url_destination_guard.py @@ -0,0 +1,139 @@ +"""Proxy-level guard against URL-valued ``model`` / ``file_id`` request fields. + +Some providers (HuggingFace, Oobabooga, Gemini files) accept a URL in the +identifier field and use it as the outbound destination. On the proxy that is +an SSRF primitive — guarded centrally in ``litellm_pre_call_utils`` so SDK +users keep working but proxy users default-deny. +""" + +import os +import sys +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException, Request + +import litellm +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.litellm_pre_call_utils import ( + _reject_url_valued_destinations, + add_litellm_data_to_request, +) + +sys.path.insert(0, os.path.abspath("../../..")) + + +class TestRejectUrlValuedDestinations: + def test_plain_model_passes(self): + _reject_url_valued_destinations({"model": "gpt-4"}) + + def test_plain_file_id_passes(self): + _reject_url_valued_destinations({"file_id": "files/abc123"}) + + def test_no_destination_field_passes(self): + _reject_url_valued_destinations({"messages": [{"role": "user"}]}) + + def test_url_valued_model_rejected(self): + with pytest.raises(HTTPException) as exc_info: + _reject_url_valued_destinations({"model": "https://attacker.example/v1"}) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["param"] == "model" + + def test_url_valued_file_id_rejected(self): + with pytest.raises(HTTPException) as exc_info: + _reject_url_valued_destinations( + {"file_id": "https://attacker.example/v1beta/files/abc"} + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["param"] == "file_id" + + def test_http_scheme_also_rejected(self): + with pytest.raises(HTTPException): + _reject_url_valued_destinations({"model": "http://10.0.0.1:8080/v1"}) + + def test_non_string_value_ignored(self): + # Defensive: malformed inputs (list, dict, None) shouldn't crash here; + # downstream Pydantic validation handles the type error. + _reject_url_valued_destinations({"model": None}) + _reject_url_valued_destinations({"model": 42}) + _reject_url_valued_destinations({"file_id": ["a", "b"]}) + + def test_allowlisted_host_passes(self, monkeypatch): + monkeypatch.setattr( + litellm, + "provider_url_destination_allowed_hosts", + ["trusted.example"], + ) + _reject_url_valued_destinations({"model": "https://trusted.example/v1"}) + + def test_allowlisted_origin_rejects_mismatched_scheme(self, monkeypatch): + monkeypatch.setattr( + litellm, + "provider_url_destination_allowed_hosts", + ["https://trusted.example"], + ) + _reject_url_valued_destinations({"model": "https://trusted.example/v1"}) + with pytest.raises(HTTPException): + _reject_url_valued_destinations({"model": "http://trusted.example/v1"}) + + def test_allowlisted_host_port_rejects_other_ports(self, monkeypatch): + monkeypatch.setattr( + litellm, + "provider_url_destination_allowed_hosts", + ["trusted.example:8443"], + ) + _reject_url_valued_destinations({"model": "https://trusted.example:8443/v1"}) + with pytest.raises(HTTPException): + _reject_url_valued_destinations({"model": "https://trusted.example/v1"}) + + def test_userinfo_in_url_rejected_even_when_host_allowlisted(self, monkeypatch): + # Embedded credentials in URL are an exfil channel — must never pass. + monkeypatch.setattr( + litellm, + "provider_url_destination_allowed_hosts", + ["trusted.example"], + ) + with pytest.raises(HTTPException): + _reject_url_valued_destinations( + {"model": "https://user:pass@trusted.example/v1"} + ) + + +def _make_request_mock() -> Request: + request_mock = MagicMock(spec=Request) + request_mock.url.path = "/v1/chat/completions" + request_mock.url = MagicMock() + request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions" + request_mock.method = "POST" + request_mock.query_params = {} + request_mock.headers = {"Content-Type": "application/json"} + request_mock.client = MagicMock() + request_mock.client.host = "127.0.0.1" + return request_mock + + +@pytest.mark.asyncio +async def test_add_litellm_data_to_request_rejects_url_valued_model(): + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-key", + metadata={}, + team_metadata={}, + spend=0.0, + max_budget=100.0, + model_max_budget={}, + team_spend=0.0, + team_max_budget=200.0, + ) + data = {"model": "https://attacker.example/v1", "messages": []} + + with pytest.raises(HTTPException) as exc_info: + await add_litellm_data_to_request( + data=data, + request=_make_request_mock(), + user_api_key_dict=user_api_key_dict, + proxy_config=MagicMock(), + general_settings={}, + version="test-version", + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["param"] == "model"