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 tool(
65 tool_call_id: impl Into<String>,
66 tool_name: impl Into<String>,
67 content: impl Into<String>,
68 ) -> Self {
69 Self {
70 role: MessageRole::Tool,
71 content: content.into(),
72 timestamp: chrono::Local::now(),
73 kind: ChatMessageKind::Normal,
74 metadata: None,
75 actions: Vec::new(),
76 thinking: None,
77 images: None,
78 tool_calls: None,
79 tool_call_id: Some(tool_call_id.into()),
80 tool_name: Some(tool_name.into()),
81 thinking_signature: None,
82 }
83 }
84
85 fn new(role: MessageRole, content: String) -> Self {
87 Self {
88 role,
89 content,
90 timestamp: chrono::Local::now(),
91 kind: ChatMessageKind::Normal,
92 metadata: None,
93 actions: Vec::new(),
94 thinking: None,
95 images: None,
96 tool_calls: None,
97 tool_call_id: None,
98 tool_name: None,
99 thinking_signature: None,
100 }
101 }
102
103 pub fn with_images(mut self, images: Vec<String>) -> Self {
105 self.images = Some(images);
106 self
107 }
108
109 pub fn with_tool_calls(mut self, tool_calls: Vec<crate::models::tool_call::ToolCall>) -> Self {
111 self.tool_calls = if tool_calls.is_empty() {
112 None
113 } else {
114 Some(tool_calls)
115 };
116 self
117 }
118
119 pub fn with_thinking_signature(mut self, signature: impl Into<String>) -> Self {
123 self.thinking_signature = Some(signature.into());
124 self
125 }
126
127 pub fn extract_thinking(text: &str) -> (Option<String>, String) {
138 let Some(thinking_start) = text.find("Thinking...") else {
139 return (None, text.to_string());
140 };
141 let content_start = thinking_start + "Thinking...".len();
142
143 if let Some(thinking_end) = text.find("...done thinking.") {
144 let thinking_text = text[content_start..thinking_end].trim().to_string();
145 let answer_start = thinking_end + "...done thinking.".len();
146 let answer_text = text[answer_start..].trim().to_string();
147 return (Some(thinking_text), answer_text);
148 }
149
150 let thinking_text = text[content_start..].trim().to_string();
152 (Some(thinking_text), String::new())
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157pub enum MessageRole {
158 User,
159 Assistant,
160 System,
161 Tool,
163}
164
165#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum ChatMessageKind {
168 #[default]
169 Normal,
170 ContextCheckpoint,
171}
172
173#[derive(Debug, Clone)]
175pub struct ModelResponse {
176 pub content: String,
178 pub usage: Option<TokenUsage>,
180 pub model_name: String,
182 pub thinking: Option<String>,
184 pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
186 pub thinking_signature: Option<String>,
193}
194
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200pub enum TokenUsageSource {
201 #[default]
202 Provider,
203 Estimate,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct TokenUsage {
213 pub prompt_tokens: usize,
214 pub completion_tokens: usize,
215 pub total_tokens: usize,
216 #[serde(default)]
217 pub cached_input_tokens: usize,
218 #[serde(default)]
219 pub cache_creation_input_tokens: usize,
220 #[serde(default)]
221 pub reasoning_output_tokens: usize,
222 #[serde(default)]
223 pub source: TokenUsageSource,
224}
225
226impl TokenUsage {
227 pub fn provider(prompt_tokens: usize, completion_tokens: usize, total_tokens: usize) -> Self {
228 Self {
229 prompt_tokens,
230 completion_tokens,
231 total_tokens,
232 cached_input_tokens: 0,
233 cache_creation_input_tokens: 0,
234 reasoning_output_tokens: 0,
235 source: TokenUsageSource::Provider,
236 }
237 }
238
239 pub fn estimate(prompt_tokens: usize) -> Self {
240 Self {
241 prompt_tokens,
242 completion_tokens: 0,
243 total_tokens: prompt_tokens,
244 cached_input_tokens: 0,
245 cache_creation_input_tokens: 0,
246 reasoning_output_tokens: 0,
247 source: TokenUsageSource::Estimate,
248 }
249 }
250
251 pub fn with_cached_input(mut self, cached_input_tokens: usize) -> Self {
252 self.cached_input_tokens = cached_input_tokens;
253 self
254 }
255
256 pub fn with_cache_creation(mut self, cache_creation_input_tokens: usize) -> Self {
257 self.cache_creation_input_tokens = cache_creation_input_tokens;
258 self
259 }
260
261 pub fn with_reasoning_output(mut self, reasoning_output_tokens: usize) -> Self {
262 self.reasoning_output_tokens = reasoning_output_tokens;
263 self
264 }
265
266 pub fn input_total_tokens(&self) -> usize {
267 self.prompt_tokens
268 .saturating_add(self.cached_input_tokens)
269 .saturating_add(self.cache_creation_input_tokens)
270 }
271
272 pub fn output_total_tokens(&self) -> usize {
273 self.completion_tokens
274 .saturating_add(self.reasoning_output_tokens)
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn test_message_role_equality() {
284 let user1 = MessageRole::User;
285 let user2 = MessageRole::User;
286 let assistant = MessageRole::Assistant;
287
288 assert_eq!(user1, user2, "User roles should be equal");
289 assert_ne!(user1, assistant, "Different roles should not be equal");
290 }
291
292 #[test]
293 fn test_chat_message_constructors() {
294 let user = ChatMessage::user("Hello!");
295 assert_eq!(user.role, MessageRole::User);
296 assert_eq!(user.content, "Hello!");
297 assert!(user.tool_calls.is_none());
298
299 let assistant = ChatMessage::assistant("Hi there");
300 assert_eq!(assistant.role, MessageRole::Assistant);
301
302 let system = ChatMessage::system("You are helpful");
303 assert_eq!(system.role, MessageRole::System);
304
305 let tool = ChatMessage::tool("call_1", "read_file", "file contents");
306 assert_eq!(tool.role, MessageRole::Tool);
307 assert_eq!(tool.tool_call_id, Some("call_1".to_string()));
308 assert_eq!(tool.tool_name, Some("read_file".to_string()));
309 }
310
311 #[test]
312 fn test_chat_message_builders() {
313 let msg = ChatMessage::user("test").with_images(vec!["base64data".to_string()]);
314 assert_eq!(msg.images, Some(vec!["base64data".to_string()]));
315 }
316
317 #[test]
318 fn test_token_usage_structure() {
319 let usage = TokenUsage::provider(100, 50, 150)
320 .with_cached_input(25)
321 .with_reasoning_output(10);
322
323 assert_eq!(usage.prompt_tokens, 100);
324 assert_eq!(usage.completion_tokens, 50);
325 assert_eq!(usage.total_tokens, 150);
326 assert_eq!(usage.cached_input_tokens, 25);
327 assert_eq!(usage.reasoning_output_tokens, 10);
328 assert_eq!(usage.source, TokenUsageSource::Provider);
329 }
330
331 #[test]
334 fn extract_thinking_no_marker_returns_text_unchanged() {
335 let (thinking, answer) = ChatMessage::extract_thinking("just a plain answer");
336 assert_eq!(thinking, None);
337 assert_eq!(answer, "just a plain answer");
338 }
339
340 #[test]
341 fn extract_thinking_complete_block() {
342 let raw = "Thinking...\n reasoning here\n...done thinking.\n\nFinal answer";
343 let (thinking, answer) = ChatMessage::extract_thinking(raw);
344 assert_eq!(thinking.as_deref(), Some("reasoning here"));
345 assert_eq!(answer, "Final answer");
346 }
347
348 #[test]
349 fn thinking_signature_round_trips_through_serde() {
350 let msg = ChatMessage::assistant("Step 3 lives.")
353 .with_thinking_signature("sig_abc123_encrypted_blob");
354 let json = serde_json::to_string(&msg).expect("serialize");
355 let back: ChatMessage = serde_json::from_str(&json).expect("deserialize");
356 assert_eq!(
357 back.thinking_signature.as_deref(),
358 Some("sig_abc123_encrypted_blob")
359 );
360 assert_eq!(back.content, "Step 3 lives.");
361 }
362
363 #[test]
364 fn thinking_signature_defaults_to_none() {
365 let pre_step3_json = r#"{
369 "role": "Assistant",
370 "content": "hello",
371 "timestamp": "2026-04-16T12:00:00-04:00"
372 }"#;
373 let msg: ChatMessage = serde_json::from_str(pre_step3_json).expect("backward compat");
374 assert!(msg.thinking_signature.is_none());
375 }
376
377 #[test]
378 fn extract_thinking_in_progress_no_end_marker() {
379 let raw = "Thinking...\n partial reasoning so far";
380 let (thinking, answer) = ChatMessage::extract_thinking(raw);
381 assert_eq!(thinking.as_deref(), Some("partial reasoning so far"));
382 assert_eq!(answer, "");
383 }
384
385 #[test]
386 fn test_model_response_creation() {
387 let usage = TokenUsage::provider(100, 50, 150);
388
389 let response = ModelResponse {
390 content: "Hello, world!".to_string(),
391 usage: Some(usage),
392 model_name: "ollama/tinyllama".to_string(),
393 thinking: None,
394 tool_calls: None,
395 thinking_signature: None,
396 };
397
398 assert_eq!(response.content, "Hello, world!");
399 assert!(response.usage.is_some());
400 assert_eq!(response.model_name, "ollama/tinyllama");
401 assert_eq!(response.usage.unwrap().total_tokens, 150);
402 assert!(response.tool_calls.is_none());
403 }
404}