Skip to main content

runifold_model/
content.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::{ExtensionMap, ModelError, ModelErrorKind};
7
8/// The author role of a model message.
9#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
10#[serde(rename_all = "snake_case")]
11#[non_exhaustive]
12pub enum Role {
13    /// High-priority system or developer instruction.
14    System,
15    /// End-user input.
16    User,
17    /// Model output.
18    Assistant,
19    /// A tool result represented as a message by a provider.
20    Tool,
21}
22
23/// A serializable media source.
24#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
25#[serde(tag = "type", rename_all = "snake_case")]
26#[non_exhaustive]
27pub enum MediaSource {
28    /// An externally accessible URL.
29    Url {
30        /// Media URL.
31        url: String,
32        /// Optional MIME type.
33        media_type: Option<String>,
34    },
35    /// An inline base64 payload.
36    Base64 {
37        /// MIME type.
38        media_type: String,
39        /// Base64-encoded bytes.
40        data: String,
41    },
42    /// A reference into an application-owned artifact store.
43    Artifact {
44        /// Stable artifact identity.
45        artifact_id: String,
46        /// Optional MIME type.
47        media_type: Option<String>,
48    },
49}
50
51/// Provider-specific data retained without normalization.
52#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
53pub struct ProviderData {
54    /// Provider namespace.
55    pub provider: String,
56    /// Provider-defined data kind.
57    pub kind: String,
58    /// Unmodified structured data.
59    pub value: Value,
60}
61
62/// A normalized citation.
63#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
64pub struct Citation {
65    /// Referenced URI, when available.
66    pub uri: Option<String>,
67    /// Human-readable title.
68    pub title: Option<String>,
69    /// Optional character start offset in the associated text.
70    pub start: Option<u64>,
71    /// Optional character end offset in the associated text.
72    pub end: Option<u64>,
73}
74
75/// Model reasoning retained for valid round trips.
76#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
77pub struct ReasoningPart {
78    /// Reasoning text or provider-generated summary, when exposed.
79    pub text: Option<String>,
80    /// Provider signature or encrypted continuation token.
81    pub signature: Option<String>,
82    /// Whether the reasoning body was redacted by the provider.
83    pub redacted: bool,
84    /// Provider information that has no normalized representation.
85    pub provider_data: Vec<ProviderData>,
86}
87
88/// A completed tool call requested by a model.
89#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
90pub struct ToolCall {
91    /// Provider- or runtime-assigned call identity.
92    pub id: String,
93    /// Tool name.
94    pub name: String,
95    /// Parsed JSON arguments.
96    pub arguments: Value,
97    /// Original argument text, when preserving it matters.
98    pub raw_arguments: Option<String>,
99    /// Namespaced metadata.
100    pub metadata: ExtensionMap,
101}
102
103/// A completed tool result supplied to a model.
104#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
105pub struct ToolResult {
106    /// Identity of the tool call being answered.
107    pub call_id: String,
108    /// Tool name, required by providers that do not correlate results by ID.
109    #[serde(default)]
110    pub name: Option<String>,
111    /// Rich result content.
112    pub content: Vec<ContentPart>,
113    /// Whether tool execution failed.
114    pub is_error: bool,
115    /// Namespaced metadata.
116    pub metadata: ExtensionMap,
117}
118
119/// One ordered unit of model-visible content.
120#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
121#[serde(tag = "type", rename_all = "snake_case")]
122#[non_exhaustive]
123pub enum ContentPart {
124    /// Plain text.
125    Text {
126        /// Text body.
127        text: String,
128    },
129    /// Image content.
130    Image {
131        /// Image source.
132        source: MediaSource,
133    },
134    /// Audio content.
135    Audio {
136        /// Audio source.
137        source: MediaSource,
138    },
139    /// Document content.
140    Document {
141        /// Document source.
142        source: MediaSource,
143        /// Optional display name.
144        name: Option<String>,
145    },
146    /// A model-requested tool call.
147    ToolCall(ToolCall),
148    /// A tool result returned to a model.
149    ToolResult(ToolResult),
150    /// Provider reasoning or thinking data.
151    Reasoning(ReasoningPart),
152    /// A provider refusal.
153    Refusal {
154        /// Refusal explanation.
155        text: String,
156    },
157    /// A citation associated with preceding or adjacent content.
158    Citation(Citation),
159    /// Information that cannot yet be normalized without loss.
160    ProviderOpaque(ProviderData),
161}
162
163impl ContentPart {
164    /// Creates a text content part.
165    pub fn text(value: impl Into<String>) -> Self {
166        Self::Text { text: value.into() }
167    }
168}
169
170/// An ordered message sent to or returned by a model.
171#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
172pub struct Message {
173    /// Message author role.
174    pub role: Role,
175    /// Ordered rich content.
176    pub content: Vec<ContentPart>,
177    /// Namespaced metadata.
178    pub metadata: BTreeMap<String, Value>,
179}
180
181impl Message {
182    /// Creates a non-empty message.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`ModelError`] when `content` is empty.
187    pub fn new(role: Role, content: Vec<ContentPart>) -> Result<Self, ModelError> {
188        if content.is_empty() {
189            return Err(ModelError::local(
190                ModelErrorKind::InvalidRequest,
191                "a message must contain at least one content part",
192            ));
193        }
194        Ok(Self {
195            role,
196            content,
197            metadata: BTreeMap::new(),
198        })
199    }
200
201    /// Creates a user text message.
202    pub fn user(text: impl Into<String>) -> Self {
203        Self {
204            role: Role::User,
205            content: vec![ContentPart::text(text)],
206            metadata: BTreeMap::new(),
207        }
208    }
209
210    /// Creates a system text message.
211    pub fn system(text: impl Into<String>) -> Self {
212        Self {
213            role: Role::System,
214            content: vec![ContentPart::text(text)],
215            metadata: BTreeMap::new(),
216        }
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::{ContentPart, Message, Role, ToolResult};
223    use crate::ModelErrorKind;
224
225    #[test]
226    fn empty_messages_are_rejected() {
227        let error = Message::new(Role::User, Vec::new()).unwrap_err();
228        assert_eq!(error.kind, ModelErrorKind::InvalidRequest);
229    }
230
231    #[test]
232    fn content_round_trips_without_erasing_opaque_data() {
233        let message = Message::new(
234            Role::Assistant,
235            vec![
236                ContentPart::text("answer"),
237                ContentPart::ProviderOpaque(super::ProviderData {
238                    provider: "example".into(),
239                    kind: "future_block".into(),
240                    value: serde_json::json!({"x": 1}),
241                }),
242            ],
243        )
244        .unwrap();
245
246        let encoded = serde_json::to_value(&message).unwrap();
247        let decoded: Message = serde_json::from_value(encoded).unwrap();
248
249        assert_eq!(decoded, message);
250    }
251
252    #[test]
253    fn legacy_tool_results_without_a_name_still_deserialize() {
254        let result: ToolResult = serde_json::from_value(serde_json::json!({
255            "call_id":"call_1",
256            "content":[{"type":"text","text":"ok"}],
257            "is_error":false,
258            "metadata":{}
259        }))
260        .unwrap();
261
262        assert_eq!(result.name, None);
263    }
264}