mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
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.
This commit is contained in:
parent
fe4f63a2e2
commit
398f8ac165
6 changed files with 159 additions and 74 deletions
|
|
@ -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://<resource-name>.services.ai.azure.com/anthropic"
|
||||
})
|
||||
Error::from(litellm_llms::Error::InvalidRequest(
|
||||
litellm_llms::ErrorDetail::MissingAzureApiBase
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
) -> Result<String, Error> {
|
||||
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://<resource-name>.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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
) -> Result<String, Error> {
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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://<resource-name>.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"
|
||||
)]
|
||||
|
|
|
|||
|
|
@ -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::<litellm_llms::ErrorDetail>(),
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue