1use serde::{Deserialize, Serialize};
2
3#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
5#[serde(rename_all = "snake_case")]
6pub enum Role {
7 User,
8 Assistant,
9 System,
10}
11
12#[derive(Serialize, Deserialize, Debug, Clone)]
14pub struct ImageRef {
15 pub path: String,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub caption: Option<String>,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub data: Option<String>,
21}
22
23impl PartialEq for ImageRef {
24 fn eq(&self, other: &Self) -> bool {
25 self.path == other.path && self.caption == other.caption
26 }
27}
28
29#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
35#[serde(tag = "type", rename_all = "snake_case")]
36pub enum ContentBlock {
37 Text {
38 text: String,
39 },
40 Thinking {
41 thinking: String,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 signature: Option<String>,
44 },
45 ToolUse {
46 id: String,
47 name: String,
48 input: serde_json::Value,
49 },
50 RedactedThinking {
51 data: String,
52 },
53 ToolResult {
54 tool_use_id: String,
55 content: String,
56 #[serde(default)]
57 is_error: bool,
58 },
59}
60
61#[derive(Serialize, Deserialize, Debug, Clone)]
67pub struct Message {
68 pub msg_id: String,
69 pub role: Role,
70 #[serde(default)]
71 pub content: String,
72 #[serde(default)]
73 pub images: Vec<ImageRef>,
74 #[serde(default)]
75 pub content_blocks: Vec<ContentBlock>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub alt_index: Option<u32>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub alt_count: Option<u32>,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
81 pub alternatives: Vec<MessageAlternative>,
82 pub timestamp: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub provider_key: Option<String>,
92}
93
94#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
101pub struct MessageAlternative {
102 #[serde(default)]
103 pub content: String,
104 #[serde(default)]
105 pub images: Vec<ImageRef>,
106 #[serde(default)]
107 pub content_blocks: Vec<ContentBlock>,
108 #[serde(default)]
109 pub timestamp: String,
110}
111
112impl MessageAlternative {
113 pub fn normalize(&mut self) {
116 if self.content_blocks.is_empty() && !self.content.is_empty() {
117 self.content_blocks = vec![ContentBlock::Text {
118 text: self.content.clone(),
119 }];
120 } else if !self.content_blocks.is_empty() {
121 self.content = derive_content_from_blocks(&self.content_blocks);
122 }
123 }
124}
125
126impl Message {
127 pub fn normalize(&mut self) {
133 if self.content_blocks.is_empty() && !self.content.is_empty() {
134 self.content_blocks = vec![ContentBlock::Text {
136 text: self.content.clone(),
137 }];
138 } else if !self.content_blocks.is_empty() {
139 self.content = derive_content_from_blocks(&self.content_blocks);
141 }
142
143 for alt in &mut self.alternatives {
144 alt.normalize();
145 }
146 if !self.alternatives.is_empty() {
147 let count = u32::try_from(self.alternatives.len()).unwrap_or(u32::MAX);
148 self.alt_count = Some(count);
149 let index = self.alt_index.unwrap_or(count.saturating_sub(1));
150 self.alt_index = Some(index.min(count.saturating_sub(1)));
151 }
152 }
153
154 pub fn is_tool_result_only(&self) -> bool {
160 if self.role != Role::User {
161 return false;
162 }
163 !self.content_blocks.is_empty()
164 && self
165 .content_blocks
166 .iter()
167 .all(|b| matches!(b, ContentBlock::ToolResult { .. }))
168 }
169
170 pub fn serialize_for_storage(&self) -> Result<String, serde_json::Error> {
176 let mut val = serde_json::to_value(self)?;
177 if let Some(obj) = val.as_object_mut() {
178 obj.remove("content");
179 if let Some(images) = obj.get_mut("images").and_then(|v| v.as_array_mut()) {
181 for img in images {
182 if let Some(obj) = img.as_object_mut() {
183 obj.remove("data");
184 }
185 }
186 }
187 }
188 serde_json::to_string(&val)
189 }
190}
191
192#[derive(Serialize, Deserialize, Debug, Clone)]
194pub struct TokenCounts {
195 pub input: u64,
196 pub output: u64,
197 pub cache_read: u64,
198 pub cache_write: u64,
199}
200
201#[derive(Serialize, Deserialize, Debug, Clone)]
203pub struct TimingInfo {
204 pub total_ms: u32,
205 pub ttft_ms: u32,
206}
207
208#[derive(Serialize, Deserialize, Debug, Clone)]
210pub struct StreamMetadata {
211 pub tokens: TokenCounts,
212 pub timing: TimingInfo,
213 pub model: String,
214}
215
216pub fn derive_content_from_blocks_with(
226 blocks: &[ContentBlock],
227 include_tool_results: bool,
228) -> String {
229 let mut parts: Vec<&str> = Vec::new();
230
231 for block in blocks {
232 match block {
233 ContentBlock::Text { text } => {
234 let trimmed = text.trim();
235 if !trimmed.is_empty() {
236 parts.push(trimmed);
237 }
238 }
239 ContentBlock::ToolResult { content, .. } if include_tool_results => {
240 let trimmed = content.trim();
241 if !trimmed.is_empty() {
242 parts.push(trimmed);
243 }
244 }
245 _ => {}
246 }
247 }
248
249 parts.join("\n")
250}
251
252pub fn derive_content_from_blocks(blocks: &[ContentBlock]) -> String {
254 derive_content_from_blocks_with(blocks, true)
255}
256
257#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
260pub struct CharacterAvatar {
261 pub mime_type: String,
262 pub data: String,
263}
264
265#[derive(Serialize, Deserialize, Debug, Clone)]
267pub struct CharacterInfo {
268 pub name: String,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub avatar: Option<CharacterAvatar>,
271}
272
273impl CharacterInfo {
274 pub fn new(name: impl Into<String>) -> Self {
275 Self {
276 name: name.into(),
277 avatar: None,
278 }
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn derive_content_empty_blocks() {
288 assert_eq!(derive_content_from_blocks(&[]), "");
289 }
290
291 #[test]
292 fn derive_content_text_only() {
293 let blocks = vec![ContentBlock::Text {
294 text: "hello world".into(),
295 }];
296 assert_eq!(derive_content_from_blocks(&blocks), "hello world");
297 }
298
299 #[test]
300 fn derive_content_trims_whitespace() {
301 let blocks = vec![ContentBlock::Text {
302 text: "\n\n".into(),
303 }];
304 assert_eq!(derive_content_from_blocks(&blocks), "");
305 }
306
307 #[test]
308 fn derive_content_tool_result() {
309 let blocks = vec![ContentBlock::ToolResult {
310 tool_use_id: "t1".into(),
311 content: "2026-03-29T10:00:00Z".into(),
312 is_error: false,
313 }];
314 assert_eq!(derive_content_from_blocks(&blocks), "2026-03-29T10:00:00Z");
315 }
316
317 #[test]
318 fn derive_content_skips_thinking_and_tool_use() {
319 let blocks = vec![
320 ContentBlock::Thinking {
321 thinking: "Let me think...".into(),
322 signature: None,
323 },
324 ContentBlock::ToolUse {
325 id: "t1".into(),
326 name: "check_time".into(),
327 input: serde_json::json!({}),
328 },
329 ContentBlock::RedactedThinking {
330 data: "opaque".into(),
331 },
332 ContentBlock::Text {
333 text: "The answer".into(),
334 },
335 ];
336 assert_eq!(derive_content_from_blocks(&blocks), "The answer");
337 }
338
339 #[test]
340 fn derive_content_multiple_text_blocks() {
341 let blocks = vec![
342 ContentBlock::Text {
343 text: "first".into(),
344 },
345 ContentBlock::Text {
346 text: "second".into(),
347 },
348 ];
349 assert_eq!(derive_content_from_blocks(&blocks), "first\nsecond");
350 }
351
352 fn make_msg(content: &str, blocks: Vec<ContentBlock>) -> Message {
355 Message {
356 msg_id: "m1".into(),
357 role: Role::User,
358 content: content.into(),
359 images: vec![],
360 content_blocks: blocks,
361 alt_index: None,
362 alt_count: None,
363 alternatives: vec![],
364 provider_key: None,
365 timestamp: "2026-01-01T00:00:00Z".into(),
366 }
367 }
368
369 #[test]
370 fn normalize_legacy_wraps_content_in_text_block() {
371 let mut msg = make_msg("hello world", vec![]);
372 msg.normalize();
373 assert_eq!(msg.content_blocks.len(), 1);
374 assert!(
375 matches!(&msg.content_blocks[0], ContentBlock::Text { text } if text == "hello world")
376 );
377 assert_eq!(msg.content, "hello world");
378 }
379
380 #[test]
381 fn normalize_canonical_derives_content_from_blocks() {
382 let mut msg = make_msg(
383 "",
384 vec![ContentBlock::Text {
385 text: "derived".into(),
386 }],
387 );
388 msg.normalize();
389 assert_eq!(msg.content, "derived");
390 assert_eq!(msg.content_blocks.len(), 1);
391 }
392
393 #[test]
394 fn normalize_both_empty_is_noop() {
395 let mut msg = make_msg("", vec![]);
396 msg.normalize();
397 assert_eq!(msg.content, "");
398 assert!(msg.content_blocks.is_empty());
399 }
400
401 #[test]
404 fn serialize_for_storage_omits_content_field() {
405 let msg = make_msg(
406 "should be removed",
407 vec![ContentBlock::Text {
408 text: "canonical".into(),
409 }],
410 );
411 let json_str = msg.serialize_for_storage().unwrap();
412 let val: serde_json::Value = serde_json::from_str(&json_str).unwrap();
413 assert!(
414 val.get("content").is_none(),
415 "content field should be omitted"
416 );
417 assert!(val.get("content_blocks").is_some());
418 }
419
420 #[test]
421 fn serialize_for_storage_roundtrips_other_fields() {
422 let msg = make_msg(
423 "ignored",
424 vec![ContentBlock::Text {
425 text: "hello".into(),
426 }],
427 );
428 let json_str = msg.serialize_for_storage().unwrap();
429 let val: serde_json::Value = serde_json::from_str(&json_str).unwrap();
430 assert_eq!(val["msg_id"], "m1");
431 assert_eq!(val["role"], "user");
432 assert_eq!(val["timestamp"], "2026-01-01T00:00:00Z");
433 }
434
435 #[test]
438 fn derive_content_excludes_tool_results_when_flag_false() {
439 let blocks = vec![
440 ContentBlock::Text {
441 text: "hello".into(),
442 },
443 ContentBlock::ToolResult {
444 tool_use_id: "t1".into(),
445 content: "result".into(),
446 is_error: false,
447 },
448 ];
449 assert_eq!(derive_content_from_blocks_with(&blocks, false), "hello");
450 assert_eq!(
451 derive_content_from_blocks_with(&blocks, true),
452 "hello\nresult"
453 );
454 }
455
456 #[test]
457 fn derive_content_mixed_text_and_tool_result() {
458 let blocks = vec![
459 ContentBlock::ToolResult {
460 tool_use_id: "t1".into(),
461 content: "tool output".into(),
462 is_error: false,
463 },
464 ContentBlock::ToolResult {
465 tool_use_id: "t2".into(),
466 content: "more output".into(),
467 is_error: false,
468 },
469 ];
470 assert_eq!(
471 derive_content_from_blocks(&blocks),
472 "tool output\nmore output"
473 );
474 }
475}