fix(messages): look up AZURE_AD_TOKEN only where python does on rust

Python reads AZURE_AD_TOKEN through get_secret_str only when the azure_ai
call has no key, no caller credential and no azure_ad_token param
(litellm/llms/azure/common_utils.py:580). Messages configs can now name
deferred secrets, resolved right before validate_environment and only when
the config names them for that call, so a keyed call or a call carrying its
own credential never asks the secret lookup for the Entra token.

Approved by the owner, same class as A06-A08: a caller Authorization other
than a bearer, such as Basic, replaces the azure_ai operator key instead of
being sent beside it as Python does.
This commit is contained in:
Claude 2026-11-12 22:52:19 +00:00
parent c6324a3e2c
commit fe4f63a2e2
No known key found for this signature in database
6 changed files with 146 additions and 4 deletions

View file

@ -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,

View file

@ -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)
);
}

View file

@ -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

View file

@ -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<String>,
) -> &'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
);
}
}

View file

@ -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<String>,
) -> &'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.

View file

@ -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),