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}
84
85#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
92pub struct MessageAlternative {
93 #[serde(default)]
94 pub content: String,
95 #[serde(default)]
96 pub images: Vec<ImageRef>,
97 #[serde(default)]
98 pub content_blocks: Vec<ContentBlock>,
99 #[serde(default)]
100 pub timestamp: String,
101}
102
103impl MessageAlternative {
104 pub fn normalize(&mut self) {
107 if self.content_blocks.is_empty() && !self.content.is_empty() {
108 self.content_blocks = vec![ContentBlock::Text {
109 text: self.content.clone(),
110 }];
111 } else if !self.content_blocks.is_empty() {
112 self.content = derive_content_from_blocks(&self.content_blocks);
113 }
114 }
115}
116
117impl Message {
118 pub fn normalize(&mut self) {
124 if self.content_blocks.is_empty() && !self.content.is_empty() {
125 self.content_blocks = vec![ContentBlock::Text {
127 text: self.content.clone(),
128 }];
129 } else if !self.content_blocks.is_empty() {
130 self.content = derive_content_from_blocks(&self.content_blocks);
132 }
133
134 for alt in &mut self.alternatives {
135 alt.normalize();
136 }
137 if !self.alternatives.is_empty() {
138 let count = self.alternatives.len() as u32;
139 self.alt_count = Some(count);
140 let index = self.alt_index.unwrap_or(count.saturating_sub(1));
141 self.alt_index = Some(index.min(count.saturating_sub(1)));
142 }
143 }
144
145 pub fn is_tool_result_only(&self) -> bool {
151 if self.role != Role::User {
152 return false;
153 }
154 !self.content_blocks.is_empty()
155 && self
156 .content_blocks
157 .iter()
158 .all(|b| matches!(b, ContentBlock::ToolResult { .. }))
159 }
160
161 pub fn serialize_for_storage(&self) -> Result<String, serde_json::Error> {
167 let mut val = serde_json::to_value(self)?;
168 if let Some(obj) = val.as_object_mut() {
169 obj.remove("content");
170 if let Some(images) = obj.get_mut("images").and_then(|v| v.as_array_mut()) {
172 for img in images {
173 if let Some(obj) = img.as_object_mut() {
174 obj.remove("data");
175 }
176 }
177 }
178 }
179 serde_json::to_string(&val)
180 }
181}
182
183#[derive(Serialize, Deserialize, Debug, Clone)]
185pub struct TokenCounts {
186 pub input: u32,
187 pub output: u32,
188 pub cache_read: u32,
189 pub cache_write: u32,
190}
191
192#[derive(Serialize, Deserialize, Debug, Clone)]
194pub struct TimingInfo {
195 pub total_ms: u32,
196 pub ttft_ms: u32,
197}
198
199#[derive(Serialize, Deserialize, Debug, Clone)]
201pub struct StreamMetadata {
202 pub tokens: TokenCounts,
203 pub timing: TimingInfo,
204 pub model: String,
205}
206
207pub fn derive_content_from_blocks_with(
217 blocks: &[ContentBlock],
218 include_tool_results: bool,
219) -> String {
220 let mut parts: Vec<&str> = Vec::new();
221
222 for block in blocks {
223 match block {
224 ContentBlock::Text { text } => {
225 let trimmed = text.trim();
226 if !trimmed.is_empty() {
227 parts.push(trimmed);
228 }
229 }
230 ContentBlock::ToolResult { content, .. } if include_tool_results => {
231 let trimmed = content.trim();
232 if !trimmed.is_empty() {
233 parts.push(trimmed);
234 }
235 }
236 _ => {}
237 }
238 }
239
240 parts.join("\n")
241}
242
243pub fn derive_content_from_blocks(blocks: &[ContentBlock]) -> String {
245 derive_content_from_blocks_with(blocks, true)
246}
247
248#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
251pub struct CharacterAvatar {
252 pub mime_type: String,
253 pub data: String,
254}
255
256#[derive(Serialize, Deserialize, Debug, Clone)]
258pub struct CharacterInfo {
259 pub name: String,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub avatar: Option<CharacterAvatar>,
262}
263
264impl CharacterInfo {
265 pub fn new(name: impl Into<String>) -> Self {
266 Self {
267 name: name.into(),
268 avatar: None,
269 }
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn derive_content_empty_blocks() {
279 assert_eq!(derive_content_from_blocks(&[]), "");
280 }
281
282 #[test]
283 fn derive_content_text_only() {
284 let blocks = vec![ContentBlock::Text {
285 text: "hello world".into(),
286 }];
287 assert_eq!(derive_content_from_blocks(&blocks), "hello world");
288 }
289
290 #[test]
291 fn derive_content_trims_whitespace() {
292 let blocks = vec![ContentBlock::Text {
293 text: "\n\n".into(),
294 }];
295 assert_eq!(derive_content_from_blocks(&blocks), "");
296 }
297
298 #[test]
299 fn derive_content_tool_result() {
300 let blocks = vec![ContentBlock::ToolResult {
301 tool_use_id: "t1".into(),
302 content: "2026-03-29T10:00:00Z".into(),
303 is_error: false,
304 }];
305 assert_eq!(derive_content_from_blocks(&blocks), "2026-03-29T10:00:00Z");
306 }
307
308 #[test]
309 fn derive_content_skips_thinking_and_tool_use() {
310 let blocks = vec![
311 ContentBlock::Thinking {
312 thinking: "Let me think...".into(),
313 signature: None,
314 },
315 ContentBlock::ToolUse {
316 id: "t1".into(),
317 name: "check_time".into(),
318 input: serde_json::json!({}),
319 },
320 ContentBlock::RedactedThinking {
321 data: "opaque".into(),
322 },
323 ContentBlock::Text {
324 text: "The answer".into(),
325 },
326 ];
327 assert_eq!(derive_content_from_blocks(&blocks), "The answer");
328 }
329
330 #[test]
331 fn derive_content_multiple_text_blocks() {
332 let blocks = vec![
333 ContentBlock::Text {
334 text: "first".into(),
335 },
336 ContentBlock::Text {
337 text: "second".into(),
338 },
339 ];
340 assert_eq!(derive_content_from_blocks(&blocks), "first\nsecond");
341 }
342
343 fn make_msg(content: &str, blocks: Vec<ContentBlock>) -> Message {
346 Message {
347 msg_id: "m1".into(),
348 role: Role::User,
349 content: content.into(),
350 images: vec![],
351 content_blocks: blocks,
352 alt_index: None,
353 alt_count: None,
354 alternatives: vec![],
355 timestamp: "2026-01-01T00:00:00Z".into(),
356 }
357 }
358
359 #[test]
360 fn normalize_legacy_wraps_content_in_text_block() {
361 let mut msg = make_msg("hello world", vec![]);
362 msg.normalize();
363 assert_eq!(msg.content_blocks.len(), 1);
364 assert!(
365 matches!(&msg.content_blocks[0], ContentBlock::Text { text } if text == "hello world")
366 );
367 assert_eq!(msg.content, "hello world");
368 }
369
370 #[test]
371 fn normalize_canonical_derives_content_from_blocks() {
372 let mut msg = make_msg(
373 "",
374 vec![ContentBlock::Text {
375 text: "derived".into(),
376 }],
377 );
378 msg.normalize();
379 assert_eq!(msg.content, "derived");
380 assert_eq!(msg.content_blocks.len(), 1);
381 }
382
383 #[test]
384 fn normalize_both_empty_is_noop() {
385 let mut msg = make_msg("", vec![]);
386 msg.normalize();
387 assert_eq!(msg.content, "");
388 assert!(msg.content_blocks.is_empty());
389 }
390
391 #[test]
394 fn serialize_for_storage_omits_content_field() {
395 let msg = make_msg(
396 "should be removed",
397 vec![ContentBlock::Text {
398 text: "canonical".into(),
399 }],
400 );
401 let json_str = msg.serialize_for_storage().unwrap();
402 let val: serde_json::Value = serde_json::from_str(&json_str).unwrap();
403 assert!(
404 val.get("content").is_none(),
405 "content field should be omitted"
406 );
407 assert!(val.get("content_blocks").is_some());
408 }
409
410 #[test]
411 fn serialize_for_storage_roundtrips_other_fields() {
412 let msg = make_msg(
413 "ignored",
414 vec![ContentBlock::Text {
415 text: "hello".into(),
416 }],
417 );
418 let json_str = msg.serialize_for_storage().unwrap();
419 let val: serde_json::Value = serde_json::from_str(&json_str).unwrap();
420 assert_eq!(val["msg_id"], "m1");
421 assert_eq!(val["role"], "user");
422 assert_eq!(val["timestamp"], "2026-01-01T00:00:00Z");
423 }
424
425 #[test]
428 fn derive_content_excludes_tool_results_when_flag_false() {
429 let blocks = vec![
430 ContentBlock::Text {
431 text: "hello".into(),
432 },
433 ContentBlock::ToolResult {
434 tool_use_id: "t1".into(),
435 content: "result".into(),
436 is_error: false,
437 },
438 ];
439 assert_eq!(derive_content_from_blocks_with(&blocks, false), "hello");
440 assert_eq!(
441 derive_content_from_blocks_with(&blocks, true),
442 "hello\nresult"
443 );
444 }
445
446 #[test]
447 fn derive_content_mixed_text_and_tool_result() {
448 let blocks = vec![
449 ContentBlock::ToolResult {
450 tool_use_id: "t1".into(),
451 content: "tool output".into(),
452 is_error: false,
453 },
454 ContentBlock::ToolResult {
455 tool_use_id: "t2".into(),
456 content: "more output".into(),
457 is_error: false,
458 },
459 ];
460 assert_eq!(
461 derive_content_from_blocks(&blocks),
462 "tool output\nmore output"
463 );
464 }
465}