diff --git a/litellm-rust/crates/core-utils/src/dot_notation_indexing.rs b/litellm-rust/crates/core-utils/src/dot_notation_indexing.rs index 86553320479..be81d900f57 100644 --- a/litellm-rust/crates/core-utils/src/dot_notation_indexing.rs +++ b/litellm-rust/crates/core-utils/src/dot_notation_indexing.rs @@ -1,6 +1,3 @@ -//! JSONPath-like field deletion for `additional_drop_params`: `field`, `parent.child`, -//! `array[*].field` and `array[0].field`, as Python's `delete_nested_value` reads them. - use serde_json::Value; #[derive(Clone, Debug, PartialEq, Eq)] @@ -49,13 +46,13 @@ fn without_path(value: Value, segments: &[Segment]) -> Value { }) .collect(), ), - (Segment::Every, Value::Array(items)) if !tail.is_empty() => Value::Array( + (Segment::Every, Value::Array(items)) => Value::Array( items .into_iter() .map(|item| without_path(item, tail)) .collect(), ), - (Segment::Index(index), Value::Array(items)) if !tail.is_empty() => Value::Array( + (Segment::Index(index), Value::Array(items)) => Value::Array( items .into_iter() .enumerate() @@ -72,8 +69,6 @@ fn without_path(value: Value, segments: &[Segment]) -> Value { } } -/// The value with the field at `path` removed. An unparsable path leaves it untouched, and -/// array elements themselves are never removed, only fields inside them. pub fn delete_nested_value(value: Value, path: &str) -> Value { match parse_segments(path) { Some(segments) => without_path(value, &segments), @@ -83,28 +78,197 @@ pub fn delete_nested_value(value: Value, path: &str) -> Value { #[cfg(test)] mod tests { - use rstest::rstest; + use rstest::{fixture, rstest}; use serde_json::json; use super::*; - const TOOLS: &str = r#"[{"name": "t1", "input_examples": ["a"]}, {"name": "t2", "keep": 1, "input_examples": ["b"]}]"#; - + #[fixture] fn body() -> Value { - let tools: Value = serde_json::from_str(TOOLS).unwrap(); - json!({"tools": tools, "metadata": {"user_id": "u"}}) + json!({ + "tools": [ + {"name": "t0", "examples": ["a"], "arr": [{"f": 1, "k": 1}, {"f": 2, "k": 2}]}, + {"name": "t1", "examples": ["b"], "arr": [{"f": 3, "k": 3}]} + ], + "meta": {"user": "u", "inner": {"drop": 1, "keep": 2}}, + "top": 0.7 + }) } #[rstest] - #[case("tools[*].input_examples", json!({"tools": [{"name": "t1"}, {"name": "t2", "keep": 1}], "metadata": {"user_id": "u"}}))] - #[case("tools[0].input_examples", json!({"tools": [{"name": "t1"}, {"name": "t2", "keep": 1, "input_examples": ["b"]}], "metadata": {"user_id": "u"}}))] - #[case("metadata.user_id", json!({"tools": body()["tools"], "metadata": {}}))] - #[case("tools", json!({"metadata": {"user_id": "u"}}))] - #[case("tools[*]", body())] - #[case("missing.path", body())] - #[case("tools[x].name", body())] - #[case("tools[1", body())] - fn deletes_like_python(#[case] path: &str, #[case] expected: Value) { - assert_eq!(delete_nested_value(body(), path), expected); + #[case::top_level_field("top", json!({ + "tools": [ + {"name": "t0", "examples": ["a"], "arr": [{"f": 1, "k": 1}, {"f": 2, "k": 2}]}, + {"name": "t1", "examples": ["b"], "arr": [{"f": 3, "k": 3}]} + ], + "meta": {"user": "u", "inner": {"drop": 1, "keep": 2}} + }))] + #[case::whole_object("meta", json!({ + "tools": [ + {"name": "t0", "examples": ["a"], "arr": [{"f": 1, "k": 1}, {"f": 2, "k": 2}]}, + {"name": "t1", "examples": ["b"], "arr": [{"f": 3, "k": 3}]} + ], + "top": 0.7 + }))] + #[case::nested_field("meta.inner.drop", json!({ + "tools": [ + {"name": "t0", "examples": ["a"], "arr": [{"f": 1, "k": 1}, {"f": 2, "k": 2}]}, + {"name": "t1", "examples": ["b"], "arr": [{"f": 3, "k": 3}]} + ], + "meta": {"user": "u", "inner": {"keep": 2}}, + "top": 0.7 + }))] + #[case::trailing_dot("meta.inner.drop.", json!({ + "tools": [ + {"name": "t0", "examples": ["a"], "arr": [{"f": 1, "k": 1}, {"f": 2, "k": 2}]}, + {"name": "t1", "examples": ["b"], "arr": [{"f": 3, "k": 3}]} + ], + "meta": {"user": "u", "inner": {"keep": 2}}, + "top": 0.7 + }))] + #[case::leading_and_doubled_dots(".meta..inner.drop", json!({ + "tools": [ + {"name": "t0", "examples": ["a"], "arr": [{"f": 1, "k": 1}, {"f": 2, "k": 2}]}, + {"name": "t1", "examples": ["b"], "arr": [{"f": 3, "k": 3}]} + ], + "meta": {"user": "u", "inner": {"keep": 2}}, + "top": 0.7 + }))] + #[case::field_in_every_element("tools[*].examples", json!({ + "tools": [ + {"name": "t0", "arr": [{"f": 1, "k": 1}, {"f": 2, "k": 2}]}, + {"name": "t1", "arr": [{"f": 3, "k": 3}]} + ], + "meta": {"user": "u", "inner": {"drop": 1, "keep": 2}}, + "top": 0.7 + }))] + #[case::whole_array_field_in_every_element("tools[*].arr", json!({ + "tools": [ + {"name": "t0", "examples": ["a"]}, + {"name": "t1", "examples": ["b"]} + ], + "meta": {"user": "u", "inner": {"drop": 1, "keep": 2}}, + "top": 0.7 + }))] + #[case::field_in_indexed_element("tools[1].examples", json!({ + "tools": [ + {"name": "t0", "examples": ["a"], "arr": [{"f": 1, "k": 1}, {"f": 2, "k": 2}]}, + {"name": "t1", "arr": [{"f": 3, "k": 3}]} + ], + "meta": {"user": "u", "inner": {"drop": 1, "keep": 2}}, + "top": 0.7 + }))] + #[case::padded_index("tools[ 1 ].examples", json!({ + "tools": [ + {"name": "t0", "examples": ["a"], "arr": [{"f": 1, "k": 1}, {"f": 2, "k": 2}]}, + {"name": "t1", "arr": [{"f": 3, "k": 3}]} + ], + "meta": {"user": "u", "inner": {"drop": 1, "keep": 2}}, + "top": 0.7 + }))] + #[case::field_right_after_bracket("tools[0]examples", json!({ + "tools": [ + {"name": "t0", "arr": [{"f": 1, "k": 1}, {"f": 2, "k": 2}]}, + {"name": "t1", "examples": ["b"], "arr": [{"f": 3, "k": 3}]} + ], + "meta": {"user": "u", "inner": {"drop": 1, "keep": 2}}, + "top": 0.7 + }))] + #[case::index_then_wildcard("tools[0].arr[*].f", json!({ + "tools": [ + {"name": "t0", "examples": ["a"], "arr": [{"k": 1}, {"k": 2}]}, + {"name": "t1", "examples": ["b"], "arr": [{"f": 3, "k": 3}]} + ], + "meta": {"user": "u", "inner": {"drop": 1, "keep": 2}}, + "top": 0.7 + }))] + #[case::wildcard_then_index_only_where_it_exists("tools[*].arr[1].f", json!({ + "tools": [ + {"name": "t0", "examples": ["a"], "arr": [{"f": 1, "k": 1}, {"k": 2}]}, + {"name": "t1", "examples": ["b"], "arr": [{"f": 3, "k": 3}]} + ], + "meta": {"user": "u", "inner": {"drop": 1, "keep": 2}}, + "top": 0.7 + }))] + #[case::nested_wildcards("tools[*].arr[*].f", json!({ + "tools": [ + {"name": "t0", "examples": ["a"], "arr": [{"k": 1}, {"k": 2}]}, + {"name": "t1", "examples": ["b"], "arr": [{"k": 3}]} + ], + "meta": {"user": "u", "inner": {"drop": 1, "keep": 2}}, + "top": 0.7 + }))] + fn deletes_the_addressed_field(body: Value, #[case] path: &str, #[case] expected: Value) { + assert_eq!(delete_nested_value(body, path), expected); + } + + #[rstest] + #[case::empty_path("")] + #[case::missing_field("missing")] + #[case::missing_parent("missing.field")] + #[case::field_through_a_scalar("top.value")] + #[case::field_on_an_array("tools.name")] + #[case::index_on_an_object("meta[0].user")] + #[case::wildcard_on_an_object("meta[*].user")] + #[case::wildcard_over_scalars("tools[*].examples[*].name")] + #[case::index_out_of_range("tools[5].name")] + #[case::every_element_itself("tools[*]")] + #[case::indexed_element_itself("tools[0]")] + #[case::nested_element_itself("tools[*].arr[0]")] + #[case::negative_index("tools[-1].name")] + #[case::non_numeric_index("tools[x].name")] + #[case::empty_index("tools[].name")] + #[case::unclosed_bracket("top[0")] + fn leaves_the_value_untouched(body: Value, #[case] path: &str) { + assert_eq!(delete_nested_value(body.clone(), path), body); + } + + #[rstest] + #[case::wildcards_indices_and_nesting( + json!({"tools": [ + {"name": "t0", "configs": [{"id": "c0", "remove_me": 1, "keep": 1}, {"id": "c1", "remove_me": 2, "keep": 2}], "metadata": {"drop_this": 1, "preserve": 1}}, + {"name": "t1", "configs": [{"id": "c0", "remove_me": 3, "keep": 3}, {"id": "c1", "remove_me": 4, "keep": 4}], "metadata": {"drop_this": 2, "preserve": 2}}, + {"name": "t2", "configs": [{"id": "c0", "remove_me": 5, "keep": 5}], "metadata": {"drop_this": 3, "preserve": 3}} + ]}), + &["tools[*].configs[1].remove_me", "tools[1].metadata.drop_this", "tools[*].configs[*].id"], + json!({"tools": [ + {"name": "t0", "configs": [{"remove_me": 1, "keep": 1}, {"keep": 2}], "metadata": {"drop_this": 1, "preserve": 1}}, + {"name": "t1", "configs": [{"remove_me": 3, "keep": 3}, {"keep": 4}], "metadata": {"preserve": 2}}, + {"name": "t2", "configs": [{"remove_me": 5, "keep": 5}], "metadata": {"drop_this": 3, "preserve": 3}} + ]}), + )] + #[case::simple_and_wildcard_nesting( + json!({ + "tools": [{"name": "t1", "simple_nested": {"remove": 1, "keep": 2}, "complex": [{"nested": {"remove": 3, "keep": 4}}]}], + "top_level_remove": "should_go", + "top_level_keep": "should_stay" + }), + &["tools[*].simple_nested.remove", "tools[*].complex[*].nested.remove"], + json!({ + "tools": [{"name": "t1", "simple_nested": {"keep": 2}, "complex": [{"nested": {"keep": 4}}]}], + "top_level_remove": "should_go", + "top_level_keep": "should_stay" + }), + )] + #[case::triple_nested_wildcards( + json!({"tools": [{"name": "t1", "arr1": [ + {"arr2": [{"field": 1, "keep": 1}, {"field": 2, "keep": 2}]}, + {"arr2": [{"field": 3, "keep": 3}]} + ]}]}), + &["tools[*].arr1[*].arr2[*].field"], + json!({"tools": [{"name": "t1", "arr1": [ + {"arr2": [{"keep": 1}, {"keep": 2}]}, + {"arr2": [{"keep": 3}]} + ]}]}), + )] + fn applies_paths_in_sequence( + #[case] value: Value, + #[case] paths: &[&str], + #[case] expected: Value, + ) { + let deleted = paths + .iter() + .fold(value, |value, path| delete_nested_value(value, path)); + assert_eq!(deleted, expected); } } diff --git a/litellm-rust/crates/core/src/messages/prepare.rs b/litellm-rust/crates/core/src/messages/prepare.rs index 50162a73f86..c56181db59b 100644 --- a/litellm-rust/crates/core/src/messages/prepare.rs +++ b/litellm-rust/crates/core/src/messages/prepare.rs @@ -44,23 +44,19 @@ pub(super) fn prepare_provider_request( .ok_or_else(|| Error::InvalidProvider(provider.to_string()))?; let env_lookup = |key: &str| std::env::var(key).ok(); - let body = request - .shaping - .additional_drop_params - .iter() - .fold(request.body, |body, path| delete_nested_value(body, path)); - let typed_request: AnthropicMessagesRequest = serde_json::from_value(body).map_err(|err| { - Error::InvalidRequest(format!("invalid Anthropic messages request: {err}")) - })?; + let typed_request: AnthropicMessagesRequest = + serde_json::from_value(request.body).map_err(invalid_request)?; let sanitized = sanitize_request( AnthropicMessagesRequest { model: model.clone(), ..typed_request }, &request.shaping, - ); + )?; + let trimmed = + without_additional_drop_params(sanitized, &request.shaping.additional_drop_params)?; let transformed = config.transform_anthropic_messages_request( - sanitized, + trimmed, &MessagesTransformContext::new(request.shaping.capabilities, request.shaping.drop_params), )?; @@ -90,18 +86,49 @@ pub(super) fn prepare_provider_request( }) } -/// The route-level cleanup Python's `anthropic_messages` runs before any provider config: -/// history sanitizers, the `metadata` allowlist and the reasoning auto summary. +fn invalid_request(err: serde_json::Error) -> Error { + Error::InvalidRequest(format!("invalid Anthropic messages request: {err}")) +} + +fn without_additional_drop_params( + request: AnthropicMessagesRequest, + paths: &[String], +) -> Result { + if paths.is_empty() { + return Ok(request); + } + let Value::Object(fields) = serde_json::to_value(request).map_err(invalid_request)? else { + return Err(Error::InvalidRequest( + "Anthropic messages request did not serialize to an object".to_string(), + )); + }; + let (required, optional): (Map, Map) = fields + .into_iter() + .partition(|(key, _)| matches!(key.as_str(), "model" | "messages")); + let trimmed = paths.iter().fold(Value::Object(optional), |body, path| { + delete_nested_value(body, path) + }); + let merged: Map = required + .into_iter() + .chain(trimmed.as_object().cloned().unwrap_or_default()) + .collect(); + serde_json::from_value(Value::Object(merged)).map_err(invalid_request) +} + fn sanitize_request( request: AnthropicMessagesRequest, shaping: &MessagesShaping, -) -> AnthropicMessagesRequest { - AnthropicMessagesRequest { +) -> Result { + Ok(AnthropicMessagesRequest { messages: sanitize_messages(request.messages), - metadata: request.metadata.as_ref().map(allowed_metadata), + metadata: request + .metadata + .as_ref() + .map(allowed_metadata) + .transpose()?, thinking: with_reasoning_auto_summary(request.thinking, shaping.reasoning_auto_summary), ..request - } + }) } fn sanitize_messages(messages: Vec) -> Vec { @@ -110,16 +137,19 @@ fn sanitize_messages(messages: Vec) -> Vec { )) } -/// Only the fields Anthropic's `metadata` accepts reach the provider; LiteLLM-specific -/// metadata travels under `litellm_metadata` instead. -fn allowed_metadata(metadata: &Value) -> Value { - let user_id = metadata.get("user_id").filter(|value| !value.is_null()); - Value::Object( - user_id - .map(|value| ("user_id".to_string(), value.clone())) - .into_iter() - .collect::>(), - ) +fn allowed_metadata(metadata: &Value) -> Result { + let Value::Object(fields) = metadata else { + return Err(Error::InvalidRequest(format!( + "metadata must be an object, got {metadata}" + ))); + }; + match fields.get("user_id") { + None | Some(Value::Null) => Ok(json!({})), + Some(Value::String(user_id)) => Ok(json!({"user_id": user_id})), + Some(other) => Err(Error::InvalidRequest(format!( + "metadata.user_id must be a string, got {other}" + ))), + } } fn with_reasoning_auto_summary(thinking: Option, enabled: bool) -> Option { @@ -153,3 +183,401 @@ fn with_default_headers( .collect(); headers.into_iter().chain(missing).collect() } + +#[cfg(test)] +mod tests { + use rstest::{fixture, rstest}; + + use super::*; + + fn messages(value: Value) -> Vec { + serde_json::from_value(value).unwrap() + } + + fn request(body: Value) -> AnthropicMessagesRequest { + serde_json::from_value(body).unwrap() + } + + #[fixture] + fn shaping() -> MessagesShaping { + MessagesShaping::default() + } + + fn prepared_body(body: Value, shaping: MessagesShaping) -> Result { + prepare_provider_request(MessagesRequest { + model: "anthropic/claude-test", + body, + api_key: Some("sk-test"), + api_base: Some("https://anthropic.test"), + custom_llm_provider: Some("anthropic"), + extra_headers: None, + timeout: None, + shaping, + }) + .map(|prepared| prepared.body) + } + + #[rstest] + #[case::empty_text_next_to_a_tool_use( + json!([{"role": "assistant", "content": [ + {"type": "text", "text": " "}, + {"type": "tool_use", "id": "t", "name": "B", "input": {}} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "tool_use", "id": "t", "name": "B", "input": {}} + ]}]), + )] + #[case::cross_provider_tool_ids( + json!([ + {"role": "assistant", "content": [{"type": "tool_use", "id": "functions.Bash:0", "name": "Bash", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "functions.Bash:0", "content": "ok"}]} + ]), + json!([ + {"role": "assistant", "content": [{"type": "tool_use", "id": "functions_Bash_0", "name": "Bash", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "functions_Bash_0", "content": "ok"}]} + ]), + )] + #[case::replayed_unencrypted_web_search_results( + json!([ + {"role": "user", "content": "latest litellm version?"}, + {"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {"query": "latest litellm version"}}, + {"type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [{ + "type": "web_search_result", + "url": "https://github.com/BerriAI/litellm/releases", + "title": "Releases", + "page_age": null, + "encrypted_content": "", + "snippet": "Latest release v1.95.0" + }]} + ]}, + {"role": "user", "content": "which version?"} + ]), + json!([ + {"role": "user", "content": "latest litellm version?"}, + {"role": "assistant", "content": [{ + "type": "text", + "text": "Web search results for 'latest litellm version':\n\nTitle: Releases\nURL: https://github.com/BerriAI/litellm/releases\nSnippet: Latest release v1.95.0" + }]}, + {"role": "user", "content": "which version?"} + ]), + )] + #[case::replayed_provider_specific_fields( + json!([ + {"role": "assistant", "content": [{ + "type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "Paris"}, + "provider_specific_fields": {"signature": "sig_abc"} + }]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "Sunny"}]} + ]), + json!([ + {"role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "Paris"}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "Sunny"}]} + ]), + )] + #[case::ids_are_normalized_before_web_search_results_flatten( + json!([ + {"role": "user", "content": "run it"}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "", "signature": "sig"}, + {"type": "text", "text": ""}, + {"type": "tool_use", "id": "functions.Bash:0", "name": "Bash", "input": {}, "provider_specific_fields": {"x": 1}}, + {"type": "server_tool_use", "id": "srv.1", "name": "web_search", "input": {"query": "q"}, "provider_specific_fields": {"x": 2}}, + {"type": "web_search_tool_result", "tool_use_id": "srv.1", "provider_specific_fields": {"x": 3}, "content": [ + {"type": "web_search_result", "url": "u", "title": "", "encrypted_content": "", "provider_specific_fields": {"x": 4}} + ]} + ]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "functions.Bash:0", "content": "ok"}]}, + {"role": "assistant", "content": [{"type": "text", "text": " "}]} + ]), + json!([ + {"role": "user", "content": "run it"}, + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "functions_Bash_0", "name": "Bash", "input": {}}, + {"type": "server_tool_use", "id": "srv_1", "name": "web_search", "input": {"query": "q"}}, + {"type": "text", "text": "Web search results:\n\nURL: u"} + ]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "functions_Bash_0", "content": "ok"}]} + ]), + )] + fn sanitize_messages_cleans_replayed_history(#[case] history: Value, #[case] expected: Value) { + assert_eq!( + serde_json::to_value(sanitize_messages(messages(history))).unwrap(), + expected + ); + } + + #[rstest] + #[case::keeps_only_user_id(json!({"user_id": "u-1", "trace_id": "internal"}), Ok(json!({"user_id": "u-1"})))] + #[case::null_user_id(json!({"user_id": null, "trace_id": "internal"}), Ok(json!({})))] + #[case::no_user_id(json!({"trace_id": "internal"}), Ok(json!({})))] + #[case::empty(json!({}), Ok(json!({})))] + #[case::numeric_user_id( + json!({"user_id": 123}), + Err(Error::InvalidRequest("metadata.user_id must be a string, got 123".to_string())), + )] + #[case::boolean_user_id( + json!({"user_id": true}), + Err(Error::InvalidRequest("metadata.user_id must be a string, got true".to_string())), + )] + #[case::not_an_object( + json!(["u-1"]), + Err(Error::InvalidRequest(r#"metadata must be an object, got ["u-1"]"#.to_string())), + )] + fn allowed_metadata_passes_only_a_string_user_id( + #[case] metadata: Value, + #[case] expected: Result, + ) { + assert_eq!(allowed_metadata(&metadata), expected); + } + + #[rstest] + #[case::adaptive( + Some(json!({"type": "adaptive", "budget_tokens": 5000})), + true, + Some(json!({"type": "adaptive", "budget_tokens": 5000, "display": "summarized"})), + )] + #[case::enabled( + Some(json!({"type": "enabled", "budget_tokens": 10000})), + true, + Some(json!({"type": "enabled", "budget_tokens": 10000, "display": "summarized"})), + )] + #[case::no_type(Some(json!({})), true, Some(json!({"display": "summarized"})))] + #[case::display_omitted_is_overridden( + Some(json!({"type": "enabled", "budget_tokens": 10000, "display": "omitted"})), + true, + Some(json!({"type": "enabled", "budget_tokens": 10000, "display": "summarized"})), + )] + #[case::display_summarized_is_kept( + Some(json!({"type": "enabled", "display": "summarized"})), + true, + Some(json!({"type": "enabled", "display": "summarized"})), + )] + #[case::disabled_thinking(Some(json!({"type": "disabled"})), true, Some(json!({"type": "disabled"})))] + #[case::flag_off( + Some(json!({"type": "enabled", "budget_tokens": 10000})), + false, + Some(json!({"type": "enabled", "budget_tokens": 10000})), + )] + #[case::flag_off_keeps_callers_display( + Some(json!({"type": "enabled", "display": "omitted"})), + false, + Some(json!({"type": "enabled", "display": "omitted"})), + )] + #[case::no_thinking(None, true, None)] + #[case::non_object_thinking(Some(json!("enabled")), true, Some(json!("enabled")))] + fn reasoning_auto_summary_marks_active_thinking_as_summarized( + #[case] thinking: Option, + #[case] enabled: bool, + #[case] expected: Option, + ) { + assert_eq!(with_reasoning_auto_summary(thinking, enabled), expected); + } + + #[rstest] + #[case::nothing_forwarded( + &[], + &[("x-version", "1"), ("content-type", "application/json")], + &[("x-version", "1"), ("content-type", "application/json")], + )] + #[case::forwarded_header_wins_in_any_case( + &[("X-Version", "custom"), ("x-api-key", "k")], + &[("x-version", "1"), ("content-type", "application/json")], + &[("X-Version", "custom"), ("x-api-key", "k"), ("content-type", "application/json")], + )] + #[case::no_defaults(&[("x-api-key", "k")], &[], &[("x-api-key", "k")])] + fn default_headers_fill_only_missing_names( + #[case] forwarded: &[(&str, &str)], + #[case] defaults: &[(&str, &str)], + #[case] expected: &[(&str, &str)], + ) { + let owned = |headers: &[(&str, &str)]| -> Vec<(String, String)> { + headers + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) + .collect() + }; + assert_eq!( + with_default_headers(owned(forwarded), defaults), + owned(expected) + ); + } + + #[rstest] + fn sanitize_request_shapes_messages_metadata_and_thinking(shaping: MessagesShaping) { + let sanitized = sanitize_request( + request(json!({ + "model": "m", + "messages": [{"role": "assistant", "content": [ + {"type": "text", "text": ""}, + {"type": "tool_use", "id": "functions.Bash:0", "name": "Bash", "input": {}} + ]}], + "metadata": {"user_id": "u", "trace_id": "t"}, + "thinking": {"type": "enabled", "budget_tokens": 1024}, + "safeguards": [{"type": "dangerous_tool_use"}] + })), + &MessagesShaping { + reasoning_auto_summary: true, + ..shaping + }, + ) + .unwrap(); + assert_eq!( + serde_json::to_value(sanitized).unwrap(), + json!({ + "model": "m", + "messages": [{"role": "assistant", "content": [ + {"type": "tool_use", "id": "functions_Bash_0", "name": "Bash", "input": {}} + ]}], + "metadata": {"user_id": "u"}, + "thinking": {"type": "enabled", "budget_tokens": 1024, "display": "summarized"}, + "safeguards": [{"type": "dangerous_tool_use"}] + }) + ); + } + + #[rstest] + #[case::top_level_and_nested_paths( + json!({ + "max_tokens": 1024, + "thinking": {"type": "enabled", "budget_tokens": 2048}, + "context_management": {"edits": [{"type": "clear_thinking_20251015"}]}, + "metadata": {"user_id": "u1"}, + "tools": [{"name": "lookup", "input_schema": {"type": "object"}, "input_examples": [{"q": "x"}]}] + }), + &["thinking", "context_management", "tools[*].input_examples"], + json!({ + "max_tokens": 1024, + "metadata": {"user_id": "u1"}, + "tools": [{"name": "lookup", "input_schema": {"type": "object"}}] + }), + )] + #[case::no_paths( + json!({"max_tokens": 16, "safeguards": [{"type": "dangerous_tool_use"}]}), + &[], + json!({"max_tokens": 16, "safeguards": [{"type": "dangerous_tool_use"}]}), + )] + #[case::model_and_messages_are_never_dropped( + json!({"max_tokens": 16}), + &["model", "messages", "messages[0].content"], + json!({"max_tokens": 16}), + )] + fn prepared_body_drops_configured_paths( + shaping: MessagesShaping, + #[case] fields: Value, + #[case] additional_drop_params: &[&str], + #[case] expected_fields: Value, + ) { + let with_messages = |fields: Value| -> Value { + let Value::Object(fields) = fields else { + unreachable!() + }; + Value::Object( + [ + ("model".to_string(), json!("claude-test")), + ( + "messages".to_string(), + json!([{"role": "user", "content": "hi"}]), + ), + ] + .into_iter() + .chain(fields) + .collect(), + ) + }; + let shaping = MessagesShaping { + additional_drop_params: additional_drop_params + .iter() + .map(ToString::to_string) + .collect(), + ..shaping + }; + assert_eq!( + prepared_body(with_messages(fields), shaping), + Ok(with_messages(expected_fields)) + ); + } + + #[rstest] + fn prepared_body_carries_the_provider_stripped_model(shaping: MessagesShaping) { + assert_eq!( + prepared_body( + json!({ + "model": "anthropic/claude-test", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 16 + }), + shaping, + ), + Ok(json!({ + "model": "claude-test", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 16 + })) + ); + } + + #[rstest] + fn dropped_thinking_display_is_not_restored_by_auto_summary(shaping: MessagesShaping) { + let shaping = MessagesShaping { + reasoning_auto_summary: true, + additional_drop_params: vec!["thinking.display".to_string()], + ..shaping + }; + assert_eq!( + prepared_body( + json!({ + "model": "claude-test", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 4096, + "thinking": {"type": "enabled", "budget_tokens": 2048} + }), + shaping, + ), + Ok(json!({ + "model": "claude-test", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 4096, + "thinking": {"type": "enabled", "budget_tokens": 2048} + })) + ); + } + + #[rstest] + fn dropping_an_invalid_metadata_user_id_does_not_skip_its_validation(shaping: MessagesShaping) { + let shaping = MessagesShaping { + additional_drop_params: vec!["metadata.user_id".to_string()], + ..shaping + }; + assert!(matches!( + prepared_body( + json!({ + "model": "claude-test", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 16, + "metadata": {"user_id": 123} + }), + shaping, + ), + Err(Error::InvalidRequest(_)) + )); + } + + #[rstest] + fn prepared_body_rejects_invalid_metadata_before_the_call(shaping: MessagesShaping) { + assert_eq!( + prepared_body( + json!({ + "model": "claude-test", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 16, + "metadata": {"user_id": 123} + }), + shaping, + ), + Err(Error::InvalidRequest( + "metadata.user_id must be a string, got 123".to_string() + )) + ); + } +} diff --git a/litellm-rust/crates/core/src/messages/types.rs b/litellm-rust/crates/core/src/messages/types.rs index f2c3981253a..f97ba1cf08a 100644 --- a/litellm-rust/crates/core/src/messages/types.rs +++ b/litellm-rust/crates/core/src/messages/types.rs @@ -7,19 +7,14 @@ use litellm_llms::{ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -/// What the host knows about the deployment that the route cannot read off the body: the -/// model's cost-map flags and the caller's LiteLLM-level request settings. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct MessagesShaping { #[serde(default)] pub capabilities: AnthropicModelCapabilities, - /// `litellm.drop_params` or the per-request `drop_params`. #[serde(default)] pub drop_params: bool, - /// `litellm.reasoning_auto_summary` or `LITELLM_REASONING_AUTO_SUMMARY`. #[serde(default)] pub reasoning_auto_summary: bool, - /// The deployment's `additional_drop_params`: dot paths removed from the request body. #[serde(default)] pub additional_drop_params: Vec, } @@ -44,3 +39,86 @@ pub struct ProviderMessagesRequest { pub upstream_headers: Vec<(String, String)>, pub timeout: Option, } + +#[cfg(test)] +mod tests { + use litellm_llms::anthropic::common_utils::SupportedEffortTiers; + use rstest::rstest; + use serde_json::json; + + use super::*; + + #[rstest] + #[case::nothing_projected(json!({}), MessagesShaping::default())] + #[case::only_drop_params( + json!({"drop_params": true}), + MessagesShaping { drop_params: true, ..MessagesShaping::default() }, + )] + #[case::only_reasoning_auto_summary( + json!({"reasoning_auto_summary": true}), + MessagesShaping { reasoning_auto_summary: true, ..MessagesShaping::default() }, + )] + #[case::only_additional_drop_params( + json!({"additional_drop_params": ["tools[*].input_examples"]}), + MessagesShaping { + additional_drop_params: vec!["tools[*].input_examples".to_string()], + ..MessagesShaping::default() + }, + )] + #[case::partial_capabilities( + json!({"capabilities": {"supports_reasoning": true}}), + MessagesShaping { + capabilities: AnthropicModelCapabilities { + supports_reasoning: true, + ..AnthropicModelCapabilities::default() + }, + ..MessagesShaping::default() + }, + )] + #[case::everything_the_python_host_projects( + json!({ + "capabilities": { + "supports_reasoning": true, + "supports_adaptive_thinking": true, + "thinking_always_on": false, + "supports_legacy_thinking": false, + "supports_output_config": true, + "supports_sampling_params": false, + "supports_speed": true, + "effort_tiers": {"minimal": false, "low": true, "medium": true, "high": true, "xhigh": true, "max": false} + }, + "drop_params": true, + "reasoning_auto_summary": true, + "additional_drop_params": ["metadata.user_id", "thinking"] + }), + MessagesShaping { + capabilities: AnthropicModelCapabilities { + supports_reasoning: true, + supports_adaptive_thinking: true, + thinking_always_on: false, + supports_legacy_thinking: false, + supports_output_config: true, + supports_sampling_params: false, + supports_speed: true, + effort_tiers: SupportedEffortTiers { + minimal: false, + low: true, + medium: true, + high: true, + xhigh: true, + max: false, + }, + }, + drop_params: true, + reasoning_auto_summary: true, + additional_drop_params: vec!["metadata.user_id".to_string(), "thinking".to_string()], + }, + )] + fn shaping_deserializes_with_defaults_for_absent_fields( + #[case] projected: Value, + #[case] expected: MessagesShaping, + ) { + let shaping: MessagesShaping = serde_json::from_value(projected).unwrap(); + assert_eq!(shaping, expected); + } +} diff --git a/litellm-rust/crates/llms/src/anthropic/common_utils.rs b/litellm-rust/crates/llms/src/anthropic/common_utils.rs index 75cfc349352..a2234e0df03 100644 --- a/litellm-rust/crates/llms/src/anthropic/common_utils.rs +++ b/litellm-rust/crates/llms/src/anthropic/common_utils.rs @@ -1,7 +1,3 @@ -//! Anthropic knowledge shared by every route that speaks the Messages API: model -//! capability flags, beta header values, and the message sanitizers Python runs in -//! `litellm/llms/anthropic/common_utils.py`. - use litellm_types::llms::anthropic_messages::anthropic_request::{ AnthropicMessage, ContentBlock, MessageContent, }; @@ -19,7 +15,6 @@ pub const ANTHROPIC_TOOL_SEARCH_TOOL_TYPES: [&str; 2] = [ pub const ENCRYPTED_REASONING_SIGNATURE_PREFIX: &str = "litellm_encrypted_reasoning:"; const THOUGHT_SIGNATURE_SEPARATOR: &str = "__thought__"; -/// Known `anthropic-beta` values, as Python's `ANTHROPIC_BETA_HEADER_VALUES` lists them. pub mod beta { pub const CONTEXT_MANAGEMENT_2025_06_27: &str = "context-management-2025-06-27"; pub const COMPACT_2026_01_12: &str = "compact-2026-01-12"; @@ -31,7 +26,6 @@ pub mod beta { pub const PER_TURN_CONTROL_2026_07_01: &str = "per-turn-control-2026-07-01"; } -/// The `output_config.effort` levels, in ascending order. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum EffortLevel { @@ -65,7 +59,6 @@ impl EffortLevel { } } -/// Which `reasoning_effort` tiers the cost map flags as `supports__reasoning_effort`. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SupportedEffortTiers { #[serde(default)] @@ -88,9 +81,6 @@ impl SupportedEffortTiers { } } -/// The cost-map facts about a model that the Messages transformation branches on. The -/// host resolves them the way Python's `AnthropicModelInfo._supports_model_capability` -/// does, under the caller's provider and with the fallback generalizations applied. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AnthropicModelCapabilities { #[serde(default)] @@ -103,10 +93,8 @@ pub struct AnthropicModelCapabilities { pub supports_legacy_thinking: bool, #[serde(default)] pub supports_output_config: bool, - /// Whether `temperature`, `top_p` and `top_k` are accepted. Claude 4.7+ removed them. #[serde(default = "default_true")] pub supports_sampling_params: bool, - /// Whether the model takes `speed` (fast mode), a direct Anthropic API feature. #[serde(default)] pub supports_speed: bool, #[serde(default)] @@ -118,7 +106,6 @@ fn default_true() -> bool { } impl Default for AnthropicModelCapabilities { - /// An unmapped model: no reasoning features, sampling params still accepted. fn default() -> Self { Self { supports_reasoning: false, @@ -144,14 +131,10 @@ impl AnthropicModelCapabilities { } } - /// Whether the model accepts `output_config.effort` at all, as Python's - /// `_model_supports_effort_param` decides it. pub fn supports_effort_param(&self) -> bool { self.supports_output_config || self.effort_tiers.any() } - /// Python's `_validate_effort_for_model`: `None` when the level is allowed, else the - /// 400 message. pub fn effort_level_rejection(&self, effort: &str, model: &str) -> Option { match effort { "max" if !(self.supports_adaptive_thinking || self.effort_tiers.max) => Some(format!( @@ -172,12 +155,6 @@ pub fn is_anthropic_oauth_key(value: &str) -> bool { .starts_with(ANTHROPIC_OAUTH_TOKEN_PREFIX) } -/// Merge one more value into a comma separated `anthropic-beta` header, sorted and -/// deduplicated like Python's `_merge_beta_headers`. -pub fn merge_beta_headers(existing: Option<&str>, new_beta: &str) -> String { - join_beta_values(split_beta_values(existing).chain(std::iter::once(new_beta.to_string()))) -} - pub fn split_beta_values(header: Option<&str>) -> impl Iterator + '_ { header .into_iter() @@ -209,8 +186,6 @@ pub fn has_advisor_tool(tools: Option<&[Value]>) -> bool { .any(|tool| tool.get("type").and_then(Value::as_str) == Some(ANTHROPIC_ADVISOR_TOOL_TYPE)) } -/// Whether the request already carries native compaction: a `compaction` param or a -/// signed `compaction` block in history. pub fn requires_native_compaction_beta( compaction: Option<&Value>, messages: &[AnthropicMessage], @@ -233,8 +208,6 @@ fn is_empty_text_block(block: &ContentBlock) -> bool { block.is_type("text") && is_blank(block.text.as_deref()) } -/// A `thinking` block with no thinking text. Anthropic rejects it regardless of any -/// signature; `redacted_thinking` blocks are a different type and never match. pub fn is_empty_thinking_block(block: &ContentBlock) -> bool { block.is_type("thinking") && is_blank(block.thinking.as_deref()) } @@ -259,15 +232,12 @@ fn retain_blocks( .collect() } -/// Drop empty `text` and `thinking` blocks. A message whose block list empties out is -/// dropped with them, since Anthropic rejects an empty content array. pub fn strip_empty_content_blocks(messages: Vec) -> Vec { retain_blocks(messages, |block| { !is_empty_text_block(block) && !is_empty_thinking_block(block) }) } -/// Rewrite a `tool_use` / `tool_result` id into Anthropic's `^[a-zA-Z0-9_-]+$` alphabet. pub fn normalize_anthropic_tool_use_id(raw_id: &str) -> String { let base = raw_id .split_once(THOUGHT_SIGNATURE_SEPARATOR) @@ -331,15 +301,12 @@ fn map_blocks( .collect() } -/// Rewrite tool ids replayed from another provider (`functions.Bash:0`) into ids Anthropic -/// accepts. pub fn sanitize_tool_use_ids(messages: Vec) -> Vec { map_blocks(messages, |blocks| { blocks.into_iter().map(sanitize_tool_use_id_block).collect() }) } -/// Drop `provider_specific_fields` from every block; it is LiteLLM's own annotation. pub fn strip_provider_specific_fields(messages: Vec) -> Vec { map_blocks(messages, |blocks| { blocks @@ -352,8 +319,6 @@ pub fn strip_provider_specific_fields(messages: Vec) -> Vec bool { let field = match block.block_type.as_deref() { Some("thinking") => block.signature.as_deref(), @@ -363,8 +328,6 @@ pub fn is_encrypted_reasoning_block(block: &ContentBlock) -> bool { field.is_some_and(|value| value.starts_with(ENCRYPTED_REASONING_SIGNATURE_PREFIX)) } -/// Drop the bridge-tagged reasoning blocks Anthropic cannot verify; its own signed -/// blocks stay. pub fn strip_encrypted_reasoning_blocks(messages: Vec) -> Vec { retain_blocks(messages, |block| !is_encrypted_reasoning_block(block)) } @@ -375,8 +338,6 @@ fn is_advisor_use(block: &ContentBlock) -> bool { && block.id.as_deref().is_some_and(|id| !id.is_empty()) } -/// Drop `server_tool_use(name=advisor)` and `advisor_tool_result` blocks from assistant -/// turns. Anthropic rejects advisor history when the advisor tool is not in `tools`. pub fn strip_advisor_blocks(messages: Vec) -> Vec { messages .into_iter() @@ -439,13 +400,12 @@ enum WebSearchResults { Error(String), } -/// The results of a replayed `web_search_tool_result` block that carries no -/// `encrypted_content`, else `None` for anything Anthropic itself issued. -fn flattenable_web_search_results(block: &ContentBlock) -> Option { - if !block.is_type("web_search_tool_result") || block.tool_use_id.is_none() { +fn flattenable_web_search_results(block: &ContentBlock) -> Option<(&str, WebSearchResults)> { + if !block.is_type("web_search_tool_result") { return None; } - match block.content.as_ref()? { + let tool_use_id = block.tool_use_id.as_deref()?; + let results = match block.content.as_ref()? { Value::Array(items) => { let results = items .iter() @@ -463,15 +423,14 @@ fn flattenable_web_search_results(block: &ContentBlock) -> Option match serde_json::from_value(error.clone()).ok()? { - ReplayedWebSearchContent::Error { error_code } => { - Some(WebSearchResults::Error(error_code)) - } + ReplayedWebSearchContent::Error { error_code } => WebSearchResults::Error(error_code), }, - _ => None, - } + _ => return None, + }; + Some((tool_use_id, results)) } fn render_web_search_results(query: &str, results: &WebSearchResults) -> String { @@ -503,7 +462,6 @@ fn render_web_search_results(query: &str, results: &WebSearchResults) -> String .collect::>() .join("\n") }) - .filter(|entry| !entry.is_empty()) .collect::>() .join("\n\n"); if body.is_empty() { @@ -520,57 +478,49 @@ fn server_tool_use_query(block: &ContentBlock) -> Option<(&str, &str)> { return None; } let id = block.id.as_deref()?; - let query = block - .input - .as_ref() - .and_then(|input| input.get("query")) - .and_then(Value::as_str) - .unwrap_or(""); + let query = match block.input.as_ref() { + None => "", + Some(Value::Object(input)) => match input.get("query") { + None => "", + Some(query) => query.as_str()?, + }, + Some(_) => return None, + }; Some((id, query)) } fn flatten_web_search_results_in_blocks(blocks: Vec) -> Vec { - let flattenable: Vec<(&str, WebSearchResults)> = blocks + let flattenable_ids: Vec<&str> = blocks .iter() - .filter_map(|block| { - let results = flattenable_web_search_results(block)?; - Some((block.tool_use_id.as_deref()?, results)) - }) + .filter_map(flattenable_web_search_results) + .map(|(tool_use_id, _)| tool_use_id) .collect(); - if flattenable.is_empty() { + if flattenable_ids.is_empty() { return blocks; } let queries: Vec<(&str, &str)> = blocks.iter().filter_map(server_tool_use_query).collect(); - let rewritten: Vec = blocks + blocks .iter() .filter_map(|block| { - if let Some((tool_use_id, results)) = block - .tool_use_id - .as_deref() - .and_then(|id| flattenable.iter().find(|(flat_id, _)| *flat_id == id)) - .filter(|_| block.is_type("web_search_tool_result")) - { + if let Some((tool_use_id, results)) = flattenable_web_search_results(block) { let query = queries .iter() - .find(|(id, _)| id == tool_use_id) + .rfind(|(id, _)| *id == tool_use_id) .map_or("", |(_, query)| query); return Some(ContentBlock::text(render_web_search_results( - query, results, + query, &results, ))); } if let Some((id, _)) = server_tool_use_query(block) - && flattenable.iter().any(|(flat_id, _)| *flat_id == id) + && flattenable_ids.contains(&id) { return None; } Some(block.clone()) }) - .collect(); - rewritten + .collect() } -/// Rewrite replayed `web_search_tool_result` blocks that carry no `encrypted_content` -/// (LiteLLM synthesized them) into plain text so Anthropic does not reject the history. pub fn flatten_unencrypted_web_search_results( messages: Vec, ) -> Vec { @@ -579,249 +529,1040 @@ pub fn flatten_unencrypted_web_search_results( #[cfg(test)] mod tests { - use rstest::rstest; + use rstest::{fixture, rstest}; use serde_json::json; use super::*; - fn message(role: &str, content: Value) -> AnthropicMessage { - serde_json::from_value(json!({"role": role, "content": content})).unwrap() + const ALL_LEVELS: [EffortLevel; 5] = [ + EffortLevel::Low, + EffortLevel::Medium, + EffortLevel::High, + EffortLevel::Xhigh, + EffortLevel::Max, + ]; + + fn apply( + sanitizer: fn(Vec) -> Vec, + messages: Value, + ) -> Value { + let parsed: Vec = serde_json::from_value(messages).unwrap(); + serde_json::to_value(sanitizer(parsed)).unwrap() } - fn wire(messages: &[AnthropicMessage]) -> Value { - serde_json::to_value(messages).unwrap() + fn block(value: Value) -> ContentBlock { + serde_json::from_value(value).unwrap() } - #[test] - fn strip_empty_content_blocks_drops_blank_text_and_thinking_and_empty_messages() { - let messages = vec![ - message( - "assistant", - json!([ - {"type": "text", "text": " "}, - {"type": "thinking", "thinking": "", "signature": "sig"}, - {"type": "redacted_thinking", "data": "opaque"}, - {"type": "tool_use", "id": "t1", "name": "f", "input": {}} - ]), - ), - message("user", json!([{"type": "text", "text": ""}])), - message("user", json!("plain")), - ]; - assert_eq!( - wire(&strip_empty_content_blocks(messages)), - json!([ - {"role": "assistant", "content": [ - {"type": "redacted_thinking", "data": "opaque"}, - {"type": "tool_use", "id": "t1", "name": "f", "input": {}} - ]}, - {"role": "user", "content": "plain"} - ]) - ); + fn history(messages: Value) -> Vec { + serde_json::from_value(messages).unwrap() + } + + fn tools(value: Option) -> Option> { + value.map(|tools| tools.as_array().unwrap().clone()) + } + + fn tagged(encrypted: &str) -> String { + format!("{ENCRYPTED_REASONING_SIGNATURE_PREFIX}{encrypted}") + } + + fn tiers( + minimal: bool, + low: bool, + medium: bool, + high: bool, + xhigh: bool, + max: bool, + ) -> SupportedEffortTiers { + SupportedEffortTiers { + minimal, + low, + medium, + high, + xhigh, + max, + } + } + + fn replayed_search_turn(results: Value) -> Value { + json!([ + {"role": "user", "content": "when was Rome founded?"}, + {"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {"query": "when"}}, + {"type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": results}, + {"type": "text", "text": "753 BC."} + ]} + ]) + } + + #[fixture] + fn unmapped() -> AnthropicModelCapabilities { + AnthropicModelCapabilities::default() } #[rstest] - #[case("functions.Bash:0", "functions_Bash_0")] - #[case("call_1__thought__abc", "call_1")] - #[case("toolu_01", "toolu_01")] - #[case("::", "__")] - #[case("", "tool_use_id")] - fn normalize_tool_use_id_matches_python(#[case] raw: &str, #[case] expected: &str) { + #[case::empty_text(json!({"type": "thinking", "thinking": ""}), true)] + #[case::whitespace_only(json!({"type": "thinking", "thinking": " \n\t "}), true)] + #[case::null_text(json!({"type": "thinking", "thinking": null}), true)] + #[case::missing_text(json!({"type": "thinking"}), true)] + #[case::empty_text_despite_signature(json!({"type": "thinking", "thinking": "", "signature": "sig_abc"}), true)] + #[case::real_thinking(json!({"type": "thinking", "thinking": "plan", "signature": "sig"}), false)] + #[case::padded_real_thinking(json!({"type": "thinking", "thinking": " plan "}), false)] + #[case::redacted_thinking_is_a_different_type(json!({"type": "redacted_thinking", "data": "opaque"}), false)] + #[case::empty_text_block(json!({"type": "text", "text": ""}), false)] + #[case::untyped_block(json!({"thinking": ""}), false)] + fn empty_thinking_block_detection(#[case] input: Value, #[case] expected: bool) { + assert_eq!(is_empty_thinking_block(&block(input)), expected); + } + + #[rstest] + #[case::empty_text_beside_tool_use( + json!([{"role": "assistant", "content": [ + {"type": "text", "text": ""}, + {"type": "tool_use", "id": "x", "name": "Bash", "input": {}} + ]}]), + json!([{"role": "assistant", "content": [{"type": "tool_use", "id": "x", "name": "Bash", "input": {}}]}]) + )] + #[case::whitespace_text_beside_tool_use( + json!([{"role": "assistant", "content": [ + {"type": "text", "text": " \n "}, + {"type": "tool_use", "id": "x", "name": "Bash", "input": {}} + ]}]), + json!([{"role": "assistant", "content": [{"type": "tool_use", "id": "x", "name": "Bash", "input": {}}]}]) + )] + #[case::null_text( + json!([{"role": "user", "content": [ + {"type": "text", "text": null}, + {"type": "tool_result", "tool_use_id": "x", "content": "y"} + ]}]), + json!([{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "x", "content": "y"}]}]) + )] + #[case::missing_text( + json!([{"role": "user", "content": [ + {"type": "text"}, + {"type": "tool_result", "tool_use_id": "x", "content": "y"} + ]}]), + json!([{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "x", "content": "y"}]}]) + )] + #[case::empty_signed_thinking_beside_tool_use( + json!([ + {"role": "user", "content": "weather?"}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "", "signature": "sig_abc"}, + {"type": "tool_use", "id": "toolu_01A", "name": "get_weather", "input": {"city": "Paris"}} + ]} + ]), + json!([ + {"role": "user", "content": "weather?"}, + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "toolu_01A", "name": "get_weather", "input": {"city": "Paris"}} + ]} + ]) + )] + #[case::whitespace_thinking_beside_real_and_redacted_thinking( + json!([{"role": "assistant", "content": [ + {"type": "thinking", "thinking": " \n "}, + {"type": "thinking", "thinking": "real plan", "signature": "sig"}, + {"type": "redacted_thinking", "data": "opaque"} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "thinking", "thinking": "real plan", "signature": "sig"}, + {"type": "redacted_thinking", "data": "opaque"} + ]}]) + )] + #[case::blank_text_beside_real_thinking( + json!([{"role": "assistant", "content": [ + {"type": "thinking", "thinking": "plan", "signature": "sig"}, + {"type": "text", "text": ""} + ]}]), + json!([{"role": "assistant", "content": [{"type": "thinking", "thinking": "plan", "signature": "sig"}]}]) + )] + #[case::message_left_without_blocks_is_dropped( + json!([ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [{"type": "text", "text": ""}]}, + {"role": "assistant", "content": [{"type": "thinking", "thinking": ""}]} + ]), + json!([{"role": "user", "content": "hello"}]) + )] + fn strip_empty_content_blocks_rewrites(#[case] input: Value, #[case] expected: Value) { + assert_eq!(apply(strip_empty_content_blocks, input), expected); + } + + #[rstest] + #[case::non_empty_text(json!([{"role": "assistant", "content": [{"type": "text", "text": "hi"}]}]))] + #[case::padded_text(json!([{"role": "assistant", "content": [{"type": "text", "text": " hi "}]}]))] + #[case::empty_string_content(json!([{"role": "user", "content": ""}]))] + #[case::textless_non_text_block(json!([{"role": "user", "content": [ + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AA=="}} + ]}]))] + #[case::encrypted_reasoning_left_for_the_responses_bridge(json!([{"role": "assistant", "content": [ + {"type": "thinking", "thinking": "plan", "signature": tagged("gAAAA_1")}, + {"type": "redacted_thinking", "data": tagged("gAAAA_2")}, + {"type": "text", "text": "The answer."} + ]}]))] + fn strip_empty_content_blocks_leaves_untouched(#[case] input: Value) { + assert_eq!(apply(strip_empty_content_blocks, input.clone()), input); + } + + #[rstest] + #[case::replayed_provider_id("functions.Bash:0", "functions_Bash_0")] + #[case::thought_signature_suffix("call_abc123__thought__CiIBDDnWx+/a==", "call_abc123")] + #[case::splits_at_first_thought_separator("call_1__thought__a__thought__b", "call_1")] + #[case::valid_id("toolu_01-A_b", "toolu_01-A_b")] + #[case::non_ascii_letter("café", "caf_")] + #[case::only_invalid_characters("::", "__")] + #[case::empty("", "tool_use_id")] + #[case::thought_signature_only("__thought__CiIB", "tool_use_id")] + fn normalize_anthropic_tool_use_id_cases(#[case] raw: &str, #[case] expected: &str) { assert_eq!(normalize_anthropic_tool_use_id(raw), expected); } - #[test] - fn sanitize_tool_use_ids_rewrites_use_and_result_ids_only() { - let messages = vec![ - message( - "assistant", - json!([{"type": "tool_use", "id": "functions.Bash:0", "name": "Bash", "input": {}}]), - ), - message( - "user", - json!([{"type": "tool_result", "tool_use_id": "functions.Bash:0", "content": "ok"}]), - ), - message( - "user", - json!([{"type": "text", "text": "id: functions.Bash:0"}]), - ), - ]; - let sanitized = wire(&sanitize_tool_use_ids(messages)); - assert_eq!(sanitized[0]["content"][0]["id"], "functions_Bash_0"); - assert_eq!( - sanitized[1]["content"][0]["tool_use_id"], - "functions_Bash_0" - ); - assert_eq!(sanitized[2]["content"][0]["text"], "id: functions.Bash:0"); - } - - #[test] - fn strip_provider_specific_fields_leaves_other_keys() { - let messages = vec![message( - "assistant", - json!([{"type": "thinking", "thinking": "hm", "signature": "s", "provider_specific_fields": {"a": 1}}]), - )]; - assert_eq!( - wire(&strip_provider_specific_fields(messages)), - json!([{"role": "assistant", "content": [{"type": "thinking", "thinking": "hm", "signature": "s"}]}]) - ); - } - - #[test] - fn strip_encrypted_reasoning_blocks_keeps_anthropic_signed_blocks() { - let messages = vec![ - message( - "assistant", - json!([ - {"type": "thinking", "thinking": "a", "signature": "litellm_encrypted_reasoning:xyz"}, - {"type": "redacted_thinking", "data": "litellm_encrypted_reasoning:xyz"}, - {"type": "thinking", "thinking": "b", "signature": "anthropic-sig"}, - ]), - ), - message( - "assistant", - json!([{"type": "thinking", "thinking": "a", "signature": "litellm_encrypted_reasoning:xyz"}]), - ), - ]; - assert_eq!( - wire(&strip_encrypted_reasoning_blocks(messages)), - json!([{"role": "assistant", "content": [{"type": "thinking", "thinking": "b", "signature": "anthropic-sig"}]}]) - ); - } - - #[test] - fn strip_advisor_blocks_removes_matched_pairs_from_assistant_turns_only() { - let messages = vec![ - message( - "assistant", - json!([ - {"type": "server_tool_use", "id": "adv_1", "name": "advisor", "input": {}}, - {"type": "advisor_tool_result", "tool_use_id": "adv_1", "content": "advice"}, - {"type": "advisor_tool_result", "tool_use_id": "other", "content": "kept"}, - {"type": "text", "text": "answer"} - ]), - ), - message( - "user", - json!([{"type": "server_tool_use", "id": "adv_2", "name": "advisor", "input": {}}]), - ), - ]; - let stripped = wire(&strip_advisor_blocks(messages)); - assert_eq!( - stripped[0]["content"], - json!([ - {"type": "advisor_tool_result", "tool_use_id": "other", "content": "kept"}, - {"type": "text", "text": "answer"} - ]) - ); - assert_eq!(stripped[1]["content"].as_array().unwrap().len(), 1); - } - - #[test] - fn flatten_web_search_results_rewrites_unencrypted_results_and_drops_their_server_tool_use() { - let messages = vec![message( - "assistant", - json!([ - {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": {"query": "rust"}}, - {"type": "web_search_tool_result", "tool_use_id": "s1", "content": [ - {"type": "web_search_result", "url": "https://r", "title": "Rust", "snippet": "fast"}, - {"type": "web_search_result", "url": "https://q", "title": "", "snippet": ""} - ]}, - {"type": "server_tool_use", "id": "s2", "name": "web_search", "input": {"query": "real"}}, - {"type": "web_search_tool_result", "tool_use_id": "s2", "content": [ - {"type": "web_search_result", "url": "https://a", "title": "A", "snippet": "b", "encrypted_content": "enc"} - ]}, - {"type": "text", "text": "done"} - ]), - )]; - assert_eq!( - wire(&flatten_unencrypted_web_search_results(messages))[0]["content"], - json!([ - {"type": "text", "text": "Web search results for 'rust':\n\nTitle: Rust\nURL: https://r\nSnippet: fast\n\nURL: https://q"}, - {"type": "server_tool_use", "id": "s2", "name": "web_search", "input": {"query": "real"}}, - {"type": "web_search_tool_result", "tool_use_id": "s2", "content": [ - {"type": "web_search_result", "url": "https://a", "title": "A", "snippet": "b", "encrypted_content": "enc"} - ]}, - {"type": "text", "text": "done"} - ]) - ); - } - - #[test] - fn flatten_web_search_results_renders_errors_and_empty_results() { - let messages = vec![message( - "assistant", - json!([ - {"type": "web_search_tool_result", "tool_use_id": "e1", "content": {"type": "web_search_tool_result_error", "error_code": "max_uses"}}, - {"type": "web_search_tool_result", "tool_use_id": "e2", "content": []} - ]), - )]; - assert_eq!( - wire(&flatten_unencrypted_web_search_results(messages))[0]["content"], - json!([ - {"type": "text", "text": "Web search results:\n\nSearch failed: max_uses"}, - {"type": "text", "text": "Web search results:\n\nNo results were returned."} - ]) - ); - } - - #[test] - fn beta_header_merging_sorts_and_dedupes() { - assert_eq!(merge_beta_headers(None, "b"), "b"); - assert_eq!(merge_beta_headers(Some("b, a ,b"), "c"), "a,b,c"); + #[rstest] + #[case::tool_use_and_its_result( + json!([ + {"role": "assistant", "content": [{"type": "tool_use", "id": "functions.Bash:0", "name": "Bash", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "functions.Bash:0", "content": "ok"}]} + ]), + json!([ + {"role": "assistant", "content": [{"type": "tool_use", "id": "functions_Bash_0", "name": "Bash", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "functions_Bash_0", "content": "ok"}]} + ]) + )] + #[case::server_tool_use( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "srv.1", "name": "web_search", "input": {}} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "srv_1", "name": "web_search", "input": {}} + ]}]) + )] + #[case::tool_use_rewrites_only_its_id( + json!([{"role": "assistant", "content": [ + {"type": "tool_use", "id": "a.b", "tool_use_id": "c.d", "name": "Bash", "input": {}} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "tool_use", "id": "a_b", "tool_use_id": "c.d", "name": "Bash", "input": {}} + ]}]) + )] + #[case::tool_result_rewrites_only_its_tool_use_id( + json!([{"role": "user", "content": [ + {"type": "tool_result", "id": "a.b", "tool_use_id": "c.d", "content": "ok"} + ]}]), + json!([{"role": "user", "content": [ + {"type": "tool_result", "id": "a.b", "tool_use_id": "c_d", "content": "ok"} + ]}]) + )] + fn sanitize_tool_use_ids_rewrites(#[case] input: Value, #[case] expected: Value) { + assert_eq!(apply(sanitize_tool_use_ids, input), expected); } #[rstest] - #[case("sk-ant-oat01-x", true)] - #[case("Bearer sk-ant-oat01-x", true)] - #[case("sk-ant-api03-x", false)] - fn oauth_key_detection(#[case] value: &str, #[case] expected: bool) { + #[case::valid_ids(json!([ + {"role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01", "name": "Bash", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "ok"}]} + ]))] + #[case::id_mentioned_in_text(json!([{"role": "user", "content": [{"type": "text", "text": "id: functions.Bash:0"}]}]))] + #[case::tool_use_without_id(json!([{"role": "assistant", "content": [{"type": "tool_use", "name": "Bash", "input": {}}]}]))] + #[case::tool_result_without_tool_use_id(json!([{"role": "user", "content": [{"type": "tool_result", "content": "ok"}]}]))] + #[case::string_content(json!([{"role": "user", "content": "functions.Bash:0"}]))] + fn sanitize_tool_use_ids_leaves_untouched(#[case] input: Value) { + assert_eq!(apply(sanitize_tool_use_ids, input.clone()), input); + } + + #[rstest] + #[case::thinking_block( + json!([{"role": "assistant", "content": [ + {"type": "thinking", "thinking": "hm", "signature": "s", "provider_specific_fields": {"a": 1}} + ]}]), + json!([{"role": "assistant", "content": [{"type": "thinking", "thinking": "hm", "signature": "s"}]}]) + )] + #[case::every_block_of_every_message( + json!([ + {"role": "assistant", "content": [ + {"type": "text", "text": "a", "provider_specific_fields": {"x": 1}}, + {"type": "tool_use", "id": "t1", "name": "f", "input": {}, "provider_specific_fields": {"y": 2}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "ok", "provider_specific_fields": {}} + ]} + ]), + json!([ + {"role": "assistant", "content": [ + {"type": "text", "text": "a"}, + {"type": "tool_use", "id": "t1", "name": "f", "input": {}} + ]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}]} + ]) + )] + fn strip_provider_specific_fields_rewrites(#[case] input: Value, #[case] expected: Value) { + assert_eq!(apply(strip_provider_specific_fields, input), expected); + } + + #[rstest] + #[case::string_content(json!([{"role": "user", "content": "provider_specific_fields"}]))] + #[case::blocks_without_the_field(json!([{"role": "assistant", "content": [{"type": "text", "text": "a"}]}]))] + fn strip_provider_specific_fields_leaves_untouched(#[case] input: Value) { + assert_eq!(apply(strip_provider_specific_fields, input.clone()), input); + } + + #[rstest] + #[case::tagged_thinking_signature(json!({"type": "thinking", "thinking": "x", "signature": tagged("g")}), true)] + #[case::tagged_redacted_data(json!({"type": "redacted_thinking", "data": tagged("g")}), true)] + #[case::bare_tag_signature(json!({"type": "thinking", "thinking": "x", "signature": tagged("")}), true)] + #[case::bare_tag_data(json!({"type": "redacted_thinking", "data": tagged("")}), true)] + #[case::anthropic_signature(json!({"type": "thinking", "thinking": "x", "signature": "ErcBCkgIValid"}), false)] + #[case::anthropic_data(json!({"type": "redacted_thinking", "data": "EmwKAhgBEgy"}), false)] + #[case::unsigned_thinking(json!({"type": "thinking", "thinking": "x"}), false)] + #[case::tag_in_text_block(json!({"type": "text", "text": tagged("g")}), false)] + #[case::tag_in_thinking_data(json!({"type": "thinking", "thinking": "x", "data": tagged("g")}), false)] + #[case::tag_in_redacted_signature( + json!({"type": "redacted_thinking", "data": "EmwKAhgBEgy", "signature": tagged("g")}), + false + )] + #[case::tag_not_at_start(json!({"type": "thinking", "thinking": "x", "signature": format!("x{}", tagged("g"))}), false)] + fn encrypted_reasoning_block_detection(#[case] input: Value, #[case] expected: bool) { + assert_eq!(is_encrypted_reasoning_block(&block(input)), expected); + } + + #[rstest] + #[case::only_the_bridge_tagged_blocks( + json!([ + {"role": "user", "content": "Solve it."}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "plan", "signature": tagged("gAAAA_1")}, + {"type": "redacted_thinking", "data": tagged("gAAAA_2")} + ]}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "plan", "signature": tagged("gAAAA_3")}, + {"type": "thinking", "thinking": "native", "signature": "EqQBCkYIAxgCIkA_anthropic_signed"}, + {"type": "redacted_thinking", "data": "EmwKAhgBEgy_anthropic_minted"}, + {"type": "text", "text": "The answer."} + ]} + ]), + json!([ + {"role": "user", "content": "Solve it."}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "native", "signature": "EqQBCkYIAxgCIkA_anthropic_signed"}, + {"type": "redacted_thinking", "data": "EmwKAhgBEgy_anthropic_minted"}, + {"type": "text", "text": "The answer."} + ]} + ]) + )] + #[case::bridge_turn_keeps_its_text( + json!([ + {"role": "user", "content": "Solve it."}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "plan", "signature": tagged("gAAAA_1")}, + {"type": "redacted_thinking", "data": tagged("gAAAA_2")}, + {"type": "text", "text": "The answer."} + ]}, + {"role": "user", "content": "And the next one?"} + ]), + json!([ + {"role": "user", "content": "Solve it."}, + {"role": "assistant", "content": [{"type": "text", "text": "The answer."}]}, + {"role": "user", "content": "And the next one?"} + ]) + )] + fn strip_encrypted_reasoning_blocks_rewrites(#[case] input: Value, #[case] expected: Value) { + assert_eq!(apply(strip_encrypted_reasoning_blocks, input), expected); + } + + #[rstest] + #[case::anthropic_signed_blocks(json!([ + {"role": "user", "content": "Solve it."}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "plan", "signature": "EqQBCkYIAxgCIkA_anthropic_signed"}, + {"type": "redacted_thinking", "data": "EmwKAhgBEgy_anthropic_minted"}, + {"type": "text", "text": "The answer."} + ]} + ]))] + #[case::string_content(json!([{"role": "user", "content": tagged("g")}]))] + fn strip_encrypted_reasoning_blocks_leaves_untouched(#[case] input: Value) { + assert_eq!( + apply(strip_encrypted_reasoning_blocks, input.clone()), + input + ); + } + + #[rstest] + #[case::advisor_exchange_between_texts( + json!([ + {"role": "user", "content": "Build a worker pool."}, + {"role": "assistant", "content": [ + {"type": "text", "text": "Let me consult the advisor."}, + {"type": "server_tool_use", "id": "srvtoolu_abc123", "name": "advisor", "input": {}}, + {"type": "advisor_tool_result", "tool_use_id": "srvtoolu_abc123", + "content": {"type": "advisor_result", "text": "Use channels."}}, + {"type": "text", "text": "Here is the implementation."} + ]} + ]), + json!([ + {"role": "user", "content": "Build a worker pool."}, + {"role": "assistant", "content": [ + {"type": "text", "text": "Let me consult the advisor."}, + {"type": "text", "text": "Here is the implementation."} + ]} + ]) + )] + #[case::only_results_of_this_turns_advisor_calls( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "adv_1", "name": "advisor", "input": {}}, + {"type": "advisor_tool_result", "tool_use_id": "adv_1", "content": "advice"}, + {"type": "advisor_tool_result", "tool_use_id": "other", "content": "kept"}, + {"type": "tool_result", "tool_use_id": "adv_1", "content": "kept"}, + {"type": "text", "text": "answer"} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "advisor_tool_result", "tool_use_id": "other", "content": "kept"}, + {"type": "tool_result", "tool_use_id": "adv_1", "content": "kept"}, + {"type": "text", "text": "answer"} + ]}]) + )] + #[case::advisor_call_without_result( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "adv_1", "name": "advisor", "input": {}}, + {"type": "text", "text": "answer"} + ]}]), + json!([{"role": "assistant", "content": [{"type": "text", "text": "answer"}]}]) + )] + #[case::advisor_only_turn_keeps_an_empty_block_list( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "adv_1", "name": "advisor", "input": {}}, + {"type": "advisor_tool_result", "tool_use_id": "adv_1", "content": "advice"} + ]}]), + json!([{"role": "assistant", "content": []}]) + )] + fn strip_advisor_blocks_rewrites(#[case] input: Value, #[case] expected: Value) { + assert_eq!(apply(strip_advisor_blocks, input), expected); + } + + #[rstest] + #[case::no_advisor_blocks(json!([ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": [ + {"type": "text", "text": "Hi there"}, + {"type": "tool_use", "id": "toolu_abc", "name": "get_weather", "input": {"location": "SF"}} + ]} + ]))] + #[case::user_turn(json!([{"role": "user", "content": [ + {"type": "server_tool_use", "id": "adv_2", "name": "advisor", "input": {}}, + {"type": "advisor_tool_result", "tool_use_id": "adv_2", "content": "advice"} + ]}]))] + #[case::other_server_tool(json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": {}}, + {"type": "advisor_tool_result", "tool_use_id": "s1", "content": "advice"} + ]}]))] + #[case::client_tool_named_advisor(json!([{"role": "assistant", "content": [ + {"type": "tool_use", "id": "t1", "name": "advisor", "input": {}}, + {"type": "advisor_tool_result", "tool_use_id": "t1", "content": "advice"} + ]}]))] + #[case::advisor_call_with_empty_id(json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "", "name": "advisor", "input": {}}, + {"type": "advisor_tool_result", "tool_use_id": "", "content": "advice"} + ]}]))] + #[case::advisor_call_without_id(json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "name": "advisor", "input": {}} + ]}]))] + #[case::string_content(json!([{"role": "assistant", "content": "advisor"}]))] + fn strip_advisor_blocks_leaves_untouched(#[case] input: Value) { + assert_eq!(apply(strip_advisor_blocks, input.clone()), input); + } + + #[rstest] + #[case::results_keep_their_evidence( + json!([ + {"role": "user", "content": "latest version?"}, + {"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {"query": "latest version"}}, + {"type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [ + {"type": "web_search_result", "url": "https://example.com/releases", "title": "Releases", + "page_age": null, "encrypted_content": "", "snippet": "Latest release v1.95.0"} + ]}, + {"type": "text", "text": "v1.95.0"} + ]} + ]), + json!([ + {"role": "user", "content": "latest version?"}, + {"role": "assistant", "content": [ + {"type": "text", "text": "Web search results for 'latest version':\n\nTitle: Releases\nURL: https://example.com/releases\nSnippet: Latest release v1.95.0"}, + {"type": "text", "text": "v1.95.0"} + ]} + ]) + )] + #[case::each_result_lists_only_its_present_fields( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": {"query": "q"}}, + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": [ + {"type": "web_search_result", "title": "A"}, + {"type": "web_search_result", "snippet": "b"}, + {"type": "web_search_result", "url": "https://c"} + ]} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "text", "text": "Web search results for 'q':\n\nTitle: A\n\nSnippet: b\n\nURL: https://c"} + ]}]) + )] + #[case::result_without_fields_renders_the_header_only( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": {"query": "q"}}, + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": [{"type": "web_search_result"}]} + ]}]), + json!([{"role": "assistant", "content": [{"type": "text", "text": "Web search results for 'q':"}]}]) + )] + #[case::resultless_search( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {"query": "who won"}}, + {"type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": []}, + {"type": "text", "text": "I could not find that."} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "text", "text": "Web search results for 'who won':\n\nNo results were returned."}, + {"type": "text", "text": "I could not find that."} + ]}]) + )] + #[case::failed_search( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {"query": "q"}}, + {"type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", + "content": {"type": "web_search_tool_result_error", "error_code": "max_uses_exceeded"}} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "text", "text": "Web search results for 'q':\n\nSearch failed: max_uses_exceeded"} + ]}]) + )] + #[case::failed_search_without_error_code( + json!([{"role": "assistant", "content": [ + {"type": "web_search_tool_result", "tool_use_id": "e1", "content": {"type": "web_search_tool_result_error"}} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "text", "text": "Web search results:\n\nSearch failed: unavailable"} + ]}]) + )] + #[case::server_tool_use_without_query( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "s1", "name": "web_search"}, + {"type": "server_tool_use", "id": "s2", "name": "web_search", "input": {}}, + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": []}, + {"type": "web_search_tool_result", "tool_use_id": "s2", "content": []} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "text", "text": "Web search results:\n\nNo results were returned."}, + {"type": "text", "text": "Web search results:\n\nNo results were returned."} + ]}]) + )] + #[case::genuine_results_in_the_same_turn_stay( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": {"query": "rust"}}, + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": [ + {"type": "web_search_result", "url": "https://r", "title": "Rust", "snippet": "fast"} + ]}, + {"type": "server_tool_use", "id": "s2", "name": "web_search", "input": {"query": "real"}}, + {"type": "web_search_tool_result", "tool_use_id": "s2", "content": [ + {"type": "web_search_result", "url": "https://a", "title": "A", "snippet": "b", "encrypted_content": "enc"} + ]}, + {"type": "text", "text": "done"} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "text", "text": "Web search results for 'rust':\n\nTitle: Rust\nURL: https://r\nSnippet: fast"}, + {"type": "server_tool_use", "id": "s2", "name": "web_search", "input": {"query": "real"}}, + {"type": "web_search_tool_result", "tool_use_id": "s2", "content": [ + {"type": "web_search_result", "url": "https://a", "title": "A", "snippet": "b", "encrypted_content": "enc"} + ]}, + {"type": "text", "text": "done"} + ]}]) + )] + #[case::other_blocks_sharing_the_tool_use_id_stay( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": {"query": "q"}}, + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": []}, + {"type": "tool_result", "tool_use_id": "s1", "content": "x"} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "text", "text": "Web search results for 'q':\n\nNo results were returned."}, + {"type": "tool_result", "tool_use_id": "s1", "content": "x"} + ]}]) + )] + #[case::query_lookup_stays_within_the_message( + json!([ + {"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": {"query": "q"}} + ]}, + {"role": "assistant", "content": [ + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": []} + ]} + ]), + json!([ + {"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": {"query": "q"}} + ]}, + {"role": "assistant", "content": [ + {"type": "text", "text": "Web search results:\n\nNo results were returned."} + ]} + ]) + )] + #[case::result_without_any_field_keeps_its_slot( + json!([{"role": "assistant", "content": [ + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": [ + {"type": "web_search_result", "url": "https://a", "title": "A"}, + {"type": "web_search_result"}, + {"type": "web_search_result", "title": "B"} + ]} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "text", "text": "Web search results:\n\nTitle: A\nURL: https://a\n\n\n\nTitle: B"} + ]}]) + )] + #[case::non_string_query_keeps_its_server_tool_use( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": {"query": 123}}, + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": []} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": {"query": 123}}, + {"type": "text", "text": "Web search results:\n\nNo results were returned."} + ]}]) + )] + #[case::non_object_input_keeps_its_server_tool_use( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": "q"}, + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": []} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": "q"}, + {"type": "text", "text": "Web search results:\n\nNo results were returned."} + ]}]) + )] + #[case::repeated_tool_use_id_renders_each_block_from_its_own_results( + json!([{"role": "assistant", "content": [ + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": []}, + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": {"type": "web_search_tool_result_error", "error_code": "max_uses"}} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "text", "text": "Web search results:\n\nNo results were returned."}, + {"type": "text", "text": "Web search results:\n\nSearch failed: max_uses"} + ]}]) + )] + #[case::encrypted_block_sharing_a_replayed_id_stays( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": {"query": "q"}}, + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": []}, + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": [ + {"type": "web_search_result", "url": "https://a", "encrypted_content": "enc"} + ]} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "text", "text": "Web search results for 'q':\n\nNo results were returned."}, + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": [ + {"type": "web_search_result", "url": "https://a", "encrypted_content": "enc"} + ]} + ]}]) + )] + #[case::last_query_wins_for_a_repeated_server_tool_use_id( + json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": {"query": "first"}}, + {"type": "server_tool_use", "id": "s1", "name": "web_search", "input": {"query": "second"}}, + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": []} + ]}]), + json!([{"role": "assistant", "content": [ + {"type": "text", "text": "Web search results for 'second':\n\nNo results were returned."} + ]}]) + )] + fn flatten_unencrypted_web_search_results_rewrites( + #[case] input: Value, + #[case] expected: Value, + ) { + assert_eq!( + apply(flatten_unencrypted_web_search_results, input), + expected + ); + } + + #[rstest] + #[case::anthropic_issued_results(json!([{"role": "assistant", "content": [ + {"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {"query": "q"}}, + {"type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [ + {"type": "web_search_result", "url": "https://example.com", "title": "Example", + "page_age": null, "encrypted_content": "EqgfCioIARgBIiQ4"} + ]} + ]}]))] + #[case::any_encrypted_result_marks_the_block_genuine(json!([{"role": "assistant", "content": [ + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": [ + {"type": "web_search_result", "url": "https://a", "encrypted_content": ""}, + {"type": "web_search_result", "url": "https://b", "encrypted_content": "enc"} + ]} + ]}]))] + #[case::result_without_tool_use_id(json!([{"role": "assistant", "content": [ + {"type": "web_search_tool_result", "content": []} + ]}]))] + #[case::foreign_item_in_results(json!([{"role": "assistant", "content": [ + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": [ + {"type": "web_search_result", "url": "https://a"}, + {"type": "text", "text": "x"} + ]} + ]}]))] + #[case::result_with_null_url(json!([{"role": "assistant", "content": [ + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": [ + {"type": "web_search_result", "url": null, "title": "A"} + ]} + ]}]))] + #[case::string_result_content(json!([{"role": "assistant", "content": [ + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": "oops"} + ]}]))] + #[case::object_content_that_is_not_an_error(json!([{"role": "assistant", "content": [ + {"type": "web_search_tool_result", "tool_use_id": "s1", "content": {"type": "web_search_result", "url": "https://a"}} + ]}]))] + #[case::string_content(json!([{"role": "assistant", "content": "web_search_tool_result"}]))] + fn flatten_unencrypted_web_search_results_leaves_untouched(#[case] input: Value) { + assert_eq!( + apply(flatten_unencrypted_web_search_results, input.clone()), + input + ); + } + + #[rstest] + #[case::with_results(json!([{"type": "web_search_result", "url": "u", "title": "Rome", "snippet": "s", "page_age": null}]))] + #[case::without_results(json!([]))] + fn flatten_unencrypted_web_search_results_is_idempotent(#[case] results: Value) { + let input = replayed_search_turn(results); + let once = apply(flatten_unencrypted_web_search_results, input.clone()); + let twice = apply(flatten_unencrypted_web_search_results, once.clone()); + assert_ne!(once, input); + assert_eq!(twice, once); + } + + #[rstest] + #[case::no_existing_header(None, "b", "b")] + #[case::empty_existing_header(Some(""), "b", "b")] + #[case::whitespace_existing_header(Some(" "), "b", "b")] + #[case::sorted_after_merge(Some("c,a"), "b", "a,b,c")] + #[case::already_present(Some("a,b"), "a", "a,b")] + #[case::trimmed_and_deduplicated(Some("b, a ,b"), "c", "a,b,c")] + #[case::blank_pieces_skipped(Some("a,,b"), "c", "a,b,c")] + fn beta_values_merge_sorted_and_deduplicated( + #[case] existing: Option<&str>, + #[case] new_beta: &str, + #[case] expected: &str, + ) { + assert_eq!( + join_beta_values(split_beta_values(existing).chain([new_beta.to_string()])), + expected + ); + } + + #[rstest] + #[case::raw_token("sk-ant-oat01-abc123", true)] + #[case::bearer_token("Bearer sk-ant-oat02-xyz789", true)] + #[case::bare_prefix(ANTHROPIC_OAUTH_TOKEN_PREFIX, true)] + #[case::api_key("sk-ant-api01-abc123", false)] + #[case::bearer_api_key("Bearer sk-ant-api01-abc123", false)] + #[case::empty("", false)] + #[case::uppercase_prefix("sk-ant-OAT01-abc123", false)] + #[case::shouting_prefix("SK-ANT-OAT01-abc123", false)] + #[case::lowercase_bearer("bearer sk-ant-oat01-abc123", false)] + #[case::bearer_stripped_once("Bearer Bearer sk-ant-oat01-abc123", false)] + #[case::prefix_not_at_start(" sk-ant-oat01-abc123", false)] + fn anthropic_oauth_key_detection(#[case] value: &str, #[case] expected: bool) { assert_eq!(is_anthropic_oauth_key(value), expected); } - #[test] - fn compaction_beta_requires_param_or_signed_block() { - let signed = vec![message( - "assistant", - json!([{"type": "compaction", "content": "c", "signature": "s"}]), - )]; - let unsigned = vec![message( - "assistant", - json!([{"type": "compaction", "content": "c"}]), - )]; - assert!(requires_native_compaction_beta(None, &signed)); - assert!(!requires_native_compaction_beta(None, &unsigned)); - assert!(requires_native_compaction_beta(Some(&json!({})), &unsigned)); + #[rstest] + #[case::regex_tool(Some(json!([{"type": ANTHROPIC_TOOL_SEARCH_TOOL_TYPES[0], "name": "tool_search_tool_regex"}])), true)] + #[case::bm25_tool(Some(json!([{"type": ANTHROPIC_TOOL_SEARCH_TOOL_TYPES[1], "name": "tool_search_tool_bm25"}])), true)] + #[case::after_other_tools( + Some(json!([{"name": "get_weather", "input_schema": {}}, {"type": ANTHROPIC_TOOL_SEARCH_TOOL_TYPES[1]}])), + true + )] + #[case::function_tool(Some(json!([{"type": "function", "function": {"name": "get_weather"}}])), false)] + #[case::name_without_type(Some(json!([{"name": ANTHROPIC_TOOL_SEARCH_TOOL_TYPES[0]}])), false)] + #[case::empty_tools(Some(json!([])), false)] + #[case::no_tools(None, false)] + fn tool_search_detection(#[case] input: Option, #[case] expected: bool) { + assert_eq!(is_tool_search_used(tools(input).as_deref()), expected); } - #[test] - fn capability_effort_gates_match_python() { - let opus_4_5 = AnthropicModelCapabilities { - supports_reasoning: true, + #[rstest] + #[case::advisor_tool(Some(json!([{"type": ANTHROPIC_ADVISOR_TOOL_TYPE, "name": "advisor"}])), true)] + #[case::after_other_tools(Some(json!([{"name": "f", "input_schema": {}}, {"type": ANTHROPIC_ADVISOR_TOOL_TYPE}])), true)] + #[case::tool_named_advisor(Some(json!([{"name": "advisor", "input_schema": {}}])), false)] + #[case::other_server_tool(Some(json!([{"type": "web_search_20250305", "name": "web_search"}])), false)] + #[case::empty_tools(Some(json!([])), false)] + #[case::no_tools(None, false)] + fn advisor_tool_detection(#[case] input: Option, #[case] expected: bool) { + assert_eq!(has_advisor_tool(tools(input).as_deref()), expected); + } + + #[rstest] + #[case::param_without_history(Some(json!({})), json!([]), true)] + #[case::param_with_unsigned_history( + Some(json!({"trigger": 1})), + json!([{"role": "assistant", "content": [{"type": "compaction", "content": "c"}]}]), + true + )] + #[case::signed_block(None, json!([{"role": "assistant", "content": [{"type": "compaction", "content": "c", "signature": "s"}]}]), true)] + #[case::signed_block_later_in_history( + None, + json!([ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": [{"type": "text", "text": "a"}, {"type": "compaction", "content": "c", "signature": "s"}]} + ]), + true + )] + #[case::unsigned_block(None, json!([{"role": "assistant", "content": [{"type": "compaction", "content": "c"}]}]), false)] + #[case::empty_signature(None, json!([{"role": "assistant", "content": [{"type": "compaction", "content": "c", "signature": ""}]}]), false)] + #[case::signed_non_compaction_block( + None, + json!([{"role": "assistant", "content": [{"type": "thinking", "thinking": "t", "signature": "s"}]}]), + false + )] + #[case::string_content(None, json!([{"role": "user", "content": "compaction"}]), false)] + #[case::neither(None, json!([]), false)] + fn native_compaction_beta_requirement( + #[case] compaction: Option, + #[case] messages: Value, + #[case] expected: bool, + ) { + assert_eq!( + requires_native_compaction_beta(compaction.as_ref(), &history(messages)), + expected + ); + } + + #[rstest] + #[case::low(EffortLevel::Low, "low")] + #[case::medium(EffortLevel::Medium, "medium")] + #[case::high(EffortLevel::High, "high")] + #[case::xhigh(EffortLevel::Xhigh, "xhigh")] + #[case::max(EffortLevel::Max, "max")] + fn effort_level_names_agree_across_str_parse_and_serde( + #[case] level: EffortLevel, + #[case] name: &str, + ) { + assert_eq!(level.as_str(), name); + assert_eq!(EffortLevel::parse(name), Some(level)); + assert_eq!(serde_json::to_value(level).unwrap(), json!(name)); + assert_eq!( + serde_json::from_value::(json!(name)).unwrap(), + level + ); + } + + #[rstest] + #[case::unknown("ultra")] + #[case::minimal_is_not_an_output_config_level("minimal")] + #[case::uppercase("HIGH")] + #[case::empty("")] + fn effort_level_parse_rejects(#[case] value: &str) { + assert_eq!(EffortLevel::parse(value), None); + } + + #[rstest] + #[case::minimal_only(tiers(true, false, false, false, false, false), [false, false, false, false, false])] + #[case::low_only(tiers(false, true, false, false, false, false), [true, false, false, false, false])] + #[case::medium_only(tiers(false, false, true, false, false, false), [false, true, false, false, false])] + #[case::high_only(tiers(false, false, false, true, false, false), [false, false, true, false, false])] + #[case::xhigh_only(tiers(false, false, false, false, true, false), [false, false, false, true, false])] + #[case::max_only(tiers(false, false, false, false, false, true), [false, false, false, false, true])] + fn supports_effort_tier_reads_the_matching_flag( + #[case] effort_tiers: SupportedEffortTiers, + #[case] expected: [bool; 5], + unmapped: AnthropicModelCapabilities, + ) { + let capabilities = AnthropicModelCapabilities { + effort_tiers, + ..unmapped + }; + assert_eq!( + ALL_LEVELS.map(|level| capabilities.supports_effort_tier(level)), + expected + ); + } + + #[rstest] + #[case::unmapped(false, false, false, SupportedEffortTiers::default(), false)] + #[case::reasoning_and_adaptive_thinking_alone( + true, + true, + false, + SupportedEffortTiers::default(), + false + )] + #[case::output_config_without_tiers(false, false, true, SupportedEffortTiers::default(), true)] + #[case::minimal_tier( + false, + false, + false, + tiers(true, false, false, false, false, false), + true + )] + #[case::low_tier( + false, + false, + false, + tiers(false, true, false, false, false, false), + true + )] + #[case::medium_tier( + false, + false, + false, + tiers(false, false, true, false, false, false), + true + )] + #[case::high_tier( + false, + false, + false, + tiers(false, false, false, true, false, false), + true + )] + #[case::xhigh_tier( + false, + false, + false, + tiers(false, false, false, false, true, false), + true + )] + #[case::max_tier( + false, + false, + false, + tiers(false, false, false, false, false, true), + true + )] + fn supports_effort_param_cases( + #[case] supports_reasoning: bool, + #[case] supports_adaptive_thinking: bool, + #[case] supports_output_config: bool, + #[case] effort_tiers: SupportedEffortTiers, + #[case] expected: bool, + unmapped: AnthropicModelCapabilities, + ) { + let capabilities = AnthropicModelCapabilities { + supports_reasoning, + supports_adaptive_thinking, + supports_output_config, + effort_tiers, + ..unmapped + }; + assert_eq!(capabilities.supports_effort_param(), expected); + } + + #[rstest] + #[case::max_on_adaptive_thinking_model(true, SupportedEffortTiers::default(), "max", None)] + #[case::max_on_max_tier_model( + false, + tiers(false, false, false, false, false, true), + "max", + None + )] + #[case::max_on_output_config_only_model( + false, + SupportedEffortTiers::default(), + "max", + Some("effort='max' is not supported by this model. Got model: claude-test") + )] + #[case::max_on_xhigh_tier_model( + false, + tiers(false, false, false, false, true, false), + "max", + Some("effort='max' is not supported by this model. Got model: claude-test") + )] + #[case::xhigh_on_xhigh_tier_model( + false, + tiers(false, false, false, false, true, false), + "xhigh", + None + )] + #[case::xhigh_on_adaptive_thinking_model( + true, + SupportedEffortTiers::default(), + "xhigh", + Some("effort='xhigh' is not supported by this model. Got model: claude-test") + )] + #[case::xhigh_on_max_tier_model( + false, + tiers(false, false, false, false, false, true), + "xhigh", + Some("effort='xhigh' is not supported by this model. Got model: claude-test") + )] + #[case::high_on_unmapped_model(false, SupportedEffortTiers::default(), "high", None)] + #[case::low_on_unmapped_model(false, SupportedEffortTiers::default(), "low", None)] + #[case::unknown_level_is_left_to_other_validation( + false, + SupportedEffortTiers::default(), + "ultra", + None + )] + fn effort_level_rejection_cases( + #[case] supports_adaptive_thinking: bool, + #[case] effort_tiers: SupportedEffortTiers, + #[case] effort: &str, + #[case] expected: Option<&str>, + unmapped: AnthropicModelCapabilities, + ) { + let capabilities = AnthropicModelCapabilities { supports_output_config: true, - ..Default::default() + supports_adaptive_thinking, + effort_tiers, + ..unmapped }; - assert!(opus_4_5.supports_effort_param()); - assert!(opus_4_5.effort_level_rejection("high", "m").is_none()); - assert!(opus_4_5.effort_level_rejection("xhigh", "m").is_some()); - assert!(opus_4_5.effort_level_rejection("max", "m").is_some()); - let adaptive = AnthropicModelCapabilities { - supports_adaptive_thinking: true, - ..Default::default() - }; - assert!(adaptive.effort_level_rejection("max", "m").is_none()); - assert!(adaptive.effort_level_rejection("xhigh", "m").is_some()); + assert_eq!( + capabilities + .effort_level_rejection(effort, "claude-test") + .as_deref(), + expected + ); } - #[test] - fn capabilities_deserialize_with_python_defaults() { - let parsed: AnthropicModelCapabilities = serde_json::from_value(json!({})).unwrap(); - assert!(parsed.supports_sampling_params); - assert!(!parsed.supports_reasoning); - let flagged: AnthropicModelCapabilities = serde_json::from_value( - json!({"effort_tiers": {"xhigh": true}, "supports_sampling_params": false}), - ) - .unwrap(); - assert!(flagged.effort_tiers.xhigh); - assert!(!flagged.supports_sampling_params); + #[rstest] + fn unmapped_model_has_no_reasoning_features_but_accepts_sampling_params( + unmapped: AnthropicModelCapabilities, + ) { + assert_eq!( + unmapped, + AnthropicModelCapabilities { + supports_reasoning: false, + supports_adaptive_thinking: false, + thinking_always_on: false, + supports_legacy_thinking: false, + supports_output_config: false, + supports_sampling_params: true, + supports_speed: false, + effort_tiers: tiers(false, false, false, false, false, false), + } + ); + assert_eq!( + serde_json::from_value::(json!({})).unwrap(), + unmapped + ); + } + + #[rstest] + #[case::sampling_params_removed( + json!({"supports_sampling_params": false}), + AnthropicModelCapabilities { supports_sampling_params: false, ..AnthropicModelCapabilities::default() } + )] + #[case::fast_mode( + json!({"supports_speed": true}), + AnthropicModelCapabilities { supports_speed: true, ..AnthropicModelCapabilities::default() } + )] + #[case::partial_effort_tiers( + json!({"supports_reasoning": true, "effort_tiers": {"xhigh": true}}), + AnthropicModelCapabilities { + supports_reasoning: true, + effort_tiers: tiers(false, false, false, false, true, false), + ..AnthropicModelCapabilities::default() + } + )] + fn capabilities_fill_missing_flags_with_unmapped_defaults( + #[case] input: Value, + #[case] expected: AnthropicModelCapabilities, + ) { + assert_eq!( + serde_json::from_value::(input).unwrap(), + expected + ); } } diff --git a/litellm-rust/crates/llms/src/anthropic/experimental_pass_through/messages/headers.rs b/litellm-rust/crates/llms/src/anthropic/experimental_pass_through/messages/headers.rs index 2fdb9e4b0a8..815fcc48c6f 100644 --- a/litellm-rust/crates/llms/src/anthropic/experimental_pass_through/messages/headers.rs +++ b/litellm-rust/crates/llms/src/anthropic/experimental_pass_through/messages/headers.rs @@ -1,6 +1,3 @@ -//! Request headers for the direct Anthropic Messages API: credential resolution, including -//! OAuth tokens, and the `anthropic-beta` values a request's features call for. - use litellm_types::llms::anthropic_messages::anthropic_request::AnthropicMessagesRequest; use serde_json::Value; @@ -35,8 +32,16 @@ fn without(headers: Headers, names: &[&str]) -> Headers { .collect() } +fn existing_betas(headers: &[(String, String)]) -> impl Iterator + '_ { + headers + .iter() + .filter(|(header, _)| header.eq_ignore_ascii_case(BETA_HEADER)) + .flat_map(|(_, value)| split_beta_values(Some(value))) +} + fn with_oauth_bearer(headers: Headers, bearer: String) -> Headers { - let beta = merge_oauth_beta(header_value(&headers, BETA_HEADER)); + let beta = + join_beta_values(existing_betas(&headers).chain([ANTHROPIC_OAUTH_BETA_HEADER.to_string()])); without(headers, &[API_KEY_HEADER, AUTHORIZATION, BETA_HEADER]) .into_iter() .chain([ @@ -47,20 +52,10 @@ fn with_oauth_bearer(headers: Headers, bearer: String) -> Headers { .collect() } -fn merge_oauth_beta(existing: Option<&str>) -> String { - join_beta_values( - split_beta_values(existing).chain(std::iter::once(ANTHROPIC_OAUTH_BETA_HEADER.to_string())), - ) -} - fn non_empty(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } -/// Python's `validate_anthropic_messages_environment` credential steps: an OAuth token in -/// the forwarded `authorization` header or in `api_key` is the whole credential; otherwise a -/// forwarded auth header is kept, else the key (`ANTHROPIC_API_KEY`) or the bearer token -/// (`ANTHROPIC_AUTH_TOKEN`) is resolved. pub fn authenticate( headers: Headers, api_key: Option<&str>, @@ -140,8 +135,6 @@ fn messages_carry_output_config(request: &AnthropicMessagesRequest) -> bool { .any(|message| message.extra.contains_key("output_config")) } -/// The `anthropic-beta` values the request's features need, as Python's -/// `_update_headers_with_anthropic_beta` derives them for the direct API. pub fn feature_betas(request: &AnthropicMessagesRequest) -> Vec<&'static str> { let tools = request.tools.as_deref(); [ @@ -161,10 +154,8 @@ pub fn feature_betas(request: &AnthropicMessagesRequest) -> Vec<&'static str> { .collect() } -/// Merge the request's feature betas into the outgoing headers. Headers without any beta -/// value are returned untouched. pub fn with_feature_betas(headers: Headers, request: &AnthropicMessagesRequest) -> Headers { - let existing = split_beta_values(header_value(&headers, BETA_HEADER)).collect::>(); + let existing = existing_betas(&headers).collect::>(); let features = feature_betas(request); if existing.is_empty() && features.is_empty() { return headers; @@ -182,11 +173,18 @@ pub fn with_feature_betas(headers: Headers, request: &AnthropicMessagesRequest) #[cfg(test)] mod tests { - use rstest::rstest; + use rstest::{fixture, rstest}; use serde_json::json; use super::*; + const OAUTH_TOKEN: &str = "sk-ant-oat01-token"; + const OAUTH_BEARER: &str = "Bearer sk-ant-oat01-token"; + const REGULAR_KEY: &str = "sk-ant-api03-regular"; + const BROWSER_ACCESS: (&str, &str) = ("anthropic-dangerous-direct-browser-access", "true"); + + type Env = &'static [(&'static str, &'static str)]; + fn request(fields: Value) -> AnthropicMessagesRequest { let mut body = json!({"model": "claude", "messages": [{"role": "user", "content": "Hello"}]}); @@ -196,137 +194,448 @@ mod tests { serde_json::from_value(body).unwrap() } - fn header(name: &str, value: &str) -> (String, String) { - (name.to_string(), value.to_string()) + fn headers(pairs: &[(&str, &str)]) -> Headers { + pairs + .iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect() } - fn no_env(_: &str) -> Option { - None + fn betas(values: &[&str]) -> String { + values.join(",") } - #[test] - fn forwarded_oauth_bearer_replaces_the_key_and_adds_oauth_headers() { - let headers = authenticate( - vec![ - header("X-Api-Key", "sk-ant-api03-deployment"), - header("Authorization", "Bearer sk-ant-oat01-token"), - header("anthropic-beta", "web-search-2025-03-05"), - ], - Some("sk-ant-api03-deployment"), - &no_env, - ) - .unwrap(); - assert_eq!(header_value(&headers, "x-api-key"), None); - assert_eq!( - header_value(&headers, "authorization"), - Some("Bearer sk-ant-oat01-token") - ); - assert_eq!( - header_value(&headers, "anthropic-beta"), - Some("oauth-2025-04-20,web-search-2025-03-05") - ); - assert_eq!( - header_value(&headers, DIRECT_BROWSER_ACCESS_HEADER), - Some("true") - ); + #[fixture] + fn no_env() -> Env { + &[] } - #[test] - fn oauth_api_key_authenticates_as_a_bearer() { - let headers = authenticate(vec![], Some("sk-ant-oat01-token"), &no_env).unwrap(); - assert_eq!( - header_value(&headers, "authorization"), - Some("Bearer sk-ant-oat01-token") - ); - assert_eq!( - header_value(&headers, "anthropic-beta"), - Some("oauth-2025-04-20") - ); - assert_eq!(header_value(&headers, "x-api-key"), None); + #[fixture] + fn full_env() -> Env { + &[ + ("ANTHROPIC_API_KEY", "sk-env"), + ("ANTHROPIC_AUTH_TOKEN", "env-token"), + ] } - #[test] - fn forwarded_auth_headers_are_kept_without_resolving_a_key() { - let forwarded = vec![header("Authorization", "Bearer some-proxy-token")]; - let headers = authenticate(forwarded.clone(), None, &no_env).unwrap(); - assert_eq!(headers, forwarded); - let keyed = vec![header("x-api-key", "caller-key")]; - assert_eq!( - authenticate(keyed.clone(), Some("sk-other"), &no_env).unwrap(), - keyed - ); - } - - #[rstest] - #[case(Some("sk-param"), &[], "x-api-key", "sk-param")] - #[case(Some(" "), &[("ANTHROPIC_API_KEY", "sk-env")], "x-api-key", "sk-env")] - #[case(None, &[("ANTHROPIC_AUTH_TOKEN", "tok")], "authorization", "Bearer tok")] - #[case(None, &[("ANTHROPIC_API_KEY", "sk-ant-oat01-env")], "authorization", "Bearer sk-ant-oat01-env")] - fn credential_resolution_order_matches_python( - #[case] api_key: Option<&str>, - #[case] env: &[(&str, &str)], - #[case] expected_header: &str, - #[case] expected_value: &str, - ) { + fn authenticate_with( + forwarded: &[(&str, &str)], + api_key: Option<&str>, + env: Env, + ) -> Result { let lookup = |name: &str| { env.iter() .find(|(key, _)| *key == name) .map(|(_, value)| value.to_string()) }; - let headers = authenticate(vec![], api_key, &lookup).unwrap(); - assert_eq!(headers, vec![header(expected_header, expected_value)]); + authenticate(headers(forwarded), api_key, &lookup) } - #[test] - fn missing_credentials_are_an_auth_error() { + #[rstest] + #[case::forwarded_bearer_drops_forwarded_and_deployment_keys( + &[("X-Api-Key", REGULAR_KEY), ("Authorization", OAUTH_BEARER)], + Some(REGULAR_KEY), + OAUTH_BEARER, + &[], + )] + #[case::forwarded_bearer_in_uppercase_authorization_header( + &[("AUTHORIZATION", OAUTH_BEARER)], + None, + OAUTH_BEARER, + &[], + )] + #[case::forwarded_bearer_keeps_unrelated_headers_in_place( + &[("anthropic-version", "2023-06-01"), ("authorization", OAUTH_BEARER)], + None, + OAUTH_BEARER, + &[("anthropic-version", "2023-06-01")], + )] + #[case::forwarded_bearer_wins_over_an_oauth_api_key( + &[("authorization", OAUTH_BEARER)], + Some("sk-ant-oat01-deployment"), + OAUTH_BEARER, + &[], + )] + #[case::api_key_authenticates_as_a_bearer(&[], Some(OAUTH_TOKEN), OAUTH_BEARER, &[])] + #[case::api_key_removes_a_forwarded_x_api_key( + &[("x-api-key", OAUTH_TOKEN)], + Some(OAUTH_TOKEN), + OAUTH_BEARER, + &[], + )] + #[case::api_key_replaces_a_forwarded_non_oauth_bearer( + &[("Authorization", "Bearer some-proxy-token")], + Some(OAUTH_TOKEN), + OAUTH_BEARER, + &[], + )] + fn oauth_token_is_the_whole_credential( + #[case] forwarded: &[(&str, &str)], + #[case] api_key: Option<&str>, + #[case] expected_bearer: &str, + #[case] kept: &[(&str, &str)], + full_env: Env, + ) { + let expected = kept + .iter() + .copied() + .chain([ + ("authorization", expected_bearer), + ("anthropic-beta", ANTHROPIC_OAUTH_BETA_HEADER), + BROWSER_ACCESS, + ]) + .collect::>(); + assert_eq!( + authenticate_with(forwarded, api_key, full_env).unwrap(), + headers(&expected) + ); + } + + #[rstest] + #[case::forwarded_bearer_merges_a_differently_cased_beta_header( + &[("Anthropic-Beta", "web-search-2025-03-05"), ("authorization", OAUTH_BEARER)], + None, + )] + #[case::forwarded_bearer_dedupes_an_existing_oauth_beta( + &[("anthropic-beta", "web-search-2025-03-05, oauth-2025-04-20"), ("authorization", OAUTH_BEARER)], + None, + )] + #[case::api_key_merges_the_existing_beta_header( + &[("anthropic-beta", " web-search-2025-03-05 ,")], + Some(OAUTH_TOKEN), + )] + #[case::forwarded_bearer_unions_every_beta_header_casing( + &[("anthropic-beta", "oauth-2025-04-20"), ("ANTHROPIC-BETA", "web-search-2025-03-05"), ("authorization", OAUTH_BEARER)], + None, + )] + fn oauth_beta_merges_into_existing_betas( + #[case] forwarded: &[(&str, &str)], + #[case] api_key: Option<&str>, + no_env: Env, + ) { + assert_eq!( + authenticate_with(forwarded, api_key, no_env).unwrap(), + headers(&[ + ("authorization", OAUTH_BEARER), + ( + "anthropic-beta", + &betas(&[ANTHROPIC_OAUTH_BETA_HEADER, "web-search-2025-03-05"]) + ), + BROWSER_ACCESS, + ]) + ); + } + + #[rstest] + #[case::x_api_key_over_the_deployment_key(&[("x-api-key", "caller-key")], Some("sk-other"))] + #[case::uppercase_x_api_key(&[("X-API-KEY", "caller-key")], None)] + #[case::non_oauth_bearer(&[("Authorization", "Bearer some-proxy-token")], None)] + #[case::non_oauth_bearer_over_a_regular_api_key( + &[("authorization", "Bearer sk-ant-api03-forwarded")], + Some(REGULAR_KEY), + )] + #[case::oauth_token_without_the_bearer_scheme(&[("authorization", OAUTH_TOKEN)], None)] + #[case::oauth_token_behind_a_lowercase_bearer_scheme( + &[("authorization", "bearer sk-ant-oat01-token")], + None, + )] + fn forwarded_auth_header_is_kept_untouched( + #[case] forwarded: &[(&str, &str)], + #[case] api_key: Option<&str>, + full_env: Env, + ) { + assert_eq!( + authenticate_with(forwarded, api_key, full_env).unwrap(), + headers(forwarded) + ); + } + + #[rstest] + #[case::api_key_param(Some("sk-param"), &[], ("x-api-key", "sk-param"))] + #[case::api_key_param_over_env_key_and_auth_token( + Some("sk-param"), + &[("ANTHROPIC_API_KEY", "sk-env"), ("ANTHROPIC_AUTH_TOKEN", "env-token")], + ("x-api-key", "sk-param"), + )] + #[case::env_key_without_a_param(None, &[("ANTHROPIC_API_KEY", "sk-env")], ("x-api-key", "sk-env"))] + #[case::env_key_when_the_param_is_empty(Some(""), &[("ANTHROPIC_API_KEY", "sk-env")], ("x-api-key", "sk-env"))] + #[case::env_key_when_the_param_is_whitespace( + Some(" "), + &[("ANTHROPIC_API_KEY", "sk-env")], + ("x-api-key", "sk-env"), + )] + #[case::env_key_over_auth_token( + None, + &[("ANTHROPIC_API_KEY", "sk-env"), ("ANTHROPIC_AUTH_TOKEN", "env-token")], + ("x-api-key", "sk-env"), + )] + #[case::auth_token_as_a_bearer( + None, + &[("ANTHROPIC_AUTH_TOKEN", "env-token")], + ("authorization", "Bearer env-token"), + )] + #[case::auth_token_when_the_env_key_is_whitespace( + None, + &[("ANTHROPIC_API_KEY", " \t"), ("ANTHROPIC_AUTH_TOKEN", "env-token")], + ("authorization", "Bearer env-token"), + )] + #[case::oauth_env_key_as_a_plain_bearer( + None, + &[("ANTHROPIC_API_KEY", "sk-ant-oat01-env")], + ("authorization", "Bearer sk-ant-oat01-env"), + )] + fn credential_is_resolved_after_the_existing_headers( + #[case] api_key: Option<&str>, + #[case] env: Env, + #[case] expected: (&str, &str), + ) { + let forwarded = [("anthropic-beta", "web-search-2025-03-05")]; + assert_eq!( + authenticate_with(&forwarded, api_key, env).unwrap(), + headers(&[forwarded[0], expected]) + ); + } + + #[rstest] + #[case::no_credentials(&[], None, &[])] + #[case::empty_api_key(&[], Some(""), &[])] + #[case::whitespace_only_env_values( + &[], + None, + &[("ANTHROPIC_API_KEY", " "), ("ANTHROPIC_AUTH_TOKEN", " \t")], + )] + #[case::unrelated_forwarded_headers(&[("anthropic-beta", "web-search-2025-03-05")], None, &[])] + fn missing_credentials_are_an_auth_error( + #[case] forwarded: &[(&str, &str)], + #[case] api_key: Option<&str>, + #[case] env: Env, + ) { assert!(matches!( - authenticate(vec![], None, &no_env), - Err(litellm_auth::Error::MissingApiKey { .. }) + authenticate_with(forwarded, api_key, env), + Err(litellm_auth::Error::MissingApiKey { + provider: "Anthropic", + environment_variable: "ANTHROPIC_API_KEY", + }) )); } #[rstest] - #[case(json!({}), &[])] - #[case(json!({"output_format": {"type": "json_schema"}}), &[beta::STRUCTURED_OUTPUT])] - #[case(json!({"output_config": {"format": {"type": "json_schema"}}}), &[beta::STRUCTURED_OUTPUT])] - #[case(json!({"output_config": {"effort": "high"}}), &[])] - #[case(json!({"speed": "fast"}), &[beta::FAST_MODE_2026_02_01])] - #[case(json!({"speed": "standard"}), &[])] - #[case(json!({"compaction": {"enabled": true}}), &[beta::COMPACT_2026_09_04])] - #[case(json!({"tools": [{"type": "advisor_20260301"}]}), &[beta::ADVISOR_TOOL_2026_03_01])] - #[case(json!({"tools": [{"type": "tool_search_tool_regex_20251119"}]}), &[beta::ADVANCED_TOOL_USE_2025_11_20])] - #[case( + #[case::no_features(json!({}), &[])] + #[case::output_format(json!({"output_format": {"type": "json_schema"}}), &[beta::STRUCTURED_OUTPUT])] + #[case::null_output_format(json!({"output_format": null}), &[])] + #[case::output_config_format( + json!({"output_config": {"format": {"type": "json_schema"}, "effort": "xhigh"}}), + &[beta::STRUCTURED_OUTPUT] + )] + #[case::null_output_config_format(json!({"output_config": {"format": null}}), &[])] + #[case::top_level_output_config_without_format(json!({"output_config": {"effort": "high"}}), &[])] + #[case::fast_speed(json!({"speed": "fast"}), &[beta::FAST_MODE_2026_02_01])] + #[case::standard_speed(json!({"speed": "standard"}), &[])] + #[case::compaction_param(json!({"compaction": {"enabled": true}}), &[beta::COMPACT_2026_09_04])] + #[case::empty_compaction_param(json!({"compaction": {}}), &[beta::COMPACT_2026_09_04])] + #[case::signed_compaction_block_in_history( + json!({"messages": [ + {"role": "assistant", "content": [{"type": "compaction", "content": "summary", "signature": "sig"}]}, + {"role": "user", "content": "Continue"}, + ]}), + &[beta::COMPACT_2026_09_04] + )] + #[case::unsigned_compaction_block_in_history( + json!({"messages": [ + {"role": "assistant", "content": [{"type": "compaction", "content": "summary", "signature": ""}]}, + {"role": "user", "content": "Continue"}, + ]}), + &[] + )] + #[case::advisor_tool( + json!({"tools": [{"type": "advisor_20260301", "name": "advisor", "model": "claude-opus-4-6"}]}), + &[beta::ADVISOR_TOOL_2026_03_01] + )] + #[case::no_tools(json!({"tools": []}), &[])] + #[case::regex_tool_search( + json!({"tools": [{"type": "tool_search_tool_regex_20251119"}]}), + &[beta::ADVANCED_TOOL_USE_2025_11_20] + )] + #[case::bm25_tool_search( + json!({"tools": [{"type": "tool_search_tool_bm25_20251119"}]}), + &[beta::ADVANCED_TOOL_USE_2025_11_20] + )] + #[case::unrelated_server_tool(json!({"tools": [{"type": "web_search_20250305", "name": "web_search"}]}), &[])] + #[case::only_compact_edits( + json!({"context_management": {"edits": [{"type": "compact_20260112"}]}}), + &[beta::COMPACT_2026_01_12] + )] + #[case::only_other_edits( + json!({"context_management": {"edits": [{"type": "clear_tool_uses_20250919", "keep": {"type": "tool_uses", "value": 3}}]}}), + &[beta::CONTEXT_MANAGEMENT_2025_06_27] + )] + #[case::compact_and_other_edits( json!({"context_management": {"edits": [{"type": "compact_20260112"}, {"type": "clear_tool_uses_20250919"}]}}), &[beta::COMPACT_2026_01_12, beta::CONTEXT_MANAGEMENT_2025_06_27] )] - #[case( + #[case::edit_without_a_type(json!({"context_management": {"edits": [{}]}}), &[beta::CONTEXT_MANAGEMENT_2025_06_27])] + #[case::empty_edits(json!({"context_management": {"edits": []}}), &[])] + #[case::context_management_without_edits(json!({"context_management": {}}), &[])] + #[case::per_message_output_config( json!({"messages": [{"role": "user", "content": "hi", "output_config": {"effort": "low"}}]}), &[beta::PER_TURN_CONTROL_2026_07_01] )] + #[case::per_message_null_output_config( + json!({"messages": [{"role": "user", "content": "hi", "output_config": null}]}), + &[beta::PER_TURN_CONTROL_2026_07_01] + )] fn feature_betas_follow_the_request(#[case] fields: Value, #[case] expected: &[&str]) { assert_eq!(feature_betas(&request(fields)), expected); } + #[rstest] + #[case::no_betas(&[("x-api-key", "k"), ("anthropic-version", "2023-06-01")], json!({}))] + #[case::blank_beta_header(&[("Anthropic-Beta", " , "), ("x-api-key", "k")], json!({}))] + fn headers_without_any_beta_value_are_untouched( + #[case] input: &[(&str, &str)], + #[case] fields: Value, + ) { + assert_eq!( + with_feature_betas(headers(input), &request(fields)), + headers(input) + ); + } + + #[rstest] + #[case::feature_beta_is_appended( + &[("x-api-key", "k")], + json!({"speed": "fast"}), + &[("x-api-key", "k"), ("anthropic-beta", beta::FAST_MODE_2026_02_01)], + )] + #[case::existing_betas_are_normalized_without_features( + &[("Anthropic-Beta", "web-search-2025-03-05, interleaved-thinking-2025-05-14 ,web-search-2025-03-05"), ("x-api-key", "k")], + json!({}), + &[("x-api-key", "k"), ("anthropic-beta", "interleaved-thinking-2025-05-14,web-search-2025-03-05")], + )] + #[case::existing_advisor_beta_is_kept_without_an_advisor_tool( + &[("anthropic-beta", beta::ADVISOR_TOOL_2026_03_01)], + json!({"tools": []}), + &[("anthropic-beta", beta::ADVISOR_TOOL_2026_03_01)], + )] + #[case::feature_already_sent_is_not_duplicated( + &[("anthropic-beta", beta::FAST_MODE_2026_02_01)], + json!({"speed": "fast"}), + &[("anthropic-beta", beta::FAST_MODE_2026_02_01)], + )] + fn feature_betas_merge_into_the_headers( + #[case] input: &[(&str, &str)], + #[case] fields: Value, + #[case] expected: &[(&str, &str)], + ) { + assert_eq!( + with_feature_betas(headers(input), &request(fields)), + headers(expected) + ); + } + #[test] - fn feature_betas_merge_into_the_existing_header_sorted() { - let headers = with_feature_betas( - vec![header( - "Anthropic-Beta", - "web-search-2025-03-05, compact-2026-09-04", - )], + fn differently_cased_beta_header_is_replaced_by_one_sorted_header() { + let merged = with_feature_betas( + headers(&[("Anthropic-Beta", "interleaved-thinking-2025-05-14")]), + &request( + json!({"messages": [{"role": "system", "content": "env", "output_config": {"effort": "low"}}]}), + ), + ); + assert_eq!( + merged, + headers(&[( + "anthropic-beta", + &betas(&[ + "interleaved-thinking-2025-05-14", + beta::PER_TURN_CONTROL_2026_07_01 + ]) + )]) + ); + } + + #[test] + fn every_beta_header_casing_is_unioned_into_one_header() { + let merged = with_feature_betas( + headers(&[ + ("anthropic-beta", "interleaved-thinking-2025-05-14"), + ("Anthropic-Beta", "web-search-2025-03-05"), + ]), &request(json!({"speed": "fast"})), ); assert_eq!( - headers, - vec![header( + merged, + headers(&[( "anthropic-beta", - "compact-2026-09-04,fast-mode-2026-02-01,web-search-2025-03-05" - )] + &betas(&[ + beta::FAST_MODE_2026_02_01, + "interleaved-thinking-2025-05-14", + "web-search-2025-03-05" + ]) + )]) + ); + } + + #[test] + fn unknown_client_betas_survive_alongside_the_added_one() { + let client_betas = [ + "claude-code-20250219", + "interleaved-thinking-2025-05-14", + beta::CONTEXT_MANAGEMENT_2025_06_27, + beta::PER_TURN_CONTROL_2026_07_01, + "effort-2025-11-24", + ]; + let merged = with_feature_betas( + headers(&[("anthropic-beta", &betas(&client_betas))]), + &request( + json!({"messages": [{"role": "user", "content": "hi", "output_config": {"effort": "low"}}]}), + ), ); - let untouched = vec![header("x-api-key", "k")]; assert_eq!( - with_feature_betas(untouched.clone(), &request(json!({}))), - untouched + merged, + headers(&[( + "anthropic-beta", + &betas(&[ + "claude-code-20250219", + beta::CONTEXT_MANAGEMENT_2025_06_27, + "effort-2025-11-24", + "interleaved-thinking-2025-05-14", + beta::PER_TURN_CONTROL_2026_07_01, + ]) + )]) + ); + } + + #[test] + fn every_feature_merges_with_the_oauth_beta_sorted_and_last() { + let oauth_headers = authenticate_with(&[], Some(OAUTH_TOKEN), &[]).unwrap(); + let all_features = request(json!({ + "compaction": {"enabled": true}, + "output_format": {"type": "json_schema"}, + "speed": "fast", + "tools": [{"type": "advisor_20260301"}, {"type": "tool_search_tool_bm25_20251119"}], + "context_management": {"edits": [{"type": "compact_20260112"}, {"type": "clear_thinking_20251015"}]}, + "messages": [{"role": "user", "content": "hi", "output_config": {"effort": "low"}}], + })); + assert_eq!( + with_feature_betas(oauth_headers, &all_features), + headers(&[ + ("authorization", OAUTH_BEARER), + BROWSER_ACCESS, + ( + "anthropic-beta", + &betas(&[ + beta::ADVANCED_TOOL_USE_2025_11_20, + beta::ADVISOR_TOOL_2026_03_01, + beta::COMPACT_2026_01_12, + beta::COMPACT_2026_09_04, + beta::CONTEXT_MANAGEMENT_2025_06_27, + beta::FAST_MODE_2026_02_01, + ANTHROPIC_OAUTH_BETA_HEADER, + beta::PER_TURN_CONTROL_2026_07_01, + beta::STRUCTURED_OUTPUT, + ]) + ), + ]) ); } } diff --git a/litellm-rust/crates/llms/src/anthropic/experimental_pass_through/messages/thinking.rs b/litellm-rust/crates/llms/src/anthropic/experimental_pass_through/messages/thinking.rs index 250446b159f..ffa4c8ffeb8 100644 --- a/litellm-rust/crates/llms/src/anthropic/experimental_pass_through/messages/thinking.rs +++ b/litellm-rust/crates/llms/src/anthropic/experimental_pass_through/messages/thinking.rs @@ -1,8 +1,3 @@ -//! Reasoning parameter translation for the Messages API: OpenAI-style `reasoning_effort`, -//! the 4.6+ adaptive interface and the legacy `budget_tokens` interface, reshaped to what -//! the target model accepts. Mirrors the thinking steps of Python's -//! `AnthropicMessagesConfig.transform_anthropic_messages_request`. - use litellm_core_utils::settings::Lookup; use litellm_types::llms::anthropic_messages::anthropic_request::AnthropicMessagesRequest; use serde_json::{Map, Value, json}; @@ -15,8 +10,6 @@ pub const ANTHROPIC_MIN_THINKING_BUDGET_TOKENS: u64 = 1024; const EFFORT_NAMES: &str = "'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'none'"; -/// The `budget_tokens` each `reasoning_effort` tier maps to. Every value honors the same -/// `DEFAULT_REASONING_EFFORT__THINKING_BUDGET` environment override Python reads. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ThinkingBudgets { pub minimal: u64, @@ -69,7 +62,6 @@ impl ThinkingBudgets { } } - /// Python's `_legacy_budget_to_effort`: the effort tier a legacy budget stands for. fn effort_for_budget( &self, budget_tokens: u64, @@ -88,7 +80,6 @@ impl ThinkingBudgets { } } -/// Everything the thinking translation needs to know besides the request itself. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct ThinkingContext { pub capabilities: AnthropicModelCapabilities, @@ -111,8 +102,6 @@ fn enabled_thinking(budget_tokens: u64) -> Value { json!({"type": "enabled", "budget_tokens": budget_tokens}) } -/// Python's `AnthropicConfig._map_reasoning_effort`: `Ok(None)` for `none`, the adaptive -/// shape for adaptive models, else a legacy budget. fn map_reasoning_effort( reasoning_effort: &str, context: &ThinkingContext, @@ -129,13 +118,11 @@ fn map_reasoning_effort( .map(|budget| Some(enabled_thinking(budget))) .ok_or_else(|| { bad_request(format!( - "Unmapped reasoning effort: {reasoning_effort:?}. Must be one of: {EFFORT_NAMES}." + "Unmapped reasoning effort: '{reasoning_effort}'. Must be one of: {EFFORT_NAMES}." )) }) } -/// Cap a legacy `budget_tokens` below `max_tokens`; `None` when even the minimum budget -/// cannot fit and thinking should be dropped. fn cap_thinking_budget_to_max_tokens(thinking: Value, max_tokens: Option) -> Option { let (Some(max_tokens), Some(budget)) = ( max_tokens, @@ -149,8 +136,7 @@ fn cap_thinking_budget_to_max_tokens(thinking: Value, max_tokens: Option) - if budget < max_tokens { return Some(thinking); } - let thinking_type = thinking_type(Some(&thinking)).unwrap_or("enabled"); - Some(json!({"type": thinking_type, "budget_tokens": max_tokens - 1})) + Some(enabled_thinking(max_tokens - 1)) } fn reasoning_effort_to_output_config_effort(reasoning_effort: &str) -> Option<&'static str> { @@ -164,19 +150,17 @@ fn reasoning_effort_to_output_config_effort(reasoning_effort: &str) -> Option<&' } } -fn with_effort(output_config: Option, effort: &str, caller_wins: bool) -> Value { +fn with_default_effort(output_config: Option, effort: &str) -> Value { let mut config = match output_config { Some(Value::Object(config)) => config, _ => Map::new(), }; - if !caller_wins || !config.contains_key("effort") { + if !config.contains_key("effort") { config.insert("effort".to_string(), Value::String(effort.to_string())); } Value::Object(config) } -/// `reasoning_effort` becomes native `thinking` (and `output_config.effort` on adaptive -/// models). Caller-supplied `thinking` / `output_config` win; `none` clears both. fn translate_reasoning_effort( request: AnthropicMessagesRequest, context: &ThinkingContext, @@ -207,7 +191,7 @@ fn translate_reasoning_effort( } let effort = reasoning_effort_to_output_config_effort(&reasoning_effort).ok_or_else(|| { bad_request(format!( - "Invalid reasoning_effort: {reasoning_effort:?}. Must be one of: {EFFORT_NAMES}" + "Invalid reasoning_effort: '{reasoning_effort}'. Must be one of: {EFFORT_NAMES}" )) })?; if let Some(rejection) = context @@ -218,12 +202,11 @@ fn translate_reasoning_effort( } Ok(AnthropicMessagesRequest { thinking, - output_config: Some(with_effort(request.output_config.clone(), effort, true)), + output_config: Some(with_default_effort(request.output_config.clone(), effort)), ..request }) } -/// Always-on-thinking models 400 on `thinking.type=disabled`; omit it instead. fn drop_disabled_thinking( request: AnthropicMessagesRequest, context: &ThinkingContext, @@ -239,8 +222,6 @@ fn drop_disabled_thinking( } } -/// Adaptive models that reject the legacy shape get `thinking.type=enabled` translated to -/// adaptive plus an `output_config.effort` bucketed from the budget. fn translate_legacy_thinking_for_adaptive_model( request: AnthropicMessagesRequest, context: &ThinkingContext, @@ -261,7 +242,7 @@ fn translate_legacy_thinking_for_adaptive_model( let effort = context.budgets.effort_for_budget(budget, capabilities); AnthropicMessagesRequest { thinking: Some(json!({"type": "adaptive"})), - output_config: Some(with_effort(request.output_config.clone(), effort, true)), + output_config: Some(with_default_effort(request.output_config.clone(), effort)), ..request } } @@ -280,9 +261,6 @@ fn output_config_without_effort(output_config: Option) -> Option { (!residual.is_empty()).then_some(Value::Object(residual)) } -/// The 4.6+ adaptive interface (`thinking.type=adaptive`, `output_config.effort`) reshaped -/// for an older model: native effort is kept where the model takes it, otherwise the effort -/// becomes a capped legacy budget, or thinking is dropped for models without reasoning. fn translate_adaptive_effort_for_non_adaptive_model( request: AnthropicMessagesRequest, context: &ThinkingContext, @@ -312,7 +290,13 @@ fn translate_adaptive_effort_for_non_adaptive_model( }); } let legacy = if capabilities.supports_reasoning { - map_reasoning_effort(effort.as_deref().unwrap_or("medium"), context)? + map_reasoning_effort( + effort + .as_deref() + .filter(|effort| !effort.is_empty()) + .unwrap_or("medium"), + context, + )? } else { None }; @@ -325,8 +309,6 @@ fn translate_adaptive_effort_for_non_adaptive_model( }) } -/// Anthropic only accepts `temperature=1` while extended thinking is on, so a pinned -/// temperature is dropped on non-adaptive models once thinking or effort is in play. fn drop_incompatible_temperature_for_thinking( request: AnthropicMessagesRequest, context: &ThinkingContext, @@ -348,7 +330,6 @@ fn drop_incompatible_temperature_for_thinking( } } -/// The full reasoning translation, in Python's order. pub fn translate_thinking( request: AnthropicMessagesRequest, context: &ThinkingContext, @@ -362,11 +343,13 @@ pub fn translate_thinking( #[cfg(test)] mod tests { - use rstest::rstest; + use rstest::{fixture, rstest}; use super::*; use crate::anthropic::common_utils::SupportedEffortTiers; + const EFFORT_CHOICES: &str = "'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'none'"; + fn request(fields: Value) -> AnthropicMessagesRequest { let mut body = json!({"model": "claude", "messages": [{"role": "user", "content": "Hello"}]}); @@ -376,10 +359,6 @@ mod tests { serde_json::from_value(body).unwrap() } - fn body(request: &AnthropicMessagesRequest) -> Value { - serde_json::to_value(request).unwrap() - } - fn context(capabilities: AnthropicModelCapabilities) -> ThinkingContext { ThinkingContext { capabilities, @@ -387,6 +366,44 @@ mod tests { } } + fn translate( + capabilities: AnthropicModelCapabilities, + fields: Value, + ) -> Result { + translate_thinking(request(fields), &context(capabilities)) + } + + fn overridden_budgets(overrides: &[(&str, &str)]) -> ThinkingBudgets { + let env = |name: &str| { + overrides + .iter() + .find(|(tier, _)| { + name == format!("DEFAULT_REASONING_EFFORT_{tier}_THINKING_BUDGET") + }) + .map(|(_, value)| value.to_string()) + }; + ThinkingBudgets::from_lookup(&env) + } + + fn claude_code_payload(effort: &str, max_tokens: u64) -> Value { + json!({"max_tokens": max_tokens, "thinking": {"type": "adaptive"}, "output_config": {"effort": effort}}) + } + + fn with_temperature(fields: Value, temperature: f64) -> Value { + let mut fields = fields; + fields + .as_object_mut() + .unwrap() + .insert("temperature".to_string(), json!(temperature)); + fields + } + + #[fixture] + fn haiku_3_5() -> AnthropicModelCapabilities { + AnthropicModelCapabilities::default() + } + + #[fixture] fn haiku_4_5() -> AnthropicModelCapabilities { AnthropicModelCapabilities { supports_reasoning: true, @@ -394,6 +411,7 @@ mod tests { } } + #[fixture] fn opus_4_5() -> AnthropicModelCapabilities { AnthropicModelCapabilities { supports_reasoning: true, @@ -402,6 +420,7 @@ mod tests { } } + #[fixture] fn sonnet_4_6() -> AnthropicModelCapabilities { AnthropicModelCapabilities { supports_reasoning: true, @@ -416,6 +435,7 @@ mod tests { } } + #[fixture] fn opus_4_7() -> AnthropicModelCapabilities { AnthropicModelCapabilities { supports_reasoning: true, @@ -430,6 +450,7 @@ mod tests { } } + #[fixture] fn fable_5_1() -> AnthropicModelCapabilities { AnthropicModelCapabilities { thinking_always_on: true, @@ -437,352 +458,725 @@ mod tests { } } - fn claude_code_payload(effort: &str, max_tokens: u64) -> Value { - json!({"max_tokens": max_tokens, "thinking": {"type": "adaptive"}, "output_config": {"effort": effort}}) + #[fixture] + fn newfamily_6() -> AnthropicModelCapabilities { + AnthropicModelCapabilities { + supports_reasoning: true, + supports_adaptive_thinking: true, + ..Default::default() + } } #[rstest] - #[case("minimal", "low")] - #[case("low", "low")] - #[case("medium", "medium")] - #[case("high", "high")] - #[case("xhigh", "xhigh")] - #[case("max", "max")] - fn reasoning_effort_maps_to_output_config_for_adaptive_model( + #[case::minimal_maps_to_low(opus_4_7(), "minimal", "low")] + #[case::low(opus_4_7(), "low", "low")] + #[case::medium(opus_4_7(), "medium", "medium")] + #[case::high(opus_4_7(), "high", "high")] + #[case::xhigh_with_xhigh_tier(opus_4_7(), "xhigh", "xhigh")] + #[case::max(opus_4_7(), "max", "max")] + #[case::minimal_maps_to_low_on_4_6(sonnet_4_6(), "minimal", "low")] + #[case::low_on_4_6(sonnet_4_6(), "low", "low")] + #[case::max_without_max_tier_is_allowed_on_adaptive_models(newfamily_6(), "max", "max")] + fn reasoning_effort_on_adaptive_model_becomes_summarized_adaptive_thinking_and_effort( + #[case] capabilities: AnthropicModelCapabilities, #[case] reasoning_effort: &str, #[case] expected_effort: &str, ) { - let result = translate_thinking( - request(json!({"max_tokens": 1024, "reasoning_effort": reasoning_effort})), - &context(opus_4_7()), - ) - .unwrap(); - let sent = body(&result); - assert!(sent.get("reasoning_effort").is_none()); assert_eq!( - sent["thinking"], - json!({"type": "adaptive", "display": "summarized"}) - ); - assert_eq!(sent["output_config"], json!({"effort": expected_effort})); - } - - #[test] - fn reasoning_effort_none_clears_thinking_and_output_config() { - let result = translate_thinking( - request(json!({ + translate( + capabilities, + json!({"max_tokens": 1024, "reasoning_effort": reasoning_effort}) + ), + Ok(request(json!({ "max_tokens": 1024, - "reasoning_effort": "none", - "thinking": {"type": "adaptive"}, - "output_config": {"effort": "high"} - })), - &context(opus_4_7()), - ) - .unwrap(); - let sent = body(&result); - assert!(sent.get("thinking").is_none()); - assert!(sent.get("output_config").is_none()); - assert!(sent.get("reasoning_effort").is_none()); - } - - #[test] - fn reasoning_effort_on_non_adaptive_model_uses_a_capped_budget() { - let result = translate_thinking( - request(json!({"max_tokens": 8192, "reasoning_effort": "high"})), - &context(opus_4_5()), - ) - .unwrap(); - assert_eq!( - result.thinking, - Some(json!({"type": "enabled", "budget_tokens": 4096})) + "thinking": {"type": "adaptive", "display": "summarized"}, + "output_config": {"effort": expected_effort} + }))) ); - assert!(result.output_config.is_none()); - - let capped = translate_thinking( - request(json!({"max_tokens": 3000, "reasoning_effort": "high"})), - &context(haiku_4_5()), - ) - .unwrap(); - assert_eq!( - capped.thinking, - Some(json!({"type": "enabled", "budget_tokens": 2999})) - ); - - let dropped = translate_thinking( - request(json!({"max_tokens": 512, "reasoning_effort": "high"})), - &context(haiku_4_5()), - ) - .unwrap(); - assert!(dropped.thinking.is_none()); } #[rstest] - #[case("bogus")] - #[case("ultra")] - fn invalid_reasoning_effort_is_a_request_error(#[case] bad_effort: &str) { - let error = translate_thinking( - request(json!({"max_tokens": 1024, "reasoning_effort": bad_effort})), - &context(opus_4_5()), - ) - .expect_err("rejected"); - assert!( - matches!(error, Error::InvalidRequest(message) if message.contains("Unmapped reasoning effort")) - ); - } - - #[test] - fn reasoning_effort_unsupported_tier_is_a_request_error_on_adaptive_models() { - let error = translate_thinking( - request(json!({"max_tokens": 1024, "reasoning_effort": "xhigh"})), - &context(sonnet_4_6()), - ) - .expect_err("rejected"); - assert!( - matches!(error, Error::InvalidRequest(message) if message.contains("effort='xhigh'")) - ); - let accepted = translate_thinking( - request(json!({"max_tokens": 1024, "reasoning_effort": "max"})), - &context(sonnet_4_6()), - ) - .unwrap(); - assert_eq!(accepted.output_config, Some(json!({"effort": "max"}))); - } - - #[test] - fn explicit_thinking_and_output_config_win_over_reasoning_effort() { - let result = translate_thinking( - request(json!({ - "max_tokens": 16000, - "reasoning_effort": "low", - "thinking": {"type": "enabled", "budget_tokens": 8000}, - "output_config": {"effort": "high"} - })), - &context(sonnet_4_6()), - ) - .unwrap(); - assert_eq!( - result.thinking, - Some(json!({"type": "enabled", "budget_tokens": 8000})) - ); - assert_eq!(result.output_config, Some(json!({"effort": "high"}))); - } - - #[test] - fn legacy_thinking_is_preserved_verbatim_on_4_6() { - let result = translate_thinking( - request(json!({"max_tokens": 32000, "thinking": {"type": "enabled", "budget_tokens": 31999}})), - &context(sonnet_4_6()), - ) - .unwrap(); - assert_eq!( - result.thinking, - Some(json!({"type": "enabled", "budget_tokens": 31999})) - ); - assert!(result.output_config.is_none()); - } - - #[rstest] - #[case(1000, "low")] - #[case(2048, "medium")] - #[case(4096, "high")] - #[case(24000, "xhigh")] - fn legacy_thinking_translates_to_adaptive_buckets_on_4_7( - #[case] budget_tokens: u64, - #[case] expected_effort: &str, + #[case::adaptive_shape_is_not_dropped_for_small_max_tokens( + opus_4_7(), + json!({"max_tokens": 64, "reasoning_effort": "high"}), + json!({"max_tokens": 64, "thinking": {"type": "adaptive", "display": "summarized"}, "output_config": {"effort": "high"}}) + )] + #[case::caller_output_config_effort_wins( + opus_4_7(), + json!({"max_tokens": 1024, "reasoning_effort": "low", "output_config": {"effort": "max"}}), + json!({"max_tokens": 1024, "thinking": {"type": "adaptive", "display": "summarized"}, "output_config": {"effort": "max"}}) + )] + #[case::effort_merges_into_caller_output_config( + opus_4_7(), + json!({"max_tokens": 1024, "reasoning_effort": "high", "output_config": {"format": {"type": "json_schema"}}}), + json!({ + "max_tokens": 1024, + "thinking": {"type": "adaptive", "display": "summarized"}, + "output_config": {"format": {"type": "json_schema"}, "effort": "high"} + }) + )] + #[case::non_object_output_config_is_replaced( + opus_4_7(), + json!({"max_tokens": 1024, "reasoning_effort": "high", "output_config": "bogus"}), + json!({"max_tokens": 1024, "thinking": {"type": "adaptive", "display": "summarized"}, "output_config": {"effort": "high"}}) + )] + #[case::caller_thinking_and_output_config_win( + sonnet_4_6(), + json!({ + "max_tokens": 16000, + "reasoning_effort": "low", + "thinking": {"type": "enabled", "budget_tokens": 8000}, + "output_config": {"effort": "high"} + }), + json!({ + "max_tokens": 16000, + "thinking": {"type": "enabled", "budget_tokens": 8000}, + "output_config": {"effort": "high"} + }) + )] + #[case::caller_legacy_thinking_is_then_translated_while_reasoning_effort_level_stays( + opus_4_7(), + json!({"max_tokens": 16000, "reasoning_effort": "low", "thinking": {"type": "enabled", "budget_tokens": 8000}}), + json!({"max_tokens": 16000, "thinking": {"type": "adaptive"}, "output_config": {"effort": "low"}}) + )] + #[case::caller_disabled_thinking_is_kept_then_omitted_on_always_on_model( + fable_5_1(), + json!({"max_tokens": 1024, "reasoning_effort": "high", "thinking": {"type": "disabled"}}), + json!({"max_tokens": 1024, "output_config": {"effort": "high"}}) + )] + #[case::non_adaptive_model_gets_no_output_config( + opus_4_5(), + json!({"max_tokens": 8192, "reasoning_effort": "high"}), + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 4096}}) + )] + #[case::caller_thinking_wins_on_non_adaptive_model( + opus_4_5(), + json!({"max_tokens": 16000, "reasoning_effort": "low", "thinking": {"type": "enabled", "budget_tokens": 8000}}), + json!({"max_tokens": 16000, "thinking": {"type": "enabled", "budget_tokens": 8000}}) + )] + #[case::caller_thinking_survives_when_mapped_budget_cannot_fit( + opus_4_5(), + json!({"max_tokens": 1024, "reasoning_effort": "low", "thinking": {"type": "enabled", "budget_tokens": 8000}}), + json!({"max_tokens": 1024, "thinking": {"type": "enabled", "budget_tokens": 8000}}) + )] + #[case::missing_max_tokens_leaves_budget_uncapped( + haiku_4_5(), + json!({"reasoning_effort": "high"}), + json!({"thinking": {"type": "enabled", "budget_tokens": 4096}}) + )] + #[case::budget_below_max_tokens_is_kept( + haiku_4_5(), + json!({"max_tokens": 4097, "reasoning_effort": "high"}), + json!({"max_tokens": 4097, "thinking": {"type": "enabled", "budget_tokens": 4096}}) + )] + #[case::budget_equal_to_max_tokens_is_capped( + haiku_4_5(), + json!({"max_tokens": 4096, "reasoning_effort": "high"}), + json!({"max_tokens": 4096, "thinking": {"type": "enabled", "budget_tokens": 4095}}) + )] + #[case::budget_above_max_tokens_is_capped( + haiku_4_5(), + json!({"max_tokens": 4000, "reasoning_effort": "xhigh"}), + json!({"max_tokens": 4000, "thinking": {"type": "enabled", "budget_tokens": 3999}}) + )] + #[case::max_tokens_just_above_min_budget_caps_to_min_budget( + haiku_4_5(), + json!({"max_tokens": 1025, "reasoning_effort": "xhigh"}), + json!({"max_tokens": 1025, "thinking": {"type": "enabled", "budget_tokens": 1024}}) + )] + #[case::max_tokens_at_min_budget_drops_thinking( + haiku_4_5(), + json!({"max_tokens": 1024, "reasoning_effort": "xhigh"}), + json!({"max_tokens": 1024}) + )] + #[case::pinned_temperature_is_dropped_after_thinking_is_synthesized( + haiku_4_5(), + json!({"max_tokens": 8192, "reasoning_effort": "low", "temperature": 0}), + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 1024}}) + )] + fn reasoning_effort_is_translated( + #[case] capabilities: AnthropicModelCapabilities, + #[case] input: Value, + #[case] expected: Value, ) { - let result = translate_thinking( - request(json!({"max_tokens": 32000, "thinking": {"type": "enabled", "budget_tokens": budget_tokens}})), - &context(opus_4_7()), - ) - .unwrap(); - assert_eq!(result.thinking, Some(json!({"type": "adaptive"}))); - assert_eq!( - result.output_config, - Some(json!({"effort": expected_effort})) - ); + assert_eq!(translate(capabilities, input), Ok(request(expected))); } - #[test] - fn legacy_thinking_does_not_override_explicit_output_config() { - let result = translate_thinking( - request(json!({ - "max_tokens": 32000, - "thinking": {"type": "enabled", "budget_tokens": 31999}, - "output_config": {"effort": "low", "format": {"type": "json_schema"}} - })), - &context(opus_4_7()), - ) - .unwrap(); + #[rstest] + #[case::minimal_floors_at_min_budget("minimal", 1024)] + #[case::low("low", 1024)] + #[case::medium("medium", 2048)] + #[case::high("high", 4096)] + #[case::xhigh("xhigh", 8192)] + #[case::max("max", 16384)] + fn reasoning_effort_on_non_adaptive_model_uses_the_tier_budget( + haiku_4_5: AnthropicModelCapabilities, + #[case] reasoning_effort: &str, + #[case] expected_budget: u64, + ) { assert_eq!( - result.output_config, - Some(json!({"effort": "low", "format": {"type": "json_schema"}})) + translate( + haiku_4_5, + json!({"max_tokens": 32000, "reasoning_effort": reasoning_effort}) + ), + Ok(request(json!({ + "max_tokens": 32000, + "thinking": {"type": "enabled", "budget_tokens": expected_budget} + }))) ); } #[rstest] - #[case(fable_5_1(), true)] - #[case(opus_4_7(), false)] + #[case::adaptive_model(opus_4_7())] + #[case::effort_capable_model(opus_4_5())] + #[case::budget_model(haiku_4_5())] + fn reasoning_effort_none_clears_thinking_and_output_config( + #[case] capabilities: AnthropicModelCapabilities, + ) { + assert_eq!( + translate( + capabilities, + json!({ + "max_tokens": 1024, + "reasoning_effort": "none", + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "high"} + }) + ), + Ok(request(json!({"max_tokens": 1024}))) + ); + } + + #[rstest] + #[case::bogus_on_budget_model( + opus_4_5(), + json!({"max_tokens": 1024, "reasoning_effort": "bogus"}), + format!("Unmapped reasoning effort: 'bogus'. Must be one of: {EFFORT_CHOICES}.") + )] + #[case::disabled_on_budget_model( + haiku_4_5(), + json!({"max_tokens": 1024, "reasoning_effort": "disabled"}), + format!("Unmapped reasoning effort: 'disabled'. Must be one of: {EFFORT_CHOICES}.") + )] + #[case::empty_on_budget_model( + haiku_4_5(), + json!({"max_tokens": 1024, "reasoning_effort": ""}), + format!("Unmapped reasoning effort: ''. Must be one of: {EFFORT_CHOICES}.") + )] + #[case::invalid_on_adaptive_model( + opus_4_7(), + json!({"max_tokens": 1024, "reasoning_effort": "invalid"}), + format!("Invalid reasoning_effort: 'invalid'. Must be one of: {EFFORT_CHOICES}") + )] + #[case::disabled_on_adaptive_model( + opus_4_7(), + json!({"max_tokens": 1024, "reasoning_effort": "disabled"}), + format!("Invalid reasoning_effort: 'disabled'. Must be one of: {EFFORT_CHOICES}") + )] + #[case::empty_on_adaptive_model( + opus_4_7(), + json!({"max_tokens": 1024, "reasoning_effort": ""}), + format!("Invalid reasoning_effort: ''. Must be one of: {EFFORT_CHOICES}") + )] + #[case::xhigh_without_xhigh_tier_on_4_6( + sonnet_4_6(), + json!({"max_tokens": 1024, "reasoning_effort": "xhigh"}), + "effort='xhigh' is not supported by this model. Got model: claude".to_string() + )] + #[case::xhigh_without_xhigh_tier_on_unmapped_adaptive_model( + newfamily_6(), + json!({"max_tokens": 1024, "reasoning_effort": "xhigh"}), + "effort='xhigh' is not supported by this model. Got model: claude".to_string() + )] + #[case::unrecognized_adaptive_effort_on_budget_model( + haiku_4_5(), + claude_code_payload("turbo", 8192), + format!("Unmapped reasoning effort: 'turbo'. Must be one of: {EFFORT_CHOICES}.") + )] + fn unsupported_effort_is_a_request_error( + #[case] capabilities: AnthropicModelCapabilities, + #[case] input: Value, + #[case] expected_message: String, + ) { + assert_eq!( + translate(capabilities, input), + Err(Error::InvalidRequest(expected_message)) + ); + } + + #[rstest] + #[case::omitted_on_always_on_model(fable_5_1(), json!({"type": "disabled"}), None)] + #[case::kept_on_adaptive_model(opus_4_7(), json!({"type": "disabled"}), Some(json!({"type": "disabled"})))] + #[case::kept_on_budget_model(haiku_4_5(), json!({"type": "disabled"}), Some(json!({"type": "disabled"})))] + #[case::adaptive_kept_on_always_on_model( + fable_5_1(), + json!({"type": "adaptive"}), + Some(json!({"type": "adaptive"})) + )] fn disabled_thinking_is_omitted_only_for_always_on_models( #[case] capabilities: AnthropicModelCapabilities, - #[case] expected_dropped: bool, + #[case] thinking: Value, + #[case] expected_thinking: Option, ) { - let result = translate_thinking( - request(json!({"max_tokens": 64, "thinking": {"type": "disabled"}})), - &context(capabilities), - ) - .unwrap(); - assert_eq!(result.thinking.is_none(), expected_dropped); - } - - #[test] - fn adaptive_payload_is_translated_to_legacy_for_haiku_4_5() { - let result = translate_thinking( - request(claude_code_payload("high", 8192)), - &context(haiku_4_5()), - ) - .unwrap(); + let expected = match expected_thinking { + Some(thinking) => json!({"max_tokens": 64, "thinking": thinking}), + None => json!({"max_tokens": 64}), + }; assert_eq!( - result.thinking, - Some(json!({"type": "enabled", "budget_tokens": 4096})) + translate( + capabilities, + json!({"max_tokens": 64, "thinking": thinking}) + ), + Ok(request(expected)) ); - assert!(result.output_config.is_none()); - } - - #[test] - fn adaptive_thinking_only_is_translated_or_dropped_by_reasoning_support() { - let translated = translate_thinking( - request(json!({"max_tokens": 8192, "thinking": {"type": "adaptive"}})), - &context(haiku_4_5()), - ) - .unwrap(); - assert_eq!( - translated.thinking, - Some(json!({"type": "enabled", "budget_tokens": 2048})) - ); - - let dropped = translate_thinking( - request(claude_code_payload("medium", 8192)), - &context(AnthropicModelCapabilities::default()), - ) - .unwrap(); - assert!(dropped.thinking.is_none()); - assert!(dropped.output_config.is_none()); - } - - #[test] - fn adaptive_payload_passes_through_untouched_on_4_6() { - let result = translate_thinking( - request(claude_code_payload("medium", 8192)), - &context(sonnet_4_6()), - ) - .unwrap(); - assert_eq!(result.thinking, Some(json!({"type": "adaptive"}))); - assert_eq!(result.output_config, Some(json!({"effort": "medium"}))); - } - - #[test] - fn residual_output_config_survives_effort_translation() { - let result = translate_thinking( - request(json!({ - "max_tokens": 8192, - "thinking": {"type": "adaptive"}, - "output_config": {"effort": "medium", "format": {"type": "json_schema"}} - })), - &context(haiku_4_5()), - ) - .unwrap(); - assert_eq!( - result.output_config, - Some(json!({"format": {"type": "json_schema"}})) - ); - } - - #[test] - fn opus_4_5_keeps_supported_effort_and_drops_adaptive_thinking() { - let result = translate_thinking( - request(claude_code_payload("high", 8192)), - &context(opus_4_5()), - ) - .unwrap(); - assert!(result.thinking.is_none()); - assert_eq!(result.output_config, Some(json!({"effort": "high"}))); - } - - #[test] - fn opus_4_5_unsupported_effort_with_adaptive_thinking_falls_back_to_legacy() { - let result = translate_thinking( - request(claude_code_payload("xhigh", 64000)), - &context(opus_4_5()), - ) - .unwrap(); - assert_eq!( - result.thinking, - Some(json!({"type": "enabled", "budget_tokens": 8192})) - ); - assert!(result.output_config.is_none()); - } - - #[test] - fn opus_4_5_effort_only_request_is_left_untouched() { - let result = translate_thinking( - request(json!({"max_tokens": 4096, "output_config": {"effort": "xhigh"}})), - &context(opus_4_5()), - ) - .unwrap(); - assert!(result.thinking.is_none()); - assert_eq!(result.output_config, Some(json!({"effort": "xhigh"}))); - } - - #[test] - fn budget_is_capped_below_max_tokens_and_dropped_when_it_cannot_fit() { - let capped = translate_thinking( - request(claude_code_payload("high", 3000)), - &context(haiku_4_5()), - ) - .unwrap(); - assert_eq!( - capped.thinking, - Some(json!({"type": "enabled", "budget_tokens": 2999})) - ); - - let dropped = translate_thinking( - request(claude_code_payload("medium", 512)), - &context(haiku_4_5()), - ) - .unwrap(); - assert!(dropped.thinking.is_none()); - assert!(dropped.output_config.is_none()); } #[rstest] - #[case(haiku_4_5(), claude_code_payload("medium", 8192), 0.0, true)] - #[case(haiku_4_5(), claude_code_payload("medium", 8192), 1.0, false)] - #[case(haiku_4_5(), claude_code_payload("medium", 512), 0.0, false)] - #[case(opus_4_7(), claude_code_payload("medium", 8192), 0.0, false)] - #[case(opus_4_5(), claude_code_payload("high", 8192), 0.0, true)] - #[case(haiku_4_5(), json!({"max_tokens": 8192, "reasoning_effort": "high"}), 0.2, true)] - #[case(haiku_4_5(), json!({"max_tokens": 8192}), 0.0, false)] - fn pinned_temperature_is_dropped_only_when_thinking_survives_on_a_non_adaptive_model( + #[case::far_above_xhigh_budget(opus_4_7(), json!(16384), "xhigh")] + #[case::at_xhigh_budget(opus_4_7(), json!(8192), "xhigh")] + #[case::below_xhigh_budget(opus_4_7(), json!(8191), "high")] + #[case::xhigh_budget_without_xhigh_tier(newfamily_6(), json!(8192), "high")] + #[case::large_budget_without_xhigh_tier(newfamily_6(), json!(31999), "high")] + #[case::at_high_budget(opus_4_7(), json!(4096), "high")] + #[case::below_high_budget(opus_4_7(), json!(4095), "medium")] + #[case::at_medium_budget(opus_4_7(), json!(2048), "medium")] + #[case::below_medium_budget(opus_4_7(), json!(2047), "low")] + #[case::tiny_budget(opus_4_7(), json!(1), "low")] + #[case::missing_budget(opus_4_7(), Value::Null, "low")] + #[case::always_on_model(fable_5_1(), json!(24000), "xhigh")] + fn legacy_thinking_is_bucketed_into_adaptive_effort_on_adaptive_only_models( #[case] capabilities: AnthropicModelCapabilities, - #[case] payload: Value, - #[case] temperature: f64, - #[case] expected_dropped: bool, + #[case] budget_tokens: Value, + #[case] expected_effort: &str, ) { - let mut payload = payload; - payload - .as_object_mut() - .unwrap() - .insert("temperature".to_string(), json!(temperature)); - let result = translate_thinking(request(payload), &context(capabilities)).unwrap(); - assert_eq!(result.temperature.is_none(), expected_dropped); + let thinking = match budget_tokens { + Value::Null => json!({"type": "enabled"}), + budget_tokens => json!({"type": "enabled", "budget_tokens": budget_tokens}), + }; + assert_eq!( + translate( + capabilities, + json!({"max_tokens": 1024, "thinking": thinking}) + ), + Ok(request(json!({ + "max_tokens": 1024, + "thinking": {"type": "adaptive"}, + "output_config": {"effort": expected_effort} + }))) + ); } - #[test] - fn budgets_honor_environment_overrides() { - let env = |name: &str| { - (name == "DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET").then(|| " 6000 ".to_string()) + #[rstest] + #[case::verbatim_on_model_accepting_legacy_thinking( + sonnet_4_6(), + json!({"max_tokens": 1024, "thinking": {"type": "enabled", "budget_tokens": 31999}}), + json!({"max_tokens": 1024, "thinking": {"type": "enabled", "budget_tokens": 31999}}) + )] + #[case::verbatim_with_explicit_output_config_on_model_accepting_legacy_thinking( + sonnet_4_6(), + json!({"max_tokens": 1024, "thinking": {"type": "enabled", "budget_tokens": 31999}, "output_config": {"effort": "low"}}), + json!({"max_tokens": 1024, "thinking": {"type": "enabled", "budget_tokens": 31999}, "output_config": {"effort": "low"}}) + )] + #[case::verbatim_on_non_adaptive_model( + opus_4_5(), + json!({"max_tokens": 1024, "thinking": {"type": "enabled", "budget_tokens": 31999}}), + json!({"max_tokens": 1024, "thinking": {"type": "enabled", "budget_tokens": 31999}}) + )] + #[case::caller_output_config_effort_wins( + opus_4_7(), + json!({ + "max_tokens": 32000, + "thinking": {"type": "enabled", "budget_tokens": 31999}, + "output_config": {"effort": "low", "format": {"type": "json_schema"}} + }), + json!({ + "max_tokens": 32000, + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "low", "format": {"type": "json_schema"}} + }) + )] + #[case::effort_merges_into_caller_output_config( + opus_4_7(), + json!({ + "max_tokens": 32000, + "thinking": {"type": "enabled", "budget_tokens": 4096}, + "output_config": {"format": {"type": "json_schema"}} + }), + json!({ + "max_tokens": 32000, + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "high", "format": {"type": "json_schema"}} + }) + )] + #[case::adaptive_thinking_is_left_alone( + opus_4_7(), + json!({"max_tokens": 8192, "thinking": {"type": "adaptive", "display": "summarized"}}), + json!({"max_tokens": 8192, "thinking": {"type": "adaptive", "display": "summarized"}}) + )] + fn legacy_thinking_on_adaptive_capable_models( + #[case] capabilities: AnthropicModelCapabilities, + #[case] input: Value, + #[case] expected: Value, + ) { + assert_eq!(translate(capabilities, input), Ok(request(expected))); + } + + #[rstest] + #[case::bare_adaptive_becomes_medium_budget_on_budget_model( + haiku_4_5(), + json!({"max_tokens": 8192, "thinking": {"type": "adaptive"}}), + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 2048}}) + )] + #[case::medium_effort_becomes_medium_budget_on_budget_model( + haiku_4_5(), + claude_code_payload("medium", 8192), + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 2048}}) + )] + #[case::empty_effort_becomes_medium_budget_on_budget_model( + haiku_4_5(), + claude_code_payload("", 8192), + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 2048}}) + )] + #[case::high_effort_becomes_high_budget_on_budget_model( + haiku_4_5(), + claude_code_payload("high", 8192), + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 4096}}) + )] + #[case::effort_only_becomes_budget_on_budget_model( + haiku_4_5(), + json!({"max_tokens": 8192, "output_config": {"effort": "high"}}), + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 4096}}) + )] + #[case::effort_replaces_caller_legacy_budget_on_budget_model( + haiku_4_5(), + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 3000}, "output_config": {"effort": "high"}}), + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 4096}}) + )] + #[case::residual_output_config_survives_effort_translation( + haiku_4_5(), + json!({ + "max_tokens": 8192, + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "medium", "format": {"type": "json_schema"}} + }), + json!({ + "max_tokens": 8192, + "thinking": {"type": "enabled", "budget_tokens": 2048}, + "output_config": {"format": {"type": "json_schema"}} + }) + )] + #[case::effortless_output_config_is_kept( + haiku_4_5(), + json!({"max_tokens": 8192, "thinking": {"type": "adaptive"}, "output_config": {"format": {"type": "json_schema"}}}), + json!({ + "max_tokens": 8192, + "thinking": {"type": "enabled", "budget_tokens": 2048}, + "output_config": {"format": {"type": "json_schema"}} + }) + )] + #[case::empty_output_config_is_kept( + haiku_4_5(), + json!({"max_tokens": 8192, "thinking": {"type": "adaptive"}, "output_config": {}}), + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 2048}, "output_config": {}}) + )] + #[case::missing_max_tokens_leaves_budget_uncapped( + haiku_4_5(), + json!({"thinking": {"type": "adaptive"}}), + json!({"thinking": {"type": "enabled", "budget_tokens": 2048}}) + )] + #[case::budget_is_capped_below_max_tokens( + haiku_4_5(), + claude_code_payload("high", 3000), + json!({"max_tokens": 3000, "thinking": {"type": "enabled", "budget_tokens": 2999}}) + )] + #[case::max_tokens_just_above_min_budget_caps_to_min_budget( + haiku_4_5(), + claude_code_payload("medium", 1025), + json!({"max_tokens": 1025, "thinking": {"type": "enabled", "budget_tokens": 1024}}) + )] + #[case::max_tokens_at_min_budget_drops_thinking_and_effort( + haiku_4_5(), + claude_code_payload("medium", 1024), + json!({"max_tokens": 1024}) + )] + #[case::max_tokens_below_min_budget_drops_thinking_and_effort( + haiku_4_5(), + claude_code_payload("medium", 512), + json!({"max_tokens": 512}) + )] + #[case::bare_adaptive_is_dropped_on_non_reasoning_model( + haiku_3_5(), + json!({"max_tokens": 8192, "thinking": {"type": "adaptive"}}), + json!({"max_tokens": 8192}) + )] + #[case::adaptive_and_effort_are_dropped_on_non_reasoning_model( + haiku_3_5(), + claude_code_payload("medium", 8192), + json!({"max_tokens": 8192}) + )] + #[case::effort_only_is_dropped_on_non_reasoning_model( + haiku_3_5(), + json!({"max_tokens": 8192, "output_config": {"effort": "high", "format": {"type": "json_schema"}}}), + json!({"max_tokens": 8192, "output_config": {"format": {"type": "json_schema"}}}) + )] + #[case::supported_effort_is_kept_and_adaptive_thinking_dropped_on_effort_model( + opus_4_5(), + claude_code_payload("medium", 8192), + json!({"max_tokens": 8192, "output_config": {"effort": "medium"}}) + )] + #[case::bare_adaptive_is_dropped_on_effort_model( + opus_4_5(), + json!({"max_tokens": 8192, "thinking": {"type": "adaptive"}}), + json!({"max_tokens": 8192}) + )] + #[case::effort_only_is_left_alone_on_effort_model( + opus_4_5(), + json!({"max_tokens": 8192, "output_config": {"effort": "high"}}), + json!({"max_tokens": 8192, "output_config": {"effort": "high"}}) + )] + #[case::unsupported_effort_only_is_left_for_provider_normalization( + opus_4_5(), + json!({"max_tokens": 4096, "output_config": {"effort": "xhigh"}}), + json!({"max_tokens": 4096, "output_config": {"effort": "xhigh"}}) + )] + #[case::legacy_thinking_is_kept_beside_native_effort_on_effort_model( + opus_4_5(), + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 4096}, "output_config": {"effort": "high"}}), + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 4096}, "output_config": {"effort": "high"}}) + )] + #[case::unsupported_xhigh_with_adaptive_thinking_falls_back_to_budget( + opus_4_5(), + claude_code_payload("xhigh", 64000), + json!({"max_tokens": 64000, "thinking": {"type": "enabled", "budget_tokens": 8192}}) + )] + #[case::unsupported_max_with_adaptive_thinking_falls_back_to_budget( + opus_4_5(), + claude_code_payload("max", 64000), + json!({"max_tokens": 64000, "thinking": {"type": "enabled", "budget_tokens": 16384}}) + )] + #[case::bare_adaptive_is_native_on_4_6( + sonnet_4_6(), + json!({"max_tokens": 8192, "thinking": {"type": "adaptive"}}), + json!({"max_tokens": 8192, "thinking": {"type": "adaptive"}}) + )] + #[case::adaptive_payload_is_native_on_4_6( + sonnet_4_6(), + claude_code_payload("high", 8192), + claude_code_payload("high", 8192) + )] + #[case::request_without_adaptive_interface_is_left_alone( + haiku_4_5(), + json!({"max_tokens": 1024}), + json!({"max_tokens": 1024}) + )] + fn adaptive_interface_is_reshaped_for_non_adaptive_models( + #[case] capabilities: AnthropicModelCapabilities, + #[case] input: Value, + #[case] expected: Value, + ) { + assert_eq!(translate(capabilities, input), Ok(request(expected))); + } + + #[rstest] + #[case::adaptive_downgraded_to_enabled_thinking( + haiku_4_5(), + claude_code_payload("medium", 8192), + 0.0, + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 2048}}) + )] + #[case::bare_adaptive_downgraded_to_enabled_thinking( + haiku_4_5(), + json!({"max_tokens": 8192, "thinking": {"type": "adaptive"}}), + 0.0, + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 2048}}) + )] + #[case::reasoning_effort_synthesized_enabled_thinking( + haiku_4_5(), + json!({"max_tokens": 8192, "reasoning_effort": "high"}), + 0.2, + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 4096}}) + )] + #[case::above_one_with_enabled_thinking( + haiku_4_5(), + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 2048}}), + 1.5, + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 2048}}) + )] + #[case::native_effort_kept_on_effort_model( + opus_4_5(), + claude_code_payload("medium", 8192), + 0.0, + json!({"max_tokens": 8192, "output_config": {"effort": "medium"}}) + )] + #[case::effort_only_on_effort_model( + opus_4_5(), + json!({"max_tokens": 8192, "output_config": {"effort": "high"}}), + 0.0, + json!({"max_tokens": 8192, "output_config": {"effort": "high"}}) + )] + fn pinned_temperature_is_dropped_when_thinking_or_effort_survives_on_non_adaptive_model( + #[case] capabilities: AnthropicModelCapabilities, + #[case] input: Value, + #[case] temperature: f64, + #[case] expected: Value, + ) { + assert_eq!( + translate(capabilities, with_temperature(input, temperature)), + Ok(request(expected)) + ); + } + + #[rstest] + #[case::temperature_one_with_enabled_thinking( + haiku_4_5(), + claude_code_payload("medium", 8192), + 1.0, + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 2048}}) + )] + #[case::thinking_dropped_for_small_max_tokens( + haiku_4_5(), + claude_code_payload("medium", 512), + 0.0, + json!({"max_tokens": 512}) + )] + #[case::thinking_dropped_on_non_reasoning_model( + haiku_3_5(), + claude_code_payload("medium", 8192), + 0.0, + json!({"max_tokens": 8192}) + )] + #[case::disabled_thinking( + haiku_4_5(), + json!({"max_tokens": 8192, "thinking": {"type": "disabled"}}), + 0.0, + json!({"max_tokens": 8192, "thinking": {"type": "disabled"}}) + )] + #[case::no_thinking(haiku_4_5(), json!({"max_tokens": 8192}), 0.0, json!({"max_tokens": 8192}))] + #[case::output_config_without_effort( + haiku_4_5(), + json!({"max_tokens": 8192, "output_config": {"format": {"type": "json_schema"}}}), + 0.0, + json!({"max_tokens": 8192, "output_config": {"format": {"type": "json_schema"}}}) + )] + #[case::adaptive_model( + opus_4_7(), + claude_code_payload("medium", 8192), + 0.0, + claude_code_payload("medium", 8192) + )] + #[case::legacy_thinking_on_adaptive_model( + sonnet_4_6(), + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 4096}}), + 0.0, + json!({"max_tokens": 8192, "thinking": {"type": "enabled", "budget_tokens": 4096}}) + )] + fn temperature_is_kept( + #[case] capabilities: AnthropicModelCapabilities, + #[case] input: Value, + #[case] temperature: f64, + #[case] expected: Value, + ) { + assert_eq!( + translate(capabilities, with_temperature(input, temperature)), + Ok(request(with_temperature(expected, temperature))) + ); + } + + #[rstest] + #[case::minimal("MINIMAL", ThinkingBudgets { minimal: 5000, ..ThinkingBudgets::default() })] + #[case::low("LOW", ThinkingBudgets { low: 5000, ..ThinkingBudgets::default() })] + #[case::medium("MEDIUM", ThinkingBudgets { medium: 5000, ..ThinkingBudgets::default() })] + #[case::high("HIGH", ThinkingBudgets { high: 5000, ..ThinkingBudgets::default() })] + #[case::xhigh("XHIGH", ThinkingBudgets { xhigh: 5000, ..ThinkingBudgets::default() })] + #[case::max("MAX", ThinkingBudgets { max: 5000, ..ThinkingBudgets::default() })] + fn each_tier_budget_reads_only_its_own_environment_override( + #[case] tier: &str, + #[case] expected: ThinkingBudgets, + ) { + assert_eq!(overridden_budgets(&[(tier, "5000")]), expected); + } + + #[rstest] + #[case::whitespace_is_trimmed(" 6000 ", 6000)] + #[case::unparseable_value_keeps_default("lots", 4096)] + fn environment_override_parsing(#[case] raw: &str, #[case] expected_high: u64) { + assert_eq!( + overridden_budgets(&[("HIGH", raw)]), + ThinkingBudgets { + high: expected_high, + ..ThinkingBudgets::default() + } + ); + } + + #[rstest] + #[case::reasoning_effort_uses_overridden_budget( + &[("HIGH", "6000")], + haiku_4_5(), + json!({"max_tokens": 32000, "reasoning_effort": "high"}), + json!({"max_tokens": 32000, "thinking": {"type": "enabled", "budget_tokens": 6000}}) + )] + #[case::minimal_override_below_min_budget_is_floored( + &[("MINIMAL", "512")], + haiku_4_5(), + json!({"max_tokens": 32000, "reasoning_effort": "minimal"}), + json!({"max_tokens": 32000, "thinking": {"type": "enabled", "budget_tokens": 1024}}) + )] + #[case::minimal_override_above_min_budget_is_used( + &[("MINIMAL", "2000")], + haiku_4_5(), + json!({"max_tokens": 32000, "reasoning_effort": "minimal"}), + json!({"max_tokens": 32000, "thinking": {"type": "enabled", "budget_tokens": 2000}}) + )] + #[case::adaptive_fallback_uses_overridden_medium_budget( + &[("MEDIUM", "3000")], + haiku_4_5(), + json!({"max_tokens": 32000, "thinking": {"type": "adaptive"}}), + json!({"max_tokens": 32000, "thinking": {"type": "enabled", "budget_tokens": 3000}}) + )] + #[case::legacy_bucket_below_overridden_high_budget( + &[("HIGH", "6000")], + opus_4_7(), + json!({"max_tokens": 32000, "thinking": {"type": "enabled", "budget_tokens": 5999}}), + json!({"max_tokens": 32000, "thinking": {"type": "adaptive"}, "output_config": {"effort": "medium"}}) + )] + #[case::legacy_bucket_at_overridden_high_budget( + &[("HIGH", "6000")], + opus_4_7(), + json!({"max_tokens": 32000, "thinking": {"type": "enabled", "budget_tokens": 6000}}), + json!({"max_tokens": 32000, "thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}}) + )] + #[case::legacy_bucket_below_overridden_xhigh_budget( + &[("XHIGH", "20000")], + opus_4_7(), + json!({"max_tokens": 32000, "thinking": {"type": "enabled", "budget_tokens": 19999}}), + json!({"max_tokens": 32000, "thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}}) + )] + #[case::legacy_bucket_at_overridden_medium_budget( + &[("MEDIUM", "3000")], + opus_4_7(), + json!({"max_tokens": 32000, "thinking": {"type": "enabled", "budget_tokens": 3000}}), + json!({"max_tokens": 32000, "thinking": {"type": "adaptive"}, "output_config": {"effort": "medium"}}) + )] + #[case::legacy_bucket_below_overridden_medium_budget( + &[("MEDIUM", "3000")], + opus_4_7(), + json!({"max_tokens": 32000, "thinking": {"type": "enabled", "budget_tokens": 2999}}), + json!({"max_tokens": 32000, "thinking": {"type": "adaptive"}, "output_config": {"effort": "low"}}) + )] + fn translation_honors_budget_overrides( + #[case] overrides: &[(&str, &str)], + #[case] capabilities: AnthropicModelCapabilities, + #[case] input: Value, + #[case] expected: Value, + ) { + let context = ThinkingContext { + capabilities, + budgets: overridden_budgets(overrides), }; - let budgets = ThinkingBudgets::from_lookup(&env); - assert_eq!(budgets.high, 6000); - assert_eq!(budgets.medium, ThinkingBudgets::default().medium); + assert_eq!( + translate_thinking(request(input), &context), + Ok(request(expected)) + ); } } diff --git a/litellm-rust/crates/llms/src/anthropic/experimental_pass_through/messages/transformation.rs b/litellm-rust/crates/llms/src/anthropic/experimental_pass_through/messages/transformation.rs index 212e89a8119..d7fb2fc40b6 100644 --- a/litellm-rust/crates/llms/src/anthropic/experimental_pass_through/messages/transformation.rs +++ b/litellm-rust/crates/llms/src/anthropic/experimental_pass_through/messages/transformation.rs @@ -30,8 +30,6 @@ pub struct AnthropicMessagesConfig; pub const ANTHROPIC_MESSAGES_CONFIG: AnthropicMessagesConfig = AnthropicMessagesConfig; impl MessagesTransformContext { - /// The context for a model whose capability flags the host resolved, with the thinking - /// budgets read from the process environment. pub fn new(capabilities: AnthropicModelCapabilities, drop_params: bool) -> Self { Self::with_lookup(capabilities, drop_params, &ProcessEnvironment) } @@ -112,21 +110,19 @@ impl BaseAnthropicMessagesConfig for AnthropicMessagesConfig { } } -fn unsupported_param(model: &str, param: &str, value: &Value, hint: &str) -> Error { +fn unsupported_param(model: &str, param: &str, value: &str, hint: &str) -> Error { Error::InvalidRequest(format!( "{model} does not support {param}={value}. {hint}To drop unsupported params, set `litellm.drop_params = True`." )) } -/// Python's `_maybe_drop_speed_param` and `_apply_sampling_param` gates: a parameter the -/// model removed is dropped under `drop_params`, else rejected before the call. fn drop_unsupported_params( request: AnthropicMessagesRequest, context: &MessagesTransformContext, ) -> Result { let capabilities = &context.thinking.capabilities; let model = request.model.clone(); - let reject = |param: &str, value: Value, hint: &str| -> Result<(), Error> { + let reject = |param: &str, value: String, hint: &str| -> Result<(), Error> { if context.drop_params { return Ok(()); } @@ -134,7 +130,7 @@ fn drop_unsupported_params( }; let speed = match request.speed.as_deref() { Some(speed) if !capabilities.supports_speed => { - reject("speed", json!(speed), "")?; + reject("speed", format!("'{speed}'"), "")?; None } _ => request.speed.clone(), @@ -146,7 +142,7 @@ fn drop_unsupported_params( Some(temperature) if temperature != 1.0 => { reject( "temperature", - json!(temperature), + json!(temperature).to_string(), "Only temperature=1 is supported. ", )?; None @@ -154,10 +150,10 @@ fn drop_unsupported_params( temperature => temperature, }; if let Some(top_p) = request.top_p { - reject("top_p", json!(top_p), "")?; + reject("top_p", json!(top_p).to_string(), "")?; } if let Some(top_k) = request.top_k { - reject("top_k", json!(top_k), "")?; + reject("top_k", json!(top_k).to_string(), "")?; } Ok(AnthropicMessagesRequest { speed, @@ -168,9 +164,6 @@ fn drop_unsupported_params( }) } -/// OpenAI's `[{"type": "compaction", "compact_threshold": N}]` list becomes Anthropic's -/// `{"edits": [{"type": "compact_20260112", "trigger": {...}}]}`. An Anthropic-shaped value -/// is returned as is; anything else yields `None`. pub fn map_openai_context_management_to_anthropic(context_management: &Value) -> Option { match context_management { Value::Object(edits) if edits.contains_key("edits") => Some(context_management.clone()), @@ -232,8 +225,6 @@ pub fn complete_anthropic_url( format!("{api_base}{MESSAGES_PATH_SUFFIX}") } -/// `api_base`, else `ANTHROPIC_API_BASE`, else the SDK's `ANTHROPIC_BASE_URL`, else the -/// public endpoint. pub fn resolve_anthropic_api_base( api_base: Option<&str>, env_lookup: &dyn Fn(&str) -> Option, @@ -248,27 +239,68 @@ pub fn resolve_anthropic_api_base( #[cfg(test)] mod tests { - use rstest::rstest; + use std::process::Command; + + use rstest::{fixture, rstest}; use super::*; + use crate::anthropic::common_utils::{ENCRYPTED_REASONING_SIGNATURE_PREFIX, beta}; - fn request(fields: Value) -> AnthropicMessagesRequest { - let mut body = json!({ - "model": "claude", - "max_tokens": 1024, - "messages": [{"role": "user", "content": "Hello"}] - }); - body.as_object_mut() - .unwrap() - .extend(fields.as_object().unwrap().clone()); - serde_json::from_value(body).unwrap() + type Env = &'static [(&'static str, &'static str)]; + + const BOTH_BASE_ENVS: Env = &[ + (ANTHROPIC_API_BASE_ENV, "https://api-base.example.com"), + (ANTHROPIC_BASE_URL_ENV, "https://base-url.example.com"), + ]; + const API_KEY_ENV: Env = &[(ANTHROPIC_API_KEY_ENV, "sk-env")]; + const MISSING_API_KEY: &str = + "Missing Anthropic API Key - Set `api_key` or the ANTHROPIC_API_KEY environment variable"; + const LOW_BUDGET_ENV: &str = "DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET"; + const PROCESS_ENV_PROBE: &str = "LITELLM_MESSAGES_TRANSFORM_CONTEXT_PROBE"; + + fn merged(base: Value, fields: Value) -> Value { + Value::Object( + base.as_object() + .unwrap() + .clone() + .into_iter() + .chain(fields.as_object().unwrap().clone()) + .collect(), + ) } - fn context( - capabilities: AnthropicModelCapabilities, - drop_params: bool, - ) -> MessagesTransformContext { - MessagesTransformContext::with_lookup(capabilities, drop_params, &|_: &str| None) + fn body(fields: Value) -> Value { + merged( + json!({ + "model": "claude", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }), + fields, + ) + } + + fn request(fields: Value) -> AnthropicMessagesRequest { + serde_json::from_value(body(fields)).unwrap() + } + + fn no_env(_: &str) -> Option { + None + } + + fn env(vars: Env) -> impl Fn(&str) -> Option { + move |name| { + vars.iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| value.to_string()) + } + } + + fn headers(pairs: &[(&str, &str)]) -> Headers { + pairs + .iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect() } fn transform( @@ -279,65 +311,537 @@ mod tests { ANTHROPIC_MESSAGES_CONFIG .transform_anthropic_messages_request( request(fields), - &context(capabilities, drop_params), + &MessagesTransformContext::with_lookup(capabilities, drop_params, &no_env), ) .map(|transformed| serde_json::to_value(transformed).unwrap()) } - #[test] - fn url_defaults_to_public_anthropic_endpoint() { - assert_eq!( - complete_anthropic_url(None, &|_| None), - "https://api.anthropic.com/v1/messages" - ); + fn invalid(message: &str) -> Result { + Err(Error::InvalidRequest(message.to_string())) } - #[test] - fn url_appends_messages_suffix_to_custom_base() { - assert_eq!( - complete_anthropic_url(Some("https://proxy.internal"), &|_| None), - "https://proxy.internal/v1/messages" - ); + fn advisor_history() -> Value { + json!([ + {"role": "user", "content": "Build a worker pool."}, + {"role": "assistant", "content": [ + {"type": "text", "text": "Let me consult the advisor."}, + {"type": "server_tool_use", "id": "srvtoolu_abc123", "name": "advisor", "input": {}}, + {"type": "advisor_tool_result", "tool_use_id": "srvtoolu_abc123", "content": {"type": "advisor_result", "text": "Use channels."}}, + {"type": "text", "text": "Here is the implementation."} + ]} + ]) } - #[test] - fn url_leaves_complete_messages_endpoint_untouched() { + #[fixture] + fn unmapped() -> AnthropicModelCapabilities { + AnthropicModelCapabilities::default() + } + + #[fixture] + fn sampling_removed() -> AnthropicModelCapabilities { + AnthropicModelCapabilities { + supports_sampling_params: false, + ..Default::default() + } + } + + #[fixture] + fn fast_mode() -> AnthropicModelCapabilities { + AnthropicModelCapabilities { + supports_speed: true, + ..Default::default() + } + } + + #[rstest] + #[case::alone(json!({"max_tokens": null}))] + #[case::ahead_of_the_param_gate(json!({"max_tokens": null, "speed": "fast"}))] + fn missing_max_tokens_is_rejected(#[case] fields: Value, unmapped: AnthropicModelCapabilities) { assert_eq!( - complete_anthropic_url(Some("https://proxy.internal/v1/messages"), &|_| None), - "https://proxy.internal/v1/messages" + transform(fields, unmapped, false), + invalid("max_tokens is required for Anthropic /v1/messages API") ); } #[rstest] - #[case(ANTHROPIC_API_BASE_ENV)] - #[case(ANTHROPIC_BASE_URL_ENV)] - fn url_falls_back_to_either_env_base(#[case] variable: &str) { - let with_env = |key: &str| (key == variable).then(|| "https://env.anthropic".to_string()); + #[case::sampling_params_on_a_sampling_model( + unmapped(), + false, + json!({"temperature": 0.3, "top_p": 0.9, "top_k": 40}) + )] + #[case::sampling_params_on_a_sampling_model_under_drop_params( + unmapped(), + true, + json!({"temperature": 0.3, "top_p": 0.9, "top_k": 40}) + )] + #[case::unit_temperature_on_a_sampling_removed_model( + sampling_removed(), + false, + json!({"temperature": 1.0}) + )] + #[case::unit_temperature_on_a_sampling_removed_model_under_drop_params( + sampling_removed(), + true, + json!({"temperature": 1.0}) + )] + #[case::speed_on_a_fast_mode_model(fast_mode(), false, json!({"speed": "fast"}))] + #[case::speed_on_a_fast_mode_model_under_drop_params(fast_mode(), true, json!({"speed": "fast"}))] + #[case::native_context_management_edits(unmapped(), false, json!({"context_management": {"edits": [{ + "type": "clear_tool_uses_20250919", + "trigger": {"type": "input_tokens", "value": 30000}, + "keep": {"type": "tool_uses", "value": 3}, + "clear_at_least": {"type": "input_tokens", "value": 5000}, + "exclude_tools": ["web_search"], + "clear_tool_inputs": false + }]}}))] + #[case::first_party_billing_header_system_block(unmapped(), false, json!({"system": [ + {"type": "text", "text": "x-anthropic-billing-header: cc_version=1"}, + {"type": "text", "text": "real system prompt"} + ]}))] + #[case::anthropic_signed_reasoning_history(unmapped(), false, json!({"messages": [ + {"role": "user", "content": "Solve it."}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "plan", "signature": "EqQBCkYIAxgCIkA_anthropic_signed"}, + {"type": "redacted_thinking", "data": "EmwKAhgBEgy_anthropic_minted"}, + {"type": "text", "text": "The answer."} + ]} + ]}))] + #[case::advisor_history_alongside_the_advisor_tool(unmapped(), false, json!({ + "messages": advisor_history(), + "tools": [{"type": "advisor_20260301", "name": "advisor"}] + }))] + fn request_is_forwarded_unchanged( + #[case] capabilities: AnthropicModelCapabilities, + #[case] drop_params: bool, + #[case] fields: Value, + ) { assert_eq!( - complete_anthropic_url(Some(" "), &with_env), - "https://env.anthropic/v1/messages" + transform(fields.clone(), capabilities, drop_params), + Ok(body(fields)) + ); + } + + #[rstest] + #[case::temperature(sampling_removed(), json!({"temperature": 0.3}), json!({}))] + #[case::top_p(sampling_removed(), json!({"top_p": 0.9}), json!({}))] + #[case::top_k(sampling_removed(), json!({"top_k": 40}), json!({}))] + #[case::every_sampling_param_keeping_the_rest( + sampling_removed(), + json!({"temperature": 0.3, "top_p": 0.9, "top_k": 40, "stream": true}), + json!({"stream": true}) + )] + #[case::speed_on_a_sampling_model( + unmapped(), + json!({"speed": "fast", "temperature": 0.5}), + json!({"temperature": 0.5}) + )] + #[case::speed_on_a_sampling_removed_model( + sampling_removed(), + json!({"speed": "fast", "temperature": 1.0}), + json!({"temperature": 1.0}) + )] + fn removed_params_are_dropped_under_drop_params( + #[case] capabilities: AnthropicModelCapabilities, + #[case] fields: Value, + #[case] expected: Value, + ) { + assert_eq!(transform(fields, capabilities, true), Ok(body(expected))); + } + + #[rstest] + #[case::temperature( + sampling_removed(), + json!({"temperature": 0.3}), + "claude does not support temperature=0.3. Only temperature=1 is supported. To drop unsupported params, set `litellm.drop_params = True`." + )] + #[case::temperature_just_below_one( + sampling_removed(), + json!({"temperature": 0.99}), + "claude does not support temperature=0.99. Only temperature=1 is supported. To drop unsupported params, set `litellm.drop_params = True`." + )] + #[case::whole_number_temperature_keeps_its_decimal( + sampling_removed(), + json!({"temperature": 2.0}), + "claude does not support temperature=2.0. Only temperature=1 is supported. To drop unsupported params, set `litellm.drop_params = True`." + )] + #[case::top_p( + sampling_removed(), + json!({"top_p": 0.9}), + "claude does not support top_p=0.9. To drop unsupported params, set `litellm.drop_params = True`." + )] + #[case::top_k( + sampling_removed(), + json!({"top_k": 5}), + "claude does not support top_k=5. To drop unsupported params, set `litellm.drop_params = True`." + )] + #[case::top_k_next_to_unit_temperature( + sampling_removed(), + json!({"temperature": 1.0, "top_k": 5}), + "claude does not support top_k=5. To drop unsupported params, set `litellm.drop_params = True`." + )] + #[case::temperature_ahead_of_top_k( + sampling_removed(), + json!({"temperature": 0.5, "top_k": 5}), + "claude does not support temperature=0.5. Only temperature=1 is supported. To drop unsupported params, set `litellm.drop_params = True`." + )] + #[case::top_p_ahead_of_top_k( + sampling_removed(), + json!({"top_p": 0.9, "top_k": 5}), + "claude does not support top_p=0.9. To drop unsupported params, set `litellm.drop_params = True`." + )] + #[case::speed( + unmapped(), + json!({"speed": "fast"}), + "claude does not support speed='fast'. To drop unsupported params, set `litellm.drop_params = True`." + )] + #[case::speed_ahead_of_sampling_params( + sampling_removed(), + json!({"speed": "fast", "temperature": 0.5}), + "claude does not support speed='fast'. To drop unsupported params, set `litellm.drop_params = True`." + )] + fn removed_params_are_rejected_without_drop_params( + #[case] capabilities: AnthropicModelCapabilities, + #[case] fields: Value, + #[case] message: &str, + ) { + assert_eq!(transform(fields, capabilities, false), invalid(message)); + } + + #[rstest] + #[case::compaction_threshold( + json!([{"type": "compaction", "compact_threshold": 200000}]), + Some(json!({"edits": [{"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 200000}}]})) + )] + #[case::other_keys_pass_through( + json!([{"type": "compaction", "compact_threshold": 150000, "instructions": "Focus on preserving code snippets"}]), + Some(json!({"edits": [{ + "type": "compact_20260112", + "trigger": {"type": "input_tokens", "value": 150000}, + "instructions": "Focus on preserving code snippets" + }]})) + )] + #[case::float_threshold_is_truncated( + json!([{"type": "compaction", "compact_threshold": 150000.9}]), + Some(json!({"edits": [{"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 150000}}]})) + )] + #[case::compaction_without_threshold( + json!([{"type": "compaction"}]), + Some(json!({"edits": [{"type": "compact_20260112"}]})) + )] + #[case::non_numeric_threshold_is_dropped( + json!([{"type": "compaction", "compact_threshold": "150000"}]), + Some(json!({"edits": [{"type": "compact_20260112"}]})) + )] + #[case::non_object_entries_are_skipped( + json!([42, "compaction", null, [], {"type": "compaction", "compact_threshold": 1000}]), + Some(json!({"edits": [{"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 1000}}]})) + )] + #[case::only_compaction_entries_are_mapped_in_order( + json!([ + {"type": "compaction", "compact_threshold": 1000}, + {"type": "other", "compact_threshold": 5}, + {"type": "compaction", "instructions": "second"} + ]), + Some(json!({"edits": [ + {"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 1000}}, + {"type": "compact_20260112", "instructions": "second"} + ]})) + )] + #[case::list_without_compaction(json!([{"type": "other"}]), None)] + #[case::empty_list(json!([]), None)] + #[case::anthropic_edits_pass_through( + json!({"edits": [{"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 150000}}]}), + Some(json!({"edits": [{"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 150000}}]})) + )] + #[case::object_without_edits(json!({"type": "compaction"}), None)] + #[case::scalar(json!("compaction"), None)] + fn openai_context_management_maps_to_anthropic_edits( + #[case] context_management: Value, + #[case] expected: Option, + ) { + assert_eq!( + map_openai_context_management_to_anthropic(&context_management), + expected + ); + } + + #[rstest] + #[case::openai_list_is_mapped( + json!([{"type": "compaction", "compact_threshold": 200000}]), + json!({"edits": [{"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 200000}}]}) + )] + #[case::unmappable_list_is_kept(json!([{"type": "other"}]), json!([{"type": "other"}]))] + #[case::unmappable_object_is_kept(json!({"type": "other"}), json!({"type": "other"}))] + fn context_management_reaches_the_wire( + #[case] context_management: Value, + #[case] expected: Value, + unmapped: AnthropicModelCapabilities, + ) { + assert_eq!( + transform( + json!({"context_management": context_management}), + unmapped, + false + ), + Ok(body(json!({"context_management": expected}))) + ); + } + + #[rstest] + #[case::without_tools(json!({}))] + #[case::with_only_other_tools(json!({"tools": [{"name": "get_weather", "input_schema": {"type": "object"}}]}))] + fn advisor_history_is_stripped_without_the_advisor_tool( + #[case] tools: Value, + unmapped: AnthropicModelCapabilities, + ) { + let stripped = json!([ + {"role": "user", "content": "Build a worker pool."}, + {"role": "assistant", "content": [ + {"type": "text", "text": "Let me consult the advisor."}, + {"type": "text", "text": "Here is the implementation."} + ]} + ]); + assert_eq!( + transform( + merged(tools.clone(), json!({"messages": advisor_history()})), + unmapped, + false + ), + Ok(body(merged(tools, json!({"messages": stripped})))) + ); + } + + #[rstest] + fn bridge_minted_reasoning_is_stripped_from_the_wire(unmapped: AnthropicModelCapabilities) { + let messages = json!([ + {"role": "user", "content": "Solve it."}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "plan", "signature": format!("{ENCRYPTED_REASONING_SIGNATURE_PREFIX}gAAAA_1")}, + {"type": "redacted_thinking", "data": format!("{ENCRYPTED_REASONING_SIGNATURE_PREFIX}gAAAA_2")}, + {"type": "text", "text": "The answer."} + ]}, + {"role": "user", "content": "And the next one?"} + ]); + assert_eq!( + transform(json!({"messages": messages}), unmapped, false), + Ok(body(json!({"messages": [ + {"role": "user", "content": "Solve it."}, + {"role": "assistant", "content": [{"type": "text", "text": "The answer."}]}, + {"role": "user", "content": "And the next one?"} + ]}))) ); } #[test] - fn api_key_prefers_param_then_env_then_errors() { - assert_eq!( - resolve_anthropic_api_key(Some("sk-param"), &|_| None).unwrap(), - "sk-param" + fn thinking_is_translated_with_the_context_budgets() { + let context = MessagesTransformContext::with_lookup( + AnthropicModelCapabilities { + supports_reasoning: true, + ..Default::default() + }, + false, + &env(&[(LOW_BUDGET_ENV, "2000")]), ); - let with_env = |key: &str| (key == ANTHROPIC_API_KEY_ENV).then(|| "sk-env".to_string()); + let transformed = ANTHROPIC_MESSAGES_CONFIG + .transform_anthropic_messages_request( + request(json!({"max_tokens": 4096, "reasoning_effort": "low"})), + &context, + ) + .map(|transformed| serde_json::to_value(transformed).unwrap()); assert_eq!( - resolve_anthropic_api_key(Some(" "), &with_env).unwrap(), - "sk-env" + transformed, + Ok(body(json!({ + "max_tokens": 4096, + "thinking": {"type": "enabled", "budget_tokens": 2000} + }))) ); + } + + #[test] + fn new_reads_thinking_budgets_from_the_process_environment() { + if std::env::var_os(PROCESS_ENV_PROBE).is_some() { + assert_eq!( + MessagesTransformContext::new(sampling_removed(), true), + MessagesTransformContext { + thinking: ThinkingContext { + capabilities: sampling_removed(), + budgets: ThinkingBudgets { + low: 2000, + ..ThinkingBudgets::default() + }, + }, + drop_params: true, + } + ); + return; + } + let (_, test_path) = concat!( + module_path!(), + "::new_reads_thinking_budgets_from_the_process_environment" + ) + .split_once("::") + .unwrap(); + let other_tiers = ["MINIMAL", "MEDIUM", "HIGH", "XHIGH", "MAX"] + .map(|tier| format!("DEFAULT_REASONING_EFFORT_{tier}_THINKING_BUDGET")); + let output = other_tiers + .iter() + .fold( + Command::new(std::env::current_exe().unwrap()), + |mut command, name| { + command.env_remove(name); + command + }, + ) + .args([test_path, "--exact"]) + .env(PROCESS_ENV_PROBE, "1") + .env(LOW_BUDGET_ENV, "2000") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("1 passed"), + "{stdout}{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[rstest] + #[case::public_endpoint_by_default(None, &[], "https://api.anthropic.com")] + #[case::explicit_api_base_beats_env( + Some("https://explicit.example.com"), + BOTH_BASE_ENVS, + "https://explicit.example.com" + )] + #[case::explicit_api_base_is_trimmed( + Some(" https://explicit.example.com "), + &[], + "https://explicit.example.com" + )] + #[case::blank_api_base_falls_back_to_env( + Some(" "), + BOTH_BASE_ENVS, + "https://api-base.example.com" + )] + #[case::api_base_env_beats_base_url_env(None, BOTH_BASE_ENVS, "https://api-base.example.com")] + #[case::base_url_env_without_api_base_env( + None, + &[(ANTHROPIC_BASE_URL_ENV, "https://base-url.example.com")], + "https://base-url.example.com" + )] + #[case::blank_api_base_env_falls_back_to_base_url_env( + None, + &[(ANTHROPIC_API_BASE_ENV, " \t "), (ANTHROPIC_BASE_URL_ENV, "https://base-url.example.com")], + "https://base-url.example.com" + )] + #[case::blank_envs_fall_back_to_public_endpoint( + None, + &[(ANTHROPIC_API_BASE_ENV, ""), (ANTHROPIC_BASE_URL_ENV, " ")], + "https://api.anthropic.com" + )] + fn api_base_resolution( + #[case] api_base: Option<&str>, + #[case] vars: Env, + #[case] expected: &str, + ) { + assert_eq!(resolve_anthropic_api_base(api_base, &env(vars)), expected); + } + + #[rstest] + #[case::public_endpoint(None, &[], "https://api.anthropic.com/v1/messages")] + #[case::base_url_env( + None, + &[(ANTHROPIC_BASE_URL_ENV, "https://custom.example.com")], + "https://custom.example.com/v1/messages" + )] + #[case::custom_base(Some("https://proxy.internal"), &[], "https://proxy.internal/v1/messages")] + #[case::trailing_slash(Some("https://proxy.internal/"), &[], "https://proxy.internal/v1/messages")] + #[case::complete_endpoint( + Some("https://proxy.internal/v1/messages"), + &[], + "https://proxy.internal/v1/messages" + )] + #[case::complete_endpoint_with_trailing_slash( + Some("https://proxy.internal/v1/messages/"), + &[], + "https://proxy.internal/v1/messages" + )] + fn complete_url_ends_in_the_messages_path( + #[case] api_base: Option<&str>, + #[case] vars: Env, + #[case] expected: &str, + ) { assert_eq!( - resolve_anthropic_api_key(None, &|_| None) - .expect_err("missing key") - .to_string(), - "Missing Anthropic API Key - Set `api_key` or the ANTHROPIC_API_KEY environment variable" + ANTHROPIC_MESSAGES_CONFIG.get_complete_url(api_base, "claude", &env(vars)), + Ok(expected.to_string()) ); } + #[rstest] + #[case::param_beats_env(Some("sk-param"), API_KEY_ENV, Ok("sk-param"))] + #[case::param_is_trimmed(Some(" sk-param "), &[], Ok("sk-param"))] + #[case::blank_param_falls_back_to_env(Some(" "), API_KEY_ENV, Ok("sk-env"))] + #[case::env_without_param(None, API_KEY_ENV, Ok("sk-env"))] + #[case::blank_env_is_missing(None, &[(ANTHROPIC_API_KEY_ENV, " ")], Err(MISSING_API_KEY))] + #[case::nothing_is_missing(None, &[], Err(MISSING_API_KEY))] + fn api_key_resolution( + #[case] api_key: Option<&str>, + #[case] vars: Env, + #[case] expected: Result<&str, &str>, + ) { + assert_eq!( + resolve_anthropic_api_key(api_key, &env(vars)).map_err(|error| error.to_string()), + expected.map(str::to_string).map_err(str::to_string) + ); + } + + #[test] + fn config_reports_a_missing_key_as_an_auth_error() { + assert_eq!( + ANTHROPIC_MESSAGES_CONFIG.resolve_api_key(None, &no_env), + Err(Error::Auth(litellm_auth::Error::MissingApiKey { + provider: "Anthropic", + environment_variable: ANTHROPIC_API_KEY_ENV, + })) + ); + } + + #[test] + fn config_authenticates_with_the_anthropic_auth_token() { + assert_eq!( + ANTHROPIC_MESSAGES_CONFIG.authenticate( + vec![], + None, + &env(&[("ANTHROPIC_AUTH_TOKEN", "auth-token")]) + ), + Ok(headers(&[("authorization", "Bearer auth-token")])) + ); + } + + #[test] + fn config_requests_the_betas_the_request_features_need() { + assert_eq!( + ANTHROPIC_MESSAGES_CONFIG.request_headers( + headers(&[("x-api-key", "sk")]), + &request(json!({"speed": "fast"})) + ), + headers(&[ + ("x-api-key", "sk"), + ("anthropic-beta", beta::FAST_MODE_2026_02_01) + ]) + ); + } + + #[rstest] + #[case::absent(None, None)] + #[case::blank(Some(" \t "), None)] + #[case::padded(Some(" value "), Some("value"))] + fn non_empty_trims_and_drops_blank_values( + #[case] value: Option<&str>, + #[case] expected: Option<&str>, + ) { + assert_eq!(non_empty(value), expected); + } + #[test] fn auth_strategy_and_default_headers_match_anthropic() { assert_eq!( @@ -352,161 +856,4 @@ mod tests { ] ); } - - #[test] - fn max_tokens_is_required() { - let mut body = request(json!({})); - body.max_tokens = None; - let error = ANTHROPIC_MESSAGES_CONFIG - .transform_anthropic_messages_request( - body, - &context(AnthropicModelCapabilities::default(), false), - ) - .expect_err("rejected"); - assert!( - matches!(error, Error::InvalidRequest(message) if message.contains("max_tokens is required")) - ); - } - - #[test] - fn advisor_history_is_stripped_only_when_the_advisor_tool_is_absent() { - let history = json!([ - {"role": "user", "content": "q"}, - {"role": "assistant", "content": [ - {"type": "server_tool_use", "id": "adv", "name": "advisor", "input": {}}, - {"type": "advisor_tool_result", "tool_use_id": "adv", "content": "x"}, - {"type": "text", "text": "a"} - ]} - ]); - let stripped = transform( - json!({"messages": history}), - AnthropicModelCapabilities::default(), - false, - ) - .unwrap(); - assert_eq!( - stripped["messages"][1]["content"], - json!([{"type": "text", "text": "a"}]) - ); - let kept = transform( - json!({"messages": history, "tools": [{"type": "advisor_20260301", "name": "advisor"}]}), - AnthropicModelCapabilities::default(), - false, - ) - .unwrap(); - assert_eq!(kept["messages"][1]["content"].as_array().unwrap().len(), 3); - } - - #[test] - fn encrypted_reasoning_blocks_are_stripped_from_the_wire() { - let sent = transform( - json!({"messages": [ - {"role": "user", "content": "q"}, - {"role": "assistant", "content": [ - {"type": "thinking", "thinking": "t", "signature": "litellm_encrypted_reasoning:abc"}, - {"type": "text", "text": "a"} - ]} - ]}), - AnthropicModelCapabilities::default(), - false, - ) - .unwrap(); - assert_eq!( - sent["messages"][1]["content"], - json!([{"type": "text", "text": "a"}]) - ); - } - - #[test] - fn openai_context_management_list_becomes_anthropic_edits() { - let sent = transform( - json!({"context_management": [ - {"type": "compaction", "compact_threshold": 150000, "note": "keep"}, - {"type": "other"} - ]}), - AnthropicModelCapabilities::default(), - false, - ) - .unwrap(); - assert_eq!( - sent["context_management"], - json!({"edits": [{"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 150000}, "note": "keep"}]}) - ); - let native = json!({"edits": [{"type": "clear_tool_uses_20250919"}]}); - let untouched = transform( - json!({"context_management": native}), - AnthropicModelCapabilities::default(), - false, - ) - .unwrap(); - assert_eq!(untouched["context_management"], native); - assert_eq!( - map_openai_context_management_to_anthropic(&json!([{"type": "other"}])), - None - ); - } - - fn no_sampling() -> AnthropicModelCapabilities { - AnthropicModelCapabilities { - supports_sampling_params: false, - ..Default::default() - } - } - - #[test] - fn removed_sampling_params_are_dropped_under_drop_params_and_rejected_otherwise() { - let dropped = transform( - json!({"temperature": 0.2, "top_p": 0.9, "top_k": 5}), - no_sampling(), - true, - ) - .unwrap(); - for param in ["temperature", "top_p", "top_k"] { - assert!(dropped.get(param).is_none(), "{param} should be dropped"); - } - let kept_unit_temperature = - transform(json!({"temperature": 1.0}), no_sampling(), false).unwrap(); - assert_eq!(kept_unit_temperature["temperature"], json!(1.0)); - let rejected = transform(json!({"top_k": 5}), no_sampling(), false).expect_err("rejected"); - assert!( - matches!(rejected, Error::InvalidRequest(message) if message.contains("does not support top_k=5")) - ); - let supported = transform( - json!({"temperature": 0.2, "top_p": 0.9, "top_k": 5}), - AnthropicModelCapabilities::default(), - false, - ) - .unwrap(); - assert_eq!(supported["top_k"], json!(5)); - } - - #[test] - fn speed_is_gated_on_the_model_flag() { - let dropped = transform( - json!({"speed": "fast"}), - AnthropicModelCapabilities::default(), - true, - ) - .unwrap(); - assert!(dropped.get("speed").is_none()); - let rejected = transform( - json!({"speed": "fast"}), - AnthropicModelCapabilities::default(), - false, - ) - .expect_err("rejected"); - assert!( - matches!(rejected, Error::InvalidRequest(message) if message.contains("does not support speed=")) - ); - let kept = transform( - json!({"speed": "fast"}), - AnthropicModelCapabilities { - supports_speed: true, - ..Default::default() - }, - false, - ) - .unwrap(); - assert_eq!(kept["speed"], json!("fast")); - } } diff --git a/litellm-rust/crates/llms/src/base_llm/anthropic_messages/transformation.rs b/litellm-rust/crates/llms/src/base_llm/anthropic_messages/transformation.rs index 0d8f6b42193..55becdd27e4 100644 --- a/litellm-rust/crates/llms/src/base_llm/anthropic_messages/transformation.rs +++ b/litellm-rust/crates/llms/src/base_llm/anthropic_messages/transformation.rs @@ -25,8 +25,6 @@ impl MessagesAuthStrategy { } } -/// What a provider transformation knows about the call beyond the request body: the -/// model's capability flags and the caller's `drop_params` choice. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct MessagesTransformContext { pub thinking: ThinkingContext, @@ -71,8 +69,6 @@ pub trait BaseAnthropicMessagesConfig: Sync { false } - /// The forwarded headers with the provider credential applied. A request that already - /// carries the provider's auth header (or a bearer the provider accepts) is left alone. fn authenticate( &self, headers: Headers, @@ -102,8 +98,188 @@ pub trait BaseAnthropicMessagesConfig: Sync { ] } - /// Headers the transformed request's features call for, such as `anthropic-beta`. fn request_headers(&self, headers: Headers, _request: &AnthropicMessagesRequest) -> Headers { headers } } + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + const X_API_KEY: MessagesAuthStrategy = MessagesAuthStrategy::Header("x-api-key"); + + struct StubConfig { + strategy: MessagesAuthStrategy, + accepts_bearer: bool, + } + + impl BaseAnthropicMessagesConfig for StubConfig { + fn get_complete_url( + &self, + _api_base: Option<&str>, + _model: &str, + _env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + Ok(String::new()) + } + + fn resolve_api_key( + &self, + api_key: Option<&str>, + _env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + api_key + .map(str::to_string) + .ok_or(Error::MissingField("api_key")) + } + + fn auth_strategy(&self) -> MessagesAuthStrategy { + self.strategy + } + + fn accepts_bearer_auth(&self) -> bool { + self.accepts_bearer + } + } + + struct DefaultsConfig; + + impl BaseAnthropicMessagesConfig for DefaultsConfig { + fn get_complete_url( + &self, + _api_base: Option<&str>, + _model: &str, + _env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + Ok(String::new()) + } + + fn resolve_api_key( + &self, + api_key: Option<&str>, + _env_lookup: &dyn Fn(&str) -> Option, + ) -> Result { + api_key + .map(str::to_string) + .ok_or(Error::MissingField("api_key")) + } + } + + #[test] + fn default_config_adds_its_key_next_to_a_forwarded_bearer() { + assert_eq!( + DefaultsConfig.authenticate( + headers(&[("authorization", "Bearer forwarded")]), + Some("sk"), + &|_| None + ), + Ok(headers(&[ + ("authorization", "Bearer forwarded"), + ("x-api-key", "sk") + ])) + ); + } + + #[test] + fn default_request_headers_are_the_given_headers() { + let request: AnthropicMessagesRequest = serde_json::from_value(serde_json::json!({ + "model": "claude", + "max_tokens": 16, + "speed": "fast", + "messages": [{"role": "user", "content": "hi"}] + })) + .unwrap(); + assert_eq!( + DefaultsConfig.request_headers(headers(&[("x-api-key", "sk")]), &request), + headers(&[("x-api-key", "sk")]) + ); + } + + fn headers(pairs: &[(&str, &str)]) -> Headers { + pairs + .iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect() + } + + #[rstest] + #[case::own_header_is_kept( + X_API_KEY, + false, + headers(&[("x-api-key", "forwarded")]), + None, + Ok(headers(&[("x-api-key", "forwarded")])) + )] + #[case::own_header_in_any_casing_is_kept( + X_API_KEY, + false, + headers(&[("X-Api-Key", "forwarded")]), + None, + Ok(headers(&[("X-Api-Key", "forwarded")])) + )] + #[case::accepted_bearer_is_kept( + X_API_KEY, + true, + headers(&[("authorization", "Bearer forwarded")]), + None, + Ok(headers(&[("authorization", "Bearer forwarded")])) + )] + #[case::bearer_the_provider_does_not_accept_gets_the_key_too( + X_API_KEY, + false, + headers(&[("authorization", "Bearer forwarded")]), + Some("sk"), + Ok(headers(&[("authorization", "Bearer forwarded"), ("x-api-key", "sk")])) + )] + #[case::blank_bearer_gets_the_key( + X_API_KEY, + true, + headers(&[("authorization", "Bearer ")]), + Some("sk"), + Ok(headers(&[("authorization", "Bearer "), ("x-api-key", "sk")])) + )] + #[case::key_goes_in_the_provider_header( + X_API_KEY, + false, + headers(&[("content-type", "application/json")]), + Some("sk"), + Ok(headers(&[("content-type", "application/json"), ("x-api-key", "sk")])) + )] + #[case::key_goes_in_a_bearer( + MessagesAuthStrategy::Bearer, + false, + headers(&[]), + Some("sk"), + Ok(headers(&[("authorization", "Bearer sk")])) + )] + #[case::bearer_strategy_keeps_a_forwarded_authorization( + MessagesAuthStrategy::Bearer, + false, + headers(&[("authorization", "Bearer forwarded")]), + None, + Ok(headers(&[("authorization", "Bearer forwarded")])) + )] + #[case::missing_key_is_an_error( + X_API_KEY, + false, + headers(&[]), + None, + Err(Error::MissingField("api_key")) + )] + fn default_authenticate_applies_the_key_unless_a_credential_is_forwarded( + #[case] strategy: MessagesAuthStrategy, + #[case] accepts_bearer: bool, + #[case] forwarded: Headers, + #[case] api_key: Option<&str>, + #[case] expected: Result, + ) { + let config = StubConfig { + strategy, + accepts_bearer, + }; + assert_eq!(config.authenticate(forwarded, api_key, &|_| None), expected); + } +} diff --git a/litellm-rust/crates/python-bridge/src/routes/messages/host.rs b/litellm-rust/crates/python-bridge/src/routes/messages/host.rs index 63b8216f81f..51122b4e0f6 100644 --- a/litellm-rust/crates/python-bridge/src/routes/messages/host.rs +++ b/litellm-rust/crates/python-bridge/src/routes/messages/host.rs @@ -21,12 +21,8 @@ use crate::{ }; const ROUTE_HOST_MODULE: &str = "litellm.rust_bridge.messages.route_host"; -/// Set on a request rejected before the provider was called, so the Python host maps it to -/// the public 400 rather than a connection failure. const REQUEST_ERROR_MARKER: &str = "messages_request_error"; -/// The Anthropic Messages body fields a caller may pass besides `model` and `messages`, -/// as `AnthropicMessagesRequestOptionalParams` declares them. const BODY_FIELDS: [&str; 22] = [ "max_tokens", "metadata", @@ -52,7 +48,6 @@ const BODY_FIELDS: [&str; 22] = [ "safeguards", ]; -/// One `provider_specific_header` entry: headers scoped to a comma separated provider list. #[derive(Deserialize)] struct ProviderSpecificHeader { #[serde(default)] @@ -86,6 +81,38 @@ impl ProviderSpecificHeaders { } } +fn merge_headers( + forwarded: Option>, + extra_headers: Option>, + scoped: impl IntoIterator, +) -> Option> { + let merged: Map = forwarded + .into_iter() + .flatten() + .chain(extra_headers.into_iter().flatten()) + .chain(scoped) + .collect(); + (!merged.is_empty()).then_some(merged) +} + +fn native_error(py: Python<'_>, error: Error) -> PyResult { + match error { + Error::Transport(TransportError::Http { status, body }) => { + let error = RustUpstreamError::new_err((status, body)); + error + .value(py) + .setattr("headers", Vec::<(String, String)>::new())?; + Ok(error) + } + Error::InvalidRequest(message) => { + let error = PyValueError::new_err(message); + error.value(py).setattr(REQUEST_ERROR_MARKER, true)?; + Ok(error) + } + other => Ok(messages_error_to_pyerr(other)), + } +} + /// The Python side of the Messages route: projects the prepared arguments and builds the /// public response, chunks and exceptions. pub(super) struct MessagesRouteHost { @@ -141,8 +168,6 @@ impl MessagesRouteHost { }) } - /// Python's handler merges the forwarded `headers`, `extra_headers` and the - /// `provider_specific_header` entries scoped to this provider, in that order. fn merged_headers( &self, py: Python<'_>, @@ -162,13 +187,11 @@ impl MessagesRouteHost { .transpose()? .into_iter() .flat_map(|headers| headers.matching(&provider)); - let merged: Map = mapping("headers")? - .into_iter() - .flatten() - .chain(mapping("extra_headers")?.into_iter().flatten()) - .chain(scoped) - .collect(); - Ok((!merged.is_empty()).then_some(merged)) + Ok(merge_headers( + mapping("headers")?, + mapping("extra_headers")?, + scoped, + )) } fn shaping( @@ -250,22 +273,7 @@ impl RouteHost for MessagesRouteHost { } fn classify(&self, py: Python<'_>, error: Error) -> PyResult { - let native = match error { - Error::Transport(TransportError::Http { status, body }) => { - let error = RustUpstreamError::new_err((status, body)); - error - .value(py) - .setattr("headers", Vec::<(String, String)>::new())?; - error - } - Error::InvalidRequest(message) => { - let error = PyValueError::new_err(message); - error.value(py).setattr(REQUEST_ERROR_MARKER, true)?; - error - } - other => messages_error_to_pyerr(other), - }; - Ok(self.map_failure(py, native)) + Ok(self.map_failure(py, native_error(py, error)?)) } fn host_error(error: &PyErr) -> Error { @@ -278,3 +286,132 @@ impl RouteHost for MessagesRouteHost { visit.call(&self.request) } } + +#[cfg(test)] +mod tests { + use rstest::rstest; + use serde_json::json; + + use super::*; + + fn map(value: Value) -> Map { + serde_json::from_value(value).unwrap() + } + + #[rstest] + #[case::single_entry_for_the_provider( + json!({"custom_llm_provider": "anthropic", "extra_headers": {"Authorization": "Bearer t", "Custom-Header": "v"}}), + json!({"Authorization": "Bearer t", "Custom-Header": "v"}), + )] + #[case::single_entry_for_another_provider( + json!({"custom_llm_provider": "openai", "extra_headers": {"Authorization": "Bearer t"}}), + json!({}), + )] + #[case::provider_in_a_comma_separated_scope( + json!({"custom_llm_provider": "bedrock,anthropic,vertex_ai", "extra_headers": {"anthropic-beta": "context-1m-2025-08-07"}}), + json!({"anthropic-beta": "context-1m-2025-08-07"}), + )] + #[case::provider_missing_from_a_comma_separated_scope( + json!({"custom_llm_provider": "bedrock,vertex_ai", "extra_headers": {"anthropic-beta": "test"}}), + json!({}), + )] + #[case::scope_with_spaces( + json!({"custom_llm_provider": "bedrock, anthropic , vertex_ai", "extra_headers": {"anthropic-beta": "test"}}), + json!({"anthropic-beta": "test"}), + )] + #[case::scope_names_must_match_exactly( + json!({"custom_llm_provider": "anthropic_text", "extra_headers": {"anthropic-beta": "test"}}), + json!({}), + )] + #[case::entries_scope_independently( + json!([ + {"custom_llm_provider": "anthropic,bedrock,vertex_ai", "extra_headers": {"anthropic-beta": "context-1m-2025-08-07"}}, + {"custom_llm_provider": "bedrock", "extra_headers": {"x-bedrock-only": "no"}}, + {"custom_llm_provider": "anthropic", "extra_headers": {"authorization": "Bearer sk-ant-oat01-fake-token"}} + ]), + json!({"anthropic-beta": "context-1m-2025-08-07", "authorization": "Bearer sk-ant-oat01-fake-token"}), + )] + #[case::later_entries_win( + json!([ + {"custom_llm_provider": "anthropic", "extra_headers": {"x-scoped": "first"}}, + {"custom_llm_provider": "anthropic", "extra_headers": {"x-scoped": "second"}} + ]), + json!({"x-scoped": "second"}), + )] + #[case::empty_list(json!([]), json!({}))] + #[case::entry_without_scope(json!({"extra_headers": {"x-scoped": "yes"}}), json!({}))] + #[case::entry_without_headers(json!({"custom_llm_provider": "anthropic"}), json!({}))] + fn provider_specific_headers_match_the_scoped_provider( + #[case] configured: Value, + #[case] expected: Value, + ) { + let headers: ProviderSpecificHeaders = serde_json::from_value(configured).unwrap(); + assert_eq!( + headers + .matching("anthropic") + .collect::>(), + map(expected) + ); + } + + #[rstest] + #[case::scoped_over_extra_over_forwarded( + Some(json!({"X-Priority": "forwarded", "X-Forwarded-Only": "keep"})), + Some(json!({"X-Priority": "extra", "X-Extra-Only": "also-keep"})), + json!({"X-Priority": "provider", "X-Provider-Only": "keep-this-too"}), + Some(json!({ + "X-Priority": "provider", + "X-Forwarded-Only": "keep", + "X-Extra-Only": "also-keep", + "X-Provider-Only": "keep-this-too" + })), + )] + #[case::extra_over_forwarded( + Some(json!({"X-Priority": "forwarded"})), + Some(json!({"X-Priority": "extra"})), + json!({}), + Some(json!({"X-Priority": "extra"})), + )] + #[case::only_extra_headers( + None, + Some(json!({"X-Custom-Header": "from-kwargs", "X-Auth-Token": "token123"})), + json!({}), + Some(json!({"X-Custom-Header": "from-kwargs", "X-Auth-Token": "token123"})), + )] + #[case::only_scoped(None, None, json!({"x-scoped": "yes"}), Some(json!({"x-scoped": "yes"})))] + #[case::nothing(None, Some(json!({})), json!({}), None)] + fn headers_merge_forwarded_then_extra_then_scoped( + #[case] forwarded: Option, + #[case] extra_headers: Option, + #[case] scoped: Value, + #[case] expected: Option, + ) { + assert_eq!( + merge_headers(forwarded.map(map), extra_headers.map(map), map(scoped)), + expected.map(map) + ); + } + + #[rstest] + #[case::rejected_request(Error::InvalidRequest("does not support top_k=5".into()), true)] + #[case::unresolvable_provider(Error::InvalidProvider("openai".into()), false)] + #[case::upstream_failure( + Error::Transport(TransportError::Http { status: 400, body: "bad".into() }), + false, + )] + fn only_request_rejections_carry_the_request_error_marker( + #[case] error: Error, + #[case] marked: bool, + ) { + Python::initialize(); + Python::attach(|py| { + let native = native_error(py, error).unwrap(); + let marker = native + .value(py) + .getattr_opt(REQUEST_ERROR_MARKER) + .unwrap() + .map(|value| value.extract::().unwrap()); + assert_eq!(marker.unwrap_or(false), marked); + }); + } +} diff --git a/litellm-rust/crates/types/src/llms/anthropic_messages/anthropic_request.rs b/litellm-rust/crates/types/src/llms/anthropic_messages/anthropic_request.rs index 6e1c1637e00..2f7a75ba517 100644 --- a/litellm-rust/crates/types/src/llms/anthropic_messages/anthropic_request.rs +++ b/litellm-rust/crates/types/src/llms/anthropic_messages/anthropic_request.rs @@ -15,8 +15,6 @@ pub enum MessageContent { Blocks(Vec), } -/// One content block of a message or system prompt. The fields the route reads are typed; -/// everything else rides in `extra` untouched. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ContentBlock { #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] @@ -146,3 +144,103 @@ impl AnthropicMessage { } } } + +#[cfg(test)] +mod tests { + use rstest::rstest; + use serde_json::json; + + use super::*; + + fn round_trip(value: &Value) -> Value { + let parsed: T = serde_json::from_value(value.clone()).unwrap(); + serde_json::to_value(parsed).unwrap() + } + + #[rstest] + #[case::text(json!({"type": "text", "text": "hi"}))] + #[case::text_with_citations_and_cache_control(json!({ + "type": "text", + "text": "hi", + "citations": [{"type": "char_location", "cited_text": "x"}], + "cache_control": {"type": "ephemeral", "ttl": "1h", "scope": "global", "future": 1} + }))] + #[case::image(json!({"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AA=="}}))] + #[case::thinking(json!({"type": "thinking", "thinking": "hmm", "signature": "sig"}))] + #[case::redacted_thinking(json!({"type": "redacted_thinking", "data": "opaque"}))] + #[case::tool_use(json!({"type": "tool_use", "id": "toolu_1", "name": "f", "input": {"q": [1, null]}}))] + #[case::tool_result_with_text(json!({"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok", "is_error": false}))] + #[case::tool_result_with_blocks(json!({"type": "tool_result", "tool_use_id": "toolu_1", "content": [{"type": "text", "text": "ok"}]}))] + #[case::web_search_result_with_nulls(json!({ + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_1", + "content": [{"type": "web_search_result", "url": "u", "page_age": null, "encrypted_content": ""}] + }))] + #[case::provider_specific_fields(json!({"type": "tool_use", "id": "t", "name": "f", "input": {}, "provider_specific_fields": {"x": 1}}))] + #[case::untyped(json!({"unknown": {"nested": true}}))] + fn content_block_round_trips_unchanged(#[case] block: Value) { + assert_eq!(round_trip::(&block), block); + } + + #[test] + fn text_constructor_serializes_as_a_text_block() { + assert_eq!( + serde_json::to_value(ContentBlock::text("hello")).unwrap(), + json!({"type": "text", "text": "hello"}) + ); + } + + #[rstest] + #[case::same_type(json!({"type": "tool_use"}), "tool_use", true)] + #[case::other_type(json!({"type": "tool_result"}), "tool_use", false)] + #[case::prefix_of_type(json!({"type": "tool_use"}), "tool", false)] + #[case::no_type(json!({"text": "x"}), "text", false)] + fn is_type_matches_the_exact_block_type( + #[case] block: Value, + #[case] block_type: &str, + #[case] expected: bool, + ) { + let block: ContentBlock = serde_json::from_value(block).unwrap(); + assert_eq!(block.is_type(block_type), expected); + } + + #[rstest] + #[case::string_content(json!({"role": "user", "content": "hi"}), vec![])] + #[case::block_content( + json!({"role": "user", "content": [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}]}), + vec![ContentBlock::text("a"), ContentBlock::text("b")], + )] + fn message_blocks_list_only_block_content( + #[case] message: Value, + #[case] expected: Vec, + ) { + let message: AnthropicMessage = serde_json::from_value(message).unwrap(); + assert_eq!(message.blocks(), expected.as_slice()); + } + + #[rstest] + #[case::replaces_string_content(json!({"role": "assistant", "content": "old", "name": "kept"}))] + #[case::replaces_block_content(json!({"role": "assistant", "content": [{"type": "text", "text": "old"}], "name": "kept"}))] + fn with_blocks_replaces_content_and_keeps_the_rest(#[case] message: Value) { + let message: AnthropicMessage = serde_json::from_value(message).unwrap(); + assert_eq!( + serde_json::to_value(message.with_blocks(vec![ContentBlock::text("new")])).unwrap(), + json!({"role": "assistant", "content": [{"type": "text", "text": "new"}], "name": "kept"}) + ); + } + + #[rstest] + #[case::minimal(json!({"model": "m", "messages": [{"role": "user", "content": "hi"}]}))] + #[case::reasoning_effort_compaction_and_unknown_fields(json!({ + "model": "m", + "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}], + "max_tokens": 8, + "reasoning_effort": "high", + "compaction": {"type": "auto"}, + "safeguards": [{"type": "dangerous_tool_use", "classifier_context": {"v": 1}}], + "metadata": {"user_id": "u"} + }))] + fn request_round_trips_unchanged(#[case] request: Value) { + assert_eq!(round_trip::(&request), request); + } +} diff --git a/litellm-rust/crates/types/src/llms/anthropic_messages/anthropic_response.rs b/litellm-rust/crates/types/src/llms/anthropic_messages/anthropic_response.rs index 7be8ad5c3a6..0a2653f352f 100644 --- a/litellm-rust/crates/types/src/llms/anthropic_messages/anthropic_response.rs +++ b/litellm-rust/crates/types/src/llms/anthropic_messages/anthropic_response.rs @@ -21,10 +21,11 @@ pub struct AnthropicMessagesResponse { #[cfg(test)] mod tests { - use super::*; use rstest::rstest; use serde_json::json; + use super::*; + fn response( stop_reason: Option<&str>, stop_sequence: Option<&str>, diff --git a/litellm/rust_bridge/messages/route_host.py b/litellm/rust_bridge/messages/route_host.py index cdc867078b5..41f9494bbdb 100644 --- a/litellm/rust_bridge/messages/route_host.py +++ b/litellm/rust_bridge/messages/route_host.py @@ -38,9 +38,6 @@ class ModelCapabilities: @dataclass(frozen=True, slots=True) class MessagesShaping: - """What the native Messages route needs from the Python side to shape a request the way - the Python handler does: the model's cost-map flags and the caller's LiteLLM settings.""" - capabilities: ModelCapabilities drop_params: bool reasoning_auto_summary: bool @@ -77,8 +74,6 @@ def _resolved_provider(model: str, custom_llm_provider: str | None) -> tuple[str def model_capabilities(model: str, custom_llm_provider: str | None) -> ModelCapabilities: - """The flags Python's Anthropic transforms read from the cost map, resolved under the - caller's provider exactly as `AnthropicModelInfo._supports_model_capability` does.""" from litellm.llms.anthropic.chat.transformation import AnthropicConfig from litellm.llms.anthropic.common_utils import AnthropicModelInfo diff --git a/tests/test_litellm_rust/messages/test_request_shaping.py b/tests/test_litellm_rust/messages/test_request_shaping.py index 8ad62d02d5c..ed967eaf3c9 100644 --- a/tests/test_litellm_rust/messages/test_request_shaping.py +++ b/tests/test_litellm_rust/messages/test_request_shaping.py @@ -202,3 +202,36 @@ async def test_provider_specific_headers_scoped_to_anthropic_reach_the_wire(mess _, headers = sent(messages_server) assert headers["x-scoped"] == "yes" assert "x-other" not in headers + + +@pytest.mark.asyncio +async def test_scoped_headers_override_extra_headers_which_override_forwarded_headers( + messages_server: RecordingServer, +) -> None: + await litellm.anthropic.messages.acreate( + **arguments( + messages_server, + headers={"x-priority": "forwarded", "x-forwarded-only": "kept"}, + extra_headers={"x-priority": "extra", "x-extra-only": "kept"}, + provider_specific_header={"custom_llm_provider": "anthropic", "extra_headers": {"x-priority": "scoped"}}, + ) + ) + + _, headers = sent(messages_server) + assert {name: headers.get(name) for name in ("x-priority", "x-forwarded-only", "x-extra-only")} == { + "x-priority": "scoped", + "x-forwarded-only": "kept", + "x-extra-only": "kept", + } + + +@pytest.mark.asyncio +async def test_non_string_metadata_user_id_is_rejected_before_the_provider_call( + messages_server: RecordingServer, +) -> None: + messages_server.expected_requests = 0 + + with pytest.raises(litellm.BadRequestError, match="metadata.user_id must be a string"): + await litellm.anthropic.messages.acreate(**arguments(messages_server, metadata={"user_id": 123})) + + assert messages_server.requests == []