Skip to main content

rskit_ai/
content.rs

1//! Multimodal content and tool block vocabulary.
2
3use serde::{Deserialize, Serialize};
4
5/// A single content part in a multimodal message.
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
7#[serde(tag = "type", rename_all = "snake_case")]
8#[non_exhaustive]
9pub enum ContentPart {
10    /// Plain text content.
11    Text {
12        /// UTF-8 text.
13        text: String,
14    },
15    /// Image content by URL/source and optional inline data.
16    Image {
17        /// Provider-readable source, such as a URL or media identifier.
18        source: String,
19        /// MIME type, for example `image/png`.
20        mime_type: String,
21        /// Optional base64-encoded data.
22        #[serde(default, skip_serializing_if = "Option::is_none")]
23        data: Option<String>,
24    },
25    /// Audio content by URL/source and optional inline data.
26    Audio {
27        /// Provider-readable source, such as a URL or media identifier.
28        source: String,
29        /// MIME type, for example `audio/mpeg`.
30        mime_type: String,
31        /// Optional base64-encoded data.
32        #[serde(default, skip_serializing_if = "Option::is_none")]
33        data: Option<String>,
34    },
35    /// Video content by URL/source and optional inline data.
36    Video {
37        /// Provider-readable source, such as a URL or media identifier.
38        source: String,
39        /// MIME type, for example `video/mp4`.
40        mime_type: String,
41        /// Optional base64-encoded data.
42        #[serde(default, skip_serializing_if = "Option::is_none")]
43        data: Option<String>,
44    },
45    /// File content by URL/source and optional inline data.
46    File {
47        /// Provider-readable source, such as a URL or media identifier.
48        source: String,
49        /// MIME type, for example `application/pdf`.
50        mime_type: String,
51        /// Optional base64-encoded data.
52        #[serde(default, skip_serializing_if = "Option::is_none")]
53        data: Option<String>,
54    },
55    /// Tool-use request block emitted by a model.
56    ToolUse {
57        /// Tool-use identifier.
58        id: String,
59        /// Tool name.
60        name: String,
61        /// Tool input JSON.
62        input: serde_json::Map<String, serde_json::Value>,
63    },
64    /// Tool-result block returned to a model.
65    ToolResult {
66        /// Tool-use identifier this result satisfies.
67        #[serde(alias = "tool_use_id")]
68        id: String,
69        /// Human-readable content.
70        content: String,
71        /// Whether the tool returned an error.
72        #[serde(default)]
73        is_error: bool,
74    },
75}
76
77/// Tool-use block shape shared across LLM/tool/MCP boundaries.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct ToolUseBlock {
80    /// Tool-use identifier.
81    pub id: String,
82    /// Tool name.
83    pub name: String,
84    /// Tool input JSON.
85    pub input: serde_json::Map<String, serde_json::Value>,
86}
87
88/// Tool-result block shape shared across LLM/tool/MCP boundaries.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct ToolResultBlock {
91    /// Tool-use identifier this result satisfies.
92    pub id: String,
93    /// Human-readable content returned by the tool.
94    pub content: String,
95    /// Whether the result represents a tool failure.
96    #[serde(default)]
97    pub is_error: bool,
98}
99
100impl ContentPart {
101    /// Rough character estimate used by approximate token counters.
102    #[must_use]
103    pub fn approx_chars(&self) -> usize {
104        match self {
105            Self::Text { text } => text.len(),
106            Self::ToolUse { input, .. } => {
107                serde_json::Value::Object(input.clone()).to_string().len()
108            }
109            Self::ToolResult { content, .. } => content.len(),
110            Self::Image { .. } | Self::Audio { .. } | Self::Video { .. } | Self::File { .. } => 256,
111        }
112    }
113}
114
115/// Wrap text in a single text content block.
116#[must_use]
117pub fn text_content(text: impl Into<String>) -> Vec<ContentPart> {
118    vec![ContentPart::Text { text: text.into() }]
119}
120
121/// Extract concatenated text from content blocks.
122#[must_use]
123pub fn text_of(blocks: &[ContentPart]) -> String {
124    blocks
125        .iter()
126        .filter_map(|block| match block {
127            ContentPart::Text { text } => Some(text.as_str()),
128            _ => None,
129        })
130        .collect::<Vec<_>>()
131        .join("")
132}