1use crate::http::{HttpClient, HttpError, HttpRequest};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17#[non_exhaustive]
18pub enum Role {
19 User,
21 Assistant,
23 Tool,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[non_exhaustive]
33pub struct Message {
34 pub role: Role,
36 pub parts: Vec<Part>,
38}
39
40impl Message {
41 pub fn new(role: Role, parts: Vec<Part>) -> Self {
43 Self { role, parts }
44 }
45
46 pub fn user(text: impl Into<String>) -> Self {
48 Self::new(Role::User, vec![Part::Text(text.into())])
49 }
50
51 pub fn assistant(text: impl Into<String>) -> Self {
53 Self::new(Role::Assistant, vec![Part::Text(text.into())])
54 }
55
56 pub fn tool_response(response: ToolResponse) -> Self {
58 Self::new(Role::Tool, vec![Part::ToolResponse(response)])
59 }
60
61 pub fn text(&self) -> Option<String> {
63 joined_text(&self.parts)
64 }
65
66 pub fn tool_calls(&self) -> Vec<&ToolCall> {
68 self.parts
69 .iter()
70 .filter_map(|part| match part {
71 Part::ToolCall(call) => Some(call),
72 _ => None,
73 })
74 .collect()
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80#[serde(tag = "type", content = "value", rename_all = "snake_case")]
81#[non_exhaustive]
82pub enum Part {
83 Text(String),
85 ToolCall(ToolCall),
87 ToolResponse(ToolResponse),
89 Reasoning(String),
91 ReasoningSignature(String),
94 Media(Media),
96 ProviderData(ProviderData),
99}
100
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103#[non_exhaustive]
104pub struct ToolCall {
105 pub id: String,
107 pub name: String,
109 pub arguments: Value,
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 signatures: Vec<String>,
114}
115
116impl ToolCall {
117 pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
119 Self {
120 id: id.into(),
121 name: name.into(),
122 arguments,
123 signatures: Vec::new(),
124 }
125 }
126
127 pub fn signatures(&self) -> &[String] {
130 &self.signatures
131 }
132
133 pub fn with_signatures(mut self, signatures: Vec<String>) -> Self {
135 self.signatures = signatures;
136 self
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142#[non_exhaustive]
143pub struct ToolResponse {
144 pub call_id: String,
146 pub name: String,
148 #[serde(deserialize_with = "deserialize_tool_content")]
152 pub content: Vec<Part>,
153}
154
155impl ToolResponse {
156 pub fn new(call: &ToolCall, content: impl Into<ToolOutput>) -> Self {
158 Self {
159 call_id: call.id.clone(),
160 name: call.name.clone(),
161 content: content.into().parts,
162 }
163 }
164
165 pub fn text(&self) -> Option<String> {
167 joined_text(&self.content)
168 }
169}
170
171fn deserialize_tool_content<'de, D>(deserializer: D) -> Result<Vec<Part>, D::Error>
173where
174 D: serde::Deserializer<'de>,
175{
176 #[derive(Deserialize)]
177 #[serde(untagged)]
178 enum Content {
179 Text(String),
180 Parts(Vec<Part>),
181 }
182 Ok(match Content::deserialize(deserializer)? {
183 Content::Text(text) => vec![Part::Text(text)],
184 Content::Parts(parts) => parts,
185 })
186}
187
188fn joined_text(parts: &[Part]) -> Option<String> {
190 let texts: Vec<&str> = parts
191 .iter()
192 .filter_map(|part| match part {
193 Part::Text(text) => Some(text.as_str()),
194 _ => None,
195 })
196 .collect();
197 (!texts.is_empty()).then(|| texts.concat())
198}
199
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202#[non_exhaustive]
203pub struct Media {
204 pub content_type: String,
206 pub source: MediaSource,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub name: Option<String>,
211}
212
213impl Media {
214 pub fn new(content_type: impl Into<String>, source: MediaSource) -> Self {
216 Self {
217 content_type: content_type.into(),
218 source,
219 name: None,
220 }
221 }
222
223 pub fn from_url(content_type: impl Into<String>, url: impl Into<String>) -> Self {
226 Self::new(content_type, MediaSource::Url(url.into()))
227 }
228
229 pub fn image_url(url: impl Into<String>) -> Self {
233 let url = url.into();
234 Self::from_url(image_type_from_url(&url), url)
235 }
236
237 pub fn from_bytes(content_type: impl Into<String>, bytes: impl AsRef<[u8]>) -> Self {
240 use base64::Engine;
241 let data = base64::engine::general_purpose::STANDARD.encode(bytes);
242 Self::from_base64(content_type, data)
243 }
244
245 pub fn from_base64(content_type: impl Into<String>, data: impl Into<String>) -> Self {
247 Self::new(content_type, MediaSource::Base64(data.into()))
248 }
249
250 pub fn with_name(mut self, name: impl Into<String>) -> Self {
252 self.name = Some(name.into());
253 self
254 }
255
256 pub fn is_image(&self) -> bool {
258 self.content_type.starts_with("image/")
259 }
260
261 pub fn byte_len(&self) -> Option<usize> {
263 use base64::Engine;
264 match &self.source {
265 MediaSource::Base64(data) => base64::engine::general_purpose::STANDARD
266 .decode(data)
267 .ok()
268 .map(|bytes| bytes.len()),
269 MediaSource::Url(_) => None,
270 }
271 }
272
273 pub async fn bytes(&self, client: &dyn HttpClient) -> Result<Vec<u8>, HttpError> {
276 use base64::Engine;
277 match &self.source {
278 MediaSource::Base64(data) => base64::engine::general_purpose::STANDARD
279 .decode(data)
280 .map_err(|e| HttpError::InvalidMedia(e.to_string())),
281 MediaSource::Url(url) => Ok(client
282 .send(HttpRequest::get(url))
283 .await?
284 .error_for_status()?
285 .body),
286 }
287 }
288}
289
290fn image_type_from_url(url: &str) -> &'static str {
292 let path = url.split(['?', '#']).next().unwrap_or_default();
293 let extension = path
294 .rsplit_once('.')
295 .map(|(_, ext)| ext.to_ascii_lowercase())
296 .unwrap_or_default();
297 match extension.as_str() {
298 "png" => "image/png",
299 "gif" => "image/gif",
300 "webp" => "image/webp",
301 _ => "image/jpeg",
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum MediaSource {
309 Url(String),
311 Base64(String),
313}
314
315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
317#[non_exhaustive]
318pub struct ProviderData {
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub provider: Option<String>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub model: Option<String>,
325 pub data: Value,
327}
328
329impl ProviderData {
330 pub fn new(data: Value) -> Self {
332 Self {
333 provider: None,
334 model: None,
335 data,
336 }
337 }
338
339 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
341 self.provider = Some(provider.into());
342 self
343 }
344
345 pub fn with_model(mut self, model: impl Into<String>) -> Self {
347 self.model = Some(model.into());
348 self
349 }
350}
351
352#[derive(Debug, Clone, Default, PartialEq)]
371#[non_exhaustive]
372pub struct ToolOutput {
373 pub parts: Vec<Part>,
375}
376
377impl ToolOutput {
378 pub fn from_text(text: impl Into<String>) -> Self {
380 Self::default().with_text(text)
381 }
382
383 pub fn from_media(media: Media) -> Self {
385 Self::default().with_media(media)
386 }
387
388 pub fn with_text(mut self, text: impl Into<String>) -> Self {
390 self.parts.push(Part::Text(text.into()));
391 self
392 }
393
394 pub fn with_media(mut self, media: Media) -> Self {
396 self.parts.push(Part::Media(media));
397 self
398 }
399
400 pub fn text(&self) -> Option<String> {
402 joined_text(&self.parts)
403 }
404
405 pub fn media(&self) -> Vec<&Media> {
407 self.parts
408 .iter()
409 .filter_map(|part| match part {
410 Part::Media(media) => Some(media),
411 _ => None,
412 })
413 .collect()
414 }
415}
416
417impl std::fmt::Display for ToolOutput {
419 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420 for part in &self.parts {
421 match part {
422 Part::Text(text) => f.write_str(text)?,
423 Part::Media(media) => write!(f, "[{}]", media.content_type)?,
424 _ => {}
425 }
426 }
427 Ok(())
428 }
429}
430
431impl From<String> for ToolOutput {
432 fn from(text: String) -> Self {
433 Self::from_text(text)
434 }
435}
436
437impl From<&str> for ToolOutput {
438 fn from(text: &str) -> Self {
439 Self::from_text(text)
440 }
441}
442
443impl From<Media> for ToolOutput {
444 fn from(media: Media) -> Self {
445 Self::from_media(media)
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use serde_json::json;
453
454 fn every_part() -> Message {
455 let mut call = ToolCall::new("call_1", "echo", json!({"text": "hi"}));
456 call.signatures = vec!["sig".to_string()];
457 Message::new(
458 Role::Assistant,
459 vec![
460 Part::ReasoningSignature("signature".into()),
461 Part::Reasoning("thinking".into()),
462 Part::Text("hello".into()),
463 Part::ToolCall(call.clone()),
464 Part::ToolResponse(ToolResponse::new(&call, "result")),
465 Part::ToolResponse(ToolResponse::new(
466 &call,
467 ToolOutput::from_text("an image")
468 .with_media(Media::new("image/png", MediaSource::Base64("aGk=".into()))),
469 )),
470 Part::Media(Media::new("image/png", MediaSource::Base64("aGk=".into()))),
471 Part::Media(Media::new(
472 "image/png",
473 MediaSource::Url("https://example.com/a.png".into()),
474 )),
475 Part::ProviderData(ProviderData {
476 provider: Some("OpenRouter".into()),
477 model: Some("m".into()),
478 data: json!({"type": "reasoning.encrypted"}),
479 }),
480 ],
481 )
482 }
483
484 #[test]
485 fn messages_round_trip_through_json() {
486 let message = every_part();
487 let json = serde_json::to_string(&message).unwrap();
488 assert_eq!(serde_json::from_str::<Message>(&json).unwrap(), message);
489 }
490
491 #[test]
492 fn tool_responses_stored_before_0_5_still_load() {
493 let json = r#"{"role":"tool","parts":[{"type":"tool_response",
494 "value":{"call_id":"call_1","name":"echo","content":"result"}}]}"#;
495 let message: Message = serde_json::from_str(json).unwrap();
496 let call = ToolCall::new("call_1", "echo", json!({}));
497 assert_eq!(
498 message,
499 Message::tool_response(ToolResponse::new(&call, "result"))
500 );
501 }
502
503 #[test]
504 fn image_url_guesses_the_type_from_the_extension() {
505 let types: Vec<String> = [
506 "https://example.com/a.png",
507 "https://example.com/a.PNG?size=2#top",
508 "https://example.com/a.webp",
509 "https://example.com/a.gif",
510 "https://example.com/a.jpeg",
511 "https://example.com/image",
512 ]
513 .into_iter()
514 .map(|url| Media::image_url(url).content_type)
515 .collect();
516 assert_eq!(
517 types,
518 [
519 "image/png",
520 "image/png",
521 "image/webp",
522 "image/gif",
523 "image/jpeg",
524 "image/jpeg"
525 ]
526 );
527 assert_eq!(
528 Media::image_url("https://example.com/a.png").source,
529 MediaSource::Url("https://example.com/a.png".into())
530 );
531 }
532
533 #[test]
534 fn json_format_is_stable() {
535 let message = Message::user("hi");
536 assert_eq!(
537 serde_json::to_value(&message).unwrap(),
538 json!({"role": "user", "parts": [{"type": "text", "value": "hi"}]})
539 );
540 }
541}