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, Copy, PartialEq, Eq)]
69#[serde(rename_all = "snake_case")]
70pub enum MessageOrigin {
71 UserInput,
72 AssistantReply,
73 Autonomous,
74}
75
76#[derive(Serialize, Deserialize, Debug, Clone)]
82pub struct Message {
83 pub msg_id: String,
84 pub role: Role,
85 #[serde(default)]
86 pub content: String,
87 #[serde(default)]
88 pub images: Vec<ImageRef>,
89 #[serde(default)]
90 pub content_blocks: Vec<ContentBlock>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub alt_index: Option<u32>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub alt_count: Option<u32>,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub alternatives: Vec<MessageAlternative>,
97 pub timestamp: String,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub provider_key: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub model: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub origin: Option<MessageOrigin>,
122}
123
124#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
131pub struct MessageAlternative {
132 #[serde(default)]
133 pub content: String,
134 #[serde(default)]
135 pub images: Vec<ImageRef>,
136 #[serde(default)]
137 pub content_blocks: Vec<ContentBlock>,
138 #[serde(default)]
139 pub timestamp: String,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub provider_key: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub model: Option<String>,
157}
158
159impl MessageAlternative {
160 pub fn normalize(&mut self) {
163 if self.content_blocks.is_empty() && !self.content.is_empty() {
164 self.content_blocks = vec![ContentBlock::Text {
165 text: self.content.clone(),
166 }];
167 } else if !self.content_blocks.is_empty() {
168 self.content = derive_content_from_blocks(&self.content_blocks);
169 } else {
170 }
172 }
173}
174
175impl Message {
176 pub fn normalize(&mut self) {
182 if self.content_blocks.is_empty() && !self.content.is_empty() {
183 self.content_blocks = vec![ContentBlock::Text {
185 text: self.content.clone(),
186 }];
187 } else if !self.content_blocks.is_empty() {
188 self.content = derive_content_from_blocks(&self.content_blocks);
190 } else {
191 }
193
194 for alt in &mut self.alternatives {
195 alt.normalize();
196 }
197 if !self.alternatives.is_empty() {
198 let count = u32::try_from(self.alternatives.len()).unwrap_or(u32::MAX);
199 self.alt_count = Some(count);
200 let index = self.alt_index.unwrap_or(count.saturating_sub(1));
201 self.alt_index = Some(index.min(count.saturating_sub(1)));
202 }
203 }
204
205 pub fn is_tool_result_only(&self) -> bool {
211 if self.role != Role::User {
212 return false;
213 }
214 !self.content_blocks.is_empty()
215 && self
216 .content_blocks
217 .iter()
218 .all(|b| matches!(b, ContentBlock::ToolResult { .. }))
219 }
220
221 pub fn serialize_for_storage(&self) -> Result<String, serde_json::Error> {
227 let mut val = serde_json::to_value(self)?;
228 if let Some(obj) = val.as_object_mut() {
229 let _ignored = obj.remove("content");
230
231 let strip_image_data = |images: Option<&mut serde_json::Value>| {
233 if let Some(arr) = images.and_then(|v| v.as_array_mut()) {
234 for img in arr {
235 if let Some(img_obj) = img.as_object_mut() {
236 let _removed = img_obj.remove("data");
237 }
238 }
239 }
240 };
241
242 strip_image_data(obj.get_mut("images"));
243
244 if let Some(alternatives) = obj.get_mut("alternatives").and_then(|v| v.as_array_mut()) {
247 for alternative in alternatives {
248 if let Some(alt_obj) = alternative.as_object_mut() {
249 strip_image_data(alt_obj.get_mut("images"));
250 }
251 }
252 }
253 }
254 serde_json::to_string(&val)
255 }
256}
257
258#[derive(Serialize, Deserialize, Debug, Clone)]
260pub struct TokenCounts {
261 pub input: u64,
262 pub output: u64,
263 pub cache_read: u64,
264 pub cache_write: u64,
265}
266
267#[derive(Serialize, Deserialize, Debug, Clone)]
269pub struct TimingInfo {
270 pub total_ms: u32,
271 pub ttft_ms: u32,
272}
273
274#[derive(Serialize, Deserialize, Debug, Clone)]
276pub struct StreamMetadata {
277 pub tokens: TokenCounts,
278 pub timing: TimingInfo,
279 pub model: String,
280}
281
282pub fn derive_content_from_blocks_with(
292 blocks: &[ContentBlock],
293 include_tool_results: bool,
294) -> String {
295 let mut parts: Vec<&str> = Vec::new();
296
297 for block in blocks {
298 match block {
299 ContentBlock::Text { text } => {
300 let trimmed = text.trim();
301 if !trimmed.is_empty() {
302 parts.push(trimmed);
303 }
304 }
305 ContentBlock::ToolResult { content, .. } if include_tool_results => {
306 let trimmed = content.trim();
307 if !trimmed.is_empty() {
308 parts.push(trimmed);
309 }
310 }
311 ContentBlock::Thinking { .. }
312 | ContentBlock::ToolUse { .. }
313 | ContentBlock::RedactedThinking { .. }
314 | ContentBlock::ToolResult { .. } => {}
315 }
316 }
317
318 parts.join("\n")
319}
320
321pub fn derive_content_from_blocks(blocks: &[ContentBlock]) -> String {
323 derive_content_from_blocks_with(blocks, true)
324}
325
326#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
329pub struct CharacterAvatar {
330 pub mime_type: String,
331 pub data: String,
332}
333
334#[derive(Serialize, Deserialize, Debug, Clone)]
336pub struct CharacterInfo {
337 pub name: String,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub avatar: Option<CharacterAvatar>,
340}
341
342impl CharacterInfo {
343 pub fn new<N: Into<String>>(name: N) -> Self {
344 Self {
345 name: name.into(),
346 avatar: None,
347 }
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 fn field<'val>(value: &'val serde_json::Value, key: &str) -> &'val serde_json::Value {
356 value.get(key).expect("expected JSON field")
357 }
358
359 fn item<T>(items: &[T], index: usize) -> &T {
360 items.get(index).expect("expected item")
361 }
362
363 #[test]
364 fn derive_content_empty_blocks() {
365 assert_eq!(derive_content_from_blocks(&[]), "");
366 }
367
368 #[test]
369 fn derive_content_text_only() {
370 let blocks = vec![ContentBlock::Text {
371 text: "hello world".into(),
372 }];
373 assert_eq!(derive_content_from_blocks(&blocks), "hello world");
374 }
375
376 #[test]
377 fn derive_content_trims_whitespace() {
378 let blocks = vec![ContentBlock::Text {
379 text: "\n\n".into(),
380 }];
381 assert_eq!(derive_content_from_blocks(&blocks), "");
382 }
383
384 #[test]
385 fn derive_content_tool_result() {
386 let blocks = vec![ContentBlock::ToolResult {
387 tool_use_id: "t1".into(),
388 content: "2026-03-29T10:00:00Z".into(),
389 is_error: false,
390 }];
391 assert_eq!(derive_content_from_blocks(&blocks), "2026-03-29T10:00:00Z");
392 }
393
394 #[test]
395 fn derive_content_skips_thinking_and_tool_use() {
396 let blocks = vec![
397 ContentBlock::Thinking {
398 thinking: "Let me think...".into(),
399 signature: None,
400 },
401 ContentBlock::ToolUse {
402 id: "t1".into(),
403 name: "check_time".into(),
404 input: serde_json::json!({}),
405 },
406 ContentBlock::RedactedThinking {
407 data: "opaque".into(),
408 },
409 ContentBlock::Text {
410 text: "The answer".into(),
411 },
412 ];
413 assert_eq!(derive_content_from_blocks(&blocks), "The answer");
414 }
415
416 #[test]
417 fn derive_content_multiple_text_blocks() {
418 let blocks = vec![
419 ContentBlock::Text {
420 text: "first".into(),
421 },
422 ContentBlock::Text {
423 text: "second".into(),
424 },
425 ];
426 assert_eq!(derive_content_from_blocks(&blocks), "first\nsecond");
427 }
428
429 fn make_msg(content: &str, blocks: Vec<ContentBlock>) -> Message {
432 Message {
433 msg_id: "m1".into(),
434 origin: None,
435 role: Role::User,
436 content: content.into(),
437 images: vec![],
438 content_blocks: blocks,
439 alt_index: None,
440 alt_count: None,
441 alternatives: vec![],
442 provider_key: None,
443 model: None,
444 timestamp: "2026-01-01T00:00:00Z".into(),
445 }
446 }
447
448 #[test]
449 fn normalize_legacy_wraps_content_in_text_block() {
450 let mut msg = make_msg("hello world", vec![]);
451 msg.normalize();
452 assert_eq!(msg.content_blocks.len(), 1);
453 assert!(
454 matches!(item(&msg.content_blocks, 0), ContentBlock::Text { text } if text == "hello world")
455 );
456 assert_eq!(msg.content, "hello world");
457 }
458
459 #[test]
460 fn normalize_canonical_derives_content_from_blocks() {
461 let mut msg = make_msg(
462 "",
463 vec![ContentBlock::Text {
464 text: "derived".into(),
465 }],
466 );
467 msg.normalize();
468 assert_eq!(msg.content, "derived");
469 assert_eq!(msg.content_blocks.len(), 1);
470 }
471
472 #[test]
473 fn normalize_both_empty_is_noop() {
474 let mut msg = make_msg("", vec![]);
475 msg.normalize();
476 assert_eq!(msg.content, "");
477 assert!(msg.content_blocks.is_empty());
478 }
479
480 #[test]
483 fn serialize_for_storage_omits_content_field() {
484 let msg = make_msg(
485 "should be removed",
486 vec![ContentBlock::Text {
487 text: "canonical".into(),
488 }],
489 );
490 let json_str = msg.serialize_for_storage().unwrap();
491 let val: serde_json::Value = serde_json::from_str(&json_str).unwrap();
492 assert!(
493 val.get("content").is_none(),
494 "content field should be omitted"
495 );
496 assert!(val.get("content_blocks").is_some());
497 }
498
499 #[test]
500 fn serialize_for_storage_roundtrips_other_fields() {
501 let msg = make_msg(
502 "ignored",
503 vec![ContentBlock::Text {
504 text: "hello".into(),
505 }],
506 );
507 let json_str = msg.serialize_for_storage().unwrap();
508 let val: serde_json::Value = serde_json::from_str(&json_str).unwrap();
509 assert_eq!(field(&val, "msg_id"), "m1");
510 assert_eq!(field(&val, "role"), "user");
511 assert_eq!(field(&val, "timestamp"), "2026-01-01T00:00:00Z");
512 }
513
514 #[test]
515 fn serialize_for_storage_strips_inline_image_data_everywhere() {
516 let mut msg = make_msg(
517 "ignored",
518 vec![ContentBlock::Text {
519 text: "active".into(),
520 }],
521 );
522 msg.images = vec![ImageRef {
523 path: "/img/top.png".into(),
524 caption: None,
525 data: Some("TOPDATA".into()),
526 }];
527 msg.alternatives = vec![MessageAlternative {
528 content: "alt".into(),
529 images: vec![ImageRef {
530 path: "/img/alt.png".into(),
531 caption: None,
532 data: Some("ALTDATA".into()),
533 }],
534 content_blocks: vec![],
535 timestamp: "2026-01-01T00:00:00Z".into(),
536 provider_key: None,
537 model: None,
538 }];
539
540 let json_str = msg.serialize_for_storage().unwrap();
541 assert!(
542 !json_str.contains("TOPDATA"),
543 "top-level image data must be stripped"
544 );
545 assert!(
546 !json_str.contains("ALTDATA"),
547 "alternative image data must be stripped"
548 );
549 assert!(json_str.contains("/img/alt.png"));
551 }
552
553 #[test]
556 fn derive_content_excludes_tool_results_when_flag_false() {
557 let blocks = vec![
558 ContentBlock::Text {
559 text: "hello".into(),
560 },
561 ContentBlock::ToolResult {
562 tool_use_id: "t1".into(),
563 content: "result".into(),
564 is_error: false,
565 },
566 ];
567 assert_eq!(derive_content_from_blocks_with(&blocks, false), "hello");
568 assert_eq!(
569 derive_content_from_blocks_with(&blocks, true),
570 "hello\nresult"
571 );
572 }
573
574 #[test]
575 fn derive_content_mixed_text_and_tool_result() {
576 let blocks = vec![
577 ContentBlock::ToolResult {
578 tool_use_id: "t1".into(),
579 content: "tool output".into(),
580 is_error: false,
581 },
582 ContentBlock::ToolResult {
583 tool_use_id: "t2".into(),
584 content: "more output".into(),
585 is_error: false,
586 },
587 ];
588 assert_eq!(
589 derive_content_from_blocks(&blocks),
590 "tool output\nmore output"
591 );
592 }
593}