diff --git a/litellm/rust/crates/inference-messages/src/handler.rs b/litellm/rust/crates/inference-messages/src/handler.rs index c5d00d6d1c0..735b5003b4e 100644 --- a/litellm/rust/crates/inference-messages/src/handler.rs +++ b/litellm/rust/crates/inference-messages/src/handler.rs @@ -184,6 +184,7 @@ pub(super) async fn execute( auth: &route.auth, workload_identity: route.workload_identity.as_deref(), azure_ad_token_hosts: route.azure_ad_token_hosts.clone(), + deferred_secrets: Some(route.secrets.as_ref()), }, provider, model, @@ -255,6 +256,7 @@ struct CallerCredentials<'a> { auth: &'a AuthServices, workload_identity: Option<&'a dyn WorkloadIdentity>, azure_ad_token_hosts: AzureAdTokenHosts, + deferred_secrets: Option<&'a dyn litellm_secrets::source::SecretSource>, } async fn provider_call( @@ -309,6 +311,18 @@ async fn provider_call( .module_api_key .as_ref() .map(SecretValue::expose)); + let deferred = match ( + credentials.deferred_secrets, + config.deferred_secret_names(&forwarded, configured_key, &connection_params, &env_lookup), + ) { + (Some(source), names) if !names.is_empty() => Some(source.resolve(names).await?), + _ => None, + }; + let env_lookup = |key: &str| { + secrets + .get(key) + .or_else(|| deferred.as_ref().and_then(|deferred| deferred.get(key))) + }; let validated = match config.validate_federated_environment( forwarded, configured_key, @@ -1100,6 +1114,7 @@ mod tests { auth: &AuthServices::default(), workload_identity, azure_ad_token_hosts: AzureAdTokenHosts::default(), + deferred_secrets: None, }, config, model, diff --git a/litellm/rust/crates/inference-messages/tests/messages/secrets.rs b/litellm/rust/crates/inference-messages/tests/messages/secrets.rs index e5405ea615e..563bbbbce48 100644 --- a/litellm/rust/crates/inference-messages/tests/messages/secrets.rs +++ b/litellm/rust/crates/inference-messages/tests/messages/secrets.rs @@ -19,8 +19,7 @@ use super::*; &[ "AZURE_OPENAI_API_KEY", "AZURE_API_KEY", - "AZURE_API_BASE", - "AZURE_AD_TOKEN" + "AZURE_API_BASE" ] )] #[case::deepseek( @@ -224,3 +223,43 @@ async fn azure_without_a_base_anywhere_fails_before_sending(call: MessagesCall) }) ); } + +#[rstest] +#[case::a_keyed_call(Some("sk-azure"), &[], false)] +#[case::a_caller_x_api_key(None, &[("x-api-key", "caller")], false)] +#[case::a_caller_bearer(None, &[("Authorization", "Bearer caller")], false)] +#[case::a_caller_api_key(None, &[("Api-Key", "caller")], false)] +#[case::no_key_and_no_caller_credential(None, &[], true)] +#[tokio::test] +async fn azure_ad_token_is_looked_up_only_where_python_looks_it_up( + call: MessagesCall, + #[case] api_key: Option<&str>, + #[case] forwarded: &[(&str, &str)], + #[case] looked_up: bool, +) { + let upstream = upstream([message_response()]).await; + let secrets = Arc::new(RecordingSecrets::empty()); + + run_with( + secrets.clone(), + MessagesCall { + custom_llm_provider: Some("azure_ai".into()), + litellm_params: Default::default(), + api_key: api_key.map(Into::into), + api_base: Some(upstream.uri()), + extra_headers: headers(forwarded.iter().copied()), + ..call + }, + ) + .await + .expect("messages call succeeds"); + + assert_eq!( + secrets + .requested() + .iter() + .filter(|name| name.as_str() == "AZURE_AD_TOKEN") + .count(), + usize::from(looked_up) + ); +} diff --git a/litellm/rust/crates/llms/src/azure_ai/messages/AGENTS.md b/litellm/rust/crates/llms/src/azure_ai/messages/AGENTS.md index 85583aca47e..fd8df31394e 100644 --- a/litellm/rust/crates/llms/src/azure_ai/messages/AGENTS.md +++ b/litellm/rust/crates/llms/src/azure_ai/messages/AGENTS.md @@ -2,7 +2,7 @@ - Claude on Azure AI Foundry: reuses `anthropic/messages` shaping with the Azure `/anthropic` path - Auth follows Python's `BaseAzureLLM._base_validate_azure_environment` order: the call's key, `litellm.api_key`, `litellm.azure_key`, `AZURE_OPENAI_API_KEY`, `AZURE_API_KEY`, each skipped only when empty, sent as `x-api-key` -- Without a key the config walks Python's `get_azure_ad_token` order, reading every setting from the call and then where Python reads it: `AZURE_AD_TOKEN` through the secret lookup (`get_secret_str`), the other Entra variables from the process environment (`os.getenv`) only when no key is set. A static token, the caller's provider and client secret run natively (`litellm-auth-azure`). Username and password, OIDC references, `os.environ/` references, an uncallable provider and token refresh go to the host's `get_azure_ad_token`, so Python orders and mints those. No token sends the call unauthenticated +- Without a key the config walks Python's `get_azure_ad_token` order, reading every setting from the call and then where Python reads it: `AZURE_AD_TOKEN` through the secret lookup (`get_secret_str`, deferred through `deferred_secret_names`), the other Entra variables from the process environment (`os.getenv`), all only when no key or caller credential is present. A static token, the caller's provider and client secret run natively (`litellm-auth-azure`). Username and password, OIDC references, `os.environ/` references, an uncallable provider and token refresh go to the host's `get_azure_ad_token`, so Python orders and mints those. No token sends the call unauthenticated - Deliberate differences from Python, approved by the owner: a caller `x-api-key`, `api-key` in any casing (sent as `x-api-key`) or `Authorization` other than a blank bearer is the only credential sent. No deployment key and no Entra ID token goes beside or over it, and the Entra ID path is never run # references diff --git a/litellm/rust/crates/llms/src/azure_ai/messages/transformation.rs b/litellm/rust/crates/llms/src/azure_ai/messages/transformation.rs index 232f533d7f9..d7c47222f93 100644 --- a/litellm/rust/crates/llms/src/azure_ai/messages/transformation.rs +++ b/litellm/rust/crates/llms/src/azure_ai/messages/transformation.rs @@ -107,10 +107,32 @@ impl BaseMessagesConfig for AzureAnthropicMessagesConfig { AZURE_OPENAI_API_KEY_ENV, AZURE_API_KEY_ENV, AZURE_API_BASE_ENV, - AZURE_AD_TOKEN_ENV, ] } + /// `AZURE_AD_TOKEN`, which Python reads through `get_secret_str` only when there is no key, + /// no caller credential and no `azure_ad_token` param. + fn deferred_secret_names( + &self, + headers: &Headers, + api_key: Option<&str>, + litellm_params: &LitellmParams, + env_lookup: &dyn Fn(&str) -> Option, + ) -> &'static [&'static str] { + let token_param = litellm_params + .extra + .get("azure_ad_token") + .and_then(Value::as_str) + .is_some_and(|token| !token.is_empty()); + match has_caller_credential(headers) + || resolve_azure_api_key(api_key, env_lookup).is_some() + || token_param + { + true => &[], + false => &[AZURE_AD_TOKEN_ENV], + } + } + /// A forwarded `x-api-key`, `api-key` (sent as `x-api-key`) or `Authorization` other than a /// blank bearer is the only credential sent: no deployment key and no Entra ID token. Otherwise the Azure key goes in `x-api-key`, and /// without one an Entra ID token is minted as Python's `get_azure_ad_token` does. @@ -156,6 +178,12 @@ impl BaseMessagesConfig for AzureAnthropicMessagesConfig { } } +fn has_caller_credential(headers: &Headers) -> bool { + has_header(headers, API_KEY_PLACEMENT.header_name()) + || has_header(headers, AZURE_API_KEY_HEADER) + || has_caller_authorization(headers) +} + /// Any `Authorization` the caller sends is their credential, except a bearer with no token. fn has_caller_authorization(headers: &Headers) -> bool { has_bearer_auth(headers) @@ -1079,14 +1107,52 @@ mod tests { ); let requested = requested.into_inner(); assert!(!requested.is_empty()); + let deferred = AZURE_ANTHROPIC_MESSAGES_CONFIG.deferred_secret_names( + &Vec::new(), + None, + &LitellmParams::default(), + &|_| None, + ); let undeclared: Vec<&String> = requested .iter() .filter(|name| { !AZURE_ANTHROPIC_MESSAGES_CONFIG .secret_names() .contains(&name.as_str()) + && !deferred.contains(&name.as_str()) }) .collect(); assert_eq!(undeclared, Vec::<&String>::new()); } + + #[rstest] + #[case::no_key_and_no_caller_credential(&[], None, json!({}), &["AZURE_AD_TOKEN"])] + #[case::a_key(&[], Some("sk-azure"), json!({}), &[])] + #[case::an_azure_ad_token_param(&[], None, json!({"azure_ad_token": "tok"}), &[])] + #[case::an_empty_azure_ad_token_param(&[], None, json!({"azure_ad_token": ""}), &["AZURE_AD_TOKEN"])] + #[case::a_caller_x_api_key(&[("X-Api-Key", "caller")], None, json!({}), &[])] + #[case::a_caller_api_key(&[("API-Key", "caller")], None, json!({}), &[])] + #[case::a_caller_bearer(&[("authorization", "Bearer caller")], None, json!({}), &[])] + #[case::a_caller_basic_authorization(&[("Authorization", "Basic caller")], None, json!({}), &[])] + #[case::a_blank_caller_bearer(&[("Authorization", "Bearer ")], None, json!({}), &["AZURE_AD_TOKEN"])] + fn the_azure_ad_token_secret_is_read_only_where_python_reads_it( + #[case] forwarded: &[(&str, &str)], + #[case] api_key: Option<&str>, + #[case] params: serde_json::Value, + #[case] expected: &[&str], + ) { + let headers: Headers = forwarded + .iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect(); + assert_eq!( + AZURE_ANTHROPIC_MESSAGES_CONFIG.deferred_secret_names( + &headers, + api_key, + &litellm_params(params), + &|_| None + ), + expected + ); + } } diff --git a/litellm/rust/crates/llms/src/base_llm/messages/transformation.rs b/litellm/rust/crates/llms/src/base_llm/messages/transformation.rs index 13c021c2aec..5b1d739d426 100644 --- a/litellm/rust/crates/llms/src/base_llm/messages/transformation.rs +++ b/litellm/rust/crates/llms/src/base_llm/messages/transformation.rs @@ -67,6 +67,18 @@ pub trait BaseMessagesConfig: Sync { fn secret_names(&self) -> &'static [&'static str]; + /// Secrets read only on some paths, as Python's lazy `get_secret_str` calls are: resolved + /// just before `validate_environment`, and only when this call names them. + fn deferred_secret_names( + &self, + _headers: &Headers, + _api_key: Option<&str>, + _litellm_params: &LitellmParams, + _env_lookup: &dyn Fn(&str) -> Option, + ) -> &'static [&'static str] { + &[] + } + /// Shapes the forwarded headers and names the credential, the way Python's /// `validate_environment` does, without applying it: `resolve_auth` does that once /// for every config. diff --git a/tests/rust-python-harness/strategies/e2e_parity/sdk/messages/test_native_parity.py b/tests/rust-python-harness/strategies/e2e_parity/sdk/messages/test_native_parity.py index e801c07d7f7..e2c2231d5af 100644 --- a/tests/rust-python-harness/strategies/e2e_parity/sdk/messages/test_native_parity.py +++ b/tests/rust-python-harness/strategies/e2e_parity/sdk/messages/test_native_parity.py @@ -3345,6 +3345,10 @@ CALLER_CREDENTIAL_SKIPS_ENTRA: Final = ( "Approved by the owner, same class as A06-A08: a caller credential header is the only credential Rust sends " "and no Entra ID token is minted, Python adds the Entra token (joined into one Authorization or beside x-api-key)" ) +NON_BEARER_CALLER_AUTHORIZATION: Final = ( + "Approved by the owner, same class as A06-A08: a caller Authorization other than a bearer (Basic) replaces the " + "azure_ai operator key on Rust, Python sends the operator key beside it" +) A10_CALLER_BEARER_ON_ENTRA: Final = ( "A10 (approved by the owner): a caller bearer on an Entra-only azure_ai deployment is sent as is on Rust, " "Python overwrites it with the deployment's Entra token" @@ -3471,6 +3475,12 @@ AZURE_AUTH_CASES: Final = tuple( model=AZURE_CLAUDE, gaps=_gaps(request=A09_MIXED_CASE_API_KEY), ), + Case( + "azure_ai_caller_basic_authorization", + _kw(extra_headers={"Authorization": "Basic Y2FsbGVyOnNlY3JldA=="}), + model=AZURE_CLAUDE, + gaps=_gaps(request=NON_BEARER_CALLER_AUTHORIZATION), + ), Case( "azure_ai_no_credential", _kw(api_key=None),