From 892a285f0e3a7ec2bd20c2968f871495c3462885 Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Tue, 21 Jul 2026 17:57:58 -0700 Subject: [PATCH 1/9] chore(proxy): clean up request parameter validation and provider destination handling (#34189) (cherry picked from commit 065faf6e695be64a1a367601271839914e4a6621) --- litellm/litellm_core_utils/url_utils.py | 9 + litellm/llms/huggingface/embedding/handler.py | 2 +- .../huggingface/embedding/transformation.py | 19 - litellm/llms/oobabooga/chat/oobabooga.py | 4 +- litellm/proxy/auth/auth_utils.py | 72 +++- litellm/proxy/auth/user_api_key_auth.py | 59 +-- litellm/proxy/litellm_pre_call_utils.py | 40 +- .../code_coverage_tests/recursive_detector.py | 1 + .../test_huggingface_embedding_handler.py | 14 + .../llms/oobabooga/chat/test_oobabooga.py | 55 +++ .../proxy/auth/test_auth_utils.py | 352 +++++++++++++++++- .../test_router_override_fallback_auth.py | 141 ++++++- .../test_provider_url_destination_guard.py | 40 ++ 13 files changed, 702 insertions(+), 106 deletions(-) create mode 100644 tests/test_litellm/llms/oobabooga/chat/test_oobabooga.py diff --git a/litellm/litellm_core_utils/url_utils.py b/litellm/litellm_core_utils/url_utils.py index 1cbb1ce973f..a83cb3bc69e 100644 --- a/litellm/litellm_core_utils/url_utils.py +++ b/litellm/litellm_core_utils/url_utils.py @@ -148,6 +148,15 @@ def _parse_url_destination_allowlist_entry( return _normalize_host(parsed.hostname), scheme, port +def provider_url_destination_candidates(value: str) -> Tuple[str, ...]: + return tuple( + candidate + for part in value.split(",") + for candidate in (part.strip(), part.strip().split("/", 1)[1] if "/" in part.strip() else "") + if candidate + ) + + 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. diff --git a/litellm/llms/huggingface/embedding/handler.py b/litellm/llms/huggingface/embedding/handler.py index 39eb430db74..f72a79e084d 100644 --- a/litellm/llms/huggingface/embedding/handler.py +++ b/litellm/llms/huggingface/embedding/handler.py @@ -322,7 +322,7 @@ class HuggingFaceEmbedding(BaseLLM): 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 "https" in model: + if model.startswith(("http://", "https://")): 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 13e38ab5560..6f27e3115eb 100644 --- a/litellm/llms/huggingface/embedding/transformation.py +++ b/litellm/llms/huggingface/embedding/transformation.py @@ -316,25 +316,6 @@ class HuggingFaceEmbeddingConfig(BaseConfig): return data - def get_api_base(self, api_base: Optional[str], model: str) -> str: - """ - Get the API base for the Huggingface API. - - 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: - completion_url = api_base - elif "HF_API_BASE" in os.environ: - completion_url = os.getenv("HF_API_BASE", "") - elif "HUGGINGFACE_API_BASE" in os.environ: - completion_url = os.getenv("HUGGINGFACE_API_BASE", "") - else: - completion_url = f"https://api-inference.huggingface.co/models/{model}" - - return completion_url - def validate_environment( self, headers: Dict, diff --git a/litellm/llms/oobabooga/chat/oobabooga.py b/litellm/llms/oobabooga/chat/oobabooga.py index fe2bb9dc6d1..40d88e8e125 100644 --- a/litellm/llms/oobabooga/chat/oobabooga.py +++ b/litellm/llms/oobabooga/chat/oobabooga.py @@ -34,7 +34,7 @@ def completion( optional_params=optional_params, litellm_params=litellm_params, ) - if "https" in model: + if model.startswith(("http://", "https://")): completion_url = model elif api_base: completion_url = api_base @@ -96,7 +96,7 @@ def embedding( encoding=None, ): # Create completion URL - if "https" in model: + if model.startswith(("http://", "https://")): embeddings_url = model elif api_base: embeddings_url = f"{api_base}/v1/embeddings" diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 293bb74e211..ecb37e67c14 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -3,7 +3,7 @@ import re import sys from functools import lru_cache from logging import Logger -from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Tuple, Union +from typing import Any, Dict, FrozenSet, Iterator, List, Mapping, Optional, Tuple, Union from fastapi import HTTPException, Request, status @@ -12,7 +12,12 @@ from litellm import Router, provider_list from litellm._logging import verbose_proxy_logger from litellm.constants import MINIMUM_CUSTOM_KEY_LENGTH, STANDARD_CUSTOMER_ID_HEADERS from litellm.litellm_core_utils.safe_json_loads import safe_json_loads -from litellm.litellm_core_utils.url_utils import SSRFError, validate_url +from litellm.litellm_core_utils.url_utils import ( + SSRFError, + is_url_destination_allowed_by_host, + provider_url_destination_candidates, + validate_url, +) from litellm.proxy._types import * from litellm.types.passthrough_endpoints.pass_through_endpoints import ( LITELLM_PASS_THROUGH_ENDPOINT_MARKER, @@ -290,6 +295,7 @@ _BANNED_REQUEST_BODY_PARAMS: Tuple[str, ...] = ( "use_ssl", # SDK-only field; also rejected outright in is_request_body_safe. "model_list", + "vertex_ai_credentials", # Observability credentials, hosts, and project identifiers: derived # from the canonical ``_supported_callback_params`` allowlist so new # integrations are covered automatically. Sorted for stable iteration @@ -342,6 +348,60 @@ def _check_banned_params( ) +_FALLBACK_FIELDS: tuple[str, ...] = ( + "fallbacks", + "context_window_fallbacks", + "content_policy_fallbacks", +) + + +def _iter_fallback_field_values(request_body: Mapping[str, object]) -> Iterator[object]: + override = request_body.get("router_settings_override") + for source in (request_body, override): + if isinstance(source, Mapping): + for field in _FALLBACK_FIELDS: + yield source.get(field) + + +def _iter_fallback_targets(value: object, depth: int) -> Iterator[str | Mapping[str, object]]: + if depth > 2 * litellm.ROUTER_MAX_FALLBACKS: + raise ValueError("Rejected Request: fallback nesting exceeds the allowed validation depth.") + if not isinstance(value, list): + return + for item in value: + if isinstance(item, str): + yield item + elif isinstance(item, Mapping): + values = tuple(item.values()) + if not (values and all(isinstance(v, list) for v in values)): + yield item + if isinstance(item.get("model"), str): + for field in _FALLBACK_FIELDS: + yield from _iter_fallback_targets(item.get(field), depth + 1) + else: + for target_list in values: + yield from _iter_fallback_targets(target_list, depth + 1) + + +def iter_request_fallback_targets(request_body: Mapping[str, object]) -> Iterator[str | Mapping[str, object]]: + for value in _iter_fallback_field_values(request_body): + yield from _iter_fallback_targets(value, 0) + + +def _reject_url_valued_fallback_target(value: str) -> None: + allowed_hosts = getattr(litellm, "provider_url_destination_allowed_hosts", []) or [] + for candidate in provider_url_destination_candidates(value): + if not candidate.lower().startswith(("http://", "https://")): + continue + if is_url_destination_allowed_by_host(candidate, allowed_hosts): + continue + raise ValueError( + f"Rejected Request: URL-valued fallback destination '{value}' 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 is_request_body_safe(request_body: dict, general_settings: dict, llm_router: Optional[Router], model: str) -> bool: """ Check if the request body is safe. @@ -379,6 +439,14 @@ def is_request_body_safe(request_body: dict, general_settings: dict, llm_router: metadata = _coerce_metadata_to_dict(request_body.get(metadata_key)) if metadata is not None: _check_banned_params(metadata, general_settings, llm_router, model) + for target in iter_request_fallback_targets(request_body): + if isinstance(target, dict): + _check_banned_params(target, general_settings, llm_router, model) + target_model = target.get("model") + if isinstance(target_model, str): + _reject_url_valued_fallback_target(target_model) + elif isinstance(target, str): + _reject_url_valued_fallback_target(target) litellm_params = _coerce_metadata_to_dict(request_body.get("litellm_params")) if litellm_params is not None: litellm_params_metadata = _coerce_metadata_to_dict(litellm_params.get("metadata")) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index e856d0935bf..9422fd89545 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -14,7 +14,7 @@ import secrets import orjson from datetime import datetime, timezone -from typing import Any, Dict, Iterator, NamedTuple, List, Optional, Protocol, Tuple, Union, cast +from typing import Any, Dict, NamedTuple, List, Optional, Protocol, Tuple, Union, cast import fastapi from fastapi import HTTPException, Request, WebSocket, status @@ -58,6 +58,7 @@ from litellm.proxy.auth.auth_utils import ( get_model_from_request, get_request_route, get_request_route_template, + iter_request_fallback_targets, normalize_request_route, pre_db_read_auth_checks, route_in_additonal_public_routes, @@ -2799,19 +2800,11 @@ async def _enforce_key_and_fallback_model_access( llm_router=llm_router, ) - # Validate every fallback model name reachable by this request. - # All three fields (``fallbacks``, ``context_window_fallbacks``, - # ``content_policy_fallbacks``) are forwarded to the router as - # per-request kwargs whether they appear at the top level of - # ``request_data`` or nested under ``router_settings_override``. - # Both surfaces must be validated against the API key's model - # allowlist or a caller can smuggle a restricted model. VERIA-44. - fallback_names: List[str] = [] - override_settings = request_data.get("router_settings_override") - for _fb_key in ROUTER_FALLBACK_FIELDS: - fallback_names.extend(iter_router_fallback_model_names(request_data.get(_fb_key))) - if isinstance(override_settings, dict): - fallback_names.extend(iter_router_fallback_model_names(override_settings.get(_fb_key))) + fallback_names = tuple( + name + for target in iter_request_fallback_targets(request_data) + if (name := _fallback_target_model_name(target)) is not None + ) for _name in dict.fromkeys(fallback_names): # dedupe, preserve order await can_key_call_model( @@ -2827,36 +2820,14 @@ async def _enforce_key_and_fallback_model_access( ) -ROUTER_FALLBACK_FIELDS: Tuple[str, ...] = ( - "fallbacks", - "context_window_fallbacks", - "content_policy_fallbacks", -) - - -def iter_router_fallback_model_names(fallbacks: Any) -> Iterator[str]: - """Yield leaf model names from any of the supported fallbacks shapes. - - Handles the simple top-level shape (``str`` or ``{"model": str}``) and - the nested router-config shape (``[{primary: [fallback_list]}]``). - """ - if not isinstance(fallbacks, list): - return - for entry in fallbacks: - if isinstance(entry, str): - yield entry - elif isinstance(entry, dict): - if isinstance(entry.get("model"), str): - yield entry["model"] - continue - for fallback_list in entry.values(): - if not isinstance(fallback_list, list): - continue - for m in fallback_list: - if isinstance(m, str): - yield m - elif isinstance(m, dict) and isinstance(m.get("model"), str): - yield m["model"] +def _fallback_target_model_name(target: object) -> str | None: + if isinstance(target, str): + return target + if isinstance(target, dict): + model = target.get("model") + if isinstance(model, str): + return model + return None async def _run_post_custom_auth_checks( diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 9ddc7ce2caf..bf574c66729 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -19,7 +19,10 @@ from litellm.litellm_core_utils.initialize_dynamic_callback_params import ( iter_client_callback_metadata_dicts, ) 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.litellm_core_utils.url_utils import ( + is_url_destination_allowed_by_host, + provider_url_destination_candidates, +) from litellm.proxy._types import ( AddTeamCallback, CommonProxyErrors, @@ -227,23 +230,26 @@ def _reject_url_valued_destinations(data: Dict[str, Any]) -> None: 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://")): + if not isinstance(value, str): 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." - ), - }, - ) + for candidate in provider_url_destination_candidates(value): + if not candidate.lower().startswith(("http://", "https://")): + continue + if is_url_destination_allowed_by_host(candidate, 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( diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index e08d703d21f..d4a4a1bb7b7 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -55,6 +55,7 @@ IGNORE_FUNCTIONS = [ "_freeze_for_dedupe", # OTEL: max depth set (default 16, _FREEZE_MAX_DEPTH); fails closed by returning repr(value) at the cap. "apply_json_merge_patch", # max depth set (_MAX_MERGE_DEPTH=64); fails closed by raising ValueError at the cap. "_filter_mcp_argument_value", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by blocking the MCP call at the cap. + "_iter_fallback_targets", # max depth set (2 * ROUTER_MAX_FALLBACKS); fails closed by raising ValueError at the cap. ] diff --git a/tests/test_litellm/llms/huggingface/embedding/test_huggingface_embedding_handler.py b/tests/test_litellm/llms/huggingface/embedding/test_huggingface_embedding_handler.py index 8a072fa5097..af8321f24a1 100644 --- a/tests/test_litellm/llms/huggingface/embedding/test_huggingface_embedding_handler.py +++ b/tests/test_litellm/llms/huggingface/embedding/test_huggingface_embedding_handler.py @@ -121,6 +121,20 @@ class TestHuggingFaceEmbedding: assert response.usage.prompt_tokens > 0 assert response.usage.total_tokens == response.usage.prompt_tokens + def test_model_name_with_https_substring_uses_api_base(self): + api_base = "https://legit.example/embed" + + litellm.embedding( + model="huggingface/my-https-endpoint", + input=["hello world"], + input_type="embed", + api_base=api_base, + ) + + self.mock_http.assert_called_once() + called_url = self.mock_http.call_args[0][0] + assert called_url == api_base + def test_embedding_with_sentence_similarity_task(self): """Test embedding when task type is sentence-similarity (requires 2+ sentences)""" diff --git a/tests/test_litellm/llms/oobabooga/chat/test_oobabooga.py b/tests/test_litellm/llms/oobabooga/chat/test_oobabooga.py new file mode 100644 index 00000000000..91ebb2bd9d4 --- /dev/null +++ b/tests/test_litellm/llms/oobabooga/chat/test_oobabooga.py @@ -0,0 +1,55 @@ +import os +import sys +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath("../../../../..")) + +import litellm + +MOCK_COMPLETION_RESPONSE = { + "choices": [{"message": {"role": "assistant", "content": "hi there"}}], + "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}, +} + + +def _mock_post_response(): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "ok" + mock_response.json.return_value = MOCK_COMPLETION_RESPONSE + return mock_response + + +def test_model_name_with_https_substring_uses_api_base(): + api_base = "https://legit.example" + + with patch( + "litellm.llms.custom_httpx.http_handler.HTTPHandler.post" + ) as mock_post: + mock_post.return_value = _mock_post_response() + + litellm.completion( + model="oobabooga/my-https-model", + messages=[{"role": "user", "content": "hello"}], + api_base=api_base, + ) + + mock_post.assert_called_once() + called_url = mock_post.call_args[0][0] + assert called_url == f"{api_base}/v1/chat/completions" + + +def test_url_valued_model_still_targets_that_url(): + with patch( + "litellm.llms.custom_httpx.http_handler.HTTPHandler.post" + ) as mock_post: + mock_post.return_value = _mock_post_response() + + litellm.completion( + model="oobabooga/https://sdk-user.example", + messages=[{"role": "user", "content": "hello"}], + ) + + mock_post.assert_called_once() + called_url = mock_post.call_args[0][0] + assert called_url == "https://sdk-user.example/v1/chat/completions" diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index 72bd215b9be..9f24c662581 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -1587,7 +1587,7 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride: } out = get_dynamic_litellm_params( litellm_params=dict(admin_params), - request_kwargs={"base_url": "https://attacker.example"}, + request_kwargs={"base_url": "https://attacker.example", "api_key": "sk-caller"}, ) assert "aws_access_key_id" not in out assert "aws_secret_access_key" not in out @@ -1608,7 +1608,7 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride: } out = get_dynamic_litellm_params( litellm_params=dict(admin_params), - request_kwargs={"api_base": "self-hosted.example.com:50051"}, + request_kwargs={"api_base": "self-hosted.example.com:50051", "api_key": "sk-caller"}, ) assert out["api_base"] == "self-hosted.example.com:50051" assert "nvcf_function_id" not in out @@ -1626,7 +1626,7 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride: } out = get_dynamic_litellm_params( litellm_params=dict(admin_params), - request_kwargs={"api_base": "self-hosted.example.com:50051"}, + request_kwargs={"api_base": "self-hosted.example.com:50051", "api_key": "sk-caller"}, ) assert out["api_base"] == "self-hosted.example.com:50051" assert "use_ssl" not in out @@ -1651,6 +1651,7 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride: }, request_kwargs={ "api_base": "https://attacker.example", + "api_key": "sk-caller", "organization": "org-attacker", "extra_body": {"attacker": "value"}, }, @@ -1674,6 +1675,7 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride: }, request_kwargs={ "api_base": "https://attacker.example", + "api_key": "sk-caller", "organization": "", "extra_body": "", }, @@ -1701,6 +1703,310 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride: assert out["api_version"] == "2026-04-01" assert out["api_base"] == "https://admin.upstream/v1" + def test_client_api_key_used_when_supplied_with_base_override(self): + from litellm.router_utils.clientside_credential_handler import ( + get_dynamic_litellm_params, + ) + + out = get_dynamic_litellm_params( + litellm_params={ + "model": "gpt-4", + "api_key": "sk-admin-secret", + "api_base": "https://admin.upstream/v1", + }, + request_kwargs={ + "api_base": "https://attacker.example", + "api_key": "sk-client-byok", + }, + ) + assert out["api_key"] == "sk-client-byok" + assert "sk-admin-secret" not in str(out) + + +_OPENAI_CHAT_RESPONSE = { + "id": "chatcmpl-x", + "object": "chat.completion", + "created": 1, + "model": "gpt-4", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, +} + + +class TestClientsideBaseOverrideOutboundKey: + """Drive a completion through the router and assert on the outbound request + when the caller overrides ``api_base``.""" + + def _router(self): + from litellm import Router + + return Router( + model_list=[ + { + "model_name": "gpt-4", + "litellm_params": { + "model": "openai/gpt-4", + "api_key": "sk-SERVER-CONFIG", + "api_base": "https://admin.upstream/v1", + }, + } + ] + ) + + @pytest.fixture(autouse=True) + def _ambient_server_key(self, monkeypatch): + import litellm + + monkeypatch.setenv("OPENAI_API_KEY", "sk-SERVER-ENV") + monkeypatch.setattr(litellm, "api_key", None, raising=False) + + def test_caller_key_override_sends_caller_key_never_server_key(self): + import httpx + import respx + + with respx.mock: + route = respx.post("https://caller.example/v1/chat/completions").mock( + return_value=httpx.Response(200, json=_OPENAI_CHAT_RESPONSE) + ) + self._router().completion( + model="gpt-4", + messages=[{"role": "user", "content": "hi"}], + api_base="https://caller.example/v1", + api_key="sk-CALLER", + ) + authorization = route.calls.last.request.headers.get("authorization") + assert authorization == "Bearer sk-CALLER" + assert "SERVER" not in (authorization or "") + + +def _rounds_deep_api_base_payload(rounds, field): + """Build a fallbacks payload with ``api_base`` on a target nested ``rounds`` + fallback-rounds deep, each round wrapped in its own grouping dict.""" + node = {"model": "leaf", "api_base": "https://attacker.example"} + for i in range(rounds): + node = {"model": f"m{i}", field: [{"grp": [node]}]} + return {"model": "gpt-4", field: [{"grp": [node]}]} + + +class TestIsRequestBodySafeBlocksFallbackSmuggle: + """``is_request_body_safe`` runs the banned-param check on every dict target + inside the fallback lists.""" + + @pytest.fixture(autouse=True) + def _disable_url_validation(self, monkeypatch): + import litellm + + monkeypatch.setattr(litellm, "user_url_validation", False, raising=False) + + @pytest.mark.parametrize( + "fallback_key", + ["fallbacks", "context_window_fallbacks", "content_policy_fallbacks"], + ) + def test_api_base_smuggled_via_nested_fallback_is_rejected(self, fallback_key): + with pytest.raises(ValueError, match="api_base"): + is_request_body_safe( + request_body={ + "model": "gpt-4", + fallback_key: [ + { + "gpt-4": [ + {"model": "evil", "api_base": "https://attacker.example"}, + ] + } + ], + }, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + + def test_string_only_fallbacks_are_accepted(self): + assert ( + is_request_body_safe( + request_body={ + "model": "gpt-4", + "fallbacks": [{"gpt-4": ["gpt-3.5-turbo", "claude-3-haiku"]}], + }, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + is True + ) + + def test_benign_dict_fallback_entry_is_accepted(self): + assert ( + is_request_body_safe( + request_body={ + "model": "gpt-4", + "fallbacks": [{"gpt-4": [{"model": "gpt-3.5-turbo"}]}], + }, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + is True + ) + + def test_smuggled_fallback_allowed_under_proxy_wide_opt_in(self): + assert ( + is_request_body_safe( + request_body={ + "model": "gpt-4", + "fallbacks": [ + {"gpt-4": [{"model": "byok", "api_base": "https://my-byok.example"}]} + ], + }, + general_settings={"allow_client_side_credentials": True}, + llm_router=None, + model="gpt-4", + ) + is True + ) + + @pytest.mark.parametrize( + "fallback_field", + ["fallbacks", "context_window_fallbacks", "content_policy_fallbacks"], + ) + @pytest.mark.parametrize("surface", ["top_level", "router_settings_override"]) + def test_deeply_nested_api_base_smuggle_rejected_on_both_surfaces(self, fallback_field, surface): + nested = [ + { + "always-fail": [ + { + "model": "x", + fallback_field: [ + {"x": [{"model": "deepseek-chat", "api_base": "http://attacker"}]} + ], + } + ] + } + ] + request_body = {"model": "gpt-4"} + if surface == "top_level": + request_body[fallback_field] = nested + else: + request_body["router_settings_override"] = {fallback_field: nested} + with pytest.raises(ValueError, match="api_base"): + is_request_body_safe( + request_body=request_body, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + + def test_router_settings_override_single_level_api_base_rejected(self): + with pytest.raises(ValueError, match="api_base"): + is_request_body_safe( + request_body={ + "model": "gpt-4", + "router_settings_override": { + "fallbacks": [{"gpt-4": [{"model": "x", "api_base": "http://attacker"}]}] + }, + }, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + + def test_model_less_config_dict_api_base_rejected(self): + with pytest.raises(ValueError, match="api_base"): + is_request_body_safe( + request_body={ + "model": "gpt-4", + "fallbacks": [{"gpt-4": [{"api_base": "http://attacker"}]}], + }, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + + def test_nested_api_base_caught_across_router_fallback_rounds(self): + """An ``api_base`` target nested ``ROUTER_MAX_FALLBACKS - 1`` rounds deep + is still reached and rejected.""" + import litellm + + with pytest.raises(ValueError, match="api_base"): + is_request_body_safe( + request_body=_rounds_deep_api_base_payload(litellm.ROUTER_MAX_FALLBACKS - 1, "fallbacks"), + general_settings={}, + llm_router=None, + model="gpt-4", + ) + + def test_grouping_only_deep_chain_is_rejected_at_depth_limit(self): + """A deep grouping-only chain (``{"g": [{"g": [...]}]}``) is rejected at the + validation-depth limit rather than accepted or raising RecursionError.""" + node: object = ["safe-model"] + for _ in range(5000): + node = [{"grp": node}] + with pytest.raises(ValueError, match="depth"): + is_request_body_safe( + request_body={"model": "gpt-4", "fallbacks": node}, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + + def test_pathologically_deep_model_nesting_is_rejected(self): + with pytest.raises(ValueError, match="depth"): + is_request_body_safe( + request_body=_rounds_deep_api_base_payload(5000, "fallbacks"), + general_settings={}, + llm_router=None, + model="gpt-4", + ) + + +class TestIsRequestBodySafeRejectsUrlValuedFallback: + @pytest.mark.parametrize("fallback_field", ["fallbacks", "context_window_fallbacks", "content_policy_fallbacks"]) + def test_url_valued_string_fallback_is_rejected(self, fallback_field): + with pytest.raises(ValueError, match="URL-valued fallback"): + is_request_body_safe( + request_body={ + "model": "gpt-4", + fallback_field: [{"gpt-4": ["huggingface/http://attacker.example/path"]}], + }, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + + @pytest.mark.parametrize("fallback_field", ["fallbacks", "context_window_fallbacks", "content_policy_fallbacks"]) + def test_url_valued_dict_model_fallback_is_rejected(self, fallback_field): + with pytest.raises(ValueError, match="URL-valued fallback"): + is_request_body_safe( + request_body={ + "model": "gpt-4", + fallback_field: [{"gpt-4": [{"model": "huggingface/http://attacker.example/path"}]}], + }, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + + def test_ordinary_string_fallback_is_allowed(self): + assert ( + is_request_body_safe( + request_body={"model": "gpt-4", "fallbacks": [{"gpt-4": ["gpt-4-backup"]}]}, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + is True + ) + + def test_ordinary_dict_model_fallback_is_allowed(self): + assert ( + is_request_body_safe( + request_body={"model": "gpt-4", "fallbacks": [{"gpt-4": [{"model": "gpt-4-backup"}]}]}, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + is True + ) + class TestIsRequestBodySafeBlocksEndpointTargetingFields: """ @@ -1823,6 +2129,46 @@ class TestIsRequestBodySafeBlocksBedrockProjectOverride: ) +class TestIsRequestBodySafeBlocksVertexCredentialAlias: + @pytest.mark.parametrize("field", ["vertex_ai_credentials"]) + def test_field_in_request_body_is_rejected(self, field): + with pytest.raises(ValueError, match=field): + is_request_body_safe( + request_body={"model": "gpt-4", field: "attacker-supplied"}, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + + @pytest.mark.parametrize("field", ["vertex_ai_credentials"]) + def test_admin_opt_in_proxy_wide_allows(self, field): + assert ( + is_request_body_safe( + request_body={"model": "gpt-4", field: "byok-supplied"}, + general_settings={"allow_client_side_credentials": True}, + llm_router=None, + model="gpt-4", + ) + is True + ) + + def test_legitimate_request_body_param_still_allowed(self): + assert ( + is_request_body_safe( + request_body={ + "model": "gpt-4", + "temperature": 0.7, + "max_tokens": 128, + "user": "end-user-123", + }, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + is True + ) + + class TestIsRequestBodySafeBlocksNVCFFunctionOverride: """``nvcf_function_id`` is rejected as a request-body param unless the admin opted in proxy-wide or per-deployment.""" diff --git a/tests/test_litellm/proxy/auth/test_router_override_fallback_auth.py b/tests/test_litellm/proxy/auth/test_router_override_fallback_auth.py index fc0e9aec501..eb1135a240a 100644 --- a/tests/test_litellm/proxy/auth/test_router_override_fallback_auth.py +++ b/tests/test_litellm/proxy/auth/test_router_override_fallback_auth.py @@ -11,12 +11,22 @@ from unittest.mock import AsyncMock, patch import pytest from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.auth.auth_utils import iter_request_fallback_targets from litellm.proxy.auth.user_api_key_auth import ( _enforce_key_and_fallback_model_access, - iter_router_fallback_model_names, + _fallback_target_model_name, ) +def _fallback_model_names(fallbacks): + """Model names the auth check validates for a top-level ``fallbacks`` value.""" + return [ + name + for target in iter_request_fallback_targets({"fallbacks": fallbacks}) + if (name := _fallback_target_model_name(target)) is not None + ] + + def _key_with_models(models: List[str]) -> UserAPIKeyAuth: return UserAPIKeyAuth( api_key="hashed", @@ -26,37 +36,40 @@ def _key_with_models(models: List[str]) -> UserAPIKeyAuth: ) -# ── iter_router_fallback_model_names ───────────────────────────────────────── +# ── fallback model-name extraction ─────────────────────────────────────────── -def testiter_router_fallback_model_names_router_config_shape(): +def test_fallback_model_names_router_config_shape(): """Router-config shape: ``[{primary: [fallback_list]}]``.""" - assert list( - iter_router_fallback_model_names( - [{"gpt-3.5-turbo": ["gpt-4", "claude-3"]}, {"gpt-4o": ["o1"]}] - ) + assert _fallback_model_names( + [{"gpt-3.5-turbo": ["gpt-4", "claude-3"]}, {"gpt-4o": ["o1"]}] ) == ["gpt-4", "claude-3", "o1"] -def testiter_router_fallback_model_names_simple_string_shape(): +def test_fallback_model_names_simple_string_shape(): """Simple top-level shape: list of strings.""" - assert list(iter_router_fallback_model_names(["gpt-4", "claude-3"])) == [ + assert _fallback_model_names(["gpt-4", "claude-3"]) == ["gpt-4", "claude-3"] + + +def test_fallback_model_names_client_side_shape(): + """ClientSideFallbackModel shape: ``[{"model": "..."}]``.""" + assert _fallback_model_names([{"model": "gpt-4"}, {"model": "claude-3"}]) == [ "gpt-4", "claude-3", ] -def testiter_router_fallback_model_names_client_side_shape(): - """ClientSideFallbackModel shape: ``[{"model": "..."}]``.""" - assert list( - iter_router_fallback_model_names([{"model": "gpt-4"}, {"model": "claude-3"}]) - ) == ["gpt-4", "claude-3"] +def test_fallback_model_names_nested_deployment_fallbacks(): + """A deployment target's own nested fallback field is unrolled too.""" + assert _fallback_model_names( + [{"primary": [{"model": "gpt-4", "fallbacks": [{"gpt-4": ["deepseek-chat"]}]}]}] + ) == ["gpt-4", "deepseek-chat"] -def testiter_router_fallback_model_names_empty_or_none(): - assert list(iter_router_fallback_model_names(None)) == [] - assert list(iter_router_fallback_model_names([])) == [] - assert list(iter_router_fallback_model_names("not a list")) == [] +def test_fallback_model_names_empty_or_none(): + assert _fallback_model_names(None) == [] + assert _fallback_model_names([]) == [] + assert _fallback_model_names("not a list") == [] # ── _enforce_key_and_fallback_model_access ──────────────────────────────────── @@ -200,6 +213,98 @@ async def test_top_level_fallback_fields_validated(fallback_field): assert "top-level-smuggled" in seen +@pytest.mark.asyncio +async def test_nested_deployment_fallback_inner_model_validated(): + """A model name nested several fallback rounds deep, inside a deployment + target's own ``fallbacks``, is extracted and passed to can_key_call_model.""" + valid_token = _key_with_models(["gpt-3.5-turbo"]) + request_data = { + "model": "gpt-3.5-turbo", + "fallbacks": [ + { + "gpt-3.5-turbo": [ + { + "model": "gpt-3.5-turbo", + "fallbacks": [{"gpt-3.5-turbo": ["deep-smuggled-model"]}], + } + ] + } + ], + } + + seen: List[str] = [] + + async def fake_can_key_call_model(model, llm_model_list, valid_token, llm_router): + seen.append(model) + + with ( + patch( + "litellm.proxy.auth.user_api_key_auth.can_key_call_model", + side_effect=fake_can_key_call_model, + ), + patch( + "litellm.proxy.auth.user_api_key_auth.is_valid_fallback_model", + new=AsyncMock(), + ), + ): + await _enforce_key_and_fallback_model_access( + valid_token=valid_token, + request_data=request_data, + route="/v1/chat/completions", + request=None, + llm_model_list=None, + llm_router=None, + ) + + assert "deep-smuggled-model" in seen + + +@pytest.mark.asyncio +async def test_model_less_fallback_dict_is_skipped_never_passed_as_none(): + """A fallback target dict without a ``model`` key is skipped, never passed + as ``None`` into can_key_call_model / is_valid_fallback_model.""" + valid_token = _key_with_models(["gpt-3.5-turbo"]) + request_data = { + "model": "gpt-3.5-turbo", + "fallbacks": [ + { + "gpt-3.5-turbo": [ + {"model": "real-fallback"}, + {"api_base": "http://attacker"}, + "string-fallback", + ] + } + ], + } + + seen: List[str] = [] + + async def fake_can_key_call_model(model, llm_model_list, valid_token, llm_router): + seen.append(model) + + with ( + patch( + "litellm.proxy.auth.user_api_key_auth.can_key_call_model", + side_effect=fake_can_key_call_model, + ), + patch( + "litellm.proxy.auth.user_api_key_auth.is_valid_fallback_model", + new=AsyncMock(), + ), + ): + await _enforce_key_and_fallback_model_access( + valid_token=valid_token, + request_data=request_data, + route="/v1/chat/completions", + request=None, + llm_model_list=None, + llm_router=None, + ) + + assert None not in seen + assert seen == ["gpt-3.5-turbo", "real-fallback", "string-fallback"] + + @pytest.mark.asyncio async def test_router_override_without_fallbacks_does_not_break_auth(): """``router_settings_override`` set without any fallback fields is a diff --git a/tests/test_litellm/proxy/test_provider_url_destination_guard.py b/tests/test_litellm/proxy/test_provider_url_destination_guard.py index 51cd76105d0..c8771abbc8e 100644 --- a/tests/test_litellm/proxy/test_provider_url_destination_guard.py +++ b/tests/test_litellm/proxy/test_provider_url_destination_guard.py @@ -39,6 +39,46 @@ class TestRejectUrlValuedDestinations: assert exc_info.value.status_code == 400 assert exc_info.value.detail["param"] == "model" + def test_provider_prefixed_url_rejected(self): + with pytest.raises(HTTPException) as exc_info: + _reject_url_valued_destinations( + {"model": "huggingface/https://attacker.example/v1"} + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["param"] == "model" + + def test_comma_batch_smuggled_url_rejected(self): + with pytest.raises(HTTPException) as exc_info: + _reject_url_valued_destinations( + {"model": "gpt-4,huggingface/https://attacker.example/v1"} + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["param"] == "model" + + def test_provider_prefixed_uppercase_scheme_url_rejected(self): + with pytest.raises(HTTPException) as exc_info: + _reject_url_valued_destinations( + {"model": "huggingface/HTTPS://evil.example/v1"} + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["param"] == "model" + + def test_provider_prefixed_plain_model_passes(self): + _reject_url_valued_destinations({"model": "huggingface/BAAI/bge-small-en"}) + + def test_comma_batch_plain_models_pass(self): + _reject_url_valued_destinations({"model": "gpt-4,huggingface/BAAI/bge-small-en"}) + + def test_provider_prefixed_url_respects_allowlist(self, monkeypatch): + monkeypatch.setattr( + litellm, + "provider_url_destination_allowed_hosts", + ["trusted.example"], + ) + _reject_url_valued_destinations( + {"model": "huggingface/https://trusted.example/v1"} + ) + def test_url_valued_file_id_rejected(self): with pytest.raises(HTTPException) as exc_info: _reject_url_valued_destinations( From f91695105828b1f969bfa36cb47061469c77c3a9 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 5 Aug 2026 14:14:33 -0700 Subject: [PATCH 2/9] fix(proxy)!: share one destination check between body and path-supplied model The URL-destination check previously ran over request-body fields only. The per-field logic moves into reject_url_valued_destination(field, value) so a deployment name resolved from the request path runs the same check against the same admin allowlist. BREAKING CHANGE: a deployment name supplied in the request path that parses as an http/https destination is now refused. Add the host to `provider_url_destination_allowed_hosts` in litellm_settings to keep it working. (cherry picked from commit fc4be70a3714d442c1e30353336f2f9cdb6df662) --- litellm/proxy/common_request_processing.py | 8 ++- litellm/proxy/image_endpoints/endpoints.py | 4 ++ litellm/proxy/litellm_pre_call_utils.py | 50 +++++++++++-------- .../image_endpoints/test_azure_routes.py | 25 ++++++++++ .../test_provider_url_destination_guard.py | 13 +++++ 5 files changed, 78 insertions(+), 22 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 1dc0ee3f947..92564a0f727 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -68,7 +68,10 @@ if TYPE_CHECKING: ProxyConfig = _ProxyConfig else: ProxyConfig = Any -from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request +from litellm.proxy.litellm_pre_call_utils import ( + add_litellm_data_to_request, + reject_url_valued_destination, +) from litellm.types.utils import ( ModelResponse, ModelResponseStream, @@ -1183,6 +1186,9 @@ class ProxyBaseLLMRequestProcessing: self.data[_metadata_variable_name] = {} self.data[_metadata_variable_name]["queue_time_seconds"] = queue_time_seconds + if isinstance(model, str): + reject_url_valued_destination("model", model) + self.data["model"] = ( general_settings.get("completion_model", None) # server default or user_model # model name passed via cli args diff --git a/litellm/proxy/image_endpoints/endpoints.py b/litellm/proxy/image_endpoints/endpoints.py index 8178cad9038..918516d7e18 100644 --- a/litellm/proxy/image_endpoints/endpoints.py +++ b/litellm/proxy/image_endpoints/endpoints.py @@ -70,6 +70,7 @@ async def image_generation( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), model: Optional[str] = None, ): + from litellm.proxy.litellm_pre_call_utils import reject_url_valued_destination from litellm.proxy.proxy_server import ( add_litellm_data_to_request, general_settings, @@ -96,6 +97,9 @@ async def image_generation( proxy_config=proxy_config, ) + if isinstance(model, str): + reject_url_valued_destination("model", model) + data["model"] = ( model or general_settings.get("image_generation_model", None) # server default diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index bf574c66729..be46c9f856f 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -4,7 +4,7 @@ import json import re import time from collections import OrderedDict -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, Final, List, Optional, Union from fastapi import HTTPException, Request from pydantic import ValidationError as PydanticValidationError @@ -227,29 +227,37 @@ def _reject_url_valued_destinations(data: Dict[str, Any]) -> None: 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): + if isinstance(value, str): + reject_url_valued_destination(field, value) + + +def reject_url_valued_destination(field: str, value: str) -> None: + """Reject a URL-valued destination identifier unless admin-allowlisted. + + Operates on one field/value pair. ``_reject_url_valued_destinations`` applies + it across ``_URL_DESTINATION_REQUEST_FIELDS`` for a request body. + """ + allowed_hosts: Final = getattr(litellm, "provider_url_destination_allowed_hosts", []) or [] + for candidate in provider_url_destination_candidates(value): + if not candidate.lower().startswith(("http://", "https://")): continue - for candidate in provider_url_destination_candidates(value): - if not candidate.lower().startswith(("http://", "https://")): - continue - if is_url_destination_allowed_by_host(candidate, 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." - ), - }, - ) + if is_url_destination_allowed_by_host(candidate, 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( diff --git a/tests/test_litellm/proxy/image_endpoints/test_azure_routes.py b/tests/test_litellm/proxy/image_endpoints/test_azure_routes.py index 16fc6c19505..f5410ef0d70 100644 --- a/tests/test_litellm/proxy/image_endpoints/test_azure_routes.py +++ b/tests/test_litellm/proxy/image_endpoints/test_azure_routes.py @@ -120,3 +120,28 @@ def test_azure_image_edit_route(client_no_auth): assert called_kwargs["prompt"] == "A cute baby sea otter" assert response.status_code == 200 assert response.json()["data"] + + +def test_azure_image_generation_route_rejects_url_valued_path_model(client_no_auth): + """A URL-valued deployment segment is refused before any provider call.""" + client, mock_aimage_generation, _ = client_no_auth + response = client.post( + "/openai/deployments/oobabooga/https://example.invalid/images/generations", + json={"prompt": "A cute baby sea otter", "n": 1, "size": "1024x1024"}, + ) + + assert response.status_code == 400 + assert "URL-valued" in response.text + mock_aimage_generation.assert_not_called() + + +def test_azure_image_generation_route_allows_ordinary_path_model(client_no_auth): + """A deployment name that merely contains a provider prefix still routes.""" + client, mock_aimage_generation, _ = client_no_auth + response = client.post( + "/openai/deployments/dall-e-3/images/generations", + json={"prompt": "A cute baby sea otter", "n": 1, "size": "1024x1024"}, + ) + + assert response.status_code == 200 + mock_aimage_generation.assert_called_once() diff --git a/tests/test_litellm/proxy/test_provider_url_destination_guard.py b/tests/test_litellm/proxy/test_provider_url_destination_guard.py index c8771abbc8e..cd993a076e8 100644 --- a/tests/test_litellm/proxy/test_provider_url_destination_guard.py +++ b/tests/test_litellm/proxy/test_provider_url_destination_guard.py @@ -177,3 +177,16 @@ async def test_add_litellm_data_to_request_rejects_url_valued_model(): ) assert exc_info.value.status_code == 400 assert exc_info.value.detail["param"] == "model" + + +class TestNonStringDestinationValues: + """Only string identifiers are inspected. Anything else is left alone for the + request's normal validation to handle.""" + + @pytest.mark.parametrize("value", [123, None, True, {"a": 1}, ["x"], 1.5]) + def test_non_string_model_is_ignored(self, value): + _reject_url_valued_destinations({"model": value}) + + @pytest.mark.parametrize("value", [123, None, True, {"a": 1}, ["x"]]) + def test_non_string_file_id_is_ignored(self, value): + _reject_url_valued_destinations({"file_id": value}) From dde20e405cf06cb0a1ac5f75a43c26bebef13f38 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 5 Aug 2026 14:14:33 -0700 Subject: [PATCH 3/9] fix(proxy)!: parse bracket-notation form metadata the same way its JSON form is parsed Multipart callers express nested metadata as flat bracket-notation keys, which reach the request-body check as literal keys rather than as a metadata dict. The check now rebuilds them with the same helper the endpoints use, so both encodings are handled identically and cannot drift apart. BREAKING CHANGE: a multipart field such as `litellm_metadata[api_base]` is now subject to the same request-body parameter rules as its JSON equivalent. Set `general_settings.allow_client_side_credentials`, or the deployment's `configurable_clientside_auth_params`, to keep passing these. (cherry picked from commit 5b2c92d7491fd9a9867db20d45a7fd08e5fb4b25) --- litellm/proxy/auth/auth_utils.py | 8 ++ .../proxy/auth/test_auth_utils.py | 77 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index ecb37e67c14..2741a9e5cf9 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -19,6 +19,7 @@ from litellm.litellm_core_utils.url_utils import ( validate_url, ) from litellm.proxy._types import * +from litellm.proxy.common_utils.http_parsing_utils import extract_nested_form_metadata from litellm.types.passthrough_endpoints.pass_through_endpoints import ( LITELLM_PASS_THROUGH_ENDPOINT_MARKER, ) @@ -439,6 +440,13 @@ def is_request_body_safe(request_body: dict, general_settings: dict, llm_router: metadata = _coerce_metadata_to_dict(request_body.get(metadata_key)) if metadata is not None: _check_banned_params(metadata, general_settings, llm_router, model) + if any(isinstance(key, str) and key.startswith(f"{metadata_key}[") for key in request_body): + _check_banned_params( + extract_nested_form_metadata(form_data=request_body, prefix=f"{metadata_key}["), + general_settings, + llm_router, + model, + ) for target in iter_request_fallback_targets(request_body): if isinstance(target, dict): _check_banned_params(target, general_settings, llm_router, model) diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index 9f24c662581..01b1aa074d2 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -2913,3 +2913,80 @@ class TestGetKeyTagRateLimits: def test_returns_none_when_unset(self): key = UserAPIKeyAuth(api_key="sk-123") assert get_key_tag_rpm_limit(key) is None + + +class TestIsRequestBodySafeChecksBracketNotationMetadata: + """Bracket notation is how multipart callers express nested metadata; it is + validated the same way the dict form is.""" + + @pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) + def test_bracket_notation_banned_param_is_rejected(self, metadata_key): + with pytest.raises(ValueError, match="langfuse_host"): + is_request_body_safe( + request_body={ + "purpose": "assistants", + f"{metadata_key}[langfuse_host]": "https://example.invalid", + }, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + + def test_bracket_notation_api_base_is_rejected(self): + with pytest.raises(ValueError, match="api_base"): + is_request_body_safe( + request_body={"litellm_metadata[api_base]": "https://example.invalid"}, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + + def test_bracket_notation_allowed_under_proxy_wide_opt_in(self): + assert ( + is_request_body_safe( + request_body={"litellm_metadata[langfuse_host]": "https://byok.example"}, + general_settings={"allow_client_side_credentials": True}, + llm_router=None, + model="gpt-4", + ) + is True + ) + + def test_benign_bracket_notation_metadata_is_allowed(self): + assert ( + is_request_body_safe( + request_body={ + "purpose": "assistants", + "litellm_metadata[spend_logs_metadata][owner]": "john", + "litellm_metadata[tags]": "production", + }, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + is True + ) + + def test_bracket_notation_matches_json_encoding_for_deeper_nesting(self): + """A value nested below the first level is treated the same either way: + the check descends one level into metadata, for both encodings.""" + deep_bracket = { + "litellm_metadata[spend_logs_metadata][langfuse_host]": "https://example.invalid" + } + deep_json = { + "litellm_metadata": {"spend_logs_metadata": {"langfuse_host": "https://example.invalid"}} + } + kwargs = dict(general_settings={}, llm_router=None, model="gpt-4") + assert is_request_body_safe(request_body=deep_bracket, **kwargs) is True + assert is_request_body_safe(request_body=deep_json, **kwargs) is True + + def test_body_without_bracket_keys_is_unaffected(self): + assert ( + is_request_body_safe( + request_body={"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]}, + general_settings={}, + llm_router=None, + model="gpt-4", + ) + is True + ) From 2653829374bdcabace3923f59f9cf05b34354e38 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 5 Aug 2026 14:14:33 -0700 Subject: [PATCH 4/9] fix(health)!: let configured deployment parameters win over request overrides When a connection test names a model that resolves to a configured deployment, that deployment's routing and credential parameters are authoritative. A request supplying a complete connection of its own is unaffected. BREAKING CHANGE: /health/test_connection no longer lets a request replace the routing or credential parameters of a configured model it names. Supply the full connection parameters instead of naming a configured model. (cherry picked from commit e5effcb8619a22c4e739a20a453d674ee1b35edb) --- .../health_endpoints/_health_endpoints.py | 39 ++++++++++++++++++- .../health_endpoints/test_health_endpoints.py | 29 ++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 4250b9668ff..64a47d5fe81 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -6,7 +6,17 @@ import secrets import time import traceback from datetime import datetime, timedelta -from typing import Any, Dict, Iterable, Literal, Optional, TypedDict, Union, cast +from typing import ( + Any, + Dict, + Iterable, + Literal, + Mapping, + Optional, + TypedDict, + Union, + cast, +) import fastapi from fastapi import APIRouter, Depends, HTTPException, Request, Response, status @@ -28,6 +38,9 @@ from litellm.proxy._types import ( UserAPIKeyAuth, WebhookEvent, ) +from litellm.proxy.auth.auth_utils import ( + _BANNED_REQUEST_BODY_PARAMS, # pyright: ignore[reportPrivateUsage] # one canonical list, shared with the request-body check +) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.proxy.health_check import ( @@ -79,6 +92,27 @@ def _reject_os_environ_references(params: dict) -> None: stack.append(value) +def _reject_banned_param_overrides(request_params: Mapping[str, object]) -> None: + """Reject request params that would replace a configured deployment's routing or credentials. + + Applied only when a configured deployment supplies the base parameters. The + request may still adjust benign fields; routing and credential fields come + from the configuration. A caller who wants a fully custom connection supplies + the complete parameter set instead of naming a configured model. + """ + for param in _BANNED_REQUEST_BODY_PARAMS: + if param in request_params: + raise HTTPException( + status_code=400, + detail={ + "error": ( + f"{param} cannot be overridden when testing a configured model. " + "Provide the full connection parameters instead of naming a configured model." + ) + }, + ) + + def get_callback_identifier(callback): """ Get the callback identifier string, handling both strings and objects. @@ -1860,7 +1894,6 @@ async def test_model_connection( ) # Merge: config params (from proxy config) as base, request params override - # This allows users to override specific params while using config for credentials litellm_params = {**config_litellm_params, **request_litellm_params} ## Auth check @@ -1875,6 +1908,8 @@ async def test_model_connection( prisma_client=prisma_client, premium_user=premium_user, ) + if config_litellm_params: + _reject_banned_param_overrides(request_litellm_params) # Include health_check_params if provided litellm_params = _update_litellm_params_for_health_check( model_info={}, diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index 917bedcb93f..7280abd8ea5 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -2364,3 +2364,32 @@ def test_clean_endpoint_data_strips_credentials_keeps_routing_fields(): assert "aws_access_key_id" not in cleaned assert cleaned.get("api_base") == "https://example.test/v1" assert cleaned.get("api_version") == "2024-10-21" + + +class TestRejectBannedParamOverrides: + """Routing and credential fields come from the deployment configuration when a + request names a configured model; a request that wants its own connection + supplies the whole parameter set instead.""" + + def test_banned_param_is_refused(self): + from fastapi import HTTPException + + from litellm.proxy.health_endpoints._health_endpoints import ( + _reject_banned_param_overrides, + ) + + for param in ("api_base", "base_url", "vertex_credentials", "aws_web_identity_token"): + with pytest.raises(HTTPException) as exc_info: + _reject_banned_param_overrides({"model": "gpt-4o", param: "caller-supplied"}) + assert exc_info.value.status_code == 400 + assert param in str(exc_info.value.detail) + + def test_benign_params_are_allowed(self): + from litellm.proxy.health_endpoints._health_endpoints import ( + _reject_banned_param_overrides, + ) + + _reject_banned_param_overrides({}) + _reject_banned_param_overrides({"model": "gpt-4o"}) + _reject_banned_param_overrides({"model": "gpt-4o", "api_key": "sk-caller-owned"}) + _reject_banned_param_overrides({"mode": "chat", "timeout": 30}) From 2cf2e037c679c040fb251256d9bff994a8b36f77 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 5 Aug 2026 15:48:16 -0700 Subject: [PATCH 5/9] fix(health): stop inheriting configured credentials when a connection test sets its own A request that supplies its own connection fields describes a connection of its own, so the configured deployment's credentials are no longer merged underneath it. Anything the request leaves unset still comes from the configuration, so naming a configured model and testing it as configured is unchanged, and adding a second deployment for an already-configured name works as before. Replaces the earlier outright rejection, which also refused requests that supplied a complete connection of their own. (cherry picked from commit b468acb31c68b63584736f57a6f07f4fde2f8d6f) --- .../health_endpoints/_health_endpoints.py | 46 +++++++----- .../health_endpoints/test_health_endpoints.py | 73 ++++++++++++++----- 2 files changed, 79 insertions(+), 40 deletions(-) diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 64a47d5fe81..4d7e164136d 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta from typing import ( Any, Dict, + Final, Iterable, Literal, Mapping, @@ -55,6 +56,10 @@ from litellm.proxy.middleware.in_flight_requests_middleware import ( get_in_flight_requests, ) from litellm.proxy.shutdown.graceful_shutdown_manager import GracefulShutdownManager +from litellm.router_utils.clientside_credential_handler import ( + _ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE, # pyright: ignore[reportPrivateUsage] # one canonical list, shared with the router path + clientside_credential_keys, +) #### Health ENDPOINTS #### @@ -92,25 +97,25 @@ def _reject_os_environ_references(params: dict) -> None: stack.append(value) -def _reject_banned_param_overrides(request_params: Mapping[str, object]) -> None: - """Reject request params that would replace a configured deployment's routing or credentials. +_CONFIG_CONNECTION_FIELDS: Final[frozenset[str]] = frozenset( + (*_ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE, *clientside_credential_keys) +) - Applied only when a configured deployment supplies the base parameters. The - request may still adjust benign fields; routing and credential fields come - from the configuration. A caller who wants a fully custom connection supplies - the complete parameter set instead of naming a configured model. + +def _config_base_for_health_check( + config_params: Mapping[str, object], request_params: Mapping[str, object] +) -> dict[str, object]: + """Return the configured parameters to merge under a connection-test request. + + A request that sets its own connection fields describes a connection of its + own, so the configuration's credentials are not carried into it: they belong + to the endpoint the configuration names. Anything the request does not set + still comes from the configuration, which is what lets a request name a + configured model and test it as configured. """ - for param in _BANNED_REQUEST_BODY_PARAMS: - if param in request_params: - raise HTTPException( - status_code=400, - detail={ - "error": ( - f"{param} cannot be overridden when testing a configured model. " - "Provide the full connection parameters instead of naming a configured model." - ) - }, - ) + if not any(param in request_params for param in _BANNED_REQUEST_BODY_PARAMS): + return dict(config_params) + return {key: value for key, value in config_params.items() if key not in _CONFIG_CONNECTION_FIELDS} def get_callback_identifier(callback): @@ -1894,7 +1899,10 @@ async def test_model_connection( ) # Merge: config params (from proxy config) as base, request params override - litellm_params = {**config_litellm_params, **request_litellm_params} + litellm_params = { + **_config_base_for_health_check(config_litellm_params, request_litellm_params), + **request_litellm_params, + } ## Auth check auth_model_info = loaded_model_info if loaded_model_info is not None else model_info @@ -1908,8 +1916,6 @@ async def test_model_connection( prisma_client=prisma_client, premium_user=premium_user, ) - if config_litellm_params: - _reject_banned_param_overrides(request_litellm_params) # Include health_check_params if provided litellm_params = _update_litellm_params_for_health_check( model_info={}, diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index 7280abd8ea5..21a3e80de02 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -2366,30 +2366,63 @@ def test_clean_endpoint_data_strips_credentials_keeps_routing_fields(): assert cleaned.get("api_version") == "2024-10-21" -class TestRejectBannedParamOverrides: - """Routing and credential fields come from the deployment configuration when a - request names a configured model; a request that wants its own connection - supplies the whole parameter set instead.""" +class TestConfigBaseForHealthCheck: + """A request that sets its own connection fields gets a base without the + configuration's credentials; anything it leaves unset still comes from + the configuration.""" - def test_banned_param_is_refused(self): - from fastapi import HTTPException + CONFIG = { + "model": "openai/gpt-4o", + "api_key": "sk-configured", + "api_base": "https://configured.example/v1", + "vertex_credentials": "configured-creds", + "rpm": 100, + } + def _base(self, config, request): from litellm.proxy.health_endpoints._health_endpoints import ( - _reject_banned_param_overrides, + _config_base_for_health_check, ) - for param in ("api_base", "base_url", "vertex_credentials", "aws_web_identity_token"): - with pytest.raises(HTTPException) as exc_info: - _reject_banned_param_overrides({"model": "gpt-4o", param: "caller-supplied"}) - assert exc_info.value.status_code == 400 - assert param in str(exc_info.value.detail) + return _config_base_for_health_check(config, request) - def test_benign_params_are_allowed(self): - from litellm.proxy.health_endpoints._health_endpoints import ( - _reject_banned_param_overrides, + def test_request_without_connection_fields_inherits_config(self): + base = self._base(self.CONFIG, {"model": "openai/gpt-4o"}) + assert base["api_key"] == "sk-configured" + assert base["api_base"] == "https://configured.example/v1" + + def test_request_setting_api_base_does_not_inherit_config_credentials(self): + base = self._base(self.CONFIG, {"api_base": "https://caller.example/v1"}) + assert "api_key" not in base + assert "api_base" not in base + assert "vertex_credentials" not in base + assert base["rpm"] == 100 + + def test_add_model_flow_keeps_its_own_credentials(self): + """Adding a second deployment for an already-configured name sends a + complete connection; it is tested as sent, not as configured.""" + request = { + "model": "openai/gpt-4o", + "api_base": "https://new-deployment.example/v1", + "api_key": "sk-new-deployment", + } + merged = {**self._base(self.CONFIG, request), **request} + assert merged["api_base"] == "https://new-deployment.example/v1" + assert merged["api_key"] == "sk-new-deployment" + assert "sk-configured" not in str(merged) + + def test_destination_override_without_own_key_inherits_no_credential(self): + """A request that redirects the destination but supplies no credential + of its own gets none from the configuration.""" + request = {"api_base": "https://elsewhere.example"} + merged = {**self._base(self.CONFIG, request), **request} + assert "api_key" not in merged + assert "sk-configured" not in str(merged) + + def test_non_api_base_destination_field_also_drops_credentials(self): + base = self._base( + {**self.CONFIG, "aws_secret_access_key": "configured-secret"}, + {"aws_bedrock_runtime_endpoint": "https://caller.example"}, ) - - _reject_banned_param_overrides({}) - _reject_banned_param_overrides({"model": "gpt-4o"}) - _reject_banned_param_overrides({"model": "gpt-4o", "api_key": "sk-caller-owned"}) - _reject_banned_param_overrides({"mode": "chat", "timeout": 30}) + assert "api_key" not in base + assert "aws_secret_access_key" not in base From 2d88b52da67b299a6e0b8c492d3df9136c55ee89 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 5 Aug 2026 15:58:49 -0700 Subject: [PATCH 6/9] feat(health): let allow_client_side_credentials re-enable configured-credential reuse The proxy-wide opt-in that already governs callers supplying their own connection parameters now also governs whether a connection test may pair a request-supplied endpoint with the configured deployment's credentials. Off by default, which keeps configured credentials scoped to the endpoint the configuration names; on, the previous merge behaviour is available unchanged. (cherry picked from commit 59173c3a203daeeefe1b2b40d420166a38369d90) --- .../health_endpoints/_health_endpoints.py | 24 ++++++++++++++++--- .../health_endpoints/test_health_endpoints.py | 16 +++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 4d7e164136d..51d9d34c956 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -103,7 +103,9 @@ _CONFIG_CONNECTION_FIELDS: Final[frozenset[str]] = frozenset( def _config_base_for_health_check( - config_params: Mapping[str, object], request_params: Mapping[str, object] + config_params: Mapping[str, object], + request_params: Mapping[str, object], + allow_client_side_credentials: bool = False, ) -> dict[str, object]: """Return the configured parameters to merge under a connection-test request. @@ -112,7 +114,14 @@ def _config_base_for_health_check( to the endpoint the configuration names. Anything the request does not set still comes from the configuration, which is what lets a request name a configured model and test it as configured. + + ``general_settings.allow_client_side_credentials`` is the existing proxy-wide + opt-in for callers supplying their own connection parameters. Where an admin + has enabled it, a request may pair its own endpoint with the configured + credentials, as it could before. """ + if allow_client_side_credentials: + return dict(config_params) if not any(param in request_params for param in _BANNED_REQUEST_BODY_PARAMS): return dict(config_params) return {key: value for key, value in config_params.items() if key not in _CONFIG_CONNECTION_FIELDS} @@ -1830,7 +1839,12 @@ async def test_model_connection( from litellm.proxy.management_endpoints.model_management_endpoints import ( ModelManagementAuthChecks, ) - from litellm.proxy.proxy_server import llm_router, premium_user, prisma_client + from litellm.proxy.proxy_server import ( + general_settings, + llm_router, + premium_user, + prisma_client, + ) from litellm.types.router import Deployment, LiteLLM_Params try: @@ -1900,7 +1914,11 @@ async def test_model_connection( # Merge: config params (from proxy config) as base, request params override litellm_params = { - **_config_base_for_health_check(config_litellm_params, request_litellm_params), + **_config_base_for_health_check( + config_litellm_params, + request_litellm_params, + allow_client_side_credentials=general_settings.get("allow_client_side_credentials") is True, + ), **request_litellm_params, } diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index 21a3e80de02..cef2e643194 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -2379,12 +2379,14 @@ class TestConfigBaseForHealthCheck: "rpm": 100, } - def _base(self, config, request): + def _base(self, config, request, allow_client_side_credentials=False): from litellm.proxy.health_endpoints._health_endpoints import ( _config_base_for_health_check, ) - return _config_base_for_health_check(config, request) + return _config_base_for_health_check( + config, request, allow_client_side_credentials=allow_client_side_credentials + ) def test_request_without_connection_fields_inherits_config(self): base = self._base(self.CONFIG, {"model": "openai/gpt-4o"}) @@ -2426,3 +2428,13 @@ class TestConfigBaseForHealthCheck: ) assert "api_key" not in base assert "aws_secret_access_key" not in base + + def test_opt_in_restores_configured_credentials_under_a_request_endpoint(self): + """With general_settings.allow_client_side_credentials enabled, a request + may pair its own endpoint with the configured credentials, as before.""" + base = self._base( + self.CONFIG, + {"api_base": "https://caller.example/v1"}, + allow_client_side_credentials=True, + ) + assert base["api_key"] == "sk-configured" From d5efca5baf93f3753c61e53a01a065561f7235ea Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 5 Aug 2026 16:07:04 -0700 Subject: [PATCH 7/9] fix(health): drop a stored-credential reference along with the credentials it names A connection test that redirects the destination already leaves the configured credentials behind. It kept litellm_credential_name, which names the same stored secrets and is resolved further down the call, so the reference is now dropped with them. A request that sets no connection fields of its own is unaffected, which is how the Admin UI tests a configured model. (cherry picked from commit 298fb8ce56099774f310594c9970cc24a9b8f900) --- .../health_endpoints/_health_endpoints.py | 11 ++++++++++- .../health_endpoints/test_health_endpoints.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 51d9d34c956..d9bcd8c1a65 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -98,7 +98,11 @@ def _reject_os_environ_references(params: dict) -> None: _CONFIG_CONNECTION_FIELDS: Final[frozenset[str]] = frozenset( - (*_ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE, *clientside_credential_keys) + ( + *_ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE, + *clientside_credential_keys, + "litellm_credential_name", + ) ) @@ -115,6 +119,11 @@ def _config_base_for_health_check( still comes from the configuration, which is what lets a request name a configured model and test it as configured. + ``litellm_credential_name`` is dropped alongside the literal credential + fields: it names a stored credential that ``load_credentials_from_list`` + resolves into the same secrets further down the call, so leaving it in place + would reintroduce them by reference. + ``general_settings.allow_client_side_credentials`` is the existing proxy-wide opt-in for callers supplying their own connection parameters. Where an admin has enabled it, a request may pair its own endpoint with the configured diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index cef2e643194..f74aafd9df1 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -2438,3 +2438,22 @@ class TestConfigBaseForHealthCheck: allow_client_side_credentials=True, ) assert base["api_key"] == "sk-configured" + + def test_stored_credential_reference_is_dropped_with_the_credentials(self): + """A stored-credential name resolves to the same secrets downstream, so a + request that redirects the destination must not keep it either.""" + config = {**self.CONFIG, "litellm_credential_name": "OpenAI-prod"} + base = self._base(config, {"api_base": "https://caller.example/v1"}) + assert "litellm_credential_name" not in base + assert "api_key" not in base + + def test_stored_credential_reference_kept_when_request_sets_no_connection(self): + """The Admin UI tests a configured model by naming it plus its stored + credential and nothing else; that keeps working.""" + config = {**self.CONFIG, "litellm_credential_name": "OpenAI-prod"} + base = self._base( + config, + {"model": "openai/gpt-4o", "litellm_credential_name": "OpenAI-prod", "custom_llm_provider": "openai"}, + ) + assert base["litellm_credential_name"] == "OpenAI-prod" + assert base["api_key"] == "sk-configured" From ab3b2fce4836a4e3ab8289e25969e838ff0f6ee1 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 8 Aug 2026 13:54:25 -0700 Subject: [PATCH 8/9] =?UTF-8?q?bump:=20version=201.94.2=20=E2=86=92=201.94?= =?UTF-8?q?.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7a5554789a3..21e8380725b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm" -version = "1.94.2" +version = "1.94.3" description = "Library to easily interface with LLM API providers" readme = "README.md" requires-python = ">=3.10, <3.15" @@ -290,7 +290,7 @@ members = ["enterprise", "litellm-proxy-extras"] profile = "black" [tool.commitizen] -version = "1.94.2" +version = "1.94.3" version_files = [ "pyproject.toml:^version", ] From be476026c6d67036d7a94288127573c2e90a66c5 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 8 Aug 2026 13:54:56 -0700 Subject: [PATCH 9/9] chore: refresh uv.lock for 1.94.3 --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 901762e3580..d645dc31268 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-08-05T01:30:38.270006Z" +exclude-newer = "2026-08-05T20:54:30.773513Z" exclude-newer-span = "P3D" [manifest] @@ -3966,7 +3966,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.94.2" +version = "1.94.3" source = { editable = "." } dependencies = [ { name = "aiohttp" },