1use serde::{Deserialize, Serialize};
13
14#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
16#[serde(tag = "role", rename_all = "lowercase")]
17pub enum Message {
18 User { content: Vec<UserContent> },
20 Assistant { content: Vec<AssistantContent> },
22}
23
24impl Message {
25 pub fn user(text: impl Into<String>) -> Self {
27 Message::User {
28 content: vec![UserContent::text(text)],
29 }
30 }
31
32 pub fn assistant(text: impl Into<String>) -> Self {
34 Message::Assistant {
35 content: vec![AssistantContent::text(text)],
36 }
37 }
38}
39
40#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
42#[serde(tag = "type", rename_all = "lowercase")]
43pub enum UserContent {
44 Text(Text),
45 ToolResult(ToolResult),
46 Image(Image),
47}
48
49impl UserContent {
50 pub fn text(text: impl Into<String>) -> Self {
52 UserContent::Text(Text { text: text.into() })
53 }
54}
55
56#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
58#[serde(untagged)]
59pub enum AssistantContent {
60 Text(Text),
61 ToolCall(ToolCall),
62 Reasoning(Reasoning),
63}
64
65impl AssistantContent {
66 pub fn text(text: impl Into<String>) -> Self {
68 AssistantContent::Text(Text { text: text.into() })
69 }
70}
71
72#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
74pub struct Text {
75 pub text: String,
76}
77
78impl From<String> for Text {
79 fn from(text: String) -> Self {
80 Text { text }
81 }
82}
83
84impl From<&str> for Text {
85 fn from(text: &str) -> Self {
86 Text { text: text.into() }
87 }
88}
89
90impl std::fmt::Display for Text {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 write!(f, "{}", self.text)
93 }
94}
95
96#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
98pub struct ToolCall {
99 pub id: String,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub call_id: Option<String>,
104 pub function: ToolFunction,
105}
106
107impl ToolCall {
108 pub fn new(
109 id: impl Into<String>,
110 name: impl Into<String>,
111 arguments: serde_json::Value,
112 ) -> Self {
113 ToolCall {
114 id: id.into(),
115 call_id: None,
116 function: ToolFunction {
117 name: name.into(),
118 arguments,
119 },
120 }
121 }
122}
123
124#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
126pub struct ToolFunction {
127 pub name: String,
128 pub arguments: serde_json::Value,
129}
130
131#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
133pub struct ToolResult {
134 pub id: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub call_id: Option<String>,
138 pub content: Vec<ToolResultContent>,
139}
140
141#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
143#[serde(tag = "type", rename_all = "lowercase")]
144pub enum ToolResultContent {
145 Text(Text),
146 Image(Image),
147}
148
149impl ToolResultContent {
150 pub fn text(text: impl Into<String>) -> Self {
152 ToolResultContent::Text(Text { text: text.into() })
153 }
154}
155
156#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
158pub struct Reasoning {
159 pub id: Option<String>,
161 pub content: Vec<ReasoningContent>,
163}
164
165impl Reasoning {
166 pub fn new_with_signature(text: impl Into<String>, signature: Option<String>) -> Self {
168 Reasoning {
169 id: None,
170 content: vec![ReasoningContent::Text {
171 text: text.into(),
172 signature,
173 }],
174 }
175 }
176
177 pub fn first_text(&self) -> Option<&str> {
179 self.content.iter().find_map(|content| match content {
180 ReasoningContent::Text { text, .. } => Some(text.as_str()),
181 _ => None,
182 })
183 }
184
185 pub fn first_signature(&self) -> Option<&str> {
187 self.content.iter().find_map(|content| match content {
188 ReasoningContent::Text {
189 signature: Some(signature),
190 ..
191 } => Some(signature.as_str()),
192 _ => None,
193 })
194 }
195
196 pub fn display_text(&self) -> String {
199 self.content
200 .iter()
201 .filter_map(|content| match content {
202 ReasoningContent::Text { text, .. } => Some(text.as_str()),
203 ReasoningContent::Summary(summary) => Some(summary.as_str()),
204 ReasoningContent::Redacted { data } => Some(data.as_str()),
205 ReasoningContent::Encrypted(_) => None,
206 })
207 .collect::<Vec<_>>()
208 .join("\n")
209 }
210}
211
212#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
214#[serde(tag = "type", content = "content", rename_all = "snake_case")]
215pub enum ReasoningContent {
216 Text {
218 text: String,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 signature: Option<String>,
221 },
222 Encrypted(String),
224 Redacted { data: String },
226 Summary(String),
228}
229
230#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
232pub struct Image {
233 pub data: ImageSource,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub media_type: Option<ImageMediaType>,
236}
237
238#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
243#[serde(tag = "type", content = "value", rename_all = "camelCase")]
244pub enum ImageSource {
245 Url(String),
247 Base64(String),
249}
250
251#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "lowercase")]
254pub enum ImageMediaType {
255 JPEG,
256 PNG,
257 GIF,
258 WEBP,
259 HEIC,
260 HEIF,
261 SVG,
262}
263
264impl ImageMediaType {
265 pub fn from_mime_type(mime_type: &str) -> Option<Self> {
266 match mime_type {
267 "image/jpeg" => Some(ImageMediaType::JPEG),
268 "image/png" => Some(ImageMediaType::PNG),
269 "image/gif" => Some(ImageMediaType::GIF),
270 "image/webp" => Some(ImageMediaType::WEBP),
271 "image/heic" => Some(ImageMediaType::HEIC),
272 "image/heif" => Some(ImageMediaType::HEIF),
273 "image/svg+xml" => Some(ImageMediaType::SVG),
274 _ => None,
275 }
276 }
277
278 pub fn to_mime_type(&self) -> &'static str {
279 match self {
280 ImageMediaType::JPEG => "image/jpeg",
281 ImageMediaType::PNG => "image/png",
282 ImageMediaType::GIF => "image/gif",
283 ImageMediaType::WEBP => "image/webp",
284 ImageMediaType::HEIC => "image/heic",
285 ImageMediaType::HEIF => "image/heif",
286 ImageMediaType::SVG => "image/svg+xml",
287 }
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[track_caller]
297 fn assert_round_trip<T>(value: &T, json: &str)
298 where
299 T: Serialize + for<'de> Deserialize<'de> + PartialEq + std::fmt::Debug,
300 {
301 let serialized = serde_json::to_string(value).expect("serialize");
302 assert_eq!(serialized, json);
303 let deserialized: T = serde_json::from_str(json).expect("deserialize");
304 assert_eq!(&deserialized, value);
305 }
306
307 #[test]
312 fn user_text_message_format() {
313 assert_round_trip(
314 &Message::user("hi"),
315 r#"{"role":"user","content":[{"type":"text","text":"hi"}]}"#,
316 );
317 }
318
319 #[test]
320 fn assistant_text_message_format() {
321 assert_round_trip(
322 &Message::assistant("hello"),
323 r#"{"role":"assistant","content":[{"text":"hello"}]}"#,
324 );
325 }
326
327 #[test]
328 fn tool_call_format() {
329 assert_round_trip(
330 &AssistantContent::ToolCall(ToolCall::new(
331 "call-1",
332 "get_weather",
333 serde_json::json!({"city": "Berlin"}),
334 )),
335 r#"{"id":"call-1","function":{"name":"get_weather","arguments":{"city":"Berlin"}}}"#,
336 );
337 }
338
339 #[test]
340 fn tool_result_format() {
341 assert_round_trip(
342 &UserContent::ToolResult(ToolResult {
343 id: "call-1".to_owned(),
344 call_id: None,
345 content: vec![ToolResultContent::text("ok")],
346 }),
347 r#"{"type":"toolresult","id":"call-1","content":[{"type":"text","text":"ok"}]}"#,
348 );
349 }
350
351 #[test]
352 fn reasoning_format() {
353 assert_round_trip(
354 &AssistantContent::Reasoning(Reasoning::new_with_signature(
355 "thinking...",
356 Some("sig".to_owned()),
357 )),
358 r#"{"id":null,"content":[{"type":"text","content":{"text":"thinking...","signature":"sig"}}]}"#,
359 );
360 }
361
362 #[test]
363 fn reasoning_content_variants_round_trip() {
364 assert_round_trip(
365 &ReasoningContent::Encrypted("opaque".to_owned()),
366 r#"{"type":"encrypted","content":"opaque"}"#,
367 );
368 assert_round_trip(
369 &ReasoningContent::Redacted {
370 data: "r".to_owned(),
371 },
372 r#"{"type":"redacted","content":{"data":"r"}}"#,
373 );
374 assert_round_trip(
375 &ReasoningContent::Summary("s".to_owned()),
376 r#"{"type":"summary","content":"s"}"#,
377 );
378 }
379
380 #[test]
381 fn image_format() {
382 assert_round_trip(
383 &UserContent::Image(Image {
384 data: ImageSource::Base64("aGVsbG8=".to_owned()),
385 media_type: Some(ImageMediaType::PNG),
386 }),
387 r#"{"type":"image","data":{"type":"base64","value":"aGVsbG8="},"media_type":"png"}"#,
388 );
389 }
390}