Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions crates/models/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,17 @@ pub fn auto_compact_default_for_model(model: &str) -> bool {

// === Streaming Structures ===

/// Decode the `redacted_thinking` start-event payload, tolerating the
/// missing-key shape (`#[serde(default)]`) and an explicit `"data": null`
/// from nonconforming gateways. Both decode to the empty payload that the
/// capture site warns about, instead of failing the whole SSE event.
fn deserialize_redacted_data<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
}

#[allow(dead_code)]
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type")]
Expand Down Expand Up @@ -520,6 +531,23 @@ pub enum ContentBlockStart {
Text { text: String },
#[serde(rename = "thinking")]
Thinking { thinking: String },
/// Anthropic `redacted_thinking`: the provider withheld the whole block
/// for safety and replaced it with an encrypted payload that arrives
/// complete on the start event (no deltas). It must be captured and
/// replayed byte-exact — the API decrypts it to restore the turn's
/// reasoning, and dropping it makes the next tool-loop request fail with
/// "Expected `thinking` or `redacted_thinking`, but found `tool_use`".
#[serde(rename = "redacted_thinking")]
RedactedThinking {
/// Tolerant of both a missing and an explicit-null `data`
/// (`deserialize_redacted_data` + default): a contract-violating
/// start event still decodes (empty payload + capture warn) instead
/// of failing the whole event and silently losing the turn. Note
/// that `#[serde(default)]` alone only covers the missing key — an
/// explicit `"data": null` would still fail `String` decoding.
#[serde(default, deserialize_with = "deserialize_redacted_data")]
data: String,
},
#[serde(rename = "tool_use")]
ToolUse {
id: String,
Expand Down Expand Up @@ -575,6 +603,43 @@ mod tests {
use super::*;
use std::any::TypeId;

#[test]
fn redacted_thinking_stream_start_decodes_with_its_payload() {
// A safeguard-triggered redacted block arrives complete on the start
// event. Before the variant existed this line failed the whole SSE
// event decode and lost the turn.
let block: ContentBlockStart = serde_json::from_value(serde_json::json!({
"type": "redacted_thinking",
"data": "ENC_REDACTED_PAYLOAD",
}))
.expect("redacted_thinking must decode");
match block {
ContentBlockStart::RedactedThinking { data } => {
assert_eq!(data, "ENC_REDACTED_PAYLOAD");
}
other => panic!("expected RedactedThinking, got {other:?}"),
}
}

#[test]
fn redacted_thinking_start_tolerates_an_explicit_null_payload() {
// `#[serde(default)]` covers a missing key only; a nonconforming
// gateway sending `"data": null` must still decode to the empty
// payload (capture warn downstream) instead of failing the whole
// SSE event and silently losing the turn's redacted block.
let block: ContentBlockStart = serde_json::from_value(serde_json::json!({
"type": "redacted_thinking",
"data": null,
}))
.expect("an explicit null payload must not fail the event decode");
match block {
ContentBlockStart::RedactedThinking { data } => {
assert_eq!(data, "");
}
other => panic!("expected RedactedThinking, got {other:?}"),
}
}

/// #6032: `model_supports_reasoning` consults the catalog before its
/// hand-maintained pile, so a literal arm that duplicates a catalog row is
/// unreachable. These 27 arms were exactly that and were deleted. They must
Expand Down
69 changes: 69 additions & 0 deletions crates/protocol/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@ pub enum ContentBlock {
/// readable `thinking` text or carry it across a route/model switch.
#[serde(skip_serializing_if = "Option::is_none", default)]
state: Option<OpaqueReasoningState>,
/// Anthropic `redacted_thinking` payload (`data`): the encrypted
/// replacement for a whole thinking block that the provider withheld
/// for safety reasons. It must be replayed byte-exact — the API
/// decrypts it to restore the turn's reasoning — and is mutually
/// exclusive with readable `thinking` text and with `signature`.
/// Stored on the same variant (instead of a sibling one) so existing
/// sessions and matches keep compiling; the serde field is absent
/// for every non-redacted block.
#[serde(skip_serializing_if = "Option::is_none", default)]
redacted_data: Option<String>,
},
#[serde(rename = "tool_use")]
ToolUse {
Expand Down Expand Up @@ -261,6 +271,7 @@ impl ContentBlock {
thinking: thinking.into(),
signature: None,
state: None,
redacted_data: None,
}
}
}
Expand Down Expand Up @@ -306,6 +317,64 @@ mod tests {
use super::*;
use serde_json::json;

#[test]
fn redacted_thinking_round_trips_through_persistence_serde() {
// Capture shape: the redacted payload rides the thinking variant and
// must survive the persisted-session round trip byte-exact.
let block = ContentBlock::Thinking {
thinking: String::new(),
signature: None,
state: None,
redacted_data: Some("ENC_REDACTED_PAYLOAD".to_string()),
};
let value = serde_json::to_value(&block).expect("thinking block serializes");
assert_eq!(
value,
json!({
"type": "thinking",
"thinking": "",
"redacted_data": "ENC_REDACTED_PAYLOAD",
}),
"the redacted payload must be on the persisted wire"
);
let round: ContentBlock =
serde_json::from_value(value).expect("redacted block deserializes");
assert_eq!(round, block, "round trip must be lossless");
}

#[test]
fn sessions_without_the_redacted_field_deserialize_unchanged() {
// A session persisted by an older build carries no `redacted_data`
// key; it must load unchanged with the field defaulting to `None`
// and must not gain the key on the way back out.
let value = json!({
"type": "thinking",
"thinking": "readable reasoning",
"signature": "sig-abc",
});
let block: ContentBlock =
serde_json::from_value(value).expect("legacy thinking block deserializes");
match &block {
ContentBlock::Thinking {
thinking,
signature,
state,
redacted_data,
} => {
assert_eq!(thinking, "readable reasoning");
assert_eq!(signature.as_deref(), Some("sig-abc"));
assert!(state.is_none());
assert!(redacted_data.is_none());
}
other => panic!("expected thinking block, got {other:?}"),
}
let serialized = serde_json::to_value(&block).expect("re-serializes");
assert!(
serialized.get("redacted_data").is_none(),
"a non-redacted block must not gain the serde field: {serialized}"
);
}

fn primary_turn() -> PrimaryTurnRequest {
PrimaryTurnRequest {
model: "deepseek-v4-flash".to_string(),
Expand Down
Loading
Loading