diff --git a/litellm-rust/crates/inference-messages/src/common_utils.rs b/litellm-rust/crates/inference-messages/src/common_utils.rs index e5ece2837d8..a56165a389c 100644 --- a/litellm-rust/crates/inference-messages/src/common_utils.rs +++ b/litellm-rust/crates/inference-messages/src/common_utils.rs @@ -1,5 +1,4 @@ use litellm_core_utils::get_llm_provider_logic::LlmProviders; -use litellm_http::request::string_headers as shared_string_headers; use litellm_llms::tencent::messages::transformation::TENCENT_MESSAGES_CONFIG; use litellm_llms::{ anthropic::messages::transformation::ANTHROPIC_MESSAGES_CONFIG, @@ -19,8 +18,6 @@ use serde_json::{Map, Value}; use super::Error; -const HEADER_CONTEXT: &str = "messages"; - #[derive(Clone, Copy)] pub(crate) struct MessagesProvider { provider: LlmProviders, @@ -72,10 +69,44 @@ pub(crate) fn messages_provider(provider: LlmProviders, model: &str) -> Option>, -) -> Result, Error> { - shared_string_headers(HEADER_CONTEXT, extra_headers).map_err(Error::from) +// Python's httpx refuses a non-string header value only when it builds the request to send, after +// the logger has the resolved call, and the handler reports that as a provider 500. +pub(super) fn sendable_headers( + extra_headers: Map, +) -> (Vec<(String, String)>, Option) { + let refused = extra_headers + .values() + .find(|value| !value.is_string()) + .map(|value| { + litellm_http::Error::upstream( + 500, + format!( + "Header value must be str or bytes, not ", + python_type_name(value) + ), + ) + .into() + }); + let headers = extra_headers + .into_iter() + .filter_map(|(name, value)| match value { + Value::String(value) => Some((name, value)), + _ => None, + }) + .collect(); + (headers, refused) +} + +fn python_type_name(value: &Value) -> &'static str { + match value { + Value::Null => "NoneType", + Value::Bool(_) => "bool", + Value::Number(number) if number.is_f64() => "float", + Value::Number(_) => "int", + Value::String(_) => "str", + Value::Array(_) => "list", + Value::Object(_) => "dict", + } } #[cfg(test)] @@ -84,8 +115,7 @@ mod tests { use rstest::rstest; - use super::{messages_provider, string_headers}; - use crate::Error; + use super::{messages_provider, sendable_headers}; use litellm_core_utils::get_llm_provider_logic::LlmProviders; #[rstest] @@ -113,18 +143,26 @@ mod tests { assert!(messages_provider(provider, model).is_none()); } - #[test] - fn string_headers_rejects_non_string_values() { - let headers = json!({"x-count": 3}).as_object().unwrap().clone(); - let err = string_headers(Some(headers)).expect_err("non-string header rejected"); - assert_eq!( - err, - Error::Headers(litellm_http::request::HeaderError { - context: "messages", - name: "x-count".to_string(), - actual: "number", - }) - ); + #[rstest] + #[case::int(json!(12345), "int")] + #[case::float(json!(1.5), "float")] + #[case::bool(json!(true), "bool")] + #[case::null(json!(null), "NoneType")] + #[case::list(json!([1]), "list")] + #[case::dict(json!({"a": 1}), "dict")] + fn non_string_header_is_refused_with_pythons_send_error( + #[case] value: serde_json::Value, + #[case] python_type: &str, + ) { + let headers = json!({"x-kept": "v", "x-count": value}) + .as_object() + .unwrap() + .clone(); + let (sendable, refused) = sendable_headers(headers); + assert_eq!(sendable, vec![("x-kept".to_string(), "v".to_string())]); + let refused = refused.expect("non-string header refused"); + let message = format!("Header value must be str or bytes, not "); + assert_eq!(refused.upstream_response(), Some((500, message.as_str()))); } } diff --git a/litellm-rust/crates/inference-messages/src/handler.rs b/litellm-rust/crates/inference-messages/src/handler.rs index 6a8ed7fff8e..476cc408261 100644 --- a/litellm-rust/crates/inference-messages/src/handler.rs +++ b/litellm-rust/crates/inference-messages/src/handler.rs @@ -30,7 +30,7 @@ use serde_json::{Map, Value}; use super::{ Error, MessagesBody, MessagesCall, MessagesCallResponse, MessagesRoute, - common_utils::{MessagesProvider, merge_messages_header_sources, string_headers}, + common_utils::{MessagesProvider, merge_messages_header_sources, sendable_headers}, constants::MESSAGES_TIMEOUT_SECS, route::Messages, types::invalid_request, @@ -140,10 +140,10 @@ async fn provider_call( let scoped = get_provider_specific_headers(provider_specific_header.as_ref(), provider.as_str()); - let forwarded = string_headers(Some(merge_messages_header_sources([ + let (forwarded, refused_header) = sendable_headers(merge_messages_header_sources([ extra_headers.unwrap_or_default(), scoped, - ])))?; + ])); let validated = config.validate_environment( forwarded, api_key.as_deref(), @@ -197,6 +197,9 @@ async fn provider_call( request_context, ) .await?; + if let Some(error) = refused_header { + return Err(error); + } let stream = match wire.body.get("stream") { None | Some(Value::Null) => false, Some(Value::Bool(stream)) => *stream, diff --git a/tests/test_litellm_rust/messages/test_callbacks.py b/tests/test_litellm_rust/messages/test_callbacks.py index 3073c1cf7dd..4aa7e9562d7 100644 --- a/tests/test_litellm_rust/messages/test_callbacks.py +++ b/tests/test_litellm_rust/messages/test_callbacks.py @@ -284,6 +284,26 @@ async def test_pre_call_deployment_rejection_logs_failure_without_provider_io(se assert not {"log_pre_api_call", "log_success_event", "async_log_success_event"} & set(recorder.names) +@pytest.mark.asyncio +async def test_non_string_header_fails_after_pre_api_call_as_a_provider_500(server: RecordingServer) -> None: + recorder: Final = RecordingLogger() + + with rebound(litellm, "callbacks", [recorder]): + with pytest.raises( + litellm.InternalServerError, + match=r"AnthropicException - Header value must be str or bytes, not ", + ) as raised: + await call(server, extra_headers={"x-org": 12345}) + failures: Final = await recorder.wait_for_async("async_log_failure_event") + + assert not server.requests + assert "log_pre_api_call" in recorder.names + logged: Final = _OBJECT.validate_python(failures[0].kwargs) + assert logged["exception"] is raised.value + logged_call: Final = _OBJECT.validate_python(logged["standard_logging_object"]) + assert (logged["custom_llm_provider"], logged_call["api_base"]) == ("anthropic", f"{server.base_url}/v1/messages") + + @pytest.mark.asyncio async def test_pre_call_deployment_hook_can_delete_keywords(server: RecordingServer) -> None: class DropKeywords(CustomLogger):