1use serde::{Deserialize, Serialize};
24
25use crate::messages::cache::CacheControl;
26use crate::messages::citation::Citation;
27
28#[derive(Debug, Clone, PartialEq)]
33pub enum ContentBlock {
34 Known(KnownBlock),
36 Other(serde_json::Value),
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(tag = "type", rename_all = "snake_case")]
46#[non_exhaustive]
47pub enum KnownBlock {
48 Text {
50 text: String,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 cache_control: Option<CacheControl>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 citations: Option<Vec<Citation>>,
58 },
59 Image {
61 source: ImageSource,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 cache_control: Option<CacheControl>,
66 },
67 Document {
69 source: DocumentSource,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 title: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 citations: Option<CitationConfig>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 cache_control: Option<CacheControl>,
80 },
81 ToolUse {
83 id: String,
85 name: String,
87 input: serde_json::Value,
89 },
90 ToolResult {
92 tool_use_id: String,
94 content: ToolResultContent,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 is_error: Option<bool>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 cache_control: Option<CacheControl>,
102 },
103 Thinking {
105 thinking: String,
107 signature: String,
109 },
110 RedactedThinking {
112 data: String,
114 },
115 ServerToolUse {
117 id: String,
119 name: String,
121 input: serde_json::Value,
123 },
124 WebSearchToolResult {
126 tool_use_id: String,
128 content: serde_json::Value,
130 },
131}
132
133const KNOWN_BLOCK_TAGS: &[&str] = &[
136 "text",
137 "image",
138 "document",
139 "tool_use",
140 "tool_result",
141 "thinking",
142 "redacted_thinking",
143 "server_tool_use",
144 "web_search_tool_result",
145];
146
147impl Serialize for ContentBlock {
148 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
149 match self {
150 ContentBlock::Known(k) => k.serialize(s),
151 ContentBlock::Other(v) => v.serialize(s),
152 }
153 }
154}
155
156impl<'de> Deserialize<'de> for ContentBlock {
157 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
158 let value = serde_json::Value::deserialize(d)?;
159 let type_tag = value.get("type").and_then(serde_json::Value::as_str);
160 match type_tag {
161 Some(t) if KNOWN_BLOCK_TAGS.contains(&t) => {
162 let known: KnownBlock =
163 serde_json::from_value(value).map_err(serde::de::Error::custom)?;
164 Ok(ContentBlock::Known(known))
165 }
166 _ => Ok(ContentBlock::Other(value)),
167 }
168 }
169}
170
171impl From<KnownBlock> for ContentBlock {
172 fn from(k: KnownBlock) -> Self {
173 ContentBlock::Known(k)
174 }
175}
176
177impl ContentBlock {
178 pub fn known(&self) -> Option<&KnownBlock> {
180 match self {
181 Self::Known(k) => Some(k),
182 Self::Other(_) => None,
183 }
184 }
185
186 pub fn other(&self) -> Option<&serde_json::Value> {
188 match self {
189 Self::Other(v) => Some(v),
190 Self::Known(_) => None,
191 }
192 }
193
194 pub fn type_tag(&self) -> Option<&str> {
200 match self {
201 Self::Known(k) => Some(known_type_tag(k)),
202 Self::Other(v) => v.get("type").and_then(serde_json::Value::as_str),
203 }
204 }
205
206 pub fn text(s: impl Into<String>) -> Self {
208 Self::Known(KnownBlock::Text {
209 text: s.into(),
210 cache_control: None,
211 citations: None,
212 })
213 }
214
215 pub fn image_url(url: impl Into<String>) -> Self {
223 Self::Known(KnownBlock::Image {
224 source: ImageSource::Url { url: url.into() },
225 cache_control: None,
226 })
227 }
228
229 pub fn image_base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
232 Self::Known(KnownBlock::Image {
233 source: ImageSource::Base64 {
234 media_type: media_type.into(),
235 data: data.into(),
236 },
237 cache_control: None,
238 })
239 }
240
241 pub fn document_text(data: impl Into<String>, title: Option<&str>) -> Self {
250 Self::Known(KnownBlock::Document {
251 source: DocumentSource::Text {
252 media_type: "text/plain".to_owned(),
253 data: data.into(),
254 },
255 title: title.map(str::to_owned),
256 citations: Some(CitationConfig { enabled: true }),
257 cache_control: None,
258 })
259 }
260
261 pub fn document_url(url: impl Into<String>) -> Self {
263 Self::Known(KnownBlock::Document {
264 source: DocumentSource::Url { url: url.into() },
265 title: None,
266 citations: Some(CitationConfig { enabled: true }),
267 cache_control: None,
268 })
269 }
270
271 pub fn text_cached(text: impl Into<String>) -> Self {
281 Self::Known(KnownBlock::Text {
282 text: text.into(),
283 cache_control: Some(CacheControl::ephemeral()),
284 citations: None,
285 })
286 }
287}
288
289fn known_type_tag(k: &KnownBlock) -> &'static str {
290 match k {
291 KnownBlock::Text { .. } => "text",
292 KnownBlock::Image { .. } => "image",
293 KnownBlock::Document { .. } => "document",
294 KnownBlock::ToolUse { .. } => "tool_use",
295 KnownBlock::ToolResult { .. } => "tool_result",
296 KnownBlock::Thinking { .. } => "thinking",
297 KnownBlock::RedactedThinking { .. } => "redacted_thinking",
298 KnownBlock::ServerToolUse { .. } => "server_tool_use",
299 KnownBlock::WebSearchToolResult { .. } => "web_search_tool_result",
300 }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(tag = "type", rename_all = "snake_case")]
306#[non_exhaustive]
307pub enum ImageSource {
308 Base64 {
310 media_type: String,
312 data: String,
314 },
315 Url {
317 url: String,
319 },
320 File {
322 file_id: String,
324 },
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(tag = "type", rename_all = "snake_case")]
330#[non_exhaustive]
331pub enum DocumentSource {
332 Base64 {
334 media_type: String,
336 data: String,
338 },
339 Url {
341 url: String,
343 },
344 File {
346 file_id: String,
348 },
349 Text {
353 media_type: String,
355 data: String,
357 },
358}
359
360#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
364#[serde(untagged)]
365pub enum ToolResultContent {
366 Text(String),
368 Blocks(Vec<ContentBlock>),
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374#[non_exhaustive]
375pub struct CitationConfig {
376 pub enabled: bool,
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use pretty_assertions::assert_eq;
384 use serde_json::json;
385
386 fn round_trip_block(block: &ContentBlock, expected: &serde_json::Value) {
387 let serialized = serde_json::to_value(block).expect("serialize");
388 assert_eq!(&serialized, expected, "wire form mismatch");
389 let parsed: ContentBlock = serde_json::from_value(serialized).expect("deserialize");
390 assert_eq!(&parsed, block, "round-trip mismatch");
391 }
392
393 #[test]
394 fn text_block_round_trips() {
395 round_trip_block(
396 &ContentBlock::text("hello"),
397 &json!({"type": "text", "text": "hello"}),
398 );
399 }
400
401 #[test]
402 fn text_block_with_cache_control_round_trips() {
403 let block = ContentBlock::Known(KnownBlock::Text {
404 text: "cached".into(),
405 cache_control: Some(CacheControl::ephemeral_ttl("1h")),
406 citations: None,
407 });
408 round_trip_block(
409 &block,
410 &json!({
411 "type": "text",
412 "text": "cached",
413 "cache_control": {"type": "ephemeral", "ttl": "1h"}
414 }),
415 );
416 }
417
418 #[test]
419 fn image_block_url_source_round_trips() {
420 let block = ContentBlock::Known(KnownBlock::Image {
421 source: ImageSource::Url {
422 url: "https://example.com/cat.png".into(),
423 },
424 cache_control: None,
425 });
426 round_trip_block(
427 &block,
428 &json!({
429 "type": "image",
430 "source": {"type": "url", "url": "https://example.com/cat.png"}
431 }),
432 );
433 }
434
435 #[test]
436 fn document_block_with_text_source_round_trips() {
437 let block = ContentBlock::Known(KnownBlock::Document {
438 source: DocumentSource::Text {
439 media_type: "text/plain".into(),
440 data: "page contents".into(),
441 },
442 title: Some("Spec".into()),
443 citations: Some(CitationConfig { enabled: true }),
444 cache_control: None,
445 });
446 round_trip_block(
447 &block,
448 &json!({
449 "type": "document",
450 "source": {"type": "text", "media_type": "text/plain", "data": "page contents"},
451 "title": "Spec",
452 "citations": {"enabled": true}
453 }),
454 );
455 }
456
457 #[test]
458 fn tool_use_round_trips() {
459 let block = ContentBlock::Known(KnownBlock::ToolUse {
460 id: "toolu_01".into(),
461 name: "get_weather".into(),
462 input: json!({"city": "Paris"}),
463 });
464 round_trip_block(
465 &block,
466 &json!({
467 "type": "tool_use",
468 "id": "toolu_01",
469 "name": "get_weather",
470 "input": {"city": "Paris"}
471 }),
472 );
473 }
474
475 #[test]
476 fn tool_result_with_string_content_round_trips() {
477 let block = ContentBlock::Known(KnownBlock::ToolResult {
478 tool_use_id: "toolu_01".into(),
479 content: ToolResultContent::Text("72F".into()),
480 is_error: None,
481 cache_control: None,
482 });
483 round_trip_block(
484 &block,
485 &json!({
486 "type": "tool_result",
487 "tool_use_id": "toolu_01",
488 "content": "72F"
489 }),
490 );
491 }
492
493 #[test]
494 fn tool_result_with_nested_blocks_round_trips() {
495 let block = ContentBlock::Known(KnownBlock::ToolResult {
496 tool_use_id: "toolu_01".into(),
497 content: ToolResultContent::Blocks(vec![ContentBlock::text("see below")]),
498 is_error: Some(false),
499 cache_control: None,
500 });
501 round_trip_block(
502 &block,
503 &json!({
504 "type": "tool_result",
505 "tool_use_id": "toolu_01",
506 "content": [{"type": "text", "text": "see below"}],
507 "is_error": false
508 }),
509 );
510 }
511
512 #[test]
513 fn thinking_block_round_trips() {
514 let block = ContentBlock::Known(KnownBlock::Thinking {
515 thinking: "let me think...".into(),
516 signature: "sig".into(),
517 });
518 round_trip_block(
519 &block,
520 &json!({
521 "type": "thinking",
522 "thinking": "let me think...",
523 "signature": "sig"
524 }),
525 );
526 }
527
528 #[test]
529 fn redacted_thinking_block_round_trips() {
530 let block = ContentBlock::Known(KnownBlock::RedactedThinking {
531 data: "<opaque>".into(),
532 });
533 round_trip_block(
534 &block,
535 &json!({"type": "redacted_thinking", "data": "<opaque>"}),
536 );
537 }
538
539 #[test]
540 fn server_tool_use_round_trips() {
541 let block = ContentBlock::Known(KnownBlock::ServerToolUse {
542 id: "stu_01".into(),
543 name: "web_search".into(),
544 input: json!({"query": "rust"}),
545 });
546 round_trip_block(
547 &block,
548 &json!({
549 "type": "server_tool_use",
550 "id": "stu_01",
551 "name": "web_search",
552 "input": {"query": "rust"}
553 }),
554 );
555 }
556
557 #[test]
558 fn web_search_tool_result_round_trips() {
559 let block = ContentBlock::Known(KnownBlock::WebSearchToolResult {
560 tool_use_id: "stu_01".into(),
561 content: json!([{"url": "https://rust-lang.org"}]),
562 });
563 round_trip_block(
564 &block,
565 &json!({
566 "type": "web_search_tool_result",
567 "tool_use_id": "stu_01",
568 "content": [{"url": "https://rust-lang.org"}]
569 }),
570 );
571 }
572
573 #[test]
574 fn unknown_block_type_falls_back_to_other_preserving_json() {
575 let raw = json!({
576 "type": "future_block_type",
577 "some_field": 42,
578 "nested": {"a": "b"}
579 });
580 let block: ContentBlock = serde_json::from_value(raw.clone()).expect("deserialize");
581 match &block {
582 ContentBlock::Other(v) => assert_eq!(v, &raw),
583 ContentBlock::Known(_) => panic!("expected Other, got Known"),
584 }
585 let reserialized = serde_json::to_value(&block).expect("serialize");
586 assert_eq!(reserialized, raw, "Other must round-trip byte-for-byte");
587 }
588
589 #[test]
590 fn missing_type_field_falls_back_to_other() {
591 let raw = json!({"text": "hi"});
592 let block: ContentBlock = serde_json::from_value(raw.clone()).expect("deserialize");
593 match &block {
594 ContentBlock::Other(v) => assert_eq!(v, &raw),
595 ContentBlock::Known(_) => panic!("expected Other"),
596 }
597 }
598
599 #[test]
600 fn malformed_known_block_is_an_error_not_other() {
601 let raw = json!({"type": "text", "text": 42});
603 let result: Result<ContentBlock, _> = serde_json::from_value(raw);
604 assert!(
605 result.is_err(),
606 "malformed known type must error, not silently fall through to Other"
607 );
608 }
609
610 #[test]
611 fn type_tag_works_for_known_and_other() {
612 assert_eq!(ContentBlock::text("x").type_tag(), Some("text"));
613
614 let other_json = json!({"type": "future_thing", "x": 1});
615 let other: ContentBlock = serde_json::from_value(other_json).unwrap();
616 assert_eq!(other.type_tag(), Some("future_thing"));
617 }
618
619 #[test]
620 fn known_and_other_accessors() {
621 let known = ContentBlock::text("hi");
622 assert!(known.known().is_some());
623 assert!(known.other().is_none());
624
625 let other: ContentBlock =
626 serde_json::from_value(json!({"type": "future", "x": 1})).unwrap();
627 assert!(other.known().is_none());
628 assert!(other.other().is_some());
629 }
630
631 #[test]
632 fn from_known_block_into_content_block() {
633 let kb = KnownBlock::Text {
634 text: "via from".into(),
635 cache_control: None,
636 citations: None,
637 };
638 let cb: ContentBlock = kb.into();
639 assert_eq!(cb.type_tag(), Some("text"));
640 }
641}