Merge nate_messages_native_header_value_error into integration

This commit is contained in:
narmstrong-berri 2026-11-06 14:36:11 +00:00
commit a302596b47
No known key found for this signature in database
3 changed files with 85 additions and 24 deletions

View file

@ -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<M
Some(MessagesProvider { provider, config })
}
pub(super) fn string_headers(
extra_headers: Option<Map<String, Value>>,
) -> Result<Vec<(String, String)>, 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<String, Value>,
) -> (Vec<(String, String)>, Option<Error>) {
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 <class '{}'>",
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 <class '{python_type}'>");
assert_eq!(refused.upstream_response(), Some((500, message.as_str())));
}
}

View file

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

View file

@ -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 <class 'int'>",
) 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):