mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
fix(messages): pass non-message bodies through and refuse non-string headers as bad requests (#50422)
Python's messages transform takes any JSON object from a 200 response and only adds stop_sequence, so a provider answering 200 with an error object reaches the client as 200 and the router counts a success. The native decoder turned it into a 500 APIConnectionError and a router failure. A JSON object that is not a message now passes through unchanged A non-string extra_headers or deployment header value surfaced from the native handler as an APIConnectionError. It is now refused as an invalid request when the headers are collected, before the provider request is built or logged, so the caller gets a 400 BadRequestError naming the header and its type
This commit is contained in:
parent
d631f3cf05
commit
ee032f10d6
14 changed files with 250 additions and 79 deletions
|
|
@ -1,5 +1,5 @@
|
|||
use litellm_core_utils::get_llm_provider_logic::LlmProviders;
|
||||
use litellm_http::request::string_headers as shared_string_headers;
|
||||
use litellm_llms::ErrorDetail;
|
||||
use litellm_llms::tencent::messages::transformation::TENCENT_MESSAGES_CONFIG;
|
||||
use litellm_llms::{
|
||||
anthropic::messages::transformation::ANTHROPIC_MESSAGES_CONFIG,
|
||||
|
|
@ -19,8 +19,6 @@ use serde_json::{Map, Value};
|
|||
|
||||
use super::Error;
|
||||
|
||||
const HEADER_CONTEXT: &str = "messages";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct MessagesProvider {
|
||||
provider: LlmProviders,
|
||||
|
|
@ -75,7 +73,52 @@ pub(crate) fn messages_provider(provider: LlmProviders, model: &str) -> Option<M
|
|||
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)
|
||||
extra_headers
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(name, value)| match value {
|
||||
Value::String(value) => Ok((name, value)),
|
||||
value => Err(Error::invalid_request(ErrorDetail::invalid(
|
||||
"extra_headers",
|
||||
NonStringHeader {
|
||||
actual: python_type_name(&value),
|
||||
name,
|
||||
},
|
||||
))),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A header value the caller set to something other than a string, named by its Python type
|
||||
/// since the caller wrote it in Python.
|
||||
#[derive(Debug)]
|
||||
struct NonStringHeader {
|
||||
name: String,
|
||||
actual: &'static str,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NonStringHeader {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"header {} must be a string, got {}",
|
||||
self.name, self.actual
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NonStringHeader {}
|
||||
|
||||
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)]
|
||||
|
|
@ -85,7 +128,6 @@ mod tests {
|
|||
use rstest::rstest;
|
||||
|
||||
use super::{messages_provider, string_headers};
|
||||
use crate::Error;
|
||||
use litellm_core_utils::get_llm_provider_logic::LlmProviders;
|
||||
|
||||
#[rstest]
|
||||
|
|
@ -113,17 +155,28 @@ 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");
|
||||
#[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_a_bad_request_naming_the_header_and_its_type(
|
||||
#[case] value: serde_json::Value,
|
||||
#[case] python_type: &str,
|
||||
) {
|
||||
let headers = json!({"x-kept": "v", "x-count": value})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let error = string_headers(Some(headers)).expect_err("non-string header refused");
|
||||
assert!(error.is_request(), "{error:?}");
|
||||
assert_eq!(error.upstream_response(), None);
|
||||
let rejection = error.rejected_request().expect("a rejected request");
|
||||
assert_eq!(
|
||||
err,
|
||||
Error::Headers(litellm_http::request::HeaderError {
|
||||
context: "messages",
|
||||
name: "x-count".to_string(),
|
||||
actual: "number",
|
||||
})
|
||||
litellm_error::Report::new(rejection).to_string(),
|
||||
format!("invalid extra_headers: header x-count must be a string, got {python_type}")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,10 @@ use litellm_llms_types::formats::messages::{
|
|||
};
|
||||
use litellm_secrets::source::Secrets;
|
||||
use litellm_tracing::ByteChunk;
|
||||
use serde_json::Value;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::{
|
||||
Error, MessagesCall, MessagesCallResponse, MessagesRoute,
|
||||
Error, MessagesBody, MessagesCall, MessagesCallResponse, MessagesRoute,
|
||||
common_utils::{MessagesProvider, merge_messages_header_sources, string_headers},
|
||||
constants::MESSAGES_TIMEOUT_SECS,
|
||||
route::Messages,
|
||||
|
|
@ -85,7 +85,10 @@ pub(super) async fn execute(
|
|||
),
|
||||
};
|
||||
let reported = match &output {
|
||||
MessagesCallResponse::Complete(response) => provider.config().reported_cost(response),
|
||||
MessagesCallResponse::Complete(response) => match response {
|
||||
MessagesBody::Message(message) => provider.config().reported_cost(message),
|
||||
MessagesBody::Unchecked(_) => None,
|
||||
},
|
||||
MessagesCallResponse::Stream { .. } => None,
|
||||
};
|
||||
context
|
||||
|
|
@ -347,8 +350,7 @@ async fn call_provider(
|
|||
// body it cannot parse, and the public mapping classifies it by that status.
|
||||
return Err(litellm_http::Error::upstream(status, text).into());
|
||||
}
|
||||
decode_response(config, &identity.model, &text)
|
||||
.map(|message| MessagesCallResponse::Complete(Box::new(message)))
|
||||
decode_response(config, &identity.model, &text).map(MessagesCallResponse::Complete)
|
||||
}
|
||||
|
||||
fn serialize_failure(err: serde_json::Error) -> Error {
|
||||
|
|
@ -412,15 +414,23 @@ fn decode_response(
|
|||
config: &dyn BaseMessagesConfig,
|
||||
model: &str,
|
||||
text: &str,
|
||||
) -> Result<MessagesResponse, Error> {
|
||||
let response = serde_json::from_str(text).map_err(|err| {
|
||||
Error::invalid_response(litellm_llms::ErrorDetail::invalid(
|
||||
"messages response JSON",
|
||||
err,
|
||||
))
|
||||
})?;
|
||||
) -> Result<MessagesBody, Error> {
|
||||
let response = match serde_json::from_str::<MessagesResponse>(text) {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
let Ok(mut body) = serde_json::from_str::<Map<String, Value>>(text) else {
|
||||
return Err(Error::invalid_response(litellm_llms::ErrorDetail::invalid(
|
||||
"messages response JSON",
|
||||
err,
|
||||
)));
|
||||
};
|
||||
body.entry("stop_sequence").or_insert(Value::Null);
|
||||
return Ok(MessagesBody::Unchecked(body));
|
||||
}
|
||||
};
|
||||
config
|
||||
.transform_anthropic_messages_response(model, response)
|
||||
.map(|message| MessagesBody::Message(Box::new(message)))
|
||||
.map_err(Error::from)
|
||||
.map_err(Error::after_send)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ use crate::common_utils::messages_provider;
|
|||
pub use common_utils::merge_messages_header_sources;
|
||||
pub use litellm_inference::Error;
|
||||
pub use types::{
|
||||
MessagesCall, MessagesCallResponse, MessagesSettings, MessagesShaping, litellm_params,
|
||||
messages_body,
|
||||
MessagesBody, MessagesCall, MessagesCallResponse, MessagesSettings, MessagesShaping,
|
||||
litellm_params, messages_body,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
|
|||
|
|
@ -5,11 +5,10 @@ use litellm_host::{
|
|||
call::{HostedCompletion, HostedMachine, hosted_call},
|
||||
protocol::Protocol,
|
||||
};
|
||||
use litellm_llms_types::formats::messages::MessagesResponse;
|
||||
|
||||
use super::{Error, MessagesCall};
|
||||
use super::{Error, MessagesBody, MessagesCall};
|
||||
|
||||
pub type MessagesOutput = HostedCompletion<Box<MessagesResponse>>;
|
||||
pub type MessagesOutput = HostedCompletion<MessagesBody>;
|
||||
|
||||
/// The upstream response as the caller sees it at stream hand-off, before any chunk.
|
||||
pub struct MessagesStreamHead {
|
||||
|
|
@ -19,7 +18,7 @@ pub struct MessagesStreamHead {
|
|||
pub struct Messages;
|
||||
|
||||
impl Protocol for Messages {
|
||||
type Response = Box<MessagesResponse>;
|
||||
type Response = MessagesBody;
|
||||
type Error = Error;
|
||||
type Request = MessagesCall;
|
||||
type HostCall = Infallible;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,16 @@ pub(super) fn invalid_request(err: serde_json::Error) -> Error {
|
|||
}
|
||||
|
||||
pub type MessagesCallResponse =
|
||||
CallOutput<Box<MessagesResponse>, super::route::MessagesStreamHead, Bytes, Error>;
|
||||
CallOutput<MessagesBody, super::route::MessagesStreamHead, Bytes, Error>;
|
||||
|
||||
// Python's `transform_anthropic_messages_response` returns any JSON object the provider sends
|
||||
// with a 200, message or not, so one that is not a message passes through as sent.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum MessagesBody {
|
||||
Message(Box<MessagesResponse>),
|
||||
Unchecked(Map<String, Value>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MessagesShaping {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use futures_util::TryStreamExt;
|
||||
use litellm_inference_messages::{Error, MessagesCall, MessagesCallResponse, MessagesShaping};
|
||||
use litellm_inference_messages::{
|
||||
Error, MessagesBody, MessagesCall, MessagesCallResponse, MessagesShaping,
|
||||
};
|
||||
use litellm_inference_testing::{RecordingSecrets, no_secrets};
|
||||
use litellm_llms_types::formats::messages::{MessagesRequest, MessagesResponse};
|
||||
use rstest::{fixture, rstest};
|
||||
|
|
@ -74,7 +76,10 @@ async fn run(call: MessagesCall) -> Result<MessagesCallResponse, Error> {
|
|||
|
||||
async fn run_message(call: MessagesCall) -> MessagesResponse {
|
||||
match run(call).await.unwrap() {
|
||||
MessagesCallResponse::Complete(message) => *message,
|
||||
MessagesCallResponse::Complete(message) => match message {
|
||||
MessagesBody::Message(message) => *message,
|
||||
MessagesBody::Unchecked(_) => panic!("expected a message"),
|
||||
},
|
||||
MessagesCallResponse::Stream { .. } => panic!("expected a completed message"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,42 +50,47 @@ async fn messages_cache_identity_includes_provider_native_parameters(
|
|||
#[case] original: Value,
|
||||
#[case] changed: Value,
|
||||
) {
|
||||
use litellm_inference_messages::route::Messages;
|
||||
use litellm_inference_messages::{MessagesBody, route::Messages};
|
||||
use litellm_llms_types::formats::messages::MessagesResponse;
|
||||
|
||||
let calls = AtomicUsize::new(0);
|
||||
for (value, expected_call) in [(original.clone(), 0), (changed, 1), (original, 0)] {
|
||||
let response =
|
||||
execute_unary::<Messages, _, _>(
|
||||
CacheRequest::from_wire(
|
||||
ProviderIdentity {
|
||||
model: "test".into(),
|
||||
provider: "anthropic".into(),
|
||||
},
|
||||
Some(&WireRequest {
|
||||
url: "https://example.test/v1/messages".into(),
|
||||
headers: vec![],
|
||||
body: json!({
|
||||
"model":"test", "messages":[{"role":"user","content":"hello"}],
|
||||
"max_tokens":32, (field):value
|
||||
}),
|
||||
}),
|
||||
),
|
||||
Some(cache.clone()),
|
||||
Some(CacheOptions::new(CacheScope::Shared)),
|
||||
&(),
|
||||
None,
|
||||
|| async {
|
||||
let call = calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Box::new(serde_json::from_value::<MessagesResponse>(json!({
|
||||
"id":call.to_string(), "type":"message", "role":"assistant", "model":"test",
|
||||
"content":[{"type":"text","text":format!("answer {call}")}],
|
||||
"stop_reason":"end_turn", "stop_sequence":null
|
||||
})).unwrap()))
|
||||
let response = execute_unary::<Messages, _, _>(
|
||||
CacheRequest::from_wire(
|
||||
ProviderIdentity {
|
||||
model: "test".into(),
|
||||
provider: "anthropic".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
Some(&WireRequest {
|
||||
url: "https://example.test/v1/messages".into(),
|
||||
headers: vec![],
|
||||
body: json!({
|
||||
"model":"test", "messages":[{"role":"user","content":"hello"}],
|
||||
"max_tokens":32, (field):value
|
||||
}),
|
||||
}),
|
||||
),
|
||||
Some(cache.clone()),
|
||||
Some(CacheOptions::new(CacheScope::Shared)),
|
||||
&(),
|
||||
None,
|
||||
|| async {
|
||||
let call = calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(MessagesBody::Message(Box::new(
|
||||
serde_json::from_value::<MessagesResponse>(json!({
|
||||
"id":call.to_string(), "type":"message", "role":"assistant", "model":"test",
|
||||
"content":[{"type":"text","text":format!("answer {call}")}],
|
||||
"stop_reason":"end_turn", "stop_sequence":null
|
||||
}))
|
||||
.unwrap(),
|
||||
)))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let MessagesBody::Message(response) = response else {
|
||||
panic!("expected a message");
|
||||
};
|
||||
assert_eq!(response.id, expected_call.to_string());
|
||||
assert_eq!(
|
||||
response.content[0]["text"],
|
||||
|
|
@ -253,7 +258,7 @@ async fn provider_reported_cost_is_delivered_once_and_never_charged_for_a_cache_
|
|||
#[case] cost: Value,
|
||||
#[case] expected: Option<f64>,
|
||||
) {
|
||||
use litellm_inference_messages::{MessagesCall, MessagesCallResponse};
|
||||
use litellm_inference_messages::{MessagesBody, MessagesCall, MessagesCallResponse};
|
||||
let response = json!({"id": "msg_cost", "type": "message", "role": "assistant", "model": "native-test",
|
||||
"content": [{"type": "text", "text": "answer"}], "stop_reason": "end_turn", "stop_sequence": null,
|
||||
"usage": {"input_tokens": 3, "output_tokens": 2}, "cost": cost});
|
||||
|
|
@ -277,6 +282,9 @@ async fn provider_reported_cost_is_delivered_once_and_never_charged_for_a_cache_
|
|||
panic!("expected Messages responses");
|
||||
};
|
||||
assert_eq!(first, second);
|
||||
let MessagesBody::Message(first) = first else {
|
||||
panic!("expected a message");
|
||||
};
|
||||
assert_eq!(first.extra.get("cost"), Some(&cost));
|
||||
assert_eq!(support::received(&upstream).await.len(), 1);
|
||||
let facts = hooks.facts.lock().unwrap();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ use futures_util::{FutureExt, TryStreamExt};
|
|||
use litellm_host::interceptors::{
|
||||
ExecutionFacts, Interceptors, RawResponse, RequestContext, ResultSource, WireRequest,
|
||||
};
|
||||
use litellm_inference_messages::{Error, MessagesCall, MessagesCallResponse, MessagesRoute};
|
||||
use litellm_inference_messages::{
|
||||
Error, MessagesBody, MessagesCall, MessagesCallResponse, MessagesRoute,
|
||||
};
|
||||
use litellm_inference_testing::live::{LiveResources, model, within_deadline};
|
||||
use litellm_llms_types::formats::messages::MessagesResponse;
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -111,7 +113,10 @@ pub(super) fn call(provider: &str, payload: Value) -> MessagesCall {
|
|||
|
||||
pub(super) fn complete(response: MessagesCallResponse) -> Box<MessagesResponse> {
|
||||
match response {
|
||||
MessagesCallResponse::Complete(message) => message,
|
||||
MessagesCallResponse::Complete(message) => match message {
|
||||
MessagesBody::Message(message) => message,
|
||||
MessagesBody::Unchecked(_) => panic!("a complete request returned no message"),
|
||||
},
|
||||
MessagesCallResponse::Stream { .. } => panic!("a complete request returned a stream"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use litellm_host::{
|
|||
interceptors::{ExecutionFacts, RequestContext, ResultSource, WireRequest},
|
||||
lifecycle::CallEvent,
|
||||
};
|
||||
use litellm_inference_messages::{MessagesCallResponse, route::Messages};
|
||||
use litellm_inference_messages::{MessagesBody, MessagesCallResponse, route::Messages};
|
||||
use litellm_inference_testing::{RecordingSecrets, no_secrets};
|
||||
use litellm_llms::base_llm::messages::context::{
|
||||
MessagesModelCapabilities as AnthropicModelCapabilities, SupportedEffortTiers,
|
||||
|
|
@ -340,7 +340,10 @@ async fn response_mode_follows_the_intercepted_request(
|
|||
}
|
||||
MessagesCallResponse::Complete(message) => {
|
||||
assert_eq!(expected_stream, Some(false));
|
||||
assert_eq!(*message, serde_json::from_value(message_body()).unwrap());
|
||||
assert_eq!(
|
||||
message,
|
||||
MessagesBody::Message(serde_json::from_value(message_body()).unwrap())
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok::<_, Error>(())
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use std::{
|
|||
|
||||
use litellm_http::{HttpSettings, Resolution};
|
||||
use litellm_inference_messages::{
|
||||
Error, MessagesCall, MessagesSettings, MessagesShaping,
|
||||
Error, MessagesBody, MessagesCall, MessagesSettings, MessagesShaping,
|
||||
route::{Messages, MessagesMachine, MessagesOutput},
|
||||
};
|
||||
use litellm_inference_testing::RecordingSecrets;
|
||||
|
|
@ -119,7 +119,10 @@ async fn run(call: MessagesCall) -> Result<MessagesOutput, Error> {
|
|||
|
||||
async fn run_message(call: MessagesCall) -> MessagesResponse {
|
||||
match run(call).await.expect("messages call succeeds") {
|
||||
MessagesOutput::Complete(message) => *message,
|
||||
MessagesOutput::Complete(message) => match message {
|
||||
MessagesBody::Message(message) => *message,
|
||||
MessagesBody::Unchecked(_) => panic!("a call returned no message"),
|
||||
},
|
||||
MessagesOutput::StreamEnded | MessagesOutput::Detached => {
|
||||
panic!("a non-streaming call returned a stream")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use litellm_host::{
|
|||
interceptors::{ExecutionFacts, ResultSource},
|
||||
lifecycle::ExecutionEvent,
|
||||
};
|
||||
use litellm_inference_messages::{MessagesCallResponse, messages_body};
|
||||
use litellm_inference_messages::{MessagesBody, MessagesCallResponse, messages_body};
|
||||
use litellm_inference_testing::{
|
||||
RecordingSecrets, http_config, no_secrets, provider_http, resources,
|
||||
};
|
||||
|
|
@ -48,6 +48,9 @@ async fn calls_defer_execution_until_polled(
|
|||
let MessagesCallResponse::Complete(response) = future.await.unwrap() else {
|
||||
panic!("expected a completed message");
|
||||
};
|
||||
let MessagesBody::Message(response) = response else {
|
||||
panic!("expected a message");
|
||||
};
|
||||
assert_eq!(
|
||||
response.content,
|
||||
message_body()["content"].as_array().unwrap().as_slice()
|
||||
|
|
@ -240,10 +243,42 @@ async fn an_unparseable_success_body_is_reported_as_the_upstream_answer(call: Me
|
|||
assert_eq!(error.upstream_response(), Some((200, "not json")));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::error_object(
|
||||
json!({"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}),
|
||||
json!({"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"},
|
||||
"stop_sequence": null}),
|
||||
)]
|
||||
#[case::message_missing_id(
|
||||
json!({"type": "message", "content": [], "stop_sequence": "###"}),
|
||||
json!({"type": "message", "content": [], "stop_sequence": "###"}),
|
||||
)]
|
||||
#[tokio::test]
|
||||
async fn a_success_body_that_is_not_a_message_passes_through(
|
||||
call: MessagesCall,
|
||||
#[case] body: Value,
|
||||
#[case] expected: Value,
|
||||
) {
|
||||
let upstream = upstream([json_response(body)]).await;
|
||||
|
||||
let response = run(MessagesCall {
|
||||
api_key: Some("sk".into()),
|
||||
api_base: Some(upstream.uri()),
|
||||
..call
|
||||
})
|
||||
.await
|
||||
.expect("any JSON object is returned");
|
||||
|
||||
let MessagesOutput::Complete(response) = response else {
|
||||
panic!("expected a completed response");
|
||||
};
|
||||
assert_eq!(serde_json::to_value(response).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn an_unreadable_success_body_is_an_invalid_response(call: MessagesCall) {
|
||||
let upstream = upstream([json_response(json!({"unexpected": true}))]).await;
|
||||
let upstream = upstream([json_response(json!(["unexpected"]))]).await;
|
||||
|
||||
let error = run(MessagesCall {
|
||||
api_key: Some("sk".into()),
|
||||
|
|
@ -320,6 +355,9 @@ async fn the_facade_sends_through_the_injected_http_pool_configuration(call: Mes
|
|||
let MessagesCallResponse::Complete(message) = response else {
|
||||
panic!("a non-streaming request returns a message");
|
||||
};
|
||||
let MessagesBody::Message(message) = message else {
|
||||
panic!("expected a message");
|
||||
};
|
||||
assert_eq!(message.id, "msg_1");
|
||||
let sent = only_request(&upstream).await;
|
||||
assert_eq!(sent.header("x-api-key"), Some("sk-ant"));
|
||||
|
|
@ -412,6 +450,9 @@ async fn route_uses_injected_dependencies_and_optional_cache(
|
|||
else {
|
||||
panic!("expected a completed message");
|
||||
};
|
||||
let MessagesBody::Message(response) = response else {
|
||||
panic!("expected a message");
|
||||
};
|
||||
assert_eq!(
|
||||
response.content,
|
||||
message_body()["content"].as_array().unwrap().as_slice()
|
||||
|
|
@ -484,6 +525,9 @@ async fn cache_overrides_preserve_the_routes_isolated_scope(call: MessagesCall)
|
|||
else {
|
||||
panic!("expected a completed message");
|
||||
};
|
||||
let MessagesBody::Message(response) = response else {
|
||||
panic!("expected a message");
|
||||
};
|
||||
assert_eq!(response.id, expected["id"].as_str().unwrap());
|
||||
}
|
||||
assert_eq!(received(&upstream).await.len(), 2);
|
||||
|
|
|
|||
|
|
@ -239,9 +239,9 @@ impl PythonBinding for MessagesPythonHost {
|
|||
fn encode_response(
|
||||
&mut self,
|
||||
py: Python<'_>,
|
||||
response: Box<litellm_llms_types::formats::messages::MessagesResponse>,
|
||||
response: litellm_inference_messages::MessagesBody,
|
||||
) -> PyResult<Py<PyAny>> {
|
||||
public_response(py, ROUTE_HOST_MODULE, response.as_ref())
|
||||
public_response(py, ROUTE_HOST_MODULE, &response)
|
||||
}
|
||||
|
||||
fn encode_stream_head(
|
||||
|
|
|
|||
|
|
@ -284,6 +284,23 @@ 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_is_a_bad_request_refused_before_the_provider_call(server: RecordingServer) -> None:
|
||||
recorder: Final = RecordingLogger()
|
||||
|
||||
with rebound(litellm, "callbacks", [recorder]):
|
||||
with pytest.raises(
|
||||
litellm.BadRequestError, match=r"invalid extra_headers: header x-org must be a string, got int"
|
||||
) as raised:
|
||||
await call(server, extra_headers={"x-org": 12345})
|
||||
failures: Final = await recorder.wait_for_async("async_log_failure_event")
|
||||
|
||||
assert raised.value.status_code == 400
|
||||
assert not server.requests
|
||||
assert "log_pre_api_call" not in recorder.names
|
||||
assert _OBJECT.validate_python(failures[0].kwargs)["exception"] is raised.value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_deployment_hook_can_delete_keywords(server: RecordingServer) -> None:
|
||||
class DropKeywords(CustomLogger):
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import pytest
|
|||
|
||||
import litellm
|
||||
from tests.test_litellm_rust.messages.data import MESSAGES, MESSAGES_RESPONSE, call
|
||||
from tests.test_litellm_rust.support.recording_server import RecordingServer
|
||||
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec
|
||||
from tests.test_litellm_rust.support.routing import assert_served_natively
|
||||
|
||||
pytestmark = pytest.mark.requires_rust_extension
|
||||
|
|
@ -57,6 +57,21 @@ async def test_replayed_tool_ids_are_sanitized_before_they_reach_the_provider(se
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_success_body_that_is_not_a_message_passes_through(server: RecordingServer) -> None:
|
||||
overloaded: Final = {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}
|
||||
server.default_response = ResponseSpec(body=overloaded)
|
||||
|
||||
response: Final = await call(server)
|
||||
|
||||
assert_served_natively(server, response)
|
||||
assert isinstance(response, dict)
|
||||
assert {name: value for name, value in response.items() if name != "_hidden_params"} == {
|
||||
**overloaded,
|
||||
"stop_sequence": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_specific_headers_scoped_to_anthropic_reach_the_wire(server: RecordingServer) -> None:
|
||||
await call(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue