Skip to content

Commit 4fa240c

Browse files
committed
Revert "fix(anthropic): trim trailing whitespace on last assistant message"
This reverts commit ee2eab6.
1 parent ee2eab6 commit 4fa240c

1 file changed

Lines changed: 36 additions & 185 deletions

File tree

libs/ai/src/providers/anthropic/convert.rs

Lines changed: 36 additions & 185 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)