use serde::{Deserialize, Serialize};
use crate::messages::cache::CacheControl;
use crate::messages::citation::Citation;
#[derive(Debug, Clone, PartialEq)]
pub enum ContentBlock {
Known(KnownBlock),
Other(serde_json::Value),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum KnownBlock {
Text {
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
#[serde(default, skip_serializing_if = "Option::is_none")]
citations: Option<Vec<Citation>>,
},
Image {
source: ImageSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
Document {
source: DocumentSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
citations: Option<CitationConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
ToolResult {
tool_use_id: String,
content: ToolResultContent,
#[serde(default, skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
Thinking {
thinking: String,
signature: String,
},
RedactedThinking {
data: String,
},
ServerToolUse {
id: String,
name: String,
input: serde_json::Value,
},
WebSearchToolResult {
tool_use_id: String,
content: serde_json::Value,
},
}
const KNOWN_BLOCK_TAGS: &[&str] = &[
"text",
"image",
"document",
"tool_use",
"tool_result",
"thinking",
"redacted_thinking",
"server_tool_use",
"web_search_tool_result",
];
impl Serialize for ContentBlock {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
ContentBlock::Known(k) => k.serialize(s),
ContentBlock::Other(v) => v.serialize(s),
}
}
}
impl<'de> Deserialize<'de> for ContentBlock {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let value = serde_json::Value::deserialize(d)?;
let type_tag = value.get("type").and_then(serde_json::Value::as_str);
match type_tag {
Some(t) if KNOWN_BLOCK_TAGS.contains(&t) => {
let known: KnownBlock =
serde_json::from_value(value).map_err(serde::de::Error::custom)?;
Ok(ContentBlock::Known(known))
}
_ => Ok(ContentBlock::Other(value)),
}
}
}
impl From<KnownBlock> for ContentBlock {
fn from(k: KnownBlock) -> Self {
ContentBlock::Known(k)
}
}
impl ContentBlock {
pub fn known(&self) -> Option<&KnownBlock> {
match self {
Self::Known(k) => Some(k),
Self::Other(_) => None,
}
}
pub fn other(&self) -> Option<&serde_json::Value> {
match self {
Self::Other(v) => Some(v),
Self::Known(_) => None,
}
}
pub fn type_tag(&self) -> Option<&str> {
match self {
Self::Known(k) => Some(known_type_tag(k)),
Self::Other(v) => v.get("type").and_then(serde_json::Value::as_str),
}
}
pub fn text(s: impl Into<String>) -> Self {
Self::Known(KnownBlock::Text {
text: s.into(),
cache_control: None,
citations: None,
})
}
pub fn image_url(url: impl Into<String>) -> Self {
Self::Known(KnownBlock::Image {
source: ImageSource::Url { url: url.into() },
cache_control: None,
})
}
pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
Self::Known(KnownBlock::Image {
source: ImageSource::Base64 {
media_type: media_type.into(),
data: data.into(),
},
cache_control: None,
})
}
pub fn document_text(data: impl Into<String>, title: Option<&str>) -> Self {
Self::Known(KnownBlock::Document {
source: DocumentSource::Text {
media_type: "text/plain".to_owned(),
data: data.into(),
},
title: title.map(str::to_owned),
citations: Some(CitationConfig { enabled: true }),
cache_control: None,
})
}
pub fn document_url(url: impl Into<String>) -> Self {
Self::Known(KnownBlock::Document {
source: DocumentSource::Url { url: url.into() },
title: None,
citations: Some(CitationConfig { enabled: true }),
cache_control: None,
})
}
pub fn text_cached(text: impl Into<String>) -> Self {
Self::Known(KnownBlock::Text {
text: text.into(),
cache_control: Some(CacheControl::ephemeral()),
citations: None,
})
}
}
fn known_type_tag(k: &KnownBlock) -> &'static str {
match k {
KnownBlock::Text { .. } => "text",
KnownBlock::Image { .. } => "image",
KnownBlock::Document { .. } => "document",
KnownBlock::ToolUse { .. } => "tool_use",
KnownBlock::ToolResult { .. } => "tool_result",
KnownBlock::Thinking { .. } => "thinking",
KnownBlock::RedactedThinking { .. } => "redacted_thinking",
KnownBlock::ServerToolUse { .. } => "server_tool_use",
KnownBlock::WebSearchToolResult { .. } => "web_search_tool_result",
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ImageSource {
Base64 {
media_type: String,
data: String,
},
Url {
url: String,
},
File {
file_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum DocumentSource {
Base64 {
media_type: String,
data: String,
},
Url {
url: String,
},
File {
file_id: String,
},
Text {
media_type: String,
data: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ToolResultContent {
Text(String),
Blocks(Vec<ContentBlock>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CitationConfig {
pub enabled: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
fn round_trip_block(block: &ContentBlock, expected: &serde_json::Value) {
let serialized = serde_json::to_value(block).expect("serialize");
assert_eq!(&serialized, expected, "wire form mismatch");
let parsed: ContentBlock = serde_json::from_value(serialized).expect("deserialize");
assert_eq!(&parsed, block, "round-trip mismatch");
}
#[test]
fn text_block_round_trips() {
round_trip_block(
&ContentBlock::text("hello"),
&json!({"type": "text", "text": "hello"}),
);
}
#[test]
fn text_block_with_cache_control_round_trips() {
let block = ContentBlock::Known(KnownBlock::Text {
text: "cached".into(),
cache_control: Some(CacheControl::ephemeral_ttl("1h")),
citations: None,
});
round_trip_block(
&block,
&json!({
"type": "text",
"text": "cached",
"cache_control": {"type": "ephemeral", "ttl": "1h"}
}),
);
}
#[test]
fn image_block_url_source_round_trips() {
let block = ContentBlock::Known(KnownBlock::Image {
source: ImageSource::Url {
url: "https://example.com/cat.png".into(),
},
cache_control: None,
});
round_trip_block(
&block,
&json!({
"type": "image",
"source": {"type": "url", "url": "https://example.com/cat.png"}
}),
);
}
#[test]
fn document_block_with_text_source_round_trips() {
let block = ContentBlock::Known(KnownBlock::Document {
source: DocumentSource::Text {
media_type: "text/plain".into(),
data: "page contents".into(),
},
title: Some("Spec".into()),
citations: Some(CitationConfig { enabled: true }),
cache_control: None,
});
round_trip_block(
&block,
&json!({
"type": "document",
"source": {"type": "text", "media_type": "text/plain", "data": "page contents"},
"title": "Spec",
"citations": {"enabled": true}
}),
);
}
#[test]
fn tool_use_round_trips() {
let block = ContentBlock::Known(KnownBlock::ToolUse {
id: "toolu_01".into(),
name: "get_weather".into(),
input: json!({"city": "Paris"}),
});
round_trip_block(
&block,
&json!({
"type": "tool_use",
"id": "toolu_01",
"name": "get_weather",
"input": {"city": "Paris"}
}),
);
}
#[test]
fn tool_result_with_string_content_round_trips() {
let block = ContentBlock::Known(KnownBlock::ToolResult {
tool_use_id: "toolu_01".into(),
content: ToolResultContent::Text("72F".into()),
is_error: None,
cache_control: None,
});
round_trip_block(
&block,
&json!({
"type": "tool_result",
"tool_use_id": "toolu_01",
"content": "72F"
}),
);
}
#[test]
fn tool_result_with_nested_blocks_round_trips() {
let block = ContentBlock::Known(KnownBlock::ToolResult {
tool_use_id: "toolu_01".into(),
content: ToolResultContent::Blocks(vec![ContentBlock::text("see below")]),
is_error: Some(false),
cache_control: None,
});
round_trip_block(
&block,
&json!({
"type": "tool_result",
"tool_use_id": "toolu_01",
"content": [{"type": "text", "text": "see below"}],
"is_error": false
}),
);
}
#[test]
fn thinking_block_round_trips() {
let block = ContentBlock::Known(KnownBlock::Thinking {
thinking: "let me think...".into(),
signature: "sig".into(),
});
round_trip_block(
&block,
&json!({
"type": "thinking",
"thinking": "let me think...",
"signature": "sig"
}),
);
}
#[test]
fn redacted_thinking_block_round_trips() {
let block = ContentBlock::Known(KnownBlock::RedactedThinking {
data: "<opaque>".into(),
});
round_trip_block(
&block,
&json!({"type": "redacted_thinking", "data": "<opaque>"}),
);
}
#[test]
fn server_tool_use_round_trips() {
let block = ContentBlock::Known(KnownBlock::ServerToolUse {
id: "stu_01".into(),
name: "web_search".into(),
input: json!({"query": "rust"}),
});
round_trip_block(
&block,
&json!({
"type": "server_tool_use",
"id": "stu_01",
"name": "web_search",
"input": {"query": "rust"}
}),
);
}
#[test]
fn web_search_tool_result_round_trips() {
let block = ContentBlock::Known(KnownBlock::WebSearchToolResult {
tool_use_id: "stu_01".into(),
content: json!([{"url": "https://rust-lang.org"}]),
});
round_trip_block(
&block,
&json!({
"type": "web_search_tool_result",
"tool_use_id": "stu_01",
"content": [{"url": "https://rust-lang.org"}]
}),
);
}
#[test]
fn unknown_block_type_falls_back_to_other_preserving_json() {
let raw = json!({
"type": "future_block_type",
"some_field": 42,
"nested": {"a": "b"}
});
let block: ContentBlock = serde_json::from_value(raw.clone()).expect("deserialize");
match &block {
ContentBlock::Other(v) => assert_eq!(v, &raw),
ContentBlock::Known(_) => panic!("expected Other, got Known"),
}
let reserialized = serde_json::to_value(&block).expect("serialize");
assert_eq!(reserialized, raw, "Other must round-trip byte-for-byte");
}
#[test]
fn missing_type_field_falls_back_to_other() {
let raw = json!({"text": "hi"});
let block: ContentBlock = serde_json::from_value(raw.clone()).expect("deserialize");
match &block {
ContentBlock::Other(v) => assert_eq!(v, &raw),
ContentBlock::Known(_) => panic!("expected Other"),
}
}
#[test]
fn malformed_known_block_is_an_error_not_other() {
let raw = json!({"type": "text", "text": 42});
let result: Result<ContentBlock, _> = serde_json::from_value(raw);
assert!(
result.is_err(),
"malformed known type must error, not silently fall through to Other"
);
}
#[test]
fn type_tag_works_for_known_and_other() {
assert_eq!(ContentBlock::text("x").type_tag(), Some("text"));
let other_json = json!({"type": "future_thing", "x": 1});
let other: ContentBlock = serde_json::from_value(other_json).unwrap();
assert_eq!(other.type_tag(), Some("future_thing"));
}
#[test]
fn known_and_other_accessors() {
let known = ContentBlock::text("hi");
assert!(known.known().is_some());
assert!(known.other().is_none());
let other: ContentBlock =
serde_json::from_value(json!({"type": "future", "x": 1})).unwrap();
assert!(other.known().is_none());
assert!(other.other().is_some());
}
#[test]
fn from_known_block_into_content_block() {
let kb = KnownBlock::Text {
text: "via from".into(),
cache_control: None,
citations: None,
};
let cb: ContentBlock = kb.into();
assert_eq!(cb.type_tag(), Some("text"));
}
}