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}