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}