refactor(redis-types): resolve Redis settings into one typed Resolution
Some checks failed
Cost map pins recheck / rerun-cost-map-guard (push) Has been cancelled
LiteLLM Rust DB / db-queries (push) Has been cancelled
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
LiteLLM Rust / rust-wheel (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Modules / fmt, validate, test (gcp) (push) Has been cancelled

resolve now returns litellm_redis_types::Resolution, the value both client
projections build from. Target replaces Endpoint, a url carries the TLS parsed
from it only when it is rediss, Sentinel carries its own monitor_auth, and Auth
is a tagged union whose Provider variant holds a ProviderHandle the host passes
with ConfiguredRedis::with_credential_provider. A credential_provider key with
no handle is an error instead of a connect-time failure.

Settings the resolver does not model are no longer dropped. Plain ones land in
passthrough and object-valued ones in host_objects, so a redis-py projection can
reattach them. TlsOptions no longer derives Default, which turned certificate
verification off, and TlsOptions::verified() is the one place defaults come from
This commit is contained in:
Yujong Lee 2026-11-04 19:14:50 +09:00 • committed by yujonglee
parent a41ba72c89
commit c07ef4568c
26 changed files with 525 additions and 283 deletions

View file

@ -4416,6 +4416,8 @@ name = "litellm-redis-types"
version = "0.1.0"
dependencies = [
"litellm-secret-value",
"serde_json",
"veil",
]
[[package]]

View file

@ -1,7 +1,7 @@
## Redis settings
- The boundary is surface in, `ResolvedRedis` out. A surface turns what it was given into a `ConfiguredRedis`, the redis-py kwargs `_get_redis_client_logic` would receive: `RedisSettings::coordination` for `general_settings.coordination_redis`, `RedisSettings::cache_params` for `litellm_settings.cache_params`, and `ConfiguredRedis::redis_cache_kwargs` for a `RedisCache.redis_kwargs` dict. `ConfiguredRedis::resolve` is then the same for every host and returns a `Resolution`: the `litellm_redis_types::ResolvedRedis` plus every accepted-but-ignored setting by source, which the host logs. A new surface is a new constructor, never a flag on `resolve`
- Resolution runs in three steps, one file each. `layers.rs` puts the configured kwargs over their `REDIS_*` values. `options.rs` reads the result into the typed `Options`, rejects unsupported settings, collects ignored ones and fills Python's `get_secret` fallbacks. `endpoint.rs` and `auth.rs` pick the target and the credentials from `Options` alone
- The boundary is surface in, `litellm_redis_types::Resolution` out. A surface turns what it was given into a `ConfiguredRedis`, the redis-py kwargs `_get_redis_client_logic` would receive: `RedisSettings::coordination` for `general_settings.coordination_redis`, `RedisSettings::cache_params` for `litellm_settings.cache_params`, and `ConfiguredRedis::redis_cache_kwargs` for a `RedisCache.redis_kwargs` dict. `ConfiguredRedis::resolve` is then the same for every host and returns the `Resolution`: the target, the auth and the timeouts Rust models, every other plain setting verbatim in `passthrough`, the names of object-valued settings in `host_objects`, and every accepted-but-ignored setting by source, which the host logs. A host passes its `credential_provider` object as a `ProviderHandle` through `ConfiguredRedis::with_credential_provider`, and a `credential_provider` key without one is `RedisError::ProviderWithoutHandle`. A new surface is a new constructor, never a flag on `resolve`
- Resolution runs in three steps, one file each. `layers.rs` puts the configured kwargs over their `REDIS_*` values. `options.rs` reads the result into the typed `Options`, rejects unsupported settings, splits the unmodeled ones into passthrough and host objects, collects ignored ones and fills Python's `get_secret` fallbacks. `endpoint.rs` and `auth.rs` pick the target and the credentials from `Options` alone
- `resolve` follows `litellm/_redis.py` `_get_redis_client_logic` plus `_credential_provider_auth_kwargs` and the per-client allowlists of `get_redis_async_client` (redis-py 5.3.1). Python and Rust pods share one Redis during the switch, so a change there is a change to a wire contract
- That contract is `resolves_like_python_redis` in `tests/redis.rs` and its mirror `test_redis_settings_resolve_like_the_rust_resolver_expects` in `tests/unit/test_redis.py`. Both hold the same named cases with the expected view written next to the input, the Python side reading the effective redis-py 5.3.1 connection objects without connecting. A new resolution rule is a new case in both files, never a generated fixture. A case where the two sides expect different outcomes is a divergence and is listed below
- `resolve` takes a `SecretSnapshot` and an environment `Lookup`. The snapshot holds every name `ConfiguredRedis::secret_names` lists, read the way Python's `get_secret` reads it (secret manager, then environment). The host fills it once before resolving, python-bridge through `get_secret` and rust-proxy from its environment, so the resolver never calls back into the host. The environment lookup is only for what Python reads straight from `os.environ` (`REDIS_USERNAME` for Azure). Reading a name the snapshot does not list fails a debug assertion

View file

@ -15,8 +15,8 @@ pub use litellm_router_types::{LitellmParams, Spelled};
pub use mcp::{McpAuth, McpServer, McpTransport};
pub use model::Model;
pub use redis::{
CacheSettings, ConfiguredRedis, Expected, RedisError, RedisSettings, Resolution,
SecretSnapshot, environment_has_target,
CacheSettings, ConfiguredRedis, Expected, RedisError, RedisSettings, SecretSnapshot,
environment_has_target,
};
pub use settings::{
ClickHouseStoreSettings, GeneralSettings, LiteLlmSettings, RouterSettings, TracingSettings,

View file

@ -1,7 +1,7 @@
use std::collections::BTreeMap;
use litellm_core_utils::settings::Lookup;
use litellm_redis_types::{Endpoint, RedisAuth, ResolvedRedis};
use litellm_redis_types::{Auth, ProviderHandle, Resolution, StaticAuth, Target};
use serde::{Deserialize, Deserializer, de::Error as _};
use veil::Redact;
@ -71,6 +71,7 @@ pub struct ConfiguredRedis {
#[redact(with = "[REDACTED]")]
fields: BTreeMap<String, Value>,
nulls: Nulls,
credential_provider: Option<ProviderHandle>,
}
/// Whether a null setting reaches redis-py as an explicit `None`, which outranks its `REDIS_*` value.
@ -84,14 +85,6 @@ enum Nulls {
Kept,
}
/// A resolved connection, plus the settings it was given that Rust accepts without honoring.
#[derive(Debug)]
pub struct Resolution {
pub redis: ResolvedRedis,
/// Where each ignored setting came from: its key, or the environment variable that set it.
pub ignored: Vec<String>,
}
impl RedisSettings {
/// `general_settings.coordination_redis`, which the proxy validates as a pydantic model first.
/// An `os.environ/` port is only known after resolution, where it is read like any other port.
@ -108,6 +101,7 @@ impl RedisSettings {
Ok(ConfiguredRedis {
fields: self.fields.clone(),
nulls: Nulls::Dropped,
credential_provider: None,
})
}
@ -130,6 +124,7 @@ impl RedisSettings {
ConfiguredRedis {
fields,
nulls: Nulls::KeptUnlessNamedByCache,
credential_provider: None,
}
}
}
@ -141,6 +136,15 @@ impl ConfiguredRedis {
Self {
fields,
nulls: Nulls::Kept,
credential_provider: None,
}
}
/// The handle the host holds for the `credential_provider` object in these settings.
pub fn with_credential_provider(self, handle: ProviderHandle) -> Self {
Self {
credential_provider: Some(handle),
..self
}
}
@ -149,6 +153,7 @@ impl ConfiguredRedis {
Self {
fields: BTreeMap::new(),
nulls: Nulls::Dropped,
credential_provider: None,
}
}
@ -158,25 +163,27 @@ impl ConfiguredRedis {
environment: &dyn Lookup,
) -> Result<Resolution, RedisError> {
let kwargs = layers::merged(self, secrets);
let (options, ignored) = Options::read(kwargs, secrets)?;
let auth = auth::redis_auth(&options, environment)?;
let endpoint = endpoint::endpoint(&options, &auth)?;
let auth = match (&endpoint, auth) {
(Endpoint::Url { .. }, RedisAuth::Static { username, .. }) => RedisAuth::Static {
username,
password: None,
},
let (options, unmodeled) = Options::read(kwargs, secrets)?;
let auth = auth::redis_auth(&options, self.credential_provider, environment)?;
let target = endpoint::target(&options, &auth)?;
let auth = match (&target, auth) {
(Target::Url(_), Auth::Static(StaticAuth { username, .. })) => {
Auth::Static(StaticAuth {
username,
password: None,
})
}
(_, auth) => auth,
};
Ok(Resolution {
redis: ResolvedRedis {
endpoint,
auth,
namespace: options::text(options.namespace.as_ref()),
timeout: options::seconds(options.socket_timeout.as_ref())?,
connect_timeout: options::seconds(options.socket_connect_timeout.as_ref())?,
},
ignored,
target,
auth,
namespace: options::text(options.namespace.as_ref()),
timeout: options::seconds(options.socket_timeout.as_ref())?,
connect_timeout: options::seconds(options.socket_connect_timeout.as_ref())?,
passthrough: unmodeled.passthrough,
host_objects: unmodeled.host_objects,
ignored: unmodeled.ignored,
})
}
}

View file

@ -1,5 +1,5 @@
use litellm_core_utils::settings::Lookup;
use litellm_redis_types::{AwsIamAuth, AzureAdAuth, RedisAuth};
use litellm_redis_types::{Auth, AwsIamAuth, AzureAdAuth, ProviderHandle, StaticAuth};
use super::{
RedisError,
@ -8,10 +8,13 @@ use super::{
pub(super) fn redis_auth(
options: &Options,
provider: Option<ProviderHandle>,
environment: &dyn Lookup,
) -> Result<RedisAuth, RedisError> {
if options.credential_provider.is_some() {
return Ok(RedisAuth::Host);
) -> Result<Auth, RedisError> {
match (provider, &options.credential_provider) {
(Some(handle), _) => return Ok(Auth::Provider(handle)),
(None, Some(_)) => return Err(RedisError::ProviderWithoutHandle),
(None, None) => {}
}
let gcp = text(options.gcp.service_account.as_ref());
let azure = text(options.azure.ad_token.as_ref())
@ -29,7 +32,7 @@ pub(super) fn redis_auth(
"Both GCP IAM (gcp_service_account) and AWS ElastiCache IAM (aws_iam_auth) are configured for Redis. Using GCP IAM. Remove one to avoid misconfiguration."
);
}
Ok(RedisAuth::GcpIam { service_account })
Ok(Auth::GcpIam { service_account })
}
(None, true, aws) => {
if aws {
@ -37,7 +40,7 @@ pub(super) fn redis_auth(
"Both Azure AD (azure_redis_ad_token) and AWS ElastiCache IAM (aws_iam_auth) are configured for Redis. Using Azure AD. Remove one to avoid misconfiguration."
);
}
Ok(RedisAuth::AzureAd(AzureAdAuth {
Ok(Auth::AzureAd(AzureAdAuth {
client_id: text(options.azure.client_id.as_ref()),
tenant_id: text(options.azure.tenant_id.as_ref()),
client_secret: secret(options.azure.client_secret.as_ref()),
@ -46,11 +49,11 @@ pub(super) fn redis_auth(
.filter(|username| !username.is_empty()),
}))
}
(None, false, true) => aws_iam(&options.aws, uses_tls(options)?).map(RedisAuth::AwsIam),
(None, false, false) => Ok(RedisAuth::Static {
(None, false, true) => aws_iam(&options.aws, uses_tls(options)?).map(Auth::AwsIam),
(None, false, false) => Ok(Auth::Static(StaticAuth {
username: secret(options.username.as_ref()),
password: secret(options.password.as_ref()),
}),
})),
}
}

View file

@ -1,5 +1,5 @@
use litellm_redis_types::{
ClusterEndpoint, ClusterNode, Endpoint, HostEndpoint, RedisAuth, SentinelEndpoint, TlsOptions,
Auth, Cluster, Node, RedisUrl, Sentinel, Standalone, StaticAuth, Target, TlsOptions,
};
use litellm_secret_value::SecretValue;
@ -12,27 +12,27 @@ use super::{
use crate::Value;
/// The target, first match wins like `get_redis_client`: cluster, url, Sentinel, then host.
pub(super) fn endpoint(options: &Options, auth: &RedisAuth) -> Result<Endpoint, RedisError> {
pub(super) fn target(options: &Options, auth: &Auth) -> Result<Target, RedisError> {
if let Some(nodes) = cluster_nodes(options.startup_nodes.as_ref())? {
reject_cluster_db(options.db.as_ref())?;
return Ok(Endpoint::Cluster(ClusterEndpoint {
return Ok(Target::Cluster(Cluster {
nodes,
tls: tls_for(options, auth)?,
}));
}
if let Some(url) = text(options.url.as_ref()) {
return Ok(Endpoint::Url {
return Ok(Target::Url(RedisUrl {
tls: tls::url_tls(&url)?,
url: SecretValue::new(match auth {
RedisAuth::Static { .. } => url,
Auth::Static(_) => url,
_ => without_userinfo(&url),
}),
});
}));
}
if let Some(sentinels) = options.sentinel.nodes.as_ref() {
return sentinel(options, auth, sentinels).map(Endpoint::Sentinel);
return sentinel(options, auth, sentinels).map(Target::Sentinel);
}
Ok(Endpoint::Host(HostEndpoint {
Ok(Target::Standalone(Standalone {
host: text(options.host.as_ref()).ok_or(RedisError::NoTarget)?,
port: integer(options.port.as_ref())?.unwrap_or(6379),
tls: tls_for(options, auth)?,
@ -40,12 +40,12 @@ pub(super) fn endpoint(options: &Options, auth: &RedisAuth) -> Result<Endpoint,
}))
}
fn tls_for(options: &Options, auth: &RedisAuth) -> Result<Option<TlsOptions>, RedisError> {
fn tls_for(options: &Options, auth: &Auth) -> Result<Option<TlsOptions>, RedisError> {
if !flag(options.ssl.as_ref())? {
return Ok(None);
}
let gcp_ca_certs = match auth {
RedisAuth::GcpIam { .. } => options.gcp.ssl_ca_certs.as_ref(),
Auth::GcpIam { .. } => options.gcp.ssl_ca_certs.as_ref(),
_ => None,
};
tls::tls_options(&options.tls, gcp_ca_certs).map(Some)
@ -55,12 +55,8 @@ fn db(options: &Options) -> Result<i64, RedisError> {
integer(options.db.as_ref()).map(|db| db.unwrap_or(0))
}
fn sentinel(
options: &Options,
auth: &RedisAuth,
sentinels: &Entry,
) -> Result<SentinelEndpoint, RedisError> {
let nodes = sentinel_nodes(&sentinels.value)
fn sentinel(options: &Options, auth: &Auth, sentinels: &Entry) -> Result<Sentinel, RedisError> {
let monitors = sentinel_nodes(&sentinels.value)
.ok_or_else(|| invalid(&sentinels.source, Expected::SentinelNodes))?;
let service_name =
text(options.sentinel.service_name.as_ref()).ok_or(RedisError::NoSentinelService)?;
@ -71,16 +67,30 @@ fn sentinel(
setting: entry.source.clone(),
});
}
Ok(SentinelEndpoint {
sentinels: nodes,
Ok(Sentinel {
monitors,
service_name,
sentinel_password: secret(options.sentinel.password.as_ref()),
monitor_auth: monitor_auth(options, auth),
tls: tls_for(options, auth)?,
db: db(options)?,
})
}
fn cluster_nodes(entry: Option<&Entry>) -> Result<Option<Vec<ClusterNode>>, RedisError> {
// _sentinel_auth_kwargs gives the monitors the data node's connection kwargs with sentinel_password as the
// password, so a static username reaches them too
fn monitor_auth(options: &Options, auth: &Auth) -> Option<StaticAuth> {
let password = secret(options.sentinel.password.as_ref())?;
let username = match auth {
Auth::Static(StaticAuth { username, .. }) => username.clone(),
_ => None,
};
Some(StaticAuth {
username,
password: Some(password),
})
}
fn cluster_nodes(entry: Option<&Entry>) -> Result<Option<Vec<Node>>, RedisError> {
let Some(entry) = entry else {
return Ok(None);
};
@ -94,7 +104,7 @@ fn cluster_nodes(entry: Option<&Entry>) -> Result<Option<Vec<ClusterNode>>, Redi
Ok((!nodes.is_empty()).then_some(nodes))
}
fn sentinel_nodes(value: &Value) -> Option<Vec<ClusterNode>> {
fn sentinel_nodes(value: &Value) -> Option<Vec<Node>> {
let nodes = nodes(value, |pair| match pair {
Value::Sequence(pair) if pair.len() == 2 => {
node_address(as_text(&pair[0]), as_text(&pair[1]))
@ -104,7 +114,7 @@ fn sentinel_nodes(value: &Value) -> Option<Vec<ClusterNode>> {
(!nodes.is_empty()).then_some(nodes)
}
fn nodes(value: &Value, parse: impl Fn(&Value) -> Option<ClusterNode>) -> Option<Vec<ClusterNode>> {
fn nodes(value: &Value, parse: impl Fn(&Value) -> Option<Node>) -> Option<Vec<Node>> {
match value {
Value::String(json) => serde_json::from_str::<Vec<Value>>(json)
.ok()?
@ -116,8 +126,8 @@ fn nodes(value: &Value, parse: impl Fn(&Value) -> Option<ClusterNode>) -> Option
}
}
fn node_address(host: Option<String>, port: Option<String>) -> Option<ClusterNode> {
Some(ClusterNode {
fn node_address(host: Option<String>, port: Option<String>) -> Option<Node> {
Some(Node {
host: host?,
port: port?.parse().ok()?,
})

View file

@ -12,6 +12,10 @@ pub enum RedisError {
SentinelWithHost { setting: String },
#[error("{setting} must be 0 in cluster mode")]
DbInCluster { setting: String },
#[error("credential_provider is a host object, and the host passed no handle for it")]
ProviderWithoutHandle,
#[error("Redis setting {setting} is not plain data")]
NotPlainData { setting: String },
#[error("AWS ElastiCache IAM Redis authentication requires TLS")]
AwsIamWithoutTls,
#[error("AWS ElastiCache IAM Redis authentication requires: {}", missing.join(", "))]

View file

@ -163,6 +163,28 @@ pub(super) const SETTINGS: &[Setting] = &[
Setting::new("username", Handling::Read, true, &[]),
];
// redis-py parameters whose value is a Python object rather than plain data. credential_provider is modeled as
// Auth::Provider instead
const HOST_OBJECTS: &[&str] = &[
"address_remap",
"cache",
"cache_config",
"connection_class",
"connection_pool",
"driver_info",
"event_dispatcher",
"maint_notifications_config",
"parser_class",
"redis_connect_func",
"retry",
"retry_on_error",
"ssl_ocsp_context",
];
pub(super) fn is_host_object(kwarg: &str) -> bool {
HOST_OBJECTS.contains(&kwarg)
}
pub(super) fn setting(kwarg: &str) -> Option<&'static Setting> {
SETTINGS.iter().find(|setting| setting.kwarg == kwarg)
}

View file

@ -1,7 +1,11 @@
use std::{collections::BTreeMap, time::Duration};
use std::{
collections::{BTreeMap, BTreeSet},
time::Duration,
};
use litellm_core_utils::settings::Lookup;
use litellm_python_compat::{BigInt, number};
use litellm_redis_types::Passthrough;
use litellm_secret_value::SecretValue;
use super::{
@ -70,12 +74,49 @@ pub(super) struct TlsInput {
pub(super) check_hostname: Option<Entry>,
}
/// The settings the resolver does not model, split the way a host reattaches them.
pub(super) struct Unmodeled {
pub(super) passthrough: Passthrough,
pub(super) host_objects: BTreeSet<String>,
pub(super) ignored: Vec<String>,
}
impl Unmodeled {
fn read(kwargs: &Kwargs) -> Result<Self, RedisError> {
let unmodeled = || {
kwargs
.iter()
.filter(|(key, _)| handling(key) != Some(Handling::Read))
};
let (objects, plain): (Vec<_>, Vec<_>) =
unmodeled().partition(|(key, _)| keys::is_host_object(key));
let passthrough = plain
.into_iter()
.map(|(key, entry)| {
serde_json::to_value(&entry.value)
.map(|value| (key.clone(), value))
.map_err(|_| RedisError::NotPlainData {
setting: entry.source.clone(),
})
})
.collect::<Result<_, _>>()?;
Ok(Self {
passthrough: Passthrough::new(passthrough),
host_objects: objects.into_iter().map(|(key, _)| key.clone()).collect(),
ignored: unmodeled()
.filter(|(key, _)| handling(key) == Some(Handling::Warn))
.map(|(_, entry)| entry.source.clone())
.collect(),
})
}
}
impl Options {
/// The typed settings and where each accepted-but-ignored one came from.
/// The typed settings and the ones the resolver leaves to the host.
pub(super) fn read(
kwargs: Kwargs,
lookup: &dyn Lookup,
) -> Result<(Self, Vec<String>), RedisError> {
) -> Result<(Self, Unmodeled), RedisError> {
if let Some((_, entry)) = kwargs
.iter()
.find(|(key, _)| handling(key) == Some(Handling::Reject))
@ -84,11 +125,7 @@ impl Options {
setting: entry.source.clone(),
});
}
let ignored = kwargs
.iter()
.filter(|(key, _)| handling(key) == Some(Handling::Warn))
.map(|(_, entry)| entry.source.clone())
.collect();
let unmodeled = Unmodeled::read(&kwargs)?;
let get = |key: &str| {
kwargs
.get(key)
@ -139,7 +176,7 @@ impl Options {
socket_timeout: get("socket_timeout"),
socket_connect_timeout: get("socket_connect_timeout"),
};
Ok((options, ignored))
Ok((options, unmodeled))
}
}

View file

@ -40,9 +40,9 @@ fn verify_certificate(cert_reqs: Option<&Entry>) -> Result<bool, RedisError> {
.map(|verify| verify.unwrap_or(true))
}
pub(super) fn url_tls(url: &str) -> Result<TlsOptions, RedisError> {
pub(super) fn url_tls(url: &str) -> Result<Option<TlsOptions>, RedisError> {
let Ok(parsed) = url::Url::parse(url) else {
return Ok(TlsOptions::verified());
return Ok(None);
};
let tls_scheme = parsed.scheme().eq_ignore_ascii_case("rediss");
let query: Vec<(String, Entry)> = parsed
@ -65,6 +65,9 @@ pub(super) fn url_tls(url: &str) -> Result<TlsOptions, RedisError> {
setting: entry.source.clone(),
});
}
if !tls_scheme {
return Ok(None);
}
let get = |key: &str| {
query
.iter()
@ -72,14 +75,14 @@ pub(super) fn url_tls(url: &str) -> Result<TlsOptions, RedisError> {
.find(|(name, _)| name == key)
.map(|(_, entry)| entry.clone())
};
Ok(TlsOptions {
Ok(Some(TlsOptions {
ca_certs: text(get("ssl_ca_certs").as_ref()).map(Into::into),
certfile: text(get("ssl_certfile").as_ref()).map(Into::into),
keyfile: text(get("ssl_keyfile").as_ref()).map(Into::into),
verify_certificate: verify_certificate(get("ssl_cert_reqs").as_ref())?,
check_hostname: text(get("ssl_check_hostname").as_ref())
.is_some_and(|text| url_bool(&text)),
})
}))
}
// redis-py's to_bool: only 0, F, FALSE, N and NO (any case) are false

View file

@ -1,10 +1,10 @@
use std::collections::{BTreeMap, HashMap};
use litellm_config::{
CacheSettings, Config, ConfiguredRedis, Expected, RedisError, Resolution, SecretSnapshot, Value,
CacheSettings, Config, ConfiguredRedis, Expected, RedisError, SecretSnapshot, Value,
};
use litellm_redis_types::{
AwsIamAuth, AzureAdAuth, ClusterNode, Endpoint, RedisAuth, ResolvedRedis, TlsOptions,
Auth, AwsIamAuth, AzureAdAuth, Node, ProviderHandle, Resolution, StaticAuth, Target, TlsOptions,
};
use rstest::rstest;
use serde_json::json;
@ -26,11 +26,22 @@ fn redis_cache_kwargs(kwargs: serde_json::Value) -> ConfiguredRedis {
)
}
const PROVIDER: ProviderHandle = ProviderHandle::new(7);
// python-bridge hands a credential_provider object over as a handle, so the contract cases do the same
fn resolve_kwargs(
kwargs: serde_json::Value,
environment: &HashMap<String, String>,
) -> Result<ResolvedRedis, RedisError> {
resolution(&redis_cache_kwargs(kwargs), environment).map(|resolution| resolution.redis)
) -> Result<Resolution, RedisError> {
let has_provider = kwargs
.get("credential_provider")
.is_some_and(|provider| !provider.is_null());
let configured = redis_cache_kwargs(kwargs);
let configured = match has_provider {
true => configured.with_credential_provider(PROVIDER),
false => configured,
};
resolution(&configured, environment)
}
fn coordination_resolution(
@ -50,15 +61,15 @@ fn coordination_resolution(
resolution(&configured, &environment)
}
fn resolve(yaml: &str, environment: &[(&str, &str)]) -> Result<ResolvedRedis, RedisError> {
coordination_resolution(yaml, environment).map(|resolution| resolution.redis)
fn resolve(yaml: &str, environment: &[(&str, &str)]) -> Result<Resolution, RedisError> {
coordination_resolution(yaml, environment)
}
fn resolve_cache(yaml: &str) -> Result<ResolvedRedis, RedisError> {
fn resolve_cache(yaml: &str) -> Result<Resolution, RedisError> {
resolve_cache_in(yaml, &[])
}
fn resolve_cache_in(yaml: &str, environment: &[(&str, &str)]) -> Result<ResolvedRedis, RedisError> {
fn resolve_cache_in(yaml: &str, environment: &[(&str, &str)]) -> Result<Resolution, RedisError> {
let lookup = |name: &str| {
environment
.iter()
@ -70,18 +81,16 @@ fn resolve_cache_in(yaml: &str, environment: &[(&str, &str)]) -> Result<Resolved
CacheSettings::Redis(settings) => settings.cache_params(),
CacheSettings::Other(_) => panic!("expected a Redis cache"),
};
configured
.resolve(
&SecretSnapshot::read(configured.secret_names(), &lookup),
&lookup,
)
.map(|resolution| resolution.redis)
configured.resolve(
&SecretSnapshot::read(configured.secret_names(), &lookup),
&lookup,
)
}
fn cluster_ports(resolved: &ResolvedRedis) -> Vec<u16> {
match &resolved.endpoint {
Endpoint::Cluster(cluster) => cluster.nodes.iter().map(|node| node.port).collect(),
endpoint => panic!("expected a cluster, got {endpoint:?}"),
fn cluster_ports(resolved: &Resolution) -> Vec<u16> {
match &resolved.target {
Target::Cluster(cluster) => cluster.nodes.iter().map(|node| node.port).collect(),
target => panic!("expected a cluster, got {target:?}"),
}
}
@ -107,10 +116,10 @@ fn resolves_redis_through_the_parsed_config(
#[case] expected: &str,
) {
let resolved = resolve(yaml, environment).unwrap();
let description = match resolved.endpoint {
Endpoint::Url { url, .. } => format!("url:{}", url.expose()),
Endpoint::Sentinel(sentinel) => format!("sentinel:{}", sentinel.service_name),
Endpoint::Host(host) => format!(
let description = match resolved.target {
Target::Url(url) => format!("url:{}", url.url.expose()),
Target::Sentinel(sentinel) => format!("sentinel:{}", sentinel.service_name),
Target::Standalone(host) => format!(
"host:{}:{}:{}:{}:{}:{}",
host.host,
host.port,
@ -122,7 +131,7 @@ fn resolves_redis_through_the_parsed_config(
.map(|timeout| timeout.as_millis().to_string())
.unwrap_or_else(|| "none".to_owned())
),
Endpoint::Cluster(cluster) => format!(
Target::Cluster(cluster) => format!(
"cluster:{}",
cluster
.nodes
@ -162,11 +171,11 @@ fn rejected_settings_name_their_source(
assert!(matches!(error, RedisError::Unsupported { setting } if setting == expected));
}
fn static_auth(username: Option<&str>, password: Option<&str>) -> RedisAuth {
RedisAuth::Static {
fn static_auth(username: Option<&str>, password: Option<&str>) -> Auth {
Auth::Static(StaticAuth {
username: username.map(litellm_secret_value::SecretValue::new),
password: password.map(litellm_secret_value::SecretValue::new),
}
})
}
#[rstest]
@ -181,11 +190,11 @@ fn an_explicit_host_keeps_environment_credentials() {
.unwrap();
assert_eq!(resolved.auth, static_auth(None, Some("environment-secret")));
assert!(matches!(resolved.endpoint, Endpoint::Host(host) if host.tls.is_some()));
assert!(matches!(resolved.target, Target::Standalone(host) if host.tls.is_some()));
}
fn azure(username: Option<&str>) -> RedisAuth {
RedisAuth::AzureAd(AzureAdAuth {
fn azure(username: Option<&str>) -> Auth {
Auth::AzureAd(AzureAdAuth {
client_id: Some("client".to_owned()),
tenant_id: None,
client_secret: None,
@ -193,8 +202,8 @@ fn azure(username: Option<&str>) -> RedisAuth {
})
}
fn aws(serverless: bool) -> RedisAuth {
RedisAuth::AwsIam(AwsIamAuth {
fn aws(serverless: bool) -> Auth {
Auth::AwsIam(AwsIamAuth {
user_name: "iam-user".to_owned(),
cache_name: "cache-1".to_owned(),
region: "us-east-1".to_owned(),
@ -208,15 +217,10 @@ fn aws(serverless: bool) -> RedisAuth {
&[],
static_auth(Some("user"), Some("pass"))
)]
#[case::an_explicit_provider_beats_every_cloud_auth(
"general_settings:\n coordination_redis:\n host: redis\n password: pass\n credential_provider: host-object\n gcp_service_account: account\n aws_iam_auth: true",
&[("REDIS_AZURE_AD_TOKEN", "true")],
RedisAuth::Host
)]
#[case::gcp_beats_azure_and_aws(
"general_settings:\n coordination_redis:\n host: redis\n password: pass\n azure_redis_ad_token: \"True\"\n aws_iam_auth: true",
&[("REDIS_GCP_SERVICE_ACCOUNT", "projects/-/serviceAccounts/sa@p.iam.gserviceaccount.com")],
RedisAuth::GcpIam { service_account: "projects/-/serviceAccounts/sa@p.iam.gserviceaccount.com".to_owned() }
Auth::GcpIam { service_account: "projects/-/serviceAccounts/sa@p.iam.gserviceaccount.com".to_owned() }
)]
#[case::azure_beats_aws_and_takes_the_environment_username(
"general_settings:\n coordination_redis:\n host: redis\n username: configured-user\n azure_redis_ad_token: true\n azure_client_id: client\n aws_iam_auth: true",
@ -246,11 +250,45 @@ fn aws(serverless: bool) -> RedisAuth {
fn auth_follows_python_precedence(
#[case] yaml: &str,
#[case] environment: &[(&str, &str)],
#[case] expected: RedisAuth,
#[case] expected: Auth,
) {
assert_eq!(resolve(yaml, environment).unwrap().auth, expected);
}
#[rstest]
fn a_provider_handle_beats_every_cloud_auth_and_static_credentials() {
let configured = redis_cache_kwargs(json!({
"host": "redis", "password": "pass", "credential_provider": "host-object",
"gcp_service_account": "account", "aws_iam_auth": true, "azure_redis_ad_token": "true",
}))
.with_credential_provider(PROVIDER);
let resolved = resolution(&configured, &HashMap::new()).unwrap();
assert_eq!(resolved.auth, Auth::Provider(PROVIDER));
}
#[rstest]
#[case::coordination(
"general_settings:\n coordination_redis:\n host: redis\n credential_provider: host-object",
&[]
)]
#[case::without_a_target(
"general_settings:\n coordination_redis:\n credential_provider: host-object",
&[("REDIS_HOST", "redis")]
)]
fn a_credential_provider_without_a_host_handle_fails(
#[case] yaml: &str,
#[case] environment: &[(&str, &str)],
) {
let error = resolve(yaml, environment).unwrap_err();
assert!(
matches!(error, RedisError::ProviderWithoutHandle),
"{error}"
);
}
#[rstest]
#[case::without_tls(
"general_settings:\n coordination_redis:\n host: redis\n aws_iam_auth: true\n aws_iam_user_name: u\n aws_iam_cache_name: c\n aws_iam_region: r",
@ -301,7 +339,7 @@ fn tls_options_resolve_like_redis_py(
let resolved = resolve(&yaml, environment).unwrap();
assert!(matches!(resolved.endpoint, Endpoint::Host(host) if host.tls == expected));
assert!(matches!(resolved.target, Target::Standalone(host) if host.tls == expected));
}
#[rstest]
@ -336,18 +374,18 @@ fn sentinel_nodes_resolve_to_a_sentinel_endpoint(
#[case] yaml: &str,
#[case] environment: &[(&str, &str)],
) {
let Endpoint::Sentinel(sentinel) = resolve(yaml, environment).unwrap().endpoint else {
let Target::Sentinel(sentinel) = resolve(yaml, environment).unwrap().target else {
panic!("expected a sentinel endpoint");
};
assert_eq!(
sentinel.sentinels,
sentinel.monitors,
vec![
ClusterNode {
Node {
host: "sentinel-a".to_owned(),
port: 26379
},
ClusterNode {
Node {
host: "sentinel-b".to_owned(),
port: 26380
},
@ -355,11 +393,11 @@ fn sentinel_nodes_resolve_to_a_sentinel_endpoint(
);
assert_eq!(sentinel.service_name, "primary");
assert_eq!(
sentinel
.sentinel_password
.as_ref()
.map(|password| password.expose()),
Some("sentinel-secret")
sentinel.monitor_auth,
Some(StaticAuth {
username: None,
password: Some(litellm_secret_value::SecretValue::new("sentinel-secret")),
})
);
assert_eq!(sentinel.db, 2);
}
@ -371,9 +409,9 @@ fn sentinel_nodes_resolve_to_a_sentinel_endpoint(
)]
fn sentinel_precedence(#[case] yaml: &str, #[case] expected: &str) {
let resolved = resolve(yaml, &[]).unwrap();
let description = match resolved.endpoint {
Endpoint::Url { url, .. } => format!("url:{}", url.expose()),
Endpoint::Sentinel(sentinel) => format!("sentinel:{}", sentinel.service_name),
let description = match resolved.target {
Target::Url(url) => format!("url:{}", url.url.expose()),
Target::Sentinel(sentinel) => format!("sentinel:{}", sentinel.service_name),
other => panic!("unexpected {other:?}"),
};
@ -523,7 +561,7 @@ fn a_null_canonical_startup_nodes_setting_shadows_the_cache_alias() {
)
.unwrap();
assert!(matches!(resolved.endpoint, Endpoint::Host(_)));
assert!(matches!(resolved.target, Target::Standalone(_)));
}
#[rstest]
@ -533,7 +571,7 @@ fn a_missing_canonical_startup_nodes_reference_shadows_the_cache_alias() {
)
.unwrap();
assert!(matches!(resolved.endpoint, Endpoint::Host(_)));
assert!(matches!(resolved.target, Target::Standalone(_)));
}
#[rstest]
@ -570,17 +608,18 @@ fn rejects_bad_socket_timeout(#[case] timeout: &str) {
#[rstest]
fn debug_redacts_redis_secrets_and_url() {
let config = Config::from_yaml(
"general_settings:\n coordination_redis:\n url: redis://user:password@redis:6379\n username: credential-user\n password: credential-password",
"general_settings:\n coordination_redis:\n url: redis://user:password@redis:6379\n username: credential-user\n password: credential-password\n sentinel_kwargs: {password: credential-passthrough}",
)
.unwrap();
let settings = config.general_settings.coordination_redis.unwrap();
let configured = settings.coordination().unwrap();
let resolved = resolution(&configured, &HashMap::new()).unwrap().redis;
let resolved = resolution(&configured, &HashMap::new()).unwrap();
let debug = format!("{settings:?} {configured:?} {resolved:?}");
assert!(!debug.contains("redis://user:password"));
assert!(!debug.contains("credential-user"));
assert!(!debug.contains("credential-password"));
assert!(!debug.contains("credential-passthrough"));
assert!(debug.contains("redacted") || debug.contains("REDACTED"));
}
@ -618,7 +657,7 @@ fn legacy_unknown_settings_do_not_change_resolution() {
)
.unwrap();
assert!(matches!(resolved.endpoint, Endpoint::Host(_)));
assert!(matches!(resolved.target, Target::Standalone(_)));
}
#[rstest]
@ -642,6 +681,48 @@ fn ignored_settings_are_named_by_their_source(
assert_eq!(resolution.ignored, vec![source.to_owned()]);
}
#[rstest]
#[case::configured(
json!({"host": "redis", "client_name": "worker", "read_from_replicas": true, "connection_class": "SSLConnection", "retry_on_error": null}),
&[],
json!({"client_name": "worker", "read_from_replicas": true}),
&["connection_class", "retry_on_error"],
)]
#[case::from_the_environment(
json!({"host": "redis"}),
&[("REDIS_CLIENT_NAME", "worker")],
json!({"client_name": "worker"}),
&[],
)]
fn settings_rust_does_not_model_reach_the_host_instead_of_being_dropped(
#[case] kwargs: serde_json::Value,
#[case] environment: &[(&str, &str)],
#[case] passthrough: serde_json::Value,
#[case] host_objects: &[&str],
) {
let environment: HashMap<String, String> = environment
.iter()
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
.collect();
let resolved = resolve_kwargs(kwargs, &environment).unwrap();
let passed: serde_json::Map<String, serde_json::Value> = resolved
.passthrough
.iter()
.map(|(key, value)| (key.to_owned(), value.clone()))
.collect();
assert_eq!(serde_json::Value::Object(passed), passthrough);
assert_eq!(
resolved
.host_objects
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
host_objects
);
}
fn tls_view(tls: Option<&TlsOptions>) -> serde_json::Value {
tls.map_or(serde_json::Value::Null, |tls| {
serde_json::json!({
@ -654,7 +735,7 @@ fn tls_view(tls: Option<&TlsOptions>) -> serde_json::Value {
})
}
fn nodes_view(nodes: &[ClusterNode]) -> Vec<String> {
fn nodes_view(nodes: &[Node]) -> Vec<String> {
nodes
.iter()
.map(|node| format!("{}:{}", node.host, node.port))
@ -681,48 +762,48 @@ fn expected_view(fields: serde_json::Value) -> serde_json::Value {
serde_json::Value::Object(defaults.into_iter().chain(fields).collect())
}
fn view(resolved: &ResolvedRedis) -> serde_json::Value {
let target = match &resolved.endpoint {
Endpoint::Host(host) => serde_json::json!({
fn view(resolved: &Resolution) -> serde_json::Value {
let target = match &resolved.target {
Target::Standalone(host) => serde_json::json!({
"mode": "host",
"host": host.host,
"port": host.port,
"db": host.db,
"tls": tls_view(host.tls.as_ref()),
}),
Endpoint::Url { url, tls } => serde_json::json!({
Target::Url(url) => serde_json::json!({
"mode": "url",
"url": url.expose(),
"tls": tls_view(url.expose().starts_with("rediss://").then_some(tls)),
"url": url.url.expose(),
"tls": tls_view(url.tls.as_ref()),
}),
Endpoint::Cluster(cluster) => serde_json::json!({
Target::Cluster(cluster) => serde_json::json!({
"mode": "cluster",
"nodes": nodes_view(&cluster.nodes),
"tls": tls_view(cluster.tls.as_ref()),
}),
Endpoint::Sentinel(sentinel) => serde_json::json!({
Target::Sentinel(sentinel) => serde_json::json!({
"mode": "sentinel",
"nodes": nodes_view(&sentinel.sentinels),
"nodes": nodes_view(&sentinel.monitors),
"service_name": sentinel.service_name,
"sentinel_password": expose(sentinel.sentinel_password.as_ref()),
"sentinel_password": expose(sentinel.monitor_auth.as_ref().and_then(|auth| auth.password.as_ref())),
"db": sentinel.db,
"tls": tls_view(sentinel.tls.as_ref()),
}),
};
let auth = match &resolved.auth {
RedisAuth::Static { username, password } => serde_json::json!({
Auth::Static(StaticAuth { username, password }) => serde_json::json!({
"kind": "static",
"username": expose(username.as_ref()),
"password": expose(password.as_ref()),
}),
RedisAuth::Host => serde_json::json!({"kind": "host"}),
RedisAuth::GcpIam { service_account } => {
Auth::Provider(_) => serde_json::json!({"kind": "host"}),
Auth::GcpIam { service_account } => {
serde_json::json!({"kind": "gcp_iam", "service_account": service_account})
}
RedisAuth::AzureAd(azure) => {
Auth::AzureAd(azure) => {
serde_json::json!({"kind": "azure_ad", "username": azure.username})
}
RedisAuth::AwsIam(aws) => serde_json::json!({
Auth::AwsIam(aws) => serde_json::json!({
"kind": "aws_iam",
"user_name": aws.user_name,
"cache_name": aws.cache_name,
@ -1089,9 +1170,11 @@ fn secret_manager_values_reach_the_resolver_through_the_snapshot() {
_ => None,
});
let resolved = settings.resolve(&secrets, &|_: &str| None).unwrap().redis;
let resolved = settings.resolve(&secrets, &|_: &str| None).unwrap();
assert!(matches!(&resolved.endpoint, Endpoint::Host(host) if host.host == "from-the-manager"));
assert!(
matches!(&resolved.target, Target::Standalone(host) if host.host == "from-the-manager")
);
assert_eq!(resolved.auth, static_auth(None, Some("manager-secret")));
}
@ -1110,11 +1193,10 @@ fn the_azure_username_comes_from_the_process_environment_like_python(
.resolve(&snapshot(secrets), &|name: &str| {
environment.get(name).map(|value| (*value).to_owned())
})
.unwrap()
.redis;
.unwrap();
assert!(
matches!(&resolved.auth, RedisAuth::AzureAd(azure) if azure.username.as_deref() == expected),
matches!(&resolved.auth, Auth::AzureAd(azure) if azure.username.as_deref() == expected),
"{:?}",
resolved.auth
);
@ -1148,8 +1230,8 @@ fn a_null_overrides_the_environment_only_where_python_passes_it_on(
}
.unwrap();
let Endpoint::Host(host) = resolved.endpoint else {
panic!("expected a host endpoint, got {:?}", resolved.endpoint);
let Target::Standalone(host) = resolved.target else {
panic!("expected a standalone target, got {:?}", resolved.target);
};
assert_eq!(host.tls.is_some(), tls);
}

View file

@ -2,17 +2,17 @@ use std::time::Duration;
use litellm_circuit_breaker::{Breaker, Config as BreakerConfig, State};
use litellm_config::{
CacheSettings, Config, ConfiguredRedis, Expected, RedisError, RedisSettings, Resolution,
SecretSnapshot, Value, environment_has_target,
CacheSettings, Config, ConfiguredRedis, Expected, RedisError, RedisSettings, SecretSnapshot,
Value, environment_has_target,
};
use litellm_core_utils::settings::Lookup;
use litellm_redis_client::RedisClient;
use litellm_redis_types::ResolvedRedis;
use litellm_redis_types::Resolution;
use crate::Error;
const ENVIRONMENT_PING_TIMEOUT: Duration = Duration::from_secs(2);
type Params = ResolvedRedis;
type Params = Resolution;
pub struct RedisClients {
pub coordination: Option<RedisClient>,
@ -108,11 +108,11 @@ fn redis_cache_params(config: &Config) -> Option<&RedisSettings> {
fn resolve(configured: &ConfiguredRedis, lookup: &dyn Lookup) -> Result<Params, RedisError> {
let secrets = SecretSnapshot::read(configured.secret_names(), lookup);
let Resolution { redis, ignored } = configured.resolve(&secrets, lookup)?;
for setting in ignored {
let resolution = configured.resolve(&secrets, lookup)?;
for setting in &resolution.ignored {
tracing::warn!(%setting, "Redis setting is accepted but ignored");
}
Ok(redis)
Ok(resolution)
}
fn environment(lookup: &dyn Lookup) -> Result<Option<Params>, Error> {
@ -241,9 +241,9 @@ mod tests {
use std::{collections::HashMap, time::Duration};
use litellm_config::{Config, RedisError};
use litellm_redis_client::{Endpoint, HostEndpoint};
use litellm_redis_client::{Standalone, StaticAuth};
use litellm_redis_testing::RedisServer;
use litellm_redis_types::RedisAuth;
use litellm_redis_types::{Auth, Passthrough};
use rstest::rstest;
use super::{Params, Plan, Target, connect, plan};
@ -251,19 +251,19 @@ mod tests {
fn host(name: &str, port: u16) -> Params {
Params {
endpoint: Endpoint::Host(HostEndpoint {
target: litellm_redis_client::Target::Standalone(Standalone {
host: name.to_owned(),
port,
tls: None,
db: 0,
}),
auth: RedisAuth::Static {
username: None,
password: None,
},
auth: Auth::Static(StaticAuth::default()),
namespace: None,
timeout: None,
connect_timeout: None,
passthrough: Passthrough::default(),
host_objects: Default::default(),
ignored: Vec::new(),
}
}
@ -431,7 +431,7 @@ mod tests {
| (None, Some(params)) => params,
(None, None) => panic!("expected a Redis target"),
};
assert_eq!(target.endpoint, host("env", 6379).endpoint);
assert_eq!(target.target, host("env", 6379).target);
}
#[rstest]

View file

@ -30,7 +30,7 @@ pub enum Ttl {
impl RedisClient {
pub fn new(config: RedisConfig) -> Result<Self, ConnectError> {
Ok(Self {
connection: Connection::new(&config.endpoint, &config.credentials, config.timeouts)?,
connection: Connection::new(&config.target, &config.credentials, config.timeouts)?,
namespace: config.namespace,
breaker: config.breaker,
events: config.events,

View file

@ -9,13 +9,13 @@ use litellm_circuit_breaker::Breaker;
use litellm_secret_value::SecretValue;
use crate::{ConnectError, Namespace, RedisEvents};
use litellm_redis_types::{ClusterEndpoint, Endpoint, TlsOptions};
use litellm_redis_types::{Cluster, Target, TlsOptions};
// litellm/constants.py REDIS_SOCKET_TIMEOUT, which _coerce_redis_kwargs_types sets when socket_timeout is unset
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
pub struct RedisConfig {
pub endpoint: Endpoint,
pub target: Target,
pub credentials: Credentials,
pub namespace: Namespace,
pub timeouts: Timeouts,
@ -140,27 +140,25 @@ pub(crate) fn addr(host: &str, port: u16, tls: Option<&TlsOptions>) -> Connectio
}
}
pub(crate) fn cluster_addrs(
endpoint: &ClusterEndpoint,
) -> impl Iterator<Item = ConnectionAddr> + '_ {
endpoint
pub(crate) fn cluster_addrs(cluster: &Cluster) -> impl Iterator<Item = ConnectionAddr> + '_ {
cluster
.nodes
.iter()
.map(|node| addr(&node.host, node.port, endpoint.tls.as_ref()))
.map(|node| addr(&node.host, node.port, cluster.tls.as_ref()))
}
pub(crate) fn connection_info(
endpoint: &Endpoint,
target: &Target,
credentials: &Credentials,
) -> Result<ConnectionInfo, ConnectError> {
let (info, tls) = match endpoint {
Endpoint::Url { url, tls } => (url.expose().into_connection_info()?, Some(tls)),
Endpoint::Host(host) => {
let (info, tls) = match target {
Target::Url(url) => (url.url.expose().into_connection_info()?, url.tls.as_ref()),
Target::Standalone(host) => {
let info = addr(&host.host, host.port, host.tls.as_ref()).into_connection_info()?;
let settings = info.redis_settings().clone().set_db(host.db);
(info.set_redis_settings(settings), host.tls.as_ref())
}
Endpoint::Cluster(_) | Endpoint::Sentinel(_) => {
Target::Cluster(_) | Target::Sentinel(_) => {
unreachable!("cluster and sentinel endpoints build their own connection info")
}
};

View file

@ -17,7 +17,7 @@ use redis::{
};
use crate::{
ClusterEndpoint, ConnectError, Credentials, Endpoint, SentinelEndpoint, Timeouts,
Cluster, ConnectError, Credentials, Sentinel, Target, Timeouts,
config::{self, addr},
error::Deployment,
};
@ -39,16 +39,16 @@ pub(crate) enum Connection {
impl Connection {
pub(crate) fn new(
endpoint: &Endpoint,
target: &Target,
credentials: &Credentials,
timeouts: Timeouts,
) -> Result<Self, ConnectError> {
match endpoint {
Endpoint::Url { .. } | Endpoint::Host(_) => {
Self::standalone(config::connection_info(endpoint, credentials)?, timeouts)
match target {
Target::Url(_) | Target::Standalone(_) => {
Self::standalone(config::connection_info(target, credentials)?, timeouts)
}
Endpoint::Cluster(cluster) => Self::cluster(cluster, credentials, timeouts),
Endpoint::Sentinel(sentinel) => Ok(Self::Sentinel(SentinelConnection::new(
Target::Cluster(cluster) => Self::cluster(cluster, credentials, timeouts),
Target::Sentinel(sentinel) => Ok(Self::Sentinel(SentinelConnection::new(
sentinel,
credentials,
timeouts,
@ -74,7 +74,7 @@ impl Connection {
}
fn cluster(
endpoint: &ClusterEndpoint,
endpoint: &Cluster,
credentials: &Credentials,
timeouts: Timeouts,
) -> Result<Self, ConnectError> {
@ -155,7 +155,7 @@ struct SentinelState {
discovery: tokio::sync::Mutex<SentinelClient>,
current: Mutex<Option<(u64, MultiplexedConnection)>>,
generations: AtomicU64,
endpoint: SentinelEndpoint,
endpoint: Sentinel,
credentials: Credentials,
config: AsyncConnectionConfig,
connect_timeout: Duration,
@ -163,12 +163,12 @@ struct SentinelState {
impl SentinelConnection {
fn new(
endpoint: &SentinelEndpoint,
endpoint: &Sentinel,
credentials: &Credentials,
timeouts: Timeouts,
) -> Result<Self, ConnectError> {
let sentinels = endpoint
.sentinels
.monitors
.iter()
.map(|node| addr(&node.host, node.port, endpoint.tls.as_ref()));
let builder = SentinelClientBuilder::new(
@ -176,12 +176,17 @@ impl SentinelConnection {
&endpoint.service_name,
SentinelServerType::Master,
)?;
let builder = match (&endpoint.sentinel_password, &credentials.username) {
(Some(password), Some(username)) => builder
.set_client_to_sentinel_username(username.expose())
.set_client_to_sentinel_password(password.expose()),
(Some(password), _) => builder.set_client_to_sentinel_password(password.expose()),
(None, _) => builder,
let (username, password) = match &endpoint.monitor_auth {
Some(auth) => (auth.username.as_ref(), auth.password.as_ref()),
None => (None, None),
};
let builder = match username {
Some(username) => builder.set_client_to_sentinel_username(username.expose()),
None => builder,
};
let builder = match password {
Some(password) => builder.set_client_to_sentinel_password(password.expose()),
None => builder,
};
let builder = match &endpoint.tls {
Some(tls) => builder
@ -248,7 +253,7 @@ impl SentinelConnection {
)));
}
};
let endpoint = Endpoint::Host(crate::HostEndpoint {
let endpoint = Target::Standalone(crate::Standalone {
host: host.to_owned(),
port,
tls: self.0.endpoint.tls.clone(),
@ -342,7 +347,7 @@ impl ConnectionLike for SentinelConnection {
#[cfg(test)]
mod tests {
use super::*;
use crate::ClusterNode;
use crate::Node;
use litellm_redis_testing::{RedisSentinel, SERVICE_NAME};
#[rstest::rstest]
@ -350,13 +355,13 @@ mod tests {
async fn a_late_failure_does_not_discard_the_reconnected_sentinel_master() {
let server = RedisSentinel::start().await.unwrap();
let (host, port) = server.sentinel();
let endpoint = SentinelEndpoint {
sentinels: vec![ClusterNode {
let endpoint = Sentinel {
monitors: vec![Node {
host: host.to_owned(),
port,
}],
service_name: SERVICE_NAME.to_owned(),
sentinel_password: None,
monitor_auth: None,
tls: None,
db: 0,
};

View file

@ -14,7 +14,7 @@ pub use config::{Credentials, DEFAULT_TIMEOUT, RedisConfig, Timeouts};
pub use error::{ConnectError, Error};
pub use events::{CallEvent, FailureKind, Operation, Outcome, RedisEvents};
pub use litellm_redis_types::{
ClusterEndpoint, ClusterNode, Endpoint, HostEndpoint, SentinelEndpoint, TlsOptions,
Cluster, Node, RedisUrl, Sentinel, Standalone, StaticAuth, Target, TlsOptions,
};
pub use namespace::Namespace;
pub use pipeline::Pipeline;

View file

@ -11,9 +11,9 @@ use rstest::rstest;
use litellm_circuit_breaker::{Breaker, Config, State};
use litellm_redis_client::{
Amount, CallEvent, Credentials, DEFAULT_TIMEOUT, Endpoint, Error, Expiry, FailureKind,
HostEndpoint, Namespace, Operation, Outcome, RedisClient, RedisConfig, RedisEvents, Script,
Timeouts, TlsOptions, Ttl,
Amount, CallEvent, Credentials, DEFAULT_TIMEOUT, Error, Expiry, FailureKind, Namespace,
Operation, Outcome, RedisClient, RedisConfig, RedisEvents, RedisUrl, Script, Standalone,
Target, Timeouts, Ttl,
};
use litellm_secret_value::SecretValue;
use support::{KEEP, connect, connect_guarded, connections, endpoint};
@ -223,7 +223,7 @@ async fn outage_events_report_the_failure_kind(
let server = RedisDeployment::start(topology).await.unwrap();
let recorded = Arc::new(Recorded::default());
let client = RedisClient::new(RedisConfig {
endpoint: endpoint(&server),
target: endpoint(&server),
namespace: Namespace::default(),
timeouts: Timeouts::new(Duration::from_millis(300), None),
breaker: None,
@ -277,7 +277,7 @@ async fn every_call_reports_its_operation_and_outcome(
let server = RedisDeployment::start(topology).await.unwrap();
let recorded = Arc::new(Recorded::default());
let client = RedisClient::new(RedisConfig {
endpoint: endpoint(&server),
target: endpoint(&server),
credentials: Credentials::NONE,
namespace: Namespace::default(),
timeouts: Timeouts::default(),
@ -735,9 +735,9 @@ async fn an_open_breaker_fails_every_pipeline_op_without_sending(
async fn a_host_endpoint_connects_to_its_database() {
let server = RedisServer::start().await.unwrap();
let port = server.url().rsplit(':').next().unwrap().parse().unwrap();
let connect = |endpoint| {
let connect = |target| {
RedisClient::new(RedisConfig {
endpoint,
target,
credentials: Credentials::NONE,
namespace: Namespace::default(),
timeouts: Timeouts::default(),
@ -745,11 +745,13 @@ async fn a_host_endpoint_connects_to_its_database() {
events: None,
})
};
let url = |url: String| Endpoint::Url {
url: SecretValue::new(url),
tls: TlsOptions::verified(),
let url = |url: String| {
Target::Url(RedisUrl {
url: SecretValue::new(url),
tls: None,
})
};
let in_db_3 = connect(Endpoint::Host(HostEndpoint {
let in_db_3 = connect(Target::Standalone(Standalone {
host: "127.0.0.1".to_owned(),
port,
tls: None,

View file

@ -1,15 +1,15 @@
use rstest::rstest;
use litellm_redis_client::{Credentials, Endpoint, SentinelEndpoint, TlsOptions};
use litellm_redis_client::{Credentials, RedisUrl, Sentinel, StaticAuth, Target, TlsOptions};
use litellm_secret_value::SecretValue;
#[rstest]
#[case::url(format!("{:?}", Endpoint::Url { url: SecretValue::new("redis://user:credential-value@cache:6379/0"), tls: TlsOptions::verified() }))]
#[case::url(format!("{:?}", Target::Url(RedisUrl { url: SecretValue::new("redis://user:credential-value@cache:6379/0"), tls: None })))]
#[case::static_password(format!("{:?}", Credentials { username: None, password: Some(SecretValue::new("credential-value")) }))]
#[case::sentinel_password(format!("{:?}", Endpoint::Sentinel(SentinelEndpoint {
sentinels: Vec::new(),
#[case::sentinel_password(format!("{:?}", Target::Sentinel(Sentinel {
monitors: Vec::new(),
service_name: "primary".to_owned(),
sentinel_password: Some(SecretValue::new("credential-value")),
monitor_auth: Some(StaticAuth { username: None, password: Some(SecretValue::new("credential-value")) }),
tls: None,
db: 0,
})))]
@ -47,7 +47,7 @@ async fn ping_over_tls(
check_hostname,
};
let client = litellm_redis_client::RedisClient::new(litellm_redis_client::RedisConfig {
endpoint: Endpoint::Host(litellm_redis_client::HostEndpoint {
target: Target::Standalone(litellm_redis_client::Standalone {
host: host.to_owned(),
port: server.port(),
tls: Some(tls),
@ -123,7 +123,7 @@ fn a_missing_tls_file_names_the_path() {
};
let error = litellm_redis_client::RedisClient::new(litellm_redis_client::RedisConfig {
endpoint: Endpoint::Host(litellm_redis_client::HostEndpoint {
target: Target::Standalone(litellm_redis_client::Standalone {
host: "127.0.0.1".to_owned(),
port: 6379,
tls: Some(tls),

View file

@ -8,8 +8,8 @@ use litellm_redis_testing::{
use rstest::rstest;
use litellm_redis_client::{
ClusterNode, Credentials, Endpoint, Error, HostEndpoint, Namespace, RedisClient, RedisConfig,
SentinelEndpoint, Timeouts,
Credentials, Error, Namespace, Node, RedisClient, RedisConfig, Sentinel, Standalone, Target,
Timeouts,
};
use litellm_secret_value::SecretValue;
use support::connect_as;
@ -20,7 +20,7 @@ async fn an_out_of_range_database_is_a_server_rejection() {
let server = RedisServer::start().await.unwrap();
let port = server.url().rsplit(':').next().unwrap().parse().unwrap();
let client = RedisClient::new(RedisConfig {
endpoint: Endpoint::Host(HostEndpoint {
target: Target::Standalone(Standalone {
host: "127.0.0.1".into(),
port,
tls: None,
@ -64,13 +64,13 @@ async fn a_rejected_credential_is_a_transport_failure() {
fn sentinel_client(sentinel: &RedisSentinel) -> RedisClient {
let (host, port) = sentinel.sentinel();
RedisClient::new(RedisConfig {
endpoint: Endpoint::Sentinel(SentinelEndpoint {
sentinels: vec![ClusterNode {
target: Target::Sentinel(Sentinel {
monitors: vec![Node {
host: host.to_owned(),
port,
}],
service_name: SERVICE_NAME.to_owned(),
sentinel_password: None,
monitor_auth: None,
tls: None,
db: 0,
}),

View file

@ -4,24 +4,24 @@ use std::time::Duration;
use litellm_circuit_breaker::Breaker;
use litellm_redis_client::{
ClusterEndpoint, ClusterNode, Credentials, Endpoint, Expiry, Namespace, RedisClient,
RedisConfig, Timeouts, TlsOptions,
Cluster, Credentials, Expiry, Namespace, Node, RedisClient, RedisConfig, RedisUrl, Target,
Timeouts,
};
use litellm_redis_testing::{ClientConnection, RedisDeployment};
use litellm_secret_value::SecretValue;
pub const KEEP: Expiry = Expiry::KeepExisting(Duration::from_secs(60));
pub fn endpoint(deployment: &RedisDeployment) -> Endpoint {
pub fn endpoint(deployment: &RedisDeployment) -> Target {
match deployment {
RedisDeployment::Standalone(server) => Endpoint::Url {
RedisDeployment::Standalone(server) => Target::Url(RedisUrl {
url: SecretValue::new(server.url()),
tls: TlsOptions::verified(),
},
RedisDeployment::Cluster(cluster) => Endpoint::Cluster(ClusterEndpoint {
tls: None,
}),
RedisDeployment::Cluster(cluster) => Target::Cluster(Cluster {
nodes: cluster
.nodes()
.map(|(host, port)| ClusterNode {
.map(|(host, port)| Node {
host: host.to_owned(),
port,
})
@ -42,7 +42,7 @@ pub fn connect_guarded(
breaker: Option<Breaker>,
) -> RedisClient {
RedisClient::new(RedisConfig {
endpoint: endpoint(deployment),
target: endpoint(deployment),
credentials: Credentials::NONE,
namespace: Namespace::new(namespace),
timeouts: Timeouts::new(timeout, None),
@ -54,7 +54,7 @@ pub fn connect_guarded(
pub fn connect_as(deployment: &RedisDeployment, credentials: Credentials) -> RedisClient {
RedisClient::new(RedisConfig {
endpoint: endpoint(deployment),
target: endpoint(deployment),
credentials,
namespace: Namespace::default(),
timeouts: Timeouts::new(Duration::from_secs(2), None),

View file

@ -1,3 +1,3 @@
- Turns a `litellm_redis_types::ResolvedRedis` into a `RedisClient`, the same way for every host. It depends on `litellm-redis-types`, never on `litellm-config`, so a host that resolves Redis some other way connects the same way. `redis-client` stays cloud-agnostic, so credentials are mapped here
- `RedisAuth::Host` (a Python `credential_provider` object) fails at connect with an error naming the key, since no Rust host can build one. Cloud auth (GCP, Azure, AWS) fails the same way until its token sources land, so a config that needs it never silently runs without Redis
- Turns a `litellm_redis_types::Resolution` into a `RedisClient`, the same way for every host. It depends on `litellm-redis-types`, never on `litellm-config`, so a host that resolves Redis some other way connects the same way. `redis-client` stays cloud-agnostic, so credentials are mapped here
- `Auth::Provider` (a handle for a Python `credential_provider` object) fails at connect with an error naming the key, since no Rust host can build one. Cloud auth (GCP, Azure, AWS) fails the same way until its token sources land, so a config that needs it never silently runs without Redis
- Tests run against real Redis from `litellm-redis-testing`

View file

@ -2,14 +2,14 @@ use litellm_circuit_breaker::Breaker;
use litellm_redis_client::{
Credentials, DEFAULT_TIMEOUT, Namespace, RedisClient, RedisConfig, Timeouts,
};
use litellm_redis_types::{RedisAuth, ResolvedRedis};
use litellm_redis_types::{Auth, Resolution, StaticAuth};
use crate::Error;
pub fn connect(resolved: ResolvedRedis, breaker: Option<Breaker>) -> Result<RedisClient, Error> {
tracing::info!(endpoint = ?resolved.endpoint, namespace = ?resolved.namespace, "redis client configured");
pub fn connect(resolved: Resolution, breaker: Option<Breaker>) -> Result<RedisClient, Error> {
tracing::info!(target = ?resolved.target, namespace = ?resolved.namespace, "redis client configured");
Ok(RedisClient::new(RedisConfig {
endpoint: resolved.endpoint,
target: resolved.target,
credentials: credentials(resolved.auth)?,
namespace: resolved.namespace.map(Namespace::new).unwrap_or_default(),
timeouts: Timeouts::new(
@ -21,17 +21,17 @@ pub fn connect(resolved: ResolvedRedis, breaker: Option<Breaker>) -> Result<Redi
})?)
}
fn credentials(auth: RedisAuth) -> Result<Credentials, Error> {
fn credentials(auth: Auth) -> Result<Credentials, Error> {
match auth {
RedisAuth::Static { username, password } => Ok(Credentials { username, password }),
RedisAuth::Host => Err(Error::NoHostCredentialProvider),
RedisAuth::GcpIam { .. } => Err(Error::CloudAuth {
Auth::Static(StaticAuth { username, password }) => Ok(Credentials { username, password }),
Auth::Provider(_) => Err(Error::NoHostCredentialProvider),
Auth::GcpIam { .. } => Err(Error::CloudAuth {
setting: "gcp_service_account",
}),
RedisAuth::AzureAd(_) => Err(Error::CloudAuth {
Auth::AzureAd(_) => Err(Error::CloudAuth {
setting: "azure_redis_ad_token",
}),
RedisAuth::AwsIam(_) => Err(Error::CloudAuth {
Auth::AwsIam(_) => Err(Error::CloudAuth {
setting: "aws_iam_auth",
}),
}

View file

@ -3,21 +3,26 @@ use std::{collections::HashMap, time::Duration};
use litellm_config::{RedisSettings, SecretSnapshot};
use litellm_redis_connect::{Error, connect};
use litellm_redis_testing::{RedisSentinel, RedisServer, RedisTlsServer, SERVICE_NAME, eventually};
use litellm_redis_types::ResolvedRedis;
use litellm_redis_types::{ProviderHandle, Resolution};
use rstest::rstest;
fn resolved(kwargs: serde_json::Value, environment: &[(&str, &str)]) -> ResolvedRedis {
// python-bridge hands a credential_provider object over as a handle, so these tests do the same
fn resolved(kwargs: serde_json::Value, environment: &[(&str, &str)]) -> Resolution {
let kwargs_name_a_provider = kwargs.get("credential_provider").is_some();
let settings: RedisSettings = serde_json::from_value(kwargs).unwrap();
let environment: HashMap<&str, &str> = environment.iter().copied().collect();
let lookup = |name: &str| environment.get(name).map(|value| (*value).to_owned());
let configured = settings.coordination().unwrap();
let configured = match kwargs_name_a_provider {
true => configured.with_credential_provider(ProviderHandle::new(1)),
false => configured,
};
configured
.resolve(
&SecretSnapshot::read(configured.secret_names(), &lookup),
&lookup,
)
.unwrap()
.redis
}
fn port(url: &str) -> u16 {

View file

@ -1,2 +1,2 @@
- `ResolvedRedis` and the endpoint, node, TLS and auth types inside it: what `config` resolves to, `redis-connect` turns into a client and `redis-client` connects with, so none of them converts another's copy
- Data only. It depends on `secret-value` and nothing else, so `config` never pulls in redis-rs or tokio. Anything that needs redis-rs, like building a `ConnectionInfo` or reading TLS files, lives in `redis-client`
- `Resolution` and the target, node, TLS and auth types inside it: what `config` resolves to, `redis-connect` turns into a client and `redis-client` connects with, so none of them converts another's copy. A host builds its redis-py client from the same `Resolution`, reattaching `passthrough` and `host_objects`
- Data only. It depends on `secret-value`, `serde_json` and `veil` and nothing else, so `config` never pulls in redis-rs or tokio. Anything that needs redis-rs, like building a `ConnectionInfo` or reading TLS files, lives in `redis-client`

View file

@ -7,3 +7,5 @@ repository.workspace = true
[dependencies]
litellm-secret-value.workspace = true
serde_json.workspace = true
veil.workspace = true

View file

@ -1,28 +1,41 @@
use std::{path::PathBuf, time::Duration};
use std::{
collections::{BTreeMap, BTreeSet},
path::PathBuf,
time::Duration,
};
use litellm_secret_value::SecretValue;
use veil::Redact;
/// One Redis connection with every setting, environment value and secret already applied, the
/// value a host hands to the connector.
/// One Redis connection with every setting, environment value and secret already applied. Each
/// client library builds its client from this one value, so they cannot read a setting differently.
#[derive(Clone, Debug, PartialEq)]
pub struct ResolvedRedis {
pub endpoint: Endpoint,
pub auth: RedisAuth,
pub struct Resolution {
pub target: Target,
pub auth: Auth,
pub namespace: Option<String>,
pub timeout: Option<Duration>,
pub connect_timeout: Option<Duration>,
/// Plain settings the resolver does not model, kept verbatim for the redis-py projection.
pub passthrough: Passthrough,
/// Settings whose value is a host object, like redis-py's `retry` or `connection_class`, which
/// only the host can reattach.
pub host_objects: BTreeSet<String>,
/// Where each setting the Rust client accepts without honoring came from: its key, or the
/// environment variable that set it.
pub ignored: Vec<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Endpoint {
Url { url: SecretValue, tls: TlsOptions },
Host(HostEndpoint),
Cluster(ClusterEndpoint),
Sentinel(SentinelEndpoint),
pub enum Target {
Standalone(Standalone),
Url(RedisUrl),
Cluster(Cluster),
Sentinel(Sentinel),
}
#[derive(Clone, Debug, PartialEq)]
pub struct HostEndpoint {
pub struct Standalone {
pub host: String,
pub port: u16,
pub tls: Option<TlsOptions>,
@ -30,27 +43,35 @@ pub struct HostEndpoint {
}
#[derive(Clone, Debug, PartialEq)]
pub struct ClusterEndpoint {
pub nodes: Vec<ClusterNode>,
pub struct RedisUrl {
pub url: SecretValue,
/// Set only for a `rediss://` url, from its query parameters.
pub tls: Option<TlsOptions>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ClusterNode {
pub struct Cluster {
pub nodes: Vec<Node>,
pub tls: Option<TlsOptions>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Node {
pub host: String,
pub port: u16,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SentinelEndpoint {
pub sentinels: Vec<ClusterNode>,
pub struct Sentinel {
pub monitors: Vec<Node>,
pub service_name: String,
pub sentinel_password: Option<SecretValue>,
/// How to authenticate to the monitors, which are separate servers from the data node.
pub monitor_auth: Option<StaticAuth>,
pub tls: Option<TlsOptions>,
pub db: i64,
}
#[derive(Clone, Debug, Default, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub struct TlsOptions {
pub ca_certs: Option<PathBuf>,
pub certfile: Option<PathBuf>,
@ -62,20 +83,20 @@ pub struct TlsOptions {
impl TlsOptions {
pub fn verified() -> Self {
Self {
ca_certs: None,
certfile: None,
keyfile: None,
verify_certificate: true,
..Self::default()
check_hostname: false,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum RedisAuth {
Static {
username: Option<SecretValue>,
password: Option<SecretValue>,
},
/// The host supplies credentials, like a redis-py `credential_provider` object.
Host,
pub enum Auth {
Static(StaticAuth),
/// Credentials come from a host object, like a redis-py `credential_provider`.
Provider(ProviderHandle),
GcpIam {
service_account: String,
},
@ -83,6 +104,27 @@ pub enum RedisAuth {
AwsIam(AwsIamAuth),
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StaticAuth {
pub username: Option<SecretValue>,
pub password: Option<SecretValue>,
}
/// Names one credential provider the host holds. Two resolutions with the same handle use the
/// same provider.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ProviderHandle(u64);
impl ProviderHandle {
pub const fn new(id: u64) -> Self {
Self(id)
}
pub const fn id(self) -> u64 {
self.0
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct AzureAdAuth {
pub client_id: Option<String>,
@ -98,3 +140,21 @@ pub struct AwsIamAuth {
pub region: String,
pub serverless: bool,
}
/// Settings passed through untouched. They can hold credentials, so they never print.
#[derive(Clone, Default, PartialEq, Redact)]
pub struct Passthrough(#[redact(with = "[REDACTED]")] BTreeMap<String, serde_json::Value>);
impl Passthrough {
pub fn new(settings: BTreeMap<String, serde_json::Value>) -> Self {
Self(settings)
}
pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
self.0.get(key)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &serde_json::Value)> {
self.0.iter().map(|(key, value)| (key.as_str(), value))
}
}