From e9d6610000565ed92cdf5d4ee0caab8311ee6a5e Mon Sep 17 00:00:00 2001 From: narmstrong-berri Date: Fri, 6 Nov 2026 07:26:21 +0000 Subject: [PATCH] fix(messages): keep an explicit null cache_control on native content blocks like python Python forwards message content blocks as sent. The native request type decoded cache_control: null to None and dropped it when re-encoding, so the provider got a different body. An explicit null now survives the round trip while transforms still see no cache control. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013FDwtkaNTifWAP4W7JZavE --- .../src/formats/messages/request.rs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/litellm-rust/crates/llms-types/src/formats/messages/request.rs b/litellm-rust/crates/llms-types/src/formats/messages/request.rs index d30d21e74e7..450a1c4a6c2 100644 --- a/litellm-rust/crates/llms-types/src/formats/messages/request.rs +++ b/litellm-rust/crates/llms-types/src/formats/messages/request.rs @@ -67,6 +67,7 @@ impl From for String { #[macro_rules_attribute::apply(crate::wire_type)] #[derive(Default)] +#[serde(from = "ContentBlockWire")] pub struct ContentBlock { #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] pub block_type: Option, @@ -96,6 +97,62 @@ pub struct ContentBlock { pub extra: Map, } +#[derive(serde::Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +struct ContentBlockWire { + #[serde(rename = "type", default)] + block_type: Option, + #[serde(default)] + text: Option, + #[serde(default)] + thinking: Option, + #[serde(default)] + signature: Option, + #[serde(default)] + data: Option, + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + tool_use_id: Option, + #[serde(default)] + input: Option, + #[serde(default)] + content: Option, + #[serde(default)] + provider_specific_fields: Option, + #[serde(default, with = "serde_with::rust::double_option")] + #[cfg_attr(feature = "schema", schemars(with = "Option"))] + cache_control: Option>, + #[serde(flatten)] + extra: Map, +} + +impl From for ContentBlock { + fn from(wire: ContentBlockWire) -> Self { + let mut extra = wire.extra; + if wire.cache_control == Some(None) { + extra.insert("cache_control".to_owned(), Value::Null); + } + Self { + block_type: wire.block_type, + text: wire.text, + thinking: wire.thinking, + signature: wire.signature, + data: wire.data, + id: wire.id, + name: wire.name, + tool_use_id: wire.tool_use_id, + input: wire.input, + content: wire.content, + provider_specific_fields: wire.provider_specific_fields, + cache_control: wire.cache_control.flatten(), + extra, + } + } +} + impl ContentBlock { pub fn text(text: impl Into) -> Self { Self { @@ -430,6 +487,9 @@ mod tests { }))] #[case::provider_specific_fields(json!({"type": "tool_use", "id": "t", "name": "f", "input": {}, "provider_specific_fields": {"x": 1}}))] #[case::untyped(json!({"unknown": {"nested": true}}))] + #[case::text_with_null_citations_and_cache_control(json!({"type": "text", "text": "hi", "citations": null, "cache_control": null}))] + #[case::tool_use_with_null_cache_control(json!({"type": "tool_use", "id": "t", "name": "f", "input": {}, "cache_control": null}))] + #[case::tool_result_with_null_is_error_and_cache_control(json!({"type": "tool_result", "tool_use_id": "t", "content": "ok", "is_error": null, "cache_control": null}))] fn content_block_round_trips_unchanged(#[case] block: Value) { assert_eq!(round_trip::(&block), block); } @@ -461,6 +521,17 @@ mod tests { assert_eq!(serde_json::to_value(request).unwrap(), body); } + #[test] + fn null_cache_control_is_absent_to_transforms_and_kept_on_the_wire() { + let block: ContentBlock = + serde_json::from_value(json!({"type": "text", "text": "hi", "cache_control": null})) + .unwrap(); + assert_eq!( + (block.cache_control, block.extra.get("cache_control")), + (None, Some(&Value::Null)) + ); + } + #[test] fn text_constructor_serializes_as_a_text_block() { assert_eq!(