redacted thinking

This commit is contained in:
jaberjaber23
2026-05-12 14:36:05 +03:00
parent 37e2043ed7
commit 94fca22124
7 changed files with 246 additions and 3 deletions
+3
View File
@@ -592,6 +592,9 @@ impl SessionStore {
openfang_types::truncate_str(thinking, 200)
));
}
ContentBlock::RedactedThinking { .. } => {
text_parts.push("[redacted_thinking]".to_string());
}
ContentBlock::Unknown => {}
}
}
+60
View File
@@ -3313,6 +3313,66 @@ mod tests {
assert_eq!(saved_text, Some(final_text));
}
/// Issue #1148 — when the LLM hits MaxTokens, the persisted assistant
/// turn must keep `Thinking` and `RedactedThinking` blocks so reasoning
/// state survives across the token-limit boundary. The helper used by
/// the MaxTokens branches is the same `build_assistant_message_preserving_thinking`
/// that EndTurn uses; this test pins that contract for both block types
/// so the four MaxTokens persistence sites stay correct.
#[test]
fn test_build_assistant_message_preserves_redacted_thinking_for_max_tokens() {
let response_blocks = vec![
ContentBlock::Thinking {
thinking: "Mid-stream reasoning".to_string(),
signature: Some("sig_xyz".to_string()),
provider_metadata: Some(serde_json::json!({
"format": "anthropic_extended_thinking"
})),
},
ContentBlock::RedactedThinking {
data: "encrypted_blob_abc".to_string(),
},
ContentBlock::Text {
text: "Partial answer before token limit".to_string(),
provider_metadata: None,
},
];
let final_text = "Partial answer before token limit";
let msg = build_assistant_message_preserving_thinking(&response_blocks, final_text);
let blocks = match &msg.content {
MessageContent::Blocks(b) => b,
other => panic!("expected Blocks content for MaxTokens persistence, got {other:?}"),
};
// All reasoning blocks must survive the persistence step so the
// follow-up "Please continue." turn carries them back to the model.
let has_thinking = blocks
.iter()
.any(|b| matches!(b, ContentBlock::Thinking { .. }));
let has_redacted = blocks
.iter()
.any(|b| matches!(b, ContentBlock::RedactedThinking { .. }));
assert!(has_thinking, "Thinking block must be preserved on MaxTokens");
assert!(
has_redacted,
"RedactedThinking block must be preserved on MaxTokens"
);
// Verify the opaque blob is byte-identical (Anthropic rejects altered data).
for b in blocks {
if let ContentBlock::RedactedThinking { data } = b {
assert_eq!(data, "encrypted_blob_abc");
}
}
// Final text reflects what the user will see.
let saved_text = blocks.iter().find_map(|b| match b {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
});
assert_eq!(saved_text, Some(final_text));
}
#[test]
fn test_retry_constants() {
assert_eq!(MAX_RETRIES, 3);
+1
View File
@@ -404,6 +404,7 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S
conversation_text.push_str(&format!("[Image: {media_type}]\n\n"));
}
ContentBlock::Thinking { .. } => {}
ContentBlock::RedactedThinking { .. } => {}
ContentBlock::Unknown => {}
}
}
@@ -93,6 +93,13 @@ enum ApiContentBlock {
/// one (e.g. legacy sessions saved before this field was tracked).
#[serde(rename = "thinking")]
Thinking { thinking: String, signature: String },
/// Redacted (encrypted) thinking block echoed back to the API.
///
/// Anthropic returns these when the model decides to hide reasoning;
/// the `data` blob is opaque and MUST be echoed verbatim on the next
/// turn or the API rejects the resubmitted history.
#[serde(rename = "redacted_thinking")]
RedactedThinking { data: String },
}
#[derive(Debug, Serialize)]
@@ -139,6 +146,11 @@ enum ResponseContentBlock {
#[serde(default)]
signature: Option<String>,
},
/// Redacted (encrypted) thinking block. The `data` blob is opaque to
/// us and must be persisted as-is so we can echo it back on the next
/// request — Anthropic rejects history that strips these blocks.
#[serde(rename = "redacted_thinking")]
RedactedThinking { data: String },
}
#[derive(Debug, Deserialize)]
@@ -174,6 +186,10 @@ enum ContentBlockAccum {
name: String,
input_json: String,
},
/// Redacted (encrypted) thinking block streamed from Anthropic.
/// The opaque `data` blob arrives on `content_block_start` and must be
/// persisted so the next turn can echo it back verbatim.
RedactedThinking { data: String },
}
#[async_trait]
@@ -447,6 +463,19 @@ impl LlmDriver for AnthropicDriver {
signature: initial_sig,
});
}
"redacted_thinking" => {
// Anthropic delivers redacted_thinking
// as a single block_start with the opaque
// `data` blob (no delta events). Store it
// verbatim so we can echo it back on the
// next request — API rejects history
// that strips redacted_thinking blocks.
let data = block["data"]
.as_str()
.unwrap_or("")
.to_string();
blocks.push(ContentBlockAccum::RedactedThinking { data });
}
_ => {}
}
}
@@ -603,6 +632,11 @@ impl LlmDriver for AnthropicDriver {
});
tool_calls.push(ToolCall { id, name, input });
}
ContentBlockAccum::RedactedThinking { data } => {
if !data.is_empty() {
content.push(ContentBlock::RedactedThinking { data });
}
}
}
}
@@ -710,6 +744,18 @@ fn convert_message(msg: &Message) -> ApiMessage {
}
})
}
ContentBlock::RedactedThinking { data } => {
// Echo the encrypted blob verbatim. Anthropic
// rejects history that drops redacted_thinking
// blocks, so always include them on resubmission.
if data.is_empty() {
None
} else {
Some(ApiContentBlock::RedactedThinking {
data: data.clone(),
})
}
}
ContentBlock::Unknown => None,
})
.collect();
@@ -757,6 +803,9 @@ fn convert_response(api: ApiResponse) -> CompletionResponse {
})),
});
}
ResponseContentBlock::RedactedThinking { data } => {
content.push(ContentBlock::RedactedThinking { data });
}
}
}
@@ -1019,4 +1068,119 @@ mod tests {
_ => panic!("expected Thinking response block"),
}
}
/// Issue #1148 — Anthropic `redacted_thinking` blocks must survive the
/// full driver round-trip. The opaque `data` blob is required verbatim
/// on resubmission; dropping or mutating it causes the API to reject
/// the assistant turn on the next request.
#[test]
fn test_redacted_thinking_round_trip() {
// Step 1: API delivers a response with a redacted_thinking block.
let api_response = ApiResponse {
content: vec![
ResponseContentBlock::RedactedThinking {
data: "EncRyPt3D_BLO8".to_string(),
},
ResponseContentBlock::Text {
text: "The answer is 42.".to_string(),
},
],
stop_reason: "end_turn".to_string(),
usage: ApiUsage {
input_tokens: 100,
output_tokens: 50,
},
};
let response = convert_response(api_response);
assert_eq!(response.content.len(), 2);
// Step 2: The opaque blob must reach the ContentBlock layer.
match &response.content[0] {
ContentBlock::RedactedThinking { data } => {
assert_eq!(data, "EncRyPt3D_BLO8");
}
other => panic!("expected RedactedThinking content block, got {other:?}"),
}
// Step 3: Resubmit the assistant turn as conversation history.
let assistant_msg = Message {
role: Role::Assistant,
content: MessageContent::Blocks(response.content.clone()),
};
let api_msg = convert_message(&assistant_msg);
let blocks = match api_msg.content {
ApiContent::Blocks(b) => b,
_ => panic!("expected Blocks content"),
};
// The redacted_thinking block must appear in the outbound payload.
let mut found_redacted = false;
for block in &blocks {
if let ApiContentBlock::RedactedThinking { data } = block {
assert_eq!(data, "EncRyPt3D_BLO8");
found_redacted = true;
}
}
assert!(
found_redacted,
"outbound API request must include the redacted_thinking block"
);
// Step 4: On-the-wire JSON shape (`type=redacted_thinking`, `data` present).
let outbound_json = serde_json::to_value(&blocks).unwrap();
let arr = outbound_json.as_array().unwrap();
let redacted_json = arr
.iter()
.find(|v| v["type"] == "redacted_thinking")
.expect("redacted_thinking block in JSON");
assert_eq!(redacted_json["data"], "EncRyPt3D_BLO8");
}
/// API response wire format for `redacted_thinking` is parsed correctly.
#[test]
fn test_redacted_thinking_serde() {
let json = serde_json::json!({
"type": "redacted_thinking",
"data": "opaque_blob_xyz"
});
let block: ResponseContentBlock = serde_json::from_value(json).unwrap();
match block {
ResponseContentBlock::RedactedThinking { data } => {
assert_eq!(data, "opaque_blob_xyz");
}
_ => panic!("expected RedactedThinking response block"),
}
}
/// Empty redacted_thinking blocks (e.g. interrupted stream) must be
/// dropped on outbound to avoid sending malformed history.
#[test]
fn test_redacted_thinking_empty_dropped_outbound() {
let assistant_msg = Message {
role: Role::Assistant,
content: MessageContent::Blocks(vec![
ContentBlock::RedactedThinking {
data: String::new(),
},
ContentBlock::Text {
text: "Hello.".to_string(),
provider_metadata: None,
},
]),
};
let api_msg = convert_message(&assistant_msg);
let blocks = match api_msg.content {
ApiContent::Blocks(b) => b,
_ => panic!("expected Blocks content"),
};
for block in &blocks {
assert!(
!matches!(block, ApiContentBlock::RedactedThinking { .. }),
"empty redacted_thinking block must be dropped"
);
}
assert!(blocks
.iter()
.any(|b| matches!(b, ApiContentBlock::Text { .. })));
}
}
@@ -299,8 +299,11 @@ fn convert_content_block(block: &ContentBlock) -> Option<BedrockContentBlock> {
},
},
}),
// Image, Thinking, and Unknown are not supported — silently drop
ContentBlock::Image { .. } | ContentBlock::Thinking { .. } | ContentBlock::Unknown => None,
// Image, Thinking, RedactedThinking, and Unknown are not supported — silently drop
ContentBlock::Image { .. }
| ContentBlock::Thinking { .. }
| ContentBlock::RedactedThinking { .. }
| ContentBlock::Unknown => None,
}
}
@@ -100,6 +100,7 @@ impl CompletionResponse {
self.content.iter().any(|block| match block {
ContentBlock::Text { text, .. } => !text.is_empty(),
ContentBlock::Thinking { thinking, .. } => !thinking.is_empty(),
ContentBlock::RedactedThinking { data } => !data.is_empty(),
ContentBlock::ToolUse { .. } | ContentBlock::Image { .. } => true,
_ => false,
})
+12 -1
View File
@@ -106,6 +106,15 @@ pub enum ContentBlock {
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_metadata: Option<serde_json::Value>,
},
/// Anthropic redacted-thinking block — encrypted reasoning the model
/// chose to hide. The `data` blob is opaque to us but MUST be echoed
/// verbatim on subsequent turns, otherwise Anthropic rejects the
/// resubmitted history. Only emitted by the Anthropic driver.
#[serde(rename = "redacted_thinking")]
RedactedThinking {
/// Opaque encrypted reasoning payload from Anthropic.
data: String,
},
/// Catch-all for unrecognized content block types (forward compatibility).
#[serde(other)]
Unknown,
@@ -161,7 +170,9 @@ impl MessageContent {
ContentBlock::ToolUse { name, input, .. } => {
name.len() + input.to_string().len()
}
ContentBlock::Image { .. } | ContentBlock::Unknown => 0,
ContentBlock::Image { .. }
| ContentBlock::RedactedThinking { .. }
| ContentBlock::Unknown => 0,
})
.sum(),
}