Skip to main content

everruns_core/
llm_driver_registry.rs

1// Chat Driver Abstractions
2//
3// This module encapsulates all abstractions needed to interact with LLM Providers:
4// - ChatDriver 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::credential_schema::CredentialFormSchema;
18use crate::error::{AgentLoopError, Result};
19use crate::openresponses_protocol::{CompactRequest, CompactResponse};
20use crate::runtime_agent::RuntimeAgent;
21use crate::tool_types::{ToolCall, ToolDefinition};
22use async_trait::async_trait;
23use chrono::{DateTime, Utc};
24use futures::Stream;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27use std::pin::Pin;
28use std::sync::Arc;
29
30// ============================================================================
31// ChatDriver Trait
32// ============================================================================
33
34/// Type alias for the LLM response stream
35pub type LlmResponseStream = Pin<Box<dyn Stream<Item = Result<LlmStreamEvent>> + Send>>;
36
37/// Events emitted during LLM streaming
38#[derive(Debug, Clone)]
39pub enum LlmStreamEvent {
40    /// Text delta (incremental content)
41    TextDelta(String),
42    /// Thinking delta (incremental reasoning content from extended thinking models)
43    ThinkingDelta(String),
44    /// Cryptographic signature for thinking content (Anthropic Claude)
45    /// Emitted when a thinking block completes, before the Done event
46    ThinkingSignature(String),
47    /// Opaque assistant reasoning response item (OpenAI Responses).
48    /// Carries provider-supplied opaque/encrypted reasoning artifacts plus safe
49    /// summary text and per-item metadata. Plaintext hidden reasoning content is
50    /// intentionally excluded so callers can persist this without exposing
51    /// chain-of-thought.
52    ReasonItem {
53        /// Provider name (e.g., "openai").
54        provider: String,
55        /// Model identifier reported by the provider, if known.
56        model: Option<String>,
57        /// Provider-assigned identifier for the reasoning item.
58        item_id: String,
59        /// Provider-encrypted reasoning context, if supplied.
60        encrypted_content: Option<String>,
61        /// Safe summary text segments curated by the provider.
62        summary: Vec<String>,
63        /// Per-item reasoning token count, when the provider reports one.
64        token_count: Option<u32>,
65    },
66    /// Tool calls from the LLM
67    ToolCalls(Vec<ToolCall>),
68    /// Streaming completed
69    Done(Box<LlmCompletionMetadata>),
70    /// Error during streaming
71    Error(String),
72}
73
74/// Model information discovered from a provider's list_models API
75///
76/// Represents a model available from a provider. Used for dynamic model discovery
77/// to sync available models from provider APIs into the database.
78///
79/// The `discovered_profile` field carries structured capability/limit metadata
80/// parsed from the provider's API response (e.g., Anthropic's capabilities object).
81/// During model sync, this profile is merged with hardcoded profiles: hardcoded
82/// values take precedence (they include cost data not available from APIs),
83/// but discovered data fills gaps for models without hardcoded profiles.
84#[derive(Debug, Clone)]
85pub struct DiscoveredModel {
86    /// Model identifier (e.g., "gpt-5.2", "claude-opus-4-5-20251101")
87    pub model_id: String,
88    /// Human-readable display name (if provided by API)
89    pub display_name: Option<String>,
90    /// When the model was created/released
91    pub created_at: Option<DateTime<Utc>>,
92    /// Owner or organization (e.g., "openai", "system")
93    pub owned_by: Option<String>,
94    /// Structured profile built from provider API metadata (capabilities, limits).
95    /// Populated by drivers that return rich model metadata (e.g., Anthropic /v1/models).
96    pub discovered_profile: Option<crate::model::ModelProfile>,
97}
98
99/// Metadata about LLM completion
100///
101/// Contains token usage and completion information from the LLM response.
102/// Cache token fields are provider-specific:
103/// - OpenAI: `cache_read_tokens` from prompt_tokens_details.cached_tokens
104/// - Anthropic: `cache_read_tokens` from cache_read_input_tokens,
105///   `cache_creation_tokens` from cache_creation_input_tokens
106#[derive(Debug, Clone, Default)]
107pub struct LlmCompletionMetadata {
108    /// Total tokens used
109    pub total_tokens: Option<u32>,
110    /// Prompt tokens
111    pub prompt_tokens: Option<u32>,
112    /// Completion tokens
113    pub completion_tokens: Option<u32>,
114    /// Tokens read from cache (reduces cost)
115    pub cache_read_tokens: Option<u32>,
116    /// Tokens written to cache (Anthropic-specific)
117    pub cache_creation_tokens: Option<u32>,
118    /// Authoritative cost of this generation in USD, when the provider reports
119    /// it inline (e.g. OpenRouter's `usage.cost`). `None` for providers that do
120    /// not return a cost.
121    pub provider_cost_usd: Option<f64>,
122    /// Model used
123    pub model: Option<String>,
124    /// Finish reason
125    pub finish_reason: Option<String>,
126    /// Retry metadata (present if rate limit retries occurred)
127    pub retry_metadata: Option<crate::llm_retry::RetryMetadata>,
128    /// Provider's response ID (e.g., OpenAI response ID from response.completed).
129    /// Used for `previous_response_id` chaining and OTel tracing.
130    pub response_id: Option<String>,
131    /// Execution phase from the provider's response (e.g., "commentary", "final_answer").
132    /// When present, this value should be preserved on the assistant message and sent
133    /// back as-is in subsequent requests. Only set by providers with native phase support.
134    pub phase: Option<String>,
135}
136
137/// Trait for LLM drivers
138///
139/// Implementations handle provider-specific API calls and response parsing.
140///
141/// # Error contract
142///
143/// Drivers surface provider failures as `AgentLoopError` and classify them
144/// semantically at the provider boundary, where HTTP status and response body
145/// are still available:
146///
147/// - request-too-large conditions => `AgentLoopError::request_too_large`
148/// - missing/unknown model => `AgentLoopError::model_not_available`
149/// - everything else => `AgentLoopError::llm_kind(LlmErrorKind::..., msg)`,
150///   using `LlmErrorKind::from_provider_status` (HTTP drivers) or
151///   `LlmErrorKind::from_error_text` (SDK drivers without a status). Plain
152///   `AgentLoopError::llm` is reserved for unclassifiable errors; downstream
153///   then falls back to string classification.
154///
155/// Quota/billing exhaustion (`LlmErrorKind::QuotaExhausted`) is non-transient
156/// and must not be retried by driver retry loops even when the provider
157/// reports it under a transient status like 429.
158#[async_trait]
159pub trait ChatDriver: Send + Sync {
160    /// Call the LLM with streaming response
161    async fn chat_completion_stream(
162        &self,
163        messages: Vec<LlmMessage>,
164        config: &LlmCallConfig,
165    ) -> Result<LlmResponseStream>;
166
167    /// Call the LLM without streaming (convenience method)
168    async fn chat_completion(
169        &self,
170        messages: Vec<LlmMessage>,
171        config: &LlmCallConfig,
172    ) -> Result<LlmResponse> {
173        use futures::StreamExt;
174
175        let mut stream = self.chat_completion_stream(messages, config).await?;
176        let mut text = String::new();
177        let mut thinking = String::new();
178        let mut thinking_signature: Option<String> = None;
179        let mut tool_calls = Vec::new();
180        let mut metadata = LlmCompletionMetadata::default();
181
182        while let Some(event) = stream.next().await {
183            match event? {
184                LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
185                LlmStreamEvent::ThinkingDelta(delta) => thinking.push_str(&delta),
186                LlmStreamEvent::ThinkingSignature(sig) => thinking_signature = Some(sig),
187                LlmStreamEvent::ReasonItem {
188                    encrypted_content, ..
189                } => {
190                    if let Some(sig) = encrypted_content {
191                        thinking_signature = Some(sig);
192                    }
193                }
194                LlmStreamEvent::ToolCalls(calls) => tool_calls = calls,
195                LlmStreamEvent::Done(meta) => metadata = *meta,
196                LlmStreamEvent::Error(err) => return Err(crate::error::AgentLoopError::llm(err)),
197            }
198        }
199
200        Ok(LlmResponse {
201            text,
202            thinking: if thinking.is_empty() {
203                None
204            } else {
205                Some(thinking)
206            },
207            thinking_signature,
208            tool_calls: if tool_calls.is_empty() {
209                None
210            } else {
211                Some(tool_calls)
212            },
213            metadata,
214        })
215    }
216
217    /// List available models from the provider
218    ///
219    /// Returns `Ok(Some(models))` if the provider supports model listing,
220    /// or `Ok(None)` if not supported (e.g., custom endpoints, proxies).
221    ///
222    /// Implementations should filter to chat/completion models only,
223    /// excluding embedding models, TTS, whisper, etc.
224    async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
225        // Default: not supported. Providers override if they support listing.
226        Ok(None)
227    }
228
229    /// Check if this driver supports the compact endpoint
230    ///
231    /// The compact endpoint compresses conversation history by replacing
232    /// assistant messages, tool calls, and tool results with an encrypted
233    /// compaction item. User messages are kept verbatim.
234    ///
235    /// Returns `true` if the driver supports compaction, `false` otherwise.
236    /// Currently only supported by OpenAI's Responses API.
237    fn supports_compact(&self) -> bool {
238        // Default: not supported
239        false
240    }
241
242    /// Compact a conversation to reduce context size
243    ///
244    /// This method compresses conversation history by calling the provider's
245    /// compact endpoint. User messages are kept verbatim, while assistant
246    /// messages, tool calls, and tool results are replaced by an encrypted
247    /// compaction item that preserves latent context but is opaque.
248    ///
249    /// # Arguments
250    ///
251    /// * `request` - The compact request containing the model and input items
252    ///
253    /// # Returns
254    ///
255    /// Returns `Ok(Some(response))` if compaction succeeded,
256    /// `Ok(None)` if compaction is not supported by this driver,
257    /// or `Err` if an error occurred.
258    ///
259    /// The response contains the compacted output items which can be used
260    /// directly as input for the next chat completion call.
261    async fn compact(&self, _request: CompactRequest) -> Result<Option<CompactResponse>> {
262        // Default: not supported
263        Ok(None)
264    }
265}
266
267/// Implement ChatDriver for `Box<dyn ChatDriver>` to allow dynamic dispatch
268#[async_trait]
269impl ChatDriver for Box<dyn ChatDriver> {
270    async fn chat_completion_stream(
271        &self,
272        messages: Vec<LlmMessage>,
273        config: &LlmCallConfig,
274    ) -> Result<LlmResponseStream> {
275        (**self).chat_completion_stream(messages, config).await
276    }
277
278    async fn chat_completion(
279        &self,
280        messages: Vec<LlmMessage>,
281        config: &LlmCallConfig,
282    ) -> Result<LlmResponse> {
283        (**self).chat_completion(messages, config).await
284    }
285
286    async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
287        (**self).list_models().await
288    }
289
290    fn supports_compact(&self) -> bool {
291        (**self).supports_compact()
292    }
293
294    async fn compact(&self, request: CompactRequest) -> Result<Option<CompactResponse>> {
295        (**self).compact(request).await
296    }
297}
298
299// ============================================================================
300// Message Types
301// ============================================================================
302
303/// Message format for LLM calls (provider-agnostic)
304#[derive(Debug, Clone)]
305pub struct LlmMessage {
306    pub role: LlmMessageRole,
307    pub content: LlmMessageContent,
308    pub tool_calls: Option<Vec<ToolCall>>,
309    pub tool_call_id: Option<String>,
310    /// Execution phase for assistant messages.
311    /// Helps models distinguish between intermediate working commentary (`Commentary`)
312    /// and completed answers (`FinalAnswer`) in multi-step tool-calling flows.
313    /// Only set on assistant messages. Must be preserved when replaying conversation history.
314    pub phase: Option<crate::message::ExecutionPhase>,
315    /// Thinking content from extended thinking models (Anthropic Claude)
316    /// Must be included in subsequent API calls when thinking is enabled
317    pub thinking: Option<String>,
318    /// Cryptographic signature for thinking content (Anthropic Claude)
319    /// Required when sending thinking back in subsequent API calls
320    pub thinking_signature: Option<String>,
321}
322
323impl LlmMessage {
324    /// Create a message with text content
325    pub fn text(role: LlmMessageRole, content: impl Into<String>) -> Self {
326        Self {
327            role,
328            content: LlmMessageContent::Text(content.into()),
329            tool_calls: None,
330            tool_call_id: None,
331            phase: None,
332            thinking: None,
333            thinking_signature: None,
334        }
335    }
336
337    /// Create a message with content parts (text, images, audio)
338    pub fn parts(role: LlmMessageRole, parts: Vec<LlmContentPart>) -> Self {
339        Self {
340            role,
341            content: LlmMessageContent::Parts(parts),
342            tool_calls: None,
343            tool_call_id: None,
344            phase: None,
345            thinking: None,
346            thinking_signature: None,
347        }
348    }
349
350    /// Get content as plain text string (for simple cases)
351    pub fn content_as_text(&self) -> String {
352        self.content.to_text()
353    }
354
355    /// Prepend a prefix to the first text content.
356    ///
357    /// Used by ReasonAtom to inject external actor identity (e.g. `"[Alice] "`)
358    /// into user messages from external channels.
359    pub fn prepend_text_prefix(&mut self, prefix: &str) {
360        match &mut self.content {
361            LlmMessageContent::Text(text) => {
362                *text = format!("{}{}", prefix, text);
363            }
364            LlmMessageContent::Parts(parts) => {
365                for part in parts.iter_mut() {
366                    if let LlmContentPart::Text { text } = part {
367                        *text = format!("{}{}", prefix, text);
368                        return;
369                    }
370                }
371                // No text part found — prepend one
372                parts.insert(
373                    0,
374                    LlmContentPart::Text {
375                        text: prefix.to_string(),
376                    },
377                );
378            }
379        }
380    }
381}
382
383/// Message content - either a simple string or array of content parts
384#[derive(Debug, Clone)]
385pub enum LlmMessageContent {
386    /// Simple text content
387    Text(String),
388    /// Array of content parts (text, images, audio)
389    Parts(Vec<LlmContentPart>),
390}
391
392impl LlmMessageContent {
393    /// Convert to plain text (concatenates text parts, ignores media)
394    pub fn to_text(&self) -> String {
395        match self {
396            LlmMessageContent::Text(s) => s.clone(),
397            LlmMessageContent::Parts(parts) => parts
398                .iter()
399                .filter_map(|p| match p {
400                    LlmContentPart::Text { text } => Some(text.clone()),
401                    _ => None,
402                })
403                .collect::<Vec<_>>()
404                .join(""),
405        }
406    }
407
408    /// Check if content is simple text
409    pub fn is_text(&self) -> bool {
410        matches!(self, LlmMessageContent::Text(_))
411    }
412
413    /// Check if content has multiple parts
414    pub fn is_parts(&self) -> bool {
415        matches!(self, LlmMessageContent::Parts(_))
416    }
417}
418
419impl From<String> for LlmMessageContent {
420    fn from(s: String) -> Self {
421        LlmMessageContent::Text(s)
422    }
423}
424
425impl From<&str> for LlmMessageContent {
426    fn from(s: &str) -> Self {
427        LlmMessageContent::Text(s.to_string())
428    }
429}
430
431/// A single content part within a message
432#[derive(Debug, Clone)]
433pub enum LlmContentPart {
434    /// Text content
435    Text { text: String },
436    /// Image content (base64 data URL or HTTP URL)
437    Image { url: String },
438    /// Audio content (base64 data URL)
439    Audio { url: String },
440}
441
442impl LlmContentPart {
443    /// Create a text content part
444    pub fn text(text: impl Into<String>) -> Self {
445        LlmContentPart::Text { text: text.into() }
446    }
447
448    /// Create an image content part from URL (can be data URL or HTTP URL)
449    pub fn image(url: impl Into<String>) -> Self {
450        LlmContentPart::Image { url: url.into() }
451    }
452
453    /// Create an audio content part from URL (typically a data URL)
454    pub fn audio(url: impl Into<String>) -> Self {
455        LlmContentPart::Audio { url: url.into() }
456    }
457}
458
459/// Message role for LLM calls
460#[derive(Debug, Clone, PartialEq, Eq)]
461pub enum LlmMessageRole {
462    System,
463    User,
464    Assistant,
465    Tool,
466}
467
468// ============================================================================
469// Configuration and Response Types
470// ============================================================================
471
472/// Configuration for tool_search (deferred tool loading).
473///
474/// When enabled, the driver groups tools into namespaces and marks them with
475/// `defer_loading: true` so the model only loads full schemas on-demand.
476/// This reduces token usage for agents with many tools.
477#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
478pub struct ToolSearchConfig {
479    /// Enable tool_search for this request (requires model support)
480    pub enabled: bool,
481    /// Minimum number of tools before activating tool_search.
482    /// Below this threshold, full schemas are sent even when enabled.
483    pub threshold: usize,
484}
485
486/// Strategy for prompt caching.
487#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
488#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
489#[serde(rename_all = "snake_case")]
490pub enum PromptCacheStrategy {
491    /// Let each driver choose the safest provider-specific behavior.
492    #[default]
493    Auto,
494}
495
496/// Configuration for prompt caching.
497///
498/// Drivers translate this into provider-specific request options when possible.
499/// Unsupported providers or models should ignore it without failing the call.
500#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
501#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
502pub struct PromptCacheConfig {
503    /// Enable prompt caching for this request.
504    pub enabled: bool,
505    /// Strategy the driver should use when enabling prompt caching.
506    #[serde(default)]
507    pub strategy: PromptCacheStrategy,
508    /// Existing Gemini cached content resource name (`cachedContents/{id}`).
509    ///
510    /// When set, the Gemini driver uses explicit caching via the
511    /// `cachedContent` request field. When absent, Gemini falls back to its
512    /// default provider behavior (for example implicit caching on supported
513    /// models).
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    pub gemini_cached_content: Option<String>,
516}
517
518/// High-level intent presets that compile into OpenRouter provider-routing
519/// controls. Presets let callers express quality, cost, privacy, and capability
520/// goals without knowing every OpenRouter `provider` flag.
521///
522/// Multiple presets may be combined. When a preset and an explicit `provider`
523/// field target the same control, the explicit field wins. Presets applied
524/// earlier in the list may be overridden by later ones for the same field.
525///
526/// Compilation happens in `OpenRouterRoutingConfig::apply_presets()`.
527#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
528#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
529#[serde(tag = "kind", rename_all = "snake_case")]
530pub enum OpenRouterRoutingPreset {
531    /// Prefer the cheapest providers that support function-calling parameters.
532    CheapestWithTools,
533    /// Prefer the highest-throughput providers for quick review or triage tasks.
534    LowestLatencyReview,
535    /// Route only to zero-data-retention (ZDR) endpoints.
536    ZdrOnly,
537    /// Try BYOK-registered providers first; fall back to shared capacity.
538    ByokFirst,
539    /// Deny all provider-side data collection (logs and training).
540    NoDataCollection,
541    /// Route only to providers that support strict JSON / structured output.
542    StrictJson,
543    /// Route only to providers that natively support reasoning/thinking models.
544    ReasoningRequired,
545    /// Cap per-token provider cost. Values are USD per million tokens; `None`
546    /// means no cap on that dimension.
547    MaxPrice {
548        /// Maximum prompt cost in USD per million tokens.
549        #[serde(default, skip_serializing_if = "Option::is_none")]
550        prompt_usd_per_million: Option<f64>,
551        /// Maximum completion cost in USD per million tokens.
552        #[serde(default, skip_serializing_if = "Option::is_none")]
553        completion_usd_per_million: Option<f64>,
554    },
555}
556
557/// OpenRouter model fallback and provider routing controls.
558///
559/// Organization-level strategy for how OpenRouter should allocate compute capacity.
560///
561/// Controls whether requests use OpenRouter shared credits, prefer customer-owned
562/// upstream keys (BYOK), or require BYOK-only routing. Compiled into OpenRouter
563/// `provider` routing controls before dispatch; not sent verbatim on the wire.
564#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
565#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
566#[serde(rename_all = "snake_case")]
567pub enum OpenRouterCapacityStrategy {
568    /// Use OpenRouter shared capacity (credits). No routing changes. Default.
569    #[default]
570    SharedCapacity,
571    /// Prefer providers where the org has registered its own upstream key.
572    /// Falls back to shared capacity when BYOK providers are unavailable.
573    /// Sets `provider.allow_fallbacks = true` unless the caller overrides it.
574    ByokFirst,
575    /// Require a provider where the org has its own upstream key.
576    /// Routing fails if `provider.only` is not explicitly configured with at
577    /// least one BYOK provider slug.
578    /// Sets `provider.allow_fallbacks = false`.
579    ByokOnly,
580}
581
582/// These fields mirror OpenRouter's request-level routing extensions. Drivers
583/// must only forward this config to OpenRouter-compatible endpoints.
584#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
585#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
586pub struct OpenRouterRoutingConfig {
587    /// Candidate models to try in OpenRouter's fallback order.
588    #[serde(default, skip_serializing_if = "Vec::is_empty")]
589    pub models: Vec<String>,
590    /// OpenRouter route strategy. Currently `fallback` is the stable route
591    /// value used with `models`.
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    pub route: Option<OpenRouterRoute>,
594    /// Provider ordering, policy, and sorting preferences.
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub provider: Option<OpenRouterProviderRouting>,
597    /// Optional plugin activations (web search, file reader).
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub plugins: Option<OpenRouterPluginConfig>,
600    /// Org-level capacity strategy. Compiled into `provider` routing before
601    /// dispatch; not forwarded verbatim. `None` and `SharedCapacity` are
602    /// equivalent (no routing changes).
603    #[serde(default, skip_serializing_if = "Option::is_none")]
604    pub capacity_strategy: Option<OpenRouterCapacityStrategy>,
605    /// High-level routing quality/policy presets. Compiled into `provider`
606    /// flags by `apply_presets()` before the request is serialized.
607    /// Explicit `provider` fields override preset-derived values.
608    #[serde(default, skip_serializing_if = "Vec::is_empty")]
609    pub presets: Vec<OpenRouterRoutingPreset>,
610}
611
612impl OpenRouterRoutingConfig {
613    pub fn is_empty(&self) -> bool {
614        self.models.is_empty()
615            && self.route.is_none()
616            && self.provider.is_none()
617            && self.plugins.as_ref().is_none_or(|p| p.is_empty())
618            && matches!(
619                self.capacity_strategy,
620                None | Some(OpenRouterCapacityStrategy::SharedCapacity)
621            )
622            && self.presets.is_empty()
623    }
624
625    /// Build an ordered model-fallback routing config.
626    pub fn fallback_models(models: impl IntoIterator<Item = impl Into<String>>) -> Self {
627        let models = models.into_iter().map(Into::into).collect::<Vec<_>>();
628        let route = (!models.is_empty()).then_some(OpenRouterRoute::Fallback);
629        Self {
630            models,
631            route,
632            provider: None,
633            plugins: None,
634            capacity_strategy: None,
635            presets: vec![],
636        }
637    }
638
639    pub fn validate_for_primary_model(
640        &self,
641        primary_model: &str,
642    ) -> std::result::Result<(), String> {
643        if self.route == Some(OpenRouterRoute::Fallback) && self.models.is_empty() {
644            return Err(
645                "OpenRouter fallback routing requires at least one model in `models`".to_string(),
646            );
647        }
648
649        if let Some(first_model) = self.models.first()
650            && first_model != primary_model
651        {
652            return Err(format!(
653                "OpenRouter routing models[0] ('{first_model}') must match primary model ('{primary_model}')"
654            ));
655        }
656
657        Ok(())
658    }
659
660    /// Apply the capacity strategy, returning a derived config with `provider`
661    /// routing adjusted accordingly.
662    ///
663    /// - `SharedCapacity` / `None` — returns `self` unchanged.
664    /// - `ByokFirst` — sets `provider.allow_fallbacks = true` when not already set.
665    /// - `ByokOnly` — requires `provider.only` to list at least one provider slug;
666    ///   sets `provider.allow_fallbacks = false`.
667    ///
668    /// Returns `Err` when the strategy constraints cannot be satisfied.
669    pub fn apply_capacity_strategy(&self) -> std::result::Result<Self, String> {
670        match self.capacity_strategy {
671            None | Some(OpenRouterCapacityStrategy::SharedCapacity) => Ok(self.clone()),
672            Some(OpenRouterCapacityStrategy::ByokFirst) => {
673                let mut result = self.clone();
674                let provider = result.provider.get_or_insert_with(Default::default);
675                if provider.allow_fallbacks.is_none() {
676                    provider.allow_fallbacks = Some(true);
677                }
678                Ok(result)
679            }
680            Some(OpenRouterCapacityStrategy::ByokOnly) => {
681                let only_is_empty = self.provider.as_ref().is_none_or(|p| p.only.is_empty());
682                if only_is_empty {
683                    return Err(
684                        "OpenRouter BYOK-only strategy requires provider.only to list at least \
685                         one upstream provider slug. Configure the provider list to match the \
686                         BYOK providers registered in your OpenRouter workspace."
687                            .to_string(),
688                    );
689                }
690                let mut result = self.clone();
691                let provider = result.provider.get_or_insert_with(Default::default);
692                provider.allow_fallbacks = Some(false);
693                Ok(result)
694            }
695        }
696    }
697
698    /// Compile `presets` into `OpenRouterProviderRouting` flags and merge with
699    /// any explicit `provider` overrides. Returns a derived config with the
700    /// `presets` list cleared and `provider` reflecting the merged result.
701    ///
702    /// Explicit `provider` fields always win over preset-derived values. When
703    /// multiple presets target the same provider field, later presets in the
704    /// list override earlier ones.
705    ///
706    /// Returns `Err` if any preset values are invalid (e.g. negative `MaxPrice` values).
707    pub fn apply_presets(&self) -> std::result::Result<Self, String> {
708        if self.presets.is_empty() {
709            return Ok(self.clone());
710        }
711
712        let mut derived = OpenRouterProviderRouting::default();
713
714        for preset in &self.presets {
715            match preset {
716                OpenRouterRoutingPreset::CheapestWithTools => {
717                    derived.require_parameters = Some(true);
718                    derived.sort = Some(OpenRouterProviderSort::Simple(
719                        OpenRouterProviderSortBy::Price,
720                    ));
721                }
722                OpenRouterRoutingPreset::LowestLatencyReview => {
723                    derived.sort = Some(OpenRouterProviderSort::Simple(
724                        OpenRouterProviderSortBy::Throughput,
725                    ));
726                }
727                OpenRouterRoutingPreset::ZdrOnly => {
728                    derived.zdr = Some(true);
729                }
730                OpenRouterRoutingPreset::ByokFirst => {
731                    if derived.allow_fallbacks.is_none() {
732                        derived.allow_fallbacks = Some(true);
733                    }
734                }
735                OpenRouterRoutingPreset::NoDataCollection => {
736                    derived.data_collection = Some(OpenRouterDataCollection::Deny);
737                }
738                OpenRouterRoutingPreset::StrictJson
739                | OpenRouterRoutingPreset::ReasoningRequired => {
740                    derived.require_parameters = Some(true);
741                }
742                OpenRouterRoutingPreset::MaxPrice {
743                    prompt_usd_per_million,
744                    completion_usd_per_million,
745                } => {
746                    if prompt_usd_per_million.is_some_and(|v| v < 0.0)
747                        || completion_usd_per_million.is_some_and(|v| v < 0.0)
748                    {
749                        return Err(
750                            "MaxPrice preset values must be non-negative USD per million tokens"
751                                .to_string(),
752                        );
753                    }
754                    if prompt_usd_per_million.is_some() || completion_usd_per_million.is_some() {
755                        let mp = derived.max_price.get_or_insert_with(Default::default);
756                        if let Some(p) = prompt_usd_per_million {
757                            mp.prompt = Some(p / 1_000_000.0);
758                        }
759                        if let Some(c) = completion_usd_per_million {
760                            mp.completion = Some(c / 1_000_000.0);
761                        }
762                    }
763                }
764            }
765        }
766
767        // Explicit provider fields override preset-derived values.
768        let merged = merge_provider_routing(derived, self.provider.clone().unwrap_or_default());
769
770        let mut result = self.clone();
771        result.presets = vec![];
772        result.provider = if merged.is_empty() {
773            None
774        } else {
775            Some(merged)
776        };
777        Ok(result)
778    }
779}
780
781/// Merge preset-derived provider routing with explicit provider overrides.
782/// Explicit fields always win; preset-derived fields fill gaps where explicit
783/// fields are absent (None / empty Vec).
784fn merge_provider_routing(
785    derived: OpenRouterProviderRouting,
786    explicit: OpenRouterProviderRouting,
787) -> OpenRouterProviderRouting {
788    OpenRouterProviderRouting {
789        order: if !explicit.order.is_empty() {
790            explicit.order
791        } else {
792            derived.order
793        },
794        only: if !explicit.only.is_empty() {
795            explicit.only
796        } else {
797            derived.only
798        },
799        ignore: if !explicit.ignore.is_empty() {
800            explicit.ignore
801        } else {
802            derived.ignore
803        },
804        allow_fallbacks: explicit.allow_fallbacks.or(derived.allow_fallbacks),
805        require_parameters: explicit.require_parameters.or(derived.require_parameters),
806        data_collection: explicit.data_collection.or(derived.data_collection),
807        zdr: explicit.zdr.or(derived.zdr),
808        enforce_distillable_text: explicit
809            .enforce_distillable_text
810            .or(derived.enforce_distillable_text),
811        quantizations: if !explicit.quantizations.is_empty() {
812            explicit.quantizations
813        } else {
814            derived.quantizations
815        },
816        sort: explicit.sort.or(derived.sort),
817        max_price: explicit.max_price.or(derived.max_price),
818    }
819}
820
821/// OpenRouter route strategy.
822#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
823#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
824#[serde(rename_all = "snake_case")]
825pub enum OpenRouterRoute {
826    Fallback,
827}
828
829/// OpenRouter provider routing preferences.
830#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
831#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
832pub struct OpenRouterProviderRouting {
833    /// Provider slugs to try first, in order.
834    #[serde(default, skip_serializing_if = "Vec::is_empty")]
835    pub order: Vec<String>,
836    /// Restrict routing to these provider slugs.
837    #[serde(default, skip_serializing_if = "Vec::is_empty")]
838    pub only: Vec<String>,
839    /// Provider slugs to skip.
840    #[serde(default, skip_serializing_if = "Vec::is_empty")]
841    pub ignore: Vec<String>,
842    /// Whether OpenRouter may fall back outside the ordered/allowed providers.
843    #[serde(default, skip_serializing_if = "Option::is_none")]
844    pub allow_fallbacks: Option<bool>,
845    /// Require routed providers to support all request parameters.
846    #[serde(default, skip_serializing_if = "Option::is_none")]
847    pub require_parameters: Option<bool>,
848    /// Restrict routing by provider data-retention policy.
849    #[serde(default, skip_serializing_if = "Option::is_none")]
850    pub data_collection: Option<OpenRouterDataCollection>,
851    /// Restrict routing to zero-data-retention endpoints.
852    #[serde(default, skip_serializing_if = "Option::is_none")]
853    pub zdr: Option<bool>,
854    /// Restrict routing to distillable-text endpoints.
855    #[serde(default, skip_serializing_if = "Option::is_none")]
856    pub enforce_distillable_text: Option<bool>,
857    /// Restrict routing to provider quantization levels.
858    #[serde(default, skip_serializing_if = "Vec::is_empty")]
859    pub quantizations: Vec<String>,
860    /// Sort provider endpoints by price, throughput, or latency.
861    #[serde(default, skip_serializing_if = "Option::is_none")]
862    pub sort: Option<OpenRouterProviderSort>,
863    /// Maximum accepted per-unit provider price.
864    #[serde(default, skip_serializing_if = "Option::is_none")]
865    pub max_price: Option<OpenRouterMaxPrice>,
866}
867
868impl OpenRouterProviderRouting {
869    pub fn is_empty(&self) -> bool {
870        self.order.is_empty()
871            && self.only.is_empty()
872            && self.ignore.is_empty()
873            && self.allow_fallbacks.is_none()
874            && self.require_parameters.is_none()
875            && self.data_collection.is_none()
876            && self.zdr.is_none()
877            && self.enforce_distillable_text.is_none()
878            && self.quantizations.is_empty()
879            && self.sort.is_none()
880            && self.max_price.is_none()
881    }
882}
883
884/// OpenRouter provider data-retention preference.
885#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
886#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
887#[serde(rename_all = "snake_case")]
888pub enum OpenRouterDataCollection {
889    Allow,
890    Deny,
891}
892
893/// OpenRouter provider sort preference.
894#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
895#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
896#[serde(untagged)]
897pub enum OpenRouterProviderSort {
898    Simple(OpenRouterProviderSortBy),
899    Advanced(OpenRouterProviderSortOptions),
900}
901
902/// OpenRouter provider sorting dimension.
903#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
904#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
905#[serde(rename_all = "snake_case")]
906pub enum OpenRouterProviderSortBy {
907    Price,
908    Throughput,
909    Latency,
910}
911
912/// OpenRouter advanced provider sort options.
913#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
914#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
915pub struct OpenRouterProviderSortOptions {
916    pub by: OpenRouterProviderSortBy,
917    #[serde(default, skip_serializing_if = "Option::is_none")]
918    pub partition: Option<OpenRouterSortPartition>,
919}
920
921/// How OpenRouter sorts endpoints when multiple fallback models are present.
922#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
923#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
924#[serde(rename_all = "snake_case")]
925pub enum OpenRouterSortPartition {
926    Model,
927    None,
928}
929
930/// Maximum accepted OpenRouter provider pricing, expressed in dollars per
931/// million prompt/completion tokens or per request/image where supported.
932#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
933#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
934pub struct OpenRouterMaxPrice {
935    #[serde(default, skip_serializing_if = "Option::is_none")]
936    pub prompt: Option<f64>,
937    #[serde(default, skip_serializing_if = "Option::is_none")]
938    pub completion: Option<f64>,
939    #[serde(default, skip_serializing_if = "Option::is_none")]
940    pub request: Option<f64>,
941    #[serde(default, skip_serializing_if = "Option::is_none")]
942    pub image: Option<f64>,
943}
944
945/// OpenRouter web-search plugin configuration.
946///
947/// Instructs OpenRouter to retrieve and inject web search results before the
948/// model sees the prompt. Only sent when the resolved provider type is
949/// OpenRouter.
950#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
951#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
952pub struct OpenRouterWebSearchPlugin {
953    /// Maximum number of search results to include.
954    #[serde(default, skip_serializing_if = "Option::is_none")]
955    pub max_results: Option<u32>,
956    /// Custom search prompt hint passed to the web-search step.
957    #[serde(default, skip_serializing_if = "Option::is_none")]
958    pub search_prompt: Option<String>,
959}
960
961/// OpenRouter file-reader plugin configuration.
962///
963/// Instructs OpenRouter to read and attach file contents before the model
964/// sees the prompt. Only sent when the resolved provider type is OpenRouter.
965#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
966#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
967pub struct OpenRouterFilePlugin {}
968
969/// OpenRouter plugin configuration bundling optional plugin activations.
970///
971/// Any `None` plugin is omitted from the wire request. When all plugins are
972/// `None`, no `plugins` field is emitted.
973#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
974#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
975pub struct OpenRouterPluginConfig {
976    /// Web-search plugin.
977    #[serde(default, skip_serializing_if = "Option::is_none")]
978    pub web: Option<OpenRouterWebSearchPlugin>,
979    /// File-reader plugin.
980    #[serde(default, skip_serializing_if = "Option::is_none")]
981    pub file: Option<OpenRouterFilePlugin>,
982}
983
984impl OpenRouterPluginConfig {
985    pub fn is_empty(&self) -> bool {
986        self.web.is_none() && self.file.is_none()
987    }
988}
989
990/// Configuration for an LLM call
991#[derive(Debug, Clone)]
992pub struct LlmCallConfig {
993    pub model: String,
994    pub temperature: Option<f32>,
995    pub max_tokens: Option<u32>,
996    pub tools: Vec<ToolDefinition>,
997    /// Reasoning effort level (for models that support it: low, medium, high)
998    pub reasoning_effort: Option<String>,
999    /// Metadata to send with the API request for tracking and debugging.
1000    /// Keys and values are strings. Both OpenAI and Anthropic support metadata fields.
1001    /// Typically includes: session_id, agent_id, org_id, turn_id, exec_id.
1002    pub metadata: HashMap<String, String>,
1003    /// Previous response ID for stateful continuation (OpenAI Responses API).
1004    /// When set, the provider can skip re-encoding cached context.
1005    pub previous_response_id: Option<String>,
1006    /// Tool search configuration for deferred tool loading
1007    pub tool_search: Option<ToolSearchConfig>,
1008    /// Prompt caching configuration for provider-specific cache controls.
1009    pub prompt_cache: Option<PromptCacheConfig>,
1010    /// OpenRouter-only model fallback and provider routing controls.
1011    pub openrouter_routing: Option<OpenRouterRoutingConfig>,
1012}
1013
1014impl From<&RuntimeAgent> for LlmCallConfig {
1015    fn from(runtime_agent: &RuntimeAgent) -> Self {
1016        Self {
1017            model: runtime_agent.model.clone(),
1018            temperature: runtime_agent.temperature,
1019            max_tokens: runtime_agent.max_tokens,
1020            tools: runtime_agent.tools.clone(),
1021            reasoning_effort: None, // Set by ReasonAtom from user message controls
1022            metadata: HashMap::new(), // Set by ReasonAtom with session/agent context
1023            previous_response_id: None,
1024            tool_search: runtime_agent.tool_search.clone(),
1025            prompt_cache: runtime_agent.prompt_cache.clone(),
1026            openrouter_routing: None,
1027        }
1028    }
1029}
1030
1031/// Response from an LLM call (non-streaming)
1032#[derive(Debug, Clone)]
1033pub struct LlmResponse {
1034    pub text: String,
1035    /// Thinking content from extended thinking models (e.g., Claude with thinking enabled)
1036    pub thinking: Option<String>,
1037    /// Cryptographic signature for thinking content (Anthropic Claude)
1038    pub thinking_signature: Option<String>,
1039    pub tool_calls: Option<Vec<ToolCall>>,
1040    pub metadata: LlmCompletionMetadata,
1041}
1042
1043/// Builder for LlmCallConfig with fluent API
1044///
1045/// Use `from(&runtime_agent)` to start building from a RuntimeAgent, then chain
1046/// methods like `reasoning_effort()`, `temperature()`, etc. Call `build()`
1047/// to get the final config.
1048///
1049/// # Example
1050///
1051/// ```ignore
1052/// use everruns_core::llm::LlmCallConfigBuilder;
1053/// use everruns_core::runtime_agent::RuntimeAgent;
1054///
1055/// let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1056/// let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1057///     .reasoning_effort("high")
1058///     .temperature(0.7)
1059///     .build();
1060/// ```
1061pub struct LlmCallConfigBuilder {
1062    config: LlmCallConfig,
1063}
1064
1065impl LlmCallConfigBuilder {
1066    /// Start building from a RuntimeAgent
1067    pub fn from(runtime_agent: &RuntimeAgent) -> Self {
1068        Self {
1069            config: LlmCallConfig::from(runtime_agent),
1070        }
1071    }
1072
1073    /// Set reasoning effort level (for models that support it: low, medium, high)
1074    pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
1075        self.config.reasoning_effort = Some(effort.into());
1076        self
1077    }
1078
1079    /// Set the model
1080    pub fn model(mut self, model: impl Into<String>) -> Self {
1081        self.config.model = model.into();
1082        self
1083    }
1084
1085    /// Set temperature
1086    pub fn temperature(mut self, temp: f32) -> Self {
1087        self.config.temperature = Some(temp);
1088        self
1089    }
1090
1091    /// Set max tokens
1092    pub fn max_tokens(mut self, tokens: u32) -> Self {
1093        self.config.max_tokens = Some(tokens);
1094        self
1095    }
1096
1097    /// Set tools
1098    pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
1099        self.config.tools = tools;
1100        self
1101    }
1102
1103    /// Set metadata for API tracking
1104    ///
1105    /// This metadata is sent to the LLM provider for tracking and debugging.
1106    /// Typically includes session_id, agent_id, org_id, turn_id, exec_id.
1107    pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
1108        self.config.metadata = metadata;
1109        self
1110    }
1111
1112    /// Add a single metadata key-value pair
1113    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1114        self.config.metadata.insert(key.into(), value.into());
1115        self
1116    }
1117
1118    /// Set previous response ID for stateful continuation
1119    pub fn previous_response_id(mut self, id: Option<String>) -> Self {
1120        self.config.previous_response_id = id;
1121        self
1122    }
1123
1124    /// Set tool_search configuration
1125    pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
1126        self.config.tool_search = Some(config);
1127        self
1128    }
1129
1130    /// Set prompt caching configuration
1131    pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
1132        self.config.prompt_cache = Some(config);
1133        self
1134    }
1135
1136    /// Set OpenRouter model fallback and provider routing controls.
1137    pub fn openrouter_routing(mut self, config: OpenRouterRoutingConfig) -> Self {
1138        self.config.openrouter_routing = (!config.is_empty()).then_some(config);
1139        self
1140    }
1141
1142    /// Build the configuration
1143    pub fn build(self) -> LlmCallConfig {
1144        self.config
1145    }
1146}
1147
1148// ============================================================================
1149// Conversion from Message
1150// ============================================================================
1151
1152impl From<&crate::message::Message> for LlmMessage {
1153    /// Convert a Message to LlmMessage (text-only, images become placeholders)
1154    ///
1155    /// This conversion is suitable for messages without images or when image
1156    /// resolution is not available. For multimodal messages, use
1157    /// `LlmMessage::from_message_with_images()` instead.
1158    fn from(msg: &crate::message::Message) -> Self {
1159        let role = match msg.role {
1160            crate::message::MessageRole::System => LlmMessageRole::System,
1161            crate::message::MessageRole::User => LlmMessageRole::User,
1162            crate::message::MessageRole::Agent => LlmMessageRole::Assistant,
1163            crate::message::MessageRole::ToolResult => LlmMessageRole::Tool,
1164        };
1165
1166        // Convert tool calls from ContentPart format to ToolCall format
1167        let tool_calls: Vec<ToolCall> = msg
1168            .tool_calls()
1169            .into_iter()
1170            .map(|tc| ToolCall {
1171                id: tc.id.clone(),
1172                name: tc.name.clone(),
1173                arguments: tc.arguments.clone(),
1174            })
1175            .collect();
1176
1177        LlmMessage {
1178            role,
1179            content: LlmMessageContent::Text(msg.content_to_llm_string()),
1180            tool_calls: if tool_calls.is_empty() {
1181                None
1182            } else {
1183                Some(tool_calls)
1184            },
1185            tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1186            phase: msg.phase,
1187            thinking: msg.thinking.clone(),
1188            thinking_signature: msg.thinking_signature.clone(),
1189        }
1190    }
1191}
1192
1193// ============================================================================
1194// Message Conversion with Images
1195// ============================================================================
1196
1197use crate::traits::ResolvedImage;
1198use uuid::Uuid;
1199
1200impl LlmMessage {
1201    /// Convert a Message to LlmMessage with resolved images
1202    ///
1203    /// This method handles multimodal messages by converting:
1204    /// - `text` content parts → `LlmContentPart::Text`
1205    /// - `image` content parts → `LlmContentPart::Image` (data URL)
1206    /// - `image_file` content parts → `LlmContentPart::Image` (resolved to data URL)
1207    /// - `tool_call` content parts → extracted to `tool_calls` field
1208    /// - `tool_result` content parts → text representation
1209    ///
1210    /// # Provider-specific formatting
1211    ///
1212    /// The `LlmContentPart::Image` uses data URLs which are converted by each provider:
1213    /// - **OpenAI**: `{ "type": "image_url", "image_url": { "url": "data:..." } }`
1214    /// - **Anthropic**: `{ "type": "image", "source": { "type": "base64", ... } }`
1215    ///
1216    /// # Arguments
1217    ///
1218    /// * `msg` - The message to convert
1219    /// * `resolved_images` - Pre-resolved images keyed by image_id
1220    pub fn from_message_with_images(
1221        msg: &crate::message::Message,
1222        resolved_images: &HashMap<Uuid, ResolvedImage>,
1223    ) -> Self {
1224        use crate::message::{ContentPart, MessageRole};
1225
1226        let role = match msg.role {
1227            MessageRole::System => LlmMessageRole::System,
1228            MessageRole::User => LlmMessageRole::User,
1229            MessageRole::Agent => LlmMessageRole::Assistant,
1230            MessageRole::ToolResult => LlmMessageRole::Tool,
1231        };
1232
1233        // Convert content parts to LlmContentParts
1234        let mut parts: Vec<LlmContentPart> = Vec::new();
1235        let mut tool_calls: Vec<ToolCall> = Vec::new();
1236
1237        for part in &msg.content {
1238            match part {
1239                ContentPart::Text(t) => {
1240                    parts.push(LlmContentPart::Text {
1241                        text: t.text.clone(),
1242                    });
1243                }
1244                ContentPart::Image(img) => {
1245                    // Convert inline image to data URL
1246                    if let Some(url) = &img.url {
1247                        parts.push(LlmContentPart::Image { url: url.clone() });
1248                    } else if let (Some(base64), Some(media_type)) = (&img.base64, &img.media_type)
1249                    {
1250                        let data_url = format!("data:{};base64,{}", media_type, base64);
1251                        parts.push(LlmContentPart::Image { url: data_url });
1252                    }
1253                }
1254                ContentPart::ImageFile(img_file) => {
1255                    // Resolve image_file to actual image data
1256                    if let Some(resolved) = resolved_images.get(&img_file.image_id.uuid()) {
1257                        parts.push(LlmContentPart::Image {
1258                            url: resolved.to_data_url(),
1259                        });
1260                    } else {
1261                        // Image not found - add placeholder text
1262                        parts.push(LlmContentPart::Text {
1263                            text: format!("[Image not found: {}]", img_file.image_id),
1264                        });
1265                    }
1266                }
1267                ContentPart::ToolCall(tc) => {
1268                    // Extract tool calls to separate field (don't include in content)
1269                    tool_calls.push(ToolCall {
1270                        id: tc.id.clone(),
1271                        name: tc.name.clone(),
1272                        arguments: tc.arguments.clone(),
1273                    });
1274                }
1275                ContentPart::ToolResult(tr) => {
1276                    // Convert tool result to text representation
1277                    let text = if let Some(err) = &tr.error {
1278                        format!("Tool error: {}", err)
1279                    } else if let Some(res) = &tr.result {
1280                        serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1281                    } else {
1282                        "{}".to_string()
1283                    };
1284                    // Primary hard limit enforced by OutputHardLimitHook (EVE-225)
1285                    // at tool execution time. This backstop catches tool results
1286                    // that bypass ActAtom hooks (client-submitted, stored events).
1287                    let text = truncate_tool_result(text);
1288                    parts.push(LlmContentPart::Text { text });
1289                }
1290            }
1291        }
1292
1293        // Determine content format
1294        let content = if parts.len() == 1 && matches!(&parts[0], LlmContentPart::Text { .. }) {
1295            // Single text part - use simple Text format
1296            if let LlmContentPart::Text { text } = &parts[0] {
1297                LlmMessageContent::Text(text.clone())
1298            } else {
1299                LlmMessageContent::Parts(parts)
1300            }
1301        } else if parts.is_empty() {
1302            // No content parts - use empty text
1303            LlmMessageContent::Text(String::new())
1304        } else {
1305            // Multiple parts or non-text - use Parts format
1306            LlmMessageContent::Parts(parts)
1307        };
1308
1309        LlmMessage {
1310            role,
1311            content,
1312            tool_calls: if tool_calls.is_empty() {
1313                None
1314            } else {
1315                Some(tool_calls)
1316            },
1317            tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1318            phase: msg.phase,
1319            thinking: msg.thinking.clone(),
1320            thinking_signature: msg.thinking_signature.clone(),
1321        }
1322    }
1323
1324    /// Check if a message contains image_file references that need resolution
1325    pub fn message_has_image_files(msg: &crate::message::Message) -> bool {
1326        msg.content.iter().any(|p| p.is_image_file())
1327    }
1328
1329    /// Extract all image_file IDs from a message
1330    pub fn extract_image_file_ids(msg: &crate::message::Message) -> Vec<Uuid> {
1331        msg.content
1332            .iter()
1333            .filter_map(|p| match p {
1334                crate::message::ContentPart::ImageFile(f) => Some(f.image_id.uuid()),
1335                _ => None,
1336            })
1337            .collect()
1338    }
1339}
1340
1341// ============================================================================
1342// Driver Factory Types
1343// ============================================================================
1344
1345pub use crate::provider::DriverId;
1346
1347/// Extra provider-specific authentication/metadata beyond an API key.
1348///
1349/// Built-in providers ignore this; embedder-defined ([`DriverId::External`])
1350/// providers use it to carry OAuth tokens, account ids, or arbitrary extras
1351/// their driver factory needs.
1352#[derive(Debug, Clone, Default, PartialEq, Eq)]
1353pub struct ProviderMetadata {
1354    /// OAuth refresh token, when the provider authenticates via OAuth.
1355    pub refresh_token: Option<String>,
1356    /// Provider-side account identifier, when required.
1357    pub account_id: Option<String>,
1358    /// Arbitrary extra fields the driver factory understands.
1359    pub extra: Option<serde_json::Value>,
1360}
1361
1362/// Configuration for creating an LLM provider
1363#[derive(Debug, Clone)]
1364pub struct ProviderConfig {
1365    /// Type of provider
1366    pub provider_type: DriverId,
1367    /// API key for authentication
1368    pub api_key: Option<String>,
1369    /// Base URL override (optional)
1370    pub base_url: Option<String>,
1371    /// Extra provider-specific metadata (OAuth tokens, account ids, etc.).
1372    pub metadata: ProviderMetadata,
1373}
1374
1375impl ProviderConfig {
1376    /// Create a new provider config
1377    pub fn new(provider_type: DriverId) -> Self {
1378        Self {
1379            provider_type,
1380            api_key: None,
1381            base_url: None,
1382            metadata: ProviderMetadata::default(),
1383        }
1384    }
1385
1386    /// Set the API key
1387    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
1388        self.api_key = Some(api_key.into());
1389        self
1390    }
1391
1392    /// Set the base URL
1393    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
1394        self.base_url = Some(base_url.into());
1395        self
1396    }
1397
1398    /// Set provider-specific metadata.
1399    pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self {
1400        self.metadata = metadata;
1401        self
1402    }
1403}
1404
1405/// Everything a [`DriverFactory`] receives to build a driver instance.
1406///
1407/// Replaces the old `(api_key, base_url)` factory arguments so that
1408/// embedder-defined providers can receive richer auth via [`ProviderMetadata`]
1409/// without changing the factory signature again.
1410#[derive(Debug, Clone)]
1411pub struct DriverConfig {
1412    /// Provider type being created.
1413    pub provider_type: DriverId,
1414    /// API key, when one is configured. `None` for keyless providers (LlmSim,
1415    /// or external providers that authenticate via [`ProviderMetadata`]).
1416    pub api_key: Option<String>,
1417    /// Base URL override, when configured.
1418    pub base_url: Option<String>,
1419    /// Extra provider-specific metadata.
1420    pub metadata: ProviderMetadata,
1421}
1422
1423impl From<&crate::traits::ResolvedModel> for ProviderConfig {
1424    fn from(model: &crate::traits::ResolvedModel) -> Self {
1425        Self {
1426            provider_type: model.provider_type.clone(),
1427            api_key: model.api_key.clone(),
1428            base_url: model.base_url.clone(),
1429            metadata: model.provider_metadata.clone().unwrap_or_default(),
1430        }
1431    }
1432}
1433
1434/// Boxed chat driver for dynamic dispatch
1435pub type BoxedChatDriver = Box<dyn ChatDriver>;
1436
1437// ============================================================================
1438// EmbeddingsDriver Trait
1439// ============================================================================
1440
1441/// Request to embed a batch of text strings into dense vectors.
1442#[derive(Debug, Clone)]
1443pub struct EmbedRequest {
1444    /// Texts to embed. All texts in a batch share the same model.
1445    pub texts: Vec<String>,
1446    /// Provider-side model id (e.g. `text-embedding-3-small`).
1447    pub model: String,
1448}
1449
1450/// Response from an embedding request.
1451#[derive(Debug, Clone)]
1452pub struct EmbedResponse {
1453    /// One float vector per input text, in the same order.
1454    pub embeddings: Vec<Vec<f32>>,
1455    /// Total tokens consumed (for usage tracking). `None` if the provider
1456    /// does not report token counts.
1457    pub usage_tokens: Option<u32>,
1458}
1459
1460/// Error returned by [`EmbeddingsDriver::embed`].
1461#[derive(Debug, thiserror::Error)]
1462pub enum EmbeddingsDriverError {
1463    #[error("embeddings provider returned an error: {0}")]
1464    Provider(String),
1465    #[error("embeddings request failed: {0}")]
1466    Transport(String),
1467}
1468
1469/// Driver trait for text embedding services.
1470///
1471/// Implementors call their provider's embedding API and return dense float
1472/// vectors. Used by knowledge-base hybrid retrieval (see specs/knowledge-bases.md
1473/// and specs/providers.md phase 6).
1474#[async_trait]
1475pub trait EmbeddingsDriver: Send + Sync {
1476    /// Embed a batch of texts and return one vector per input.
1477    async fn embed(
1478        &self,
1479        request: EmbedRequest,
1480    ) -> std::result::Result<EmbedResponse, EmbeddingsDriverError>;
1481}
1482
1483/// Boxed embeddings driver for dynamic dispatch.
1484pub type BoxedEmbeddingsDriver = Box<dyn EmbeddingsDriver>;
1485
1486/// Factory function type for creating embeddings drivers.
1487pub type EmbeddingsDriverFactory =
1488    Arc<dyn Fn(&DriverConfig) -> BoxedEmbeddingsDriver + Send + Sync>;
1489
1490// ============================================================================
1491// Driver Registry
1492// ============================================================================
1493
1494/// Factory function type for creating chat drivers.
1495///
1496/// Receives a [`DriverConfig`] (provider type, optional key/base URL, and
1497/// provider metadata) and returns a boxed driver.
1498pub type DriverFactory = Arc<dyn Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync>;
1499
1500/// A typed service a provider driver can offer (see specs/providers.md).
1501///
1502/// Declared in code by each driver, never stored in the database. Only `Chat`
1503/// has a driver trait today; the set is additive and new kinds gain factories
1504/// on [`DriverDescriptor`] when their first consumer lands.
1505#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1506#[serde(rename_all = "snake_case")]
1507pub enum ServiceKind {
1508    /// Chat completion ([`ChatDriver`]).
1509    Chat,
1510    /// Text embeddings (planned: knowledge-base hybrid retrieval).
1511    Embeddings,
1512    /// Realtime voice sessions (server-side adapter using provider credentials).
1513    Realtime,
1514    /// Image generation.
1515    Images,
1516    /// Search-result reranking.
1517    Rerank,
1518}
1519
1520impl std::fmt::Display for ServiceKind {
1521    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1522        let s = match self {
1523            ServiceKind::Chat => "chat",
1524            ServiceKind::Embeddings => "embeddings",
1525            ServiceKind::Realtime => "realtime",
1526            ServiceKind::Images => "images",
1527            ServiceKind::Rerank => "rerank",
1528        };
1529        f.write_str(s)
1530    }
1531}
1532
1533/// A registered provider driver: identity, declared services, the credential
1534/// shape its providers must supply, and per-service factories.
1535///
1536/// The descriptor is the code-side unit of the providers domain model
1537/// (specs/providers.md): one descriptor per driver id, instantiated as many
1538/// org-scoped providers.
1539#[derive(Clone)]
1540pub struct DriverDescriptor {
1541    /// Driver id (also the registry key).
1542    pub id: DriverId,
1543    /// Human-readable driver name (e.g. "OpenAI", "AWS Bedrock").
1544    pub display_name: String,
1545    /// Services this driver's providers can power. Declared, not stored.
1546    pub services: Vec<ServiceKind>,
1547    /// Credential fields a provider instance must supply.
1548    pub credential_schema: CredentialFormSchema,
1549    /// Chat service factory. `None` for drivers that only offer other services.
1550    pub chat: Option<DriverFactory>,
1551    /// Embeddings service factory. `None` for drivers that do not support embeddings.
1552    pub embeddings: Option<EmbeddingsDriverFactory>,
1553}
1554
1555impl DriverDescriptor {
1556    /// Descriptor for a chat-only driver with the default credential schema
1557    /// for the driver id (a single required `api_key` field for real
1558    /// providers; empty for `LlmSim` and `External`, which may authenticate
1559    /// via [`ProviderMetadata`]) and a display name derived from the id.
1560    pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1561    where
1562        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1563    {
1564        let id = id.into();
1565        Self {
1566            display_name: default_display_name(&id),
1567            credential_schema: default_credential_schema(&id),
1568            services: vec![ServiceKind::Chat],
1569            chat: Some(Arc::new(factory)),
1570            embeddings: None,
1571            id,
1572        }
1573    }
1574
1575    /// Whether the driver declares the given service.
1576    pub fn supports(&self, service: ServiceKind) -> bool {
1577        self.services.contains(&service)
1578    }
1579}
1580
1581impl std::fmt::Debug for DriverDescriptor {
1582    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1583        f.debug_struct("DriverDescriptor")
1584            .field("id", &self.id)
1585            .field("display_name", &self.display_name)
1586            .field("services", &self.services)
1587            .field("chat", &self.chat.is_some())
1588            .field("embeddings", &self.embeddings.is_some())
1589            .finish()
1590    }
1591}
1592
1593fn default_display_name(id: &DriverId) -> String {
1594    match id {
1595        DriverId::OpenAI => "OpenAI".to_string(),
1596        DriverId::OpenRouter => "OpenRouter".to_string(),
1597        DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
1598        DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
1599        DriverId::Anthropic => "Anthropic".to_string(),
1600        DriverId::Gemini => "Google Gemini".to_string(),
1601        DriverId::Bedrock => "AWS Bedrock".to_string(),
1602        DriverId::LlmSim => "LLM Simulator".to_string(),
1603        DriverId::External(id) => id.to_string(),
1604    }
1605}
1606
1607fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
1608    match id {
1609        // Keyless: simulator always; external drivers may auth via metadata.
1610        DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
1611        _ => CredentialFormSchema::api_key(String::new()),
1612    }
1613}
1614
1615/// Registry for LLM drivers
1616///
1617/// Enables dependency inversion: provider crates (everruns-anthropic, everruns-openai)
1618/// register their drivers at startup. The core has no direct knowledge of implementations.
1619///
1620/// # Example
1621///
1622/// ```ignore
1623/// use everruns_core::{DriverRegistry, DriverId};
1624/// use everruns_anthropic::register_driver;
1625/// use everruns_openai::register_driver as register_openai;
1626///
1627/// let mut registry = DriverRegistry::new();
1628/// everruns_anthropic::register_driver(&mut registry);
1629/// everruns_openai::register_driver(&mut registry);
1630///
1631/// // Later, create a driver from config
1632/// let driver = registry.create_chat_driver(&config)?;
1633/// ```
1634#[derive(Clone, Default)]
1635pub struct DriverRegistry {
1636    descriptors: HashMap<DriverId, DriverDescriptor>,
1637}
1638
1639impl DriverRegistry {
1640    /// Create a new empty registry
1641    pub fn new() -> Self {
1642        Self {
1643            descriptors: HashMap::new(),
1644        }
1645    }
1646
1647    /// Register a full driver descriptor.
1648    ///
1649    /// Panics if a descriptor is already registered for the same driver id —
1650    /// silent overwrites hide double-registration bugs. Use
1651    /// [`Self::register_descriptor_or_replace`] to overwrite intentionally.
1652    pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
1653        if self.descriptors.contains_key(&descriptor.id) {
1654            panic!(
1655                "driver already registered for provider '{}'; \
1656                 use register_descriptor_or_replace to overwrite intentionally",
1657                descriptor.id
1658            );
1659        }
1660        self.descriptors.insert(descriptor.id.clone(), descriptor);
1661    }
1662
1663    /// Register a full driver descriptor, replacing any existing one.
1664    pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
1665        self.descriptors.insert(descriptor.id.clone(), descriptor);
1666    }
1667
1668    /// Register a driver factory for a provider type.
1669    ///
1670    /// Panics if a factory is already registered for `provider_type` — silent
1671    /// overwrites hide double-registration bugs. Use
1672    /// [`Self::register_or_replace`] to overwrite intentionally.
1673    pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1674    where
1675        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1676    {
1677        self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
1678    }
1679
1680    /// Register a driver factory, replacing any existing one for the provider.
1681    ///
1682    /// Use when overwriting is intentional (e.g. swapping in an `LlmSim` driver
1683    /// for tests). Prefer [`Self::register`] otherwise so duplicates surface.
1684    pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1685    where
1686        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1687    {
1688        self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
1689    }
1690
1691    /// Register a driver factory for an embedder-defined external provider,
1692    /// keyed by its canonical id. The id is normalized to lowercase (via
1693    /// [`DriverId::external`]) so it matches parsed lookups regardless of
1694    /// the casing stored in the database or sent on the wire.
1695    pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
1696    where
1697        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1698    {
1699        self.register(DriverId::external(id), factory);
1700    }
1701
1702    /// Create an LLM driver based on configuration
1703    ///
1704    /// API keys must be provided in the config for real providers. This function does NOT fall back to
1705    /// environment variables. Keys should be decrypted from the database and passed here.
1706    /// Exception: `LlmSim` and `External` providers do not require an API key
1707    /// (external providers may authenticate via [`ProviderMetadata`]).
1708    ///
1709    /// Returns `DriverNotRegistered` error if no driver is registered for the provider type.
1710    pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
1711        // API key is required for real built-in providers, but not for LlmSim
1712        // (testing) or External providers (which may use metadata-based auth).
1713        let requires_api_key = !matches!(
1714            config.provider_type,
1715            DriverId::LlmSim | DriverId::External(_)
1716        );
1717        if requires_api_key && config.api_key.is_none() {
1718            return Err(AgentLoopError::llm(
1719                "API key is required. Configure the API key in provider settings.",
1720            ));
1721        }
1722
1723        // Look up the descriptor and its chat factory for this provider type
1724        let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
1725            AgentLoopError::driver_not_registered(config.provider_type.to_string())
1726        })?;
1727        let factory = descriptor.chat.as_ref().ok_or_else(|| {
1728            AgentLoopError::llm(format!(
1729                "Provider driver '{}' does not implement the chat service.",
1730                config.provider_type
1731            ))
1732        })?;
1733
1734        // Create the driver using the factory
1735        let driver_config = DriverConfig {
1736            provider_type: config.provider_type.clone(),
1737            api_key: config.api_key.clone(),
1738            base_url: config.base_url.clone(),
1739            metadata: config.metadata.clone(),
1740        };
1741        Ok(factory(&driver_config))
1742    }
1743
1744    /// Check if a driver is registered for a provider type
1745    pub fn has_driver(&self, provider_type: &DriverId) -> bool {
1746        self.descriptors.contains_key(provider_type)
1747    }
1748
1749    /// Get the registered descriptor for a provider type.
1750    pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
1751        self.descriptors.get(provider_type)
1752    }
1753
1754    /// Whether the registered driver declares the given service.
1755    pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
1756        self.descriptors
1757            .get(provider_type)
1758            .is_some_and(|d| d.supports(service))
1759    }
1760
1761    /// Driver ids whose descriptors declare the given service.
1762    pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
1763        self.descriptors
1764            .values()
1765            .filter(|d| d.supports(service))
1766            .map(|d| d.id.clone())
1767            .collect()
1768    }
1769
1770    /// Get the list of registered provider types
1771    pub fn registered_providers(&self) -> Vec<DriverId> {
1772        self.descriptors.keys().cloned().collect()
1773    }
1774
1775    /// Create an embeddings driver based on configuration.
1776    ///
1777    /// API keys must be provided in the config for real providers. Exception:
1778    /// `LlmSim` and `External` providers do not require an API key.
1779    ///
1780    /// Returns an error if the driver is not registered or does not implement
1781    /// the embeddings service.
1782    pub fn create_embeddings_driver(
1783        &self,
1784        config: &ProviderConfig,
1785    ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
1786        let requires_api_key = !matches!(
1787            config.provider_type,
1788            DriverId::LlmSim | DriverId::External(_)
1789        );
1790        if requires_api_key && config.api_key.is_none() {
1791            return Err(EmbeddingsDriverError::Provider(
1792                "API key is required. Configure the API key in provider settings.".to_string(),
1793            ));
1794        }
1795        let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
1796            EmbeddingsDriverError::Provider(format!(
1797                "No driver registered for provider '{}'",
1798                config.provider_type
1799            ))
1800        })?;
1801        let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
1802            EmbeddingsDriverError::Provider(format!(
1803                "Provider driver '{}' does not implement the embeddings service.",
1804                config.provider_type
1805            ))
1806        })?;
1807        let driver_config = DriverConfig {
1808            provider_type: config.provider_type.clone(),
1809            api_key: config.api_key.clone(),
1810            base_url: config.base_url.clone(),
1811            metadata: config.metadata.clone(),
1812        };
1813        Ok(factory(&driver_config))
1814    }
1815}
1816
1817/// Maximum tool result size in bytes before truncation (64 KiB).
1818/// Defense-in-depth backstop for tool results that bypass ActAtom hooks
1819/// (e.g. client-submitted or stored events). The primary hard limit is
1820/// enforced by `OutputHardLimitHook` (EVE-225) at tool execution time.
1821const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
1822
1823const TRUNCATION_SUFFIX: &str =
1824    "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
1825
1826fn truncate_tool_result(text: String) -> String {
1827    if text.len() <= MAX_TOOL_RESULT_BYTES {
1828        return text;
1829    }
1830    let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
1831    let mut end = content_budget;
1832    while end > 0 && !text.is_char_boundary(end) {
1833        end -= 1;
1834    }
1835    let mut truncated = text[..end].to_string();
1836    truncated.push_str(TRUNCATION_SUFFIX);
1837    truncated
1838}
1839
1840// ============================================================================
1841// Tests
1842// ============================================================================
1843
1844#[cfg(test)]
1845mod tests {
1846    use super::*;
1847
1848    #[test]
1849    fn test_llm_call_config_builder_from_runtime_agent() {
1850        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1851        let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
1852
1853        assert_eq!(llm_config.model, "gpt-4o");
1854        assert!(llm_config.reasoning_effort.is_none());
1855        assert!(llm_config.temperature.is_none());
1856        assert!(llm_config.max_tokens.is_none());
1857        assert!(llm_config.tools.is_empty());
1858        assert!(llm_config.metadata.is_empty());
1859    }
1860
1861    #[test]
1862    fn test_llm_call_config_builder_with_metadata() {
1863        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1864        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1865            .with_metadata("session_id", "session_abc123")
1866            .with_metadata("agent_id", "agent_xyz789")
1867            .build();
1868
1869        assert_eq!(
1870            llm_config.metadata.get("session_id"),
1871            Some(&"session_abc123".to_string())
1872        );
1873        assert_eq!(
1874            llm_config.metadata.get("agent_id"),
1875            Some(&"agent_xyz789".to_string())
1876        );
1877    }
1878
1879    #[test]
1880    fn test_llm_call_config_builder_with_metadata_hashmap() {
1881        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1882        let mut metadata = HashMap::new();
1883        metadata.insert("key1".to_string(), "value1".to_string());
1884        metadata.insert("key2".to_string(), "value2".to_string());
1885
1886        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1887            .metadata(metadata)
1888            .build();
1889
1890        assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
1891        assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
1892    }
1893
1894    #[test]
1895    fn test_llm_call_config_builder_with_reasoning_effort() {
1896        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1897        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1898            .reasoning_effort("high")
1899            .build();
1900
1901        assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
1902    }
1903
1904    #[test]
1905    fn test_llm_call_config_builder_with_all_options() {
1906        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1907        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1908            .model("claude-3-opus")
1909            .reasoning_effort("medium")
1910            .temperature(0.7)
1911            .max_tokens(1000)
1912            .build();
1913
1914        assert_eq!(llm_config.model, "claude-3-opus");
1915        assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
1916        assert_eq!(llm_config.temperature, Some(0.7));
1917        assert_eq!(llm_config.max_tokens, Some(1000));
1918    }
1919
1920    #[test]
1921    fn test_llm_call_config_builder_with_openrouter_routing() {
1922        let runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
1923        let routing = OpenRouterRoutingConfig::fallback_models([
1924            "openai/gpt-5-mini",
1925            "anthropic/claude-sonnet-4.5",
1926        ]);
1927
1928        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1929            .openrouter_routing(routing.clone())
1930            .build();
1931
1932        assert_eq!(llm_config.openrouter_routing, Some(routing));
1933    }
1934
1935    #[test]
1936    fn test_openrouter_fallback_models_empty_is_empty() {
1937        let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
1938
1939        assert!(routing.is_empty());
1940        assert_eq!(routing.route, None);
1941    }
1942
1943    #[test]
1944    fn test_openrouter_routing_validates_primary_model() {
1945        let routing = OpenRouterRoutingConfig::fallback_models([
1946            "openai/gpt-5-mini",
1947            "anthropic/claude-sonnet-4.5",
1948        ]);
1949
1950        assert!(
1951            routing
1952                .validate_for_primary_model("openai/gpt-5-mini")
1953                .is_ok()
1954        );
1955        let err = routing
1956            .validate_for_primary_model("anthropic/claude-sonnet-4.5")
1957            .unwrap_err();
1958        assert!(err.contains("models[0]"));
1959    }
1960
1961    #[test]
1962    fn test_openrouter_routing_rejects_fallback_without_models() {
1963        let routing = OpenRouterRoutingConfig {
1964            route: Some(OpenRouterRoute::Fallback),
1965            ..Default::default()
1966        };
1967
1968        let err = routing
1969            .validate_for_primary_model("openai/gpt-5-mini")
1970            .unwrap_err();
1971        assert!(err.contains("requires at least one model"));
1972    }
1973
1974    #[test]
1975    fn test_openrouter_routing_serializes_request_fields() {
1976        let routing = OpenRouterRoutingConfig {
1977            models: vec![
1978                "openai/gpt-5-mini".to_string(),
1979                "anthropic/claude-sonnet-4.5".to_string(),
1980            ],
1981            route: Some(OpenRouterRoute::Fallback),
1982            provider: Some(OpenRouterProviderRouting {
1983                order: vec!["anthropic".to_string(), "openai".to_string()],
1984                allow_fallbacks: Some(false),
1985                require_parameters: Some(true),
1986                data_collection: Some(OpenRouterDataCollection::Deny),
1987                zdr: Some(true),
1988                sort: Some(OpenRouterProviderSort::Advanced(
1989                    OpenRouterProviderSortOptions {
1990                        by: OpenRouterProviderSortBy::Throughput,
1991                        partition: Some(OpenRouterSortPartition::None),
1992                    },
1993                )),
1994                max_price: Some(OpenRouterMaxPrice {
1995                    prompt: Some(1.0),
1996                    completion: Some(2.0),
1997                    ..Default::default()
1998                }),
1999                ..Default::default()
2000            }),
2001            ..Default::default()
2002        };
2003
2004        let json = serde_json::to_value(routing).unwrap();
2005
2006        assert_eq!(
2007            json,
2008            serde_json::json!({
2009                "models": [
2010                    "openai/gpt-5-mini",
2011                    "anthropic/claude-sonnet-4.5"
2012                ],
2013                "route": "fallback",
2014                "provider": {
2015                    "order": ["anthropic", "openai"],
2016                    "allow_fallbacks": false,
2017                    "require_parameters": true,
2018                    "data_collection": "deny",
2019                    "zdr": true,
2020                    "sort": {
2021                        "by": "throughput",
2022                        "partition": "none"
2023                    },
2024                    "max_price": {
2025                        "prompt": 1.0,
2026                        "completion": 2.0
2027                    }
2028                }
2029            })
2030        );
2031    }
2032
2033    #[test]
2034    fn test_provider_type_parsing() {
2035        assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2036        assert_eq!(
2037            "openrouter".parse::<DriverId>().unwrap(),
2038            DriverId::OpenRouter
2039        );
2040        assert_eq!(
2041            "openai_completions".parse::<DriverId>().unwrap(),
2042            DriverId::OpenAICompletions
2043        );
2044        assert_eq!(
2045            "azure_openai".parse::<DriverId>().unwrap(),
2046            DriverId::AzureOpenAI
2047        );
2048        assert_eq!(
2049            "anthropic".parse::<DriverId>().unwrap(),
2050            DriverId::Anthropic
2051        );
2052        assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2053        // Unknown ids parse to External rather than erroring.
2054        assert_eq!(
2055            "ollama".parse::<DriverId>().unwrap(),
2056            DriverId::external("ollama")
2057        );
2058        assert_eq!(
2059            "custom".parse::<DriverId>().unwrap(),
2060            DriverId::external("custom")
2061        );
2062    }
2063
2064    #[test]
2065    fn test_external_provider_id_is_case_insensitive() {
2066        // Built-in matching and external normalization are both case-folding,
2067        // so the same id in different casing resolves to one provider.
2068        assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2069        assert_eq!(
2070            "Ollama".parse::<DriverId>().unwrap(),
2071            "ollama".parse::<DriverId>().unwrap()
2072        );
2073        assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2074        // Registration and parsed lookup agree regardless of casing.
2075        assert_eq!(
2076            DriverId::external("MyProvider"),
2077            "myprovider".parse::<DriverId>().unwrap()
2078        );
2079    }
2080
2081    #[test]
2082    fn test_provider_type_display() {
2083        assert_eq!(DriverId::OpenAI.to_string(), "openai");
2084        assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2085        assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2086        assert_eq!(
2087            DriverId::OpenAICompletions.to_string(),
2088            "openai_completions"
2089        );
2090        assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2091        assert_eq!(DriverId::Gemini.to_string(), "gemini");
2092    }
2093
2094    #[test]
2095    fn test_provider_config_builder() {
2096        let config = ProviderConfig::new(DriverId::Anthropic)
2097            .with_api_key("test-key")
2098            .with_base_url("https://custom.api.com");
2099
2100        assert_eq!(config.provider_type, DriverId::Anthropic);
2101        assert_eq!(config.api_key, Some("test-key".to_string()));
2102        assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2103    }
2104
2105    #[test]
2106    fn test_driver_registry_requires_api_key() {
2107        // Register a mock factory
2108        let mut registry = DriverRegistry::new();
2109        registry.register(DriverId::OpenAI, |_config| {
2110            // Return a mock driver - just need something that compiles
2111            struct MockDriver;
2112            #[async_trait]
2113            impl ChatDriver for MockDriver {
2114                async fn chat_completion_stream(
2115                    &self,
2116                    _messages: Vec<LlmMessage>,
2117                    _config: &LlmCallConfig,
2118                ) -> Result<LlmResponseStream> {
2119                    unimplemented!()
2120                }
2121            }
2122            Box::new(MockDriver)
2123        });
2124
2125        // Driver without API key should fail
2126        let config = ProviderConfig::new(DriverId::OpenAI);
2127        let result = registry.create_chat_driver(&config);
2128        assert!(result.is_err());
2129
2130        // Driver with API key should succeed
2131        let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2132        let result = registry.create_chat_driver(&config_with_key);
2133        assert!(result.is_ok());
2134    }
2135
2136    #[test]
2137    fn test_driver_registry_returns_error_for_unregistered_provider() {
2138        let registry = DriverRegistry::new();
2139        let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2140
2141        let result = registry.create_chat_driver(&config);
2142
2143        // Should fail with DriverNotRegistered error
2144        if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2145            assert_eq!(provider, "anthropic");
2146        } else {
2147            panic!("Expected DriverNotRegistered error");
2148        }
2149    }
2150
2151    #[test]
2152    fn test_driver_registry_registration() {
2153        let mut registry = DriverRegistry::new();
2154
2155        assert!(!registry.has_driver(&DriverId::OpenAI));
2156        assert!(!registry.has_driver(&DriverId::Anthropic));
2157
2158        registry.register(DriverId::OpenAI, |_config| {
2159            struct MockDriver;
2160            #[async_trait]
2161            impl ChatDriver for MockDriver {
2162                async fn chat_completion_stream(
2163                    &self,
2164                    _messages: Vec<LlmMessage>,
2165                    _config: &LlmCallConfig,
2166                ) -> Result<LlmResponseStream> {
2167                    unimplemented!()
2168                }
2169            }
2170            Box::new(MockDriver)
2171        });
2172
2173        assert!(registry.has_driver(&DriverId::OpenAI));
2174        assert!(!registry.has_driver(&DriverId::Anthropic));
2175    }
2176
2177    #[test]
2178    fn test_register_external_and_create_driver_without_api_key() {
2179        struct MockDriver;
2180        #[async_trait]
2181        impl ChatDriver for MockDriver {
2182            async fn chat_completion_stream(
2183                &self,
2184                _messages: Vec<LlmMessage>,
2185                _config: &LlmCallConfig,
2186            ) -> Result<LlmResponseStream> {
2187                unimplemented!()
2188            }
2189        }
2190
2191        let mut registry = DriverRegistry::new();
2192        registry.register_external("openai-codex", |config| {
2193            // External providers may authenticate via metadata, not an api_key.
2194            assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2195            Box::new(MockDriver)
2196        });
2197
2198        assert!(registry.has_driver(&DriverId::external("openai-codex")));
2199
2200        // No api_key required for external providers.
2201        let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2202            ProviderMetadata {
2203                refresh_token: Some("rt".into()),
2204                ..Default::default()
2205            },
2206        );
2207        assert!(registry.create_chat_driver(&config).is_ok());
2208    }
2209
2210    #[test]
2211    fn test_register_defaults_to_chat_only_descriptor() {
2212        struct MockDriver;
2213        #[async_trait]
2214        impl ChatDriver for MockDriver {
2215            async fn chat_completion_stream(
2216                &self,
2217                _messages: Vec<LlmMessage>,
2218                _config: &LlmCallConfig,
2219            ) -> Result<LlmResponseStream> {
2220                unimplemented!()
2221            }
2222        }
2223
2224        let mut registry = DriverRegistry::new();
2225        registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2226
2227        let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2228        assert_eq!(descriptor.display_name, "Anthropic");
2229        assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2230        assert!(descriptor.chat.is_some());
2231        // Default credential shape is a single required api_key field.
2232        assert_eq!(descriptor.credential_schema.fields.len(), 1);
2233        assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2234        assert!(descriptor.credential_schema.fields[0].required);
2235
2236        // Keyless drivers default to an empty schema.
2237        registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2238        let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2239        assert!(sim.credential_schema.fields.is_empty());
2240    }
2241
2242    #[test]
2243    fn test_descriptor_services_and_lookup() {
2244        struct MockDriver;
2245        #[async_trait]
2246        impl ChatDriver for MockDriver {
2247            async fn chat_completion_stream(
2248                &self,
2249                _messages: Vec<LlmMessage>,
2250                _config: &LlmCallConfig,
2251            ) -> Result<LlmResponseStream> {
2252                unimplemented!()
2253            }
2254        }
2255
2256        let mut registry = DriverRegistry::new();
2257        registry.register_descriptor(DriverDescriptor {
2258            services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2259            ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2260        });
2261        registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2262
2263        assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2264        assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2265        assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2266        assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2267
2268        let realtime = registry.providers_for(ServiceKind::Realtime);
2269        assert_eq!(realtime, vec![DriverId::OpenAI]);
2270        let mut chat = registry.providers_for(ServiceKind::Chat);
2271        chat.sort_by_key(|p| p.to_string());
2272        assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2273    }
2274
2275    #[test]
2276    fn test_create_chat_driver_fails_without_chat_factory() {
2277        let mut registry = DriverRegistry::new();
2278        registry.register_descriptor(DriverDescriptor {
2279            id: DriverId::external("embeddings-only"),
2280            display_name: "Embeddings Only".to_string(),
2281            services: vec![ServiceKind::Embeddings],
2282            credential_schema: CredentialFormSchema::empty(),
2283            chat: None,
2284            embeddings: None,
2285        });
2286
2287        let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2288        let err = match registry.create_chat_driver(&config) {
2289            Ok(_) => panic!("expected error for missing chat factory"),
2290            Err(err) => err,
2291        };
2292        assert!(
2293            err.to_string()
2294                .contains("does not implement the chat service"),
2295            "unexpected error: {err}"
2296        );
2297    }
2298
2299    #[test]
2300    #[should_panic(expected = "already registered")]
2301    fn test_register_duplicate_panics() {
2302        struct MockDriver;
2303        #[async_trait]
2304        impl ChatDriver for MockDriver {
2305            async fn chat_completion_stream(
2306                &self,
2307                _messages: Vec<LlmMessage>,
2308                _config: &LlmCallConfig,
2309            ) -> Result<LlmResponseStream> {
2310                unimplemented!()
2311            }
2312        }
2313
2314        let mut registry = DriverRegistry::new();
2315        registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2316        // Second registration for the same provider must panic.
2317        registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2318    }
2319
2320    #[test]
2321    fn test_register_or_replace_overwrites() {
2322        struct MockDriver;
2323        #[async_trait]
2324        impl ChatDriver for MockDriver {
2325            async fn chat_completion_stream(
2326                &self,
2327                _messages: Vec<LlmMessage>,
2328                _config: &LlmCallConfig,
2329            ) -> Result<LlmResponseStream> {
2330                unimplemented!()
2331            }
2332        }
2333
2334        let mut registry = DriverRegistry::new();
2335        registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2336        // Replacing intentionally must not panic.
2337        registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2338        assert!(registry.has_driver(&DriverId::LlmSim));
2339    }
2340
2341    // ========================================================================
2342    // Image resolution tests
2343    // ========================================================================
2344
2345    use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
2346
2347    #[test]
2348    fn test_message_has_image_files_with_image_file() {
2349        let message = Message {
2350            id: uuid::Uuid::new_v4().into(),
2351            role: MessageRole::User,
2352            content: vec![
2353                ContentPart::Text(TextContentPart {
2354                    text: "Look at this image".to_string(),
2355                }),
2356                ContentPart::ImageFile(ImageFileContentPart {
2357                    image_id: uuid::Uuid::new_v4().into(),
2358                    filename: Some("test.png".to_string()),
2359                }),
2360            ],
2361            phase: None,
2362            thinking: None,
2363            thinking_signature: None,
2364            controls: None,
2365            metadata: None,
2366            external_actor: None,
2367            created_at: chrono::Utc::now(),
2368        };
2369
2370        assert!(LlmMessage::message_has_image_files(&message));
2371    }
2372
2373    #[test]
2374    fn test_message_has_image_files_without_image_file() {
2375        let message = Message {
2376            id: uuid::Uuid::new_v4().into(),
2377            role: MessageRole::User,
2378            content: vec![ContentPart::Text(TextContentPart {
2379                text: "Just text".to_string(),
2380            })],
2381            phase: None,
2382            thinking: None,
2383            thinking_signature: None,
2384            controls: None,
2385            metadata: None,
2386            external_actor: None,
2387            created_at: chrono::Utc::now(),
2388        };
2389
2390        assert!(!LlmMessage::message_has_image_files(&message));
2391    }
2392
2393    #[test]
2394    fn test_extract_image_file_ids() {
2395        let id1 = uuid::Uuid::new_v4();
2396        let id2 = uuid::Uuid::new_v4();
2397
2398        let message = Message {
2399            id: uuid::Uuid::new_v4().into(),
2400            role: MessageRole::User,
2401            content: vec![
2402                ContentPart::Text(TextContentPart {
2403                    text: "Look at these images".to_string(),
2404                }),
2405                ContentPart::ImageFile(ImageFileContentPart {
2406                    image_id: id1.into(),
2407                    filename: Some("test1.png".to_string()),
2408                }),
2409                ContentPart::ImageFile(ImageFileContentPart {
2410                    image_id: id2.into(),
2411                    filename: Some("test2.png".to_string()),
2412                }),
2413            ],
2414            phase: None,
2415            thinking: None,
2416            thinking_signature: None,
2417            controls: None,
2418            metadata: None,
2419            external_actor: None,
2420            created_at: chrono::Utc::now(),
2421        };
2422
2423        let ids = LlmMessage::extract_image_file_ids(&message);
2424        assert_eq!(ids.len(), 2);
2425        assert!(ids.contains(&id1));
2426        assert!(ids.contains(&id2));
2427    }
2428
2429    #[test]
2430    fn test_from_message_with_images_text_only() {
2431        let message = Message {
2432            id: uuid::Uuid::new_v4().into(),
2433            role: MessageRole::User,
2434            content: vec![ContentPart::Text(TextContentPart {
2435                text: "Hello".to_string(),
2436            })],
2437            phase: None,
2438            thinking: None,
2439            thinking_signature: None,
2440            controls: None,
2441            metadata: None,
2442            external_actor: None,
2443            created_at: chrono::Utc::now(),
2444        };
2445
2446        let resolved = std::collections::HashMap::new();
2447        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2448
2449        assert_eq!(llm_message.role, LlmMessageRole::User);
2450        match llm_message.content {
2451            LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
2452            _ => panic!("Expected text content"),
2453        }
2454    }
2455
2456    #[test]
2457    fn test_from_message_with_images_resolved_image() {
2458        let image_id = uuid::Uuid::new_v4();
2459        let message = Message {
2460            id: uuid::Uuid::new_v4().into(),
2461            role: MessageRole::User,
2462            content: vec![
2463                ContentPart::Text(TextContentPart {
2464                    text: "Look at this".to_string(),
2465                }),
2466                ContentPart::ImageFile(ImageFileContentPart {
2467                    image_id: image_id.into(),
2468                    filename: Some("test.png".to_string()),
2469                }),
2470            ],
2471            phase: None,
2472            thinking: None,
2473            thinking_signature: None,
2474            controls: None,
2475            metadata: None,
2476            external_actor: None,
2477            created_at: chrono::Utc::now(),
2478        };
2479
2480        let mut resolved = std::collections::HashMap::new();
2481        resolved.insert(
2482            image_id,
2483            crate::ResolvedImage::new("base64data", "image/png"),
2484        );
2485
2486        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2487
2488        match &llm_message.content {
2489            LlmMessageContent::Parts(parts) => {
2490                assert_eq!(parts.len(), 2);
2491                // First part should be text
2492                assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
2493                // Second part should be resolved image
2494                if let LlmContentPart::Image { url } = &parts[1] {
2495                    assert!(url.starts_with("data:image/png;base64,"));
2496                } else {
2497                    panic!("Expected image content part");
2498                }
2499            }
2500            _ => panic!("Expected parts content"),
2501        }
2502    }
2503
2504    #[test]
2505    fn test_from_message_with_images_unresolved_image() {
2506        let image_id = uuid::Uuid::new_v4();
2507        let message = Message {
2508            id: uuid::Uuid::new_v4().into(),
2509            role: MessageRole::User,
2510            content: vec![ContentPart::ImageFile(ImageFileContentPart {
2511                image_id: image_id.into(),
2512                filename: Some("missing.png".to_string()),
2513            })],
2514            phase: None,
2515            thinking: None,
2516            thinking_signature: None,
2517            controls: None,
2518            metadata: None,
2519            external_actor: None,
2520            created_at: chrono::Utc::now(),
2521        };
2522
2523        // Empty resolved map - image not found
2524        let resolved = std::collections::HashMap::new();
2525        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2526
2527        // Should have placeholder text for missing image
2528        // When there's only one part, it may return Text directly instead of Parts
2529        match &llm_message.content {
2530            LlmMessageContent::Text(text) => {
2531                assert!(text.contains("Image not found"));
2532            }
2533            LlmMessageContent::Parts(parts) => {
2534                assert_eq!(parts.len(), 1);
2535                if let LlmContentPart::Text { text } = &parts[0] {
2536                    assert!(text.contains("Image not found"));
2537                } else {
2538                    panic!("Expected text placeholder for missing image");
2539                }
2540            }
2541        }
2542    }
2543
2544    #[test]
2545    fn test_prepend_text_prefix_simple_text() {
2546        let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
2547        msg.prepend_text_prefix("[Alice] ");
2548        assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
2549    }
2550
2551    #[test]
2552    fn test_prepend_text_prefix_parts() {
2553        let mut msg = LlmMessage::parts(
2554            LlmMessageRole::User,
2555            vec![
2556                LlmContentPart::Text {
2557                    text: "Hello".to_string(),
2558                },
2559                LlmContentPart::Image {
2560                    url: "data:image/png;base64,abc".to_string(),
2561                },
2562            ],
2563        );
2564        msg.prepend_text_prefix("[Bob] ");
2565        match &msg.content {
2566            LlmMessageContent::Parts(parts) => {
2567                if let LlmContentPart::Text { text } = &parts[0] {
2568                    assert_eq!(text, "[Bob] Hello");
2569                } else {
2570                    panic!("Expected text part");
2571                }
2572            }
2573            _ => panic!("Expected parts content"),
2574        }
2575    }
2576
2577    #[test]
2578    fn test_prepend_text_prefix_parts_no_text() {
2579        let mut msg = LlmMessage::parts(
2580            LlmMessageRole::User,
2581            vec![LlmContentPart::Image {
2582                url: "data:image/png;base64,abc".to_string(),
2583            }],
2584        );
2585        msg.prepend_text_prefix("[Eve] ");
2586        match &msg.content {
2587            LlmMessageContent::Parts(parts) => {
2588                assert_eq!(parts.len(), 2);
2589                if let LlmContentPart::Text { text } = &parts[0] {
2590                    assert_eq!(text, "[Eve] ");
2591                } else {
2592                    panic!("Expected prepended text part");
2593                }
2594            }
2595            _ => panic!("Expected parts content"),
2596        }
2597    }
2598
2599    #[test]
2600    fn test_openrouter_plugin_config_is_empty() {
2601        assert!(OpenRouterPluginConfig::default().is_empty());
2602        assert!(
2603            !OpenRouterPluginConfig {
2604                web: Some(OpenRouterWebSearchPlugin::default()),
2605                file: None,
2606            }
2607            .is_empty()
2608        );
2609        assert!(
2610            !OpenRouterPluginConfig {
2611                web: None,
2612                file: Some(OpenRouterFilePlugin {}),
2613            }
2614            .is_empty()
2615        );
2616    }
2617
2618    #[test]
2619    fn test_openrouter_routing_is_empty_with_plugins() {
2620        let with_plugins = OpenRouterRoutingConfig {
2621            plugins: Some(OpenRouterPluginConfig {
2622                web: Some(OpenRouterWebSearchPlugin::default()),
2623                file: None,
2624            }),
2625            ..Default::default()
2626        };
2627        assert!(!with_plugins.is_empty());
2628
2629        let empty_plugins = OpenRouterRoutingConfig {
2630            plugins: Some(OpenRouterPluginConfig::default()),
2631            ..Default::default()
2632        };
2633        assert!(empty_plugins.is_empty());
2634    }
2635
2636    #[test]
2637    fn test_openrouter_web_search_plugin_serialization() {
2638        let plugin = OpenRouterWebSearchPlugin {
2639            max_results: Some(10),
2640            search_prompt: Some("search for Rust crates".to_string()),
2641        };
2642        let json = serde_json::to_value(&plugin).unwrap();
2643        assert_eq!(json["max_results"], 10);
2644        assert_eq!(json["search_prompt"], "search for Rust crates");
2645    }
2646
2647    #[test]
2648    fn test_openrouter_web_search_plugin_omits_none_fields() {
2649        let plugin = OpenRouterWebSearchPlugin::default();
2650        let json = serde_json::to_value(&plugin).unwrap();
2651        assert!(json.get("max_results").is_none());
2652        assert!(json.get("search_prompt").is_none());
2653    }
2654
2655    #[test]
2656    fn test_capacity_strategy_shared_capacity_is_noop() {
2657        let base = OpenRouterRoutingConfig {
2658            models: vec!["openai/gpt-5-mini".to_string()],
2659            capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2660            ..Default::default()
2661        };
2662        let result = base.apply_capacity_strategy().unwrap();
2663        assert_eq!(
2664            result.capacity_strategy,
2665            Some(OpenRouterCapacityStrategy::SharedCapacity)
2666        );
2667        assert!(result.provider.is_none());
2668    }
2669
2670    #[test]
2671    fn test_capacity_strategy_none_is_noop() {
2672        let base = OpenRouterRoutingConfig {
2673            models: vec!["openai/gpt-5-mini".to_string()],
2674            capacity_strategy: None,
2675            ..Default::default()
2676        };
2677        let result = base.apply_capacity_strategy().unwrap();
2678        assert!(result.provider.is_none());
2679    }
2680
2681    #[test]
2682    fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
2683        let base = OpenRouterRoutingConfig {
2684            models: vec!["openai/gpt-5-mini".to_string()],
2685            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2686            ..Default::default()
2687        };
2688        let result = base.apply_capacity_strategy().unwrap();
2689        let provider = result.provider.as_ref().expect("provider set by ByokFirst");
2690        assert_eq!(provider.allow_fallbacks, Some(true));
2691    }
2692
2693    #[test]
2694    fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
2695        // If allow_fallbacks was already set explicitly, ByokFirst must not override it.
2696        let base = OpenRouterRoutingConfig {
2697            models: vec!["openai/gpt-5-mini".to_string()],
2698            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2699            provider: Some(OpenRouterProviderRouting {
2700                allow_fallbacks: Some(false),
2701                ..Default::default()
2702            }),
2703            ..Default::default()
2704        };
2705        let result = base.apply_capacity_strategy().unwrap();
2706        let provider = result.provider.as_ref().unwrap();
2707        assert_eq!(provider.allow_fallbacks, Some(false));
2708    }
2709
2710    #[test]
2711    fn test_capacity_strategy_byok_only_requires_provider_only() {
2712        let base = OpenRouterRoutingConfig {
2713            models: vec!["openai/gpt-5-mini".to_string()],
2714            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2715            ..Default::default()
2716        };
2717        let err = base.apply_capacity_strategy().unwrap_err();
2718        assert!(
2719            err.contains("provider.only"),
2720            "error should mention provider.only: {err}"
2721        );
2722    }
2723
2724    #[test]
2725    fn test_capacity_strategy_byok_only_disables_fallbacks() {
2726        let base = OpenRouterRoutingConfig {
2727            models: vec!["openai/gpt-5-mini".to_string()],
2728            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2729            provider: Some(OpenRouterProviderRouting {
2730                only: vec!["my-byok-provider".to_string()],
2731                ..Default::default()
2732            }),
2733            ..Default::default()
2734        };
2735        let result = base.apply_capacity_strategy().unwrap();
2736        let provider = result.provider.as_ref().unwrap();
2737        assert_eq!(provider.allow_fallbacks, Some(false));
2738        assert_eq!(provider.only, vec!["my-byok-provider"]);
2739    }
2740
2741    #[test]
2742    fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
2743        let with_strategy = OpenRouterRoutingConfig {
2744            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
2745            ..Default::default()
2746        };
2747        assert!(!with_strategy.is_empty());
2748
2749        let byok_first = OpenRouterRoutingConfig {
2750            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2751            ..Default::default()
2752        };
2753        assert!(!byok_first.is_empty());
2754
2755        let shared = OpenRouterRoutingConfig {
2756            capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2757            ..Default::default()
2758        };
2759        assert!(shared.is_empty());
2760    }
2761
2762    // -------------------------------------------------------------------------
2763    // OpenRouterRoutingPreset tests
2764    // -------------------------------------------------------------------------
2765
2766    #[test]
2767    fn test_preset_no_presets_is_noop() {
2768        let base = OpenRouterRoutingConfig {
2769            models: vec!["openai/gpt-5-mini".to_string()],
2770            ..Default::default()
2771        };
2772        let result = base.apply_presets().unwrap();
2773        assert_eq!(result, base);
2774    }
2775
2776    #[test]
2777    fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
2778        let base = OpenRouterRoutingConfig {
2779            presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
2780            ..Default::default()
2781        };
2782        let result = base.apply_presets().unwrap();
2783        assert!(result.presets.is_empty(), "presets cleared after apply");
2784        let provider = result.provider.expect("provider set by preset");
2785        assert_eq!(provider.require_parameters, Some(true));
2786        assert_eq!(
2787            provider.sort,
2788            Some(OpenRouterProviderSort::Simple(
2789                OpenRouterProviderSortBy::Price
2790            ))
2791        );
2792    }
2793
2794    #[test]
2795    fn test_preset_lowest_latency_review_sets_sort_throughput() {
2796        let base = OpenRouterRoutingConfig {
2797            presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
2798            ..Default::default()
2799        };
2800        let result = base.apply_presets().unwrap();
2801        let provider = result.provider.expect("provider set by preset");
2802        assert_eq!(
2803            provider.sort,
2804            Some(OpenRouterProviderSort::Simple(
2805                OpenRouterProviderSortBy::Throughput
2806            ))
2807        );
2808    }
2809
2810    #[test]
2811    fn test_preset_zdr_only_sets_zdr() {
2812        let base = OpenRouterRoutingConfig {
2813            presets: vec![OpenRouterRoutingPreset::ZdrOnly],
2814            ..Default::default()
2815        };
2816        let result = base.apply_presets().unwrap();
2817        let provider = result.provider.expect("provider set");
2818        assert_eq!(provider.zdr, Some(true));
2819    }
2820
2821    #[test]
2822    fn test_preset_byok_first_sets_allow_fallbacks() {
2823        let base = OpenRouterRoutingConfig {
2824            presets: vec![OpenRouterRoutingPreset::ByokFirst],
2825            ..Default::default()
2826        };
2827        let result = base.apply_presets().unwrap();
2828        let provider = result.provider.expect("provider set");
2829        assert_eq!(provider.allow_fallbacks, Some(true));
2830    }
2831
2832    #[test]
2833    fn test_preset_no_data_collection_sets_data_collection_deny() {
2834        let base = OpenRouterRoutingConfig {
2835            presets: vec![OpenRouterRoutingPreset::NoDataCollection],
2836            ..Default::default()
2837        };
2838        let result = base.apply_presets().unwrap();
2839        let provider = result.provider.expect("provider set");
2840        assert_eq!(
2841            provider.data_collection,
2842            Some(OpenRouterDataCollection::Deny)
2843        );
2844    }
2845
2846    #[test]
2847    fn test_preset_strict_json_sets_require_parameters() {
2848        let base = OpenRouterRoutingConfig {
2849            presets: vec![OpenRouterRoutingPreset::StrictJson],
2850            ..Default::default()
2851        };
2852        let result = base.apply_presets().unwrap();
2853        let provider = result.provider.expect("provider set");
2854        assert_eq!(provider.require_parameters, Some(true));
2855    }
2856
2857    #[test]
2858    fn test_preset_reasoning_required_sets_require_parameters() {
2859        let base = OpenRouterRoutingConfig {
2860            presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
2861            ..Default::default()
2862        };
2863        let result = base.apply_presets().unwrap();
2864        let provider = result.provider.expect("provider set");
2865        assert_eq!(provider.require_parameters, Some(true));
2866    }
2867
2868    #[test]
2869    fn test_preset_max_price_converts_usd_per_million() {
2870        let base = OpenRouterRoutingConfig {
2871            presets: vec![OpenRouterRoutingPreset::MaxPrice {
2872                prompt_usd_per_million: Some(5.0),
2873                completion_usd_per_million: Some(15.0),
2874            }],
2875            ..Default::default()
2876        };
2877        let result = base.apply_presets().unwrap();
2878        let provider = result.provider.expect("provider set");
2879        let max_price = provider.max_price.expect("max_price set");
2880        // 5.0 USD/M → 5.0 / 1_000_000 per token
2881        let prompt = max_price.prompt.expect("prompt set");
2882        assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
2883        let completion = max_price.completion.expect("completion set");
2884        assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
2885    }
2886
2887    #[test]
2888    fn test_preset_max_price_rejects_negative_values() {
2889        let base = OpenRouterRoutingConfig {
2890            presets: vec![OpenRouterRoutingPreset::MaxPrice {
2891                prompt_usd_per_million: Some(-1.0),
2892                completion_usd_per_million: None,
2893            }],
2894            ..Default::default()
2895        };
2896        let err = base.apply_presets().unwrap_err();
2897        assert!(
2898            err.contains("non-negative"),
2899            "error should mention non-negative: {err}"
2900        );
2901    }
2902
2903    #[test]
2904    fn test_preset_max_price_both_none_no_provider_field() {
2905        let base = OpenRouterRoutingConfig {
2906            presets: vec![OpenRouterRoutingPreset::MaxPrice {
2907                prompt_usd_per_million: None,
2908                completion_usd_per_million: None,
2909            }],
2910            ..Default::default()
2911        };
2912        let result = base.apply_presets().unwrap();
2913        assert!(
2914            result.provider.is_none(),
2915            "MaxPrice with no dimensions should not produce a provider field"
2916        );
2917    }
2918
2919    #[test]
2920    fn test_preset_explicit_provider_overrides_preset() {
2921        let base = OpenRouterRoutingConfig {
2922            presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
2923            provider: Some(OpenRouterProviderRouting {
2924                // Caller explicitly wants throughput sort, overriding Price preset
2925                sort: Some(OpenRouterProviderSort::Simple(
2926                    OpenRouterProviderSortBy::Throughput,
2927                )),
2928                ..Default::default()
2929            }),
2930            ..Default::default()
2931        };
2932        let result = base.apply_presets().unwrap();
2933        let provider = result.provider.expect("provider set");
2934        // Explicit sort wins
2935        assert_eq!(
2936            provider.sort,
2937            Some(OpenRouterProviderSort::Simple(
2938                OpenRouterProviderSortBy::Throughput
2939            ))
2940        );
2941        // But preset-derived require_parameters still set (not overridden by explicit)
2942        assert_eq!(provider.require_parameters, Some(true));
2943    }
2944
2945    #[test]
2946    fn test_preset_multiple_presets_combined() {
2947        let base = OpenRouterRoutingConfig {
2948            presets: vec![
2949                OpenRouterRoutingPreset::ZdrOnly,
2950                OpenRouterRoutingPreset::NoDataCollection,
2951                OpenRouterRoutingPreset::LowestLatencyReview,
2952            ],
2953            ..Default::default()
2954        };
2955        let result = base.apply_presets().unwrap();
2956        let provider = result.provider.expect("provider set");
2957        assert_eq!(provider.zdr, Some(true));
2958        assert_eq!(
2959            provider.data_collection,
2960            Some(OpenRouterDataCollection::Deny)
2961        );
2962        assert_eq!(
2963            provider.sort,
2964            Some(OpenRouterProviderSort::Simple(
2965                OpenRouterProviderSortBy::Throughput
2966            ))
2967        );
2968    }
2969
2970    #[test]
2971    fn test_preset_later_preset_overrides_sort() {
2972        let base = OpenRouterRoutingConfig {
2973            presets: vec![
2974                OpenRouterRoutingPreset::CheapestWithTools, // sets Price sort
2975                OpenRouterRoutingPreset::LowestLatencyReview, // overrides to Throughput
2976            ],
2977            ..Default::default()
2978        };
2979        let result = base.apply_presets().unwrap();
2980        let provider = result.provider.expect("provider set");
2981        // Later preset wins for sort
2982        assert_eq!(
2983            provider.sort,
2984            Some(OpenRouterProviderSort::Simple(
2985                OpenRouterProviderSortBy::Throughput
2986            ))
2987        );
2988        // require_parameters still set by CheapestWithTools
2989        assert_eq!(provider.require_parameters, Some(true));
2990    }
2991
2992    #[test]
2993    fn test_preset_non_empty_in_is_empty() {
2994        let with_preset = OpenRouterRoutingConfig {
2995            presets: vec![OpenRouterRoutingPreset::ZdrOnly],
2996            ..Default::default()
2997        };
2998        assert!(!with_preset.is_empty());
2999
3000        let without = OpenRouterRoutingConfig::default();
3001        assert!(without.is_empty());
3002    }
3003}