mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
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:
parent
c6324a3e2c
commit
fe4f63a2e2
6 changed files with 146 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue