1use crate::completion::StopReason;
4use crate::MetadataMap;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::path::PathBuf;
8use url::Url;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
13#[serde(tag = "role", rename_all = "snake_case")]
14pub enum Message {
15 System {
16 content: String,
17 #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
18 meta: MetadataMap,
19 },
20 User {
21 content: Vec<Content>,
22 #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
23 meta: MetadataMap,
24 },
25 Assistant {
26 content: Vec<Content>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 stop_reason: Option<StopReason>,
29 #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
30 meta: MetadataMap,
31 },
32}
33
34impl Message {
35 pub fn system(content: impl Into<String>) -> Self {
36 Self::System {
37 content: content.into(),
38 meta: MetadataMap::new(),
39 }
40 }
41
42 pub fn user_text(text: impl Into<String>) -> Self {
43 Self::User {
44 content: vec![Content::text(text)],
45 meta: MetadataMap::new(),
46 }
47 }
48
49 pub fn assistant_text(text: impl Into<String>) -> Self {
50 Self::Assistant {
51 content: vec![Content::text(text)],
52 stop_reason: None,
53 meta: MetadataMap::new(),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
65#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
66#[serde(tag = "type", rename_all = "snake_case")]
67pub enum Content {
68 Text {
69 text: String,
70 },
71 ToolUse {
72 id: String,
73 name: String,
74 input: Value,
75 },
76 ToolResult {
77 tool_use_id: String,
78 output: ToolOutput,
79 #[serde(default)]
80 is_error: bool,
81 },
82 Thinking {
84 thinking: String,
85 },
86 Image(ImageRef),
87 Document(DocumentRef),
88 Audio(AudioRef),
89 Citation(CitationRef),
90}
91
92impl Content {
93 pub fn text(s: impl Into<String>) -> Self {
94 Self::Text { text: s.into() }
95 }
96
97 pub fn thinking(s: impl Into<String>) -> Self {
98 Self::Thinking { thinking: s.into() }
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
105pub struct ToolOutput {
106 pub content: Vec<Content>,
107 #[serde(default)]
108 pub truncated: bool,
109}
110
111impl ToolOutput {
112 pub fn text(s: impl Into<String>) -> Self {
113 Self {
114 content: vec![Content::text(s)],
115 truncated: false,
116 }
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
123#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
124#[serde(untagged)]
125pub enum ImageRef {
126 Url {
127 #[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
128 url: Url,
129 #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
130 extensions: MetadataMap,
131 },
132 File {
133 #[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
134 path: PathBuf,
135 #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
136 extensions: MetadataMap,
137 },
138 Inline {
139 mime: String,
140 bytes: Vec<u8>,
141 #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
142 extensions: MetadataMap,
143 },
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
148#[serde(untagged)]
149pub enum DocumentRef {
150 Url {
151 #[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
152 url: Url,
153 mime: Option<String>,
154 #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
155 extensions: MetadataMap,
156 },
157 File {
158 #[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
159 path: PathBuf,
160 #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
161 extensions: MetadataMap,
162 },
163 Inline {
164 mime: String,
165 bytes: Vec<u8>,
166 #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
167 extensions: MetadataMap,
168 },
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
173#[serde(untagged)]
174pub enum AudioRef {
175 Url {
176 #[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
177 url: Url,
178 #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
179 extensions: MetadataMap,
180 },
181 File {
182 #[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
183 path: PathBuf,
184 #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
185 extensions: MetadataMap,
186 },
187 Inline {
188 mime: String,
189 bytes: Vec<u8>,
190 #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
191 extensions: MetadataMap,
192 },
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
197pub struct CitationRef {
198 pub source: String,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub quoted_text: Option<String>,
201 #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
202 pub extensions: MetadataMap,
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use serde_json::json;
209
210 fn round_trip(content: &Content) -> Content {
211 let bytes = serde_json::to_vec(content).expect("serialize Content");
212 serde_json::from_slice::<Content>(&bytes).expect("deserialize Content")
213 }
214
215 #[test]
216 fn text_serializes_as_tagged_struct() {
217 let c = Content::text("hello");
218 let v = serde_json::to_value(&c).expect("to_value");
219 assert_eq!(v, json!({"type": "text", "text": "hello"}));
220 assert!(matches!(round_trip(&c), Content::Text { text } if text == "hello"));
221 }
222
223 #[test]
224 fn thinking_serializes_as_tagged_struct() {
225 let c = Content::thinking("hmm");
226 let v = serde_json::to_value(&c).expect("to_value");
227 assert_eq!(v, json!({"type": "thinking", "thinking": "hmm"}));
228 assert!(matches!(round_trip(&c), Content::Thinking { thinking } if thinking == "hmm"));
229 }
230
231 #[test]
232 fn tool_use_round_trips() {
233 let c = Content::ToolUse {
234 id: "tu_1".into(),
235 name: "fs_list".into(),
236 input: json!({"path": "."}),
237 };
238 let v = serde_json::to_value(&c).expect("to_value");
239 assert_eq!(v["type"], "tool_use");
240 assert_eq!(v["id"], "tu_1");
241 assert_eq!(v["name"], "fs_list");
242 assert_eq!(v["input"], json!({"path": "."}));
243 assert!(matches!(round_trip(&c), Content::ToolUse { id, .. } if id == "tu_1"));
244 }
245
246 #[test]
247 fn tool_result_round_trips() {
248 let c = Content::ToolResult {
249 tool_use_id: "tu_1".into(),
250 output: ToolOutput::text("ok"),
251 is_error: false,
252 };
253 let bytes = serde_json::to_vec(&c).expect("serialize");
254 let back: Content = serde_json::from_slice(&bytes).expect("deserialize");
255 match back {
256 Content::ToolResult {
257 tool_use_id,
258 output,
259 is_error,
260 } => {
261 assert_eq!(tool_use_id, "tu_1");
262 assert!(!is_error);
263 assert!(!output.truncated);
264 assert!(
265 matches!(&output.content[..], [Content::Text { text }] if text == "ok"),
266 "output content: {:?}",
267 output.content
268 );
269 }
270 other => panic!("expected ToolResult, got {other:?}"),
271 }
272 }
273
274 #[test]
275 fn message_with_mixed_content_round_trips() {
276 let msg = Message::Assistant {
277 content: vec![
278 Content::text("Here's what I found:"),
279 Content::ToolUse {
280 id: "tu_1".into(),
281 name: "fs_list".into(),
282 input: json!({"path": "."}),
283 },
284 ],
285 stop_reason: Some(StopReason::ToolUse),
286 meta: MetadataMap::new(),
287 };
288 let bytes = serde_json::to_vec(&msg).expect("serialize Message");
289 let back: Message = serde_json::from_slice(&bytes).expect("deserialize Message");
290 match back {
291 Message::Assistant { content, .. } => assert_eq!(content.len(), 2),
292 other => panic!("expected Assistant, got {other:?}"),
293 }
294 }
295}