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