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    /// OpenRouter using the OpenAI-compatible Responses API.
851    OpenRouter,
852    /// Azure OpenAI using the Azure-hosted OpenAI v1 API.
853    AzureOpenAI,
854    /// OpenAI using Chat Completions API (for backward compatibility)
855    /// Use this if you need the legacy /v1/chat/completions endpoint.
856    OpenAICompletions,
857    Anthropic,
858    /// Google Gemini API
859    Gemini,
860    /// LLM simulator for testing (uses llmsim crate)
861    LlmSim,
862    /// AWS Bedrock Runtime (ConverseStream API)
863    Bedrock,
864}
865
866impl std::str::FromStr for ProviderType {
867    type Err = String;
868
869    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
870        match s.to_lowercase().as_str() {
871            "openai" => Ok(ProviderType::OpenAI),
872            "openrouter" => Ok(ProviderType::OpenRouter),
873            "azure_openai" => Ok(ProviderType::AzureOpenAI),
874            "openai_completions" => Ok(ProviderType::OpenAICompletions),
875            "anthropic" => Ok(ProviderType::Anthropic),
876            "gemini" => Ok(ProviderType::Gemini),
877            "llmsim" => Ok(ProviderType::LlmSim),
878            "bedrock" => Ok(ProviderType::Bedrock),
879            _ => Err(format!("Unknown provider type: {}", s)),
880        }
881    }
882}
883
884impl std::fmt::Display for ProviderType {
885    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
886        match self {
887            ProviderType::OpenAI => write!(f, "openai"),
888            ProviderType::OpenRouter => write!(f, "openrouter"),
889            ProviderType::AzureOpenAI => write!(f, "azure_openai"),
890            ProviderType::OpenAICompletions => write!(f, "openai_completions"),
891            ProviderType::Anthropic => write!(f, "anthropic"),
892            ProviderType::Gemini => write!(f, "gemini"),
893            ProviderType::LlmSim => write!(f, "llmsim"),
894            ProviderType::Bedrock => write!(f, "bedrock"),
895        }
896    }
897}
898
899/// Configuration for creating an LLM provider
900#[derive(Debug, Clone)]
901pub struct ProviderConfig {
902    /// Type of provider
903    pub provider_type: ProviderType,
904    /// API key for authentication
905    pub api_key: Option<String>,
906    /// Base URL override (optional)
907    pub base_url: Option<String>,
908}
909
910impl ProviderConfig {
911    /// Create a new provider config
912    pub fn new(provider_type: ProviderType) -> Self {
913        Self {
914            provider_type,
915            api_key: None,
916            base_url: None,
917        }
918    }
919
920    /// Set the API key
921    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
922        self.api_key = Some(api_key.into());
923        self
924    }
925
926    /// Set the base URL
927    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
928        self.base_url = Some(base_url.into());
929        self
930    }
931}
932
933/// Boxed LLM driver for dynamic dispatch
934pub type BoxedLlmDriver = Box<dyn LlmDriver>;
935
936// ============================================================================
937// Driver Registry
938// ============================================================================
939
940/// Factory function type for creating LLM drivers
941///
942/// Takes api_key and optional base_url, returns a boxed driver
943pub type DriverFactory = Arc<dyn Fn(&str, Option<&str>) -> BoxedLlmDriver + Send + Sync>;
944
945/// Registry for LLM drivers
946///
947/// Enables dependency inversion: provider crates (everruns-anthropic, everruns-openai)
948/// register their drivers at startup. The core has no direct knowledge of implementations.
949///
950/// # Example
951///
952/// ```ignore
953/// use everruns_core::llm_drivers::{DriverRegistry, ProviderType};
954/// use everruns_anthropic::register_driver;
955/// use everruns_openai::register_driver as register_openai;
956///
957/// let mut registry = DriverRegistry::new();
958/// everruns_anthropic::register_driver(&mut registry);
959/// everruns_openai::register_driver(&mut registry);
960///
961/// // Later, create a driver from config
962/// let driver = registry.create_driver(&config)?;
963/// ```
964#[derive(Clone, Default)]
965pub struct DriverRegistry {
966    factories: HashMap<ProviderType, DriverFactory>,
967}
968
969impl DriverRegistry {
970    /// Create a new empty registry
971    pub fn new() -> Self {
972        Self {
973            factories: HashMap::new(),
974        }
975    }
976
977    /// Register a driver factory for a provider type
978    pub fn register<F>(&mut self, provider_type: ProviderType, factory: F)
979    where
980        F: Fn(&str, Option<&str>) -> BoxedLlmDriver + Send + Sync + 'static,
981    {
982        self.factories.insert(provider_type, Arc::new(factory));
983    }
984
985    /// Create an LLM driver based on configuration
986    ///
987    /// API keys must be provided in the config for real providers. This function does NOT fall back to
988    /// environment variables. Keys should be decrypted from the database and passed here.
989    /// Exception: LlmSim provider does not require an API key.
990    ///
991    /// Returns `DriverNotRegistered` error if no driver is registered for the provider type.
992    pub fn create_driver(&self, config: &ProviderConfig) -> Result<BoxedLlmDriver> {
993        // API key is required for real providers, but not for LlmSim (testing)
994        let api_key = if config.provider_type == ProviderType::LlmSim {
995            // LlmSim doesn't need a real API key
996            config.api_key.as_deref().unwrap_or("")
997        } else {
998            config.api_key.as_ref().ok_or_else(|| {
999                AgentLoopError::llm(
1000                    "API key is required. Configure the API key in provider settings.",
1001                )
1002            })?
1003        };
1004
1005        // Look up the factory for this provider type
1006        let factory = self.factories.get(&config.provider_type).ok_or_else(|| {
1007            AgentLoopError::driver_not_registered(config.provider_type.to_string())
1008        })?;
1009
1010        // Create the driver using the factory
1011        Ok(factory(api_key, config.base_url.as_deref()))
1012    }
1013
1014    /// Check if a driver is registered for a provider type
1015    pub fn has_driver(&self, provider_type: &ProviderType) -> bool {
1016        self.factories.contains_key(provider_type)
1017    }
1018
1019    /// Get the list of registered provider types
1020    pub fn registered_providers(&self) -> Vec<ProviderType> {
1021        self.factories.keys().cloned().collect()
1022    }
1023}
1024
1025/// Maximum tool result size in bytes before truncation (64 KiB).
1026/// Defense-in-depth backstop for tool results that bypass ActAtom hooks
1027/// (e.g. client-submitted or stored events). The primary hard limit is
1028/// enforced by `OutputHardLimitHook` (EVE-225) at tool execution time.
1029const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
1030
1031const TRUNCATION_SUFFIX: &str =
1032    "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
1033
1034fn truncate_tool_result(text: String) -> String {
1035    if text.len() <= MAX_TOOL_RESULT_BYTES {
1036        return text;
1037    }
1038    let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
1039    let mut end = content_budget;
1040    while end > 0 && !text.is_char_boundary(end) {
1041        end -= 1;
1042    }
1043    let mut truncated = text[..end].to_string();
1044    truncated.push_str(TRUNCATION_SUFFIX);
1045    truncated
1046}
1047
1048// ============================================================================
1049// Tests
1050// ============================================================================
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055
1056    #[test]
1057    fn test_llm_call_config_builder_from_runtime_agent() {
1058        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1059        let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
1060
1061        assert_eq!(llm_config.model, "gpt-4o");
1062        assert!(llm_config.reasoning_effort.is_none());
1063        assert!(llm_config.temperature.is_none());
1064        assert!(llm_config.max_tokens.is_none());
1065        assert!(llm_config.tools.is_empty());
1066        assert!(llm_config.metadata.is_empty());
1067    }
1068
1069    #[test]
1070    fn test_llm_call_config_builder_with_metadata() {
1071        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1072        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1073            .with_metadata("session_id", "session_abc123")
1074            .with_metadata("agent_id", "agent_xyz789")
1075            .build();
1076
1077        assert_eq!(
1078            llm_config.metadata.get("session_id"),
1079            Some(&"session_abc123".to_string())
1080        );
1081        assert_eq!(
1082            llm_config.metadata.get("agent_id"),
1083            Some(&"agent_xyz789".to_string())
1084        );
1085    }
1086
1087    #[test]
1088    fn test_llm_call_config_builder_with_metadata_hashmap() {
1089        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1090        let mut metadata = HashMap::new();
1091        metadata.insert("key1".to_string(), "value1".to_string());
1092        metadata.insert("key2".to_string(), "value2".to_string());
1093
1094        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1095            .metadata(metadata)
1096            .build();
1097
1098        assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
1099        assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
1100    }
1101
1102    #[test]
1103    fn test_llm_call_config_builder_with_reasoning_effort() {
1104        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1105        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1106            .reasoning_effort("high")
1107            .build();
1108
1109        assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
1110    }
1111
1112    #[test]
1113    fn test_llm_call_config_builder_with_all_options() {
1114        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1115        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1116            .model("claude-3-opus")
1117            .reasoning_effort("medium")
1118            .temperature(0.7)
1119            .max_tokens(1000)
1120            .build();
1121
1122        assert_eq!(llm_config.model, "claude-3-opus");
1123        assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
1124        assert_eq!(llm_config.temperature, Some(0.7));
1125        assert_eq!(llm_config.max_tokens, Some(1000));
1126    }
1127
1128    #[test]
1129    fn test_provider_type_parsing() {
1130        assert_eq!(
1131            "openai".parse::<ProviderType>().unwrap(),
1132            ProviderType::OpenAI
1133        );
1134        assert_eq!(
1135            "openrouter".parse::<ProviderType>().unwrap(),
1136            ProviderType::OpenRouter
1137        );
1138        assert_eq!(
1139            "openai_completions".parse::<ProviderType>().unwrap(),
1140            ProviderType::OpenAICompletions
1141        );
1142        assert_eq!(
1143            "azure_openai".parse::<ProviderType>().unwrap(),
1144            ProviderType::AzureOpenAI
1145        );
1146        assert_eq!(
1147            "anthropic".parse::<ProviderType>().unwrap(),
1148            ProviderType::Anthropic
1149        );
1150        assert_eq!(
1151            "gemini".parse::<ProviderType>().unwrap(),
1152            ProviderType::Gemini
1153        );
1154        // Ollama and Custom are no longer supported
1155        assert!("ollama".parse::<ProviderType>().is_err());
1156        assert!("custom".parse::<ProviderType>().is_err());
1157    }
1158
1159    #[test]
1160    fn test_provider_type_display() {
1161        assert_eq!(ProviderType::OpenAI.to_string(), "openai");
1162        assert_eq!(ProviderType::OpenRouter.to_string(), "openrouter");
1163        assert_eq!(ProviderType::AzureOpenAI.to_string(), "azure_openai");
1164        assert_eq!(
1165            ProviderType::OpenAICompletions.to_string(),
1166            "openai_completions"
1167        );
1168        assert_eq!(ProviderType::Anthropic.to_string(), "anthropic");
1169        assert_eq!(ProviderType::Gemini.to_string(), "gemini");
1170    }
1171
1172    #[test]
1173    fn test_provider_config_builder() {
1174        let config = ProviderConfig::new(ProviderType::Anthropic)
1175            .with_api_key("test-key")
1176            .with_base_url("https://custom.api.com");
1177
1178        assert_eq!(config.provider_type, ProviderType::Anthropic);
1179        assert_eq!(config.api_key, Some("test-key".to_string()));
1180        assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
1181    }
1182
1183    #[test]
1184    fn test_driver_registry_requires_api_key() {
1185        // Register a mock factory
1186        let mut registry = DriverRegistry::new();
1187        registry.register(ProviderType::OpenAI, |_api_key, _base_url| {
1188            // Return a mock driver - just need something that compiles
1189            struct MockDriver;
1190            #[async_trait]
1191            impl LlmDriver for MockDriver {
1192                async fn chat_completion_stream(
1193                    &self,
1194                    _messages: Vec<LlmMessage>,
1195                    _config: &LlmCallConfig,
1196                ) -> Result<LlmResponseStream> {
1197                    unimplemented!()
1198                }
1199            }
1200            Box::new(MockDriver)
1201        });
1202
1203        // Driver without API key should fail
1204        let config = ProviderConfig::new(ProviderType::OpenAI);
1205        let result = registry.create_driver(&config);
1206        assert!(result.is_err());
1207
1208        // Driver with API key should succeed
1209        let config_with_key = ProviderConfig::new(ProviderType::OpenAI).with_api_key("test-key");
1210        let result = registry.create_driver(&config_with_key);
1211        assert!(result.is_ok());
1212    }
1213
1214    #[test]
1215    fn test_driver_registry_returns_error_for_unregistered_provider() {
1216        let registry = DriverRegistry::new();
1217        let config = ProviderConfig::new(ProviderType::Anthropic).with_api_key("test-key");
1218
1219        let result = registry.create_driver(&config);
1220
1221        // Should fail with DriverNotRegistered error
1222        if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
1223            assert_eq!(provider, "anthropic");
1224        } else {
1225            panic!("Expected DriverNotRegistered error");
1226        }
1227    }
1228
1229    #[test]
1230    fn test_driver_registry_registration() {
1231        let mut registry = DriverRegistry::new();
1232
1233        assert!(!registry.has_driver(&ProviderType::OpenAI));
1234        assert!(!registry.has_driver(&ProviderType::Anthropic));
1235
1236        registry.register(ProviderType::OpenAI, |_, _| {
1237            struct MockDriver;
1238            #[async_trait]
1239            impl LlmDriver for MockDriver {
1240                async fn chat_completion_stream(
1241                    &self,
1242                    _messages: Vec<LlmMessage>,
1243                    _config: &LlmCallConfig,
1244                ) -> Result<LlmResponseStream> {
1245                    unimplemented!()
1246                }
1247            }
1248            Box::new(MockDriver)
1249        });
1250
1251        assert!(registry.has_driver(&ProviderType::OpenAI));
1252        assert!(!registry.has_driver(&ProviderType::Anthropic));
1253    }
1254
1255    // ========================================================================
1256    // Image resolution tests
1257    // ========================================================================
1258
1259    use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
1260
1261    #[test]
1262    fn test_message_has_image_files_with_image_file() {
1263        let message = Message {
1264            id: uuid::Uuid::new_v4().into(),
1265            role: MessageRole::User,
1266            content: vec![
1267                ContentPart::Text(TextContentPart {
1268                    text: "Look at this image".to_string(),
1269                }),
1270                ContentPart::ImageFile(ImageFileContentPart {
1271                    image_id: uuid::Uuid::new_v4().into(),
1272                    filename: Some("test.png".to_string()),
1273                }),
1274            ],
1275            phase: None,
1276            thinking: None,
1277            thinking_signature: None,
1278            controls: None,
1279            metadata: None,
1280            external_actor: None,
1281            created_at: chrono::Utc::now(),
1282        };
1283
1284        assert!(LlmMessage::message_has_image_files(&message));
1285    }
1286
1287    #[test]
1288    fn test_message_has_image_files_without_image_file() {
1289        let message = Message {
1290            id: uuid::Uuid::new_v4().into(),
1291            role: MessageRole::User,
1292            content: vec![ContentPart::Text(TextContentPart {
1293                text: "Just text".to_string(),
1294            })],
1295            phase: None,
1296            thinking: None,
1297            thinking_signature: None,
1298            controls: None,
1299            metadata: None,
1300            external_actor: None,
1301            created_at: chrono::Utc::now(),
1302        };
1303
1304        assert!(!LlmMessage::message_has_image_files(&message));
1305    }
1306
1307    #[test]
1308    fn test_extract_image_file_ids() {
1309        let id1 = uuid::Uuid::new_v4();
1310        let id2 = uuid::Uuid::new_v4();
1311
1312        let message = Message {
1313            id: uuid::Uuid::new_v4().into(),
1314            role: MessageRole::User,
1315            content: vec![
1316                ContentPart::Text(TextContentPart {
1317                    text: "Look at these images".to_string(),
1318                }),
1319                ContentPart::ImageFile(ImageFileContentPart {
1320                    image_id: id1.into(),
1321                    filename: Some("test1.png".to_string()),
1322                }),
1323                ContentPart::ImageFile(ImageFileContentPart {
1324                    image_id: id2.into(),
1325                    filename: Some("test2.png".to_string()),
1326                }),
1327            ],
1328            phase: None,
1329            thinking: None,
1330            thinking_signature: None,
1331            controls: None,
1332            metadata: None,
1333            external_actor: None,
1334            created_at: chrono::Utc::now(),
1335        };
1336
1337        let ids = LlmMessage::extract_image_file_ids(&message);
1338        assert_eq!(ids.len(), 2);
1339        assert!(ids.contains(&id1));
1340        assert!(ids.contains(&id2));
1341    }
1342
1343    #[test]
1344    fn test_from_message_with_images_text_only() {
1345        let message = Message {
1346            id: uuid::Uuid::new_v4().into(),
1347            role: MessageRole::User,
1348            content: vec![ContentPart::Text(TextContentPart {
1349                text: "Hello".to_string(),
1350            })],
1351            phase: None,
1352            thinking: None,
1353            thinking_signature: None,
1354            controls: None,
1355            metadata: None,
1356            external_actor: None,
1357            created_at: chrono::Utc::now(),
1358        };
1359
1360        let resolved = std::collections::HashMap::new();
1361        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
1362
1363        assert_eq!(llm_message.role, LlmMessageRole::User);
1364        match llm_message.content {
1365            LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
1366            _ => panic!("Expected text content"),
1367        }
1368    }
1369
1370    #[test]
1371    fn test_from_message_with_images_resolved_image() {
1372        let image_id = uuid::Uuid::new_v4();
1373        let message = Message {
1374            id: uuid::Uuid::new_v4().into(),
1375            role: MessageRole::User,
1376            content: vec![
1377                ContentPart::Text(TextContentPart {
1378                    text: "Look at this".to_string(),
1379                }),
1380                ContentPart::ImageFile(ImageFileContentPart {
1381                    image_id: image_id.into(),
1382                    filename: Some("test.png".to_string()),
1383                }),
1384            ],
1385            phase: None,
1386            thinking: None,
1387            thinking_signature: None,
1388            controls: None,
1389            metadata: None,
1390            external_actor: None,
1391            created_at: chrono::Utc::now(),
1392        };
1393
1394        let mut resolved = std::collections::HashMap::new();
1395        resolved.insert(
1396            image_id,
1397            crate::ResolvedImage::new("base64data", "image/png"),
1398        );
1399
1400        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
1401
1402        match &llm_message.content {
1403            LlmMessageContent::Parts(parts) => {
1404                assert_eq!(parts.len(), 2);
1405                // First part should be text
1406                assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
1407                // Second part should be resolved image
1408                if let LlmContentPart::Image { url } = &parts[1] {
1409                    assert!(url.starts_with("data:image/png;base64,"));
1410                } else {
1411                    panic!("Expected image content part");
1412                }
1413            }
1414            _ => panic!("Expected parts content"),
1415        }
1416    }
1417
1418    #[test]
1419    fn test_from_message_with_images_unresolved_image() {
1420        let image_id = uuid::Uuid::new_v4();
1421        let message = Message {
1422            id: uuid::Uuid::new_v4().into(),
1423            role: MessageRole::User,
1424            content: vec![ContentPart::ImageFile(ImageFileContentPart {
1425                image_id: image_id.into(),
1426                filename: Some("missing.png".to_string()),
1427            })],
1428            phase: None,
1429            thinking: None,
1430            thinking_signature: None,
1431            controls: None,
1432            metadata: None,
1433            external_actor: None,
1434            created_at: chrono::Utc::now(),
1435        };
1436
1437        // Empty resolved map - image not found
1438        let resolved = std::collections::HashMap::new();
1439        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
1440
1441        // Should have placeholder text for missing image
1442        // When there's only one part, it may return Text directly instead of Parts
1443        match &llm_message.content {
1444            LlmMessageContent::Text(text) => {
1445                assert!(text.contains("Image not found"));
1446            }
1447            LlmMessageContent::Parts(parts) => {
1448                assert_eq!(parts.len(), 1);
1449                if let LlmContentPart::Text { text } = &parts[0] {
1450                    assert!(text.contains("Image not found"));
1451                } else {
1452                    panic!("Expected text placeholder for missing image");
1453                }
1454            }
1455        }
1456    }
1457
1458    #[test]
1459    fn test_prepend_text_prefix_simple_text() {
1460        let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
1461        msg.prepend_text_prefix("[Alice] ");
1462        assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
1463    }
1464
1465    #[test]
1466    fn test_prepend_text_prefix_parts() {
1467        let mut msg = LlmMessage::parts(
1468            LlmMessageRole::User,
1469            vec![
1470                LlmContentPart::Text {
1471                    text: "Hello".to_string(),
1472                },
1473                LlmContentPart::Image {
1474                    url: "data:image/png;base64,abc".to_string(),
1475                },
1476            ],
1477        );
1478        msg.prepend_text_prefix("[Bob] ");
1479        match &msg.content {
1480            LlmMessageContent::Parts(parts) => {
1481                if let LlmContentPart::Text { text } = &parts[0] {
1482                    assert_eq!(text, "[Bob] Hello");
1483                } else {
1484                    panic!("Expected text part");
1485                }
1486            }
1487            _ => panic!("Expected parts content"),
1488        }
1489    }
1490
1491    #[test]
1492    fn test_prepend_text_prefix_parts_no_text() {
1493        let mut msg = LlmMessage::parts(
1494            LlmMessageRole::User,
1495            vec![LlmContentPart::Image {
1496                url: "data:image/png;base64,abc".to_string(),
1497            }],
1498        );
1499        msg.prepend_text_prefix("[Eve] ");
1500        match &msg.content {
1501            LlmMessageContent::Parts(parts) => {
1502                assert_eq!(parts.len(), 2);
1503                if let LlmContentPart::Text { text } = &parts[0] {
1504                    assert_eq!(text, "[Eve] ");
1505                } else {
1506                    panic!("Expected prepended text part");
1507                }
1508            }
1509            _ => panic!("Expected parts content"),
1510        }
1511    }
1512}