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 get_images(&self) -> Vec<&ContentPart> {
255 match self {
256 MessageContent::Text(_) => vec![],
257 MessageContent::Parts(parts) => parts.iter().filter(|part| part.is_image()).collect(),
258 }
259 }
260}
261
262impl Default for MessageContent {
263 fn default() -> Self {
264 MessageContent::Text(String::new())
265 }
266}
267
268impl From<String> for MessageContent {
269 fn from(value: String) -> Self {
270 MessageContent::Text(value)
271 }
272}
273
274impl From<&str> for MessageContent {
275 fn from(value: &str) -> Self {
276 MessageContent::Text(value.to_owned())
277 }
278}
279
280impl Message {
281 pub fn estimate_tokens(&self) -> usize {
283 let mut count = 0;
284
285 count += 4;
287
288 match &self.content {
290 MessageContent::Text(text) => count += crate::llm::utils::estimate_token_count(text),
291 MessageContent::Parts(parts) => {
292 for part in parts {
293 match part {
294 ContentPart::Text { text } => {
295 count += crate::llm::utils::estimate_token_count(text)
296 }
297 ContentPart::Image { .. } | ContentPart::File { .. } => count += 1000, }
299 }
300 }
301 }
302
303 if let Some(tool_calls) = &self.tool_calls {
305 for call in tool_calls {
306 count += 20; if let Some(func) = &call.function {
308 count += crate::llm::utils::estimate_token_count(&func.name);
309 count += crate::llm::utils::estimate_token_count(&func.arguments);
310 }
311 if let Some(sig) = &call.thought_signature {
312 count += crate::llm::utils::estimate_token_count(sig);
313 }
314 }
315 }
316
317 if let Some(id) = &self.tool_call_id {
319 count += crate::llm::utils::estimate_token_count(id);
320 }
321
322 if let Some(phase) = self.phase {
323 count += crate::llm::utils::estimate_token_count(phase.as_str());
324 }
325
326 count
327 }
328
329 #[inline]
332 pub const fn base(role: MessageRole, content: MessageContent) -> Self {
333 Self {
334 role,
335 content,
336 reasoning: None,
337 reasoning_details: None,
338 tool_calls: None,
339 tool_call_id: None,
340 phase: None,
341 origin_tool: None,
342 }
343 }
344
345 #[inline]
347 pub fn user(content: String) -> Self {
348 Self::base(MessageRole::User, MessageContent::Text(content))
349 }
350
351 #[inline]
353 pub fn user_with_parts(content_parts: Vec<ContentPart>) -> Self {
354 Self::base(MessageRole::User, MessageContent::Parts(content_parts))
355 }
356
357 #[inline]
359 pub fn assistant(content: String) -> Self {
360 Self::base(MessageRole::Assistant, MessageContent::Text(content))
361 }
362
363 #[inline]
365 pub fn assistant_with_parts(content_parts: Vec<ContentPart>) -> Self {
366 Self::base(MessageRole::Assistant, MessageContent::Parts(content_parts))
367 }
368
369 #[inline]
372 pub fn assistant_with_tools(content: String, tool_calls: Vec<ToolCall>) -> Self {
373 Self {
374 tool_calls: Some(tool_calls),
375 ..Self::base(MessageRole::Assistant, MessageContent::Text(content))
376 }
377 }
378
379 #[inline]
381 pub fn assistant_with_tools_and_parts(
382 content_parts: Vec<ContentPart>,
383 tool_calls: Vec<ToolCall>,
384 ) -> Self {
385 Self {
386 tool_calls: Some(tool_calls),
387 ..Self::base(MessageRole::Assistant, MessageContent::Parts(content_parts))
388 }
389 }
390
391 #[inline]
394 pub fn assistant_with_tools_and_reasoning(
395 content: String,
396 tool_calls: Vec<ToolCall>,
397 reasoning_details: Option<Vec<serde_json::Value>>,
398 ) -> Self {
399 Self {
400 tool_calls: Some(tool_calls),
401 reasoning_details,
402 ..Self::base(MessageRole::Assistant, MessageContent::Text(content))
403 }
404 }
405
406 #[inline]
408 pub fn system(content: String) -> Self {
409 Self::base(MessageRole::System, MessageContent::Text(content))
410 }
411
412 #[inline]
422 pub fn tool_response(tool_call_id: String, content: String) -> Self {
423 Self {
424 tool_call_id: Some(tool_call_id),
425 ..Self::base(MessageRole::Tool, MessageContent::Text(content))
426 }
427 }
428
429 #[inline]
432 pub fn tool_response_with_name(
433 tool_call_id: String,
434 _function_name: String,
435 content: String,
436 ) -> Self {
437 Self::tool_response(tool_call_id, content)
439 }
440
441 #[inline]
444 pub fn tool_response_with_origin(
445 tool_call_id: String,
446 content: String,
447 origin_tool: String,
448 ) -> Self {
449 Self {
450 tool_call_id: Some(tool_call_id),
451 origin_tool: Some(origin_tool),
452 ..Self::base(MessageRole::Tool, MessageContent::Text(content))
453 }
454 }
455
456 pub async fn user_with_local_image<P: AsRef<std::path::Path>>(
458 file_path: P,
459 ) -> Result<Self, anyhow::Error> {
460 let image_data = crate::utils::image_processing::read_image_file(file_path).await?;
461 let image_part = ContentPart::image(image_data.base64_data, image_data.mime_type);
462 Ok(Self::user_with_parts(vec![image_part]))
463 }
464
465 pub async fn user_with_text_and_local_image<P: AsRef<std::path::Path>>(
467 text: String,
468 file_path: P,
469 ) -> Result<Self, anyhow::Error> {
470 let image_data = crate::utils::image_processing::read_image_file(file_path).await?;
471 let text_part = ContentPart::text(text);
472 let image_part = ContentPart::image(image_data.base64_data, image_data.mime_type);
473 Ok(Self::user_with_parts(vec![text_part, image_part]))
474 }
475
476 pub fn with_reasoning(mut self, reasoning: Option<String>) -> Self {
478 if self.role == MessageRole::Assistant
479 && let Some(reasoning_text) = reasoning.as_ref()
480 {
481 let cleaned_reasoning = clean_reasoning_text(reasoning_text);
482 if !cleaned_reasoning.is_empty() {
483 let cleaned_content = clean_reasoning_text(self.content.as_text().as_ref());
484 if !cleaned_content.is_empty() && cleaned_reasoning == cleaned_content {
485 self.reasoning = None;
486 return self;
487 }
488 }
489 }
490 self.reasoning = reasoning;
491 self
492 }
493
494 pub fn with_tool_calls(mut self, tool_calls: Vec<ToolCall>) -> Self {
496 self.tool_calls = Some(tool_calls);
497 self
498 }
499
500 pub fn with_reasoning_details(
502 mut self,
503 reasoning_details: Option<Vec<serde_json::Value>>,
504 ) -> Self {
505 self.reasoning_details = reasoning_details;
506 self
507 }
508
509 #[must_use]
511 pub fn with_phase(mut self, phase: Option<AssistantPhase>) -> Self {
512 self.phase = if self.role == MessageRole::Assistant {
513 phase
514 } else {
515 None
516 };
517 self
518 }
519
520 pub fn validate_for_provider(&self, provider: &str) -> Result<(), String> {
523 self.role
525 .validate_for_provider(provider, self.tool_call_id.is_some())?;
526
527 if let Some(tool_calls) = &self.tool_calls {
529 if !self.role.can_make_tool_calls() {
530 return Err(format!("Role {:?} cannot make tool calls", self.role));
531 }
532
533 if tool_calls.is_empty() {
534 return Err("Tool calls array should not be empty".to_owned());
535 }
536
537 for tool_call in tool_calls {
539 tool_call.validate()?;
540 }
541 }
542
543 match provider {
545 "openai" | "openrouter" | "zai" | "stepfun" | "evolink" => {
546 if self.role == MessageRole::Tool && self.tool_call_id.is_none() {
547 return Err(format!(
548 "{} requires tool_call_id for tool messages",
549 provider
550 ));
551 }
552 }
553 "gemini" => {
554 if self.role == MessageRole::Tool && self.tool_call_id.is_none() {
555 return Err(
556 "Gemini tool responses need tool_call_id for function name mapping"
557 .to_owned(),
558 );
559 }
560 if self.role == MessageRole::System && !self.content.as_text().is_empty() {
562 }
564 }
565 "anthropic" => {
566 }
569 _ => {} }
571
572 Ok(())
573 }
574
575 pub fn has_tool_calls(&self) -> bool {
577 self.tool_calls
578 .as_ref()
579 .is_some_and(|calls| !calls.is_empty())
580 }
581
582 pub fn get_tool_calls(&self) -> Option<&[ToolCall]> {
584 self.tool_calls.as_deref()
585 }
586
587 pub fn is_tool_response(&self) -> bool {
589 self.role == MessageRole::Tool
590 }
591
592 pub fn get_text_content(&self) -> std::borrow::Cow<'_, str> {
594 self.content.as_text()
595 }
596
597 pub fn has_images(&self) -> bool {
599 self.content.has_images()
600 }
601
602 pub fn get_images(&self) -> Vec<&ContentPart> {
604 self.content.get_images()
605 }
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
609pub enum MessageRole {
610 System,
611 #[default]
612 User,
613 Assistant,
614 Tool,
615}
616
617impl std::fmt::Display for MessageRole {
618 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
619 match self {
620 MessageRole::System => write!(f, "system"),
621 MessageRole::User => write!(f, "user"),
622 MessageRole::Assistant => write!(f, "assistant"),
623 MessageRole::Tool => write!(f, "tool"),
624 }
625 }
626}
627
628impl MessageRole {
629 pub fn as_gemini_str(&self) -> &'static str {
635 match self {
636 MessageRole::System => "system", MessageRole::User => "user",
638 MessageRole::Assistant => "model", MessageRole::Tool => "user", }
641 }
642
643 pub fn as_openai_str(&self) -> &'static str {
648 match self {
649 MessageRole::System => "system",
650 MessageRole::User => "user",
651 MessageRole::Assistant => "assistant",
652 MessageRole::Tool => "tool", }
654 }
655
656 pub fn as_anthropic_str(&self) -> &'static str {
662 match self {
663 MessageRole::System => "system", MessageRole::User => "user",
665 MessageRole::Assistant => "assistant",
666 MessageRole::Tool => "user", }
668 }
669
670 pub fn as_generic_str(&self) -> &'static str {
673 match self {
674 MessageRole::System => "system",
675 MessageRole::User => "user",
676 MessageRole::Assistant => "assistant",
677 MessageRole::Tool => "tool",
678 }
679 }
680
681 pub fn can_make_tool_calls(&self) -> bool {
684 matches!(self, MessageRole::Assistant)
685 }
686
687 pub fn is_tool_response(&self) -> bool {
689 matches!(self, MessageRole::Tool)
690 }
691
692 pub fn validate_for_provider(
695 &self,
696 provider: &str,
697 has_tool_call_id: bool,
698 ) -> Result<(), String> {
699 match (self, provider) {
700 (MessageRole::Tool, provider)
701 if matches!(provider, "openai" | "openrouter" | "deepseek" | "zai")
702 && !has_tool_call_id =>
703 {
704 Err(format!("{} tool messages must have tool_call_id", provider))
705 }
706 (MessageRole::Tool, "gemini") if !has_tool_call_id => {
707 Err("Gemini tool messages need tool_call_id for function mapping".to_owned())
708 }
709 _ => Ok(()),
710 }
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::{AssistantPhase, ContentPart, Message, MessageContent, MessageRole, ToolCall};
717
718 #[test]
719 fn message_content_parts_concatenate_without_extra_spaces() {
720 let parts = vec![
721 ContentPart::text("Andre".to_string()),
722 ContentPart::text("j".to_string()),
723 ContentPart::text(" Kar".to_string()),
724 ContentPart::text("pathy".to_string()),
725 ContentPart::text("'s".to_string()),
726 ];
727 let content = MessageContent::Parts(parts);
728
729 assert_eq!(content.as_text().as_ref() as &str, "Andrej Karpathy's");
730 }
731
732 #[test]
733 fn message_content_parts_with_single_text_stays_borrowed() {
734 let content = MessageContent::Parts(vec![ContentPart::text("borrowed".to_string())]);
735
736 assert!(matches!(
737 content.as_text(),
738 std::borrow::Cow::Borrowed("borrowed")
739 ));
740 }
741
742 #[test]
743 fn message_content_parts_without_text_stays_borrowed_empty() {
744 let content = MessageContent::Parts(vec![ContentPart::image(
745 "encoded".to_string(),
746 "image/png".to_string(),
747 )]);
748
749 assert!(matches!(content.as_text(), std::borrow::Cow::Borrowed("")));
750 }
751
752 #[test]
753 fn assistant_phase_parses_wire_strings() {
754 assert_eq!(
755 AssistantPhase::from_wire_str("commentary"),
756 Some(AssistantPhase::Commentary)
757 );
758 assert_eq!(
759 AssistantPhase::from_wire_str("final_answer"),
760 Some(AssistantPhase::FinalAnswer)
761 );
762 assert_eq!(AssistantPhase::from_wire_str("other"), None);
763 }
764
765 #[test]
766 fn with_phase_ignores_non_assistant_roles() {
767 let user = Message::user("hello".to_string()).with_phase(Some(AssistantPhase::Commentary));
768 let tool = Message::tool_response("call_1".to_string(), "ok".to_string())
769 .with_phase(Some(AssistantPhase::FinalAnswer));
770
771 assert_eq!(user.role, MessageRole::User);
772 assert!(user.phase.is_none());
773 assert_eq!(tool.role, MessageRole::Tool);
774 assert!(tool.phase.is_none());
775 }
776
777 #[test]
778 fn validate_for_provider_accepts_recovered_tool_arguments() {
779 let message = Message::assistant_with_tools(
780 String::new(),
781 vec![ToolCall::function(
782 "call_search".to_string(),
783 "unified_search".to_string(),
784 "{\"action\": \"grep\", \"pattern\": \"persistent_memory\", \"path\": \"vtcode-core/src</parameter>\n<</invoke>\n</minimax:tool_call>".to_string(),
785 )],
786 );
787
788 message.validate_for_provider("anthropic").unwrap();
789 }
790}