Skip to main content

llm_content_blocks/
lib.rs

1//! # llm-content-blocks
2//!
3//! Typed fluent builder for [Anthropic Messages-API][1] content blocks.
4//!
5//! The Anthropic Messages API takes a list of content blocks per message:
6//! `text`, `image`, `tool_use`, `tool_result`, `document`. The shapes are
7//! simple but fiddly, especially when you stitch them together
8//! programmatically. [`Blocks`] is a fluent builder that emits the exact
9//! JSON shape the API expects, with no SDK dependency.
10//!
11//! [1]: https://docs.anthropic.com/en/api/messages
12//!
13//! ## Serialization approach
14//!
15//! Variants of [`ContentBlock`] derive [`serde::Serialize`] with
16//! `#[serde(tag = "type", rename_all = "snake_case")]`. This puts the
17//! `"type": "..."` discriminator alongside the variant fields, which is
18//! exactly the dict shape Anthropic expects. `cache_control` and
19//! `is_error` are skipped when absent / `false` so the produced JSON
20//! is byte-equivalent to the Python reference library's output.
21//!
22//! ## Quick example
23//!
24//! ```
25//! use llm_content_blocks::Blocks;
26//!
27//! let mut b = Blocks::new();
28//! b.text("Look at this:")
29//!     .image_b64(b"\x89PNG", "image/png").unwrap()
30//!     .text("What is it?");
31//! let content = b.build();
32//!
33//! assert_eq!(content.len(), 3);
34//! ```
35//!
36//! ## Wrap as a full message
37//!
38//! ```
39//! use llm_content_blocks::Blocks;
40//!
41//! let mut b = Blocks::new();
42//! b.text("Hi");
43//! let msg = b.build_message("user");
44//! assert_eq!(msg.role, "user");
45//! assert_eq!(msg.content.len(), 1);
46//! ```
47//!
48//! ## One-shot tool result
49//!
50//! ```
51//! use llm_content_blocks::Blocks;
52//! use serde_json::json;
53//!
54//! let block = Blocks::tool_result_block("toolu_1", json!("the answer"), false);
55//! ```
56
57#![deny(missing_docs)]
58
59use std::fmt;
60
61use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
62use serde::Serialize;
63use serde_json::Value;
64
65/// Image media types accepted by the Anthropic Messages API.
66///
67/// Slice form keeps the dependency footprint minimal (no `HashSet`
68/// allocation for a four-element list).
69pub const VALID_IMAGE_MEDIA_TYPES: &[&str] =
70    &["image/jpeg", "image/png", "image/gif", "image/webp"];
71
72/// Document media types accepted by the Anthropic Messages API.
73pub const VALID_DOCUMENT_MEDIA_TYPES: &[&str] = &["application/pdf", "text/plain"];
74
75/// Cache-control marker for a single content block.
76///
77/// Only `"ephemeral"` is currently supported on Anthropic's prompt cache.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
79#[serde(tag = "type", rename_all = "snake_case")]
80pub enum CacheControl {
81    /// Anthropic prompt-cache marker; serializes as `{"type": "ephemeral"}`.
82    Ephemeral,
83}
84
85/// Source for an `image` content block.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87#[serde(tag = "type", rename_all = "snake_case")]
88pub enum ImageSource {
89    /// Inline base64-encoded image bytes.
90    Base64 {
91        /// Image MIME type, must be in [`VALID_IMAGE_MEDIA_TYPES`].
92        media_type: String,
93        /// Base64-encoded payload.
94        data: String,
95    },
96    /// Remote image fetched by URL.
97    Url {
98        /// Fully qualified URL.
99        url: String,
100    },
101}
102
103/// Source for a `document` content block.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
105#[serde(tag = "type", rename_all = "snake_case")]
106pub enum DocumentSource {
107    /// Inline base64-encoded document bytes.
108    Base64 {
109        /// Document MIME type, must be in [`VALID_DOCUMENT_MEDIA_TYPES`].
110        media_type: String,
111        /// Base64-encoded payload.
112        data: String,
113    },
114}
115
116/// One entry in the `content` array of an Anthropic message.
117#[derive(Debug, Clone, PartialEq, Serialize)]
118#[serde(tag = "type", rename_all = "snake_case")]
119pub enum ContentBlock {
120    /// Plain text block.
121    Text {
122        /// Body text.
123        text: String,
124        /// Optional cache-control marker.
125        #[serde(skip_serializing_if = "Option::is_none")]
126        cache_control: Option<CacheControl>,
127    },
128    /// Image block (base64 or URL source).
129    Image {
130        /// Image source (`base64` or `url`).
131        source: ImageSource,
132        /// Optional cache-control marker.
133        #[serde(skip_serializing_if = "Option::is_none")]
134        cache_control: Option<CacheControl>,
135    },
136    /// Model-issued tool call.
137    ToolUse {
138        /// Provider-assigned tool-call id (e.g. `toolu_…`).
139        id: String,
140        /// Tool name as registered with the model.
141        name: String,
142        /// JSON-shaped tool input.
143        input: Value,
144    },
145    /// User-issued tool result corresponding to a previous tool_use.
146    ToolResult {
147        /// Id of the tool_use block this is responding to.
148        tool_use_id: String,
149        /// Result payload (any JSON value: string, list of blocks, etc).
150        content: Value,
151        /// Whether the result represents an error to the model.
152        #[serde(skip_serializing_if = "is_false")]
153        is_error: bool,
154    },
155    /// Document block (PDF, plain text).
156    Document {
157        /// Document source.
158        source: DocumentSource,
159        /// Optional cache-control marker.
160        #[serde(skip_serializing_if = "Option::is_none")]
161        cache_control: Option<CacheControl>,
162    },
163}
164
165fn is_false(b: &bool) -> bool {
166    !*b
167}
168
169/// A full `{role, content}` message envelope.
170#[derive(Debug, Clone, PartialEq, Serialize)]
171pub struct Message {
172    /// `"user"` or `"assistant"`.
173    pub role: String,
174    /// The accumulated content blocks.
175    pub content: Vec<ContentBlock>,
176}
177
178/// Errors returned by builder methods that validate input up front.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum BlockError {
181    /// Image media type was not in [`VALID_IMAGE_MEDIA_TYPES`].
182    UnsupportedImageMediaType(String),
183    /// Document media type was not in [`VALID_DOCUMENT_MEDIA_TYPES`].
184    UnsupportedDocumentMediaType(String),
185}
186
187impl fmt::Display for BlockError {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        match self {
190            BlockError::UnsupportedImageMediaType(t) => write!(
191                f,
192                "unsupported image media_type {:?}; expected one of {:?}",
193                t, VALID_IMAGE_MEDIA_TYPES
194            ),
195            BlockError::UnsupportedDocumentMediaType(t) => write!(
196                f,
197                "unsupported document media_type {:?}; expected one of {:?}",
198                t, VALID_DOCUMENT_MEDIA_TYPES
199            ),
200        }
201    }
202}
203
204impl std::error::Error for BlockError {}
205
206/// Fluent builder for a list of Anthropic content blocks.
207///
208/// All chained appenders return `&mut Self`; finalize with
209/// [`Blocks::build`] (consumes the builder) or [`Blocks::build_message`].
210#[derive(Debug, Default, Clone)]
211pub struct Blocks {
212    inner: Vec<ContentBlock>,
213}
214
215impl Blocks {
216    /// Create an empty builder.
217    pub fn new() -> Self {
218        Self { inner: Vec::new() }
219    }
220
221    /// Append a plain text block.
222    pub fn text(&mut self, s: impl Into<String>) -> &mut Self {
223        self.inner.push(ContentBlock::Text {
224            text: s.into(),
225            cache_control: None,
226        });
227        self
228    }
229
230    /// Append a text block carrying a `cache_control` marker.
231    pub fn text_with_cache(
232        &mut self,
233        s: impl Into<String>,
234        cache_control: CacheControl,
235    ) -> &mut Self {
236        self.inner.push(ContentBlock::Text {
237            text: s.into(),
238            cache_control: Some(cache_control),
239        });
240        self
241    }
242
243    /// Append a base64-encoded inline image block.
244    ///
245    /// `data` is the raw image bytes; the builder base64-encodes them
246    /// internally so callers never have to think about the encoding.
247    ///
248    /// Returns [`BlockError::UnsupportedImageMediaType`] if `media_type`
249    /// is not in [`VALID_IMAGE_MEDIA_TYPES`].
250    pub fn image_b64(
251        &mut self,
252        data: &[u8],
253        media_type: impl Into<String>,
254    ) -> Result<&mut Self, BlockError> {
255        let media_type = media_type.into();
256        if !VALID_IMAGE_MEDIA_TYPES.contains(&media_type.as_str()) {
257            return Err(BlockError::UnsupportedImageMediaType(media_type));
258        }
259        let encoded = BASE64_STANDARD.encode(data);
260        self.inner.push(ContentBlock::Image {
261            source: ImageSource::Base64 {
262                media_type,
263                data: encoded,
264            },
265            cache_control: None,
266        });
267        Ok(self)
268    }
269
270    /// Same as [`Blocks::image_b64`] but also tags the block with a
271    /// `cache_control` marker.
272    pub fn image_b64_with_cache(
273        &mut self,
274        data: &[u8],
275        media_type: impl Into<String>,
276        cache_control: CacheControl,
277    ) -> Result<&mut Self, BlockError> {
278        let media_type = media_type.into();
279        if !VALID_IMAGE_MEDIA_TYPES.contains(&media_type.as_str()) {
280            return Err(BlockError::UnsupportedImageMediaType(media_type));
281        }
282        let encoded = BASE64_STANDARD.encode(data);
283        self.inner.push(ContentBlock::Image {
284            source: ImageSource::Base64 {
285                media_type,
286                data: encoded,
287            },
288            cache_control: Some(cache_control),
289        });
290        Ok(self)
291    }
292
293    /// Append an image block that references a remote URL.
294    pub fn image_url(&mut self, url: impl Into<String>) -> &mut Self {
295        self.inner.push(ContentBlock::Image {
296            source: ImageSource::Url { url: url.into() },
297            cache_control: None,
298        });
299        self
300    }
301
302    /// Append a `tool_use` block. `input` is taken as a `serde_json::Value`
303    /// so callers may use the [`serde_json::json!`] macro or pass any
304    /// type implementing `Into<Value>`.
305    pub fn tool_use(
306        &mut self,
307        id: impl Into<String>,
308        name: impl Into<String>,
309        input: Value,
310    ) -> &mut Self {
311        self.inner.push(ContentBlock::ToolUse {
312            id: id.into(),
313            name: name.into(),
314            input,
315        });
316        self
317    }
318
319    /// Append a `tool_result` block.
320    pub fn tool_result(
321        &mut self,
322        tool_use_id: impl Into<String>,
323        content: Value,
324        is_error: bool,
325    ) -> &mut Self {
326        self.inner.push(ContentBlock::ToolResult {
327            tool_use_id: tool_use_id.into(),
328            content,
329            is_error,
330        });
331        self
332    }
333
334    /// Append a base64-encoded `document` block. Defaults to PDF when
335    /// no media type is supplied (see [`Blocks::document_pdf_b64`]).
336    ///
337    /// Returns [`BlockError::UnsupportedDocumentMediaType`] if
338    /// `media_type` is not in [`VALID_DOCUMENT_MEDIA_TYPES`].
339    pub fn document_b64(
340        &mut self,
341        data: &[u8],
342        media_type: impl Into<String>,
343    ) -> Result<&mut Self, BlockError> {
344        let media_type = media_type.into();
345        if !VALID_DOCUMENT_MEDIA_TYPES.contains(&media_type.as_str()) {
346            return Err(BlockError::UnsupportedDocumentMediaType(media_type));
347        }
348        let encoded = BASE64_STANDARD.encode(data);
349        self.inner.push(ContentBlock::Document {
350            source: DocumentSource::Base64 {
351                media_type,
352                data: encoded,
353            },
354            cache_control: None,
355        });
356        Ok(self)
357    }
358
359    /// Convenience wrapper for the common `application/pdf` case.
360    pub fn document_pdf_b64(&mut self, data: &[u8]) -> &mut Self {
361        // safe: "application/pdf" is in the allowlist
362        let _ = self.document_b64(data, "application/pdf");
363        self
364    }
365
366    /// Splice an existing iterable of content blocks into the builder.
367    pub fn extend<I>(&mut self, blocks: I) -> &mut Self
368    where
369        I: IntoIterator<Item = ContentBlock>,
370    {
371        self.inner.extend(blocks);
372        self
373    }
374
375    /// Drain the builder and return the accumulated block list.
376    ///
377    /// Takes `&mut self` (rather than `self`) so it composes naturally
378    /// with the `&mut Self`-returning fluent chain (e.g.
379    /// `Blocks::new().text("hi").build()`). The builder is left empty
380    /// after the call; calling `.build()` twice yields `[blocks, []]`.
381    pub fn build(&mut self) -> Vec<ContentBlock> {
382        std::mem::take(&mut self.inner)
383    }
384
385    /// Drain the builder and wrap the accumulated blocks in a
386    /// `{role, content}` envelope.
387    pub fn build_message(&mut self, role: impl Into<String>) -> Message {
388        Message {
389            role: role.into(),
390            content: std::mem::take(&mut self.inner),
391        }
392    }
393
394    /// Return the current number of accumulated blocks.
395    pub fn len(&self) -> usize {
396        self.inner.len()
397    }
398
399    /// Return `true` when no blocks have been appended yet.
400    pub fn is_empty(&self) -> bool {
401        self.inner.is_empty()
402    }
403
404    // ---- static one-shot helpers ------------------------------------
405
406    /// Build a single `tool_result` block without going through the
407    /// builder. Mirrors the Python `Blocks.tool_result` classmethod.
408    pub fn tool_result_block(
409        tool_use_id: impl Into<String>,
410        content: Value,
411        is_error: bool,
412    ) -> ContentBlock {
413        ContentBlock::ToolResult {
414            tool_use_id: tool_use_id.into(),
415            content,
416            is_error,
417        }
418    }
419}