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] 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 77fa48625d9..16ae730384a 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 f21296ef26b..fe7f81ab95d 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 1591ce0e888..d138ade51ed 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 2d5f583873a..1e94e28d119 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 4e3ac0ec0ba..2f7232a726f 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 3a17b0a114f..ce8319e82df 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 bcd3584efbe..090ee11d1a4 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 69f2cb519b7..d09f03f6a62 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 41006fb916c..93188da9c91 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 c0bb56c1717..f4c2c372009 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 dab02637e50..2e43c01f381 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"