agent_chain_core/messages/
content.rs

1//! Standard, multimodal content blocks for Large Language Model I/O.
2//!
3//! This module provides standardized data structures for representing inputs to and
4//! outputs from LLMs. The core abstraction is the **Content Block**, a struct with
5//! a `type` field for discrimination.
6//!
7//! Mirrors `langchain_core.messages.content` from Python.
8//!
9//! # Rationale
10//!
11//! Different LLM providers use distinct and incompatible API schemas. This module
12//! provides a unified, provider-agnostic format to facilitate these interactions. A
13//! message to or from a model is simply a list of content blocks, allowing for the natural
14//! interleaving of text, images, and other content in a single ordered sequence.
15//!
16//! # Key Block Types
17//!
18//! - [`TextContentBlock`]: Standard text output.
19//! - [`Citation`]: For annotations that link text output to a source document.
20//! - [`ReasoningContentBlock`]: To capture a model's thought process.
21//! - Multimodal data:
22//!     - [`ImageContentBlock`]
23//!     - [`AudioContentBlock`]
24//!     - [`VideoContentBlock`]
25//!     - [`PlainTextContentBlock`] (e.g. .txt or .md files)
26//!     - [`FileContentBlock`] (e.g. PDFs, etc.)
27//! - Tool calls:
28//!     - [`ToolCallBlock`]
29//!     - [`ToolCallChunkBlock`]
30//!     - [`InvalidToolCallBlock`]
31//!     - [`ServerToolCall`]
32//!     - [`ServerToolCallChunk`]
33//!     - [`ServerToolResult`]
34
35use serde::{Deserialize, Serialize};
36use std::collections::HashMap;
37
38#[cfg(feature = "specta")]
39use specta::Type;
40
41use crate::utils::uuid::ensure_id;
42
43// =============================================================================
44// Legacy types (kept for backwards compatibility)
45// =============================================================================
46
47/// Image detail level for vision models.
48///
49/// This controls how the model processes the image:
50/// - `Low`: Faster, lower token cost, suitable for simple images
51/// - `High`: More detailed analysis, higher token cost
52/// - `Auto`: Let the model decide based on image size
53#[cfg_attr(feature = "specta", derive(Type))]
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
55#[serde(rename_all = "lowercase")]
56pub enum ImageDetail {
57    Low,
58    High,
59    #[default]
60    Auto,
61}
62
63/// Source of an image for multimodal messages.
64#[cfg_attr(feature = "specta", derive(Type))]
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
66#[serde(tag = "type", rename_all = "snake_case")]
67pub enum ImageSource {
68    /// Image from a URL.
69    Url { url: String },
70    /// Base64-encoded image data.
71    Base64 {
72        /// MIME type (e.g., "image/jpeg", "image/png", "image/gif", "image/webp")
73        media_type: String,
74        /// Base64-encoded image data (without the data URL prefix)
75        data: String,
76    },
77}
78
79/// A content part in a multimodal message.
80///
81/// Messages can contain multiple content parts, allowing for mixed text and images.
82/// This corresponds to content blocks in LangChain Python's `langchain_core.messages.content`.
83#[cfg_attr(feature = "specta", derive(Type))]
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85#[serde(tag = "type", rename_all = "snake_case")]
86pub enum ContentPart {
87    /// Text content.
88    Text { text: String },
89    /// Image content.
90    Image {
91        source: ImageSource,
92        /// Detail level for image processing (optional, defaults to Auto)
93        #[serde(skip_serializing_if = "Option::is_none")]
94        detail: Option<ImageDetail>,
95    },
96}
97
98impl From<&str> for ContentPart {
99    fn from(text: &str) -> Self {
100        ContentPart::Text {
101            text: text.to_string(),
102        }
103    }
104}
105
106impl From<String> for ContentPart {
107    fn from(text: String) -> Self {
108        ContentPart::Text { text }
109    }
110}
111
112/// Message content that can be either simple text or multipart.
113///
114/// This represents the content field of messages and can be either
115/// a simple string or a list of content parts for multimodal messages.
116#[cfg_attr(feature = "specta", derive(Type))]
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118#[serde(untagged)]
119pub enum MessageContent {
120    /// Simple text content.
121    Text(String),
122    /// Multiple content parts (for multimodal messages).
123    Parts(Vec<ContentPart>),
124}
125
126impl MessageContent {
127    /// Get the text content, concatenating text parts if multipart.
128    pub fn as_text(&self) -> String {
129        match self {
130            MessageContent::Text(s) => s.clone(),
131            MessageContent::Parts(parts) => parts
132                .iter()
133                .filter_map(|p| match p {
134                    ContentPart::Text { text } => Some(text.as_str()),
135                    _ => None,
136                })
137                .collect::<Vec<_>>()
138                .join(" "),
139        }
140    }
141
142    /// Check if this content has any images.
143    pub fn has_images(&self) -> bool {
144        match self {
145            MessageContent::Text(_) => false,
146            MessageContent::Parts(parts) => {
147                parts.iter().any(|p| matches!(p, ContentPart::Image { .. }))
148            }
149        }
150    }
151
152    /// Get content parts, converting simple text to a single text part if needed.
153    pub fn parts(&self) -> Vec<ContentPart> {
154        match self {
155            MessageContent::Text(s) => vec![ContentPart::Text { text: s.clone() }],
156            MessageContent::Parts(parts) => parts.clone(),
157        }
158    }
159}
160
161impl Default for MessageContent {
162    fn default() -> Self {
163        MessageContent::Text(String::new())
164    }
165}
166
167impl From<String> for MessageContent {
168    fn from(s: String) -> Self {
169        MessageContent::Text(s)
170    }
171}
172
173impl From<&str> for MessageContent {
174    fn from(s: &str) -> Self {
175        MessageContent::Text(s.to_string())
176    }
177}
178
179impl From<Vec<ContentPart>> for MessageContent {
180    fn from(parts: Vec<ContentPart>) -> Self {
181        MessageContent::Parts(parts)
182    }
183}
184
185// =============================================================================
186// Standard Content Block Types (matching Python langchain_core.messages.content)
187// =============================================================================
188
189/// Index type that can be either an integer or string.
190/// Used during streaming for block ordering.
191#[cfg_attr(feature = "specta", derive(Type))]
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
193#[serde(untagged)]
194pub enum BlockIndex {
195    Int(i64),
196    Str(String),
197}
198
199impl From<i64> for BlockIndex {
200    fn from(i: i64) -> Self {
201        BlockIndex::Int(i)
202    }
203}
204
205impl From<i32> for BlockIndex {
206    fn from(i: i32) -> Self {
207        BlockIndex::Int(i as i64)
208    }
209}
210
211impl From<usize> for BlockIndex {
212    fn from(i: usize) -> Self {
213        BlockIndex::Int(i as i64)
214    }
215}
216
217impl From<String> for BlockIndex {
218    fn from(s: String) -> Self {
219        BlockIndex::Str(s)
220    }
221}
222
223impl From<&str> for BlockIndex {
224    fn from(s: &str) -> Self {
225        BlockIndex::Str(s.to_string())
226    }
227}
228
229/// Annotation for citing data from a document.
230///
231/// Note: `start_index`/`end_index` indices refer to the **response text**,
232/// not the source text.
233#[cfg_attr(feature = "specta", derive(Type))]
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
235pub struct Citation {
236    /// Type of the content block. Always "citation".
237    #[serde(rename = "type")]
238    pub block_type: String,
239    /// Content block identifier.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub id: Option<String>,
242    /// URL of the document source.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub url: Option<String>,
245    /// Source document title.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub title: Option<String>,
248    /// Start index of the response text.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub start_index: Option<i64>,
251    /// End index of the response text.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub end_index: Option<i64>,
254    /// Excerpt of source text being cited.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub cited_text: Option<String>,
257    /// Provider-specific metadata.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub extras: Option<HashMap<String, serde_json::Value>>,
260}
261
262impl Citation {
263    /// Create a new Citation with the type field set.
264    pub fn new() -> Self {
265        Self {
266            block_type: "citation".to_string(),
267            id: None,
268            url: None,
269            title: None,
270            start_index: None,
271            end_index: None,
272            cited_text: None,
273            extras: None,
274        }
275    }
276}
277
278impl Default for Citation {
279    fn default() -> Self {
280        Self::new()
281    }
282}
283
284/// Provider-specific annotation format.
285#[cfg_attr(feature = "specta", derive(Type))]
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
287pub struct NonStandardAnnotation {
288    /// Type of the content block. Always "non_standard_annotation".
289    #[serde(rename = "type")]
290    pub block_type: String,
291    /// Content block identifier.
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub id: Option<String>,
294    /// Provider-specific annotation data.
295    pub value: HashMap<String, serde_json::Value>,
296}
297
298impl NonStandardAnnotation {
299    /// Create a new NonStandardAnnotation.
300    pub fn new(value: HashMap<String, serde_json::Value>) -> Self {
301        Self {
302            block_type: "non_standard_annotation".to_string(),
303            id: None,
304            value,
305        }
306    }
307}
308
309/// A union of all defined Annotation types.
310#[cfg_attr(feature = "specta", derive(Type))]
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
312#[serde(tag = "type")]
313pub enum Annotation {
314    #[serde(rename = "citation")]
315    Citation(Citation),
316    #[serde(rename = "non_standard_annotation")]
317    NonStandardAnnotation(NonStandardAnnotation),
318}
319
320/// Text output from a LLM.
321///
322/// This typically represents the main text content of a message.
323#[cfg_attr(feature = "specta", derive(Type))]
324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
325pub struct TextContentBlock {
326    /// Type of the content block. Always "text".
327    #[serde(rename = "type")]
328    pub block_type: String,
329    /// Content block identifier.
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub id: Option<String>,
332    /// Block text.
333    pub text: String,
334    /// Citations and other annotations.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub annotations: Option<Vec<Annotation>>,
337    /// Index of block in aggregate response. Used during streaming.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub index: Option<BlockIndex>,
340    /// Provider-specific metadata.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub extras: Option<HashMap<String, serde_json::Value>>,
343}
344
345impl TextContentBlock {
346    /// Create a new TextContentBlock with the given text.
347    pub fn new(text: impl Into<String>) -> Self {
348        Self {
349            block_type: "text".to_string(),
350            id: None,
351            text: text.into(),
352            annotations: None,
353            index: None,
354            extras: None,
355        }
356    }
357}
358
359/// Represents an AI's request to call a tool (content block version).
360///
361/// This version includes a `type` field for discrimination and is used
362/// as part of content blocks.
363#[cfg_attr(feature = "specta", derive(Type))]
364#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
365pub struct ToolCallBlock {
366    /// Type of the content block. Always "tool_call".
367    #[serde(rename = "type")]
368    pub block_type: String,
369    /// An identifier associated with the tool call.
370    pub id: Option<String>,
371    /// The name of the tool to be called.
372    pub name: String,
373    /// The arguments to the tool call.
374    pub args: HashMap<String, serde_json::Value>,
375    /// Index of block in aggregate response. Used during streaming.
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub index: Option<BlockIndex>,
378    /// Provider-specific metadata.
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub extras: Option<HashMap<String, serde_json::Value>>,
381}
382
383impl ToolCallBlock {
384    /// Create a new ToolCallBlock.
385    pub fn new(name: impl Into<String>, args: HashMap<String, serde_json::Value>) -> Self {
386        Self {
387            block_type: "tool_call".to_string(),
388            id: None,
389            name: name.into(),
390            args,
391            index: None,
392            extras: None,
393        }
394    }
395}
396
397/// A chunk of a tool call (yielded when streaming, content block version).
398#[cfg_attr(feature = "specta", derive(Type))]
399#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
400pub struct ToolCallChunkBlock {
401    /// Type of the content block. Always "tool_call_chunk".
402    #[serde(rename = "type")]
403    pub block_type: String,
404    /// An identifier associated with the tool call.
405    pub id: Option<String>,
406    /// The name of the tool to be called.
407    pub name: Option<String>,
408    /// The arguments to the tool call (as a string, since it may be partial JSON).
409    pub args: Option<String>,
410    /// The index of the tool call in a sequence.
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub index: Option<BlockIndex>,
413    /// Provider-specific metadata.
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub extras: Option<HashMap<String, serde_json::Value>>,
416}
417
418impl ToolCallChunkBlock {
419    /// Create a new ToolCallChunkBlock.
420    pub fn new() -> Self {
421        Self {
422            block_type: "tool_call_chunk".to_string(),
423            id: None,
424            name: None,
425            args: None,
426            index: None,
427            extras: None,
428        }
429    }
430}
431
432impl Default for ToolCallChunkBlock {
433    fn default() -> Self {
434        Self::new()
435    }
436}
437
438/// Allowance for errors made by LLM (content block version).
439///
440/// Here we add an `error` key to surface errors made during generation.
441#[cfg_attr(feature = "specta", derive(Type))]
442#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
443pub struct InvalidToolCallBlock {
444    /// Type of the content block. Always "invalid_tool_call".
445    #[serde(rename = "type")]
446    pub block_type: String,
447    /// An identifier associated with the tool call.
448    pub id: Option<String>,
449    /// The name of the tool to be called.
450    pub name: Option<String>,
451    /// The arguments to the tool call.
452    pub args: Option<String>,
453    /// An error message associated with the tool call.
454    pub error: Option<String>,
455    /// Index of block in aggregate response. Used during streaming.
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub index: Option<BlockIndex>,
458    /// Provider-specific metadata.
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub extras: Option<HashMap<String, serde_json::Value>>,
461}
462
463impl InvalidToolCallBlock {
464    /// Create a new InvalidToolCallBlock.
465    pub fn new() -> Self {
466        Self {
467            block_type: "invalid_tool_call".to_string(),
468            id: None,
469            name: None,
470            args: None,
471            error: None,
472            index: None,
473            extras: None,
474        }
475    }
476}
477
478impl Default for InvalidToolCallBlock {
479    fn default() -> Self {
480        Self::new()
481    }
482}
483
484/// Tool call that is executed server-side.
485///
486/// For example: code execution, web search, etc.
487#[cfg_attr(feature = "specta", derive(Type))]
488#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
489pub struct ServerToolCall {
490    /// Type of the content block. Always "server_tool_call".
491    #[serde(rename = "type")]
492    pub block_type: String,
493    /// An identifier associated with the tool call.
494    pub id: String,
495    /// The name of the tool to be called.
496    pub name: String,
497    /// The arguments to the tool call.
498    pub args: HashMap<String, serde_json::Value>,
499    /// Index of block in aggregate response. Used during streaming.
500    #[serde(skip_serializing_if = "Option::is_none")]
501    pub index: Option<BlockIndex>,
502    /// Provider-specific metadata.
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub extras: Option<HashMap<String, serde_json::Value>>,
505}
506
507impl ServerToolCall {
508    /// Create a new ServerToolCall.
509    pub fn new(
510        id: impl Into<String>,
511        name: impl Into<String>,
512        args: HashMap<String, serde_json::Value>,
513    ) -> Self {
514        Self {
515            block_type: "server_tool_call".to_string(),
516            id: id.into(),
517            name: name.into(),
518            args,
519            index: None,
520            extras: None,
521        }
522    }
523}
524
525/// A chunk of a server-side tool call (yielded when streaming).
526#[cfg_attr(feature = "specta", derive(Type))]
527#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
528pub struct ServerToolCallChunk {
529    /// Type of the content block. Always "server_tool_call_chunk".
530    #[serde(rename = "type")]
531    pub block_type: String,
532    /// The name of the tool to be called.
533    #[serde(skip_serializing_if = "Option::is_none")]
534    pub name: Option<String>,
535    /// JSON substring of the arguments to the tool call.
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub args: Option<String>,
538    /// An identifier associated with the tool call.
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub id: Option<String>,
541    /// Index of block in aggregate response. Used during streaming.
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub index: Option<BlockIndex>,
544    /// Provider-specific metadata.
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub extras: Option<HashMap<String, serde_json::Value>>,
547}
548
549impl ServerToolCallChunk {
550    /// Create a new ServerToolCallChunk.
551    pub fn new() -> Self {
552        Self {
553            block_type: "server_tool_call_chunk".to_string(),
554            name: None,
555            args: None,
556            id: None,
557            index: None,
558            extras: None,
559        }
560    }
561}
562
563impl Default for ServerToolCallChunk {
564    fn default() -> Self {
565        Self::new()
566    }
567}
568
569/// Execution status of the server-side tool.
570#[cfg_attr(feature = "specta", derive(Type))]
571#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
572#[serde(rename_all = "lowercase")]
573pub enum ServerToolStatus {
574    Success,
575    Error,
576}
577
578/// Result of a server-side tool call.
579#[cfg_attr(feature = "specta", derive(Type))]
580#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
581pub struct ServerToolResult {
582    /// Type of the content block. Always "server_tool_result".
583    #[serde(rename = "type")]
584    pub block_type: String,
585    /// An identifier associated with the server tool result.
586    #[serde(skip_serializing_if = "Option::is_none")]
587    pub id: Option<String>,
588    /// ID of the corresponding server tool call.
589    pub tool_call_id: String,
590    /// Execution status of the server-side tool.
591    pub status: ServerToolStatus,
592    /// Output of the executed tool.
593    #[serde(skip_serializing_if = "Option::is_none")]
594    pub output: Option<serde_json::Value>,
595    /// Index of block in aggregate response. Used during streaming.
596    #[serde(skip_serializing_if = "Option::is_none")]
597    pub index: Option<BlockIndex>,
598    /// Provider-specific metadata.
599    #[serde(skip_serializing_if = "Option::is_none")]
600    pub extras: Option<HashMap<String, serde_json::Value>>,
601}
602
603impl ServerToolResult {
604    /// Create a new successful ServerToolResult.
605    pub fn success(tool_call_id: impl Into<String>) -> Self {
606        Self {
607            block_type: "server_tool_result".to_string(),
608            id: None,
609            tool_call_id: tool_call_id.into(),
610            status: ServerToolStatus::Success,
611            output: None,
612            index: None,
613            extras: None,
614        }
615    }
616
617    /// Create a new error ServerToolResult.
618    pub fn error(tool_call_id: impl Into<String>) -> Self {
619        Self {
620            block_type: "server_tool_result".to_string(),
621            id: None,
622            tool_call_id: tool_call_id.into(),
623            status: ServerToolStatus::Error,
624            output: None,
625            index: None,
626            extras: None,
627        }
628    }
629}
630
631/// Reasoning output from a LLM.
632///
633/// Used to represent reasoning/thinking content from AI models that support
634/// chain-of-thought reasoning (e.g., DeepSeek, Ollama, XAI, Groq).
635#[cfg_attr(feature = "specta", derive(Type))]
636#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
637pub struct ReasoningContentBlock {
638    /// Type of the content block. Always "reasoning".
639    #[serde(rename = "type")]
640    pub block_type: String,
641    /// Content block identifier.
642    #[serde(skip_serializing_if = "Option::is_none")]
643    pub id: Option<String>,
644    /// Reasoning text. Either the thought summary or the raw reasoning text itself.
645    #[serde(skip_serializing_if = "Option::is_none")]
646    pub reasoning: Option<String>,
647    /// Index of block in aggregate response. Used during streaming.
648    #[serde(skip_serializing_if = "Option::is_none")]
649    pub index: Option<BlockIndex>,
650    /// Provider-specific metadata.
651    #[serde(skip_serializing_if = "Option::is_none")]
652    pub extras: Option<HashMap<String, serde_json::Value>>,
653}
654
655impl ReasoningContentBlock {
656    /// Create a new reasoning content block.
657    pub fn new(reasoning: impl Into<String>) -> Self {
658        Self {
659            block_type: "reasoning".to_string(),
660            id: None,
661            reasoning: Some(reasoning.into()),
662            index: None,
663            extras: None,
664        }
665    }
666
667    /// Get the reasoning content.
668    pub fn reasoning(&self) -> Option<&str> {
669        self.reasoning.as_deref()
670    }
671}
672
673impl Default for ReasoningContentBlock {
674    fn default() -> Self {
675        Self {
676            block_type: "reasoning".to_string(),
677            id: None,
678            reasoning: None,
679            index: None,
680            extras: None,
681        }
682    }
683}
684
685/// Image data content block.
686#[cfg_attr(feature = "specta", derive(Type))]
687#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
688pub struct ImageContentBlock {
689    /// Type of the content block. Always "image".
690    #[serde(rename = "type")]
691    pub block_type: String,
692    /// Content block identifier.
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub id: Option<String>,
695    /// ID of the image file, e.g., from a file storage system.
696    #[serde(skip_serializing_if = "Option::is_none")]
697    pub file_id: Option<String>,
698    /// MIME type of the image. Required for base64.
699    #[serde(skip_serializing_if = "Option::is_none")]
700    pub mime_type: Option<String>,
701    /// Index of block in aggregate response. Used during streaming.
702    #[serde(skip_serializing_if = "Option::is_none")]
703    pub index: Option<BlockIndex>,
704    /// URL of the image.
705    #[serde(skip_serializing_if = "Option::is_none")]
706    pub url: Option<String>,
707    /// Data as a base64 string.
708    #[serde(skip_serializing_if = "Option::is_none")]
709    pub base64: Option<String>,
710    /// Provider-specific metadata.
711    #[serde(skip_serializing_if = "Option::is_none")]
712    pub extras: Option<HashMap<String, serde_json::Value>>,
713}
714
715impl ImageContentBlock {
716    /// Create a new ImageContentBlock.
717    pub fn new() -> Self {
718        Self {
719            block_type: "image".to_string(),
720            id: None,
721            file_id: None,
722            mime_type: None,
723            index: None,
724            url: None,
725            base64: None,
726            extras: None,
727        }
728    }
729
730    /// Create an ImageContentBlock from a URL.
731    pub fn from_url(url: impl Into<String>) -> Self {
732        Self {
733            block_type: "image".to_string(),
734            id: None,
735            file_id: None,
736            mime_type: None,
737            index: None,
738            url: Some(url.into()),
739            base64: None,
740            extras: None,
741        }
742    }
743
744    /// Create an ImageContentBlock from base64 data.
745    pub fn from_base64(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
746        Self {
747            block_type: "image".to_string(),
748            id: None,
749            file_id: None,
750            mime_type: Some(mime_type.into()),
751            index: None,
752            url: None,
753            base64: Some(data.into()),
754            extras: None,
755        }
756    }
757}
758
759impl Default for ImageContentBlock {
760    fn default() -> Self {
761        Self::new()
762    }
763}
764
765/// Video data content block.
766#[cfg_attr(feature = "specta", derive(Type))]
767#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
768pub struct VideoContentBlock {
769    /// Type of the content block. Always "video".
770    #[serde(rename = "type")]
771    pub block_type: String,
772    /// Content block identifier.
773    #[serde(skip_serializing_if = "Option::is_none")]
774    pub id: Option<String>,
775    /// ID of the video file, e.g., from a file storage system.
776    #[serde(skip_serializing_if = "Option::is_none")]
777    pub file_id: Option<String>,
778    /// MIME type of the video. Required for base64.
779    #[serde(skip_serializing_if = "Option::is_none")]
780    pub mime_type: Option<String>,
781    /// Index of block in aggregate response. Used during streaming.
782    #[serde(skip_serializing_if = "Option::is_none")]
783    pub index: Option<BlockIndex>,
784    /// URL of the video.
785    #[serde(skip_serializing_if = "Option::is_none")]
786    pub url: Option<String>,
787    /// Data as a base64 string.
788    #[serde(skip_serializing_if = "Option::is_none")]
789    pub base64: Option<String>,
790    /// Provider-specific metadata.
791    #[serde(skip_serializing_if = "Option::is_none")]
792    pub extras: Option<HashMap<String, serde_json::Value>>,
793}
794
795impl VideoContentBlock {
796    /// Create a new VideoContentBlock.
797    pub fn new() -> Self {
798        Self {
799            block_type: "video".to_string(),
800            id: None,
801            file_id: None,
802            mime_type: None,
803            index: None,
804            url: None,
805            base64: None,
806            extras: None,
807        }
808    }
809}
810
811impl Default for VideoContentBlock {
812    fn default() -> Self {
813        Self::new()
814    }
815}
816
817/// Audio data content block.
818#[cfg_attr(feature = "specta", derive(Type))]
819#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
820pub struct AudioContentBlock {
821    /// Type of the content block. Always "audio".
822    #[serde(rename = "type")]
823    pub block_type: String,
824    /// Content block identifier.
825    #[serde(skip_serializing_if = "Option::is_none")]
826    pub id: Option<String>,
827    /// ID of the audio file, e.g., from a file storage system.
828    #[serde(skip_serializing_if = "Option::is_none")]
829    pub file_id: Option<String>,
830    /// MIME type of the audio. Required for base64.
831    #[serde(skip_serializing_if = "Option::is_none")]
832    pub mime_type: Option<String>,
833    /// Index of block in aggregate response. Used during streaming.
834    #[serde(skip_serializing_if = "Option::is_none")]
835    pub index: Option<BlockIndex>,
836    /// URL of the audio.
837    #[serde(skip_serializing_if = "Option::is_none")]
838    pub url: Option<String>,
839    /// Data as a base64 string.
840    #[serde(skip_serializing_if = "Option::is_none")]
841    pub base64: Option<String>,
842    /// Provider-specific metadata.
843    #[serde(skip_serializing_if = "Option::is_none")]
844    pub extras: Option<HashMap<String, serde_json::Value>>,
845}
846
847impl AudioContentBlock {
848    /// Create a new AudioContentBlock.
849    pub fn new() -> Self {
850        Self {
851            block_type: "audio".to_string(),
852            id: None,
853            file_id: None,
854            mime_type: None,
855            index: None,
856            url: None,
857            base64: None,
858            extras: None,
859        }
860    }
861}
862
863impl Default for AudioContentBlock {
864    fn default() -> Self {
865        Self::new()
866    }
867}
868
869/// Plaintext data content block (e.g., from a `.txt` or `.md` document).
870#[cfg_attr(feature = "specta", derive(Type))]
871#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
872pub struct PlainTextContentBlock {
873    /// Type of the content block. Always "text-plain".
874    #[serde(rename = "type")]
875    pub block_type: String,
876    /// Content block identifier.
877    #[serde(skip_serializing_if = "Option::is_none")]
878    pub id: Option<String>,
879    /// ID of the plaintext file, e.g., from a file storage system.
880    #[serde(skip_serializing_if = "Option::is_none")]
881    pub file_id: Option<String>,
882    /// MIME type of the file. Always "text/plain".
883    pub mime_type: String,
884    /// Index of block in aggregate response. Used during streaming.
885    #[serde(skip_serializing_if = "Option::is_none")]
886    pub index: Option<BlockIndex>,
887    /// URL of the plaintext.
888    #[serde(skip_serializing_if = "Option::is_none")]
889    pub url: Option<String>,
890    /// Data as a base64 string.
891    #[serde(skip_serializing_if = "Option::is_none")]
892    pub base64: Option<String>,
893    /// Plaintext content. Optional if the data is provided as base64.
894    #[serde(skip_serializing_if = "Option::is_none")]
895    pub text: Option<String>,
896    /// Title of the text data.
897    #[serde(skip_serializing_if = "Option::is_none")]
898    pub title: Option<String>,
899    /// Context for the text, e.g., a description or summary.
900    #[serde(skip_serializing_if = "Option::is_none")]
901    pub context: Option<String>,
902    /// Provider-specific metadata.
903    #[serde(skip_serializing_if = "Option::is_none")]
904    pub extras: Option<HashMap<String, serde_json::Value>>,
905}
906
907impl PlainTextContentBlock {
908    /// Create a new PlainTextContentBlock.
909    pub fn new() -> Self {
910        Self {
911            block_type: "text-plain".to_string(),
912            id: None,
913            file_id: None,
914            mime_type: "text/plain".to_string(),
915            index: None,
916            url: None,
917            base64: None,
918            text: None,
919            title: None,
920            context: None,
921            extras: None,
922        }
923    }
924}
925
926impl Default for PlainTextContentBlock {
927    fn default() -> Self {
928        Self::new()
929    }
930}
931
932/// File data content block for files that don't fit other categories.
933///
934/// This block is intended for files that are not images, audio, or plaintext.
935/// For example, it can be used for PDFs, Word documents, etc.
936#[cfg_attr(feature = "specta", derive(Type))]
937#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
938pub struct FileContentBlock {
939    /// Type of the content block. Always "file".
940    #[serde(rename = "type")]
941    pub block_type: String,
942    /// Content block identifier.
943    #[serde(skip_serializing_if = "Option::is_none")]
944    pub id: Option<String>,
945    /// ID of the file, e.g., from a file storage system.
946    #[serde(skip_serializing_if = "Option::is_none")]
947    pub file_id: Option<String>,
948    /// MIME type of the file. Required for base64.
949    #[serde(skip_serializing_if = "Option::is_none")]
950    pub mime_type: Option<String>,
951    /// Index of block in aggregate response. Used during streaming.
952    #[serde(skip_serializing_if = "Option::is_none")]
953    pub index: Option<BlockIndex>,
954    /// URL of the file.
955    #[serde(skip_serializing_if = "Option::is_none")]
956    pub url: Option<String>,
957    /// Data as a base64 string.
958    #[serde(skip_serializing_if = "Option::is_none")]
959    pub base64: Option<String>,
960    /// Provider-specific metadata.
961    #[serde(skip_serializing_if = "Option::is_none")]
962    pub extras: Option<HashMap<String, serde_json::Value>>,
963}
964
965impl FileContentBlock {
966    /// Create a new FileContentBlock.
967    pub fn new() -> Self {
968        Self {
969            block_type: "file".to_string(),
970            id: None,
971            file_id: None,
972            mime_type: None,
973            index: None,
974            url: None,
975            base64: None,
976            extras: None,
977        }
978    }
979}
980
981impl Default for FileContentBlock {
982    fn default() -> Self {
983        Self::new()
984    }
985}
986
987/// Provider-specific content data.
988///
989/// This block contains data for which there is not yet a standard type.
990#[cfg_attr(feature = "specta", derive(Type))]
991#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
992pub struct NonStandardContentBlock {
993    /// Type of the content block. Always "non_standard".
994    #[serde(rename = "type")]
995    pub block_type: String,
996    /// Content block identifier.
997    #[serde(skip_serializing_if = "Option::is_none")]
998    pub id: Option<String>,
999    /// Provider-specific content data.
1000    pub value: HashMap<String, serde_json::Value>,
1001    /// Index of block in aggregate response. Used during streaming.
1002    #[serde(skip_serializing_if = "Option::is_none")]
1003    pub index: Option<BlockIndex>,
1004}
1005
1006impl NonStandardContentBlock {
1007    /// Create a new NonStandardContentBlock.
1008    pub fn new(value: HashMap<String, serde_json::Value>) -> Self {
1009        Self {
1010            block_type: "non_standard".to_string(),
1011            id: None,
1012            value,
1013            index: None,
1014        }
1015    }
1016}
1017
1018// =============================================================================
1019// Union Types
1020// =============================================================================
1021
1022/// A union of all defined multimodal data ContentBlock types.
1023#[cfg_attr(feature = "specta", derive(Type))]
1024#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1025#[serde(tag = "type")]
1026pub enum DataContentBlock {
1027    #[serde(rename = "image")]
1028    Image(ImageContentBlock),
1029    #[serde(rename = "video")]
1030    Video(VideoContentBlock),
1031    #[serde(rename = "audio")]
1032    Audio(AudioContentBlock),
1033    #[serde(rename = "text-plain")]
1034    PlainText(PlainTextContentBlock),
1035    #[serde(rename = "file")]
1036    File(FileContentBlock),
1037}
1038
1039/// A union of all tool-related ContentBlock types.
1040#[cfg_attr(feature = "specta", derive(Type))]
1041#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1042#[serde(tag = "type")]
1043pub enum ToolContentBlock {
1044    #[serde(rename = "tool_call")]
1045    ToolCall(ToolCallBlock),
1046    #[serde(rename = "tool_call_chunk")]
1047    ToolCallChunk(ToolCallChunkBlock),
1048    #[serde(rename = "server_tool_call")]
1049    ServerToolCall(ServerToolCall),
1050    #[serde(rename = "server_tool_call_chunk")]
1051    ServerToolCallChunk(ServerToolCallChunk),
1052    #[serde(rename = "server_tool_result")]
1053    ServerToolResult(ServerToolResult),
1054}
1055
1056/// A union of all defined ContentBlock types.
1057#[cfg_attr(feature = "specta", derive(Type))]
1058#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1059#[serde(tag = "type")]
1060pub enum ContentBlock {
1061    #[serde(rename = "text")]
1062    Text(TextContentBlock),
1063    #[serde(rename = "invalid_tool_call")]
1064    InvalidToolCall(InvalidToolCallBlock),
1065    #[serde(rename = "reasoning")]
1066    Reasoning(ReasoningContentBlock),
1067    #[serde(rename = "non_standard")]
1068    NonStandard(NonStandardContentBlock),
1069    #[serde(rename = "image")]
1070    Image(ImageContentBlock),
1071    #[serde(rename = "video")]
1072    Video(VideoContentBlock),
1073    #[serde(rename = "audio")]
1074    Audio(AudioContentBlock),
1075    #[serde(rename = "text-plain")]
1076    PlainText(PlainTextContentBlock),
1077    #[serde(rename = "file")]
1078    File(FileContentBlock),
1079    #[serde(rename = "tool_call")]
1080    ToolCall(ToolCallBlock),
1081    #[serde(rename = "tool_call_chunk")]
1082    ToolCallChunk(ToolCallChunkBlock),
1083    #[serde(rename = "server_tool_call")]
1084    ServerToolCall(ServerToolCall),
1085    #[serde(rename = "server_tool_call_chunk")]
1086    ServerToolCallChunk(ServerToolCallChunk),
1087    #[serde(rename = "server_tool_result")]
1088    ServerToolResult(ServerToolResult),
1089}
1090
1091// =============================================================================
1092// Constants
1093// =============================================================================
1094
1095/// These are block types known to langchain-core>=1.0.0.
1096///
1097/// If a block has a type not in this set, it is considered to be provider-specific.
1098pub const KNOWN_BLOCK_TYPES: &[&str] = &[
1099    // Text output
1100    "text",
1101    "reasoning",
1102    // Tools
1103    "tool_call",
1104    "invalid_tool_call",
1105    "tool_call_chunk",
1106    // Multimodal data
1107    "image",
1108    "audio",
1109    "file",
1110    "text-plain",
1111    "video",
1112    // Server-side tool calls
1113    "server_tool_call",
1114    "server_tool_call_chunk",
1115    "server_tool_result",
1116    // Catch-all
1117    "non_standard",
1118    // citation and non_standard_annotation intentionally omitted
1119];
1120
1121/// Data content block type literals.
1122const DATA_CONTENT_BLOCK_TYPES: &[&str] = &["image", "video", "audio", "text-plain", "file"];
1123
1124// =============================================================================
1125// Helper Functions
1126// =============================================================================
1127
1128/// Check if the provided content block is a data content block.
1129///
1130/// Returns true for both v0 (old-style) and v1 (new-style) multimodal data blocks.
1131pub fn is_data_content_block(block: &serde_json::Value) -> bool {
1132    let block_type = match block.get("type").and_then(|t| t.as_str()) {
1133        Some(t) => t,
1134        None => return false,
1135    };
1136
1137    if !DATA_CONTENT_BLOCK_TYPES.contains(&block_type) {
1138        return false;
1139    }
1140
1141    // Check for new-style data fields
1142    if block.get("url").is_some()
1143        || block.get("base64").is_some()
1144        || block.get("file_id").is_some()
1145        || block.get("text").is_some()
1146    {
1147        // 'text' is checked to support v0 PlainTextContentBlock types
1148        // We must guard against new style TextContentBlock which also has 'text' type
1149        // by ensuring the presence of source_type
1150        if block_type == "text" && block.get("source_type").is_none() {
1151            return false;
1152        }
1153        return true;
1154    }
1155
1156    // Old-style content blocks had possible types of 'image', 'audio', and 'file'
1157    if let Some(source_type) = block.get("source_type").and_then(|s| s.as_str()) {
1158        if (source_type == "url" && block.get("url").is_some())
1159            || (source_type == "base64" && block.get("data").is_some())
1160        {
1161            return true;
1162        }
1163        if (source_type == "id" && block.get("id").is_some())
1164            || (source_type == "text" && block.get("url").is_some())
1165        {
1166            return true;
1167        }
1168    }
1169
1170    false
1171}
1172
1173// =============================================================================
1174// Factory Functions
1175// =============================================================================
1176
1177/// Create a `TextContentBlock`.
1178///
1179/// # Arguments
1180///
1181/// * `text` - The text content of the block.
1182/// * `id` - Content block identifier. Generated automatically if not provided.
1183/// * `annotations` - Citations and other annotations for the text.
1184/// * `index` - Index of block in aggregate response.
1185/// * `extras` - Provider-specific metadata.
1186pub fn create_text_block(
1187    text: impl Into<String>,
1188    id: Option<String>,
1189    annotations: Option<Vec<Annotation>>,
1190    index: Option<BlockIndex>,
1191    extras: Option<HashMap<String, serde_json::Value>>,
1192) -> TextContentBlock {
1193    TextContentBlock {
1194        block_type: "text".to_string(),
1195        text: text.into(),
1196        id: Some(ensure_id(id)),
1197        annotations,
1198        index,
1199        extras,
1200    }
1201}
1202
1203/// Create an `ImageContentBlock`.
1204///
1205/// # Arguments
1206///
1207/// * `url` - URL of the image.
1208/// * `base64` - Base64-encoded image data.
1209/// * `file_id` - ID of the image file from a file storage system.
1210/// * `mime_type` - MIME type of the image. Required for base64 data.
1211/// * `id` - Content block identifier. Generated automatically if not provided.
1212/// * `index` - Index of block in aggregate response.
1213/// * `extras` - Provider-specific metadata.
1214///
1215/// # Errors
1216///
1217/// Returns an error if no image source is provided.
1218pub fn create_image_block(
1219    url: Option<String>,
1220    base64: Option<String>,
1221    file_id: Option<String>,
1222    mime_type: Option<String>,
1223    id: Option<String>,
1224    index: Option<BlockIndex>,
1225    extras: Option<HashMap<String, serde_json::Value>>,
1226) -> Result<ImageContentBlock, &'static str> {
1227    if url.is_none() && base64.is_none() && file_id.is_none() {
1228        return Err("Must provide one of: url, base64, or file_id");
1229    }
1230
1231    Ok(ImageContentBlock {
1232        block_type: "image".to_string(),
1233        id: Some(ensure_id(id)),
1234        url,
1235        base64,
1236        file_id,
1237        mime_type,
1238        index,
1239        extras,
1240    })
1241}
1242
1243/// Create a `VideoContentBlock`.
1244///
1245/// # Arguments
1246///
1247/// * `url` - URL of the video.
1248/// * `base64` - Base64-encoded video data.
1249/// * `file_id` - ID of the video file from a file storage system.
1250/// * `mime_type` - MIME type of the video. Required for base64 data.
1251/// * `id` - Content block identifier. Generated automatically if not provided.
1252/// * `index` - Index of block in aggregate response.
1253/// * `extras` - Provider-specific metadata.
1254///
1255/// # Errors
1256///
1257/// Returns an error if no video source is provided or if base64 is used without mime_type.
1258pub fn create_video_block(
1259    url: Option<String>,
1260    base64: Option<String>,
1261    file_id: Option<String>,
1262    mime_type: Option<String>,
1263    id: Option<String>,
1264    index: Option<BlockIndex>,
1265    extras: Option<HashMap<String, serde_json::Value>>,
1266) -> Result<VideoContentBlock, &'static str> {
1267    if url.is_none() && base64.is_none() && file_id.is_none() {
1268        return Err("Must provide one of: url, base64, or file_id");
1269    }
1270
1271    if base64.is_some() && mime_type.is_none() {
1272        return Err("mime_type is required when using base64 data");
1273    }
1274
1275    Ok(VideoContentBlock {
1276        block_type: "video".to_string(),
1277        id: Some(ensure_id(id)),
1278        url,
1279        base64,
1280        file_id,
1281        mime_type,
1282        index,
1283        extras,
1284    })
1285}
1286
1287/// Create an `AudioContentBlock`.
1288///
1289/// # Arguments
1290///
1291/// * `url` - URL of the audio.
1292/// * `base64` - Base64-encoded audio data.
1293/// * `file_id` - ID of the audio file from a file storage system.
1294/// * `mime_type` - MIME type of the audio. Required for base64 data.
1295/// * `id` - Content block identifier. Generated automatically if not provided.
1296/// * `index` - Index of block in aggregate response.
1297/// * `extras` - Provider-specific metadata.
1298///
1299/// # Errors
1300///
1301/// Returns an error if no audio source is provided or if base64 is used without mime_type.
1302pub fn create_audio_block(
1303    url: Option<String>,
1304    base64: Option<String>,
1305    file_id: Option<String>,
1306    mime_type: Option<String>,
1307    id: Option<String>,
1308    index: Option<BlockIndex>,
1309    extras: Option<HashMap<String, serde_json::Value>>,
1310) -> Result<AudioContentBlock, &'static str> {
1311    if url.is_none() && base64.is_none() && file_id.is_none() {
1312        return Err("Must provide one of: url, base64, or file_id");
1313    }
1314
1315    if base64.is_some() && mime_type.is_none() {
1316        return Err("mime_type is required when using base64 data");
1317    }
1318
1319    Ok(AudioContentBlock {
1320        block_type: "audio".to_string(),
1321        id: Some(ensure_id(id)),
1322        url,
1323        base64,
1324        file_id,
1325        mime_type,
1326        index,
1327        extras,
1328    })
1329}
1330
1331/// Create a `FileContentBlock`.
1332///
1333/// # Arguments
1334///
1335/// * `url` - URL of the file.
1336/// * `base64` - Base64-encoded file data.
1337/// * `file_id` - ID of the file from a file storage system.
1338/// * `mime_type` - MIME type of the file. Required for base64 data.
1339/// * `id` - Content block identifier. Generated automatically if not provided.
1340/// * `index` - Index of block in aggregate response.
1341/// * `extras` - Provider-specific metadata.
1342///
1343/// # Errors
1344///
1345/// Returns an error if no file source is provided or if base64 is used without mime_type.
1346pub fn create_file_block(
1347    url: Option<String>,
1348    base64: Option<String>,
1349    file_id: Option<String>,
1350    mime_type: Option<String>,
1351    id: Option<String>,
1352    index: Option<BlockIndex>,
1353    extras: Option<HashMap<String, serde_json::Value>>,
1354) -> Result<FileContentBlock, &'static str> {
1355    if url.is_none() && base64.is_none() && file_id.is_none() {
1356        return Err("Must provide one of: url, base64, or file_id");
1357    }
1358
1359    if base64.is_some() && mime_type.is_none() {
1360        return Err("mime_type is required when using base64 data");
1361    }
1362
1363    Ok(FileContentBlock {
1364        block_type: "file".to_string(),
1365        id: Some(ensure_id(id)),
1366        url,
1367        base64,
1368        file_id,
1369        mime_type,
1370        index,
1371        extras,
1372    })
1373}
1374
1375/// Configuration for creating a `PlainTextContentBlock`.
1376#[derive(Debug, Clone, Default)]
1377pub struct PlainTextBlockConfig {
1378    /// The plaintext content.
1379    pub text: Option<String>,
1380    /// URL of the plaintext file.
1381    pub url: Option<String>,
1382    /// Base64-encoded plaintext data.
1383    pub base64: Option<String>,
1384    /// ID of the plaintext file from a file storage system.
1385    pub file_id: Option<String>,
1386    /// Title of the text data.
1387    pub title: Option<String>,
1388    /// Context or description of the text content.
1389    pub context: Option<String>,
1390    /// Content block identifier. Generated automatically if not provided.
1391    pub id: Option<String>,
1392    /// Index of block in aggregate response.
1393    pub index: Option<BlockIndex>,
1394    /// Provider-specific metadata.
1395    pub extras: Option<HashMap<String, serde_json::Value>>,
1396}
1397
1398/// Create a `PlainTextContentBlock`.
1399///
1400/// # Arguments
1401///
1402/// * `config` - Configuration for the plaintext block.
1403pub fn create_plaintext_block(config: PlainTextBlockConfig) -> PlainTextContentBlock {
1404    PlainTextContentBlock {
1405        block_type: "text-plain".to_string(),
1406        mime_type: "text/plain".to_string(),
1407        id: Some(ensure_id(config.id)),
1408        text: config.text,
1409        url: config.url,
1410        base64: config.base64,
1411        file_id: config.file_id,
1412        title: config.title,
1413        context: config.context,
1414        index: config.index,
1415        extras: config.extras,
1416    }
1417}
1418
1419/// Create a `ToolCallBlock`.
1420///
1421/// # Arguments
1422///
1423/// * `name` - The name of the tool to be called.
1424/// * `args` - The arguments to the tool call.
1425/// * `id` - An identifier for the tool call. Generated automatically if not provided.
1426/// * `index` - Index of block in aggregate response.
1427/// * `extras` - Provider-specific metadata.
1428pub fn create_tool_call_block(
1429    name: impl Into<String>,
1430    args: HashMap<String, serde_json::Value>,
1431    id: Option<String>,
1432    index: Option<BlockIndex>,
1433    extras: Option<HashMap<String, serde_json::Value>>,
1434) -> ToolCallBlock {
1435    ToolCallBlock {
1436        block_type: "tool_call".to_string(),
1437        name: name.into(),
1438        args,
1439        id: Some(ensure_id(id)),
1440        index,
1441        extras,
1442    }
1443}
1444
1445/// Create a `ReasoningContentBlock`.
1446///
1447/// # Arguments
1448///
1449/// * `reasoning` - The reasoning text or thought summary.
1450/// * `id` - Content block identifier. Generated automatically if not provided.
1451/// * `index` - Index of block in aggregate response.
1452/// * `extras` - Provider-specific metadata.
1453pub fn create_reasoning_block(
1454    reasoning: Option<String>,
1455    id: Option<String>,
1456    index: Option<BlockIndex>,
1457    extras: Option<HashMap<String, serde_json::Value>>,
1458) -> ReasoningContentBlock {
1459    ReasoningContentBlock {
1460        block_type: "reasoning".to_string(),
1461        reasoning,
1462        id: Some(ensure_id(id)),
1463        index,
1464        extras,
1465    }
1466}
1467
1468/// Create a `Citation`.
1469///
1470/// # Arguments
1471///
1472/// * `url` - URL of the document source.
1473/// * `title` - Source document title.
1474/// * `start_index` - Start index in the response text where citation applies.
1475/// * `end_index` - End index in the response text where citation applies.
1476/// * `cited_text` - Excerpt of source text being cited.
1477/// * `id` - Content block identifier. Generated automatically if not provided.
1478/// * `extras` - Provider-specific metadata.
1479pub fn create_citation(
1480    url: Option<String>,
1481    title: Option<String>,
1482    start_index: Option<i64>,
1483    end_index: Option<i64>,
1484    cited_text: Option<String>,
1485    id: Option<String>,
1486    extras: Option<HashMap<String, serde_json::Value>>,
1487) -> Citation {
1488    Citation {
1489        block_type: "citation".to_string(),
1490        id: Some(ensure_id(id)),
1491        url,
1492        title,
1493        start_index,
1494        end_index,
1495        cited_text,
1496        extras,
1497    }
1498}
1499
1500/// Create a `NonStandardContentBlock`.
1501///
1502/// # Arguments
1503///
1504/// * `value` - Provider-specific content data.
1505/// * `id` - Content block identifier. Generated automatically if not provided.
1506/// * `index` - Index of block in aggregate response.
1507pub fn create_non_standard_block(
1508    value: HashMap<String, serde_json::Value>,
1509    id: Option<String>,
1510    index: Option<BlockIndex>,
1511) -> NonStandardContentBlock {
1512    NonStandardContentBlock {
1513        block_type: "non_standard".to_string(),
1514        value,
1515        id: Some(ensure_id(id)),
1516        index,
1517    }
1518}
1519
1520#[cfg(test)]
1521mod tests {
1522    use super::*;
1523
1524    #[test]
1525    fn test_text_content_block_serialization() {
1526        let block = TextContentBlock::new("Hello, world!");
1527        let json = serde_json::to_string(&block).unwrap();
1528        assert!(json.contains("\"type\":\"text\""));
1529        assert!(json.contains("\"text\":\"Hello, world!\""));
1530    }
1531
1532    #[test]
1533    fn test_create_text_block() {
1534        let block = create_text_block("Test", None, None, None, None);
1535        assert_eq!(block.text, "Test");
1536        assert!(block.id.unwrap().starts_with("lc_"));
1537    }
1538
1539    #[test]
1540    fn test_create_image_block() {
1541        let block = create_image_block(
1542            Some("https://example.com/image.png".to_string()),
1543            None,
1544            None,
1545            Some("image/png".to_string()),
1546            None,
1547            None,
1548            None,
1549        )
1550        .unwrap();
1551        assert_eq!(block.url.as_ref().unwrap(), "https://example.com/image.png");
1552        assert_eq!(block.mime_type.as_ref().unwrap(), "image/png");
1553    }
1554
1555    #[test]
1556    fn test_create_image_block_error() {
1557        let result = create_image_block(None, None, None, None, None, None, None);
1558        assert!(result.is_err());
1559        assert_eq!(
1560            result.unwrap_err(),
1561            "Must provide one of: url, base64, or file_id"
1562        );
1563    }
1564
1565    #[test]
1566    fn test_reasoning_content_block() {
1567        let block = ReasoningContentBlock::new("Thinking...");
1568        assert_eq!(block.reasoning(), Some("Thinking..."));
1569        assert_eq!(block.block_type, "reasoning");
1570    }
1571
1572    #[test]
1573    fn test_known_block_types() {
1574        assert!(KNOWN_BLOCK_TYPES.contains(&"text"));
1575        assert!(KNOWN_BLOCK_TYPES.contains(&"reasoning"));
1576        assert!(KNOWN_BLOCK_TYPES.contains(&"image"));
1577        assert!(KNOWN_BLOCK_TYPES.contains(&"tool_call"));
1578    }
1579
1580    #[test]
1581    fn test_is_data_content_block() {
1582        let image_block = serde_json::json!({
1583            "type": "image",
1584            "url": "https://example.com/image.png"
1585        });
1586        assert!(is_data_content_block(&image_block));
1587
1588        let text_block = serde_json::json!({
1589            "type": "text",
1590            "text": "Hello"
1591        });
1592        assert!(!is_data_content_block(&text_block));
1593    }
1594
1595    #[test]
1596    fn test_content_block_enum_serialization() {
1597        let block = ContentBlock::Text(TextContentBlock::new("Hello"));
1598        let json = serde_json::to_string(&block).unwrap();
1599        assert!(json.contains("\"type\":\"text\""));
1600    }
1601
1602    #[test]
1603    fn test_legacy_message_content() {
1604        let content = MessageContent::Text("Hello".to_string());
1605        assert_eq!(content.as_text(), "Hello");
1606
1607        let content = MessageContent::Parts(vec![
1608            ContentPart::Text {
1609                text: "Hello".to_string(),
1610            },
1611            ContentPart::Text {
1612                text: "World".to_string(),
1613            },
1614        ]);
1615        assert_eq!(content.as_text(), "Hello World");
1616    }
1617}