1use serde::{Deserialize, Serialize};
4
5use super::{Annotation, ToolCall};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum Role {
11 System,
13 User,
15 Assistant,
17 Tool,
19}
20
21#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
23pub struct Message {
24 pub role: Role,
26 pub content: Content,
29 #[serde(skip_serializing_if = "Option::is_none", default)]
31 pub name: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none", default)]
34 pub tool_calls: Option<Vec<ToolCall>>,
35 #[serde(skip_serializing_if = "Option::is_none", default)]
37 pub tool_call_id: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none", default)]
41 pub reasoning: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none", default)]
44 pub annotations: Option<Vec<Annotation>>,
45}
46
47#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
49#[serde(untagged)]
50pub enum Content {
51 Text(String),
53 Parts(Vec<ContentPart>),
55}
56
57impl Content {
58 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
81#[serde(tag = "type", rename_all = "snake_case")]
82pub enum ContentPart {
83 Text {
85 text: String,
87 },
88 ImageUrl {
90 image_url: ImageUrl,
92 },
93 File {
95 file: FileRef,
97 },
98 InputAudio {
100 input_audio: InputAudio,
102 },
103}
104
105#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
107pub struct ImageUrl {
108 pub url: String,
110 #[serde(skip_serializing_if = "Option::is_none", default)]
112 pub detail: Option<String>,
113}
114
115#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
117pub struct FileRef {
118 #[serde(skip_serializing_if = "Option::is_none", default)]
120 pub filename: Option<String>,
121 #[serde(skip_serializing_if = "Option::is_none", default)]
123 pub file_data: Option<String>,
124 #[serde(skip_serializing_if = "Option::is_none", default)]
126 pub file_url: Option<String>,
127}
128
129#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
131pub struct InputAudio {
132 pub data: String,
134 pub format: String,
136}
137
138impl Message {
139 pub fn system(content: impl Into<String>) -> Self {
141 Self::new(Role::System, content)
142 }
143
144 pub fn user(content: impl Into<String>) -> Self {
146 Self::new(Role::User, content)
147 }
148
149 pub fn assistant(content: impl Into<String>) -> Self {
151 Self::new(Role::Assistant, content)
152 }
153
154 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 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}