Skip to main content

openrouter/types/
message.rs

1//! Chat-style messages and multimodal content parts.
2
3use serde::{Deserialize, Serialize};
4
5use super::{Annotation, ToolCall};
6
7/// Role of a chat message author.
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum Role {
11    /// System / developer prompt.
12    System,
13    /// End-user input.
14    User,
15    /// Model output.
16    Assistant,
17    /// Tool-call response message.
18    Tool,
19}
20
21/// A chat message.
22#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
23pub struct Message {
24    /// Author role.
25    pub role: Role,
26    /// Message content — either a plain string or typed parts for
27    /// multimodal inputs.
28    pub content: Content,
29    /// Optional human-readable name (rarely used).
30    #[serde(skip_serializing_if = "Option::is_none", default)]
31    pub name: Option<String>,
32    /// Tool calls requested by the assistant (assistant messages only).
33    #[serde(skip_serializing_if = "Option::is_none", default)]
34    pub tool_calls: Option<Vec<ToolCall>>,
35    /// Tool call id this message responds to (tool messages only).
36    #[serde(skip_serializing_if = "Option::is_none", default)]
37    pub tool_call_id: Option<String>,
38    /// Reasoning trace returned by the model (non-streaming responses).
39    /// Streaming reasoning chunks come through [`crate::types::Delta::reasoning`].
40    #[serde(skip_serializing_if = "Option::is_none", default)]
41    pub reasoning: Option<String>,
42    /// Typed annotations attached by plugins (e.g. web-search citations).
43    #[serde(skip_serializing_if = "Option::is_none", default)]
44    pub annotations: Option<Vec<Annotation>>,
45}
46
47/// Message content: either a plain string or an array of typed parts.
48#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
49#[serde(untagged)]
50pub enum Content {
51    /// Single plain-text string.
52    Text(String),
53    /// Array of typed multimodal parts.
54    Parts(Vec<ContentPart>),
55}
56
57impl Content {
58    /// Borrowed plain-text view when the content is a single string.
59    pub fn as_text(&self) -> Option<&str> {
60        match self {
61            Content::Text(s) => Some(s),
62            Content::Parts(_) => None,
63        }
64    }
65}
66
67impl From<String> for Content {
68    fn from(s: String) -> Self {
69        Content::Text(s)
70    }
71}
72
73impl From<&str> for Content {
74    fn from(s: &str) -> Self {
75        Content::Text(s.to_string())
76    }
77}
78
79/// One element of a multimodal content array.
80#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
81#[serde(tag = "type", rename_all = "snake_case")]
82pub enum ContentPart {
83    /// Inline text.
84    Text {
85        /// Text content.
86        text: String,
87    },
88    /// Image URL (HTTPS or data URL).
89    ImageUrl {
90        /// The image reference.
91        image_url: ImageUrl,
92    },
93    /// File attachment (PDF / text file).
94    File {
95        /// The file reference.
96        file: FileRef,
97    },
98    /// Inline audio input.
99    InputAudio {
100        /// The audio payload.
101        input_audio: InputAudio,
102    },
103}
104
105/// Image URL (or data URL) reference.
106#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
107pub struct ImageUrl {
108    /// HTTPS URL or `data:` URL pointing at the image bytes.
109    pub url: String,
110    /// Image-detail hint (`low`, `high`, `auto`).
111    #[serde(skip_serializing_if = "Option::is_none", default)]
112    pub detail: Option<String>,
113}
114
115/// File reference (URL or inline base64). Multimodal Phase 4 expands this.
116#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
117pub struct FileRef {
118    /// Display filename.
119    #[serde(skip_serializing_if = "Option::is_none", default)]
120    pub filename: Option<String>,
121    /// Inline data URL (base64) when sending the bytes directly.
122    #[serde(skip_serializing_if = "Option::is_none", default)]
123    pub file_data: Option<String>,
124    /// HTTPS URL when serving from a public location.
125    #[serde(skip_serializing_if = "Option::is_none", default)]
126    pub file_url: Option<String>,
127}
128
129/// Inline audio input.
130#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
131pub struct InputAudio {
132    /// Base64-encoded audio bytes.
133    pub data: String,
134    /// Format hint (`wav`, `mp3`).
135    pub format: String,
136}
137
138impl Message {
139    /// Construct a `system` message.
140    pub fn system(content: impl Into<String>) -> Self {
141        Self::new(Role::System, content)
142    }
143
144    /// Construct a `user` message.
145    pub fn user(content: impl Into<String>) -> Self {
146        Self::new(Role::User, content)
147    }
148
149    /// Construct an `assistant` message.
150    pub fn assistant(content: impl Into<String>) -> Self {
151        Self::new(Role::Assistant, content)
152    }
153
154    /// Construct a `tool` message responding to a prior tool call.
155    pub fn tool(content: impl Into<String>, tool_call_id: impl Into<String>) -> Self {
156        Self {
157            role: Role::Tool,
158            content: Content::Text(content.into()),
159            name: None,
160            tool_calls: None,
161            tool_call_id: Some(tool_call_id.into()),
162            reasoning: None,
163            annotations: None,
164        }
165    }
166
167    fn new(role: Role, content: impl Into<String>) -> Self {
168        Self {
169            role,
170            content: Content::Text(content.into()),
171            name: None,
172            tool_calls: None,
173            tool_call_id: None,
174            reasoning: None,
175            annotations: None,
176        }
177    }
178
179    /// Plain-text view of this message's content, when available.
180    pub fn content_text(&self) -> Option<&str> {
181        self.content.as_text()
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use pretty_assertions::assert_eq;
189    use serde_json::json;
190
191    #[test]
192    fn string_content_round_trip() {
193        let m = Message::user("hello");
194        let v = serde_json::to_value(&m).unwrap();
195        assert_eq!(v, json!({"role":"user","content":"hello"}));
196        let back: Message = serde_json::from_value(v).unwrap();
197        assert_eq!(back, m);
198    }
199
200    #[test]
201    fn parts_content_deserializes() {
202        let v = json!({
203            "role": "user",
204            "content": [
205                {"type": "text", "text": "look at this"},
206                {"type": "image_url", "image_url": {"url": "https://x/y.png"}}
207            ]
208        });
209        let m: Message = serde_json::from_value(v).unwrap();
210        match &m.content {
211            Content::Parts(p) => assert_eq!(p.len(), 2),
212            _ => panic!("expected parts"),
213        }
214    }
215
216    #[test]
217    fn assistant_with_tool_calls() {
218        let v = json!({
219            "role": "assistant",
220            "content": "",
221            "tool_calls": [
222                {"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}
223            ]
224        });
225        let m: Message = serde_json::from_value(v).unwrap();
226        assert_eq!(m.role, Role::Assistant);
227        assert_eq!(m.tool_calls.as_ref().unwrap().len(), 1);
228    }
229
230    #[test]
231    fn optional_fields_skipped_when_none() {
232        let m = Message::system("hi");
233        let s = serde_json::to_string(&m).unwrap();
234        assert!(!s.contains("name"));
235        assert!(!s.contains("tool_calls"));
236        assert!(!s.contains("tool_call_id"));
237    }
238}