use serde::{Deserialize, Serialize};
use crate::forward_compat::dispatch_known_or_other;
use crate::messages::content::ContentBlock;
use crate::types::{ModelId, Role, StopReason, Usage};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Message {
pub id: String,
#[serde(rename = "type", default = "default_message_kind")]
pub kind: String,
#[serde(default = "default_assistant_role")]
pub role: Role,
#[serde(default)]
pub content: Vec<ContentBlock>,
pub model: ModelId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<StopReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_sequence: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_details: Option<StopDetails>,
#[serde(default)]
pub usage: Usage,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_management: Option<ResponseContextManagement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container: Option<ContainerInfo>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum StopDetails {
Known(KnownStopDetails),
Other(serde_json::Value),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum KnownStopDetails {
Refusal(RefusalStopDetails),
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RefusalStopDetails {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub explanation: Option<String>,
}
const KNOWN_STOP_DETAILS_TAGS: &[&str] = &["refusal"];
impl Serialize for StopDetails {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
StopDetails::Known(k) => k.serialize(s),
StopDetails::Other(v) => v.serialize(s),
}
}
}
impl<'de> Deserialize<'de> for StopDetails {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = serde_json::Value::deserialize(d)?;
dispatch_known_or_other(
raw,
KNOWN_STOP_DETAILS_TAGS,
StopDetails::Known,
StopDetails::Other,
)
.map_err(serde::de::Error::custom)
}
}
impl From<KnownStopDetails> for StopDetails {
fn from(k: KnownStopDetails) -> Self {
StopDetails::Known(k)
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ResponseContextManagement {
#[serde(default)]
pub applied_edits: Vec<ContextEdit>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ContextEdit {
Known(KnownContextEdit),
Other(serde_json::Value),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum KnownContextEdit {
#[serde(rename = "clear_thinking_20251015")]
ClearThinking(ClearThinkingEdit),
#[serde(rename = "clear_tool_uses_20250919")]
ClearToolUses(ClearToolUsesEdit),
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ClearThinkingEdit {
pub cleared_input_tokens: u64,
pub cleared_thinking_turns: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ClearToolUsesEdit {
pub cleared_input_tokens: u64,
pub cleared_tool_uses: u64,
}
const KNOWN_CONTEXT_EDIT_TAGS: &[&str] = &["clear_thinking_20251015", "clear_tool_uses_20250919"];
impl Serialize for ContextEdit {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
ContextEdit::Known(k) => k.serialize(s),
ContextEdit::Other(v) => v.serialize(s),
}
}
}
impl<'de> Deserialize<'de> for ContextEdit {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = serde_json::Value::deserialize(d)?;
dispatch_known_or_other(
raw,
KNOWN_CONTEXT_EDIT_TAGS,
ContextEdit::Known,
ContextEdit::Other,
)
.map_err(serde::de::Error::custom)
}
}
impl From<KnownContextEdit> for ContextEdit {
fn from(k: KnownContextEdit) -> Self {
ContextEdit::Known(k)
}
}
fn default_message_kind() -> String {
"message".to_owned()
}
fn default_assistant_role() -> Role {
Role::Assistant
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ContainerInfo {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CountTokensResponse {
pub input_tokens: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::messages::content::KnownBlock;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn realistic_message_response_round_trips() {
let raw = json!({
"id": "msg_01ABCDEF",
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "Hello!"}
],
"model": "claude-sonnet-4-6",
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 10,
"output_tokens": 5
}
});
let msg: Message = serde_json::from_value(raw).expect("deserialize");
assert_eq!(msg.id, "msg_01ABCDEF");
assert_eq!(msg.kind, "message");
assert_eq!(msg.role, Role::Assistant);
assert_eq!(msg.model, ModelId::SONNET_4_6);
assert_eq!(msg.stop_reason, Some(StopReason::EndTurn));
assert_eq!(msg.usage.input_tokens, 10);
assert_eq!(msg.usage.output_tokens, 5);
assert_eq!(msg.content.len(), 1);
assert_eq!(msg.content[0].type_tag(), Some("text"));
let reserialized = serde_json::to_value(&msg).expect("serialize");
let parsed_again: Message = serde_json::from_value(reserialized).expect("re-deserialize");
assert_eq!(parsed_again, msg, "round-trip mismatch");
}
#[test]
fn message_with_unknown_content_block_round_trips() {
let raw = json!({
"id": "msg_X",
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "hi"},
{"type": "future_block", "payload": 42}
],
"model": "claude-opus-4-7",
"usage": {"input_tokens": 1, "output_tokens": 1}
});
let msg: Message = serde_json::from_value(raw.clone()).expect("deserialize");
assert_eq!(msg.content.len(), 2);
assert_eq!(msg.content[0].type_tag(), Some("text"));
assert_eq!(msg.content[1].type_tag(), Some("future_block"));
assert!(msg.content[1].other().is_some());
let reserialized = serde_json::to_value(&msg).expect("serialize");
let blocks = reserialized.get("content").unwrap().as_array().unwrap();
assert_eq!(blocks[1], json!({"type": "future_block", "payload": 42}));
}
#[test]
fn message_kind_defaults_when_missing() {
let raw = json!({
"id": "msg_1",
"role": "assistant",
"content": [],
"model": "claude-sonnet-4-6",
"usage": {"input_tokens": 0, "output_tokens": 0}
});
let msg: Message = serde_json::from_value(raw).expect("deserialize");
assert_eq!(msg.kind, "message");
}
#[test]
fn message_with_tool_use_block_round_trips() {
let msg = Message {
id: "msg_tool".into(),
kind: "message".into(),
role: Role::Assistant,
content: vec![ContentBlock::Known(KnownBlock::ToolUse {
id: "toolu_1".into(),
name: "lookup".into(),
input: json!({"q": "rust"}),
})],
model: ModelId::HAIKU_4_5,
stop_reason: Some(StopReason::ToolUse),
stop_sequence: None,
stop_details: None,
usage: Usage {
input_tokens: 7,
output_tokens: 3,
..Usage::default()
},
context_management: None,
container: None,
};
let v = serde_json::to_value(&msg).expect("serialize");
let parsed: Message = serde_json::from_value(v).expect("deserialize");
assert_eq!(parsed, msg);
}
#[test]
fn stop_details_refusal_round_trips() {
let raw = json!({
"type": "refusal",
"category": "cyber",
"explanation": "Request involves offensive cyber techniques."
});
let sd: StopDetails = serde_json::from_value(raw.clone()).unwrap();
match &sd {
StopDetails::Known(KnownStopDetails::Refusal(r)) => {
assert_eq!(r.category.as_deref(), Some("cyber"));
assert!(r.explanation.is_some());
}
other => panic!("expected Refusal, got {other:?}"),
}
assert_eq!(serde_json::to_value(&sd).unwrap(), raw);
}
#[test]
fn stop_details_null_category_round_trips() {
let raw = json!({"type": "refusal", "category": null, "explanation": null});
let sd: StopDetails = serde_json::from_value(raw).unwrap();
if let StopDetails::Known(KnownStopDetails::Refusal(r)) = &sd {
assert!(r.category.is_none());
} else {
panic!("expected Refusal");
}
}
#[test]
fn stop_details_unknown_type_falls_through_to_other() {
let raw = json!({"type": "future_stop_reason", "detail": 42});
let sd: StopDetails = serde_json::from_value(raw.clone()).unwrap();
assert!(matches!(sd, StopDetails::Other(_)));
assert_eq!(serde_json::to_value(&sd).unwrap(), raw);
}
#[test]
fn context_edit_clear_thinking_round_trips() {
let raw = json!({
"type": "clear_thinking_20251015",
"cleared_input_tokens": 1500,
"cleared_thinking_turns": 3
});
let edit: ContextEdit = serde_json::from_value(raw.clone()).unwrap();
match &edit {
ContextEdit::Known(KnownContextEdit::ClearThinking(e)) => {
assert_eq!(e.cleared_input_tokens, 1500);
assert_eq!(e.cleared_thinking_turns, 3);
}
other => panic!("expected ClearThinking, got {other:?}"),
}
assert_eq!(serde_json::to_value(&edit).unwrap(), raw);
}
#[test]
fn context_edit_clear_tool_uses_round_trips() {
let raw = json!({
"type": "clear_tool_uses_20250919",
"cleared_input_tokens": 800,
"cleared_tool_uses": 2
});
let edit: ContextEdit = serde_json::from_value(raw.clone()).unwrap();
if let ContextEdit::Known(KnownContextEdit::ClearToolUses(e)) = &edit {
assert_eq!(e.cleared_tool_uses, 2);
} else {
panic!("expected ClearToolUses");
}
assert_eq!(serde_json::to_value(&edit).unwrap(), raw);
}
#[test]
fn context_edit_unknown_type_falls_through_to_other() {
let raw = json!({"type": "compact_20260112", "summary": "..."});
let edit: ContextEdit = serde_json::from_value(raw.clone()).unwrap();
assert!(matches!(edit, ContextEdit::Other(_)));
assert_eq!(serde_json::to_value(&edit).unwrap(), raw);
}
#[test]
fn response_context_management_round_trips() {
let raw = json!({
"applied_edits": [
{"type": "clear_thinking_20251015", "cleared_input_tokens": 500, "cleared_thinking_turns": 1},
{"type": "clear_tool_uses_20250919", "cleared_input_tokens": 200, "cleared_tool_uses": 1}
]
});
let cm: ResponseContextManagement = serde_json::from_value(raw.clone()).unwrap();
assert_eq!(cm.applied_edits.len(), 2);
assert!(matches!(
&cm.applied_edits[0],
ContextEdit::Known(KnownContextEdit::ClearThinking(_))
));
assert_eq!(serde_json::to_value(&cm).unwrap(), raw);
}
#[test]
fn message_with_stop_details_and_context_management_round_trips() {
let raw = json!({
"id": "msg_refusal",
"type": "message",
"role": "assistant",
"content": [],
"model": "claude-sonnet-4-6",
"stop_reason": "refusal",
"usage": {"input_tokens": 5, "output_tokens": 0},
"stop_details": {"type": "refusal", "category": "bio", "explanation": "Biosecurity policy."},
"context_management": {
"applied_edits": [
{"type": "clear_thinking_20251015", "cleared_input_tokens": 300, "cleared_thinking_turns": 2}
]
}
});
let msg: Message = serde_json::from_value(raw).unwrap();
assert!(msg.stop_details.is_some());
assert!(msg.context_management.is_some());
let cm = msg.context_management.as_ref().unwrap();
assert_eq!(cm.applied_edits.len(), 1);
}
#[test]
fn count_tokens_response_round_trips() {
let r = CountTokensResponse { input_tokens: 42 };
let v = serde_json::to_value(&r).expect("serialize");
assert_eq!(v, json!({"input_tokens": 42}));
let parsed: CountTokensResponse = serde_json::from_value(v).expect("deserialize");
assert_eq!(parsed, r);
}
#[test]
fn container_info_round_trips() {
let c = ContainerInfo {
id: "cnt_01".into(),
expires_at: Some("2026-01-01T00:00:00Z".into()),
};
let v = serde_json::to_value(&c).expect("serialize");
assert_eq!(
v,
json!({"id": "cnt_01", "expires_at": "2026-01-01T00:00:00Z"})
);
let parsed: ContainerInfo = serde_json::from_value(v).expect("deserialize");
assert_eq!(parsed, c);
}
#[test]
fn message_with_container_round_trips() {
let raw = json!({
"id": "msg_with_container",
"type": "message",
"role": "assistant",
"content": [],
"model": "claude-opus-4-7",
"usage": {"input_tokens": 0, "output_tokens": 0},
"container": {"id": "cnt_42"}
});
let msg: Message = serde_json::from_value(raw).expect("deserialize");
assert_eq!(msg.container.as_ref().unwrap().id, "cnt_42");
}
}