@@ -340,7 +340,9 @@ fn build_messages_with_caching(
340340 // Phase 4: Sanitize all messages to enforce Anthropic API constraints.
341341 // This is the single boundary where we fix structural issues that would
342342 // cause 400 errors, regardless of which upstream phase introduced them.
343- sanitize_anthropic_messages ( & mut merged) ;
343+ for msg in & mut merged {
344+ sanitize_anthropic_message ( msg) ;
345+ }
344346
345347 Ok ( merged)
346348}
@@ -391,81 +393,35 @@ fn is_empty_content_message(msg: &AnthropicMessage) -> bool {
391393 }
392394}
393395
394- /// Sanitize Anthropic messages to enforce API constraints.
396+ /// Sanitize an Anthropic message to enforce API constraints.
395397///
396- /// This is the **single boundary** that fixes structural issues before
397- /// messages are sent to the API. All Anthropic-specific content invariants
398+ /// This is the **single boundary** that fixes structural issues before the
399+ /// message is sent to the API. All Anthropic-specific content invariants
398400/// are enforced here, rather than scattering guards across conversion,
399401/// merging, and caching phases. Inspired by Vercel AI SDK's approach of
400402/// handling structural concerns in one pass at the output boundary.
401403///
402- /// Per-message rules:
404+ /// Current rules:
403405/// - Strip `cache_control` from empty text blocks
404406/// (Anthropic `invalid_request_error`: "cache_control cannot be set for empty text blocks")
405- ///
406- /// Array-level rules:
407- /// - Trim trailing whitespace from the last assistant message's last text block
408- /// (Anthropic `invalid_request_error`: rejects trailing whitespace in pre-filled
409- /// assistant responses, i.e. when the conversation ends with an assistant message)
410- fn sanitize_anthropic_messages ( messages : & mut [ AnthropicMessage ] ) {
411- // Per-message sanitization
412- for msg in messages. iter_mut ( ) {
413- sanitize_message_blocks ( msg) ;
414- }
415-
416- // Array-level: trim trailing whitespace on the last message if it's an assistant message.
417- // Anthropic treats a trailing assistant message as a "pre-filled" response and rejects
418- // trailing whitespace in that position.
419- if let Some ( last) = messages. last_mut ( )
420- && last. role == "assistant"
421- {
422- trim_trailing_assistant_text ( last) ;
423- }
424- }
425-
426- /// Strip `cache_control` from empty text blocks within a single message.
427- fn sanitize_message_blocks ( msg : & mut AnthropicMessage ) {
428- if let AnthropicMessageContent :: Blocks ( blocks) = & mut msg. content {
429- for block in blocks. iter_mut ( ) {
430- if let AnthropicContent :: Text {
431- text,
432- cache_control,
433- } = block
434- && text. is_empty ( )
435- && cache_control. is_some ( )
436- {
437- * cache_control = None ;
438- }
439- }
440- }
441- }
442-
443- /// Trim trailing whitespace from the last text block of an assistant message.
444- ///
445- /// Anthropic does not allow trailing whitespace in pre-filled assistant responses
446- /// (conversations ending with an assistant message). Only the last text block in
447- /// the last assistant message needs trimming — intermediate messages are unaffected.
448- fn trim_trailing_assistant_text ( msg : & mut AnthropicMessage ) {
407+ fn sanitize_anthropic_message ( msg : & mut AnthropicMessage ) {
449408 match & mut msg. content {
450- AnthropicMessageContent :: String ( s) => {
451- let trimmed = s. trim_end ( ) ;
452- if trimmed. len ( ) != s. len ( ) {
453- * s = trimmed. to_string ( ) ;
454- }
455- }
456409 AnthropicMessageContent :: Blocks ( blocks) => {
457- // Find the last text block and trim it
458- if let Some ( AnthropicContent :: Text { text , .. } ) = blocks
459- . iter_mut ( )
460- . rev ( )
461- . find ( |b| matches ! ( b , AnthropicContent :: Text { .. } ) )
462- {
463- let trimmed = text . trim_end ( ) ;
464- if trimmed . len ( ) != text . len ( ) {
465- * text = trimmed . to_string ( ) ;
410+ for block in blocks . iter_mut ( ) {
411+ if let AnthropicContent :: Text {
412+ text ,
413+ cache_control ,
414+ } = block
415+ && text . is_empty ( )
416+ && cache_control . is_some ( )
417+ {
418+ * cache_control = None ;
466419 }
467420 }
468421 }
422+ AnthropicMessageContent :: String ( _) => {
423+ // String content has no cache_control field; nothing to sanitize.
424+ }
469425 }
470426}
471427
@@ -1439,19 +1395,19 @@ mod tests {
14391395 }
14401396 }
14411397
1442- // --- sanitize_anthropic_messages tests ---
1398+ // --- sanitize_anthropic_message tests ---
14431399
14441400 #[ test]
14451401 fn test_sanitize_strips_cache_control_from_empty_text_blocks ( ) {
14461402 let cc = AnthropicCacheControl :: ephemeral ( ) ;
14471403
14481404 // Empty text block with cache_control should have it stripped
1449- let mut msgs = vec ! [ user_blocks_msg( vec![ AnthropicContent :: Text {
1405+ let mut msg = user_blocks_msg ( vec ! [ AnthropicContent :: Text {
14501406 text: String :: new( ) ,
14511407 cache_control: Some ( cc. clone( ) ) ,
1452- } ] ) ] ;
1453- sanitize_anthropic_messages ( & mut msgs ) ;
1454- match & msgs [ 0 ] . content {
1408+ } ] ) ;
1409+ sanitize_anthropic_message ( & mut msg ) ;
1410+ match & msg . content {
14551411 AnthropicMessageContent :: Blocks ( blocks) => {
14561412 assert_eq ! ( blocks. len( ) , 1 ) ;
14571413 match & blocks[ 0 ] {
@@ -1476,12 +1432,12 @@ mod tests {
14761432 fn test_sanitize_preserves_cache_control_on_non_empty_text ( ) {
14771433 let cc = AnthropicCacheControl :: ephemeral ( ) ;
14781434
1479- let mut msgs = vec ! [ user_blocks_msg( vec![ AnthropicContent :: Text {
1435+ let mut msg = user_blocks_msg ( vec ! [ AnthropicContent :: Text {
14801436 text: "hello" . to_string( ) ,
14811437 cache_control: Some ( cc. clone( ) ) ,
1482- } ] ) ] ;
1483- sanitize_anthropic_messages ( & mut msgs ) ;
1484- match & msgs [ 0 ] . content {
1438+ } ] ) ;
1439+ sanitize_anthropic_message ( & mut msg ) ;
1440+ match & msg . content {
14851441 AnthropicMessageContent :: Blocks ( blocks) => match & blocks[ 0 ] {
14861442 AnthropicContent :: Text { cache_control, .. } => {
14871443 assert ! (
@@ -1500,7 +1456,7 @@ mod tests {
15001456 let cc = AnthropicCacheControl :: ephemeral ( ) ;
15011457
15021458 // Mix of empty text (with cache), non-empty text (with cache), and tool_result
1503- let mut msgs = vec ! [ user_blocks_msg( vec![
1459+ let mut msg = user_blocks_msg ( vec ! [
15041460 AnthropicContent :: Text {
15051461 text: String :: new( ) ,
15061462 cache_control: Some ( cc. clone( ) ) ,
@@ -1515,9 +1471,9 @@ mod tests {
15151471 is_error: None ,
15161472 cache_control: Some ( cc. clone( ) ) ,
15171473 } ,
1518- ] ) ] ;
1519- sanitize_anthropic_messages ( & mut msgs ) ;
1520- match & msgs [ 0 ] . content {
1474+ ] ) ;
1475+ sanitize_anthropic_message ( & mut msg ) ;
1476+ match & msg . content {
15211477 AnthropicMessageContent :: Blocks ( blocks) => {
15221478 assert_eq ! ( blocks. len( ) , 3 ) ;
15231479 // Empty text: cache_control stripped
@@ -1549,119 +1505,14 @@ mod tests {
15491505 #[ test]
15501506 fn test_sanitize_noop_on_string_content ( ) {
15511507 // String content has no cache_control field — sanitize should be a no-op
1552- let mut msgs = vec ! [ user_msg( "hello" ) ] ;
1553- sanitize_anthropic_messages ( & mut msgs ) ;
1554- match & msgs [ 0 ] . content {
1508+ let mut msg = user_msg ( "hello" ) ;
1509+ sanitize_anthropic_message ( & mut msg ) ;
1510+ match & msg . content {
15551511 AnthropicMessageContent :: String ( s) => assert_eq ! ( s, "hello" ) ,
15561512 _ => panic ! ( "Expected String content" ) ,
15571513 }
15581514 }
15591515
1560- // --- trim_trailing_assistant_text tests ---
1561-
1562- #[ test]
1563- fn test_sanitize_trims_trailing_whitespace_on_last_assistant_string ( ) {
1564- let mut msgs = vec ! [ user_msg( "hello" ) , assistant_msg( "response \n " ) ] ;
1565- sanitize_anthropic_messages ( & mut msgs) ;
1566- match & msgs[ 1 ] . content {
1567- AnthropicMessageContent :: String ( s) => assert_eq ! ( s, "response" ) ,
1568- _ => panic ! ( "Expected String" ) ,
1569- }
1570- }
1571-
1572- #[ test]
1573- fn test_sanitize_trims_trailing_whitespace_on_last_assistant_blocks ( ) {
1574- let mut msgs = vec ! [
1575- user_msg( "hello" ) ,
1576- assistant_blocks_msg( vec![
1577- text_block( "first part" ) ,
1578- AnthropicContent :: Text {
1579- text: "second part " . to_string( ) ,
1580- cache_control: None ,
1581- } ,
1582- ] ) ,
1583- ] ;
1584- sanitize_anthropic_messages ( & mut msgs) ;
1585- match & msgs[ 1 ] . content {
1586- AnthropicMessageContent :: Blocks ( blocks) => {
1587- // First text block unchanged
1588- match & blocks[ 0 ] {
1589- AnthropicContent :: Text { text, .. } => assert_eq ! ( text, "first part" ) ,
1590- _ => panic ! ( "Expected Text" ) ,
1591- }
1592- // Last text block trimmed
1593- match & blocks[ 1 ] {
1594- AnthropicContent :: Text { text, .. } => assert_eq ! ( text, "second part" ) ,
1595- _ => panic ! ( "Expected Text" ) ,
1596- }
1597- }
1598- _ => panic ! ( "Expected Blocks" ) ,
1599- }
1600- }
1601-
1602- #[ test]
1603- fn test_sanitize_no_trim_when_last_is_user ( ) {
1604- // Trailing whitespace is only trimmed on the last assistant message.
1605- // If the last message is a user message, no trimming should occur.
1606- let mut msgs = vec ! [ assistant_msg( "response " ) , user_msg( "followup " ) ] ;
1607- sanitize_anthropic_messages ( & mut msgs) ;
1608- // Assistant is no longer last — should NOT be trimmed
1609- match & msgs[ 0 ] . content {
1610- AnthropicMessageContent :: String ( s) => assert_eq ! ( s, "response " ) ,
1611- _ => panic ! ( "Expected String" ) ,
1612- }
1613- // User message is last — should NOT be trimmed (rule only applies to assistant)
1614- match & msgs[ 1 ] . content {
1615- AnthropicMessageContent :: String ( s) => assert_eq ! ( s, "followup " ) ,
1616- _ => panic ! ( "Expected String" ) ,
1617- }
1618- }
1619-
1620- #[ test]
1621- fn test_sanitize_no_trim_on_non_trailing_assistant ( ) {
1622- // Only the LAST message gets trimmed, not intermediate assistant messages
1623- let mut msgs = vec ! [
1624- user_msg( "hello" ) ,
1625- assistant_msg( "middle " ) ,
1626- user_msg( "followup" ) ,
1627- ] ;
1628- sanitize_anthropic_messages ( & mut msgs) ;
1629- match & msgs[ 1 ] . content {
1630- AnthropicMessageContent :: String ( s) => {
1631- assert_eq ! ( s, "middle " , "Non-trailing assistant must not be trimmed" ) ;
1632- }
1633- _ => panic ! ( "Expected String" ) ,
1634- }
1635- }
1636-
1637- #[ test]
1638- fn test_sanitize_trims_last_text_block_in_assistant_with_tool_use ( ) {
1639- // Assistant message with tool_use blocks followed by text — trim the last text
1640- let mut msgs = vec ! [
1641- user_msg( "hello" ) ,
1642- assistant_blocks_msg( vec![
1643- tool_use_block( "t1" , "my_tool" ) ,
1644- AnthropicContent :: Text {
1645- text: "thinking... " . to_string( ) ,
1646- cache_control: None ,
1647- } ,
1648- ] ) ,
1649- ] ;
1650- sanitize_anthropic_messages ( & mut msgs) ;
1651- match & msgs[ 1 ] . content {
1652- AnthropicMessageContent :: Blocks ( blocks) => {
1653- // tool_use untouched
1654- assert ! ( matches!( & blocks[ 0 ] , AnthropicContent :: ToolUse { .. } ) ) ;
1655- // last text block trimmed
1656- match & blocks[ 1 ] {
1657- AnthropicContent :: Text { text, .. } => assert_eq ! ( text, "thinking..." ) ,
1658- _ => panic ! ( "Expected Text" ) ,
1659- }
1660- }
1661- _ => panic ! ( "Expected Blocks" ) ,
1662- }
1663- }
1664-
16651516 // --- is_empty_content_message tests ---
16661517
16671518 #[ test]
0 commit comments