Skip to main content

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