From 398f8ac165525d4e3af5714fa8a7bb45db14ec00 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 00:05:46 +0000 Subject: [PATCH] fix(messages): build azure_ai urls and resolve the base like python on rust The native azure_ai Messages URL builder now ports Python's azure_anthropic_messages_url. It cuts at /anthropic in the path only, so a base whose userinfo starts with "anthropic" no longer sends the key to http://anthropic/v1/messages (security regression fix). It keeps the base untrimmed and moves the slash before a query as Python does. The base resolves as Python's `api_base or AZURE_API_BASE`: a blank call base is kept, an empty one falls back to the environment, and the environment value is used as given. With neither, the call raises Python's bare ValueError ("Missing Azure API Base ..."), flagged as raised by Python and as a late rejection, so the failure log carries the resolved provider and model parameters as Python's does. --- .../tests/messages/secrets.rs | 7 +- .../crates/llms/src/anthropic/common_utils.rs | 2 +- .../crates/llms/src/azure_ai/common_utils.rs | 12 +- .../src/azure_ai/messages/transformation.rs | 193 ++++++++++++------ litellm/rust/crates/llms/src/error.rs | 6 + .../rust/crates/python-bridge/src/errors.rs | 13 ++ 6 files changed, 159 insertions(+), 74 deletions(-) diff --git a/litellm/rust/crates/inference-messages/tests/messages/secrets.rs b/litellm/rust/crates/inference-messages/tests/messages/secrets.rs index 563bbbbce48..6c09844d587 100644 --- a/litellm/rust/crates/inference-messages/tests/messages/secrets.rs +++ b/litellm/rust/crates/inference-messages/tests/messages/secrets.rs @@ -217,10 +217,9 @@ async fn azure_without_a_base_anywhere_fails_before_sending(call: MessagesCall) assert_eq!( error, - Error::Auth(litellm_auth::Error::MissingApiBase { - provider: "Azure", - guidance: "Set `api_base` or the AZURE_API_BASE environment variable. Expected format: https://.services.ai.azure.com/anthropic" - }) + Error::from(litellm_llms::Error::InvalidRequest( + litellm_llms::ErrorDetail::MissingAzureApiBase + )) ); } diff --git a/litellm/rust/crates/llms/src/anthropic/common_utils.rs b/litellm/rust/crates/llms/src/anthropic/common_utils.rs index 17150547c59..48a64be84bb 100644 --- a/litellm/rust/crates/llms/src/anthropic/common_utils.rs +++ b/litellm/rust/crates/llms/src/anthropic/common_utils.rs @@ -194,7 +194,7 @@ fn url_path(url: &str) -> &str { } /// Python's `_strip_trailing_slashes_keeping_the_scheme`. -fn strip_trailing_slashes(api_base: &str) -> String { +pub(crate) fn strip_trailing_slashes(api_base: &str) -> String { let stripped = api_base.trim_end_matches('/'); if stripped.ends_with(':') && !stripped.contains('/') && !stripped.contains('?') { return api_base.to_string(); diff --git a/litellm/rust/crates/llms/src/azure_ai/common_utils.rs b/litellm/rust/crates/llms/src/azure_ai/common_utils.rs index ec2db612733..c825dd5d855 100644 --- a/litellm/rust/crates/llms/src/azure_ai/common_utils.rs +++ b/litellm/rust/crates/llms/src/azure_ai/common_utils.rs @@ -1,5 +1,4 @@ -use crate::Error; -use litellm_core_utils::settings::resolve_non_empty; +use crate::{Error, ErrorDetail}; pub const AZURE_OPENAI_API_KEY_ENV: &str = "AZURE_OPENAI_API_KEY"; pub const AZURE_API_KEY_ENV: &str = "AZURE_API_KEY"; @@ -47,10 +46,15 @@ pub fn resolve_azure_api_key( }) } +/// Python's `api_base or get_secret_str("AZURE_API_BASE")`: the call's base unless empty, else the +/// environment's as it is. pub fn resolve_azure_api_base( api_base: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, ) -> Result { - resolve_non_empty(api_base, env_lookup, &[AZURE_API_BASE_ENV]) - .ok_or_else(|| Error::from(litellm_auth::Error::MissingApiBase { provider: "Azure", guidance: "Set `api_base` or the AZURE_API_BASE environment variable. Expected format: https://.services.ai.azure.com/anthropic" })) + api_base + .filter(|base| !base.is_empty()) + .map(str::to_string) + .or_else(|| env_lookup(AZURE_API_BASE_ENV)) + .ok_or(Error::InvalidRequest(ErrorDetail::MissingAzureApiBase)) } 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 d7c47222f93..de4639a87d0 100644 --- a/litellm/rust/crates/llms/src/azure_ai/messages/transformation.rs +++ b/litellm/rust/crates/llms/src/azure_ai/messages/transformation.rs @@ -14,7 +14,7 @@ use serde_json::Value; use crate::{ Error, anthropic::{ - common_utils::API_KEY_PLACEMENT, + common_utils::{API_KEY_PLACEMENT, strip_trailing_slashes}, messages::{ handler::shape_anthropic_messages_request, transformation::{transform_messages_request, update_headers_with_safeguards_beta}, @@ -38,6 +38,7 @@ use crate::{ }; const ANTHROPIC_PATH_SEGMENT: &str = "/anthropic"; +const SCHEME_SEPARATOR: &str = "://"; const AZURE_API_KEY_HEADER: &str = "api-key"; const OIDC_PREFIX: &str = "oidc/"; const AUTHORIZATION_HEADER: &str = "authorization"; @@ -305,23 +306,35 @@ fn entra_id_auth( } } +/// Python's `azure_anthropic_messages_url`: the base is cut at the first `/anthropic` of its path, +/// never of its host, and kept untrimmed. pub fn complete_azure_anthropic_url( api_base: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, ) -> Result { let api_base = resolve_azure_api_base(api_base, env_lookup)?; - - let api_base = api_base.trim_end_matches('/'); - - if api_base.ends_with(MESSAGES_PATH) { - return Ok(api_base.to_string()); + let base = api_base.trim_end_matches('/'); + if base.ends_with(MESSAGES_PATH) { + return Ok(base.to_string()); } - - let with_anthropic = match api_base.split_once(ANTHROPIC_PATH_SEGMENT) { - Some((prefix, _)) => format!("{prefix}{ANTHROPIC_PATH_SEGMENT}"), - None => format!("{api_base}{ANTHROPIC_PATH_SEGMENT}"), + let path_start = match base.find(SCHEME_SEPARATOR) { + Some(scheme) => { + let authority = scheme + SCHEME_SEPARATOR.len(); + base[authority..].find('/').map(|slash| authority + slash) + } + None => Some(0), }; - Ok(format!("{with_anthropic}{MESSAGES_PATH}")) + let root = path_start + .and_then(|start| { + base[start..] + .find(ANTHROPIC_PATH_SEGMENT) + .map(|anthropic| &base[..start + anthropic]) + }) + .unwrap_or(base); + Ok(format!( + "{}{ANTHROPIC_PATH_SEGMENT}{MESSAGES_PATH}", + strip_trailing_slashes(root) + )) } #[cfg(test)] @@ -333,6 +346,7 @@ mod tests { use litellm_auth::CredentialPlacement; use super::*; + use crate::ErrorDetail; use crate::base_llm::messages::{ context::MessagesModelCapabilities, mid_conversation_system::CONVERTED_SYSTEM_NOTE, }; @@ -345,67 +359,116 @@ mod tests { serde_json::to_value(request).expect("serializable request") } - #[test] - fn url_appends_anthropic_and_messages_suffix() { - let url = - complete_azure_anthropic_url(Some("https://resource.services.ai.azure.com"), &|_| None) - .expect("url builds"); + #[rstest] + #[case::bare_host( + "https://resource.services.ai.azure.com", + "https://resource.services.ai.azure.com/anthropic/v1/messages" + )] + #[case::anthropic_segment( + "https://resource.services.ai.azure.com/anthropic", + "https://resource.services.ai.azure.com/anthropic/v1/messages" + )] + #[case::complete_endpoint( + "https://resource.services.ai.azure.com/anthropic/v1/messages", + "https://resource.services.ai.azure.com/anthropic/v1/messages" + )] + #[case::messages_path_without_anthropic( + "https://resource.services.ai.azure.com/v1/messages", + "https://resource.services.ai.azure.com/v1/messages" + )] + #[case::path_after_anthropic_is_dropped( + "https://resource.services.ai.azure.com/anthropic/extra/", + "https://resource.services.ai.azure.com/anthropic/v1/messages" + )] + #[case::host_starting_with_anthropic( + "http://anthropic-x.services.ai.azure.com/d/dep/anthropic", + "http://anthropic-x.services.ai.azure.com/d/dep/anthropic/v1/messages" + )] + #[case::bare_host_starting_with_anthropic( + "http://anthropic-x.services.ai.azure.com", + "http://anthropic-x.services.ai.azure.com/anthropic/v1/messages" + )] + #[case::userinfo_starting_with_anthropic( + "http://anthropic@resource.services.ai.azure.com/d/dep", + "http://anthropic@resource.services.ai.azure.com/d/dep/anthropic/v1/messages" + )] + #[case::empty_host_is_kept_for_the_client_to_refuse( + "http:///d/dep/anthropic", + "http:///d/dep/anthropic/v1/messages" + )] + #[case::without_scheme_cuts_anywhere( + "resource.services.ai.azure.com/d/dep/anthropic", + "resource.services.ai.azure.com/d/dep/anthropic/v1/messages" + )] + #[case::slash_before_query_moves_behind_the_path( + "https://h/d/dep/?q=1", + "https://h/d/dep?q=1/anthropic/v1/messages" + )] + #[case::trailing_spaces_without_anthropic_are_kept( + "https://h/d/dep ", + "https://h/d/dep /anthropic/v1/messages" + )] + #[case::trailing_spaces_after_anthropic_are_cut( + "https://h/d/dep/anthropic ", + "https://h/d/dep/anthropic/v1/messages" + )] + #[case::trailing_crlf_after_anthropic_is_cut( + "https://h/d/dep/anthropic\r\n", + "https://h/d/dep/anthropic/v1/messages" + )] + #[case::leading_space_is_kept( + " https://h/d/dep/anthropic", + " https://h/d/dep/anthropic/v1/messages" + )] + #[case::blank_base_is_kept(" ", " /anthropic/v1/messages")] + fn url_is_built_as_python_builds_it(#[case] base: &str, #[case] expected: &str) { assert_eq!( - url, - "https://resource.services.ai.azure.com/anthropic/v1/messages" + complete_azure_anthropic_url(Some(base), &|_| None).expect("url builds"), + expected ); } - #[test] - fn url_keeps_existing_anthropic_segment() { - let url = complete_azure_anthropic_url( - Some("https://resource.services.ai.azure.com/anthropic"), - &|_| None, - ) - .expect("url builds"); - assert_eq!( - url, - "https://resource.services.ai.azure.com/anthropic/v1/messages" - ); - } - - #[test] - fn url_leaves_complete_messages_endpoint_untouched() { - for base in [ - "https://resource.services.ai.azure.com/anthropic/v1/messages", - "https://resource.services.ai.azure.com/v1/messages", - ] { - assert_eq!( - complete_azure_anthropic_url(Some(base), &|_| None).expect("url builds"), - base - ); - } - } - - #[test] - fn url_trims_trailing_slash_and_truncates_after_anthropic() { - let url = complete_azure_anthropic_url( - Some("https://resource.services.ai.azure.com/anthropic/extra/"), - &|_| None, - ) - .expect("url builds"); - assert_eq!( - url, - "https://resource.services.ai.azure.com/anthropic/v1/messages" - ); - } - - #[test] - fn url_falls_back_to_env_then_errors_when_absent() { - let with_env = |key: &str| { - (key == AZURE_API_BASE_ENV).then(|| "https://env.services.ai.azure.com".to_string()) + #[rstest] + #[case::call_base_wins( + Some("http://call/d"), + Some("http://env/d"), + "http://call/d/anthropic/v1/messages" + )] + #[case::empty_call_base_falls_to_environment( + Some(""), + Some("http://env/d"), + "http://env/d/anthropic/v1/messages" + )] + #[case::blank_call_base_is_kept(Some(" "), Some("http://env/d"), " /anthropic/v1/messages")] + #[case::environment_base_is_kept_untrimmed( + None, + Some(" http://env/d "), + " http://env/d /anthropic/v1/messages" + )] + fn url_resolves_the_base_as_python_does( + #[case] base: Option<&str>, + #[case] environment: Option<&str>, + #[case] expected: &str, + ) { + let lookup = |name: &str| { + (name == AZURE_API_BASE_ENV) + .then(|| environment.map(str::to_string)) + .flatten() }; assert_eq!( - complete_azure_anthropic_url(None, &with_env).expect("url builds"), - "https://env.services.ai.azure.com/anthropic/v1/messages" + complete_azure_anthropic_url(base, &lookup).expect("url builds"), + expected + ); + } + + #[rstest] + #[case::no_base(None)] + #[case::empty_base(Some(""))] + fn a_missing_base_is_python_s_value_error(#[case] base: Option<&str>) { + assert_eq!( + complete_azure_anthropic_url(base, &|_| None), + Err(Error::InvalidRequest(ErrorDetail::MissingAzureApiBase)) ); - let err = complete_azure_anthropic_url(Some(" "), &|_| None).expect_err("missing base"); - assert!(matches!(err, Error::Auth(_))); } #[rstest] diff --git a/litellm/rust/crates/llms/src/error.rs b/litellm/rust/crates/llms/src/error.rs index 77ee1f1036f..af7ceb7da17 100644 --- a/litellm/rust/crates/llms/src/error.rs +++ b/litellm/rust/crates/llms/src/error.rs @@ -199,6 +199,12 @@ pub enum ErrorDetail { left: &'static str, right: &'static str, }, + /// Python's azure_ai Messages `get_complete_url` raises this bare `ValueError` when neither the + /// call nor `AZURE_API_BASE` names a base. + #[error( + "Missing Azure API Base - Please set `api_base` or `AZURE_API_BASE` environment variable. Expected format: https://.services.ai.azure.com/anthropic" + )] + MissingAzureApiBase, #[error( "Missing API Base - A call is being made to LLM Provider but no api base is set either in the environment variables ({{LLM_PROVIDER}}_API_KEY) or via params" )] diff --git a/litellm/rust/crates/python-bridge/src/errors.rs b/litellm/rust/crates/python-bridge/src/errors.rs index 29c26ed30a3..e19cd8880e1 100644 --- a/litellm/rust/crates/python-bridge/src/errors.rs +++ b/litellm/rust/crates/python-bridge/src/errors.rs @@ -150,6 +150,19 @@ pub(crate) fn route_failure( value.setattr(MESSAGES_PYTHON_RAISED, true)?; native } + (RequestMarker::Messages, Some(rejection)) + if matches!( + rejection.downcast_ref::(), + Some(litellm_llms::ErrorDetail::MissingAzureApiBase) + ) => + { + // Python raises it from `get_complete_url`, after the provider resolved. + let native = PyValueError::new_err(rejection.to_string()); + let value = native.value(py); + value.setattr(MESSAGES_LATE_REJECTION, true)?; + value.setattr(MESSAGES_PYTHON_RAISED, true)?; + native + } (RequestMarker::Messages, Some(rejection)) => { let native = PyValueError::new_err(Report::new(rejection).to_string()); let value = native.value(py);