mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-13 03:05:31 +00:00
36c494fdd2
* fix(opentelemetry): JSON-serialize dict metadata fields for OTEL span attributes (#27451) (#27455) Squash-merged by litellm-agent from Anai-Guo's PR. * feat(dashscope): add embeddings and reranks(qwen3-rerank) support via OpenAI-compatible endpoint (#27508) Squash-merged by litellm-agent from yimao's PR. * fix(vertex_ai/gemini): raise BadRequestError when image_url or url fi… (#24550) Squash-merged by litellm-agent from krisxia0506's PR. * fix(vertex_ai): raise error on mid-stream 429/error chunks instead of silently swallowing (#23711) Squash-merged by litellm-agent from krisxia0506's PR. * fix: raise BadRequestError for file content blocks missing 'file' sub… (#24503) Squash-merged by litellm-agent from krisxia0506's PR. * Fix Gemini MIME detection for extensionless GCS URIs (#27278) Squash-merged by litellm-agent from krisxia0506's PR. * fix(vertex_ai/partner_models): drop unused vertexai SDK gate from count_tokens (closes #28084) (#28107) Squash-merged by litellm-agent from voidborne-d's PR. * feat(chart): add support for autoscaling behavior in HPA (#27990) Squash-merged by litellm-agent from FabrizioCafolla's PR. * feat(proxy): add blocked flag to models for pause/resume from the UI (#27927) Squash-merged by litellm-agent from Cyberfilo's PR. * fix: pass socket timeouts to Redis cluster clients (#27920) Squash-merged by litellm-agent from tomdee's PR. * Fix/cache token (#28009) Squash-merged by litellm-agent from escon1004's PR. * fix(deepseek): forward reasoning_content in multi-turn thinking mode conversations (#28080) Squash-merged by litellm-agent from Divyansh8321's PR. * fix(guardrails): return HTTP 400 instead of 500 for blocked requests (#27617) * fix: reset org and tag budgets (#27326) * reset org budgets * reset tag budgets --------- Co-authored-by: Michael Riad Zaky <michaelr@Mac.localdomain> * fix(ui): omit allowed_routes from key edit save when unchanged (#27553) * fix(ui): omit allowed_routes from key edit save when unchanged When a team admin opens Edit Settings on a key with key_type=AI APIs and saves without changing anything, the UI re-sends the existing allowed_routes value, which the backend's _check_allowed_routes_caller_permission gate rejects for non-proxy-admins (LIT-2681). Strip allowed_routes from the patch in handleSubmit when it deep-equals the original keyData.allowed_routes. The backend treats absence as "leave alone," so no-op saves now succeed for non-admins. Admins explicitly editing the field still send the new value. * fix(ui): order-insensitive allowed_routes diff + cover null-original case Address Greptile review: - Switch the "is allowed_routes unchanged" check to a Set-based comparison so a server-side reorder of the array doesn't register as a user edit and re-trigger LIT-2681. - Add two regression tests: (1) keyData.allowed_routes is null and the form is untouched — patch should strip the field; (2) server returned routes in a different order than the user originally entered — patch should still recognize the value as unchanged. * chore(ui): strip ticket refs and tighten comments in key edit fix - Remove internal-tracker references from in-code comments - Tighten the WHY comment in handleSubmit to two lines - Drop redundant test-block comments — test names already describe the case * fix(ui): annotate Set<string> generic in allowed_routes diff to fix tsc * fix(guardrails): return HTTP 400 instead of 500 for guardrail-blocked requests GuardrailRaisedException and BlockedPiiEntityError both lacked a status_code attribute. When these exceptions reached the proxy exception handler (getattr(e, 'status_code', 500)), the fallback defaulted to HTTP 500 — making intentional guardrail blocks indistinguishable from server errors and causing unnecessary client retries. Changes: - Add status_code=400 (keyword-only) to GuardrailRaisedException - Add status_code=400 (keyword-only) to BlockedPiiEntityError - Update _is_guardrail_intervention() to recognize both exceptions so downstream loggers record 'guardrail_intervened' instead of 'guardrail_failed_to_respond' - Add 6 unit tests for default/custom status codes and getattr pattern - Strengthen existing blocked-action test with status_code assertion Fixes #24348 --------- Co-authored-by: Michael-RZ-Berri <michael@berri.ai> Co-authored-by: Michael Riad Zaky <michaelr@Mac.localdomain> Co-authored-by: ryan-crabbe-berri <ryan@berri.ai> Co-authored-by: Krrish Dholakia <krrish+github@berri.ai> * fix(router/proxy): address Greptile P1+P2 review comments on PR #28161 - router: raise ServiceUnavailableError (503) instead of RouterRateLimitErrorBasic (429) when a specifically-addressed deployment is administratively blocked; 429 misleads retry-enabled clients into spinning forever against a paused model - proxy_server: compute get_fully_blocked_model_names() once before both branches in model_list() instead of duplicating the call in each branch - deepseek: upgrade silent debug log to warning when injecting placeholder reasoning_content so callers are clearly notified of degraded multi-turn quality - tests: update two blocked-deployment assertions to expect ServiceUnavailableError Co-authored-by: Cursor <cursoragent@cursor.com> * fix: address bug detection findings (cache token order, mutable defaults) Co-authored-by: Yassin Kortam <yassin@berri.ai> * fix: address bugs in async pass-through, anthropic cache token detection, rerank tests - async_get_available_deployment_for_pass_through: enforce blocked check on specific deployments - cost_calculator: detect anthropic-style usage by attribute presence (not truthiness) to avoid mixing OpenAI cached_tokens into anthropic normalization when read=0 - dashscope rerank tests: pass request to httpx.Response constructions for consistency Co-authored-by: Yassin Kortam <yassin@berri.ai> * fix code qa * fix(vertex_ai/gemini): strip MIME parameters from GCS contentType GCS object metadata's contentType field can include parameters such as 'text/html; charset=utf-8'. Strip them in _apply_gemini_mime_type_aliases so downstream get_file_extension_from_mime_type sees a bare MIME type. Co-authored-by: Yassin Kortam <yassin@berri.ai> * fix(vertex_ai/gemini): clarify mime-type error message string concatenation Co-authored-by: Yassin Kortam <yassin@berri.ai> --------- Co-authored-by: Tai An <antai12232931@outlook.com> Co-authored-by: Vincent <yimao1231@gmail.com> Co-authored-by: Kris Xia <xiajiayi0506@gmail.com> Co-authored-by: d 🔹 <liusway405@gmail.com> Co-authored-by: Fabrizio Cafolla <developer@fabriziocafolla.com> Co-authored-by: Filippo Menghi <113345637+Cyberfilo@users.noreply.github.com> Co-authored-by: Tom Denham <tom@tomdee.co.uk> Co-authored-by: escon1004 <70471150+escon1004@users.noreply.github.com> Co-authored-by: Divyansh Singhal <97736786+Divyansh8321@users.noreply.github.com> Co-authored-by: robin-fiddler <robin@fiddler.ai> Co-authored-by: Michael-RZ-Berri <michael@berri.ai> Co-authored-by: Michael Riad Zaky <michaelr@Mac.localdomain> Co-authored-by: ryan-crabbe-berri <ryan@berri.ai> Co-authored-by: Krrish Dholakia <krrish+github@berri.ai> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Yassin Kortam <yassin@berri.ai>
591 lines
21 KiB
Python
591 lines
21 KiB
Python
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
import redis
|
|
import redis.asyncio as async_redis
|
|
|
|
from litellm._redis import (
|
|
_get_redis_cluster_kwargs,
|
|
get_redis_async_client,
|
|
get_redis_client,
|
|
get_redis_connection_pool,
|
|
get_redis_url_from_environment,
|
|
)
|
|
from litellm._redis_credential_provider import (
|
|
GCPIAMCredentialProvider,
|
|
_token_cache,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_gcp_iam_token_cache():
|
|
"""Reset the module-level GCP IAM token cache between tests."""
|
|
_token_cache.clear()
|
|
yield
|
|
_token_cache.clear()
|
|
|
|
|
|
def test_get_redis_url_from_environment_single_url(monkeypatch):
|
|
"""Test when REDIS_URL is directly provided"""
|
|
# Set the environment variable
|
|
monkeypatch.setenv("REDIS_URL", "redis://redis-server:6379/0")
|
|
|
|
# Call the function to get the Redis URL
|
|
redis_url = get_redis_url_from_environment()
|
|
|
|
# Assert that the returned URL matches the expected value
|
|
assert redis_url == "redis://redis-server:6379/0"
|
|
|
|
|
|
def test_get_redis_url_from_environment_host_port(monkeypatch):
|
|
"""Test when REDIS_HOST and REDIS_PORT are provided"""
|
|
# Set the environment variables
|
|
monkeypatch.setenv("REDIS_HOST", "redis-server")
|
|
monkeypatch.setenv("REDIS_PORT", "6379")
|
|
# Ensure authentication variables are not set
|
|
monkeypatch.delenv("REDIS_USERNAME", raising=False)
|
|
monkeypatch.delenv("REDIS_PASSWORD", raising=False)
|
|
monkeypatch.delenv("REDIS_SSL", raising=False)
|
|
|
|
# Call the function to get the Redis URL
|
|
redis_url = get_redis_url_from_environment()
|
|
|
|
# Assert that the returned URL matches the expected value
|
|
assert redis_url == "redis://redis-server:6379"
|
|
|
|
|
|
def test_get_redis_url_from_environment_with_ssl(monkeypatch):
|
|
"""Test when SSL is enabled"""
|
|
# Set the environment variables
|
|
monkeypatch.setenv("REDIS_HOST", "redis-server")
|
|
monkeypatch.setenv("REDIS_PORT", "6379")
|
|
monkeypatch.setenv("REDIS_SSL", "true")
|
|
# Ensure authentication variables are not set
|
|
monkeypatch.delenv("REDIS_USERNAME", raising=False)
|
|
monkeypatch.delenv("REDIS_PASSWORD", raising=False)
|
|
|
|
# Call the function to get the Redis URL
|
|
redis_url = get_redis_url_from_environment()
|
|
|
|
# Assert that the returned URL uses rediss:// protocol
|
|
assert redis_url == "rediss://redis-server:6379"
|
|
|
|
|
|
def test_get_redis_url_from_environment_with_username_password(monkeypatch):
|
|
"""Test when username and password are provided"""
|
|
# Set the environment variables
|
|
monkeypatch.setenv("REDIS_HOST", "redis-server")
|
|
monkeypatch.setenv("REDIS_PORT", "6379")
|
|
monkeypatch.setenv("REDIS_USERNAME", "user")
|
|
monkeypatch.setenv("REDIS_PASSWORD", "password")
|
|
|
|
# Call the function to get the Redis URL
|
|
redis_url = get_redis_url_from_environment()
|
|
|
|
# Assert that the returned URL includes username:password@
|
|
assert redis_url == "redis://user:password@redis-server:6379"
|
|
|
|
|
|
def test_get_redis_url_from_environment_with_password_only(monkeypatch):
|
|
"""Test when only password is provided"""
|
|
# Set the environment variables
|
|
monkeypatch.setenv("REDIS_HOST", "redis-server")
|
|
monkeypatch.setenv("REDIS_PORT", "6379")
|
|
monkeypatch.setenv("REDIS_PASSWORD", "password")
|
|
# Ensure username is not set
|
|
monkeypatch.delenv("REDIS_USERNAME", raising=False)
|
|
monkeypatch.delenv("REDIS_SSL", raising=False)
|
|
|
|
# Call the function to get the Redis URL
|
|
redis_url = get_redis_url_from_environment()
|
|
|
|
# Assert that the returned URL includes :password@
|
|
assert redis_url == "redis://password@redis-server:6379"
|
|
|
|
|
|
def test_get_redis_url_from_environment_with_all_options(monkeypatch):
|
|
"""Test when all options are provided"""
|
|
# Set the environment variables
|
|
monkeypatch.setenv("REDIS_HOST", "redis-server")
|
|
monkeypatch.setenv("REDIS_PORT", "6379")
|
|
monkeypatch.setenv("REDIS_USERNAME", "user")
|
|
monkeypatch.setenv("REDIS_PASSWORD", "password")
|
|
monkeypatch.setenv("REDIS_SSL", "true")
|
|
|
|
# Call the function to get the Redis URL
|
|
redis_url = get_redis_url_from_environment()
|
|
|
|
# Assert that the returned URL includes all components
|
|
assert redis_url == "rediss://user:password@redis-server:6379"
|
|
|
|
|
|
def test_get_redis_url_from_environment_missing_host_port(monkeypatch):
|
|
"""Test error when required variables are missing"""
|
|
# Make sure these environment variables don't exist
|
|
monkeypatch.delenv("REDIS_URL", raising=False)
|
|
monkeypatch.delenv("REDIS_HOST", raising=False)
|
|
monkeypatch.delenv("REDIS_PORT", raising=False)
|
|
|
|
# Call the function and expect a ValueError
|
|
with pytest.raises(ValueError) as excinfo:
|
|
get_redis_url_from_environment()
|
|
|
|
# Check the error message
|
|
assert (
|
|
"Either 'REDIS_URL' or both 'REDIS_HOST' and 'REDIS_PORT' must be specified"
|
|
in str(excinfo.value)
|
|
)
|
|
|
|
|
|
def test_get_redis_url_from_environment_missing_port(monkeypatch):
|
|
"""Test error when only REDIS_HOST is provided but REDIS_PORT is missing"""
|
|
# Make sure REDIS_URL doesn't exist and set only REDIS_HOST
|
|
monkeypatch.delenv("REDIS_URL", raising=False)
|
|
monkeypatch.delenv("REDIS_PORT", raising=False)
|
|
monkeypatch.setenv("REDIS_HOST", "redis-server")
|
|
|
|
# Call the function and expect a ValueError
|
|
with pytest.raises(ValueError) as excinfo:
|
|
get_redis_url_from_environment()
|
|
|
|
# Check the error message
|
|
assert (
|
|
"Either 'REDIS_URL' or both 'REDIS_HOST' and 'REDIS_PORT' must be specified"
|
|
in str(excinfo.value)
|
|
)
|
|
|
|
|
|
def test_max_connections_in_cluster_kwargs():
|
|
"""Test that max_connections is included in Redis cluster kwargs"""
|
|
kwargs = _get_redis_cluster_kwargs()
|
|
assert (
|
|
"max_connections" in kwargs
|
|
), "max_connections should be in available Redis cluster kwargs"
|
|
|
|
|
|
def test_socket_timeouts_in_cluster_kwargs():
|
|
"""Test that Redis cluster clients can receive socket timeout configuration"""
|
|
kwargs = _get_redis_cluster_kwargs()
|
|
assert "socket_timeout" in kwargs
|
|
assert "socket_connect_timeout" in kwargs
|
|
|
|
|
|
def test_get_redis_async_client_with_connection_pool():
|
|
"""Test that connection_pool parameter is properly passed to Redis client"""
|
|
# Create a mock connection pool
|
|
mock_pool = MagicMock(spec=async_redis.BlockingConnectionPool)
|
|
|
|
# Mock the Redis client creation
|
|
with (
|
|
patch("litellm._redis.async_redis.Redis") as mock_redis,
|
|
patch("litellm._redis._get_redis_client_logic") as mock_logic,
|
|
):
|
|
|
|
# Configure mock to return basic redis kwargs
|
|
mock_logic.return_value = {"host": "localhost", "port": 6379, "db": 0}
|
|
|
|
# Call get_redis_async_client with connection_pool
|
|
get_redis_async_client(connection_pool=mock_pool)
|
|
|
|
# Verify Redis was called with connection_pool in kwargs
|
|
call_kwargs = mock_redis.call_args[1]
|
|
assert (
|
|
"connection_pool" in call_kwargs
|
|
), "connection_pool should be passed to Redis client"
|
|
assert (
|
|
call_kwargs["connection_pool"] == mock_pool
|
|
), "connection_pool should match the provided pool"
|
|
|
|
|
|
def test_get_redis_async_client_without_connection_pool():
|
|
"""Test that Redis client works without connection_pool parameter"""
|
|
with (
|
|
patch("litellm._redis.async_redis.Redis") as mock_redis,
|
|
patch("litellm._redis._get_redis_client_logic") as mock_logic,
|
|
):
|
|
|
|
# Configure mock to return basic redis kwargs
|
|
mock_logic.return_value = {"host": "localhost", "port": 6379, "db": 0}
|
|
|
|
# Call get_redis_async_client without connection_pool
|
|
get_redis_async_client()
|
|
|
|
# Verify Redis was called without connection_pool in kwargs
|
|
call_kwargs = mock_redis.call_args[1]
|
|
assert (
|
|
"connection_pool" not in call_kwargs
|
|
), "connection_pool should not be in kwargs when not provided"
|
|
|
|
|
|
def test_gcp_iam_credential_provider_get_credentials():
|
|
"""GCPIAMCredentialProvider.get_credentials() returns a token tuple."""
|
|
service_account = "projects/-/serviceAccounts/test@project.iam.gserviceaccount.com"
|
|
|
|
with patch(
|
|
"litellm._redis_credential_provider._generate_gcp_iam_access_token",
|
|
return_value="tok-1",
|
|
) as mock_gen:
|
|
provider = GCPIAMCredentialProvider(service_account)
|
|
creds = provider.get_credentials()
|
|
|
|
assert creds == ("tok-1",)
|
|
mock_gen.assert_called_once_with(service_account)
|
|
|
|
|
|
def test_gcp_iam_credential_provider_caches_token():
|
|
"""
|
|
Repeated calls to get_credentials() reuse the cached token and only call
|
|
_generate_gcp_iam_access_token once, avoiding redundant blocking I/O.
|
|
"""
|
|
service_account = "projects/-/serviceAccounts/test@project.iam.gserviceaccount.com"
|
|
|
|
with patch(
|
|
"litellm._redis_credential_provider._generate_gcp_iam_access_token",
|
|
return_value="tok-cached",
|
|
) as mock_gen:
|
|
provider = GCPIAMCredentialProvider(service_account)
|
|
results = [provider.get_credentials() for _ in range(5)]
|
|
|
|
assert all(r == ("tok-cached",) for r in results)
|
|
# Token must be fetched exactly once regardless of how many connections are established
|
|
mock_gen.assert_called_once_with(service_account)
|
|
|
|
|
|
def test_gcp_iam_credential_provider_refreshes_on_expiry():
|
|
"""
|
|
get_credentials() fetches a new token after the cached one expires,
|
|
ensuring connections always authenticate with a valid token.
|
|
"""
|
|
import time
|
|
|
|
import litellm._redis_credential_provider as cred_module
|
|
|
|
service_account = "projects/-/serviceAccounts/test@project.iam.gserviceaccount.com"
|
|
|
|
with patch(
|
|
"litellm._redis_credential_provider._generate_gcp_iam_access_token",
|
|
side_effect=["tok-1", "tok-2"],
|
|
) as mock_gen:
|
|
provider = GCPIAMCredentialProvider(service_account)
|
|
|
|
# First call — populates cache
|
|
assert provider.get_credentials() == ("tok-1",)
|
|
|
|
# Artificially expire the cached token
|
|
cred_module._token_cache[service_account] = ("tok-1", time.monotonic() - 1)
|
|
|
|
# Second call — cache miss, must refresh
|
|
assert provider.get_credentials() == ("tok-2",)
|
|
|
|
assert mock_gen.call_count == 2
|
|
|
|
|
|
def test_gcp_iam_credential_provider_cache_shared_across_instances():
|
|
"""
|
|
Multiple GCPIAMCredentialProvider instances for the same service account
|
|
share one cached token so concurrent Redis connections don't each trigger
|
|
a blocking IAM round-trip.
|
|
"""
|
|
service_account = (
|
|
"projects/-/serviceAccounts/shared@project.iam.gserviceaccount.com"
|
|
)
|
|
|
|
with patch(
|
|
"litellm._redis_credential_provider._generate_gcp_iam_access_token",
|
|
return_value="tok-shared",
|
|
) as mock_gen:
|
|
p1 = GCPIAMCredentialProvider(service_account)
|
|
p2 = GCPIAMCredentialProvider(service_account)
|
|
|
|
assert p1.get_credentials() == ("tok-shared",)
|
|
assert p2.get_credentials() == ("tok-shared",)
|
|
|
|
# Only one network call despite two provider instances
|
|
mock_gen.assert_called_once()
|
|
|
|
|
|
def test_get_redis_async_client_gcp_cluster_uses_credential_provider():
|
|
"""
|
|
When startup_nodes + gcp_service_account are provided, the async cluster client
|
|
must be constructed with a GCPIAMCredentialProvider — not a static password.
|
|
This ensures that the 1-hour IAM token expiry does not cause auth failures.
|
|
"""
|
|
startup_nodes = [{"host": "redis-node-1", "port": 6379}]
|
|
|
|
mock_connect_func = MagicMock()
|
|
mock_connect_func._gcp_service_account = (
|
|
"projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com"
|
|
)
|
|
|
|
redis_kwargs = {
|
|
"startup_nodes": startup_nodes,
|
|
"redis_connect_func": mock_connect_func,
|
|
}
|
|
|
|
with (
|
|
patch("litellm._redis.async_redis.RedisCluster") as mock_cluster,
|
|
patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs),
|
|
):
|
|
get_redis_async_client()
|
|
|
|
assert mock_cluster.called
|
|
cluster_call_kwargs = mock_cluster.call_args[1]
|
|
|
|
# Must use credential_provider, not a static password
|
|
assert (
|
|
"credential_provider" in cluster_call_kwargs
|
|
), "async GCP cluster must use credential_provider for per-connection token refresh"
|
|
assert isinstance(
|
|
cluster_call_kwargs["credential_provider"], GCPIAMCredentialProvider
|
|
)
|
|
assert (
|
|
"password" not in cluster_call_kwargs
|
|
), "async GCP cluster must not use a static password (expires after 1h)"
|
|
|
|
|
|
@patch("litellm._redis.init_redis_cluster")
|
|
def test_sync_client_prefers_cluster_over_url(mock_init_cluster, monkeypatch):
|
|
"""
|
|
Test get_redis_client returns RedisCluster when startup_nodes is present even if
|
|
REDIS_URL is also set.
|
|
"""
|
|
monkeypatch.setenv("REDIS_URL", "redis://fallback-host:6379")
|
|
mock_init_cluster.return_value = MagicMock(spec=redis.RedisCluster)
|
|
|
|
startup_nodes = [{"host": "cluster-node.example.com", "port": 6379}]
|
|
get_redis_client(startup_nodes=startup_nodes)
|
|
|
|
mock_init_cluster.assert_called_once()
|
|
call_kwargs = mock_init_cluster.call_args[0][0]
|
|
assert (
|
|
"startup_nodes" in call_kwargs
|
|
), "startup_nodes must be forwarded to init_redis_cluster"
|
|
|
|
|
|
@patch("litellm._redis.async_redis.RedisCluster")
|
|
def test_async_client_prefers_cluster_over_url(mock_cluster_cls, monkeypatch):
|
|
"""
|
|
Test (1) get_redis_async_client returns async RedisCluster when startup_nodes is present
|
|
even if REDIS_URL is also set and (2) startup_nodes is forwarded to RedisCluster.
|
|
"""
|
|
monkeypatch.setenv("REDIS_URL", "redis://fallback-host:6379")
|
|
|
|
startup_nodes = [{"host": "cluster-node.example.com", "port": 6379}]
|
|
get_redis_async_client(startup_nodes=startup_nodes)
|
|
|
|
mock_cluster_cls.assert_called_once()
|
|
call_kwargs = mock_cluster_cls.call_args[1]
|
|
assert (
|
|
"startup_nodes" in call_kwargs
|
|
), "startup_nodes must be forwarded to async RedisCluster"
|
|
assert (
|
|
len(call_kwargs["startup_nodes"]) == 1
|
|
), "should forward exactly 1 cluster node"
|
|
|
|
|
|
@patch("litellm._redis.async_redis.RedisCluster")
|
|
def test_async_client_prefers_cluster_over_url_via_env_var(
|
|
mock_cluster_cls, monkeypatch
|
|
):
|
|
"""
|
|
Test get_redis_async_client returns async RedisCluster when REDIS_CLUSTER_NODES is set
|
|
even if REDIS_URL is also set.
|
|
"""
|
|
monkeypatch.setenv("REDIS_URL", "redis://fallback-host:6379")
|
|
monkeypatch.setenv(
|
|
"REDIS_CLUSTER_NODES",
|
|
json.dumps([{"host": "cluster-node.example.com", "port": 6379}]),
|
|
)
|
|
|
|
get_redis_async_client()
|
|
|
|
mock_cluster_cls.assert_called_once()
|
|
call_kwargs = mock_cluster_cls.call_args[1]
|
|
assert (
|
|
"startup_nodes" in call_kwargs
|
|
), "startup_nodes must be forwarded to async RedisCluster"
|
|
|
|
|
|
@patch("litellm._redis.init_redis_cluster")
|
|
def test_sync_client_prefers_cluster_over_url_via_env_var(
|
|
mock_init_cluster, monkeypatch
|
|
):
|
|
"""
|
|
Test get_redis_client returns RedisCluster when REDIS_CLUSTER_NODES is set even if
|
|
REDIS_URL is also set.
|
|
"""
|
|
monkeypatch.setenv("REDIS_URL", "redis://fallback-host:6379")
|
|
monkeypatch.setenv(
|
|
"REDIS_CLUSTER_NODES",
|
|
json.dumps([{"host": "cluster-node.example.com", "port": 6379}]),
|
|
)
|
|
mock_init_cluster.return_value = MagicMock(spec=redis.RedisCluster)
|
|
|
|
get_redis_client()
|
|
|
|
mock_init_cluster.assert_called_once()
|
|
call_kwargs = mock_init_cluster.call_args[0][0]
|
|
assert (
|
|
"startup_nodes" in call_kwargs
|
|
), "startup_nodes must be forwarded to init_redis_cluster"
|
|
assert len(call_kwargs["startup_nodes"]) == 1
|
|
|
|
|
|
@patch("litellm._redis.redis.Sentinel")
|
|
def test_sync_sentinel_uses_sentinel_password_and_master_password(mock_sentinel_cls):
|
|
"""Sentinel auth must be passed to the sentinel, not the Redis master client."""
|
|
mock_sentinel = MagicMock()
|
|
mock_sentinel_cls.return_value = mock_sentinel
|
|
|
|
get_redis_client(
|
|
sentinel_nodes=[("sentinel-1", 26379)],
|
|
sentinel_password="sentinel-secret",
|
|
service_name="mymaster",
|
|
password="redis-secret",
|
|
username="redis-user",
|
|
ssl=True,
|
|
ssl_cert_reqs="required",
|
|
ssl_check_hostname=True,
|
|
ssl_ca_certs="/tmp/test-ca.pem",
|
|
max_connections=17,
|
|
socket_timeout=5,
|
|
)
|
|
|
|
mock_sentinel_cls.assert_called_once()
|
|
sentinel_call_kwargs = mock_sentinel_cls.call_args[1]
|
|
assert "password" not in sentinel_call_kwargs
|
|
assert "username" not in sentinel_call_kwargs
|
|
assert "ssl" not in sentinel_call_kwargs
|
|
assert "ssl_cert_reqs" not in sentinel_call_kwargs
|
|
assert "ssl_check_hostname" not in sentinel_call_kwargs
|
|
assert "ssl_ca_certs" not in sentinel_call_kwargs
|
|
assert "max_connections" not in sentinel_call_kwargs
|
|
assert "socket_timeout" not in sentinel_call_kwargs
|
|
assert sentinel_call_kwargs["sentinel_kwargs"] == {
|
|
"password": "sentinel-secret",
|
|
"username": "redis-user",
|
|
"ssl": True,
|
|
"ssl_cert_reqs": "required",
|
|
"ssl_check_hostname": True,
|
|
"ssl_ca_certs": "/tmp/test-ca.pem",
|
|
"max_connections": 17,
|
|
"socket_timeout": 5,
|
|
}
|
|
assert "service_name" not in sentinel_call_kwargs["sentinel_kwargs"]
|
|
assert "sentinel_nodes" not in sentinel_call_kwargs["sentinel_kwargs"]
|
|
assert "sentinel_password" not in sentinel_call_kwargs["sentinel_kwargs"]
|
|
mock_sentinel.master_for.assert_called_once_with(
|
|
"mymaster",
|
|
password="redis-secret",
|
|
username="redis-user",
|
|
ssl=True,
|
|
ssl_cert_reqs="required",
|
|
ssl_check_hostname=True,
|
|
ssl_ca_certs="/tmp/test-ca.pem",
|
|
max_connections=17,
|
|
socket_timeout=5,
|
|
)
|
|
|
|
|
|
@patch("litellm._redis.async_redis.Sentinel")
|
|
def test_async_sentinel_uses_sentinel_password_and_master_password(
|
|
mock_sentinel_cls,
|
|
):
|
|
"""Async sentinel auth must mirror the sync sentinel password routing."""
|
|
mock_sentinel = MagicMock()
|
|
mock_sentinel_cls.return_value = mock_sentinel
|
|
|
|
get_redis_async_client(
|
|
sentinel_nodes=[("sentinel-1", 26379)],
|
|
sentinel_password="sentinel-secret",
|
|
service_name="mymaster",
|
|
password="redis-secret",
|
|
username="redis-user",
|
|
ssl=True,
|
|
ssl_cert_reqs="required",
|
|
ssl_check_hostname=True,
|
|
ssl_ca_certs="/tmp/test-ca.pem",
|
|
max_connections=17,
|
|
socket_timeout=5,
|
|
)
|
|
|
|
mock_sentinel_cls.assert_called_once()
|
|
sentinel_call_kwargs = mock_sentinel_cls.call_args[1]
|
|
assert "password" not in sentinel_call_kwargs
|
|
assert "username" not in sentinel_call_kwargs
|
|
assert "ssl" not in sentinel_call_kwargs
|
|
assert "ssl_cert_reqs" not in sentinel_call_kwargs
|
|
assert "ssl_check_hostname" not in sentinel_call_kwargs
|
|
assert "ssl_ca_certs" not in sentinel_call_kwargs
|
|
assert "max_connections" not in sentinel_call_kwargs
|
|
assert "socket_timeout" not in sentinel_call_kwargs
|
|
assert sentinel_call_kwargs["sentinel_kwargs"] == {
|
|
"password": "sentinel-secret",
|
|
"username": "redis-user",
|
|
"ssl": True,
|
|
"ssl_cert_reqs": "required",
|
|
"ssl_check_hostname": True,
|
|
"ssl_ca_certs": "/tmp/test-ca.pem",
|
|
"max_connections": 17,
|
|
"socket_timeout": 5,
|
|
}
|
|
assert "service_name" not in sentinel_call_kwargs["sentinel_kwargs"]
|
|
assert "sentinel_nodes" not in sentinel_call_kwargs["sentinel_kwargs"]
|
|
assert "sentinel_password" not in sentinel_call_kwargs["sentinel_kwargs"]
|
|
mock_sentinel.master_for.assert_called_once_with(
|
|
"mymaster",
|
|
password="redis-secret",
|
|
username="redis-user",
|
|
ssl=True,
|
|
ssl_cert_reqs="required",
|
|
ssl_check_hostname=True,
|
|
ssl_ca_certs="/tmp/test-ca.pem",
|
|
max_connections=17,
|
|
socket_timeout=5,
|
|
)
|
|
|
|
|
|
@patch("litellm._redis.init_redis_cluster")
|
|
def test_sync_client_preserves_password_for_cluster_when_url_also_set(
|
|
mock_init_cluster, monkeypatch
|
|
):
|
|
"""
|
|
Test _get_redis_client_logic does not strip password from redis_kwargs when
|
|
startup_nodes is present even if REDIS_URL is also set.
|
|
"""
|
|
monkeypatch.setenv("REDIS_URL", "redis://fallback-host:6379")
|
|
monkeypatch.setenv("REDIS_PASSWORD", "secret")
|
|
mock_init_cluster.return_value = MagicMock(spec=redis.RedisCluster)
|
|
|
|
startup_nodes = [{"host": "cluster-node.example.com", "port": 6379}]
|
|
get_redis_client(startup_nodes=startup_nodes)
|
|
|
|
mock_init_cluster.assert_called_once()
|
|
call_kwargs = mock_init_cluster.call_args[0][0]
|
|
assert (
|
|
"password" in call_kwargs
|
|
), "password must not be stripped when routing to cluster"
|
|
assert call_kwargs["password"] == "secret"
|
|
|
|
|
|
def test_connection_pool_returns_none_for_cluster(monkeypatch):
|
|
"""Test get_redis_connection_pool returns None when startup_nodes is present."""
|
|
monkeypatch.setenv("REDIS_URL", "redis://fallback-host:6379")
|
|
startup_nodes = [{"host": "cluster-node.example.com", "port": 6379}]
|
|
result = get_redis_connection_pool(startup_nodes=startup_nodes)
|
|
assert result is None, "connection pool must be None for cluster mode"
|
|
|
|
|
|
@patch("litellm._redis.redis.Redis.from_url")
|
|
def test_sync_client_url_used_when_no_cluster(mock_from_url, monkeypatch):
|
|
"""
|
|
Test get_redis_client default to using URL path when no startup_nodes are provided.
|
|
"""
|
|
monkeypatch.setenv("REDIS_URL", "redis://plain-host:6379")
|
|
monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False)
|
|
|
|
get_redis_client()
|
|
|
|
mock_from_url.assert_called_once()
|