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