Merge branch 'BerriAI:main' into bugfix-14404-image-gen-azure-managed-identity

This commit is contained in:
shagunb-acn 2025-09-24 16:28:45 +05:30 • committed by GitHub
commit 47d59a372c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 655 additions and 50 deletions

View file

@ -1521,6 +1521,7 @@ jobs:
- run: python ./tests/code_coverage_tests/prevent_key_leaks_in_exceptions.py
- run: python ./tests/code_coverage_tests/check_unsafe_enterprise_import.py
- run: python ./tests/code_coverage_tests/ban_copy_deepcopy_kwargs.py
- run: python ./tests/code_coverage_tests/check_fastuuid_usage.py
- run: helm lint ./deploy/charts/litellm-helm
db_migration_disable_update_check:

23
litellm/_uuid.py Normal file
View file

@ -0,0 +1,23 @@
"""
Internal unified UUID helper.
Tries to use fastuuid (performance) and falls back to stdlib uuid if unavailable.
"""
FASTUUID_AVAILABLE = False
try:
import fastuuid as _uuid # type: ignore
FASTUUID_AVAILABLE = True
except Exception: # pragma: no cover - fallback path
import uuid as _uuid # type: ignore
# Expose a module-like alias so callers can use: uuid.uuid4()
uuid = _uuid
def uuid4():
"""Return a UUID4 using the selected backend."""
return uuid.uuid4()

View file

@ -26,7 +26,6 @@ from typing import (
cast,
)
import fastuuid as uuid
from httpx import Response
from pydantic import BaseModel
@ -38,6 +37,7 @@ from litellm import (
turn_off_message_logging,
)
from litellm._logging import _is_debugging_on, verbose_logger
from litellm._uuid import uuid
from litellm.batches.batch_utils import _handle_completed_batch
from litellm.caching.caching import DualCache, InMemoryCache
from litellm.caching.caching_handler import LLMCachingHandler

View file

@ -1619,11 +1619,12 @@ class CustomStreamWrapper:
completion_start_time=datetime.datetime.now()
)
## LOGGING
executor.submit(
self.run_success_logging_and_cache_storage,
response,
cache_hit,
) # log response
if not litellm.disable_streaming_logging:
executor.submit(
self.run_success_logging_and_cache_storage,
response,
cache_hit,
) # log response
choice = response.choices[0]
if isinstance(choice, StreamingChoices):
self.response_uptil_now += choice.delta.get("content", "") or ""

View file

@ -804,7 +804,15 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
if content.get("citations") is not None:
if citations is None:
citations = []
citations.append(content["citations"])
citations.append(
[
{
**citation,
"supported_text": content.get("text", ""),
}
for citation in content["citations"]
]
)
if thinking_blocks is not None:
reasoning_content = ""
for block in thinking_blocks:

View file

@ -774,7 +774,7 @@ class CommonBatchFilesUtils:
Returns:
Unique job name (≤ 63 characters for Bedrock compatibility)
"""
import fastuuid as uuid
from litellm._uuid import uuid
unique_id = str(uuid.uuid4())[:8]
# Format: {prefix}-batch-{model}-{uuid}
# Example: litellm-batch-claude-266c398e

View file

@ -935,7 +935,7 @@ class HTTPHandler:
if litellm.force_ipv4:
return HTTPTransport(local_address="0.0.0.0")
else:
return None
return getattr(litellm, 'sync_transport', None)
def get_async_httpx_client(

View file

@ -14,7 +14,6 @@ from typing import (
Union,
)
import fastuuid as uuid
import httpx
import orjson
from fastapi import HTTPException, Request, status
@ -22,6 +21,7 @@ from fastapi.responses import Response, StreamingResponse
import litellm
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.constants import (
DD_TRACER_STREAMING_CHUNK_YIELD_RESOURCE,
STREAM_SSE_DATA_PREFIX,

View file

@ -459,6 +459,14 @@ async def anthropic_proxy_route(
region_name=None,
)
custom_headers = {}
if (
"authorization" not in request.headers
and "x-api-key" not in request.headers
and anthropic_api_key is not None
):
custom_headers["x-api-key"] = "{}".format(anthropic_api_key)
## check for streaming
is_streaming_request = await is_streaming_request_fn(request)
@ -466,7 +474,7 @@ async def anthropic_proxy_route(
endpoint_func = create_pass_through_route(
endpoint=endpoint,
target=str(updated_url),
custom_headers={"x-api-key": "{}".format(anthropic_api_key)},
custom_headers=custom_headers,
_forward_headers=True,
) # dynamically construct pass-through endpoint based on incoming path
received_value = await endpoint_func(

View file

@ -35,6 +35,7 @@ from litellm.constants import (
LITELLM_SETTINGS_SAFE_DB_OVERRIDES,
)
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.utils import load_credentials_from_list
from litellm.types.utils import (
ModelResponse,
ModelResponseStream,
@ -5894,6 +5895,7 @@ async def token_counter(request: TokenCountRequest, call_endpoint: bool = False)
pass
if deployment is not None:
litellm_model_name = deployment.get("litellm_params", {}).get("model")
load_credentials_from_list(deployment.get("litellm_params", {}))
# remove the custom_llm_provider_prefix in the litellm_model_name
if "/" in litellm_model_name:
litellm_model_name = litellm_model_name.split("/", 1)[1]

View file

@ -493,21 +493,32 @@ async def update_sso_settings(sso_config: SSOConfig):
config["general_settings"] = {}
# Update environment variables in config and in memory
sso_data = sso_config.model_dump(exclude_none=True)
sso_data = sso_config.model_dump()
for field_name, value in sso_data.items():
if field_name == "user_email" and value is not None:
# Store user_email in general_settings instead of environment variables
config["general_settings"]["proxy_admin_email"] = value
elif field_name == "ui_access_mode" and value is not None:
config["general_settings"]["ui_access_mode"] = value
elif field_name in env_var_mapping and value is not None:
if field_name == "user_email":
if value:
# Store user_email in general_settings instead of environment variables
config["general_settings"]["proxy_admin_email"] = value
else:
# Clear user_email if null/empty
config["general_settings"].pop("proxy_admin_email", None)
elif field_name == "ui_access_mode":
if value:
config["general_settings"]["ui_access_mode"] = value
else:
# Clear ui_access_mode if null/empty
config["general_settings"].pop("ui_access_mode", None)
elif field_name in env_var_mapping and value:
env_var_name = env_var_mapping[field_name]
# Update in config
config["environment_variables"][env_var_name] = value
# Update in runtime environment
os.environ[env_var_name] = value
elif field_name in env_var_mapping:
# Clear environment variable if value is null/empty
env_var_name = env_var_mapping[field_name]
config["environment_variables"].pop(env_var_name, None)
os.environ.pop(env_var_name, None)
stored_config = config
if len(config["environment_variables"]) > 0:

View file

@ -13,7 +13,6 @@ from typing import (
Union,
)
import fastuuid as uuid
from aiohttp import FormData
from openai._models import BaseModel as OpenAIObject
from openai.types.audio.transcription_create_params import FileTypes # type: ignore
@ -33,6 +32,7 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
from typing_extensions import Callable, Dict, Required, TypedDict, override
import litellm
from litellm._uuid import uuid
from litellm.types.llms.base import (
BaseLiteLLMOpenAIResponseObject,
LiteLLMPydanticObjectBase,

View file

@ -40,7 +40,6 @@ from os.path import abspath, dirname, join
import aiohttp
import dotenv
import fastuuid as uuid
import httpx
import openai
import tiktoken
@ -59,6 +58,7 @@ import litellm.litellm_core_utils.audio_utils.utils
import litellm.litellm_core_utils.json_validation_rule
import litellm.llms
import litellm.llms.gemini
from litellm._uuid import uuid
from litellm.caching._internal_lru_cache import lru_cache_wrapper
from litellm.caching.caching import DualCache
from litellm.caching.caching_handler import CachingHandlerResponse, LLMCachingHandler

13
poetry.lock generated
View file

@ -555,7 +555,7 @@ description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
groups = ["main", "dev", "proxy-dev"]
markers = "python_version < \"3.14\" and platform_python_implementation != \"PyPy\""
markers = "python_version < \"3.10\" and platform_python_implementation != \"PyPy\""
files = [
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
@ -636,7 +636,7 @@ description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.9"
groups = ["main", "dev", "proxy-dev"]
markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""
markers = "python_version >= \"3.10\" and platform_python_implementation != \"PyPy\""
files = [
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
@ -1339,9 +1339,10 @@ zstandard = ["zstandard"]
name = "fastuuid"
version = "0.12.0"
description = "Python bindings to Rust's UUID library."
optional = false
optional = true
python-versions = ">=3.8"
groups = ["main"]
markers = "extra == \"proxy\""
files = [
{file = "fastuuid-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:22a900ef0956aacf862b460e20541fdae2d7c340594fe1bd6fdcb10d5f0791a9"},
{file = "fastuuid-0.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0302f5acf54dc75de30103025c5a95db06d6c2be36829043a0aa16fc170076bc"},
@ -4361,7 +4362,7 @@ description = "C parser in Python"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev", "proxy-dev"]
markers = "platform_python_implementation != \"PyPy\" and (python_version < \"3.14\" or implementation_name != \"PyPy\")"
markers = "platform_python_implementation != \"PyPy\" and (implementation_name != \"PyPy\" or python_version < \"3.10\")"
files = [
{file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
{file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
@ -6746,11 +6747,11 @@ type = ["pytest-mypy"]
caching = ["diskcache"]
extra-proxy = ["azure-identity", "azure-keyvault-secrets", "google-cloud-iam", "google-cloud-kms", "prisma", "redisvl", "resend"]
mlflow = ["mlflow"]
proxy = ["PyJWT", "apscheduler", "azure-identity", "azure-storage-blob", "backoff", "boto3", "cryptography", "fastapi", "fastapi-sso", "gunicorn", "litellm-enterprise", "litellm-proxy-extras", "mcp", "orjson", "polars", "pynacl", "python-multipart", "pyyaml", "rich", "rq", "uvicorn", "uvloop", "websockets"]
proxy = ["PyJWT", "apscheduler", "azure-identity", "azure-storage-blob", "backoff", "boto3", "cryptography", "fastapi", "fastapi-sso", "fastuuid", "gunicorn", "litellm-enterprise", "litellm-proxy-extras", "mcp", "orjson", "polars", "pynacl", "python-multipart", "pyyaml", "rich", "rq", "uvicorn", "uvloop", "websockets"]
semantic-router = ["semantic-router"]
utils = ["numpydoc"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.8.1,<4.0, !=3.9.7"
content-hash = "dcec654bc4b233d2f0d160341d8adf1ba2017ccb74c104bff9ba4cf027ba0186"
content-hash = "75004c6a23b70be86622fa417fd0d62fa3843e6e61c8dff8507ae5c967b7205d"

View file

@ -20,7 +20,6 @@ Documentation = "https://docs.litellm.ai"
[tool.poetry.dependencies]
python = ">=3.8.1,<4.0, !=3.9.7"
fastuuid = ">=0.12.0"
httpx = ">=0.23.0"
openai = ">=1.99.5"
python-dotenv = ">=0.2.0"
@ -34,6 +33,7 @@ pydantic = "^2.5.0"
jsonschema = "^4.22.0"
pondpond = "^1.4.1"
numpydoc = {version = "*", optional = true} # used in utils.py
fastuuid = {version = ">=0.12.0", optional = true}
uvicorn = {version = "^0.29.0", optional = true}
uvloop = {version = "^0.21.0", optional = true, markers="sys_platform != 'win32'"}
@ -93,6 +93,7 @@ proxy = [
"litellm-enterprise",
"rich",
"polars",
"fastuuid",
]
extra_proxy = [
@ -115,6 +116,7 @@ semantic-router = ["semantic-router"]
mlflow = ["mlflow"]
[tool.isort]
profile = "black"

View file

@ -0,0 +1,87 @@
import ast
import os
from typing import List, Dict, Any
ALLOWED_FILE = os.path.normpath("litellm/_uuid.py")
def _to_module_path(relative_path: str) -> str:
module = os.path.splitext(relative_path)[0].replace(os.sep, ".")
if module.endswith(".__init__"):
return module[: -len(".__init__")]
return module
def _find_fastuuid_imports_in_file(
file_path: str, base_dir: str
) -> List[Dict[str, Any]]:
results: List[Dict[str, Any]] = []
try:
with open(file_path, "r", encoding="utf-8") as f:
source = f.read()
tree = ast.parse(source, filename=file_path)
except Exception:
return results
relative = os.path.normpath(os.path.relpath(file_path, base_dir))
if relative == ALLOWED_FILE:
return results
module = _to_module_path(relative)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name == "fastuuid":
results.append(
{
"file": relative,
"line": getattr(node, "lineno", 0),
"import": f"import {alias.name}",
"module": module,
}
)
elif isinstance(node, ast.ImportFrom) and node.module == "fastuuid":
names = ", ".join([a.name for a in node.names])
results.append(
{
"file": relative,
"line": getattr(node, "lineno", 0),
"import": f"from fastuuid import {names}",
"module": module,
}
)
return results
def scan_directory_for_fastuuid(base_dir: str) -> List[Dict[str, Any]]:
violations: List[Dict[str, Any]] = []
scan_root = os.path.join(base_dir, "litellm")
for root, _, files in os.walk(scan_root):
for filename in files:
if filename.endswith(".py"):
file_path = os.path.join(root, filename)
violations.extend(_find_fastuuid_imports_in_file(file_path, base_dir))
return violations
def main() -> None:
base_dir = "." # tests run from repo root in CI
violations = scan_directory_for_fastuuid(base_dir)
if violations:
print(
"\n🚨 fastuuid must only be imported inside litellm/_uuid.py. Found violations:"
)
for v in violations:
print(f"* {v['module']} ({v['file']}:{v['line']}) -> {v['import']}")
print("\n")
raise Exception(
"Found fastuuid imports outside litellm/_uuid.py. Use litellm._uuid.uuid or litellm._uuid.uuid4 instead."
)
else:
print("✅ No invalid fastuuid imports found.")
if __name__ == "__main__":
main()

View file

@ -256,7 +256,6 @@ def test_anthropic_tool_streaming():
for chunk in anthropic_chunk_list:
parsed_chunk = response_iter.chunk_parser(chunk)
if tool_use := parsed_chunk.get("tool_use"):
# We only increment when a new block starts
if tool_use.get("id") is not None:
correct_tool_index += 1
@ -920,6 +919,14 @@ def test_anthropic_citations_api():
citations = resp.choices[0].message.provider_specific_fields["citations"]
assert citations is not None
if citations:
citation = citations[0][0]
assert "supported_text" in citation
assert "cited_text" in citation
assert "document_index" in citation
assert "document_title" in citation
assert "start_char_index" in citation
assert "end_char_index" in citation
def test_anthropic_citations_api_streaming():
@ -955,11 +962,9 @@ def test_anthropic_citations_api_streaming():
has_citations = False
for chunk in resp:
print(f"returned chunk: {chunk}")
if (
chunk.choices[0].delta.provider_specific_fields
and "citation" in chunk.choices[0].delta.provider_specific_fields
):
has_citations = True
if provider_specific_fields := chunk.choices[0].delta.provider_specific_fields:
if "citation" in provider_specific_fields:
has_citations = True
assert has_citations

View file

@ -183,7 +183,30 @@ def test_extract_response_content_with_citations():
}
_, citations, _, _, _ = config.extract_response_content(completion_response)
assert citations is not None
assert citations == [
[
{
"type": "char_location",
"cited_text": "The grass is green. ",
"document_index": 0,
"document_title": "My Document",
"start_char_index": 0,
"end_char_index": 20,
"supported_text": "the grass is green",
},
],
[
{
"type": "char_location",
"cited_text": "The sky is blue.",
"document_index": 0,
"document_title": "My Document",
"start_char_index": 20,
"end_char_index": 36,
"supported_text": "the sky is blue",
},
],
]
def test_map_tool_helper():

View file

@ -0,0 +1,218 @@
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
anthropic_proxy_route,
)
class TestAnthropicAuthHeaders:
"""Test authentication header handling in anthropic_proxy_route."""
@pytest.fixture
def mock_request(self):
"""Create a mock request object."""
request = MagicMock()
request.method = "POST"
request.headers = {}
return request
@pytest.fixture
def mock_response(self):
"""Create a mock FastAPI response object."""
return MagicMock()
@pytest.fixture
def mock_user_api_key_dict(self):
"""Create a mock user API key dict."""
return {"user_id": "test_user"}
@pytest.mark.asyncio
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route")
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_streaming_request_fn")
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router")
async def test_client_authorization_header_priority(
self,
mock_router,
mock_streaming,
mock_create_route,
mock_request,
mock_response,
mock_user_api_key_dict,
):
"""Test that client Authorization header takes priority over server key."""
# Setup
mock_request.headers = {"authorization": "Bearer client-key-123"}
mock_router.get_credentials.return_value = "server-key-456"
mock_streaming.return_value = False
mock_endpoint_func = AsyncMock(return_value="test_response")
mock_create_route.return_value = mock_endpoint_func
# Act
await anthropic_proxy_route(
endpoint="v1/messages",
request=mock_request,
fastapi_response=mock_response,
user_api_key_dict=mock_user_api_key_dict,
)
# Assert
mock_create_route.assert_called_once()
call_kwargs = mock_create_route.call_args[1]
assert call_kwargs["custom_headers"] == {}
assert call_kwargs["_forward_headers"] is True
@pytest.mark.asyncio
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route")
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_streaming_request_fn")
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router")
async def test_client_x_api_key_header_priority(
self,
mock_router,
mock_streaming,
mock_create_route,
mock_request,
mock_response,
mock_user_api_key_dict,
):
"""Test that client x-api-key header takes priority over server key."""
# Setup
mock_request.headers = {"x-api-key": "client-x-api-key-123"}
mock_router.get_credentials.return_value = "server-key-456"
mock_streaming.return_value = False
mock_endpoint_func = AsyncMock(return_value="test_response")
mock_create_route.return_value = mock_endpoint_func
# Act
await anthropic_proxy_route(
endpoint="v1/messages",
request=mock_request,
fastapi_response=mock_response,
user_api_key_dict=mock_user_api_key_dict,
)
# Assert
mock_create_route.assert_called_once()
call_kwargs = mock_create_route.call_args[1]
assert call_kwargs["custom_headers"] == {}
assert call_kwargs["_forward_headers"] is True
@pytest.mark.asyncio
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route")
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_streaming_request_fn")
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router")
async def test_server_api_key_fallback(
self,
mock_router,
mock_streaming,
mock_create_route,
mock_request,
mock_response,
mock_user_api_key_dict,
):
"""Test that server API key is used when no client authentication is provided."""
# Setup
mock_request.headers = {} # No authentication headers
mock_router.get_credentials.return_value = "server-key-456"
mock_streaming.return_value = False
mock_endpoint_func = AsyncMock(return_value="test_response")
mock_create_route.return_value = mock_endpoint_func
# Act
await anthropic_proxy_route(
endpoint="v1/messages",
request=mock_request,
fastapi_response=mock_response,
user_api_key_dict=mock_user_api_key_dict,
)
# Assert
mock_create_route.assert_called_once()
call_kwargs = mock_create_route.call_args[1]
assert call_kwargs["custom_headers"] == {"x-api-key": "server-key-456"}
assert call_kwargs["_forward_headers"] is True
@pytest.mark.asyncio
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route")
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_streaming_request_fn")
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router")
async def test_no_authentication_available(
self,
mock_router,
mock_streaming,
mock_create_route,
mock_request,
mock_response,
mock_user_api_key_dict,
):
"""Test that no x-api-key header is added when no authentication is available."""
# Setup
mock_request.headers = {} # No authentication headers
mock_router.get_credentials.return_value = None # No server key
mock_streaming.return_value = False
mock_endpoint_func = AsyncMock(return_value="test_response")
mock_create_route.return_value = mock_endpoint_func
# Act
await anthropic_proxy_route(
endpoint="v1/messages",
request=mock_request,
fastapi_response=mock_response,
user_api_key_dict=mock_user_api_key_dict,
)
# Assert
mock_create_route.assert_called_once()
call_kwargs = mock_create_route.call_args[1]
assert call_kwargs["custom_headers"] == {}
assert call_kwargs["_forward_headers"] is True
@pytest.mark.asyncio
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route")
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.is_streaming_request_fn")
@patch("litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router")
async def test_both_client_headers_present(
self,
mock_router,
mock_streaming,
mock_create_route,
mock_request,
mock_response,
mock_user_api_key_dict,
):
"""Test that no server key is added when client has both auth headers."""
# Setup
mock_request.headers = {
"authorization": "Bearer client-auth-key",
"x-api-key": "client-x-api-key"
}
mock_router.get_credentials.return_value = "server-key-456"
mock_streaming.return_value = False
mock_endpoint_func = AsyncMock(return_value="test_response")
mock_create_route.return_value = mock_endpoint_func
# Act
await anthropic_proxy_route(
endpoint="v1/messages",
request=mock_request,
fastapi_response=mock_response,
user_api_key_dict=mock_user_api_key_dict,
)
# Assert
mock_create_route.assert_called_once()
call_kwargs = mock_create_route.call_args[1]
assert call_kwargs["custom_headers"] == {}
assert call_kwargs["_forward_headers"] is True

View file

@ -322,3 +322,207 @@ class TestProxySettingEndpoints:
# Verify save_config was called exactly once
assert mock_proxy_config["save_call_count"]() == 1
def test_update_sso_settings_with_null_values_clears_env_vars(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""Test that updating SSO settings with null values clears environment variables"""
monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
# First, verify we have existing environment variables
initial_config = mock_proxy_config["config"]
assert "GOOGLE_CLIENT_ID" in initial_config["environment_variables"]
assert "MICROSOFT_CLIENT_ID" in initial_config["environment_variables"]
# Set some initial environment variables for runtime testing
monkeypatch.setenv("GOOGLE_CLIENT_ID", "test_existing_google_id")
monkeypatch.setenv("MICROSOFT_CLIENT_ID", "test_existing_microsoft_id")
# Send SSO settings with null values to clear them
clear_sso_settings = {
"google_client_id": None,
"google_client_secret": None,
"microsoft_client_id": None,
"microsoft_client_secret": None,
"microsoft_tenant": None,
"generic_client_id": None,
"generic_client_secret": None,
"generic_authorization_endpoint": None,
"generic_token_endpoint": None,
"generic_userinfo_endpoint": None,
"proxy_base_url": None,
"user_email": None,
"sso_provider": None,
}
response = client.patch("/update/sso_settings", json=clear_sso_settings)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
# Verify that environment variables were cleared from config
updated_config = mock_proxy_config["config"]
# These should be removed from environment_variables
assert "GOOGLE_CLIENT_ID" not in updated_config["environment_variables"]
assert "GOOGLE_CLIENT_SECRET" not in updated_config["environment_variables"]
assert "MICROSOFT_CLIENT_ID" not in updated_config["environment_variables"]
assert "MICROSOFT_CLIENT_SECRET" not in updated_config["environment_variables"]
assert "MICROSOFT_TENANT" not in updated_config["environment_variables"]
assert "PROXY_BASE_URL" not in updated_config["environment_variables"]
# Verify that runtime environment variables were cleared
assert "GOOGLE_CLIENT_ID" not in os.environ
assert "MICROSOFT_CLIENT_ID" not in os.environ
# Verify user_email was cleared from general_settings
assert updated_config["general_settings"].get("proxy_admin_email") is None
# Verify save_config was called
assert mock_proxy_config["save_call_count"]() == 1
def test_update_sso_settings_with_empty_strings_clears_env_vars(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""Test that updating SSO settings with empty strings also clears environment variables"""
monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
# Set some initial environment variables for runtime testing
monkeypatch.setenv("GOOGLE_CLIENT_ID", "test_existing_google_id")
monkeypatch.setenv("MICROSOFT_CLIENT_SECRET", "test_existing_microsoft_secret")
# Send SSO settings with empty strings to clear them
clear_sso_settings = {
"google_client_id": "",
"google_client_secret": "",
"microsoft_client_secret": "",
"proxy_base_url": "",
"user_email": "",
}
response = client.patch("/update/sso_settings", json=clear_sso_settings)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
# Verify that environment variables with empty strings were cleared from config
updated_config = mock_proxy_config["config"]
assert "GOOGLE_CLIENT_ID" not in updated_config["environment_variables"]
assert "GOOGLE_CLIENT_SECRET" not in updated_config["environment_variables"]
assert "MICROSOFT_CLIENT_SECRET" not in updated_config["environment_variables"]
assert "PROXY_BASE_URL" not in updated_config["environment_variables"]
# Verify that runtime environment variables were cleared
assert "GOOGLE_CLIENT_ID" not in os.environ
assert "MICROSOFT_CLIENT_SECRET" not in os.environ
# Verify user_email was cleared from general_settings
assert updated_config["general_settings"].get("proxy_admin_email") is None
# Verify save_config was called
assert mock_proxy_config["save_call_count"]() == 1
def test_update_sso_settings_mixed_null_and_valid_values(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""Test updating SSO settings with mix of null and valid values"""
monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
# Set some initial environment variables
monkeypatch.setenv("GOOGLE_CLIENT_ID", "old_google_id")
monkeypatch.setenv("MICROSOFT_CLIENT_ID", "old_microsoft_id")
monkeypatch.setenv("PROXY_BASE_URL", "old_proxy_url")
# Send mixed SSO settings - some null, some valid
mixed_sso_settings = {
"google_client_id": "new_google_client_id", # Valid value
"google_client_secret": None, # Null to clear
"microsoft_client_id": None, # Null to clear
"microsoft_client_secret": "new_microsoft_secret", # Valid value
"proxy_base_url": "https://newproxy.com", # Valid value
"user_email": None, # Null to clear
}
response = client.patch("/update/sso_settings", json=mixed_sso_settings)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
# Verify the config was updated correctly
updated_config = mock_proxy_config["config"]
# Valid values should be set
assert (
updated_config["environment_variables"]["GOOGLE_CLIENT_ID"]
!= "new_google_client_id"
) # Encrypted
assert (
updated_config["environment_variables"]["MICROSOFT_CLIENT_SECRET"]
!= "new_microsoft_secret"
) # Encrypted
assert (
updated_config["environment_variables"]["PROXY_BASE_URL"]
!= "https://newproxy.com"
) # Encrypted
# Null values should be cleared
assert "GOOGLE_CLIENT_SECRET" not in updated_config["environment_variables"]
assert "MICROSOFT_CLIENT_ID" not in updated_config["environment_variables"]
# Verify runtime environment variables
assert os.environ.get("GOOGLE_CLIENT_ID") == "new_google_client_id"
assert os.environ.get("MICROSOFT_CLIENT_SECRET") == "new_microsoft_secret"
assert "GOOGLE_CLIENT_SECRET" not in os.environ
assert "MICROSOFT_CLIENT_ID" not in os.environ
# Verify user_email was cleared from general_settings
assert updated_config["general_settings"].get("proxy_admin_email") is None
# Verify save_config was called
assert mock_proxy_config["save_call_count"]() == 1
def test_update_sso_settings_ui_access_mode_handling(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""Test that ui_access_mode is handled correctly in general_settings"""
monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
# Test setting ui_access_mode
sso_settings_with_ui_mode = {
"ui_access_mode": "admin_only",
"user_email": "admin@test.com",
}
response = client.patch("/update/sso_settings", json=sso_settings_with_ui_mode)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
# Verify ui_access_mode was set in general_settings (not environment_variables)
updated_config = mock_proxy_config["config"]
assert updated_config["general_settings"]["ui_access_mode"] == "admin_only"
assert (
updated_config["general_settings"]["proxy_admin_email"] == "admin@test.com"
)
# Verify ui_access_mode is NOT in environment_variables
assert "ui_access_mode" not in updated_config["environment_variables"]
# Test clearing ui_access_mode
clear_ui_mode = {"ui_access_mode": None, "user_email": None}
response = client.patch("/update/sso_settings", json=clear_ui_mode)
assert response.status_code == 200
# Verify ui_access_mode and user_email were cleared
updated_config = mock_proxy_config["config"]
assert updated_config["general_settings"].get("ui_access_mode") is None
assert updated_config["general_settings"].get("proxy_admin_email") is None
# Verify save_config was called twice (once for each update)
assert mock_proxy_config["save_call_count"]() == 2

View file

@ -0,0 +1,11 @@
import importlib
def test_fastuuid_flag_exposed():
mod = importlib.import_module("litellm._uuid")
assert hasattr(mod, "FASTUUID_AVAILABLE")
assert hasattr(mod, "uuid4")
# Ensure uuid4 returns something that looks like a UUID string
val = str(mod.uuid4())
assert isinstance(val, str)
assert len(val) >= 8

View file

@ -186,21 +186,21 @@ const SSOModals: React.FC<SSOModalsProps> = ({
}
try {
// Clear all SSO settings by sending empty values
// Clear all SSO settings
const clearSettings = {
google_client_id: '',
google_client_secret: '',
microsoft_client_id: '',
microsoft_client_secret: '',
microsoft_tenant: '',
generic_client_id: '',
generic_client_secret: '',
generic_authorization_endpoint: '',
generic_token_endpoint: '',
generic_userinfo_endpoint: '',
proxy_base_url: '',
user_email: '',
sso_provider: '',
google_client_id: null,
google_client_secret: null,
microsoft_client_id: null,
microsoft_client_secret: null,
microsoft_tenant: null,
generic_client_id: null,
generic_client_secret: null,
generic_authorization_endpoint: null,
generic_token_endpoint: null,
generic_userinfo_endpoint: null,
proxy_base_url: null,
user_email: null,
sso_provider: null,
};
await updateSSOSettings(accessToken, clearSettings);