mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 00:47:51 +00:00
redacted thinking
This commit is contained in:
@@ -592,6 +592,9 @@ impl SessionStore {
|
||||
openfang_types::truncate_str(thinking, 200)
|
||||
));
|
||||
}
|
||||
ContentBlock::RedactedThinking { .. } => {
|
||||
text_parts.push("[redacted_thinking]".to_string());
|
||||
}
|
||||
ContentBlock::Unknown => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user