Skip to main content

claude_api/messages/
content.rs

1//! Content blocks: the building blocks of message bodies and stream deltas.
2//!
3//! [`ContentBlock`] is the public, forward-compatible enum: it wraps a
4//! [`KnownBlock`] for any block type the SDK understands, or a raw
5//! [`serde_json::Value`] for any block type it doesn't. This means an SDK
6//! older than the API will keep round-tripping payloads instead of panicking
7//! on a new variant.
8//!
9//! # Forward-compat semantics
10//!
11//! - **Unknown `type` tag** → [`ContentBlock::Other`] preserving the JSON byte-for-byte.
12//! - **Known `type` tag with malformed fields** → deserialization error
13//!   (we do *not* silently fall through, so genuine bugs surface).
14//!
15//! ```
16//! use claude_api::messages::content::ContentBlock;
17//!
18//! let json = serde_json::json!({"type": "text", "text": "hi"});
19//! let block: ContentBlock = serde_json::from_value(json).unwrap();
20//! assert_eq!(block.type_tag(), Some("text"));
21//! ```
22
23use serde::{Deserialize, Serialize};
24
25use crate::messages::cache::CacheControl;
26use crate::messages::citation::Citation;
27
28/// One block of content within a message.
29///
30/// Forward-compatible: unknown `type` tags deserialize into [`ContentBlock::Other`]
31/// with the raw JSON preserved.
32#[derive(Debug, Clone, PartialEq)]
33pub enum ContentBlock {
34    /// A block whose `type` is recognized by this SDK version.
35    Known(KnownBlock),
36    /// A block whose `type` is not recognized; the raw JSON is preserved.
37    Other(serde_json::Value),
38}
39
40/// All content block variants known to this SDK version.
41///
42/// `#[non_exhaustive]` so that adding a new variant in a future release
43/// is not a breaking change for downstream `match` statements.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(tag = "type", rename_all = "snake_case")]
46#[non_exhaustive]
47pub enum KnownBlock {
48    /// Plain text content.
49    Text {
50        /// The text payload.
51        text: String,
52        /// Optional cache breakpoint.
53        #[serde(default, skip_serializing_if = "Option::is_none")]
54        cache_control: Option<CacheControl>,
55        /// Citations the model attached to this text span, if any.
56        #[serde(default, skip_serializing_if = "Option::is_none")]
57        citations: Option<Vec<Citation>>,
58    },
59    /// An image embedded in the message.
60    Image {
61        /// Where the image bytes come from.
62        source: ImageSource,
63        /// Optional cache breakpoint.
64        #[serde(default, skip_serializing_if = "Option::is_none")]
65        cache_control: Option<CacheControl>,
66    },
67    /// A document (e.g. PDF) embedded in the message.
68    Document {
69        /// Where the document bytes come from.
70        source: DocumentSource,
71        /// Optional human-readable title used in citation rendering.
72        #[serde(default, skip_serializing_if = "Option::is_none")]
73        title: Option<String>,
74        /// Optional citation configuration for this document.
75        #[serde(default, skip_serializing_if = "Option::is_none")]
76        citations: Option<CitationConfig>,
77        /// Optional cache breakpoint.
78        #[serde(default, skip_serializing_if = "Option::is_none")]
79        cache_control: Option<CacheControl>,
80    },
81    /// A model-emitted request to invoke a tool.
82    ToolUse {
83        /// Identifier the model assigns to this invocation.
84        id: String,
85        /// Name of the tool to invoke.
86        name: String,
87        /// Tool arguments as JSON.
88        input: serde_json::Value,
89    },
90    /// The result of a tool invocation, supplied back to the model.
91    ToolResult {
92        /// The `id` of the [`KnownBlock::ToolUse`] this result corresponds to.
93        tool_use_id: String,
94        /// The tool's output.
95        content: ToolResultContent,
96        /// Whether the tool execution failed.
97        #[serde(default, skip_serializing_if = "Option::is_none")]
98        is_error: Option<bool>,
99        /// Optional cache breakpoint.
100        #[serde(default, skip_serializing_if = "Option::is_none")]
101        cache_control: Option<CacheControl>,
102    },
103    /// Extended-thinking trace from the model.
104    Thinking {
105        /// The model's chain-of-thought text.
106        thinking: String,
107        /// Cryptographic signature over the thinking text.
108        signature: String,
109    },
110    /// A redacted thinking block; only the opaque blob is visible.
111    RedactedThinking {
112        /// Opaque server-side blob.
113        data: String,
114    },
115    /// A server-side tool invocation initiated by the model.
116    ServerToolUse {
117        /// Identifier the model assigns to this invocation.
118        id: String,
119        /// Server tool name (e.g. `web_search`).
120        name: String,
121        /// Tool arguments as JSON.
122        input: serde_json::Value,
123    },
124    /// The result of a server-side `web_search` invocation.
125    WebSearchToolResult {
126        /// The `id` of the [`KnownBlock::ServerToolUse`] this result corresponds to.
127        tool_use_id: String,
128        /// Result payload (search hits etc.); shape is server-defined.
129        content: serde_json::Value,
130    },
131}
132
133/// `type` tags recognized by this SDK version. Used by [`ContentBlock`]'s
134/// `Deserialize` impl to decide between `Known` and `Other`.
135const 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    /// If this is a known block, return the inner [`KnownBlock`].
179    pub fn known(&self) -> Option<&KnownBlock> {
180        match self {
181            Self::Known(k) => Some(k),
182            Self::Other(_) => None,
183        }
184    }
185
186    /// If this is an unknown block, return the raw JSON.
187    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    /// Returns the wire-level `type` tag for this block, regardless of variant.
195    ///
196    /// For known blocks this returns the `snake_case` discriminant; for unknown
197    /// blocks it returns whatever string the server sent in the `type` field
198    /// (or `None` if the field was missing or non-string).
199    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    /// Convenience constructor for a plain text block.
207    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    /// Convenience constructor for a URL-sourced image block.
216    ///
217    /// ```
218    /// use claude_api::messages::ContentBlock;
219    /// let block = ContentBlock::image_url("https://example.com/cat.png");
220    /// assert_eq!(block.type_tag(), Some("image"));
221    /// ```
222    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    /// Convenience constructor for a base64-encoded image block. `media_type`
230    /// is the IANA MIME type (e.g. `"image/png"`); `data` is base64.
231    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    /// Convenience constructor for an inline-text document block. Cites the
242    /// document by `title` if provided.
243    ///
244    /// ```
245    /// use claude_api::messages::ContentBlock;
246    /// let block = ContentBlock::document_text("Page contents.", Some("Spec"));
247    /// assert_eq!(block.type_tag(), Some("document"));
248    /// ```
249    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    /// Convenience constructor for a URL-sourced document block.
262    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    /// Convenience constructor: a text block with an ephemeral cache
272    /// breakpoint at the default (5-minute) TTL. Use this on the last
273    /// block of a long-lived prefix you expect to reuse across requests.
274    ///
275    /// ```
276    /// use claude_api::messages::ContentBlock;
277    /// let block = ContentBlock::text_cached("Be concise.");
278    /// assert_eq!(block.type_tag(), Some("text"));
279    /// ```
280    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/// Source of bytes for an [`KnownBlock::Image`].
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(tag = "type", rename_all = "snake_case")]
306#[non_exhaustive]
307pub enum ImageSource {
308    /// Inline base64-encoded bytes.
309    Base64 {
310        /// MIME type (e.g. `image/png`).
311        media_type: String,
312        /// Base64-encoded image bytes.
313        data: String,
314    },
315    /// Public URL the server should fetch.
316    Url {
317        /// Image URL.
318        url: String,
319    },
320    /// Reference to an uploaded file.
321    File {
322        /// File ID returned by the Files API.
323        file_id: String,
324    },
325}
326
327/// Source of bytes for an [`KnownBlock::Document`].
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(tag = "type", rename_all = "snake_case")]
330#[non_exhaustive]
331pub enum DocumentSource {
332    /// Inline base64-encoded bytes.
333    Base64 {
334        /// MIME type (e.g. `application/pdf`).
335        media_type: String,
336        /// Base64-encoded document bytes.
337        data: String,
338    },
339    /// Public URL the server should fetch.
340    Url {
341        /// Document URL.
342        url: String,
343    },
344    /// Reference to an uploaded file.
345    File {
346        /// File ID returned by the Files API.
347        file_id: String,
348    },
349    /// Inline plain-text document. The API requires `media_type` for this
350    /// variant (typically `"text/plain"`); use [`ContentBlock::document_text`]
351    /// for the common-case constructor.
352    Text {
353        /// MIME type, e.g. `"text/plain"`. Required by the API.
354        media_type: String,
355        /// Document text.
356        data: String,
357    },
358}
359
360/// Content payload of a `tool_result` block.
361///
362/// May be a plain string or a list of further [`ContentBlock`]s (e.g. text + image).
363#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
364#[serde(untagged)]
365pub enum ToolResultContent {
366    /// Plain-text result.
367    Text(String),
368    /// Structured result composed of further content blocks.
369    Blocks(Vec<ContentBlock>),
370}
371
372/// Per-document citation configuration on a [`KnownBlock::Document`].
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374#[non_exhaustive]
375pub struct CitationConfig {
376    /// Whether the model should cite this document in its response.
377    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        // Known type tag but `text` field is the wrong shape.
602        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}