Skip to main content

vtcode_llm/provider/
message.rs

1use super::ToolCall;
2use crate::providers::clean_reasoning_text;
3use serde::{Deserialize, Serialize};
4use vtcode_commons::message_metadata::MessageMetadata;
5
6/// Phase metadata for assistant messages in multi-step Responses-style workflows.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum AssistantPhase {
10    Commentary,
11    FinalAnswer,
12}
13
14/// Controls when an Anthropic mid-conversation system message is cleared.
15///
16/// This is intentionally a typed message property instead of a provider-wide
17/// request flag so the message remains in persisted history and can be replayed
18/// verbatim on the next request.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum MessageClearAt {
22    NextUserMessage,
23}
24
25impl AssistantPhase {
26    #[must_use]
27    pub(crate) const fn as_str(self) -> &'static str {
28        match self {
29            Self::Commentary => "commentary",
30            Self::FinalAnswer => "final_answer",
31        }
32    }
33
34    #[must_use]
35    pub(crate) fn from_wire_str(value: &str) -> Option<Self> {
36        match value {
37            "commentary" => Some(Self::Commentary),
38            "final_answer" => Some(Self::FinalAnswer),
39            _ => None,
40        }
41    }
42}
43
44/// Detail level for image processing (DeepSeek/OpenAI `detail` field).
45///
46/// `Original` is retained for Gemini compatibility but not used for DeepSeek/OpenAI chat.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum ImageDetail {
50    #[serde(rename = "low")]
51    Low,
52    #[serde(rename = "high")]
53    High,
54    #[serde(rename = "original")]
55    Original,
56    #[serde(rename = "auto")]
57    Auto,
58}
59
60impl ImageDetail {
61    pub fn as_str(&self) -> &'static str {
62        match self {
63            Self::Low => "low",
64            Self::High => "high",
65            Self::Original => "original",
66            Self::Auto => "auto",
67        }
68    }
69
70    #[allow(
71        clippy::should_implement_trait,
72        reason = "preserve the public compatibility parser API"
73    )]
74    pub fn from_str(s: &str) -> Option<Self> {
75        match s.trim().to_ascii_lowercase().as_str() {
76            "low" => Some(Self::Low),
77            "high" => Some(Self::High),
78            "original" => Some(Self::Original),
79            "auto" => Some(Self::Auto),
80            _ => None,
81        }
82    }
83}
84
85/// Strictly typed image source — replaces bare `data`/`mime_type`/`image_url` triple.
86///
87/// This makes the mutual exclusivity explicit (shape-suffix naming) and provides
88/// a single dispatch point for serialization. The underlying `ContentPart::Image`
89/// fields are kept for serde backward compat, but new code should use this enum.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum ImageSource<'a> {
92    Base64 { data: &'a str, mime_type: &'a str },
93    Url { url: &'a str },
94}
95
96/// Content type for messages that can include both text and images
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(untagged)]
99pub enum ContentPart {
100    Text {
101        text: String,
102    },
103    Image {
104        data: String,      // Base64 encoded image data (empty when `image_url` is used)
105        mime_type: String, // MIME type (e.g., "image/png")
106        #[serde(rename = "type")]
107        content_type: String, // "image"
108        #[serde(default, skip_serializing_if = "Option::is_none")]
109        detail: Option<ImageDetail>, // DeepSeek/OpenAI detail: low|high|original|auto
110        #[serde(default, skip_serializing_if = "Option::is_none")]
111        image_url: Option<String>, // External https URL alternative to base64 data
112    },
113    File {
114        #[serde(rename = "type")]
115        content_type: String, // "file" or "input_file"
116        #[serde(default, skip_serializing_if = "Option::is_none")]
117        filename: Option<String>,
118        #[serde(default, skip_serializing_if = "Option::is_none")]
119        file_id: Option<String>,
120        #[serde(default, skip_serializing_if = "Option::is_none")]
121        file_data: Option<String>,
122        #[serde(default, skip_serializing_if = "Option::is_none")]
123        file_url: Option<String>,
124    },
125}
126
127impl ContentPart {
128    pub fn text(text: String) -> Self {
129        ContentPart::Text { text }
130    }
131
132    pub fn image(data: String, mime_type: String) -> Self {
133        ContentPart::Image {
134            data,
135            mime_type,
136            content_type: "image".to_owned(),
137            detail: None,
138            image_url: None,
139        }
140    }
141
142    pub fn image_with_detail(data: String, mime_type: String, detail: ImageDetail) -> Self {
143        ContentPart::Image {
144            data,
145            mime_type,
146            content_type: "image".to_owned(),
147            detail: Some(detail),
148            image_url: None,
149        }
150    }
151
152    /// Create an image part from an external URL.
153    ///
154    /// DeepSeek external URLs must be `https://`, ≤8192 chars, and ≤32MiB file.
155    /// Returns `Err` for malformed URLs (fail-closed) so callers must handle it.
156    pub fn image_from_url(url: String, detail: Option<ImageDetail>) -> Result<Self, String> {
157        let trimmed = url.trim();
158        if trimmed.is_empty() {
159            return Err("image URL must not be empty".to_owned());
160        }
161        if trimmed.len() > 8192 {
162            return Err(format!("image URL exceeds 8192 char limit (len={})", trimmed.len()));
163        }
164        if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) {
165            return Err(format!("image URL must be https://, got {trimmed:?}"));
166        }
167        Ok(ContentPart::Image {
168            data: String::new(),
169            mime_type: String::new(),
170            content_type: "image".to_owned(),
171            detail,
172            image_url: Some(url),
173        })
174    }
175
176    /// Strictly typed view of the image source (Base64 vs URL).
177    ///
178    /// This isolates the `data`/`mime_type`/`image_url` triple behind a single
179    /// dispatch point (KISS/DRY guard rail for the next generation phase).
180    pub fn image_source(&self) -> Option<ImageSource<'_>> {
181        match self {
182            ContentPart::Image { data, mime_type, image_url, .. } => {
183                if let Some(url) = image_url {
184                    Some(ImageSource::Url { url })
185                } else if !data.is_empty() && !mime_type.is_empty() {
186                    Some(ImageSource::Base64 { data, mime_type })
187                } else {
188                    None
189                }
190            }
191            _ => None,
192        }
193    }
194
195    /// Validate image part invariants (MIME allowlist, size, URL limits).
196    ///
197    /// DeepSeek supports JPEG/PNG/GIF/WebP; other types are warned but not rejected
198    /// to keep the interface open for future providers.
199    pub fn validate_image(&self) -> Result<(), String> {
200        match self {
201            ContentPart::Image { data, mime_type, image_url, .. } => {
202                if let Some(url) = image_url {
203                    if url.len() > 8192 {
204                        return Err(format!("image URL exceeds 8192 chars: {}", url.len()));
205                    }
206                    if !(url.starts_with("https://") || url.starts_with("http://")) {
207                        return Err(format!("image URL must be https://: {url:?}"));
208                    }
209                } else {
210                    if data.is_empty() || mime_type.is_empty() {
211                        return Err("image data and mime_type must be non-empty for base64 images".to_owned());
212                    }
213                    const ALLOWED: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
214                    if !ALLOWED.contains(&mime_type.as_str()) {
215                        tracing::warn!(mime_type = %mime_type, "image MIME type not in DeepSeek allowlist");
216                    }
217                    // Rough size check: base64 string length * 3/4 ≈ decoded bytes
218                    let decoded_approx = data.len() * 3 / 4;
219                    if decoded_approx > 32 * 1024 * 1024 {
220                        return Err(format!("image exceeds 32MiB limit: ~{} bytes", decoded_approx));
221                    }
222                }
223                Ok(())
224            }
225            _ => Ok(()),
226        }
227    }
228
229    /// Ergonomic helper for string-based detail (parses case-insensitively).
230    /// Returns `None` and warns if `detail_str` is invalid.
231    pub fn image_with_detail_str(data: String, mime_type: String, detail_str: &str) -> Option<Self> {
232        match ImageDetail::from_str(detail_str) {
233            Some(d) => Some(Self::image_with_detail(data, mime_type, d)),
234            None => {
235                tracing::warn!(detail = %detail_str, "invalid image detail, expected low|high|original|auto");
236                None
237            }
238        }
239    }
240
241    pub(crate) fn file_from_id(file_id: String) -> Self {
242        ContentPart::File {
243            content_type: "file".to_owned(),
244            filename: None,
245            file_id: Some(file_id),
246            file_data: None,
247            file_url: None,
248        }
249    }
250
251    pub fn file_from_url(file_url: String) -> Self {
252        ContentPart::File {
253            content_type: "input_file".to_owned(),
254            filename: None,
255            file_id: None,
256            file_data: None,
257            file_url: Some(file_url),
258        }
259    }
260
261    pub fn file_from_data(filename: String, file_data: String) -> Self {
262        ContentPart::File {
263            content_type: "input_file".to_owned(),
264            filename: Some(filename),
265            file_id: None,
266            file_data: Some(file_data),
267            file_url: None,
268        }
269    }
270
271    pub fn as_text(&self) -> Option<&str> {
272        match self {
273            ContentPart::Text { text } => Some(text),
274            _ => None,
275        }
276    }
277
278    pub(crate) fn is_image(&self) -> bool {
279        matches!(self, ContentPart::Image { .. })
280    }
281
282    pub(crate) fn is_file(&self) -> bool {
283        matches!(self, ContentPart::File { .. })
284    }
285}
286
287/// Universal message structure supporting both text and image content
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
289pub struct Message {
290    #[serde(default)]
291    pub role: MessageRole,
292    /// Content can be a string (for backward compatibility) or an array of content parts
293    #[serde(default)]
294    pub content: MessageContent,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub reasoning: Option<String>,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub reasoning_details: Option<Vec<serde_json::Value>>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub tool_calls: Option<Vec<ToolCall>>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub tool_call_id: Option<String>,
303    /// Optional assistant-only phase metadata used by OpenAI Responses workflows.
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub phase: Option<AssistantPhase>,
306    /// Optional origin tool name for tracking which tool generated this message
307    /// Used in tool-aware context retention to preserve results from recently-active tools
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub origin_tool: Option<String>,
310    /// Optional per-message metadata (timestamp, importance, compression status, etc.).
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub metadata: Option<MessageMetadata>,
313    /// Optional provider-specific clear scope for a mid-conversation system message.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub clear_at: Option<MessageClearAt>,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(untagged)]
320pub enum MessageContent {
321    /// Legacy single text string
322    Text(String),
323    /// Multiple content parts (text and images)
324    Parts(Vec<ContentPart>),
325}
326
327impl MessageContent {
328    pub fn text(text: String) -> Self {
329        MessageContent::Text(text)
330    }
331
332    pub fn parts(parts: Vec<ContentPart>) -> Self {
333        MessageContent::Parts(parts)
334    }
335
336    /// Returns a borrowed reference to the text content if this is a simple Text variant.
337    /// For Parts variant, returns None (use as_text() for combined content).
338    #[inline]
339    pub fn as_text_borrowed(&self) -> Option<&str> {
340        match self {
341            MessageContent::Text(text) => Some(text.as_str()),
342            MessageContent::Parts(_) => None,
343        }
344    }
345
346    /// Returns the text content, avoiding allocation if possible.
347    /// For Parts variant, concatenates text parts in order without adding spacing.
348    pub fn as_text(&self) -> std::borrow::Cow<'_, str> {
349        match self {
350            MessageContent::Text(text) => std::borrow::Cow::Borrowed(text),
351            MessageContent::Parts(parts) => {
352                let mut first_text = None;
353                let mut text_count = 0usize;
354                let mut total_len = 0usize;
355
356                for text in parts.iter().filter_map(ContentPart::as_text) {
357                    if first_text.is_none() {
358                        first_text = Some(text);
359                    }
360                    text_count += 1;
361                    total_len += text.len();
362                }
363
364                if text_count == 0 {
365                    return std::borrow::Cow::Borrowed("");
366                }
367                if text_count == 1 {
368                    return std::borrow::Cow::Borrowed(first_text.unwrap_or(""));
369                }
370
371                let mut result = String::with_capacity(total_len);
372                for text in parts.iter().filter_map(ContentPart::as_text) {
373                    result.push_str(text);
374                }
375                std::borrow::Cow::Owned(result)
376            }
377        }
378    }
379
380    /// Returns trimmed text content. Avoids allocation when possible.
381    pub fn trim(&self) -> std::borrow::Cow<'_, str> {
382        match self {
383            MessageContent::Text(text) => {
384                let trimmed = text.trim();
385                // Optimization: Only allocate if trim actually changed the string
386                if trimmed.len() == text.len() {
387                    std::borrow::Cow::Borrowed(text)
388                } else {
389                    std::borrow::Cow::Borrowed(trimmed)
390                }
391            }
392            MessageContent::Parts(_) => {
393                // For Parts, we need to get text first, then trim
394                match self.as_text() {
395                    std::borrow::Cow::Borrowed(s) => std::borrow::Cow::Borrowed(s.trim()),
396                    std::borrow::Cow::Owned(s) => {
397                        let trimmed = s.trim();
398                        if trimmed.len() == s.len() {
399                            std::borrow::Cow::Owned(s)
400                        } else {
401                            std::borrow::Cow::Owned(trimmed.to_owned())
402                        }
403                    }
404                }
405            }
406        }
407    }
408
409    pub(crate) fn is_empty(&self) -> bool {
410        match self {
411            MessageContent::Text(text) => text.is_empty(),
412            MessageContent::Parts(parts) => {
413                parts.is_empty()
414                    || parts.iter().all(|part| match part {
415                        ContentPart::Text { text } => text.is_empty(),
416                        ContentPart::Image { .. } | ContentPart::File { .. } => false,
417                    })
418            }
419        }
420    }
421
422    pub(crate) fn has_images(&self) -> bool {
423        match self {
424            MessageContent::Text(_) => false,
425            MessageContent::Parts(parts) => parts.iter().any(|part| part.is_image()),
426        }
427    }
428
429    /// Returns content with images stripped, preserving text and file parts.
430    /// Returns `None` if the content already contains no images.
431    pub(crate) fn without_images(&self) -> Option<MessageContent> {
432        match self {
433            MessageContent::Text(_) => None,
434            MessageContent::Parts(parts) => {
435                let has_image = parts.iter().any(|part| part.is_image());
436                if !has_image {
437                    return None;
438                }
439                let text_parts: Vec<ContentPart> = parts.iter().filter(|part| !part.is_image()).cloned().collect();
440                if text_parts.is_empty() {
441                    Some(MessageContent::Text(String::new()))
442                } else if text_parts.len() == 1 {
443                    if let ContentPart::Text { text } = &text_parts[0] {
444                        Some(MessageContent::Text(text.clone()))
445                    } else {
446                        Some(MessageContent::Parts(text_parts))
447                    }
448                } else {
449                    Some(MessageContent::Parts(text_parts))
450                }
451            }
452        }
453    }
454
455    pub fn get_images(&self) -> Vec<&ContentPart> {
456        match self {
457            MessageContent::Text(_) => vec![],
458            MessageContent::Parts(parts) => parts.iter().filter(|part| part.is_image()).collect(),
459        }
460    }
461}
462
463impl Default for MessageContent {
464    fn default() -> Self {
465        MessageContent::Text(String::new())
466    }
467}
468
469impl From<String> for MessageContent {
470    fn from(value: String) -> Self {
471        MessageContent::Text(value)
472    }
473}
474
475impl From<&str> for MessageContent {
476    fn from(value: &str) -> Self {
477        MessageContent::Text(value.to_owned())
478    }
479}
480
481impl Message {
482    /// Estimate the number of tokens in this message (rough approximation).
483    pub fn estimate_tokens(&self) -> usize {
484        let mut count = 0;
485
486        // Role overhead (approximate)
487        count += 4;
488
489        // Content tokens
490        match &self.content {
491            MessageContent::Text(text) => count += crate::utils::estimate_token_count(text),
492            MessageContent::Parts(parts) => {
493                for part in parts {
494                    match part {
495                        ContentPart::Text { text } => count += crate::utils::estimate_token_count(text),
496                        ContentPart::Image { .. } | ContentPart::File { .. } => count += 1000, // Rough estimate for images/files
497                    }
498                }
499            }
500        }
501
502        // Tool calls tokens
503        if let Some(tool_calls) = &self.tool_calls {
504            for call in tool_calls {
505                count += 20; // Base overhead per call
506                if let Some(func) = &call.function {
507                    count += crate::utils::estimate_token_count(&func.name);
508                    count += crate::utils::estimate_token_count(&func.arguments);
509                }
510                if let Some(sig) = &call.thought_signature {
511                    count += crate::utils::estimate_token_count(sig);
512                }
513            }
514        }
515
516        // Tool call ID (for responses)
517        if let Some(id) = &self.tool_call_id {
518            count += crate::utils::estimate_token_count(id);
519        }
520
521        if let Some(phase) = self.phase {
522            count += crate::utils::estimate_token_count(phase.as_str());
523        }
524
525        count
526    }
527
528    /// Helper to create a base message with common defaults.
529    /// Public for use in provider implementations.
530    #[inline]
531    pub(crate) const fn base(role: MessageRole, content: MessageContent) -> Self {
532        Self {
533            role,
534            content,
535            reasoning: None,
536            reasoning_details: None,
537            tool_calls: None,
538            tool_call_id: None,
539            phase: None,
540            origin_tool: None,
541            metadata: None,
542            clear_at: None,
543        }
544    }
545
546    /// Create a user message with text content
547    #[inline]
548    pub fn user(content: String) -> Self {
549        Self::base(MessageRole::User, MessageContent::Text(content))
550    }
551
552    /// Create a user message with multiple content parts (text and images)
553    #[inline]
554    pub fn user_with_parts(content_parts: Vec<ContentPart>) -> Self {
555        Self::base(MessageRole::User, MessageContent::Parts(content_parts))
556    }
557
558    /// Create an assistant message with text content
559    #[inline]
560    pub fn assistant(content: String) -> Self {
561        Self::base(MessageRole::Assistant, MessageContent::Text(content))
562    }
563
564    /// Create an assistant message with multiple content parts
565    #[inline]
566    pub fn assistant_with_parts(content_parts: Vec<ContentPart>) -> Self {
567        Self::base(MessageRole::Assistant, MessageContent::Parts(content_parts))
568    }
569
570    /// Create an assistant message with tool calls
571    /// Based on OpenAI Cookbook patterns for function calling
572    #[inline]
573    pub fn assistant_with_tools(content: String, tool_calls: Vec<ToolCall>) -> Self {
574        Self {
575            tool_calls: Some(tool_calls),
576            ..Self::base(MessageRole::Assistant, MessageContent::Text(content))
577        }
578    }
579
580    /// Create an assistant message with tool calls and multiple content parts
581    #[inline]
582    pub fn assistant_with_tools_and_parts(content_parts: Vec<ContentPart>, tool_calls: Vec<ToolCall>) -> Self {
583        Self {
584            tool_calls: Some(tool_calls),
585            ..Self::base(MessageRole::Assistant, MessageContent::Parts(content_parts))
586        }
587    }
588
589    /// Create an assistant message with tool calls and reasoning details
590    /// Used for preserving reasoning state in multi-turn conversations
591    #[inline]
592    pub fn assistant_with_tools_and_reasoning(
593        content: String,
594        tool_calls: Vec<ToolCall>,
595        reasoning_details: Option<Vec<serde_json::Value>>,
596    ) -> Self {
597        Self {
598            tool_calls: Some(tool_calls),
599            reasoning_details,
600            ..Self::base(MessageRole::Assistant, MessageContent::Text(content))
601        }
602    }
603
604    /// Create a system message
605    #[inline]
606    pub fn system(content: String) -> Self {
607        Self::base(MessageRole::System, MessageContent::Text(content))
608    }
609
610    /// Create a system message whose lifecycle is scoped to the current turn.
611    /// Provider/model routes that advertise native support let Anthropic clear
612    /// it when the next user turn arrives. Other routes receive the same text
613    /// as an ordinary system/history directive after the runtime removes the
614    /// Anthropic-only lifecycle field; canonical history retains the typed
615    /// marker for replay fidelity.
616    #[inline]
617    pub fn turn_scoped_system(content: String) -> Self {
618        Self {
619            clear_at: Some(MessageClearAt::NextUserMessage),
620            ..Self::system(content)
621        }
622    }
623
624    /// Create a tool response message
625    /// This follows the exact pattern from OpenAI Cookbook:
626    /// ```json
627    /// {
628    ///   "role": "tool",
629    ///   "tool_call_id": "call_123",
630    ///   "content": "Function result"
631    /// }
632    /// ```
633    #[inline]
634    pub fn tool_response(tool_call_id: String, content: String) -> Self {
635        Self {
636            tool_call_id: Some(tool_call_id),
637            ..Self::base(MessageRole::Tool, MessageContent::Text(content))
638        }
639    }
640
641    /// Create a tool response message with function name (for compatibility)
642    /// Some providers might need the function name in addition to tool_call_id
643    #[inline]
644    pub fn tool_response_with_name(tool_call_id: String, _function_name: String, content: String) -> Self {
645        // We can store the function name in the content metadata or handle it provider-specifically
646        Self::tool_response(tool_call_id, content)
647    }
648
649    /// Create a tool response message with origin tool tracking
650    /// The origin_tool field helps with tool-aware context retention
651    #[inline]
652    pub fn tool_response_with_origin(tool_call_id: String, content: String, origin_tool: String) -> Self {
653        Self {
654            tool_call_id: Some(tool_call_id),
655            origin_tool: Some(origin_tool),
656            ..Self::base(MessageRole::Tool, MessageContent::Text(content))
657        }
658    }
659
660    /// Create a user message with image from a local file
661    pub async fn user_with_local_image<P: AsRef<std::path::Path>>(file_path: P) -> Result<Self, anyhow::Error> {
662        let image_data = vtcode_commons::image::read_image_file(file_path).await?;
663        let image_part = ContentPart::image(image_data.base64_data, image_data.mime_type);
664        Ok(Self::user_with_parts(vec![image_part]))
665    }
666
667    /// Create a user message with text and a local image
668    pub async fn user_with_text_and_local_image<P: AsRef<std::path::Path>>(
669        text: String,
670        file_path: P,
671    ) -> Result<Self, anyhow::Error> {
672        let image_data = vtcode_commons::image::read_image_file(file_path).await?;
673        let text_part = ContentPart::text(text);
674        let image_part = ContentPart::image(image_data.base64_data, image_data.mime_type);
675        Ok(Self::user_with_parts(vec![text_part, image_part]))
676    }
677
678    /// Attach provider-visible reasoning trace for archival without affecting payloads.
679    pub fn with_reasoning(mut self, reasoning: Option<String>) -> Self {
680        if self.role == MessageRole::Assistant
681            && let Some(reasoning_text) = reasoning.as_ref()
682        {
683            let cleaned_reasoning = clean_reasoning_text(reasoning_text);
684            if !cleaned_reasoning.is_empty() {
685                let cleaned_content = clean_reasoning_text(self.content.as_text().as_ref());
686                if !cleaned_content.is_empty() && cleaned_reasoning == cleaned_content {
687                    self.reasoning = None;
688                    return self;
689                }
690            }
691        }
692        self.reasoning = reasoning;
693        self
694    }
695
696    /// Attach tool calls to this message.
697    pub fn with_tool_calls(mut self, tool_calls: Vec<ToolCall>) -> Self {
698        self.tool_calls = Some(tool_calls);
699        self
700    }
701
702    /// Attach reasoning details for providers that support structured reasoning
703    pub fn with_reasoning_details(mut self, reasoning_details: Option<Vec<serde_json::Value>>) -> Self {
704        self.reasoning_details = reasoning_details;
705        self
706    }
707
708    /// Attach assistant phase metadata for providers that support it.
709    #[must_use]
710    pub fn with_phase(mut self, phase: Option<AssistantPhase>) -> Self {
711        self.phase = if self.role == MessageRole::Assistant {
712            phase
713        } else {
714            None
715        };
716        self
717    }
718
719    /// Attach per-message metadata.
720    #[must_use]
721    pub fn with_metadata(mut self, metadata: MessageMetadata) -> Self {
722        self.metadata = Some(metadata);
723        self
724    }
725
726    /// Validate this message for a specific provider
727    /// Based on official API documentation constraints
728    pub(crate) fn validate_for_provider(&self, provider: &str) -> Result<(), String> {
729        if self.clear_at.is_some() {
730            if self.role != MessageRole::System {
731                return Err("clear_at is only valid on system messages".to_owned());
732            }
733            let text_only = match &self.content {
734                MessageContent::Text(_) => true,
735                MessageContent::Parts(parts) => parts.iter().all(|part| matches!(part, ContentPart::Text { .. })),
736            };
737            if !text_only {
738                return Err("clear_at system messages must contain text-only content".to_owned());
739            }
740            if !provider.eq_ignore_ascii_case("anthropic") {
741                return Err(format!("clear_at system messages are only supported by Anthropic, got {provider}"));
742            }
743        }
744
745        // Check role-specific constraints
746        self.role.validate_for_provider(provider, self.tool_call_id.is_some())?;
747
748        // Check tool call constraints
749        if let Some(tool_calls) = &self.tool_calls {
750            if !self.role.can_make_tool_calls() {
751                return Err(format!("Role {:?} cannot make tool calls", self.role));
752            }
753
754            if tool_calls.is_empty() {
755                return Err("Tool calls array should not be empty".to_owned());
756            }
757
758            // Validate each tool call
759            for tool_call in tool_calls {
760                tool_call.validate()?;
761            }
762        }
763
764        // Provider-specific validations based on official docs
765        match provider {
766            "openai" | "openrouter" | "meta" | "zai" | "stepfun" | "evolink" | "deepseek" => {
767                if self.role == MessageRole::Tool && self.tool_call_id.is_none() {
768                    return Err(format!("{provider} requires tool_call_id for tool messages"));
769                }
770            }
771            "gemini" => {
772                if self.role == MessageRole::Tool && self.tool_call_id.is_none() {
773                    return Err("Gemini tool responses need tool_call_id for function name mapping".to_owned());
774                }
775                // Gemini has additional constraints on content structure
776                if self.role == MessageRole::System && !self.content.as_text().is_empty() {
777                    // System messages should be handled as systemInstruction, not in contents
778                }
779            }
780            "anthropic" => {
781                // Anthropic is more flexible with tool message format
782                // Tool messages are converted to user messages anyway
783            }
784            _ => {} // Generic validation already done above
785        }
786
787        // DeepSeek vision guard rails: images only in user messages, MIME/size checks
788        if provider == "deepseek" && self.has_images() {
789            if self.role != MessageRole::User {
790                return Err("DeepSeek vision images are only supported in user messages".to_owned());
791            }
792            for part in self.content.get_images() {
793                if let Err(e) = part.validate_image() {
794                    return Err(format!("DeepSeek image validation failed: {e}"));
795                }
796            }
797        }
798
799        Ok(())
800    }
801
802    /// Check if this message has tool calls
803    pub(crate) fn has_tool_calls(&self) -> bool {
804        self.tool_calls.as_ref().is_some_and(|calls| !calls.is_empty())
805    }
806
807    /// Get the tool calls if present
808    pub fn get_tool_calls(&self) -> Option<&[ToolCall]> {
809        self.tool_calls.as_deref()
810    }
811
812    /// Check if this is a tool response message
813    pub fn is_tool_response(&self) -> bool {
814        self.role == MessageRole::Tool
815    }
816
817    /// Get the text content of the message (for backward compatibility)
818    pub fn get_text_content(&self) -> std::borrow::Cow<'_, str> {
819        self.content.as_text()
820    }
821
822    /// Check if this message contains images
823    pub fn has_images(&self) -> bool {
824        self.content.has_images()
825    }
826
827    /// Get all images in this message
828    pub fn get_images(&self) -> Vec<&ContentPart> {
829        self.content.get_images()
830    }
831}
832
833#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
834pub enum MessageRole {
835    System,
836    #[default]
837    User,
838    Assistant,
839    Tool,
840}
841
842impl std::fmt::Display for MessageRole {
843    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
844        match self {
845            MessageRole::System => write!(f, "system"),
846            MessageRole::User => write!(f, "user"),
847            MessageRole::Assistant => write!(f, "assistant"),
848            MessageRole::Tool => write!(f, "tool"),
849        }
850    }
851}
852
853impl MessageRole {
854    /// Get the role string for Gemini API
855    /// Note: Gemini API has specific constraints on message roles
856    /// - Only accepts "user" and "model" roles in conversations
857    /// - System messages are handled separately as system instructions
858    /// - Tool responses are sent as "user" role with function response format
859    pub(crate) fn as_gemini_str(&self) -> &'static str {
860        match self {
861            MessageRole::System => "system", // Handled as systemInstruction, not in contents
862            MessageRole::User => "user",
863            MessageRole::Assistant => "model", // Gemini uses "model" instead of "assistant"
864            MessageRole::Tool => "user",       // Tool responses are sent as user messages with functionResponse
865        }
866    }
867
868    /// Get the role string for OpenAI API
869    /// OpenAI supports all standard role types including:
870    /// - system, user, assistant, tool
871    /// - function (legacy, now replaced by tool)
872    pub(crate) fn as_openai_str(&self) -> &'static str {
873        match self {
874            MessageRole::System => "system",
875            MessageRole::User => "user",
876            MessageRole::Assistant => "assistant",
877            MessageRole::Tool => "tool", // Full support for tool role with tool_call_id
878        }
879    }
880
881    /// Get the role string for Anthropic API
882    /// Anthropic has specific handling for tool messages:
883    /// - Supports user, assistant roles normally
884    /// - Tool responses are treated as user messages
885    /// - System messages can be handled as system parameter or hoisted
886    pub(crate) fn as_anthropic_str(&self) -> &'static str {
887        match self {
888            MessageRole::System => "system", // Can be hoisted to system parameter
889            MessageRole::User => "user",
890            MessageRole::Assistant => "assistant",
891            MessageRole::Tool => "user", // Anthropic treats tool responses as user messages
892        }
893    }
894
895    /// Get the role string for generic OpenAI-compatible providers
896    /// Most providers follow OpenAI's role conventions
897    pub fn as_generic_str(&self) -> &'static str {
898        match self {
899            MessageRole::System => "system",
900            MessageRole::User => "user",
901            MessageRole::Assistant => "assistant",
902            MessageRole::Tool => "tool",
903        }
904    }
905
906    /// Check if this role supports tool calls
907    /// Only Assistant role can initiate tool calls in most APIs
908    fn can_make_tool_calls(&self) -> bool {
909        matches!(self, MessageRole::Assistant)
910    }
911
912    /// Check if this role represents a tool response
913    pub fn is_tool_response(&self) -> bool {
914        matches!(self, MessageRole::Tool)
915    }
916
917    /// Validate message role constraints for a given provider
918    /// Based on official API documentation requirements
919    fn validate_for_provider(&self, provider: &str, has_tool_call_id: bool) -> Result<(), String> {
920        match (self, provider) {
921            (MessageRole::Tool, provider)
922                if matches!(provider, "openai" | "openrouter" | "meta" | "deepseek" | "zai") && !has_tool_call_id =>
923            {
924                Err(format!("{provider} tool messages must have tool_call_id"))
925            }
926            (MessageRole::Tool, "gemini") if !has_tool_call_id => {
927                Err("Gemini tool messages need tool_call_id for function mapping".to_owned())
928            }
929            _ => Ok(()),
930        }
931    }
932}
933
934#[cfg(test)]
935mod tests {
936    use super::{
937        AssistantPhase, ContentPart, ImageDetail, Message, MessageClearAt, MessageContent, MessageRole, ToolCall,
938    };
939
940    #[test]
941    fn message_content_parts_concatenate_without_extra_spaces() {
942        let parts = vec![
943            ContentPart::text("Andre".to_string()),
944            ContentPart::text("j".to_string()),
945            ContentPart::text(" Kar".to_string()),
946            ContentPart::text("pathy".to_string()),
947            ContentPart::text("'s".to_string()),
948        ];
949        let content = MessageContent::Parts(parts);
950
951        assert_eq!(content.as_text().as_ref() as &str, "Andrej Karpathy's");
952    }
953
954    #[test]
955    fn message_content_parts_with_single_text_stays_borrowed() {
956        let content = MessageContent::Parts(vec![ContentPart::text("borrowed".to_string())]);
957
958        assert!(matches!(content.as_text(), std::borrow::Cow::Borrowed("borrowed")));
959    }
960
961    #[test]
962    fn message_content_parts_without_text_stays_borrowed_empty() {
963        let content = MessageContent::Parts(vec![ContentPart::image("encoded".to_string(), "image/png".to_string())]);
964
965        assert!(matches!(content.as_text(), std::borrow::Cow::Borrowed("")));
966    }
967
968    #[test]
969    fn assistant_phase_parses_wire_strings() {
970        assert_eq!(AssistantPhase::from_wire_str("commentary"), Some(AssistantPhase::Commentary));
971        assert_eq!(AssistantPhase::from_wire_str("final_answer"), Some(AssistantPhase::FinalAnswer));
972        assert_eq!(AssistantPhase::from_wire_str("other"), None);
973    }
974
975    /// `AssistantPhase` exposes the same value through the derived serde wire
976    /// form, `as_str()`, and `from_wire_str()`. Lock all three so a one-sided
977    /// edit cannot desync the persisted phase from the wire parser.
978    #[test]
979    fn assistant_phase_string_paths_stay_in_lockstep() {
980        for phase in [AssistantPhase::Commentary, AssistantPhase::FinalAnswer] {
981            let expected = phase.as_str();
982            assert_eq!(
983                serde_json::to_value(phase).expect("serializes"),
984                serde_json::json!(expected),
985                "serde form drifted for {phase:?}"
986            );
987            assert_eq!(AssistantPhase::from_wire_str(expected), Some(phase), "from_wire_str drift");
988        }
989    }
990
991    /// `ImageDetail` mirrors its `#[serde(rename)]` values in `as_str()` and
992    /// `from_str()`; lock them together.
993    #[test]
994    fn image_detail_string_paths_stay_in_lockstep() {
995        for detail in [
996            ImageDetail::Low,
997            ImageDetail::High,
998            ImageDetail::Original,
999            ImageDetail::Auto,
1000        ] {
1001            let expected = detail.as_str();
1002            assert_eq!(
1003                serde_json::to_value(&detail).expect("serializes"),
1004                serde_json::json!(expected),
1005                "serde form drifted for {detail:?}"
1006            );
1007            assert_eq!(ImageDetail::from_str(expected), Some(detail), "from_str drift");
1008        }
1009    }
1010
1011    #[test]
1012    fn with_phase_ignores_non_assistant_roles() {
1013        let user = Message::user("hello".to_string()).with_phase(Some(AssistantPhase::Commentary));
1014        let tool = Message::tool_response("call_1".to_string(), "ok".to_string())
1015            .with_phase(Some(AssistantPhase::FinalAnswer));
1016
1017        assert_eq!(user.role, MessageRole::User);
1018        assert!(user.phase.is_none());
1019        assert_eq!(tool.role, MessageRole::Tool);
1020        assert!(tool.phase.is_none());
1021    }
1022
1023    #[test]
1024    fn turn_scoped_system_message_round_trips_clear_scope() {
1025        let message = Message::turn_scoped_system("Only you see the output".to_string());
1026        let encoded = serde_json::to_value(&message).expect("message serialization");
1027
1028        assert_eq!(encoded["role"], "System");
1029        assert_eq!(encoded["clear_at"], "next_user_message");
1030        assert_eq!(message.clear_at, Some(MessageClearAt::NextUserMessage));
1031        assert_eq!(serde_json::from_value::<Message>(encoded).expect("message deserialization"), message);
1032    }
1033
1034    #[test]
1035    fn turn_scoped_system_message_requires_non_anthropic_wire_translation() {
1036        let error = Message::turn_scoped_system("notice".to_string())
1037            .validate_for_provider("openai")
1038            .expect_err("raw Anthropic lifecycle fields are not valid on an OpenAI wire");
1039        assert!(error.contains("only supported by Anthropic"));
1040    }
1041
1042    #[test]
1043    fn validate_for_provider_accepts_recovered_tool_arguments() {
1044        let message = Message::assistant_with_tools(
1045            String::new(),
1046            vec![ToolCall::function(
1047                "call_search".to_string(),
1048                "code_search".to_string(),
1049                "{\"query\": \"persistent_memory\", \"path\": \"crates/codegen/vtcode-core/src</parameter>\n<</invoke>\n</minimax:tool_call>".to_string(),
1050            )],
1051        );
1052
1053        message.validate_for_provider("anthropic").unwrap();
1054    }
1055
1056    #[test]
1057    fn validate_for_provider_requires_tool_call_id_for_meta() {
1058        let message = Message {
1059            role: MessageRole::Tool,
1060            content: MessageContent::text("result".to_owned()),
1061            ..Default::default()
1062        };
1063
1064        let error = message
1065            .validate_for_provider("meta")
1066            .expect_err("Meta tool messages need an id");
1067        assert!(error.contains("tool_call_id"));
1068    }
1069}