1use crate::domain::ActionDisplay;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ChatMessage {
7 pub role: MessageRole,
8 pub content: String,
9 pub timestamp: chrono::DateTime<chrono::Local>,
10 #[serde(default)]
14 pub kind: ChatMessageKind,
15 #[serde(default)]
17 pub metadata: Option<serde_json::Value>,
18 #[serde(default)]
20 pub actions: Vec<ActionDisplay>,
21 #[serde(default)]
23 pub thinking: Option<String>,
24 #[serde(default)]
26 pub images: Option<Vec<String>>,
27 #[serde(default)]
29 pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
30 #[serde(default)]
33 pub tool_call_id: Option<String>,
34 #[serde(default)]
37 pub tool_name: Option<String>,
38 #[serde(default)]
44 pub thinking_signature: Option<String>,
45}
46
47impl ChatMessage {
48 pub fn user(content: impl Into<String>) -> Self {
50 Self::new(MessageRole::User, content.into())
51 }
52
53 pub fn assistant(content: impl Into<String>) -> Self {
55 Self::new(MessageRole::Assistant, content.into())
56 }
57
58 pub fn system(content: impl Into<String>) -> Self {
60 Self::new(MessageRole::System, content.into())
61 }
62
63 pub fn run_summary(content: impl Into<String>) -> Self {
67 let mut m = Self::new(MessageRole::System, content.into());
68 m.kind = ChatMessageKind::RunSummary;
69 m
70 }
71
72 pub fn tool(
74 tool_call_id: impl Into<String>,
75 tool_name: impl Into<String>,
76 content: impl Into<String>,
77 ) -> Self {
78 Self {
79 role: MessageRole::Tool,
80 content: content.into(),
81 timestamp: chrono::Local::now(),
82 kind: ChatMessageKind::Normal,
83 metadata: None,
84 actions: Vec::new(),
85 thinking: None,
86 images: None,
87 tool_calls: None,
88 tool_call_id: Some(tool_call_id.into()),
89 tool_name: Some(tool_name.into()),
90 thinking_signature: None,
91 }
92 }
93
94 fn new(role: MessageRole, content: String) -> Self {
96 Self {
97 role,
98 content,
99 timestamp: chrono::Local::now(),
100 kind: ChatMessageKind::Normal,
101 metadata: None,
102 actions: Vec::new(),
103 thinking: None,
104 images: None,
105 tool_calls: None,
106 tool_call_id: None,
107 tool_name: None,
108 thinking_signature: None,
109 }
110 }
111
112 pub fn with_images(mut self, images: Vec<String>) -> Self {
114 self.images = Some(images);
115 self
116 }
117
118 pub fn with_tool_calls(mut self, tool_calls: Vec<crate::models::tool_call::ToolCall>) -> Self {
120 self.tool_calls = if tool_calls.is_empty() {
121 None
122 } else {
123 Some(tool_calls)
124 };
125 self
126 }
127
128 pub fn with_thinking_signature(mut self, signature: impl Into<String>) -> Self {
132 self.thinking_signature = Some(signature.into());
133 self
134 }
135
136 pub fn extract_thinking(text: &str) -> (Option<String>, String) {
147 let Some(thinking_start) = text.find("Thinking...") else {
148 return (None, text.to_string());
149 };
150 let content_start = thinking_start + "Thinking...".len();
151
152 if let Some(thinking_end) = text.find("...done thinking.") {
153 let thinking_text = text[content_start..thinking_end].trim().to_string();
154 let answer_start = thinking_end + "...done thinking.".len();
155 let answer_text = text[answer_start..].trim().to_string();
156 return (Some(thinking_text), answer_text);
157 }
158
159 let thinking_text = text[content_start..].trim().to_string();
161 (Some(thinking_text), String::new())
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub enum MessageRole {
167 User,
168 Assistant,
169 System,
170 Tool,
172}
173
174#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum ChatMessageKind {
177 #[default]
178 Normal,
179 ContextCheckpoint,
180 RunSummary,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "snake_case")]
194pub enum FinishReason {
195 Stop,
197 ToolUse,
199 Length,
201 ContentFilter,
203 Other(String),
205}
206
207#[derive(Debug, Clone)]
209pub struct ModelResponse {
210 pub content: String,
212 pub usage: Option<TokenUsage>,
214 pub model_name: String,
216 pub thinking: Option<String>,
218 pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
220 pub stop_reason: Option<FinishReason>,
223 pub thinking_signature: Option<String>,
230}
231
232#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum TokenUsageSource {
238 #[default]
239 Provider,
240 Estimate,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct TokenUsage {
250 pub prompt_tokens: usize,
251 pub completion_tokens: usize,
252 pub total_tokens: usize,
253 #[serde(default)]
254 pub cached_input_tokens: usize,
255 #[serde(default)]
256 pub cache_creation_input_tokens: usize,
257 #[serde(default)]
258 pub reasoning_output_tokens: usize,
259 #[serde(default)]
260 pub source: TokenUsageSource,
261}
262
263impl TokenUsage {
264 pub fn provider(prompt_tokens: usize, completion_tokens: usize, total_tokens: usize) -> Self {
265 Self {
266 prompt_tokens,
267 completion_tokens,
268 total_tokens,
269 cached_input_tokens: 0,
270 cache_creation_input_tokens: 0,
271 reasoning_output_tokens: 0,
272 source: TokenUsageSource::Provider,
273 }
274 }
275
276 pub fn estimate(prompt_tokens: usize) -> Self {
277 Self {
278 prompt_tokens,
279 completion_tokens: 0,
280 total_tokens: prompt_tokens,
281 cached_input_tokens: 0,
282 cache_creation_input_tokens: 0,
283 reasoning_output_tokens: 0,
284 source: TokenUsageSource::Estimate,
285 }
286 }
287
288 pub fn with_cached_input(mut self, cached_input_tokens: usize) -> Self {
289 self.cached_input_tokens = cached_input_tokens;
290 self
291 }
292
293 pub fn with_cache_creation(mut self, cache_creation_input_tokens: usize) -> Self {
294 self.cache_creation_input_tokens = cache_creation_input_tokens;
295 self
296 }
297
298 pub fn with_reasoning_output(mut self, reasoning_output_tokens: usize) -> Self {
299 self.reasoning_output_tokens = reasoning_output_tokens;
300 self
301 }
302
303 pub fn input_total_tokens(&self) -> usize {
304 self.prompt_tokens
305 .saturating_add(self.cached_input_tokens)
306 .saturating_add(self.cache_creation_input_tokens)
307 }
308
309 pub fn output_total_tokens(&self) -> usize {
310 self.completion_tokens
311 .saturating_add(self.reasoning_output_tokens)
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn test_message_role_equality() {
321 let user1 = MessageRole::User;
322 let user2 = MessageRole::User;
323 let assistant = MessageRole::Assistant;
324
325 assert_eq!(user1, user2, "User roles should be equal");
326 assert_ne!(user1, assistant, "Different roles should not be equal");
327 }
328
329 #[test]
330 fn test_chat_message_constructors() {
331 let user = ChatMessage::user("Hello!");
332 assert_eq!(user.role, MessageRole::User);
333 assert_eq!(user.content, "Hello!");
334 assert!(user.tool_calls.is_none());
335
336 let assistant = ChatMessage::assistant("Hi there");
337 assert_eq!(assistant.role, MessageRole::Assistant);
338
339 let system = ChatMessage::system("You are helpful");
340 assert_eq!(system.role, MessageRole::System);
341
342 let tool = ChatMessage::tool("call_1", "read_file", "file contents");
343 assert_eq!(tool.role, MessageRole::Tool);
344 assert_eq!(tool.tool_call_id, Some("call_1".to_string()));
345 assert_eq!(tool.tool_name, Some("read_file".to_string()));
346 }
347
348 #[test]
349 fn test_chat_message_builders() {
350 let msg = ChatMessage::user("test").with_images(vec!["base64data".to_string()]);
351 assert_eq!(msg.images, Some(vec!["base64data".to_string()]));
352 }
353
354 #[test]
355 fn test_token_usage_structure() {
356 let usage = TokenUsage::provider(100, 50, 150)
357 .with_cached_input(25)
358 .with_reasoning_output(10);
359
360 assert_eq!(usage.prompt_tokens, 100);
361 assert_eq!(usage.completion_tokens, 50);
362 assert_eq!(usage.total_tokens, 150);
363 assert_eq!(usage.cached_input_tokens, 25);
364 assert_eq!(usage.reasoning_output_tokens, 10);
365 assert_eq!(usage.source, TokenUsageSource::Provider);
366 }
367
368 #[test]
371 fn extract_thinking_no_marker_returns_text_unchanged() {
372 let (thinking, answer) = ChatMessage::extract_thinking("just a plain answer");
373 assert_eq!(thinking, None);
374 assert_eq!(answer, "just a plain answer");
375 }
376
377 #[test]
378 fn extract_thinking_complete_block() {
379 let raw = "Thinking...\n reasoning here\n...done thinking.\n\nFinal answer";
380 let (thinking, answer) = ChatMessage::extract_thinking(raw);
381 assert_eq!(thinking.as_deref(), Some("reasoning here"));
382 assert_eq!(answer, "Final answer");
383 }
384
385 #[test]
386 fn thinking_signature_round_trips_through_serde() {
387 let msg = ChatMessage::assistant("Step 3 lives.")
390 .with_thinking_signature("sig_abc123_encrypted_blob");
391 let json = serde_json::to_string(&msg).expect("serialize");
392 let back: ChatMessage = serde_json::from_str(&json).expect("deserialize");
393 assert_eq!(
394 back.thinking_signature.as_deref(),
395 Some("sig_abc123_encrypted_blob")
396 );
397 assert_eq!(back.content, "Step 3 lives.");
398 }
399
400 #[test]
401 fn thinking_signature_defaults_to_none() {
402 let pre_step3_json = r#"{
406 "role": "Assistant",
407 "content": "hello",
408 "timestamp": "2026-04-16T12:00:00-04:00"
409 }"#;
410 let msg: ChatMessage = serde_json::from_str(pre_step3_json).expect("backward compat");
411 assert!(msg.thinking_signature.is_none());
412 }
413
414 #[test]
415 fn extract_thinking_in_progress_no_end_marker() {
416 let raw = "Thinking...\n partial reasoning so far";
417 let (thinking, answer) = ChatMessage::extract_thinking(raw);
418 assert_eq!(thinking.as_deref(), Some("partial reasoning so far"));
419 assert_eq!(answer, "");
420 }
421
422 #[test]
423 fn test_model_response_creation() {
424 let usage = TokenUsage::provider(100, 50, 150);
425
426 let response = ModelResponse {
427 content: "Hello, world!".to_string(),
428 usage: Some(usage),
429 model_name: "ollama/tinyllama".to_string(),
430 thinking: None,
431 tool_calls: None,
432 stop_reason: None,
433 thinking_signature: None,
434 };
435
436 assert_eq!(response.content, "Hello, world!");
437 assert!(response.usage.is_some());
438 assert_eq!(response.model_name, "ollama/tinyllama");
439 assert_eq!(response.usage.unwrap().total_tokens, 150);
440 assert!(response.tool_calls.is_none());
441 }
442}