Skip to main content

everruns_core/
llm_driver_registry.rs

1// LLM Driver Abstractions
2//
3// This module encapsulates all abstractions needed to interact with LLM Providers:
4// - LlmDriver trait and types for provider-agnostic LLM interactions
5// - DriverRegistry for dynamic driver registration at startup
6// - Message types for LLM calls
7//
8// Supports both simple text content and multipart content (text, images, audio).
9//
10// IMPORTANT: API keys must be provided from the database. The registry does NOT read
11// from environment variables. Keys should be decrypted and passed via ProviderConfig.
12//
13// Design: Dependency inversion - provider crates (everruns-anthropic, everruns-openai)
14// depend on core and register their drivers at startup. Core has no knowledge of
15// specific provider implementations.
16
17use crate::error::{AgentLoopError, Result};
18use crate::openresponses_protocol::{CompactRequest, CompactResponse};
19use crate::runtime_agent::RuntimeAgent;
20use crate::tool_types::{ToolCall, ToolDefinition};
21use async_trait::async_trait;
22use chrono::{DateTime, Utc};
23use futures::Stream;
24use std::collections::HashMap;
25use std::pin::Pin;
26use std::sync::Arc;
27
28// ============================================================================
29// LlmDriver Trait
30// ============================================================================
31
32/// Type alias for the LLM response stream
33pub type LlmResponseStream = Pin<Box<dyn Stream<Item = Result<LlmStreamEvent>> + Send>>;
34
35/// Events emitted during LLM streaming
36#[derive(Debug, Clone)]
37pub enum LlmStreamEvent {
38    /// Text delta (incremental content)
39    TextDelta(String),
40    /// Thinking delta (incremental reasoning content from extended thinking models)
41    ThinkingDelta(String),
42    /// Cryptographic signature for thinking content (Anthropic Claude)
43    /// Emitted when a thinking block completes, before the Done event
44    ThinkingSignature(String),
45    /// Opaque assistant reasoning response item (OpenAI Responses).
46    /// Carries provider-supplied opaque/encrypted reasoning artifacts plus safe
47    /// summary text and per-item metadata. Plaintext hidden reasoning content is
48    /// intentionally excluded so callers can persist this without exposing
49    /// chain-of-thought.
50    ReasonItem {
51        /// Provider name (e.g., "openai").
52        provider: String,
53        /// Model identifier reported by the provider, if known.
54        model: Option<String>,
55        /// Provider-assigned identifier for the reasoning item.
56        item_id: String,
57        /// Provider-encrypted reasoning context, if supplied.
58        encrypted_content: Option<String>,
59        /// Safe summary text segments curated by the provider.
60        summary: Vec<String>,
61        /// Per-item reasoning token count, when the provider reports one.
62        token_count: Option<u32>,
63    },
64    /// Tool calls from the LLM
65    ToolCalls(Vec<ToolCall>),
66    /// Streaming completed
67    Done(Box<LlmCompletionMetadata>),
68    /// Error during streaming
69    Error(String),
70}
71
72/// Model information discovered from a provider's list_models API
73///
74/// Represents a model available from a provider. Used for dynamic model discovery
75/// to sync available models from provider APIs into the database.
76///
77/// The `discovered_profile` field carries structured capability/limit metadata
78/// parsed from the provider's API response (e.g., Anthropic's capabilities object).
79/// During model sync, this profile is merged with hardcoded profiles: hardcoded
80/// values take precedence (they include cost data not available from APIs),
81/// but discovered data fills gaps for models without hardcoded profiles.
82#[derive(Debug, Clone)]
83pub struct DiscoveredModel {
84    /// Model identifier (e.g., "gpt-5.2", "claude-opus-4-5-20251101")
85    pub model_id: String,
86    /// Human-readable display name (if provided by API)
87    pub display_name: Option<String>,
88    /// When the model was created/released
89    pub created_at: Option<DateTime<Utc>>,
90    /// Owner or organization (e.g., "openai", "system")
91    pub owned_by: Option<String>,
92    /// Structured profile built from provider API metadata (capabilities, limits).
93    /// Populated by drivers that return rich model metadata (e.g., Anthropic /v1/models).
94    pub discovered_profile: Option<crate::llm_models::LlmModelProfile>,
95}
96
97/// Metadata about LLM completion
98///
99/// Contains token usage and completion information from the LLM response.
100/// Cache token fields are provider-specific:
101/// - OpenAI: `cache_read_tokens` from prompt_tokens_details.cached_tokens
102/// - Anthropic: `cache_read_tokens` from cache_read_input_tokens,
103///   `cache_creation_tokens` from cache_creation_input_tokens
104#[derive(Debug, Clone, Default)]
105pub struct LlmCompletionMetadata {
106    /// Total tokens used
107    pub total_tokens: Option<u32>,
108    /// Prompt tokens
109    pub prompt_tokens: Option<u32>,
110    /// Completion tokens
111    pub completion_tokens: Option<u32>,
112    /// Tokens read from cache (reduces cost)
113    pub cache_read_tokens: Option<u32>,
114    /// Tokens written to cache (Anthropic-specific)
115    pub cache_creation_tokens: Option<u32>,
116    /// Authoritative cost of this generation in USD, when the provider reports
117    /// it inline (e.g. OpenRouter's `usage.cost`). `None` for providers that do
118    /// not return a cost.
119    pub provider_cost_usd: Option<f64>,
120    /// Model used
121    pub model: Option<String>,
122    /// Finish reason
123    pub finish_reason: Option<String>,
124    /// Retry metadata (present if rate limit retries occurred)
125    pub retry_metadata: Option<crate::llm_retry::RetryMetadata>,
126    /// Provider's response ID (e.g., OpenAI response ID from response.completed).
127    /// Used for `previous_response_id` chaining and OTel tracing.
128    pub response_id: Option<String>,
129    /// Execution phase from the provider's response (e.g., "commentary", "final_answer").
130    /// When present, this value should be preserved on the assistant message and sent
131    /// back as-is in subsequent requests. Only set by providers with native phase support.
132    pub phase: Option<String>,
133}
134
135/// Trait for LLM drivers
136///
137/// Implementations handle provider-specific API calls and response parsing.
138#[async_trait]
139pub trait LlmDriver: Send + Sync {
140    /// Call the LLM with streaming response
141    async fn chat_completion_stream(
142        &self,
143        messages: Vec<LlmMessage>,
144        config: &LlmCallConfig,
145    ) -> Result<LlmResponseStream>;
146
147    /// Call the LLM without streaming (convenience method)
148    async fn chat_completion(
149        &self,
150        messages: Vec<LlmMessage>,
151        config: &LlmCallConfig,
152    ) -> Result<LlmResponse> {
153        use futures::StreamExt;
154
155        let mut stream = self.chat_completion_stream(messages, config).await?;
156        let mut text = String::new();
157        let mut thinking = String::new();
158        let mut thinking_signature: Option<String> = None;
159        let mut tool_calls = Vec::new();
160        let mut metadata = LlmCompletionMetadata::default();
161
162        while let Some(event) = stream.next().await {
163            match event? {
164                LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
165                LlmStreamEvent::ThinkingDelta(delta) => thinking.push_str(&delta),
166                LlmStreamEvent::ThinkingSignature(sig) => thinking_signature = Some(sig),
167                LlmStreamEvent::ReasonItem {
168                    encrypted_content, ..
169                } => {
170                    if let Some(sig) = encrypted_content {
171                        thinking_signature = Some(sig);
172                    }
173                }
174                LlmStreamEvent::ToolCalls(calls) => tool_calls = calls,
175                LlmStreamEvent::Done(meta) => metadata = *meta,
176                LlmStreamEvent::Error(err) => return Err(crate::error::AgentLoopError::llm(err)),
177            }
178        }
179
180        Ok(LlmResponse {
181            text,
182            thinking: if thinking.is_empty() {
183                None
184            } else {
185                Some(thinking)
186            },
187            thinking_signature,
188            tool_calls: if tool_calls.is_empty() {
189                None
190            } else {
191                Some(tool_calls)
192            },
193            metadata,
194        })
195    }
196
197    /// List available models from the provider
198    ///
199    /// Returns `Ok(Some(models))` if the provider supports model listing,
200    /// or `Ok(None)` if not supported (e.g., custom endpoints, proxies).
201    ///
202    /// Implementations should filter to chat/completion models only,
203    /// excluding embedding models, TTS, whisper, etc.
204    async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
205        // Default: not supported. Providers override if they support listing.
206        Ok(None)
207    }
208
209    /// Check if this driver supports the compact endpoint
210    ///
211    /// The compact endpoint compresses conversation history by replacing
212    /// assistant messages, tool calls, and tool results with an encrypted
213    /// compaction item. User messages are kept verbatim.
214    ///
215    /// Returns `true` if the driver supports compaction, `false` otherwise.
216    /// Currently only supported by OpenAI's Responses API.
217    fn supports_compact(&self) -> bool {
218        // Default: not supported
219        false
220    }
221
222    /// Compact a conversation to reduce context size
223    ///
224    /// This method compresses conversation history by calling the provider's
225    /// compact endpoint. User messages are kept verbatim, while assistant
226    /// messages, tool calls, and tool results are replaced by an encrypted
227    /// compaction item that preserves latent context but is opaque.
228    ///
229    /// # Arguments
230    ///
231    /// * `request` - The compact request containing the model and input items
232    ///
233    /// # Returns
234    ///
235    /// Returns `Ok(Some(response))` if compaction succeeded,
236    /// `Ok(None)` if compaction is not supported by this driver,
237    /// or `Err` if an error occurred.
238    ///
239    /// The response contains the compacted output items which can be used
240    /// directly as input for the next chat completion call.
241    async fn compact(&self, _request: CompactRequest) -> Result<Option<CompactResponse>> {
242        // Default: not supported
243        Ok(None)
244    }
245}
246
247/// Implement LlmDriver for `Box<dyn LlmDriver>` to allow dynamic dispatch
248#[async_trait]
249impl LlmDriver for Box<dyn LlmDriver> {
250    async fn chat_completion_stream(
251        &self,
252        messages: Vec<LlmMessage>,
253        config: &LlmCallConfig,
254    ) -> Result<LlmResponseStream> {
255        (**self).chat_completion_stream(messages, config).await
256    }
257
258    async fn chat_completion(
259        &self,
260        messages: Vec<LlmMessage>,
261        config: &LlmCallConfig,
262    ) -> Result<LlmResponse> {
263        (**self).chat_completion(messages, config).await
264    }
265
266    async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
267        (**self).list_models().await
268    }
269
270    fn supports_compact(&self) -> bool {
271        (**self).supports_compact()
272    }
273
274    async fn compact(&self, request: CompactRequest) -> Result<Option<CompactResponse>> {
275        (**self).compact(request).await
276    }
277}
278
279// ============================================================================
280// Message Types
281// ============================================================================
282
283/// Message format for LLM calls (provider-agnostic)
284#[derive(Debug, Clone)]
285pub struct LlmMessage {
286    pub role: LlmMessageRole,
287    pub content: LlmMessageContent,
288    pub tool_calls: Option<Vec<ToolCall>>,
289    pub tool_call_id: Option<String>,
290    /// Execution phase for assistant messages.
291    /// Helps models distinguish between intermediate working commentary (`Commentary`)
292    /// and completed answers (`FinalAnswer`) in multi-step tool-calling flows.
293    /// Only set on assistant messages. Must be preserved when replaying conversation history.
294    pub phase: Option<crate::message::ExecutionPhase>,
295    /// Thinking content from extended thinking models (Anthropic Claude)
296    /// Must be included in subsequent API calls when thinking is enabled
297    pub thinking: Option<String>,
298    /// Cryptographic signature for thinking content (Anthropic Claude)
299    /// Required when sending thinking back in subsequent API calls
300    pub thinking_signature: Option<String>,
301}
302
303impl LlmMessage {
304    /// Create a message with text content
305    pub fn text(role: LlmMessageRole, content: impl Into<String>) -> Self {
306        Self {
307            role,
308            content: LlmMessageContent::Text(content.into()),
309            tool_calls: None,
310            tool_call_id: None,
311            phase: None,
312            thinking: None,
313            thinking_signature: None,
314        }
315    }
316
317    /// Create a message with content parts (text, images, audio)
318    pub fn parts(role: LlmMessageRole, parts: Vec<LlmContentPart>) -> Self {
319        Self {
320            role,
321            content: LlmMessageContent::Parts(parts),
322            tool_calls: None,
323            tool_call_id: None,
324            phase: None,
325            thinking: None,
326            thinking_signature: None,
327        }
328    }
329
330    /// Get content as plain text string (for simple cases)
331    pub fn content_as_text(&self) -> String {
332        self.content.to_text()
333    }
334
335    /// Prepend a prefix to the first text content.
336    ///
337    /// Used by ReasonAtom to inject external actor identity (e.g. `"[Alice] "`)
338    /// into user messages from external channels.
339    pub fn prepend_text_prefix(&mut self, prefix: &str) {
340        match &mut self.content {
341            LlmMessageContent::Text(text) => {
342                *text = format!("{}{}", prefix, text);
343            }
344            LlmMessageContent::Parts(parts) => {
345                for part in parts.iter_mut() {
346                    if let LlmContentPart::Text { text } = part {
347                        *text = format!("{}{}", prefix, text);
348                        return;
349                    }
350                }
351                // No text part found — prepend one
352                parts.insert(
353                    0,
354                    LlmContentPart::Text {
355                        text: prefix.to_string(),
356                    },
357                );
358            }
359        }
360    }
361}
362
363/// Message content - either a simple string or array of content parts
364#[derive(Debug, Clone)]
365pub enum LlmMessageContent {
366    /// Simple text content
367    Text(String),
368    /// Array of content parts (text, images, audio)
369    Parts(Vec<LlmContentPart>),
370}
371
372impl LlmMessageContent {
373    /// Convert to plain text (concatenates text parts, ignores media)
374    pub fn to_text(&self) -> String {
375        match self {
376            LlmMessageContent::Text(s) => s.clone(),
377            LlmMessageContent::Parts(parts) => parts
378                .iter()
379                .filter_map(|p| match p {
380                    LlmContentPart::Text { text } => Some(text.clone()),
381                    _ => None,
382                })
383                .collect::<Vec<_>>()
384                .join(""),
385        }
386    }
387
388    /// Check if content is simple text
389    pub fn is_text(&self) -> bool {
390        matches!(self, LlmMessageContent::Text(_))
391    }
392
393    /// Check if content has multiple parts
394    pub fn is_parts(&self) -> bool {
395        matches!(self, LlmMessageContent::Parts(_))
396    }
397}
398
399impl From<String> for LlmMessageContent {
400    fn from(s: String) -> Self {
401        LlmMessageContent::Text(s)
402    }
403}
404
405impl From<&str> for LlmMessageContent {
406    fn from(s: &str) -> Self {
407        LlmMessageContent::Text(s.to_string())
408    }
409}
410
411/// A single content part within a message
412#[derive(Debug, Clone)]
413pub enum LlmContentPart {
414    /// Text content
415    Text { text: String },
416    /// Image content (base64 data URL or HTTP URL)
417    Image { url: String },
418    /// Audio content (base64 data URL)
419    Audio { url: String },
420}
421
422impl LlmContentPart {
423    /// Create a text content part
424    pub fn text(text: impl Into<String>) -> Self {
425        LlmContentPart::Text { text: text.into() }
426    }
427
428    /// Create an image content part from URL (can be data URL or HTTP URL)
429    pub fn image(url: impl Into<String>) -> Self {
430        LlmContentPart::Image { url: url.into() }
431    }
432
433    /// Create an audio content part from URL (typically a data URL)
434    pub fn audio(url: impl Into<String>) -> Self {
435        LlmContentPart::Audio { url: url.into() }
436    }
437}
438
439/// Message role for LLM calls
440#[derive(Debug, Clone, PartialEq, Eq)]
441pub enum LlmMessageRole {
442    System,
443    User,
444    Assistant,
445    Tool,
446}
447
448// ============================================================================
449// Configuration and Response Types
450// ============================================================================
451
452/// Configuration for tool_search (deferred tool loading).
453///
454/// When enabled, the driver groups tools into namespaces and marks them with
455/// `defer_loading: true` so the model only loads full schemas on-demand.
456/// This reduces token usage for agents with many tools.
457#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
458pub struct ToolSearchConfig {
459    /// Enable tool_search for this request (requires model support)
460    pub enabled: bool,
461    /// Minimum number of tools before activating tool_search.
462    /// Below this threshold, full schemas are sent even when enabled.
463    pub threshold: usize,
464}
465
466/// Strategy for prompt caching.
467#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
468#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
469#[serde(rename_all = "snake_case")]
470pub enum PromptCacheStrategy {
471    /// Let each driver choose the safest provider-specific behavior.
472    #[default]
473    Auto,
474}
475
476/// Configuration for prompt caching.
477///
478/// Drivers translate this into provider-specific request options when possible.
479/// Unsupported providers or models should ignore it without failing the call.
480#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
481#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
482pub struct PromptCacheConfig {
483    /// Enable prompt caching for this request.
484    pub enabled: bool,
485    /// Strategy the driver should use when enabling prompt caching.
486    #[serde(default)]
487    pub strategy: PromptCacheStrategy,
488    /// Existing Gemini cached content resource name (`cachedContents/{id}`).
489    ///
490    /// When set, the Gemini driver uses explicit caching via the
491    /// `cachedContent` request field. When absent, Gemini falls back to its
492    /// default provider behavior (for example implicit caching on supported
493    /// models).
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub gemini_cached_content: Option<String>,
496}
497
498/// Configuration for an LLM call
499#[derive(Debug, Clone)]
500pub struct LlmCallConfig {
501    pub model: String,
502    pub temperature: Option<f32>,
503    pub max_tokens: Option<u32>,
504    pub tools: Vec<ToolDefinition>,
505    /// Reasoning effort level (for models that support it: low, medium, high)
506    pub reasoning_effort: Option<String>,
507    /// Metadata to send with the API request for tracking and debugging.
508    /// Keys and values are strings. Both OpenAI and Anthropic support metadata fields.
509    /// Typically includes: session_id, agent_id, org_id, turn_id, exec_id.
510    pub metadata: HashMap<String, String>,
511    /// Previous response ID for stateful continuation (OpenAI Responses API).
512    /// When set, the provider can skip re-encoding cached context.
513    pub previous_response_id: Option<String>,
514    /// Tool search configuration for deferred tool loading
515    pub tool_search: Option<ToolSearchConfig>,
516    /// Prompt caching configuration for provider-specific cache controls.
517    pub prompt_cache: Option<PromptCacheConfig>,
518}
519
520impl From<&RuntimeAgent> for LlmCallConfig {
521    fn from(runtime_agent: &RuntimeAgent) -> Self {
522        Self {
523            model: runtime_agent.model.clone(),
524            temperature: runtime_agent.temperature,
525            max_tokens: runtime_agent.max_tokens,
526            tools: runtime_agent.tools.clone(),
527            reasoning_effort: None, // Set by ReasonAtom from user message controls
528            metadata: HashMap::new(), // Set by ReasonAtom with session/agent context
529            previous_response_id: None,
530            tool_search: runtime_agent.tool_search.clone(),
531            prompt_cache: runtime_agent.prompt_cache.clone(),
532        }
533    }
534}
535
536/// Response from an LLM call (non-streaming)
537#[derive(Debug, Clone)]
538pub struct LlmResponse {
539    pub text: String,
540    /// Thinking content from extended thinking models (e.g., Claude with thinking enabled)
541    pub thinking: Option<String>,
542    /// Cryptographic signature for thinking content (Anthropic Claude)
543    pub thinking_signature: Option<String>,
544    pub tool_calls: Option<Vec<ToolCall>>,
545    pub metadata: LlmCompletionMetadata,
546}
547
548/// Builder for LlmCallConfig with fluent API
549///
550/// Use `from(&runtime_agent)` to start building from a RuntimeAgent, then chain
551/// methods like `reasoning_effort()`, `temperature()`, etc. Call `build()`
552/// to get the final config.
553///
554/// # Example
555///
556/// ```ignore
557/// use everruns_core::llm::LlmCallConfigBuilder;
558/// use everruns_core::runtime_agent::RuntimeAgent;
559///
560/// let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
561/// let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
562///     .reasoning_effort("high")
563///     .temperature(0.7)
564///     .build();
565/// ```
566pub struct LlmCallConfigBuilder {
567    config: LlmCallConfig,
568}
569
570impl LlmCallConfigBuilder {
571    /// Start building from a RuntimeAgent
572    pub fn from(runtime_agent: &RuntimeAgent) -> Self {
573        Self {
574            config: LlmCallConfig::from(runtime_agent),
575        }
576    }
577
578    /// Set reasoning effort level (for models that support it: low, medium, high)
579    pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
580        self.config.reasoning_effort = Some(effort.into());
581        self
582    }
583
584    /// Set the model
585    pub fn model(mut self, model: impl Into<String>) -> Self {
586        self.config.model = model.into();
587        self
588    }
589
590    /// Set temperature
591    pub fn temperature(mut self, temp: f32) -> Self {
592        self.config.temperature = Some(temp);
593        self
594    }
595
596    /// Set max tokens
597    pub fn max_tokens(mut self, tokens: u32) -> Self {
598        self.config.max_tokens = Some(tokens);
599        self
600    }
601
602    /// Set tools
603    pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
604        self.config.tools = tools;
605        self
606    }
607
608    /// Set metadata for API tracking
609    ///
610    /// This metadata is sent to the LLM provider for tracking and debugging.
611    /// Typically includes session_id, agent_id, org_id, turn_id, exec_id.
612    pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
613        self.config.metadata = metadata;
614        self
615    }
616
617    /// Add a single metadata key-value pair
618    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
619        self.config.metadata.insert(key.into(), value.into());
620        self
621    }
622
623    /// Set previous response ID for stateful continuation
624    pub fn previous_response_id(mut self, id: Option<String>) -> Self {
625        self.config.previous_response_id = id;
626        self
627    }
628
629    /// Set tool_search configuration
630    pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
631        self.config.tool_search = Some(config);
632        self
633    }
634
635    /// Set prompt caching configuration
636    pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
637        self.config.prompt_cache = Some(config);
638        self
639    }
640
641    /// Build the configuration
642    pub fn build(self) -> LlmCallConfig {
643        self.config
644    }
645}
646
647// ============================================================================
648// Conversion from Message
649// ============================================================================
650
651impl From<&crate::message::Message> for LlmMessage {
652    /// Convert a Message to LlmMessage (text-only, images become placeholders)
653    ///
654    /// This conversion is suitable for messages without images or when image
655    /// resolution is not available. For multimodal messages, use
656    /// `LlmMessage::from_message_with_images()` instead.
657    fn from(msg: &crate::message::Message) -> Self {
658        let role = match msg.role {
659            crate::message::MessageRole::System => LlmMessageRole::System,
660            crate::message::MessageRole::User => LlmMessageRole::User,
661            crate::message::MessageRole::Agent => LlmMessageRole::Assistant,
662            crate::message::MessageRole::ToolResult => LlmMessageRole::Tool,
663        };
664
665        // Convert tool calls from ContentPart format to ToolCall format
666        let tool_calls: Vec<ToolCall> = msg
667            .tool_calls()
668            .into_iter()
669            .map(|tc| ToolCall {
670                id: tc.id.clone(),
671                name: tc.name.clone(),
672                arguments: tc.arguments.clone(),
673            })
674            .collect();
675
676        LlmMessage {
677            role,
678            content: LlmMessageContent::Text(msg.content_to_llm_string()),
679            tool_calls: if tool_calls.is_empty() {
680                None
681            } else {
682                Some(tool_calls)
683            },
684            tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
685            phase: msg.phase,
686            thinking: msg.thinking.clone(),
687            thinking_signature: msg.thinking_signature.clone(),
688        }
689    }
690}
691
692// ============================================================================
693// Message Conversion with Images
694// ============================================================================
695
696use crate::traits::ResolvedImage;
697use uuid::Uuid;
698
699impl LlmMessage {
700    /// Convert a Message to LlmMessage with resolved images
701    ///
702    /// This method handles multimodal messages by converting:
703    /// - `text` content parts → `LlmContentPart::Text`
704    /// - `image` content parts → `LlmContentPart::Image` (data URL)
705    /// - `image_file` content parts → `LlmContentPart::Image` (resolved to data URL)
706    /// - `tool_call` content parts → extracted to `tool_calls` field
707    /// - `tool_result` content parts → text representation
708    ///
709    /// # Provider-specific formatting
710    ///
711    /// The `LlmContentPart::Image` uses data URLs which are converted by each provider:
712    /// - **OpenAI**: `{ "type": "image_url", "image_url": { "url": "data:..." } }`
713    /// - **Anthropic**: `{ "type": "image", "source": { "type": "base64", ... } }`
714    ///
715    /// # Arguments
716    ///
717    /// * `msg` - The message to convert
718    /// * `resolved_images` - Pre-resolved images keyed by image_id
719    pub fn from_message_with_images(
720        msg: &crate::message::Message,
721        resolved_images: &HashMap<Uuid, ResolvedImage>,
722    ) -> Self {
723        use crate::message::{ContentPart, MessageRole};
724
725        let role = match msg.role {
726            MessageRole::System => LlmMessageRole::System,
727            MessageRole::User => LlmMessageRole::User,
728            MessageRole::Agent => LlmMessageRole::Assistant,
729            MessageRole::ToolResult => LlmMessageRole::Tool,
730        };
731
732        // Convert content parts to LlmContentParts
733        let mut parts: Vec<LlmContentPart> = Vec::new();
734        let mut tool_calls: Vec<ToolCall> = Vec::new();
735
736        for part in &msg.content {
737            match part {
738                ContentPart::Text(t) => {
739                    parts.push(LlmContentPart::Text {
740                        text: t.text.clone(),
741                    });
742                }
743                ContentPart::Image(img) => {
744                    // Convert inline image to data URL
745                    if let Some(url) = &img.url {
746                        parts.push(LlmContentPart::Image { url: url.clone() });
747                    } else if let (Some(base64), Some(media_type)) = (&img.base64, &img.media_type)
748                    {
749                        let data_url = format!("data:{};base64,{}", media_type, base64);
750                        parts.push(LlmContentPart::Image { url: data_url });
751                    }
752                }
753                ContentPart::ImageFile(img_file) => {
754                    // Resolve image_file to actual image data
755                    if let Some(resolved) = resolved_images.get(&img_file.image_id.uuid()) {
756                        parts.push(LlmContentPart::Image {
757                            url: resolved.to_data_url(),
758                        });
759                    } else {
760                        // Image not found - add placeholder text
761                        parts.push(LlmContentPart::Text {
762                            text: format!("[Image not found: {}]", img_file.image_id),
763                        });
764                    }
765                }
766                ContentPart::ToolCall(tc) => {
767                    // Extract tool calls to separate field (don't include in content)
768                    tool_calls.push(ToolCall {
769                        id: tc.id.clone(),
770                        name: tc.name.clone(),
771                        arguments: tc.arguments.clone(),
772                    });
773                }
774                ContentPart::ToolResult(tr) => {
775                    // Convert tool result to text representation
776                    let text = if let Some(err) = &tr.error {
777                        format!("Tool error: {}", err)
778                    } else if let Some(res) = &tr.result {
779                        serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
780                    } else {
781                        "{}".to_string()
782                    };
783                    // Primary hard limit enforced by OutputHardLimitHook (EVE-225)
784                    // at tool execution time. This backstop catches tool results
785                    // that bypass ActAtom hooks (client-submitted, stored events).
786                    let text = truncate_tool_result(text);
787                    parts.push(LlmContentPart::Text { text });
788                }
789            }
790        }
791
792        // Determine content format
793        let content = if parts.len() == 1 && matches!(&parts[0], LlmContentPart::Text { .. }) {
794            // Single text part - use simple Text format
795            if let LlmContentPart::Text { text } = &parts[0] {
796                LlmMessageContent::Text(text.clone())
797            } else {
798                LlmMessageContent::Parts(parts)
799            }
800        } else if parts.is_empty() {
801            // No content parts - use empty text
802            LlmMessageContent::Text(String::new())
803        } else {
804            // Multiple parts or non-text - use Parts format
805            LlmMessageContent::Parts(parts)
806        };
807
808        LlmMessage {
809            role,
810            content,
811            tool_calls: if tool_calls.is_empty() {
812                None
813            } else {
814                Some(tool_calls)
815            },
816            tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
817            phase: msg.phase,
818            thinking: msg.thinking.clone(),
819            thinking_signature: msg.thinking_signature.clone(),
820        }
821    }
822
823    /// Check if a message contains image_file references that need resolution
824    pub fn message_has_image_files(msg: &crate::message::Message) -> bool {
825        msg.content.iter().any(|p| p.is_image_file())
826    }
827
828    /// Extract all image_file IDs from a message
829    pub fn extract_image_file_ids(msg: &crate::message::Message) -> Vec<Uuid> {
830        msg.content
831            .iter()
832            .filter_map(|p| match p {
833                crate::message::ContentPart::ImageFile(f) => Some(f.image_id.uuid()),
834                _ => None,
835            })
836            .collect()
837    }
838}
839
840// ============================================================================
841// Driver Factory Types
842// ============================================================================
843
844/// Provider type enumeration matching the database/contracts
845#[derive(Debug, Clone, PartialEq, Eq, Hash)]
846pub enum ProviderType {
847    /// OpenAI using Open Responses API (<https://www.openresponses.org/>)
848    /// This is the recommended API for new projects.
849    OpenAI,
850    /// Azure OpenAI using the Azure-hosted OpenAI v1 API.
851    AzureOpenAI,
852    /// OpenAI using Chat Completions API (for backward compatibility)
853    /// Use this if you need the legacy /v1/chat/completions endpoint.
854    OpenAICompletions,
855    Anthropic,
856    /// Google Gemini API
857    Gemini,
858    /// LLM simulator for testing (uses llmsim crate)
859    LlmSim,
860}
861
862impl std::str::FromStr for ProviderType {
863    type Err = String;
864
865    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
866        match s.to_lowercase().as_str() {
867            "openai" => Ok(ProviderType::OpenAI),
868            "azure_openai" => Ok(ProviderType::AzureOpenAI),
869            "openai_completions" => Ok(ProviderType::OpenAICompletions),
870            "anthropic" => Ok(ProviderType::Anthropic),
871            "gemini" => Ok(ProviderType::Gemini),
872            "llmsim" => Ok(ProviderType::LlmSim),
873            _ => Err(format!("Unknown provider type: {}", s)),
874        }
875    }
876}
877
878impl std::fmt::Display for ProviderType {
879    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
880        match self {
881            ProviderType::OpenAI => write!(f, "openai"),
882            ProviderType::AzureOpenAI => write!(f, "azure_openai"),
883            ProviderType::OpenAICompletions => write!(f, "openai_completions"),
884            ProviderType::Anthropic => write!(f, "anthropic"),
885            ProviderType::Gemini => write!(f, "gemini"),
886            ProviderType::LlmSim => write!(f, "llmsim"),
887        }
888    }
889}
890
891/// Configuration for creating an LLM provider
892#[derive(Debug, Clone)]
893pub struct ProviderConfig {
894    /// Type of provider
895    pub provider_type: ProviderType,
896    /// API key for authentication
897    pub api_key: Option<String>,
898    /// Base URL override (optional)
899    pub base_url: Option<String>,
900}
901
902impl ProviderConfig {
903    /// Create a new provider config
904    pub fn new(provider_type: ProviderType) -> Self {
905        Self {
906            provider_type,
907            api_key: None,
908            base_url: None,
909        }
910    }
911
912    /// Set the API key
913    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
914        self.api_key = Some(api_key.into());
915        self
916    }
917
918    /// Set the base URL
919    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
920        self.base_url = Some(base_url.into());
921        self
922    }
923}
924
925/// Boxed LLM driver for dynamic dispatch
926pub type BoxedLlmDriver = Box<dyn LlmDriver>;
927
928// ============================================================================
929// Driver Registry
930// ============================================================================
931
932/// Factory function type for creating LLM drivers
933///
934/// Takes api_key and optional base_url, returns a boxed driver
935pub type DriverFactory = Arc<dyn Fn(&str, Option<&str>) -> BoxedLlmDriver + Send + Sync>;
936
937/// Registry for LLM drivers
938///
939/// Enables dependency inversion: provider crates (everruns-anthropic, everruns-openai)
940/// register their drivers at startup. The core has no direct knowledge of implementations.
941///
942/// # Example
943///
944/// ```ignore
945/// use everruns_core::llm_drivers::{DriverRegistry, ProviderType};
946/// use everruns_anthropic::register_driver;
947/// use everruns_openai::register_driver as register_openai;
948///
949/// let mut registry = DriverRegistry::new();
950/// everruns_anthropic::register_driver(&mut registry);
951/// everruns_openai::register_driver(&mut registry);
952///
953/// // Later, create a driver from config
954/// let driver = registry.create_driver(&config)?;
955/// ```
956#[derive(Clone, Default)]
957pub struct DriverRegistry {
958    factories: HashMap<ProviderType, DriverFactory>,
959}
960
961impl DriverRegistry {
962    /// Create a new empty registry
963    pub fn new() -> Self {
964        Self {
965            factories: HashMap::new(),
966        }
967    }
968
969    /// Register a driver factory for a provider type
970    pub fn register<F>(&mut self, provider_type: ProviderType, factory: F)
971    where
972        F: Fn(&str, Option<&str>) -> BoxedLlmDriver + Send + Sync + 'static,
973    {
974        self.factories.insert(provider_type, Arc::new(factory));
975    }
976
977    /// Create an LLM driver based on configuration
978    ///
979    /// API keys must be provided in the config for real providers. This function does NOT fall back to
980    /// environment variables. Keys should be decrypted from the database and passed here.
981    /// Exception: LlmSim provider does not require an API key.
982    ///
983    /// Returns `DriverNotRegistered` error if no driver is registered for the provider type.
984    pub fn create_driver(&self, config: &ProviderConfig) -> Result<BoxedLlmDriver> {
985        // API key is required for real providers, but not for LlmSim (testing)
986        let api_key = if config.provider_type == ProviderType::LlmSim {
987            // LlmSim doesn't need a real API key
988            config.api_key.as_deref().unwrap_or("")
989        } else {
990            config.api_key.as_ref().ok_or_else(|| {
991                AgentLoopError::llm(
992                    "API key is required. Configure the API key in provider settings.",
993                )
994            })?
995        };
996
997        // Look up the factory for this provider type
998        let factory = self.factories.get(&config.provider_type).ok_or_else(|| {
999            AgentLoopError::driver_not_registered(config.provider_type.to_string())
1000        })?;
1001
1002        // Create the driver using the factory
1003        Ok(factory(api_key, config.base_url.as_deref()))
1004    }
1005
1006    /// Check if a driver is registered for a provider type
1007    pub fn has_driver(&self, provider_type: &ProviderType) -> bool {
1008        self.factories.contains_key(provider_type)
1009    }
1010
1011    /// Get the list of registered provider types
1012    pub fn registered_providers(&self) -> Vec<ProviderType> {
1013        self.factories.keys().cloned().collect()
1014    }
1015}
1016
1017/// Maximum tool result size in bytes before truncation (64 KiB).
1018/// Defense-in-depth backstop for tool results that bypass ActAtom hooks
1019/// (e.g. client-submitted or stored events). The primary hard limit is
1020/// enforced by `OutputHardLimitHook` (EVE-225) at tool execution time.
1021const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
1022
1023const TRUNCATION_SUFFIX: &str =
1024    "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
1025
1026fn truncate_tool_result(text: String) -> String {
1027    if text.len() <= MAX_TOOL_RESULT_BYTES {
1028        return text;
1029    }
1030    let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
1031    let mut end = content_budget;
1032    while end > 0 && !text.is_char_boundary(end) {
1033        end -= 1;
1034    }
1035    let mut truncated = text[..end].to_string();
1036    truncated.push_str(TRUNCATION_SUFFIX);
1037    truncated
1038}
1039
1040// ============================================================================
1041// Tests
1042// ============================================================================
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047
1048    #[test]
1049    fn test_llm_call_config_builder_from_runtime_agent() {
1050        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1051        let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
1052
1053        assert_eq!(llm_config.model, "gpt-4o");
1054        assert!(llm_config.reasoning_effort.is_none());
1055        assert!(llm_config.temperature.is_none());
1056        assert!(llm_config.max_tokens.is_none());
1057        assert!(llm_config.tools.is_empty());
1058        assert!(llm_config.metadata.is_empty());
1059    }
1060
1061    #[test]
1062    fn test_llm_call_config_builder_with_metadata() {
1063        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1064        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1065            .with_metadata("session_id", "session_abc123")
1066            .with_metadata("agent_id", "agent_xyz789")
1067            .build();
1068
1069        assert_eq!(
1070            llm_config.metadata.get("session_id"),
1071            Some(&"session_abc123".to_string())
1072        );
1073        assert_eq!(
1074            llm_config.metadata.get("agent_id"),
1075            Some(&"agent_xyz789".to_string())
1076        );
1077    }
1078
1079    #[test]
1080    fn test_llm_call_config_builder_with_metadata_hashmap() {
1081        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1082        let mut metadata = HashMap::new();
1083        metadata.insert("key1".to_string(), "value1".to_string());
1084        metadata.insert("key2".to_string(), "value2".to_string());
1085
1086        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1087            .metadata(metadata)
1088            .build();
1089
1090        assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
1091        assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
1092    }
1093
1094    #[test]
1095    fn test_llm_call_config_builder_with_reasoning_effort() {
1096        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1097        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1098            .reasoning_effort("high")
1099            .build();
1100
1101        assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
1102    }
1103
1104    #[test]
1105    fn test_llm_call_config_builder_with_all_options() {
1106        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1107        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1108            .model("claude-3-opus")
1109            .reasoning_effort("medium")
1110            .temperature(0.7)
1111            .max_tokens(1000)
1112            .build();
1113
1114        assert_eq!(llm_config.model, "claude-3-opus");
1115        assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
1116        assert_eq!(llm_config.temperature, Some(0.7));
1117        assert_eq!(llm_config.max_tokens, Some(1000));
1118    }
1119
1120    #[test]
1121    fn test_provider_type_parsing() {
1122        assert_eq!(
1123            "openai".parse::<ProviderType>().unwrap(),
1124            ProviderType::OpenAI
1125        );
1126        assert_eq!(
1127            "openai_completions".parse::<ProviderType>().unwrap(),
1128            ProviderType::OpenAICompletions
1129        );
1130        assert_eq!(
1131            "azure_openai".parse::<ProviderType>().unwrap(),
1132            ProviderType::AzureOpenAI
1133        );
1134        assert_eq!(
1135            "anthropic".parse::<ProviderType>().unwrap(),
1136            ProviderType::Anthropic
1137        );
1138        assert_eq!(
1139            "gemini".parse::<ProviderType>().unwrap(),
1140            ProviderType::Gemini
1141        );
1142        // Ollama and Custom are no longer supported
1143        assert!("ollama".parse::<ProviderType>().is_err());
1144        assert!("custom".parse::<ProviderType>().is_err());
1145    }
1146
1147    #[test]
1148    fn test_provider_type_display() {
1149        assert_eq!(ProviderType::OpenAI.to_string(), "openai");
1150        assert_eq!(ProviderType::AzureOpenAI.to_string(), "azure_openai");
1151        assert_eq!(
1152            ProviderType::OpenAICompletions.to_string(),
1153            "openai_completions"
1154        );
1155        assert_eq!(ProviderType::Anthropic.to_string(), "anthropic");
1156        assert_eq!(ProviderType::Gemini.to_string(), "gemini");
1157    }
1158
1159    #[test]
1160    fn test_provider_config_builder() {
1161        let config = ProviderConfig::new(ProviderType::Anthropic)
1162            .with_api_key("test-key")
1163            .with_base_url("https://custom.api.com");
1164
1165        assert_eq!(config.provider_type, ProviderType::Anthropic);
1166        assert_eq!(config.api_key, Some("test-key".to_string()));
1167        assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
1168    }
1169
1170    #[test]
1171    fn test_driver_registry_requires_api_key() {
1172        // Register a mock factory
1173        let mut registry = DriverRegistry::new();
1174        registry.register(ProviderType::OpenAI, |_api_key, _base_url| {
1175            // Return a mock driver - just need something that compiles
1176            struct MockDriver;
1177            #[async_trait]
1178            impl LlmDriver for MockDriver {
1179                async fn chat_completion_stream(
1180                    &self,
1181                    _messages: Vec<LlmMessage>,
1182                    _config: &LlmCallConfig,
1183                ) -> Result<LlmResponseStream> {
1184                    unimplemented!()
1185                }
1186            }
1187            Box::new(MockDriver)
1188        });
1189
1190        // Driver without API key should fail
1191        let config = ProviderConfig::new(ProviderType::OpenAI);
1192        let result = registry.create_driver(&config);
1193        assert!(result.is_err());
1194
1195        // Driver with API key should succeed
1196        let config_with_key = ProviderConfig::new(ProviderType::OpenAI).with_api_key("test-key");
1197        let result = registry.create_driver(&config_with_key);
1198        assert!(result.is_ok());
1199    }
1200
1201    #[test]
1202    fn test_driver_registry_returns_error_for_unregistered_provider() {
1203        let registry = DriverRegistry::new();
1204        let config = ProviderConfig::new(ProviderType::Anthropic).with_api_key("test-key");
1205
1206        let result = registry.create_driver(&config);
1207
1208        // Should fail with DriverNotRegistered error
1209        if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
1210            assert_eq!(provider, "anthropic");
1211        } else {
1212            panic!("Expected DriverNotRegistered error");
1213        }
1214    }
1215
1216    #[test]
1217    fn test_driver_registry_registration() {
1218        let mut registry = DriverRegistry::new();
1219
1220        assert!(!registry.has_driver(&ProviderType::OpenAI));
1221        assert!(!registry.has_driver(&ProviderType::Anthropic));
1222
1223        registry.register(ProviderType::OpenAI, |_, _| {
1224            struct MockDriver;
1225            #[async_trait]
1226            impl LlmDriver for MockDriver {
1227                async fn chat_completion_stream(
1228                    &self,
1229                    _messages: Vec<LlmMessage>,
1230                    _config: &LlmCallConfig,
1231                ) -> Result<LlmResponseStream> {
1232                    unimplemented!()
1233                }
1234            }
1235            Box::new(MockDriver)
1236        });
1237
1238        assert!(registry.has_driver(&ProviderType::OpenAI));
1239        assert!(!registry.has_driver(&ProviderType::Anthropic));
1240    }
1241
1242    // ========================================================================
1243    // Image resolution tests
1244    // ========================================================================
1245
1246    use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
1247
1248    #[test]
1249    fn test_message_has_image_files_with_image_file() {
1250        let message = Message {
1251            id: uuid::Uuid::new_v4().into(),
1252            role: MessageRole::User,
1253            content: vec![
1254                ContentPart::Text(TextContentPart {
1255                    text: "Look at this image".to_string(),
1256                }),
1257                ContentPart::ImageFile(ImageFileContentPart {
1258                    image_id: uuid::Uuid::new_v4().into(),
1259                    filename: Some("test.png".to_string()),
1260                }),
1261            ],
1262            phase: None,
1263            thinking: None,
1264            thinking_signature: None,
1265            controls: None,
1266            metadata: None,
1267            external_actor: None,
1268            created_at: chrono::Utc::now(),
1269        };
1270
1271        assert!(LlmMessage::message_has_image_files(&message));
1272    }
1273
1274    #[test]
1275    fn test_message_has_image_files_without_image_file() {
1276        let message = Message {
1277            id: uuid::Uuid::new_v4().into(),
1278            role: MessageRole::User,
1279            content: vec![ContentPart::Text(TextContentPart {
1280                text: "Just text".to_string(),
1281            })],
1282            phase: None,
1283            thinking: None,
1284            thinking_signature: None,
1285            controls: None,
1286            metadata: None,
1287            external_actor: None,
1288            created_at: chrono::Utc::now(),
1289        };
1290
1291        assert!(!LlmMessage::message_has_image_files(&message));
1292    }
1293
1294    #[test]
1295    fn test_extract_image_file_ids() {
1296        let id1 = uuid::Uuid::new_v4();
1297        let id2 = uuid::Uuid::new_v4();
1298
1299        let message = Message {
1300            id: uuid::Uuid::new_v4().into(),
1301            role: MessageRole::User,
1302            content: vec![
1303                ContentPart::Text(TextContentPart {
1304                    text: "Look at these images".to_string(),
1305                }),
1306                ContentPart::ImageFile(ImageFileContentPart {
1307                    image_id: id1.into(),
1308                    filename: Some("test1.png".to_string()),
1309                }),
1310                ContentPart::ImageFile(ImageFileContentPart {
1311                    image_id: id2.into(),
1312                    filename: Some("test2.png".to_string()),
1313                }),
1314            ],
1315            phase: None,
1316            thinking: None,
1317            thinking_signature: None,
1318            controls: None,
1319            metadata: None,
1320            external_actor: None,
1321            created_at: chrono::Utc::now(),
1322        };
1323
1324        let ids = LlmMessage::extract_image_file_ids(&message);
1325        assert_eq!(ids.len(), 2);
1326        assert!(ids.contains(&id1));
1327        assert!(ids.contains(&id2));
1328    }
1329
1330    #[test]
1331    fn test_from_message_with_images_text_only() {
1332        let message = Message {
1333            id: uuid::Uuid::new_v4().into(),
1334            role: MessageRole::User,
1335            content: vec![ContentPart::Text(TextContentPart {
1336                text: "Hello".to_string(),
1337            })],
1338            phase: None,
1339            thinking: None,
1340            thinking_signature: None,
1341            controls: None,
1342            metadata: None,
1343            external_actor: None,
1344            created_at: chrono::Utc::now(),
1345        };
1346
1347        let resolved = std::collections::HashMap::new();
1348        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
1349
1350        assert_eq!(llm_message.role, LlmMessageRole::User);
1351        match llm_message.content {
1352            LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
1353            _ => panic!("Expected text content"),
1354        }
1355    }
1356
1357    #[test]
1358    fn test_from_message_with_images_resolved_image() {
1359        let image_id = uuid::Uuid::new_v4();
1360        let message = Message {
1361            id: uuid::Uuid::new_v4().into(),
1362            role: MessageRole::User,
1363            content: vec![
1364                ContentPart::Text(TextContentPart {
1365                    text: "Look at this".to_string(),
1366                }),
1367                ContentPart::ImageFile(ImageFileContentPart {
1368                    image_id: image_id.into(),
1369                    filename: Some("test.png".to_string()),
1370                }),
1371            ],
1372            phase: None,
1373            thinking: None,
1374            thinking_signature: None,
1375            controls: None,
1376            metadata: None,
1377            external_actor: None,
1378            created_at: chrono::Utc::now(),
1379        };
1380
1381        let mut resolved = std::collections::HashMap::new();
1382        resolved.insert(
1383            image_id,
1384            crate::ResolvedImage::new("base64data", "image/png"),
1385        );
1386
1387        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
1388
1389        match &llm_message.content {
1390            LlmMessageContent::Parts(parts) => {
1391                assert_eq!(parts.len(), 2);
1392                // First part should be text
1393                assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
1394                // Second part should be resolved image
1395                if let LlmContentPart::Image { url } = &parts[1] {
1396                    assert!(url.starts_with("data:image/png;base64,"));
1397                } else {
1398                    panic!("Expected image content part");
1399                }
1400            }
1401            _ => panic!("Expected parts content"),
1402        }
1403    }
1404
1405    #[test]
1406    fn test_from_message_with_images_unresolved_image() {
1407        let image_id = uuid::Uuid::new_v4();
1408        let message = Message {
1409            id: uuid::Uuid::new_v4().into(),
1410            role: MessageRole::User,
1411            content: vec![ContentPart::ImageFile(ImageFileContentPart {
1412                image_id: image_id.into(),
1413                filename: Some("missing.png".to_string()),
1414            })],
1415            phase: None,
1416            thinking: None,
1417            thinking_signature: None,
1418            controls: None,
1419            metadata: None,
1420            external_actor: None,
1421            created_at: chrono::Utc::now(),
1422        };
1423
1424        // Empty resolved map - image not found
1425        let resolved = std::collections::HashMap::new();
1426        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
1427
1428        // Should have placeholder text for missing image
1429        // When there's only one part, it may return Text directly instead of Parts
1430        match &llm_message.content {
1431            LlmMessageContent::Text(text) => {
1432                assert!(text.contains("Image not found"));
1433            }
1434            LlmMessageContent::Parts(parts) => {
1435                assert_eq!(parts.len(), 1);
1436                if let LlmContentPart::Text { text } = &parts[0] {
1437                    assert!(text.contains("Image not found"));
1438                } else {
1439                    panic!("Expected text placeholder for missing image");
1440                }
1441            }
1442        }
1443    }
1444
1445    #[test]
1446    fn test_prepend_text_prefix_simple_text() {
1447        let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
1448        msg.prepend_text_prefix("[Alice] ");
1449        assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
1450    }
1451
1452    #[test]
1453    fn test_prepend_text_prefix_parts() {
1454        let mut msg = LlmMessage::parts(
1455            LlmMessageRole::User,
1456            vec![
1457                LlmContentPart::Text {
1458                    text: "Hello".to_string(),
1459                },
1460                LlmContentPart::Image {
1461                    url: "data:image/png;base64,abc".to_string(),
1462                },
1463            ],
1464        );
1465        msg.prepend_text_prefix("[Bob] ");
1466        match &msg.content {
1467            LlmMessageContent::Parts(parts) => {
1468                if let LlmContentPart::Text { text } = &parts[0] {
1469                    assert_eq!(text, "[Bob] Hello");
1470                } else {
1471                    panic!("Expected text part");
1472                }
1473            }
1474            _ => panic!("Expected parts content"),
1475        }
1476    }
1477
1478    #[test]
1479    fn test_prepend_text_prefix_parts_no_text() {
1480        let mut msg = LlmMessage::parts(
1481            LlmMessageRole::User,
1482            vec![LlmContentPart::Image {
1483                url: "data:image/png;base64,abc".to_string(),
1484            }],
1485        );
1486        msg.prepend_text_prefix("[Eve] ");
1487        match &msg.content {
1488            LlmMessageContent::Parts(parts) => {
1489                assert_eq!(parts.len(), 2);
1490                if let LlmContentPart::Text { text } = &parts[0] {
1491                    assert_eq!(text, "[Eve] ");
1492                } else {
1493                    panic!("Expected prepended text part");
1494                }
1495            }
1496            _ => panic!("Expected parts content"),
1497        }
1498    }
1499}