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, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum FinishReason {
183 Stop,
185 ToolUse,
187 Length,
189 ContentFilter,
191 Other(String),
193}
194
195#[derive(Debug, Clone)]
197pub struct ModelResponse {
198 pub content: String,
200 pub usage: Option<TokenUsage>,
202 pub model_name: String,
204 pub thinking: Option<String>,
206 pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
208 pub stop_reason: Option<FinishReason>,
211 pub thinking_signature: Option<String>,
218}
219
220#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "snake_case")]
225pub enum TokenUsageSource {
226 #[default]
227 Provider,
228 Estimate,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct TokenUsage {
238 pub prompt_tokens: usize,
239 pub completion_tokens: usize,
240 pub total_tokens: usize,
241 #[serde(default)]
242 pub cached_input_tokens: usize,
243 #[serde(default)]
244 pub cache_creation_input_tokens: usize,
245 #[serde(default)]
246 pub reasoning_output_tokens: usize,
247 #[serde(default)]
248 pub source: TokenUsageSource,
249}
250
251impl TokenUsage {
252 pub fn provider(prompt_tokens: usize, completion_tokens: usize, total_tokens: usize) -> Self {
253 Self {
254 prompt_tokens,
255 completion_tokens,
256 total_tokens,
257 cached_input_tokens: 0,
258 cache_creation_input_tokens: 0,
259 reasoning_output_tokens: 0,
260 source: TokenUsageSource::Provider,
261 }
262 }
263
264 pub fn estimate(prompt_tokens: usize) -> Self {
265 Self {
266 prompt_tokens,
267 completion_tokens: 0,
268 total_tokens: prompt_tokens,
269 cached_input_tokens: 0,
270 cache_creation_input_tokens: 0,
271 reasoning_output_tokens: 0,
272 source: TokenUsageSource::Estimate,
273 }
274 }
275
276 pub fn with_cached_input(mut self, cached_input_tokens: usize) -> Self {
277 self.cached_input_tokens = cached_input_tokens;
278 self
279 }
280
281 pub fn with_cache_creation(mut self, cache_creation_input_tokens: usize) -> Self {
282 self.cache_creation_input_tokens = cache_creation_input_tokens;
283 self
284 }
285
286 pub fn with_reasoning_output(mut self, reasoning_output_tokens: usize) -> Self {
287 self.reasoning_output_tokens = reasoning_output_tokens;
288 self
289 }
290
291 pub fn input_total_tokens(&self) -> usize {
292 self.prompt_tokens
293 .saturating_add(self.cached_input_tokens)
294 .saturating_add(self.cache_creation_input_tokens)
295 }
296
297 pub fn output_total_tokens(&self) -> usize {
298 self.completion_tokens
299 .saturating_add(self.reasoning_output_tokens)
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn test_message_role_equality() {
309 let user1 = MessageRole::User;
310 let user2 = MessageRole::User;
311 let assistant = MessageRole::Assistant;
312
313 assert_eq!(user1, user2, "User roles should be equal");
314 assert_ne!(user1, assistant, "Different roles should not be equal");
315 }
316
317 #[test]
318 fn test_chat_message_constructors() {
319 let user = ChatMessage::user("Hello!");
320 assert_eq!(user.role, MessageRole::User);
321 assert_eq!(user.content, "Hello!");
322 assert!(user.tool_calls.is_none());
323
324 let assistant = ChatMessage::assistant("Hi there");
325 assert_eq!(assistant.role, MessageRole::Assistant);
326
327 let system = ChatMessage::system("You are helpful");
328 assert_eq!(system.role, MessageRole::System);
329
330 let tool = ChatMessage::tool("call_1", "read_file", "file contents");
331 assert_eq!(tool.role, MessageRole::Tool);
332 assert_eq!(tool.tool_call_id, Some("call_1".to_string()));
333 assert_eq!(tool.tool_name, Some("read_file".to_string()));
334 }
335
336 #[test]
337 fn test_chat_message_builders() {
338 let msg = ChatMessage::user("test").with_images(vec!["base64data".to_string()]);
339 assert_eq!(msg.images, Some(vec!["base64data".to_string()]));
340 }
341
342 #[test]
343 fn test_token_usage_structure() {
344 let usage = TokenUsage::provider(100, 50, 150)
345 .with_cached_input(25)
346 .with_reasoning_output(10);
347
348 assert_eq!(usage.prompt_tokens, 100);
349 assert_eq!(usage.completion_tokens, 50);
350 assert_eq!(usage.total_tokens, 150);
351 assert_eq!(usage.cached_input_tokens, 25);
352 assert_eq!(usage.reasoning_output_tokens, 10);
353 assert_eq!(usage.source, TokenUsageSource::Provider);
354 }
355
356 #[test]
359 fn extract_thinking_no_marker_returns_text_unchanged() {
360 let (thinking, answer) = ChatMessage::extract_thinking("just a plain answer");
361 assert_eq!(thinking, None);
362 assert_eq!(answer, "just a plain answer");
363 }
364
365 #[test]
366 fn extract_thinking_complete_block() {
367 let raw = "Thinking...\n reasoning here\n...done thinking.\n\nFinal answer";
368 let (thinking, answer) = ChatMessage::extract_thinking(raw);
369 assert_eq!(thinking.as_deref(), Some("reasoning here"));
370 assert_eq!(answer, "Final answer");
371 }
372
373 #[test]
374 fn thinking_signature_round_trips_through_serde() {
375 let msg = ChatMessage::assistant("Step 3 lives.")
378 .with_thinking_signature("sig_abc123_encrypted_blob");
379 let json = serde_json::to_string(&msg).expect("serialize");
380 let back: ChatMessage = serde_json::from_str(&json).expect("deserialize");
381 assert_eq!(
382 back.thinking_signature.as_deref(),
383 Some("sig_abc123_encrypted_blob")
384 );
385 assert_eq!(back.content, "Step 3 lives.");
386 }
387
388 #[test]
389 fn thinking_signature_defaults_to_none() {
390 let pre_step3_json = r#"{
394 "role": "Assistant",
395 "content": "hello",
396 "timestamp": "2026-04-16T12:00:00-04:00"
397 }"#;
398 let msg: ChatMessage = serde_json::from_str(pre_step3_json).expect("backward compat");
399 assert!(msg.thinking_signature.is_none());
400 }
401
402 #[test]
403 fn extract_thinking_in_progress_no_end_marker() {
404 let raw = "Thinking...\n partial reasoning so far";
405 let (thinking, answer) = ChatMessage::extract_thinking(raw);
406 assert_eq!(thinking.as_deref(), Some("partial reasoning so far"));
407 assert_eq!(answer, "");
408 }
409
410 #[test]
411 fn test_model_response_creation() {
412 let usage = TokenUsage::provider(100, 50, 150);
413
414 let response = ModelResponse {
415 content: "Hello, world!".to_string(),
416 usage: Some(usage),
417 model_name: "ollama/tinyllama".to_string(),
418 thinking: None,
419 tool_calls: None,
420 stop_reason: None,
421 thinking_signature: None,
422 };
423
424 assert_eq!(response.content, "Hello, world!");
425 assert!(response.usage.is_some());
426 assert_eq!(response.model_name, "ollama/tinyllama");
427 assert_eq!(response.usage.unwrap().total_tokens, 150);
428 assert!(response.tool_calls.is_none());
429 }
430}