1use super::ToolCall;
2use crate::llm::providers::clean_reasoning_text;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum AssistantPhase {
9 Commentary,
10 FinalAnswer,
11}
12
13impl AssistantPhase {
14 #[must_use]
15 pub const fn as_str(self) -> &'static str {
16 match self {
17 Self::Commentary => "commentary",
18 Self::FinalAnswer => "final_answer",
19 }
20 }
21
22 #[must_use]
23 pub fn from_wire_str(value: &str) -> Option<Self> {
24 match value {
25 "commentary" => Some(Self::Commentary),
26 "final_answer" => Some(Self::FinalAnswer),
27 _ => None,
28 }
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(untagged)]
35pub enum ContentPart {
36 Text {
37 text: String,
38 },
39 Image {
40 data: String, mime_type: String, #[serde(rename = "type")]
43 content_type: String, },
45 File {
46 #[serde(rename = "type")]
47 content_type: String, #[serde(default, skip_serializing_if = "Option::is_none")]
49 filename: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 file_id: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 file_data: Option<String>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 file_url: Option<String>,
56 },
57}
58
59impl ContentPart {
60 pub fn text(text: String) -> Self {
61 ContentPart::Text { text }
62 }
63
64 pub fn image(data: String, mime_type: String) -> Self {
65 ContentPart::Image {
66 data,
67 mime_type,
68 content_type: "image".to_owned(),
69 }
70 }
71
72 pub fn file_from_id(file_id: String) -> Self {
73 ContentPart::File {
74 content_type: "file".to_owned(),
75 filename: None,
76 file_id: Some(file_id),
77 file_data: None,
78 file_url: None,
79 }
80 }
81
82 pub fn file_from_url(file_url: String) -> Self {
83 ContentPart::File {
84 content_type: "input_file".to_owned(),
85 filename: None,
86 file_id: None,
87 file_data: None,
88 file_url: Some(file_url),
89 }
90 }
91
92 pub fn file_from_data(filename: String, file_data: String) -> Self {
93 ContentPart::File {
94 content_type: "input_file".to_owned(),
95 filename: Some(filename),
96 file_id: None,
97 file_data: Some(file_data),
98 file_url: None,
99 }
100 }
101
102 pub fn as_text(&self) -> Option<&str> {
103 match self {
104 ContentPart::Text { text } => Some(text),
105 _ => None,
106 }
107 }
108
109 pub fn is_image(&self) -> bool {
110 matches!(self, ContentPart::Image { .. })
111 }
112
113 pub fn is_file(&self) -> bool {
114 matches!(self, ContentPart::File { .. })
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
120pub struct Message {
121 #[serde(default)]
122 pub role: MessageRole,
123 #[serde(default)]
125 pub content: MessageContent,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub reasoning: Option<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub reasoning_details: Option<Vec<serde_json::Value>>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub tool_calls: Option<Vec<ToolCall>>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub tool_call_id: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub phase: Option<AssistantPhase>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub origin_tool: Option<String>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(untagged)]
145pub enum MessageContent {
146 Text(String),
148 Parts(Vec<ContentPart>),
150}
151
152impl MessageContent {
153 pub fn text(text: String) -> Self {
154 MessageContent::Text(text)
155 }
156
157 pub fn parts(parts: Vec<ContentPart>) -> Self {
158 MessageContent::Parts(parts)
159 }
160
161 #[inline]
164 pub fn as_text_borrowed(&self) -> Option<&str> {
165 match self {
166 MessageContent::Text(text) => Some(text.as_str()),
167 MessageContent::Parts(_) => None,
168 }
169 }
170
171 pub fn as_text(&self) -> std::borrow::Cow<'_, str> {
174 match self {
175 MessageContent::Text(text) => std::borrow::Cow::Borrowed(text),
176 MessageContent::Parts(parts) => {
177 let mut first_text = None;
178 let mut text_count = 0usize;
179 let mut total_len = 0usize;
180
181 for text in parts.iter().filter_map(ContentPart::as_text) {
182 if first_text.is_none() {
183 first_text = Some(text);
184 }
185 text_count += 1;
186 total_len += text.len();
187 }
188
189 if text_count == 0 {
190 return std::borrow::Cow::Borrowed("");
191 }
192 if text_count == 1 {
193 return std::borrow::Cow::Borrowed(first_text.unwrap_or(""));
194 }
195
196 let mut result = String::with_capacity(total_len);
197 for text in parts.iter().filter_map(ContentPart::as_text) {
198 result.push_str(text);
199 }
200 std::borrow::Cow::Owned(result)
201 }
202 }
203 }
204
205 pub fn trim(&self) -> std::borrow::Cow<'_, str> {
207 match self {
208 MessageContent::Text(text) => {
209 let trimmed = text.trim();
210 if trimmed.len() == text.len() {
212 std::borrow::Cow::Borrowed(text)
213 } else {
214 std::borrow::Cow::Borrowed(trimmed)
215 }
216 }
217 MessageContent::Parts(_) => {
218 match self.as_text() {
220 std::borrow::Cow::Borrowed(s) => std::borrow::Cow::Borrowed(s.trim()),
221 std::borrow::Cow::Owned(s) => {
222 let trimmed = s.trim();
223 if trimmed.len() == s.len() {
224 std::borrow::Cow::Owned(s)
225 } else {
226 std::borrow::Cow::Owned(trimmed.to_owned())
227 }
228 }
229 }
230 }
231 }
232 }
233
234 pub fn is_empty(&self) -> bool {
235 match self {
236 MessageContent::Text(text) => text.is_empty(),
237 MessageContent::Parts(parts) => {
238 parts.is_empty()
239 || parts.iter().all(|part| match part {
240 ContentPart::Text { text } => text.is_empty(),
241 ContentPart::Image { .. } | ContentPart::File { .. } => false,
242 })
243 }
244 }
245 }
246
247 pub fn has_images(&self) -> bool {
248 match self {
249 MessageContent::Text(_) => false,
250 MessageContent::Parts(parts) => parts.iter().any(|part| part.is_image()),
251 }
252 }
253
254 pub fn without_images(&self) -> Option<MessageContent> {
257 match self {
258 MessageContent::Text(_) => None,
259 MessageContent::Parts(parts) => {
260 let has_image = parts.iter().any(|part| part.is_image());
261 if !has_image {
262 return None;
263 }
264 let text_parts: Vec<ContentPart> = parts
265 .iter()
266 .filter(|part| !part.is_image())
267 .cloned()
268 .collect();
269 if text_parts.is_empty() {
270 Some(MessageContent::Text(String::new()))
271 } else if text_parts.len() == 1 {
272 if let ContentPart::Text { text } = &text_parts[0] {
273 Some(MessageContent::Text(text.clone()))
274 } else {
275 Some(MessageContent::Parts(text_parts))
276 }
277 } else {
278 Some(MessageContent::Parts(text_parts))
279 }
280 }
281 }
282 }
283
284 pub fn get_images(&self) -> Vec<&ContentPart> {
285 match self {
286 MessageContent::Text(_) => vec![],
287 MessageContent::Parts(parts) => parts.iter().filter(|part| part.is_image()).collect(),
288 }
289 }
290}
291
292impl Default for MessageContent {
293 fn default() -> Self {
294 MessageContent::Text(String::new())
295 }
296}
297
298impl From<String> for MessageContent {
299 fn from(value: String) -> Self {
300 MessageContent::Text(value)
301 }
302}
303
304impl From<&str> for MessageContent {
305 fn from(value: &str) -> Self {
306 MessageContent::Text(value.to_owned())
307 }
308}
309
310impl Message {
311 pub fn estimate_tokens(&self) -> usize {
313 let mut count = 0;
314
315 count += 4;
317
318 match &self.content {
320 MessageContent::Text(text) => count += crate::llm::utils::estimate_token_count(text),
321 MessageContent::Parts(parts) => {
322 for part in parts {
323 match part {
324 ContentPart::Text { text } => {
325 count += crate::llm::utils::estimate_token_count(text)
326 }
327 ContentPart::Image { .. } | ContentPart::File { .. } => count += 1000, }
329 }
330 }
331 }
332
333 if let Some(tool_calls) = &self.tool_calls {
335 for call in tool_calls {
336 count += 20; if let Some(func) = &call.function {
338 count += crate::llm::utils::estimate_token_count(&func.name);
339 count += crate::llm::utils::estimate_token_count(&func.arguments);
340 }
341 if let Some(sig) = &call.thought_signature {
342 count += crate::llm::utils::estimate_token_count(sig);
343 }
344 }
345 }
346
347 if let Some(id) = &self.tool_call_id {
349 count += crate::llm::utils::estimate_token_count(id);
350 }
351
352 if let Some(phase) = self.phase {
353 count += crate::llm::utils::estimate_token_count(phase.as_str());
354 }
355
356 count
357 }
358
359 #[inline]
362 pub const fn base(role: MessageRole, content: MessageContent) -> Self {
363 Self {
364 role,
365 content,
366 reasoning: None,
367 reasoning_details: None,
368 tool_calls: None,
369 tool_call_id: None,
370 phase: None,
371 origin_tool: None,
372 }
373 }
374
375 #[inline]
377 pub fn user(content: String) -> Self {
378 Self::base(MessageRole::User, MessageContent::Text(content))
379 }
380
381 #[inline]
383 pub fn user_with_parts(content_parts: Vec<ContentPart>) -> Self {
384 Self::base(MessageRole::User, MessageContent::Parts(content_parts))
385 }
386
387 #[inline]
389 pub fn assistant(content: String) -> Self {
390 Self::base(MessageRole::Assistant, MessageContent::Text(content))
391 }
392
393 #[inline]
395 pub fn assistant_with_parts(content_parts: Vec<ContentPart>) -> Self {
396 Self::base(MessageRole::Assistant, MessageContent::Parts(content_parts))
397 }
398
399 #[inline]
402 pub fn assistant_with_tools(content: String, tool_calls: Vec<ToolCall>) -> Self {
403 Self {
404 tool_calls: Some(tool_calls),
405 ..Self::base(MessageRole::Assistant, MessageContent::Text(content))
406 }
407 }
408
409 #[inline]
411 pub fn assistant_with_tools_and_parts(
412 content_parts: Vec<ContentPart>,
413 tool_calls: Vec<ToolCall>,
414 ) -> Self {
415 Self {
416 tool_calls: Some(tool_calls),
417 ..Self::base(MessageRole::Assistant, MessageContent::Parts(content_parts))
418 }
419 }
420
421 #[inline]
424 pub fn assistant_with_tools_and_reasoning(
425 content: String,
426 tool_calls: Vec<ToolCall>,
427 reasoning_details: Option<Vec<serde_json::Value>>,
428 ) -> Self {
429 Self {
430 tool_calls: Some(tool_calls),
431 reasoning_details,
432 ..Self::base(MessageRole::Assistant, MessageContent::Text(content))
433 }
434 }
435
436 #[inline]
438 pub fn system(content: String) -> Self {
439 Self::base(MessageRole::System, MessageContent::Text(content))
440 }
441
442 #[inline]
452 pub fn tool_response(tool_call_id: String, content: String) -> Self {
453 Self {
454 tool_call_id: Some(tool_call_id),
455 ..Self::base(MessageRole::Tool, MessageContent::Text(content))
456 }
457 }
458
459 #[inline]
462 pub fn tool_response_with_name(
463 tool_call_id: String,
464 _function_name: String,
465 content: String,
466 ) -> Self {
467 Self::tool_response(tool_call_id, content)
469 }
470
471 #[inline]
474 pub fn tool_response_with_origin(
475 tool_call_id: String,
476 content: String,
477 origin_tool: String,
478 ) -> Self {
479 Self {
480 tool_call_id: Some(tool_call_id),
481 origin_tool: Some(origin_tool),
482 ..Self::base(MessageRole::Tool, MessageContent::Text(content))
483 }
484 }
485
486 pub async fn user_with_local_image<P: AsRef<std::path::Path>>(
488 file_path: P,
489 ) -> Result<Self, anyhow::Error> {
490 let image_data = crate::utils::image_processing::read_image_file(file_path).await?;
491 let image_part = ContentPart::image(image_data.base64_data, image_data.mime_type);
492 Ok(Self::user_with_parts(vec![image_part]))
493 }
494
495 pub async fn user_with_text_and_local_image<P: AsRef<std::path::Path>>(
497 text: String,
498 file_path: P,
499 ) -> Result<Self, anyhow::Error> {
500 let image_data = crate::utils::image_processing::read_image_file(file_path).await?;
501 let text_part = ContentPart::text(text);
502 let image_part = ContentPart::image(image_data.base64_data, image_data.mime_type);
503 Ok(Self::user_with_parts(vec![text_part, image_part]))
504 }
505
506 pub fn with_reasoning(mut self, reasoning: Option<String>) -> Self {
508 if self.role == MessageRole::Assistant
509 && let Some(reasoning_text) = reasoning.as_ref()
510 {
511 let cleaned_reasoning = clean_reasoning_text(reasoning_text);
512 if !cleaned_reasoning.is_empty() {
513 let cleaned_content = clean_reasoning_text(self.content.as_text().as_ref());
514 if !cleaned_content.is_empty() && cleaned_reasoning == cleaned_content {
515 self.reasoning = None;
516 return self;
517 }
518 }
519 }
520 self.reasoning = reasoning;
521 self
522 }
523
524 pub fn with_tool_calls(mut self, tool_calls: Vec<ToolCall>) -> Self {
526 self.tool_calls = Some(tool_calls);
527 self
528 }
529
530 pub fn with_reasoning_details(
532 mut self,
533 reasoning_details: Option<Vec<serde_json::Value>>,
534 ) -> Self {
535 self.reasoning_details = reasoning_details;
536 self
537 }
538
539 #[must_use]
541 pub fn with_phase(mut self, phase: Option<AssistantPhase>) -> Self {
542 self.phase = if self.role == MessageRole::Assistant {
543 phase
544 } else {
545 None
546 };
547 self
548 }
549
550 pub fn validate_for_provider(&self, provider: &str) -> Result<(), String> {
553 self.role
555 .validate_for_provider(provider, self.tool_call_id.is_some())?;
556
557 if let Some(tool_calls) = &self.tool_calls {
559 if !self.role.can_make_tool_calls() {
560 return Err(format!("Role {:?} cannot make tool calls", self.role));
561 }
562
563 if tool_calls.is_empty() {
564 return Err("Tool calls array should not be empty".to_owned());
565 }
566
567 for tool_call in tool_calls {
569 tool_call.validate()?;
570 }
571 }
572
573 match provider {
575 "openai" | "openrouter" | "zai" | "stepfun" | "evolink" => {
576 if self.role == MessageRole::Tool && self.tool_call_id.is_none() {
577 return Err(format!(
578 "{} requires tool_call_id for tool messages",
579 provider
580 ));
581 }
582 }
583 "gemini" => {
584 if self.role == MessageRole::Tool && self.tool_call_id.is_none() {
585 return Err(
586 "Gemini tool responses need tool_call_id for function name mapping"
587 .to_owned(),
588 );
589 }
590 if self.role == MessageRole::System && !self.content.as_text().is_empty() {
592 }
594 }
595 "anthropic" => {
596 }
599 _ => {} }
601
602 Ok(())
603 }
604
605 pub fn has_tool_calls(&self) -> bool {
607 self.tool_calls
608 .as_ref()
609 .is_some_and(|calls| !calls.is_empty())
610 }
611
612 pub fn get_tool_calls(&self) -> Option<&[ToolCall]> {
614 self.tool_calls.as_deref()
615 }
616
617 pub fn is_tool_response(&self) -> bool {
619 self.role == MessageRole::Tool
620 }
621
622 pub fn get_text_content(&self) -> std::borrow::Cow<'_, str> {
624 self.content.as_text()
625 }
626
627 pub fn has_images(&self) -> bool {
629 self.content.has_images()
630 }
631
632 pub fn get_images(&self) -> Vec<&ContentPart> {
634 self.content.get_images()
635 }
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
639pub enum MessageRole {
640 System,
641 #[default]
642 User,
643 Assistant,
644 Tool,
645}
646
647impl std::fmt::Display for MessageRole {
648 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
649 match self {
650 MessageRole::System => write!(f, "system"),
651 MessageRole::User => write!(f, "user"),
652 MessageRole::Assistant => write!(f, "assistant"),
653 MessageRole::Tool => write!(f, "tool"),
654 }
655 }
656}
657
658impl MessageRole {
659 pub fn as_gemini_str(&self) -> &'static str {
665 match self {
666 MessageRole::System => "system", MessageRole::User => "user",
668 MessageRole::Assistant => "model", MessageRole::Tool => "user", }
671 }
672
673 pub fn as_openai_str(&self) -> &'static str {
678 match self {
679 MessageRole::System => "system",
680 MessageRole::User => "user",
681 MessageRole::Assistant => "assistant",
682 MessageRole::Tool => "tool", }
684 }
685
686 pub fn as_anthropic_str(&self) -> &'static str {
692 match self {
693 MessageRole::System => "system", MessageRole::User => "user",
695 MessageRole::Assistant => "assistant",
696 MessageRole::Tool => "user", }
698 }
699
700 pub fn as_generic_str(&self) -> &'static str {
703 match self {
704 MessageRole::System => "system",
705 MessageRole::User => "user",
706 MessageRole::Assistant => "assistant",
707 MessageRole::Tool => "tool",
708 }
709 }
710
711 pub fn can_make_tool_calls(&self) -> bool {
714 matches!(self, MessageRole::Assistant)
715 }
716
717 pub fn is_tool_response(&self) -> bool {
719 matches!(self, MessageRole::Tool)
720 }
721
722 pub fn validate_for_provider(
725 &self,
726 provider: &str,
727 has_tool_call_id: bool,
728 ) -> Result<(), String> {
729 match (self, provider) {
730 (MessageRole::Tool, provider)
731 if matches!(provider, "openai" | "openrouter" | "deepseek" | "zai")
732 && !has_tool_call_id =>
733 {
734 Err(format!("{} tool messages must have tool_call_id", provider))
735 }
736 (MessageRole::Tool, "gemini") if !has_tool_call_id => {
737 Err("Gemini tool messages need tool_call_id for function mapping".to_owned())
738 }
739 _ => Ok(()),
740 }
741 }
742}
743
744#[cfg(test)]
745mod tests {
746 use super::{AssistantPhase, ContentPart, Message, MessageContent, MessageRole, ToolCall};
747
748 #[test]
749 fn message_content_parts_concatenate_without_extra_spaces() {
750 let parts = vec![
751 ContentPart::text("Andre".to_string()),
752 ContentPart::text("j".to_string()),
753 ContentPart::text(" Kar".to_string()),
754 ContentPart::text("pathy".to_string()),
755 ContentPart::text("'s".to_string()),
756 ];
757 let content = MessageContent::Parts(parts);
758
759 assert_eq!(content.as_text().as_ref() as &str, "Andrej Karpathy's");
760 }
761
762 #[test]
763 fn message_content_parts_with_single_text_stays_borrowed() {
764 let content = MessageContent::Parts(vec![ContentPart::text("borrowed".to_string())]);
765
766 assert!(matches!(
767 content.as_text(),
768 std::borrow::Cow::Borrowed("borrowed")
769 ));
770 }
771
772 #[test]
773 fn message_content_parts_without_text_stays_borrowed_empty() {
774 let content = MessageContent::Parts(vec![ContentPart::image(
775 "encoded".to_string(),
776 "image/png".to_string(),
777 )]);
778
779 assert!(matches!(content.as_text(), std::borrow::Cow::Borrowed("")));
780 }
781
782 #[test]
783 fn assistant_phase_parses_wire_strings() {
784 assert_eq!(
785 AssistantPhase::from_wire_str("commentary"),
786 Some(AssistantPhase::Commentary)
787 );
788 assert_eq!(
789 AssistantPhase::from_wire_str("final_answer"),
790 Some(AssistantPhase::FinalAnswer)
791 );
792 assert_eq!(AssistantPhase::from_wire_str("other"), None);
793 }
794
795 #[test]
796 fn with_phase_ignores_non_assistant_roles() {
797 let user = Message::user("hello".to_string()).with_phase(Some(AssistantPhase::Commentary));
798 let tool = Message::tool_response("call_1".to_string(), "ok".to_string())
799 .with_phase(Some(AssistantPhase::FinalAnswer));
800
801 assert_eq!(user.role, MessageRole::User);
802 assert!(user.phase.is_none());
803 assert_eq!(tool.role, MessageRole::Tool);
804 assert!(tool.phase.is_none());
805 }
806
807 #[test]
808 fn validate_for_provider_accepts_recovered_tool_arguments() {
809 let message = Message::assistant_with_tools(
810 String::new(),
811 vec![ToolCall::function(
812 "call_search".to_string(),
813 "unified_search".to_string(),
814 "{\"action\": \"grep\", \"pattern\": \"persistent_memory\", \"path\": \"vtcode-core/src</parameter>\n<</invoke>\n</minimax:tool_call>".to_string(),
815 )],
816 );
817
818 message.validate_for_provider("anthropic").unwrap();
819 }
820}