feat(messages): retry rejected strict tools on rust

This commit is contained in:
narmstrong-berry 2026-11-10 21:08:57 +00:00
parent ff2d7c985c
commit 228ee9ffab
No known key found for this signature in database
21 changed files with 1037 additions and 84 deletions

View file

@ -4293,6 +4293,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"data-url",
"fancy-regex 0.19.2",
"futures-util",
"litellm-auth",
"litellm-auth-aws",

View file

@ -192,8 +192,10 @@ async fn provider_call(
MessagesRequest { model, ..body },
shaping.settings.shape_options(),
)?;
let transform_context =
MessagesTransformContext::new(shaping.capabilities, shaping.settings.drop_params);
let transform_context = MessagesTransformContext {
drops_unsupported_params: shaping.settings.drops_unsupported_params,
..MessagesTransformContext::new(shaping.capabilities, shaping.settings.drop_params)
};
let without_listed =
without_additional_drop_params(sanitized, &shaping.settings.additional_drop_params)?;
let trimmed = requested_optional_params(without_listed, provider, &transform_context)?;

View file

@ -80,6 +80,8 @@ pub struct MessagesSettings {
pub whole_sse_frames: bool,
#[serde(default)]
pub presanitized: bool,
#[serde(default)]
pub drops_unsupported_params: bool,
}
impl MessagesSettings {
@ -156,7 +158,8 @@ mod tests {
"drop_params": true,
"reasoning_auto_summary": true,
"additional_drop_params": ["metadata.user_id", "thinking"],
"request_timeout": 1800.0
"request_timeout": 1800.0,
"drops_unsupported_params": true
}),
MessagesShaping {
settings: MessagesSettings {
@ -164,6 +167,7 @@ mod tests {
reasoning_auto_summary: true,
additional_drop_params: vec!["metadata.user_id".to_string(), "thinking".to_string()],
request_timeout: Some(1800.0),
drops_unsupported_params: true,
..MessagesSettings::default()
},
capabilities: MessagesModelCapabilities {
@ -202,6 +206,10 @@ mod tests {
serialized["reasoning_auto_summary"],
json!(expected.settings.reasoning_auto_summary)
);
assert_eq!(
serialized["drops_unsupported_params"],
json!(expected.settings.drops_unsupported_params)
);
assert_eq!(
serialized["additional_drop_params"],
json!(expected.settings.additional_drop_params)

View file

@ -216,3 +216,149 @@ async fn other_failures_reach_the_caller_without_a_retry(
assert!(host.retried.lock().unwrap().is_empty());
assert!(observed_retries(&host).is_empty());
}
const STRICT_REJECTION: &str = "Too many strict tools (21). The maximum is 20.";
fn strict_tools(
call: MessagesCall,
api_base: String,
stream: bool,
drop_params: bool,
) -> MessagesCall {
MessagesCall {
api_key: Some("sk-ant".into()),
api_base: Some(api_base),
shaping: MessagesShaping {
settings: MessagesSettings {
drop_params,
drops_unsupported_params: drop_params,
..MessagesSettings::default()
},
..MessagesShaping::default()
},
..with_fields(
call,
json!({
"max_tokens": 64,
"stream": stream,
"messages": [{"role": "user", "content": "hi"}],
"tools": [
{"name": "a", "input_schema": {"type": "object"}, "strict": true},
{"name": "b", "input_schema": {"type": "object"}, "strict": false},
{"name": "c", "input_schema": {"type": "object"}}
]
}),
)
}
}
#[rstest]
#[tokio::test]
async fn a_rejected_strict_tool_is_sent_once_more_without_strict_under_drop_params(
call: MessagesCall,
#[values(false, true)] stream: bool,
) {
let success = if stream {
sse_response()
} else {
message_response()
};
let upstream = upstream([rejection(400, STRICT_REJECTION), success]).await;
let host = RecordingCall::<Messages>::new(strict_tools(call, upstream.uri(), stream, true));
let (response, _) = execute(&host).await.expect("the retry succeeds");
let [first, second] = <[_; 2]>::try_from(received(&upstream).await).unwrap();
assert_eq!(first.json()["tools"][0]["strict"], json!(true));
let mut expected = first.json();
expected["tools"] = json!([
{"name": "a", "input_schema": {"type": "object"}},
{"name": "b", "input_schema": {"type": "object"}},
{"name": "c", "input_schema": {"type": "object"}}
]);
assert_eq!(second.json(), expected);
let expected_retry = RetriedRequest {
reasons: vec![RetryReason::StrictTools { dropped: 1 }],
body: second.json(),
};
assert_eq!(*host.retried.lock().unwrap(), vec![expected_retry.clone()]);
assert_eq!(observed_retries(&host), vec![expected_retry]);
assert_eq!(
matches!(response, MessagesCallResponse::Stream { .. }),
stream
);
}
#[rstest]
#[tokio::test]
async fn a_rejected_strict_tool_reaches_the_caller_without_drop_params(call: MessagesCall) {
let upstream = upstream([rejection(400, STRICT_REJECTION), message_response()]).await;
let host = RecordingCall::<Messages>::new(strict_tools(call, upstream.uri(), false, false));
let error = execute(&host).await.err().expect("the call fails");
assert_eq!(
only_request(&upstream).await.json()["tools"][0]["strict"],
json!(true)
);
assert_eq!(error.upstream_response().unwrap().0, 400);
assert!(observed_retries(&host).is_empty());
}
#[rstest]
#[tokio::test]
async fn strict_tools_and_a_thinking_replay_share_the_one_retry(call: MessagesCall) {
let upstream = upstream([
rejection(400, STRICT_REJECTION),
rejection(400, INVALID_SIGNATURE),
message_response(),
])
.await;
let host = RecordingCall::<Messages>::new(replay(
strict_tools(call, upstream.uri(), false, true),
upstream.uri(),
false,
enabled(),
));
let error = execute(&host)
.await
.err()
.expect("the second rejection is raised");
let [first, second] = <[_; 2]>::try_from(received(&upstream).await).unwrap();
assert_eq!(first.json()["thinking"], enabled());
assert_eq!(second.json()["thinking"], enabled());
assert_eq!(
second.json()["tools"][0],
json!({"name": "a", "input_schema": {"type": "object"}})
);
assert_eq!(received(&upstream).await.len(), 2);
assert!(error.upstream_response().unwrap().1.contains("signature"));
}
#[rstest]
#[tokio::test]
async fn a_truthy_global_drop_params_that_is_not_true_does_not_drop_strict(call: MessagesCall) {
let upstream = upstream([rejection(400, STRICT_REJECTION), message_response()]).await;
let strict = strict_tools(call, upstream.uri(), false, true);
let host = RecordingCall::<Messages>::new(MessagesCall {
shaping: MessagesShaping {
settings: MessagesSettings {
drops_unsupported_params: false,
..strict.shaping.settings.clone()
},
..strict.shaping.clone()
},
..strict
});
let error = execute(&host).await.err().expect("the call fails");
assert_eq!(
only_request(&upstream).await.json()["tools"][0]["strict"],
json!(true)
);
assert_eq!(error.upstream_response().unwrap().0, 400);
assert!(observed_retries(&host).is_empty());
}

View file

@ -26,6 +26,7 @@ litellm-router-types.workspace = true
base64.workspace = true
bytes.workspace = true
data-url = "0.3.2"
fancy-regex.workspace = true
futures-util.workspace = true
reqwest.workspace = true
serde.workspace = true

View file

@ -1,4 +1,7 @@
use std::sync::LazyLock;
use crate::base_llm::messages::context::MessagesModelCapabilities;
use fancy_regex::Regex;
use litellm_auth::CredentialPlacement;
use litellm_secret_value::SecretValue;
@ -731,6 +734,57 @@ pub fn strip_thinking_blocks_from_anthropic_messages_request(request_body: Value
Value::Object(body)
}
static CLAUDE_STRICT_TOOLS_REJECTION: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"Too many strict tools \(\d+\)",
r"|Schemas contains too many (?:optional parameters|parameters with union types)",
r"|[Cc]ompiled grammar (?:is too large|size)",
r"|tools\.\d+\.custom(?:\.strict)?: ",
r"|Grammar compilation timed out",
))
.expect("valid regex")
});
pub fn is_claude_strict_tools_rejection(status: u16, error_text: &str) -> bool {
status == 400
&& CLAUDE_STRICT_TOOLS_REJECTION
.is_match(error_text)
.unwrap_or(false)
}
#[derive(Clone, Debug, PartialEq)]
pub struct ToolsWithoutStrict {
pub tools: Vec<Value>,
pub dropped_count: usize,
}
pub fn tools_without_strict(tools: Option<&Value>) -> Option<ToolsWithoutStrict> {
let tools = tools?.as_array()?;
if !tools.iter().all(Value::is_object) {
return None;
}
let dropped_count = tools
.iter()
.filter(|tool| tool.get("strict") == Some(&Value::Bool(true)))
.count();
if dropped_count == 0 {
return None;
}
Some(ToolsWithoutStrict {
tools: tools
.iter()
.cloned()
.map(|mut tool| {
if let Value::Object(fields) = &mut tool {
fields.shift_remove("strict");
}
tool
})
.collect(),
dropped_count,
})
}
#[cfg(test)]
mod tests {
use crate::base_llm::messages::context::SupportedEffortTiers;
@ -2166,4 +2220,55 @@ mod tests {
.collect();
assert_eq!(keys, ["model", "messages", "max_tokens"]);
}
fn shared_table(table: &str) -> Vec<Value> {
let fixture: Value = serde_json::from_str(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/anthropic_messages_http_error_retry.json"
)))
.unwrap();
fixture[table].as_array().unwrap().clone()
}
#[test]
fn strict_tools_rejections_match_the_cases_shared_with_python() {
let cases = shared_table("claude_strict_tools_rejection");
assert!(!cases.is_empty());
let mismatched: Vec<&str> = cases
.iter()
.filter(|case| {
let status = u16::try_from(case["status"].as_u64().unwrap()).unwrap();
is_claude_strict_tools_rejection(status, case["error_text"].as_str().unwrap())
!= case["expected"].as_bool().unwrap()
})
.map(|case| case["name"].as_str().unwrap())
.collect();
assert_eq!(mismatched, Vec::<&str>::new());
}
#[test]
fn tools_without_strict_matches_the_cases_shared_with_python() {
let cases = shared_table("tools_without_strict");
assert!(!cases.is_empty());
let mismatched: Vec<&str> = cases
.iter()
.filter(|case| {
let expected = case["expected"]
.as_object()
.map(|expected| ToolsWithoutStrict {
tools: expected["tools"].as_array().unwrap().clone(),
dropped_count: usize::try_from(expected["dropped_count"].as_u64().unwrap())
.unwrap(),
});
tools_without_strict(Some(&case["tools"])) != expected
})
.map(|case| case["name"].as_str().unwrap())
.collect();
assert_eq!(mismatched, Vec::<&str>::new());
}
#[test]
fn absent_tools_have_no_strict_to_drop() {
assert_eq!(tools_without_strict(None), None);
}
}

View file

@ -26,9 +26,10 @@ use crate::{
anthropic_request_errors_on_thinking_binding_mismatch, complete_anthropic_url,
existing_betas, get_auth_header, has_advisor_tool, has_anthropic_credential,
is_anthropic_invalid_thinking_block_error, is_anthropic_thinking_binding_mismatch_error,
is_tool_search_used, merge_beta_headers, minted_oauth_environment,
optionally_handle_anthropic_oauth, requires_native_compaction_beta, strip_advisor_blocks,
strip_encrypted_reasoning_blocks, strip_thinking_blocks_from_anthropic_messages_request,
is_claude_strict_tools_rejection, is_tool_search_used, merge_beta_headers,
minted_oauth_environment, optionally_handle_anthropic_oauth,
requires_native_compaction_beta, strip_advisor_blocks, strip_encrypted_reasoning_blocks,
strip_thinking_blocks_from_anthropic_messages_request, tools_without_strict,
},
base_llm::{
auth::AuthScheme,
@ -181,11 +182,17 @@ pub fn should_retry_anthropic_messages_on_http_error(
status: u16,
error_text: &str,
request_body: &Value,
_context: &MessagesTransformContext,
context: &MessagesTransformContext,
) -> bool {
if status != 400 {
return false;
}
let drops_rejected_strict_tools = context.drops_unsupported_params
&& is_claude_strict_tools_rejection(status, error_text)
&& tools_without_strict(request_body.get("tools")).is_some();
if drops_rejected_strict_tools {
return true;
}
let caller_asked_for_this_error =
anthropic_request_errors_on_thinking_binding_mismatch(request_body)
&& is_anthropic_thinking_binding_mismatch_error(error_text);
@ -197,16 +204,25 @@ pub fn transform_anthropic_messages_request_on_http_error(
error_text: &str,
request_body: Value,
) -> RetriedRequest {
if status == 400 && is_anthropic_invalid_thinking_block_error(error_text) {
return RetriedRequest {
reasons: vec![RetryReason::ThinkingSignature],
body: strip_thinking_blocks_from_anthropic_messages_request(request_body),
};
}
RetriedRequest {
reasons: Vec::new(),
body: request_body,
let mut reasons = Vec::new();
let mut body = if status == 400 && is_anthropic_invalid_thinking_block_error(error_text) {
reasons.push(RetryReason::ThinkingSignature);
strip_thinking_blocks_from_anthropic_messages_request(request_body)
} else {
request_body
};
let without_strict = if is_claude_strict_tools_rejection(status, error_text) {
tools_without_strict(body.get("tools"))
} else {
None
};
if let (Some(without_strict), Value::Object(fields)) = (without_strict, &mut body) {
reasons.push(RetryReason::StrictTools {
dropped: without_strict.dropped_count,
});
fields.insert("tools".to_owned(), Value::Array(without_strict.tools));
}
RetriedRequest { reasons, body }
}
pub fn retry_anthropic_messages_after_http_error(
@ -1009,6 +1025,7 @@ mod tests {
keeps_display_updates: false,
},
drop_params: true,
drops_unsupported_params: false,
}
);
return;
@ -1596,4 +1613,59 @@ mod tests {
.collect();
assert_eq!(mismatched, Vec::<&str>::new());
}
#[test]
fn the_strict_tools_retry_matches_the_cases_shared_with_python() {
let fixture: Value = serde_json::from_str(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/anthropic_messages_http_error_retry.json"
)))
.unwrap();
let replay = &fixture["http_error_retry"];
let table = &fixture["strict_tools_retry"];
let cases = table["cases"].as_array().unwrap();
assert!(!cases.is_empty());
let mismatched: Vec<&str> = cases
.iter()
.filter(|case| {
let mut body = replay["request"].clone();
body["thinking"] = case["thinking"].clone();
body["tools"] = table["tools"].clone();
let status = u16::try_from(case["status"].as_u64().unwrap()).unwrap();
let error_text = case["error_text"].as_str().unwrap();
let context = MessagesTransformContext {
drops_unsupported_params: case["drops_unsupported_params"].as_bool().unwrap(),
..MessagesTransformContext::default()
};
let mut reasons = Vec::new();
let mut expected = if case["stripped"].as_bool().unwrap() {
reasons.push(RetryReason::ThinkingSignature);
replay["stripped_request"].clone()
} else {
body.clone()
};
expected["tools"] = if case["strict_dropped"].as_bool().unwrap() {
reasons.push(RetryReason::StrictTools {
dropped: usize::try_from(table["dropped"].as_u64().unwrap()).unwrap(),
});
table["tools_without_strict"].clone()
} else {
table["tools"].clone()
};
let retried = should_retry_anthropic_messages_on_http_error(
status, error_text, &body, &context,
);
let transformed =
transform_anthropic_messages_request_on_http_error(status, error_text, body);
retried != case["retried"].as_bool().unwrap()
|| transformed
!= RetriedRequest {
reasons,
body: expected,
}
})
.map(|case| case["name"].as_str().unwrap())
.collect();
assert_eq!(mismatched, Vec::<&str>::new());
}
}

View file

@ -129,6 +129,7 @@ pub struct ThinkingContext {
pub struct MessagesTransformContext {
pub thinking: ThinkingContext,
pub drop_params: bool,
pub drops_unsupported_params: bool,
}
impl MessagesTransformContext {
pub fn new(capabilities: MessagesModelCapabilities, drop_params: bool) -> Self {
@ -148,6 +149,7 @@ impl MessagesTransformContext {
keeps_display_updates: false,
},
drop_params,
drops_unsupported_params: false,
}
}

View file

@ -718,6 +718,7 @@ mod tests {
..ThinkingContext::default()
},
drop_params: false,
drops_unsupported_params: false,
},
);
assert_eq!(body["system"], json!("be terse"));
@ -735,6 +736,7 @@ mod tests {
..ThinkingContext::default()
},
drop_params: false,
drops_unsupported_params: false,
}
}

View file

@ -248,5 +248,376 @@
"stripped": false
}
]
},
"claude_strict_tools_rejection": [
{
"name": "too_many_strict_tools",
"status": 400,
"error_text": "Too many strict tools (21). The maximum number of strict tools is 20.",
"expected": true
},
{
"name": "too_many_strict_tools_in_an_error_envelope",
"status": 400,
"error_text": "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"Too many strict tools (24). The maximum is 20.\"}}",
"expected": true
},
{
"name": "non_ascii_digits",
"status": 400,
"error_text": "Too many strict tools (٢١)",
"expected": true
},
{
"name": "optional_parameters",
"status": 400,
"error_text": "Schemas contains too many optional parameters (30)",
"expected": true
},
{
"name": "union_types",
"status": 400,
"error_text": "Schemas contains too many parameters with union types",
"expected": true
},
{
"name": "compiled_grammar_too_large",
"status": 400,
"error_text": "The compiled grammar is too large",
"expected": true
},
{
"name": "compiled_grammar_size",
"status": 400,
"error_text": "Compiled grammar size exceeds the limit",
"expected": true
},
{
"name": "custom_strict",
"status": 400,
"error_text": "tools.3.custom.strict: Extra inputs are not permitted",
"expected": true
},
{
"name": "custom",
"status": 400,
"error_text": "tools.0.custom: Extra inputs are not permitted",
"expected": true
},
{
"name": "grammar_compilation_timed_out",
"status": 400,
"error_text": "Grammar compilation timed out",
"expected": true
},
{
"name": "no_count",
"status": 400,
"error_text": "tools.0.strict: too many strict tools",
"expected": false
},
{
"name": "empty_count",
"status": 400,
"error_text": "Too many strict tools ()",
"expected": false
},
{
"name": "lowercase_grammar_timeout",
"status": 400,
"error_text": "grammar compilation timed out",
"expected": false
},
{
"name": "custom_without_separator",
"status": 400,
"error_text": "tools.0.custom.strict",
"expected": false
},
{
"name": "uppercase_schemas",
"status": 400,
"error_text": "SCHEMAS CONTAINS TOO MANY OPTIONAL PARAMETERS",
"expected": false
},
{
"name": "not_a_400",
"status": 429,
"error_text": "Too many strict tools (21). The maximum number of strict tools is 20.",
"expected": false
},
{
"name": "empty",
"status": 400,
"error_text": "",
"expected": false
}
],
"tools_without_strict": [
{
"name": "strict_dropped_from_every_tool",
"tools": [
{
"name": "a",
"strict": true,
"input_schema": {}
},
{
"name": "b",
"strict": false
},
{
"name": "c"
}
],
"expected": {
"tools": [
{
"name": "a",
"input_schema": {}
},
{
"name": "b"
},
{
"name": "c"
}
],
"dropped_count": 1
}
},
{
"name": "only_a_true_strict_counts",
"tools": [
{
"name": "a",
"strict": 1
},
{
"name": "b",
"strict": "true"
}
],
"expected": null
},
{
"name": "no_strict",
"tools": [
{
"name": "a"
}
],
"expected": null
},
{
"name": "empty",
"tools": [],
"expected": null
},
{
"name": "non_object_tool",
"tools": [
{
"name": "a",
"strict": true
},
"b"
],
"expected": null
},
{
"name": "not_a_list",
"tools": {
"name": "a",
"strict": true
},
"expected": null
},
{
"name": "null",
"tools": null,
"expected": null
}
],
"strict_tools_retry": {
"tools": [
{
"name": "a",
"input_schema": {
"type": "object"
},
"strict": true
},
{
"name": "b",
"input_schema": {
"type": "object"
},
"strict": false
},
{
"name": "c",
"input_schema": {
"type": "object"
}
}
],
"tools_without_strict": [
{
"name": "a",
"input_schema": {
"type": "object"
}
},
{
"name": "b",
"input_schema": {
"type": "object"
}
},
{
"name": "c",
"input_schema": {
"type": "object"
}
}
],
"dropped": 1,
"cases": [
{
"name": "strict_rejection_under_drop_params",
"status": 400,
"error_text": "Too many strict tools (21). The maximum number of strict tools is 20.",
"thinking": {
"type": "enabled",
"budget_tokens": 2048
},
"drops_unsupported_params": true,
"retried": true,
"stripped": false,
"strict_dropped": true
},
{
"name": "strict_rejection_without_drop_params",
"status": 400,
"error_text": "Too many strict tools (21). The maximum number of strict tools is 20.",
"thinking": {
"type": "enabled",
"budget_tokens": 2048
},
"drops_unsupported_params": false,
"retried": false,
"stripped": false,
"strict_dropped": true
},
{
"name": "compiled_grammar_under_drop_params",
"status": 400,
"error_text": "The compiled grammar is too large",
"thinking": {
"type": "enabled",
"budget_tokens": 2048
},
"drops_unsupported_params": true,
"retried": true,
"stripped": false,
"strict_dropped": true
},
{
"name": "strict_text_on_a_429",
"status": 429,
"error_text": "Too many strict tools (21). The maximum number of strict tools is 20.",
"thinking": {
"type": "enabled",
"budget_tokens": 2048
},
"drops_unsupported_params": true,
"retried": false,
"stripped": false,
"strict_dropped": false
},
{
"name": "unrelated_400_under_drop_params",
"status": 400,
"error_text": "max_tokens: 4096 > 64000",
"thinking": {
"type": "enabled",
"budget_tokens": 2048
},
"drops_unsupported_params": true,
"retried": false,
"stripped": false,
"strict_dropped": false
},
{
"name": "thinking_rejection_keeps_strict",
"status": 400,
"error_text": "messages.1.content.0: Invalid `signature` in `thinking` block",
"thinking": {
"type": "enabled",
"budget_tokens": 2048
},
"drops_unsupported_params": true,
"retried": true,
"stripped": true,
"strict_dropped": false
},
{
"name": "thinking_rejection_without_drop_params_keeps_strict",
"status": 400,
"error_text": "messages.1.content.0: Invalid `signature` in `thinking` block",
"thinking": {
"type": "enabled",
"budget_tokens": 2048
},
"drops_unsupported_params": false,
"retried": true,
"stripped": true,
"strict_dropped": false
},
{
"name": "one_error_naming_both_without_drop_params",
"status": 400,
"error_text": "messages.1.content.0: Invalid `signature` in `thinking` block. Too many strict tools (21). The maximum number of strict tools is 20.",
"thinking": {
"type": "enabled",
"budget_tokens": 2048
},
"drops_unsupported_params": false,
"retried": true,
"stripped": true,
"strict_dropped": true
},
{
"name": "pinned_binding_mismatch_with_a_strict_rejection_under_drop_params",
"status": 400,
"error_text": "messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different conversation. Too many strict tools (21). The maximum number of strict tools is 20.",
"thinking": {
"type": "adaptive",
"block_binding": {
"prefix_mismatch_behavior": "error"
}
},
"drops_unsupported_params": true,
"retried": true,
"stripped": true,
"strict_dropped": true
},
{
"name": "pinned_binding_mismatch_with_a_strict_rejection_without_drop_params",
"status": 400,
"error_text": "messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different conversation. Too many strict tools (21). The maximum number of strict tools is 20.",
"thinking": {
"type": "adaptive",
"block_binding": {
"prefix_mismatch_behavior": "error"
}
},
"drops_unsupported_params": false,
"retried": false,
"stripped": true,
"strict_dropped": true
}
]
}
}

View file

@ -70,3 +70,69 @@ fn every_messages_provider_retries_like_the_python_base_config(
.collect();
assert_eq!(mismatched, Vec::<&str>::new());
}
#[rstest]
#[case::anthropic(&ANTHROPIC_MESSAGES_CONFIG)]
#[case::azure_ai(&AZURE_ANTHROPIC_MESSAGES_CONFIG)]
#[case::bedrock_invoke(&BEDROCK_ANTHROPIC_MESSAGES_CONFIG)]
#[case::bedrock_claude_platform(&BEDROCK_CLAUDE_PLATFORM_MESSAGES_CONFIG)]
#[case::bedrock_mantle(&BEDROCK_MANTLE_MESSAGES_CONFIG)]
#[case::deepseek(&DEEPSEEK_ANTHROPIC_MESSAGES_CONFIG)]
#[case::edenai(&EDENAI_MESSAGES_CONFIG)]
#[case::minimax(&MINIMAX_MESSAGES_CONFIG)]
#[case::openrouter(&OPENROUTER_MESSAGES_CONFIG)]
#[case::tencent(&TENCENT_MESSAGES_CONFIG)]
#[case::vertex_ai(&VERTEX_ANTHROPIC_MESSAGES_CONFIG)]
fn every_messages_provider_drops_rejected_strict_tools_like_the_python_base_config(
#[case] config: &dyn BaseMessagesConfig,
) {
let fixture: Value = serde_json::from_str(include_str!(
"fixtures/anthropic_messages_http_error_retry.json"
))
.unwrap();
let replay = &fixture["http_error_retry"];
let table = &fixture["strict_tools_retry"];
let cases = table["cases"].as_array().unwrap();
assert!(!cases.is_empty());
let mismatched: Vec<&str> = cases
.iter()
.filter(|case| {
let mut body = replay["request"].clone();
body["thinking"] = case["thinking"].clone();
body["tools"] = table["tools"].clone();
let status = u16::try_from(case["status"].as_u64().unwrap()).unwrap();
let expected = case["retried"].as_bool().unwrap().then(|| {
let mut reasons = Vec::new();
let mut expected = if case["stripped"].as_bool().unwrap() {
reasons.push(RetryReason::ThinkingSignature);
replay["stripped_request"].clone()
} else {
body.clone()
};
expected["tools"] = if case["strict_dropped"].as_bool().unwrap() {
reasons.push(RetryReason::StrictTools {
dropped: usize::try_from(table["dropped"].as_u64().unwrap()).unwrap(),
});
table["tools_without_strict"].clone()
} else {
table["tools"].clone()
};
RetriedRequest {
reasons,
body: expected,
}
});
config.retry_after_http_error(
status,
case["error_text"].as_str().unwrap(),
&body,
&MessagesTransformContext {
drops_unsupported_params: case["drops_unsupported_params"].as_bool().unwrap(),
..MessagesTransformContext::default()
},
) != expected
})
.map(|case| case["name"].as_str().unwrap())
.collect();
assert_eq!(mismatched, Vec::<&str>::new());
}

View file

@ -31,6 +31,8 @@ impl Router {
.model_list
.iter()
.map(|model| {
let drop_params =
drops_params_globally || is_on(model.litellm_params.drop_params.as_ref(), env);
(
model.model_name.clone(),
Deployment {
@ -45,8 +47,8 @@ impl Router {
litellm_params: model.litellm_params.clone(),
shaping: MessagesShaping {
settings: MessagesSettings {
drop_params: drops_params_globally
|| is_on(model.litellm_params.drop_params.as_ref(), env),
drop_params,
drops_unsupported_params: drop_params,
..MessagesSettings::default()
},
..MessagesShaping::default()
@ -113,15 +115,18 @@ mod tests {
))
.unwrap();
let settings = Router::from_config(&config, &|name: &str| {
(name == "DROP_PARAMS_FLAG").then(|| " Yes ".to_owned())
})
.get("public-model")
.unwrap()
.shaping
.settings
.clone();
assert_eq!(
Router::from_config(&config, &|name: &str| (name == "DROP_PARAMS_FLAG")
.then(|| " Yes ".to_owned()))
.get("public-model")
.unwrap()
.shaping
.settings
.drop_params,
expected
(settings.drop_params, settings.drops_unsupported_params),
(expected, expected)
);
}
}

View file

@ -149,7 +149,6 @@ _ASYNC_FEATURES: Final[detectors.Detectors] = MappingProxyType(
Feature.PRE_REQUEST_HOOK: route_detectors.pre_request_hook,
Feature.WEBSEARCH_INTERCEPTION: route_detectors.websearch_interception,
Feature.MESSAGES_INTERCEPTOR: route_detectors.intercepted,
Feature.STRICT_TOOLS_RETRY: route_detectors.strict_tools_retry,
}
)

View file

@ -92,8 +92,6 @@ class Feature(str, Enum):
"""No static key, so Python mints a workload identity federation token."""
CACHE_CONTROL_INJECTION = "cache_control_injection"
"""Prompt caching injection that Python applies to messages and system."""
STRICT_TOOLS_RETRY = "strict_tools_retry"
"""Strict tools under drop_params, which Python sends again without strict if Claude rejects their limits."""
CLIENT_HEADERS = "client_headers"
"""Proxy-forwarded headers that Python merges into extra_headers."""
DEPLOYMENT_HOOK = "deployment_hook"
@ -169,7 +167,6 @@ MESSAGES_PYTHON_FEATURES: Final = (COMMON_PYTHON_FEATURES - {Feature.TIMEOUT_VAR
Feature.PRE_REQUEST_HOOK,
Feature.WEBSEARCH_INTERCEPTION,
Feature.MESSAGES_INTERCEPTOR,
Feature.STRICT_TOOLS_RETRY,
}
RESPONSES_PYTHON_FEATURES: Final = COMMON_PYTHON_FEATURES | {

View file

@ -18,6 +18,7 @@ import litellm
from litellm.litellm_core_utils import exception_mapping_utils
from litellm.litellm_core_utils.core_helpers import (
as_str_mapping,
drops_unsupported_params,
normalize_drop_params,
proxy_request_headers,
stream_cost_injection_requested,
@ -95,6 +96,7 @@ class MessagesSettings:
reasoning_auto_summary: bool
additional_drop_params: Sequence[str]
request_timeout: float | None
drops_unsupported_params: bool
model: str | None = None
mock_response: Mapping[str, object] | None = None
stream_cost_model: str | None = None
@ -528,6 +530,7 @@ def settings(kwargs: Mapping[str, object], request: Mapping[str, object] | None
reasoning_auto_summary=is_reasoning_auto_summary_enabled(),
additional_drop_params=handler.additional_drop_paths(kwargs),
request_timeout=get_configured_request_timeout(),
drops_unsupported_params=drops_unsupported_params(kwargs.get("drop_params")),
)
return asdict(configured if request is None else _with_call_settings(configured, request))

View file

@ -5,7 +5,6 @@ from __future__ import annotations
from typing import Final
import litellm
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.rust_bridge.detectors import overridden, present
from litellm.rust_bridge.public_call import NativeCall, optional_mapping, optional_sequence, optional_str
@ -14,13 +13,6 @@ def streamed_mock_response(request: NativeCall) -> bool:
return present(request, "mock_response") and request.resolved.get("stream") is True
def strict_tools_retry(request: NativeCall) -> bool:
tools: Final = optional_sequence(request.resolved.get("tools")) or ()
return drops_unsupported_params(request.resolved.get("drop_params")) and any(
(optional_mapping(tool) or {}).get("strict") is True for tool in tools
)
def pre_request_hook(_request: NativeCall) -> bool:
"""The async Messages handler runs callbacks that can rewrite the request."""
return overridden("async_pre_request_hook", litellm.callbacks)

View file

@ -905,14 +905,6 @@ SERVED_NATIVELY_CASES: Final = (
FALLBACK_CASES: Final = (
Case("mock_response_streamed", _kw(stream=True, mock_response="Hi from the mock"), upstream=()),
Case(
"strict_tools_rejected_then_retried",
_kw(tools=[{**WEATHER_TOOL, "strict": True}], drop_params=True),
upstream=(
_failure(400, "invalid_request_error", "tools.0.strict: too many strict tools"),
_ok(),
),
),
Case("modify_params", _kw(max_tokens=1), settings=_MODIFY_PARAMS),
Case("modify_params_clamps_max_tokens", _kw(max_tokens=200_000), settings=_MODIFY_PARAMS),
Case(
@ -1161,6 +1153,97 @@ WORKLOAD_IDENTITY_CASES: Final = (
Case("workload_identity_shadowed_by_a_static_key", _kw(**FEDERATION_IDS), env=FEDERATION_ENV),
)
STRICT_REJECTION: Final = "Too many strict tools (21). The maximum number of strict tools is 20."
STRICT_TOOLS: Final[tuple[JsonValue, ...]] = (
{**WEATHER_TOOL, "strict": True},
{**WEATHER_TOOL, "name": "get_time", "strict": False},
dict(WEATHER_TOOL, name="get_tide"),
)
def _strict(**kwargs: object) -> Mapping[str, object]:
return _kw(tools=list(STRICT_TOOLS), **kwargs)
STRICT_TOOLS_RETRY_CASES: Final = (
Case(
"strict_tools_rejected_then_retried", _strict(drop_params=True), upstream=(_rejected(STRICT_REJECTION), _ok())
),
Case(
"strict_tools_rejected_then_retried_stream",
_strict(drop_params=True, stream=True),
upstream=(_rejected(STRICT_REJECTION), _stream(TEXT_STREAM)),
),
Case(
"strict_tools_rejected_then_retried_under_global_drop_params",
_strict(),
upstream=(_rejected("The compiled grammar is too large"), _ok()),
settings=MappingProxyType({"drop_params": True}),
),
Case(
"strict_tools_custom_field_rejected_then_retried",
_strict(drop_params="true"),
upstream=(_rejected("tools.0.custom.strict: Extra inputs are not permitted"), _ok()),
),
Case(
"strict_tools_rejected_without_drop_params",
_strict(),
upstream=(_rejected(STRICT_REJECTION),),
settings=MappingProxyType({"drop_params": False}),
),
Case(
"strict_tools_rejected_under_a_truthy_global_drop_params_that_is_not_true",
_strict(),
upstream=(_rejected(STRICT_REJECTION),),
settings=MappingProxyType({"drop_params": 1}),
),
Case(
"strict_rejection_without_a_strict_tool",
_kw(tools=[{**WEATHER_TOOL, "strict": False}], drop_params=True),
upstream=(_rejected(STRICT_REJECTION),),
),
Case(
"strict_rejection_text_on_a_429_is_not_retried",
_strict(drop_params=True),
upstream=(_rejected(STRICT_REJECTION, status=429),),
),
Case(
"strict_tools_rejected_twice",
_strict(drop_params=True),
upstream=(_rejected(STRICT_REJECTION), _rejected("Grammar compilation timed out")),
),
Case(
"strict_tools_rejected_twice_stream",
_strict(drop_params=True, stream=True),
upstream=(_rejected(STRICT_REJECTION), _rejected(STRICT_REJECTION)),
),
Case(
"strict_tools_and_thinking_strict_rejected_then_retried",
_replay(tools=list(STRICT_TOOLS), drop_params=True),
upstream=(_rejected(STRICT_REJECTION), _ok()),
),
Case(
"strict_tools_and_thinking_signature_rejected_then_retried",
_replay(tools=list(STRICT_TOOLS), drop_params=True),
upstream=(_rejected(), _ok()),
),
Case(
"strict_tools_and_thinking_each_rejected_spends_the_one_retry",
_replay(tools=list(STRICT_TOOLS), drop_params=True),
upstream=(_rejected(STRICT_REJECTION), _rejected()),
),
Case(
"strict_tools_and_thinking_signature_then_strict_rejected",
_replay(tools=list(STRICT_TOOLS), drop_params=True),
upstream=(_rejected(), _rejected(STRICT_REJECTION), _ok()),
),
Case(
"one_error_naming_both_drops_thinking_and_strict_without_drop_params",
_replay(tools=list(STRICT_TOOLS)),
upstream=(_rejected(f"{INVALID_SIGNATURE}. {STRICT_REJECTION}"), _ok()),
),
)
CASES: Final = (
*REQUEST_CASES,
*PROMPT_CACHING_CASES,
@ -1171,6 +1254,7 @@ CASES: Final = (
*SYNC_CASES,
*SERVED_NATIVELY_CASES,
*THINKING_RETRY_CASES,
*STRICT_TOOLS_RETRY_CASES,
*FALLBACK_CASES,
*CACHE_CASES,
*WORKLOAD_IDENTITY_CASES,
@ -1434,6 +1518,7 @@ def _logged(
"logged_request": {
"messages": details.get("messages"),
"thinking": details.get("thinking"),
"tools": details.get("tools"),
"complete_input_dict": _mapping(details.get("additional_args")).get("complete_input_dict"),
},
}

View file

@ -594,27 +594,24 @@ async def test_replayed_thinking_is_served_natively_under_the_shipped_rules(
@pytest.mark.asyncio
@pytest.mark.parametrize(
"kwargs",
("kwargs", "global_drop_params"),
(
pytest.param({"tools": [STRICT_TOOL]}, id="drop-params-unset"),
pytest.param({"drop_params": False, "tools": [STRICT_TOOL]}, id="drop-params-off"),
pytest.param(
{"drop_params": True, "tools": [{"name": "a", "input_schema": {"type": "object"}}]}, id="no-strict"
),
pytest.param({"drop_params": True, "tools": [STRICT_TOOL]}, False, id="drop-params-kwarg"),
pytest.param({"tools": [STRICT_TOOL]}, True, id="drop-params-global"),
pytest.param({"tools": [STRICT_TOOL]}, False, id="drop-params-unset"),
),
)
async def test_strict_tools_stay_native_unless_python_could_drop_strict(
monkeypatch: pytest.MonkeyPatch, kwargs: Mapping[str, object]
async def test_strict_tools_are_served_natively_under_the_shipped_rules(
monkeypatch: pytest.MonkeyPatch, kwargs: Mapping[str, object], global_drop_params: bool
) -> None:
rules: Final[Rules] = (
RouteRule(Route.MESSAGES, Rollout.PYTHON_ONLY, features=frozenset({Feature.STRICT_TOOLS_RETRY})),
RouteRule(Route.MESSAGES, Rollout.RUST_REQUIRED),
)
monkeypatch.setenv("LITELLM_RUST", "1")
monkeypatch.setattr(litellm, "callbacks", [])
monkeypatch.setattr(litellm, "cache", None)
monkeypatch.setattr(litellm, "drop_params", global_drop_params)
native_result: Final = response()
monkeypatch.setattr(litellm, "drop_params", False)
async def python(*call_args: object, **python_kwargs: object) -> AnthropicMessagesResponse: # kwargs-ok: stub
pytest.fail("Python cannot drop strict here")
pytest.fail("strict tools stay on the native path")
async def native(request: NativeCall) -> AnthropicMessagesResponse:
return native_result
@ -625,7 +622,7 @@ async def test_strict_tools_stay_native_unless_python_could_drop_strict(
python=python,
binding=amessages_binding(native),
native=lambda hook, request, call_args, native_kwargs: hook(request),
rules=rules,
rules=RULES,
)
assert served is native_result
@ -721,12 +718,6 @@ ASYNC_ONLY_FEATURES: Final = (
{},
id="messages-interceptor",
),
pytest.param(
Feature.STRICT_TOOLS_RETRY, {"drop_params": True, "tools": [STRICT_TOOL]}, {}, id="strict-tools-retry"
),
pytest.param(
Feature.STRICT_TOOLS_RETRY, {"tools": [STRICT_TOOL]}, {"drop_params": True}, id="strict-tools-retry-global"
),
)

View file

@ -8,9 +8,13 @@ import pytest
from pydantic import TypeAdapter
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm.llms.anthropic.common_utils import (
ToolsWithoutStrict,
is_anthropic_invalid_thinking_block_error,
is_anthropic_thinking_binding_mismatch_error,
is_claude_strict_tools_rejection,
tools_without_strict,
)
from litellm.llms.anthropic.messages.native.transformation import AnthropicMessagesConfig
from litellm.llms.deepseek.messages.transformation import DeepSeekAnthropicMessagesConfig
@ -42,14 +46,54 @@ class _RetryTable(TypedDict):
cases: ReadOnly[tuple[_RetryCase, ...]]
class _StrictRejectionCase(TypedDict):
name: ReadOnly[str]
status: ReadOnly[int]
error_text: ReadOnly[str]
expected: ReadOnly[bool]
class _ToolsWithoutStrictExpected(TypedDict):
tools: ReadOnly[tuple[Mapping[str, object], ...]]
dropped_count: ReadOnly[int]
class _ToolsWithoutStrictCase(TypedDict):
name: ReadOnly[str]
tools: ReadOnly[object]
expected: ReadOnly[_ToolsWithoutStrictExpected | None]
class _StrictRetryCase(TypedDict):
name: ReadOnly[str]
status: ReadOnly[int]
error_text: ReadOnly[str]
thinking: ReadOnly[Mapping[str, object]]
drops_unsupported_params: ReadOnly[bool]
retried: ReadOnly[bool]
stripped: ReadOnly[bool]
strict_dropped: ReadOnly[bool]
class _StrictRetryTable(TypedDict):
tools: ReadOnly[tuple[Mapping[str, object], ...]]
tools_without_strict: ReadOnly[tuple[Mapping[str, object], ...]]
dropped: ReadOnly[int]
cases: ReadOnly[tuple[_StrictRetryCase, ...]]
class _Fixture(TypedDict):
invalid_thinking_block_error: ReadOnly[tuple[_TextCase, ...]]
thinking_binding_mismatch_error: ReadOnly[tuple[_TextCase, ...]]
http_error_retry: ReadOnly[_RetryTable]
claude_strict_tools_rejection: ReadOnly[tuple[_StrictRejectionCase, ...]]
tools_without_strict: ReadOnly[tuple[_ToolsWithoutStrictCase, ...]]
strict_tools_retry: ReadOnly[_StrictRetryTable]
FIXTURE: Final = TypeAdapter(_Fixture).validate_json(FIXTURE_PATH.read_text())
RETRY: Final = FIXTURE["http_error_retry"]
STRICT_RETRY: Final = FIXTURE["strict_tools_retry"]
@pytest.mark.parametrize(
@ -66,7 +110,25 @@ def test_binding_mismatch_errors_match_the_shared_cases(case: _TextCase) -> None
assert is_anthropic_thinking_binding_mismatch_error(case["error_text"]) is case["expected"]
def _rejection(case: _RetryCase) -> httpx.HTTPStatusError:
@pytest.mark.parametrize(
"case", [pytest.param(case, id=case["name"]) for case in FIXTURE["claude_strict_tools_rejection"]]
)
def test_strict_tools_rejections_match_the_shared_cases(case: _StrictRejectionCase) -> None:
assert is_claude_strict_tools_rejection(case["status"], case["error_text"]) is case["expected"]
@pytest.mark.parametrize("case", [pytest.param(case, id=case["name"]) for case in FIXTURE["tools_without_strict"]])
def test_tools_without_strict_matches_the_shared_cases(case: _ToolsWithoutStrictCase) -> None:
expected: Final = case["expected"]
assert tools_without_strict(copy.deepcopy(case["tools"])) == (
None
if expected is None
else ToolsWithoutStrict(tools=tuple(expected["tools"]), dropped_count=expected["dropped_count"])
)
def _rejection(case: _RetryCase | _StrictRetryCase) -> httpx.HTTPStatusError:
request: Final = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
return httpx.HTTPStatusError(
"rejected",
@ -93,3 +155,34 @@ def test_the_http_error_retry_matches_the_shared_cases(config: BaseAnthropicMess
assert retried is case["retried"]
assert transformed == (RETRY["stripped_request"] if case["stripped"] else body)
@pytest.mark.parametrize(
"config",
[AnthropicMessagesConfig(), DeepSeekAnthropicMessagesConfig()],
ids=["anthropic", "deepseek"],
)
@pytest.mark.parametrize("case", [pytest.param(case, id=case["name"]) for case in STRICT_RETRY["cases"]])
def test_the_strict_tools_retry_matches_the_shared_cases(
monkeypatch: pytest.MonkeyPatch, config: BaseAnthropicMessagesConfig, case: _StrictRetryCase
) -> None:
monkeypatch.setattr(litellm, "drop_params", False)
body: Final = {
**copy.deepcopy(RETRY["request"]),
"thinking": case["thinking"],
"tools": copy.deepcopy(list(STRICT_RETRY["tools"])),
}
expected: Final = {
**copy.deepcopy(RETRY["stripped_request"] if case["stripped"] else body),
"tools": list(STRICT_RETRY["tools_without_strict"] if case["strict_dropped"] else STRICT_RETRY["tools"]),
}
retried: Final = config.should_retry_anthropic_messages_on_http_error(
e=_rejection(case), litellm_params={"drop_params": case["drops_unsupported_params"]}, request_body=body
)
transformed: Final = config.transform_anthropic_messages_request_on_http_error(
e=_rejection(case), request_data=copy.deepcopy(body)
)
assert retried is case["retried"]
assert transformed == expected

View file

@ -1,3 +1,4 @@
from collections.abc import Mapping
from types import MappingProxyType
from typing import Final
@ -44,6 +45,7 @@ def test_settings_project_caller_configuration_without_resolving_a_model(monkeyp
"reasoning_auto_summary": True,
"additional_drop_params": ("metadata.user_id",),
"request_timeout": 1800.0,
"drops_unsupported_params": True,
"model": None,
"mock_response": None,
"stream_cost_model": None,
@ -51,6 +53,32 @@ def test_settings_project_caller_configuration_without_resolving_a_model(monkeyp
}
@pytest.mark.parametrize(
("global_drop_params", "kwargs", "drop_params", "drops_unsupported_params"),
[
pytest.param(True, {}, True, True, id="global-true"),
pytest.param(1, {}, True, False, id="global-truthy-non-bool"),
pytest.param(False, {"drop_params": "True"}, True, True, id="kwarg-flag-string"),
pytest.param(False, {"drop_params": 1}, True, True, id="kwarg-one"),
pytest.param(False, {"drop_params": "maybe"}, False, False, id="kwarg-not-a-flag"),
pytest.param(False, {}, False, False, id="unset"),
],
)
def test_strict_tools_retry_reads_drop_params_as_the_python_retry_check_does(
monkeypatch: pytest.MonkeyPatch,
global_drop_params: object,
kwargs: Mapping[str, object],
drop_params: bool,
drops_unsupported_params: bool,
) -> None:
monkeypatch.setattr(litellm, "drop_params", global_drop_params)
projected: Final = messages_host.settings(kwargs)
assert projected["drop_params"] is drop_params
assert projected["drops_unsupported_params"] is drops_unsupported_params
@pytest.mark.parametrize(
("timeout", "explicitly_set", "expected"),
[

View file

@ -32,7 +32,6 @@ MAPPED_FILE: Final = {
}
MAPPED_FILE_INPUT: Final = [{"role": "user", "content": [{"type": "input_file", "file_id": "litellm-file"}]}]
CACHED_TEXT: Final = {"type": "input_text", "text": "hi", "cache_control": {"type": "ephemeral"}}
STRICT_TOOL: Final = {"name": "b", "input_schema": {"type": "object"}, "strict": True}
TOOL_SEARCH_TYPE: Final = sorted(ANTHROPIC_TOOL_SEARCH_TOOL_TYPES)[0]
@ -193,21 +192,6 @@ def _wrap_nested_level(value: object, depth: int) -> object:
(detectors.cache_control_injection, {"enable_prompt_caching": True}, True),
(detectors.cache_control_injection, {"cache_control_injection_points": [{"location": "message"}]}, True),
(detectors.cache_control_injection, {"cache_control_injection_points": []}, False),
(messages_detectors.strict_tools_retry, {"drop_params": True, "tools": [{"name": "a"}, STRICT_TOOL]}, True),
(
messages_detectors.strict_tools_retry,
{"drop_params": True, "tools": [{**STRICT_TOOL, "strict": False}]},
False,
),
(
messages_detectors.strict_tools_retry,
{"additional_drop_params": ["metadata.user_id"], "tools": [STRICT_TOOL]},
False,
),
(messages_detectors.strict_tools_retry, {"tools": [STRICT_TOOL]}, False),
(messages_detectors.strict_tools_retry, {"drop_params": False, "tools": [STRICT_TOOL]}, False),
(messages_detectors.strict_tools_retry, {"drop_params": True, "tools": [{"name": "a"}]}, False),
(messages_detectors.strict_tools_retry, {"drop_params": True}, False),
(detectors.drop_params, {"drop_params": True}, True),
(detectors.drop_params, {"drop_params": False}, False),
(detectors.drop_params, {"drop_params": " True "}, True),