chore(providers): add guarded URL model migration path

This commit is contained in:
user 2026-04-30 13:17:09 -07:00
parent 5ec925b02b
commit d75f62a17c
11 changed files with 189 additions and 16 deletions

View file

@ -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
)

View file

@ -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")

View file

@ -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

View file

@ -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."
),
)

View file

@ -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", "")

View file

@ -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", "")

View file

@ -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(

View file

@ -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."
),
)

View file

@ -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,
)

View file

@ -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()

View file

@ -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"