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