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/// Wire flavor of a driver's interactive OAuth connect flow.
1687///
1688/// A driver may let an org admin connect a provider by authorizing in the
1689/// browser instead of pasting an API key. The flow always yields a long-lived
1690/// credential that lands in `providers.credentials_encrypted`, exactly like a
1691/// hand-entered key — so runtime resolution is unchanged and non-admin users
1692/// are unaffected (see specs/providers.md "OAuth provider connection").
1693///
1694/// Only OpenRouter's PKCE flavor exists today. Adding OAuth to another driver
1695/// means a new variant here (which the server matches on) plus a
1696/// [`DriverOAuthConfig`] on that driver's descriptor — never a parallel set of
1697/// endpoints.
1698#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1699pub enum DriverOAuthFlow {
1700    /// OpenRouter one-click PKCE
1701    /// (<https://openrouter.ai/docs/guides/overview/auth/oauth>): redirect the
1702    /// admin to `authorize_url?callback_url=..&code_challenge=..&code_challenge_method=S256`,
1703    /// then POST JSON `{code, code_verifier, code_challenge_method}` to
1704    /// `token_url`; the `key` field of the response is the user-controlled API
1705    /// key to store. No client registration or secret is required (public PKCE
1706    /// client).
1707    OpenRouterPkce,
1708}
1709
1710/// A driver's declared OAuth connect flow.
1711///
1712/// Presence of this on a [`DriverDescriptor`] is what makes "Connect with
1713/// {provider}" available; absence means credentials must be entered manually.
1714#[derive(Debug, Clone)]
1715pub struct DriverOAuthConfig {
1716    /// Authorization endpoint the admin's browser is redirected to.
1717    pub authorize_url: String,
1718    /// Endpoint that exchanges the returned authorization code for a credential.
1719    pub token_url: String,
1720    /// Wire flavor of the two steps above.
1721    pub flow: DriverOAuthFlow,
1722}
1723
1724impl DriverOAuthConfig {
1725    /// OpenRouter's one-click PKCE connect flow.
1726    pub fn openrouter() -> Self {
1727        Self {
1728            authorize_url: "https://openrouter.ai/auth".to_string(),
1729            token_url: "https://openrouter.ai/api/v1/auth/keys".to_string(),
1730            flow: DriverOAuthFlow::OpenRouterPkce,
1731        }
1732    }
1733}
1734
1735/// A registered provider driver: identity, declared services, the credential
1736/// shape its providers must supply, and per-service factories.
1737///
1738/// The descriptor is the code-side unit of the providers domain model
1739/// (specs/providers.md): one descriptor per driver id, instantiated as many
1740/// org-scoped providers.
1741#[derive(Clone)]
1742pub struct DriverDescriptor {
1743    /// Driver id (also the registry key).
1744    pub id: DriverId,
1745    /// Human-readable driver name (e.g. "OpenAI", "AWS Bedrock").
1746    pub display_name: String,
1747    /// Services this driver's providers can power. Declared, not stored.
1748    pub services: Vec<ServiceKind>,
1749    /// Credential fields a provider instance must supply.
1750    pub credential_schema: CredentialFormSchema,
1751    /// Optional interactive OAuth connect flow. `Some` makes "Connect with
1752    /// {provider}" available as an alternative to entering a key by hand.
1753    pub oauth: Option<DriverOAuthConfig>,
1754    /// Chat service factory. `None` for drivers that only offer other services.
1755    pub chat: Option<DriverFactory>,
1756    /// Embeddings service factory. `None` for drivers that do not support embeddings.
1757    pub embeddings: Option<EmbeddingsDriverFactory>,
1758}
1759
1760impl DriverDescriptor {
1761    /// Descriptor for a chat-only driver with the default credential schema
1762    /// for the driver id (a single required `api_key` field for real
1763    /// providers; empty for `LlmSim` and `External`, which may authenticate
1764    /// via [`ProviderMetadata`]) and a display name derived from the id.
1765    pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1766    where
1767        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1768    {
1769        let id = id.into();
1770        Self {
1771            display_name: default_display_name(&id),
1772            credential_schema: default_credential_schema(&id),
1773            services: vec![ServiceKind::Chat],
1774            oauth: None,
1775            chat: Some(Arc::new(factory)),
1776            embeddings: None,
1777            id,
1778        }
1779    }
1780
1781    /// Whether the driver declares the given service.
1782    pub fn supports(&self, service: ServiceKind) -> bool {
1783        self.services.contains(&service)
1784    }
1785}
1786
1787impl std::fmt::Debug for DriverDescriptor {
1788    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1789        f.debug_struct("DriverDescriptor")
1790            .field("id", &self.id)
1791            .field("display_name", &self.display_name)
1792            .field("services", &self.services)
1793            .field("oauth", &self.oauth.is_some())
1794            .field("chat", &self.chat.is_some())
1795            .field("embeddings", &self.embeddings.is_some())
1796            .finish()
1797    }
1798}
1799
1800fn default_display_name(id: &DriverId) -> String {
1801    match id {
1802        DriverId::OpenAI => "OpenAI".to_string(),
1803        DriverId::OpenRouter => "OpenRouter".to_string(),
1804        DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
1805        DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
1806        DriverId::Anthropic => "Anthropic".to_string(),
1807        DriverId::Gemini => "Google Gemini".to_string(),
1808        DriverId::Bedrock => "AWS Bedrock".to_string(),
1809        DriverId::Mai => "Microsoft MAI".to_string(),
1810        DriverId::Fireworks => "Fireworks AI".to_string(),
1811        DriverId::LlmSim => "LLM Simulator".to_string(),
1812        DriverId::External(id) => id.to_string(),
1813    }
1814}
1815
1816fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
1817    match id {
1818        // Keyless: simulator always; external drivers may auth via metadata.
1819        DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
1820        _ => CredentialFormSchema::api_key(String::new()),
1821    }
1822}
1823
1824/// Registry for LLM drivers
1825///
1826/// Enables dependency inversion: provider crates (everruns-anthropic, everruns-openai)
1827/// register their drivers at startup. The core has no direct knowledge of implementations.
1828///
1829/// # Example
1830///
1831/// ```ignore
1832/// use everruns_core::{DriverRegistry, DriverId};
1833/// use everruns_anthropic::register_driver;
1834/// use everruns_openai::register_driver as register_openai;
1835///
1836/// let mut registry = DriverRegistry::new();
1837/// everruns_anthropic::register_driver(&mut registry);
1838/// everruns_openai::register_driver(&mut registry);
1839///
1840/// // Later, create a driver from config
1841/// let driver = registry.create_chat_driver(&config)?;
1842/// ```
1843#[derive(Clone, Default)]
1844pub struct DriverRegistry {
1845    descriptors: HashMap<DriverId, DriverDescriptor>,
1846}
1847
1848impl DriverRegistry {
1849    /// Create a new empty registry
1850    pub fn new() -> Self {
1851        Self {
1852            descriptors: HashMap::new(),
1853        }
1854    }
1855
1856    /// Register a full driver descriptor.
1857    ///
1858    /// Panics if a descriptor is already registered for the same driver id —
1859    /// silent overwrites hide double-registration bugs. Use
1860    /// [`Self::register_descriptor_or_replace`] to overwrite intentionally.
1861    pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
1862        if self.descriptors.contains_key(&descriptor.id) {
1863            panic!(
1864                "driver already registered for provider '{}'; \
1865                 use register_descriptor_or_replace to overwrite intentionally",
1866                descriptor.id
1867            );
1868        }
1869        self.descriptors.insert(descriptor.id.clone(), descriptor);
1870    }
1871
1872    /// Register a full driver descriptor, replacing any existing one.
1873    pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
1874        self.descriptors.insert(descriptor.id.clone(), descriptor);
1875    }
1876
1877    /// Register a driver factory for a provider type.
1878    ///
1879    /// Panics if a factory is already registered for `provider_type` — silent
1880    /// overwrites hide double-registration bugs. Use
1881    /// [`Self::register_or_replace`] to overwrite intentionally.
1882    pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1883    where
1884        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1885    {
1886        self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
1887    }
1888
1889    /// Register a driver factory, replacing any existing one for the provider.
1890    ///
1891    /// Use when overwriting is intentional (e.g. swapping in an `LlmSim` driver
1892    /// for tests). Prefer [`Self::register`] otherwise so duplicates surface.
1893    pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1894    where
1895        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1896    {
1897        self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
1898    }
1899
1900    /// Register a driver factory for an embedder-defined external provider,
1901    /// keyed by its canonical id. The id is normalized to lowercase (via
1902    /// [`DriverId::external`]) so it matches parsed lookups regardless of
1903    /// the casing stored in the database or sent on the wire.
1904    pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
1905    where
1906        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1907    {
1908        self.register(DriverId::external(id), factory);
1909    }
1910
1911    /// Create an LLM driver based on configuration
1912    ///
1913    /// API keys must be provided in the config for real providers. This function does NOT fall back to
1914    /// environment variables. Keys should be decrypted from the database and passed here.
1915    /// Exception: `LlmSim` and `External` providers do not require an API key
1916    /// (external providers may authenticate via [`ProviderMetadata`]).
1917    ///
1918    /// Returns `DriverNotRegistered` error if no driver is registered for the provider type.
1919    pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
1920        // API key is required for real built-in providers, but not for LlmSim
1921        // (testing), External providers, or Mai (which may all authenticate via
1922        // metadata-based auth — Mai supports Entra ID OAuth without an api_key).
1923        let requires_api_key = !matches!(
1924            config.provider_type,
1925            DriverId::LlmSim | DriverId::External(_) | DriverId::Mai
1926        );
1927        if requires_api_key && config.api_key.is_none() {
1928            return Err(AgentLoopError::llm(
1929                "API key is required. Configure the API key in provider settings.",
1930            ));
1931        }
1932
1933        // Look up the descriptor and its chat factory for this provider type
1934        let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
1935            AgentLoopError::driver_not_registered(config.provider_type.to_string())
1936        })?;
1937        let factory = descriptor.chat.as_ref().ok_or_else(|| {
1938            AgentLoopError::llm(format!(
1939                "Provider driver '{}' does not implement the chat service.",
1940                config.provider_type
1941            ))
1942        })?;
1943
1944        // Create the driver using the factory
1945        let driver_config = DriverConfig {
1946            provider_type: config.provider_type.clone(),
1947            api_key: config.api_key.clone(),
1948            base_url: config.base_url.clone(),
1949            metadata: config.metadata.clone(),
1950        };
1951        Ok(factory(&driver_config))
1952    }
1953
1954    /// Check if a driver is registered for a provider type
1955    pub fn has_driver(&self, provider_type: &DriverId) -> bool {
1956        self.descriptors.contains_key(provider_type)
1957    }
1958
1959    /// Get the registered descriptor for a provider type.
1960    pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
1961        self.descriptors.get(provider_type)
1962    }
1963
1964    /// Whether the registered driver declares the given service.
1965    pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
1966        self.descriptors
1967            .get(provider_type)
1968            .is_some_and(|d| d.supports(service))
1969    }
1970
1971    /// Driver ids whose descriptors declare the given service.
1972    pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
1973        self.descriptors
1974            .values()
1975            .filter(|d| d.supports(service))
1976            .map(|d| d.id.clone())
1977            .collect()
1978    }
1979
1980    /// Get the list of registered provider types
1981    pub fn registered_providers(&self) -> Vec<DriverId> {
1982        self.descriptors.keys().cloned().collect()
1983    }
1984
1985    /// Create an embeddings driver based on configuration.
1986    ///
1987    /// API keys must be provided in the config for real providers. Exception:
1988    /// `LlmSim` and `External` providers do not require an API key.
1989    ///
1990    /// Returns an error if the driver is not registered or does not implement
1991    /// the embeddings service.
1992    pub fn create_embeddings_driver(
1993        &self,
1994        config: &ProviderConfig,
1995    ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
1996        let requires_api_key = !matches!(
1997            config.provider_type,
1998            DriverId::LlmSim | DriverId::External(_)
1999        );
2000        if requires_api_key && config.api_key.is_none() {
2001            return Err(EmbeddingsDriverError::Provider(
2002                "API key is required. Configure the API key in provider settings.".to_string(),
2003            ));
2004        }
2005        let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2006            EmbeddingsDriverError::Provider(format!(
2007                "No driver registered for provider '{}'",
2008                config.provider_type
2009            ))
2010        })?;
2011        let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
2012            EmbeddingsDriverError::Provider(format!(
2013                "Provider driver '{}' does not implement the embeddings service.",
2014                config.provider_type
2015            ))
2016        })?;
2017        let driver_config = DriverConfig {
2018            provider_type: config.provider_type.clone(),
2019            api_key: config.api_key.clone(),
2020            base_url: config.base_url.clone(),
2021            metadata: config.metadata.clone(),
2022        };
2023        Ok(factory(&driver_config))
2024    }
2025}
2026
2027/// Maximum tool result size in bytes before truncation (64 KiB).
2028/// Defense-in-depth backstop for tool results that bypass ActAtom hooks
2029/// (e.g. client-submitted or stored events). The primary hard limit is
2030/// enforced by `OutputHardLimitHook` (EVE-225) at tool execution time.
2031const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
2032
2033const TRUNCATION_SUFFIX: &str =
2034    "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
2035
2036fn truncate_tool_result(text: String) -> String {
2037    if text.len() <= MAX_TOOL_RESULT_BYTES {
2038        return text;
2039    }
2040    let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
2041    let mut end = content_budget;
2042    while end > 0 && !text.is_char_boundary(end) {
2043        end -= 1;
2044    }
2045    let mut truncated = text[..end].to_string();
2046    truncated.push_str(TRUNCATION_SUFFIX);
2047    truncated
2048}
2049
2050// ============================================================================
2051// Tests
2052// ============================================================================
2053
2054#[cfg(test)]
2055mod tests {
2056    use super::*;
2057
2058    #[test]
2059    fn test_fold_system_messages_none_when_absent() {
2060        let messages = vec![
2061            LlmMessage::text(LlmMessageRole::User, "hi"),
2062            LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2063        ];
2064        assert_eq!(fold_system_messages(&messages), None);
2065    }
2066
2067    #[test]
2068    fn test_fold_system_messages_single() {
2069        let messages = vec![
2070            LlmMessage::text(LlmMessageRole::System, "AGENT-PROMPT"),
2071            LlmMessage::text(LlmMessageRole::User, "hi"),
2072        ];
2073        assert_eq!(
2074            fold_system_messages(&messages),
2075            Some("AGENT-PROMPT".to_string())
2076        );
2077    }
2078
2079    #[test]
2080    fn test_fold_system_messages_accumulates_in_order() {
2081        // The agent system prompt plus a later notice/summary System message
2082        // (infinity_context / compaction) must both survive, in order — the
2083        // later one must not overwrite the real agent system prompt.
2084        let messages = vec![
2085            LlmMessage::text(LlmMessageRole::System, "A"),
2086            LlmMessage::text(LlmMessageRole::User, "hi"),
2087            LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2088            LlmMessage::text(LlmMessageRole::System, "B"),
2089        ];
2090        assert_eq!(fold_system_messages(&messages), Some("A\n\nB".to_string()));
2091    }
2092
2093    #[test]
2094    fn test_fold_system_messages_concatenates_parts() {
2095        let messages = vec![LlmMessage::parts(
2096            LlmMessageRole::System,
2097            vec![
2098                LlmContentPart::text("foo"),
2099                LlmContentPart::image("data:image/png;base64,xxx"),
2100                LlmContentPart::text("bar"),
2101            ],
2102        )];
2103        assert_eq!(fold_system_messages(&messages), Some("foobar".to_string()));
2104    }
2105
2106    #[test]
2107    fn test_llm_call_config_builder_from_runtime_agent() {
2108        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2109        let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
2110
2111        assert_eq!(llm_config.model, "gpt-4o");
2112        assert!(llm_config.reasoning_effort.is_none());
2113        assert!(llm_config.temperature.is_none());
2114        assert!(llm_config.max_tokens.is_none());
2115        assert!(llm_config.tools.is_empty());
2116        assert!(llm_config.metadata.is_empty());
2117        // No server tools configured on the agent → none on the call config.
2118        assert!(llm_config.openrouter_routing.is_none());
2119    }
2120
2121    #[test]
2122    fn runtime_agent_openrouter_routing_flows_into_call_config() {
2123        // Closes the assembly loop: a capability sets RuntimeAgent.openrouter_routing
2124        // (server tools), and the From<&RuntimeAgent> conversion the reason atom
2125        // uses must carry it through to the OpenRouter driver.
2126        let mut runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2127        runtime_agent.openrouter_routing = Some(OpenRouterRoutingConfig {
2128            server_tools: vec![OpenRouterServerTool::new(
2129                OpenRouterServerToolKind::WebSearch,
2130            )],
2131            ..Default::default()
2132        });
2133
2134        let llm_config = LlmCallConfig::from(&runtime_agent);
2135        let routing = llm_config
2136            .openrouter_routing
2137            .expect("server-tool routing survives into the call config");
2138        assert_eq!(routing.server_tools.len(), 1);
2139        assert_eq!(
2140            routing.server_tools[0].kind.wire_type(),
2141            "openrouter:web_search"
2142        );
2143    }
2144
2145    #[test]
2146    fn test_llm_call_config_builder_with_metadata() {
2147        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2148        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2149            .with_metadata("session_id", "session_abc123")
2150            .with_metadata("agent_id", "agent_xyz789")
2151            .build();
2152
2153        assert_eq!(
2154            llm_config.metadata.get("session_id"),
2155            Some(&"session_abc123".to_string())
2156        );
2157        assert_eq!(
2158            llm_config.metadata.get("agent_id"),
2159            Some(&"agent_xyz789".to_string())
2160        );
2161    }
2162
2163    #[test]
2164    fn test_llm_call_config_builder_with_metadata_hashmap() {
2165        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2166        let mut metadata = HashMap::new();
2167        metadata.insert("key1".to_string(), "value1".to_string());
2168        metadata.insert("key2".to_string(), "value2".to_string());
2169
2170        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2171            .metadata(metadata)
2172            .build();
2173
2174        assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
2175        assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
2176    }
2177
2178    #[test]
2179    fn test_llm_call_config_builder_with_reasoning_effort() {
2180        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2181        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2182            .reasoning_effort("high")
2183            .build();
2184
2185        assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
2186    }
2187
2188    #[test]
2189    fn test_llm_call_config_builder_with_all_options() {
2190        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2191        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2192            .model("claude-3-opus")
2193            .reasoning_effort("medium")
2194            .temperature(0.7)
2195            .max_tokens(1000)
2196            .build();
2197
2198        assert_eq!(llm_config.model, "claude-3-opus");
2199        assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
2200        assert_eq!(llm_config.temperature, Some(0.7));
2201        assert_eq!(llm_config.max_tokens, Some(1000));
2202    }
2203
2204    #[test]
2205    fn test_llm_call_config_builder_with_openrouter_routing() {
2206        let runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2207        let routing = OpenRouterRoutingConfig::fallback_models([
2208            "openai/gpt-5-mini",
2209            "anthropic/claude-sonnet-4.5",
2210        ]);
2211
2212        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2213            .openrouter_routing(routing.clone())
2214            .build();
2215
2216        assert_eq!(llm_config.openrouter_routing, Some(routing));
2217    }
2218
2219    #[test]
2220    fn test_openrouter_fallback_models_empty_is_empty() {
2221        let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
2222
2223        assert!(routing.is_empty());
2224        assert_eq!(routing.route, None);
2225    }
2226
2227    #[test]
2228    fn test_openrouter_routing_validates_primary_model() {
2229        let routing = OpenRouterRoutingConfig::fallback_models([
2230            "openai/gpt-5-mini",
2231            "anthropic/claude-sonnet-4.5",
2232        ]);
2233
2234        assert!(
2235            routing
2236                .validate_for_primary_model("openai/gpt-5-mini")
2237                .is_ok()
2238        );
2239        let err = routing
2240            .validate_for_primary_model("anthropic/claude-sonnet-4.5")
2241            .unwrap_err();
2242        assert!(err.contains("models[0]"));
2243    }
2244
2245    #[test]
2246    fn test_openrouter_routing_rejects_fallback_without_models() {
2247        let routing = OpenRouterRoutingConfig {
2248            route: Some(OpenRouterRoute::Fallback),
2249            ..Default::default()
2250        };
2251
2252        let err = routing
2253            .validate_for_primary_model("openai/gpt-5-mini")
2254            .unwrap_err();
2255        assert!(err.contains("requires at least one model"));
2256    }
2257
2258    #[test]
2259    fn test_openrouter_routing_serializes_request_fields() {
2260        let routing = OpenRouterRoutingConfig {
2261            models: vec![
2262                "openai/gpt-5-mini".to_string(),
2263                "anthropic/claude-sonnet-4.5".to_string(),
2264            ],
2265            route: Some(OpenRouterRoute::Fallback),
2266            provider: Some(OpenRouterProviderRouting {
2267                order: vec!["anthropic".to_string(), "openai".to_string()],
2268                allow_fallbacks: Some(false),
2269                require_parameters: Some(true),
2270                data_collection: Some(OpenRouterDataCollection::Deny),
2271                zdr: Some(true),
2272                sort: Some(OpenRouterProviderSort::Advanced(
2273                    OpenRouterProviderSortOptions {
2274                        by: OpenRouterProviderSortBy::Throughput,
2275                        partition: Some(OpenRouterSortPartition::None),
2276                    },
2277                )),
2278                max_price: Some(OpenRouterMaxPrice {
2279                    prompt: Some(1.0),
2280                    completion: Some(2.0),
2281                    ..Default::default()
2282                }),
2283                ..Default::default()
2284            }),
2285            ..Default::default()
2286        };
2287
2288        let json = serde_json::to_value(routing).unwrap();
2289
2290        assert_eq!(
2291            json,
2292            serde_json::json!({
2293                "models": [
2294                    "openai/gpt-5-mini",
2295                    "anthropic/claude-sonnet-4.5"
2296                ],
2297                "route": "fallback",
2298                "provider": {
2299                    "order": ["anthropic", "openai"],
2300                    "allow_fallbacks": false,
2301                    "require_parameters": true,
2302                    "data_collection": "deny",
2303                    "zdr": true,
2304                    "sort": {
2305                        "by": "throughput",
2306                        "partition": "none"
2307                    },
2308                    "max_price": {
2309                        "prompt": 1.0,
2310                        "completion": 2.0
2311                    }
2312                }
2313            })
2314        );
2315    }
2316
2317    #[test]
2318    fn test_provider_type_parsing() {
2319        assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2320        assert_eq!(
2321            "openrouter".parse::<DriverId>().unwrap(),
2322            DriverId::OpenRouter
2323        );
2324        assert_eq!(
2325            "openai_completions".parse::<DriverId>().unwrap(),
2326            DriverId::OpenAICompletions
2327        );
2328        assert_eq!(
2329            "azure_openai".parse::<DriverId>().unwrap(),
2330            DriverId::AzureOpenAI
2331        );
2332        assert_eq!(
2333            "anthropic".parse::<DriverId>().unwrap(),
2334            DriverId::Anthropic
2335        );
2336        assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2337        // Unknown ids parse to External rather than erroring.
2338        assert_eq!(
2339            "ollama".parse::<DriverId>().unwrap(),
2340            DriverId::external("ollama")
2341        );
2342        assert_eq!(
2343            "custom".parse::<DriverId>().unwrap(),
2344            DriverId::external("custom")
2345        );
2346    }
2347
2348    #[test]
2349    fn test_external_provider_id_is_case_insensitive() {
2350        // Built-in matching and external normalization are both case-folding,
2351        // so the same id in different casing resolves to one provider.
2352        assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2353        assert_eq!(
2354            "Ollama".parse::<DriverId>().unwrap(),
2355            "ollama".parse::<DriverId>().unwrap()
2356        );
2357        assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2358        // Registration and parsed lookup agree regardless of casing.
2359        assert_eq!(
2360            DriverId::external("MyProvider"),
2361            "myprovider".parse::<DriverId>().unwrap()
2362        );
2363    }
2364
2365    #[test]
2366    fn test_provider_type_display() {
2367        assert_eq!(DriverId::OpenAI.to_string(), "openai");
2368        assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2369        assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2370        assert_eq!(
2371            DriverId::OpenAICompletions.to_string(),
2372            "openai_completions"
2373        );
2374        assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2375        assert_eq!(DriverId::Gemini.to_string(), "gemini");
2376    }
2377
2378    #[test]
2379    fn test_provider_config_builder() {
2380        let config = ProviderConfig::new(DriverId::Anthropic)
2381            .with_api_key("test-key")
2382            .with_base_url("https://custom.api.com");
2383
2384        assert_eq!(config.provider_type, DriverId::Anthropic);
2385        assert_eq!(config.api_key, Some("test-key".to_string()));
2386        assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2387    }
2388
2389    #[test]
2390    fn test_driver_registry_requires_api_key() {
2391        // Register a mock factory
2392        let mut registry = DriverRegistry::new();
2393        registry.register(DriverId::OpenAI, |_config| {
2394            // Return a mock driver - just need something that compiles
2395            struct MockDriver;
2396            #[async_trait]
2397            impl ChatDriver for MockDriver {
2398                async fn chat_completion_stream(
2399                    &self,
2400                    _messages: Vec<LlmMessage>,
2401                    _config: &LlmCallConfig,
2402                ) -> Result<LlmResponseStream> {
2403                    unimplemented!()
2404                }
2405            }
2406            Box::new(MockDriver)
2407        });
2408
2409        // Driver without API key should fail
2410        let config = ProviderConfig::new(DriverId::OpenAI);
2411        let result = registry.create_chat_driver(&config);
2412        assert!(result.is_err());
2413
2414        // Driver with API key should succeed
2415        let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2416        let result = registry.create_chat_driver(&config_with_key);
2417        assert!(result.is_ok());
2418    }
2419
2420    #[test]
2421    fn test_driver_registry_returns_error_for_unregistered_provider() {
2422        let registry = DriverRegistry::new();
2423        let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2424
2425        let result = registry.create_chat_driver(&config);
2426
2427        // Should fail with DriverNotRegistered error
2428        if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2429            assert_eq!(provider, "anthropic");
2430        } else {
2431            panic!("Expected DriverNotRegistered error");
2432        }
2433    }
2434
2435    #[test]
2436    fn test_driver_registry_registration() {
2437        let mut registry = DriverRegistry::new();
2438
2439        assert!(!registry.has_driver(&DriverId::OpenAI));
2440        assert!(!registry.has_driver(&DriverId::Anthropic));
2441
2442        registry.register(DriverId::OpenAI, |_config| {
2443            struct MockDriver;
2444            #[async_trait]
2445            impl ChatDriver for MockDriver {
2446                async fn chat_completion_stream(
2447                    &self,
2448                    _messages: Vec<LlmMessage>,
2449                    _config: &LlmCallConfig,
2450                ) -> Result<LlmResponseStream> {
2451                    unimplemented!()
2452                }
2453            }
2454            Box::new(MockDriver)
2455        });
2456
2457        assert!(registry.has_driver(&DriverId::OpenAI));
2458        assert!(!registry.has_driver(&DriverId::Anthropic));
2459    }
2460
2461    #[test]
2462    fn test_register_external_and_create_driver_without_api_key() {
2463        struct MockDriver;
2464        #[async_trait]
2465        impl ChatDriver for MockDriver {
2466            async fn chat_completion_stream(
2467                &self,
2468                _messages: Vec<LlmMessage>,
2469                _config: &LlmCallConfig,
2470            ) -> Result<LlmResponseStream> {
2471                unimplemented!()
2472            }
2473        }
2474
2475        let mut registry = DriverRegistry::new();
2476        registry.register_external("openai-codex", |config| {
2477            // External providers may authenticate via metadata, not an api_key.
2478            assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2479            Box::new(MockDriver)
2480        });
2481
2482        assert!(registry.has_driver(&DriverId::external("openai-codex")));
2483
2484        // No api_key required for external providers.
2485        let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2486            ProviderMetadata {
2487                refresh_token: Some("rt".into()),
2488                ..Default::default()
2489            },
2490        );
2491        assert!(registry.create_chat_driver(&config).is_ok());
2492    }
2493
2494    #[test]
2495    fn test_register_defaults_to_chat_only_descriptor() {
2496        struct MockDriver;
2497        #[async_trait]
2498        impl ChatDriver for MockDriver {
2499            async fn chat_completion_stream(
2500                &self,
2501                _messages: Vec<LlmMessage>,
2502                _config: &LlmCallConfig,
2503            ) -> Result<LlmResponseStream> {
2504                unimplemented!()
2505            }
2506        }
2507
2508        let mut registry = DriverRegistry::new();
2509        registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2510
2511        let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2512        assert_eq!(descriptor.display_name, "Anthropic");
2513        assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2514        assert!(descriptor.chat.is_some());
2515        // Default credential shape is a single required api_key field.
2516        assert_eq!(descriptor.credential_schema.fields.len(), 1);
2517        assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2518        assert!(descriptor.credential_schema.fields[0].required);
2519
2520        // Keyless drivers default to an empty schema.
2521        registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2522        let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2523        assert!(sim.credential_schema.fields.is_empty());
2524    }
2525
2526    #[test]
2527    fn test_descriptor_services_and_lookup() {
2528        struct MockDriver;
2529        #[async_trait]
2530        impl ChatDriver for MockDriver {
2531            async fn chat_completion_stream(
2532                &self,
2533                _messages: Vec<LlmMessage>,
2534                _config: &LlmCallConfig,
2535            ) -> Result<LlmResponseStream> {
2536                unimplemented!()
2537            }
2538        }
2539
2540        let mut registry = DriverRegistry::new();
2541        registry.register_descriptor(DriverDescriptor {
2542            services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2543            ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2544        });
2545        registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2546
2547        assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2548        assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2549        assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2550        assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2551
2552        let realtime = registry.providers_for(ServiceKind::Realtime);
2553        assert_eq!(realtime, vec![DriverId::OpenAI]);
2554        let mut chat = registry.providers_for(ServiceKind::Chat);
2555        chat.sort_by_key(|p| p.to_string());
2556        assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2557    }
2558
2559    #[test]
2560    fn test_create_chat_driver_fails_without_chat_factory() {
2561        let mut registry = DriverRegistry::new();
2562        registry.register_descriptor(DriverDescriptor {
2563            id: DriverId::external("embeddings-only"),
2564            display_name: "Embeddings Only".to_string(),
2565            services: vec![ServiceKind::Embeddings],
2566            credential_schema: CredentialFormSchema::empty(),
2567            oauth: None,
2568            chat: None,
2569            embeddings: None,
2570        });
2571
2572        let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2573        let err = match registry.create_chat_driver(&config) {
2574            Ok(_) => panic!("expected error for missing chat factory"),
2575            Err(err) => err,
2576        };
2577        assert!(
2578            err.to_string()
2579                .contains("does not implement the chat service"),
2580            "unexpected error: {err}"
2581        );
2582    }
2583
2584    #[test]
2585    #[should_panic(expected = "already registered")]
2586    fn test_register_duplicate_panics() {
2587        struct MockDriver;
2588        #[async_trait]
2589        impl ChatDriver for MockDriver {
2590            async fn chat_completion_stream(
2591                &self,
2592                _messages: Vec<LlmMessage>,
2593                _config: &LlmCallConfig,
2594            ) -> Result<LlmResponseStream> {
2595                unimplemented!()
2596            }
2597        }
2598
2599        let mut registry = DriverRegistry::new();
2600        registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2601        // Second registration for the same provider must panic.
2602        registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2603    }
2604
2605    #[test]
2606    fn test_register_or_replace_overwrites() {
2607        struct MockDriver;
2608        #[async_trait]
2609        impl ChatDriver for MockDriver {
2610            async fn chat_completion_stream(
2611                &self,
2612                _messages: Vec<LlmMessage>,
2613                _config: &LlmCallConfig,
2614            ) -> Result<LlmResponseStream> {
2615                unimplemented!()
2616            }
2617        }
2618
2619        let mut registry = DriverRegistry::new();
2620        registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2621        // Replacing intentionally must not panic.
2622        registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2623        assert!(registry.has_driver(&DriverId::LlmSim));
2624    }
2625
2626    // ========================================================================
2627    // Image resolution tests
2628    // ========================================================================
2629
2630    use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
2631
2632    #[test]
2633    fn test_message_has_image_files_with_image_file() {
2634        let message = Message {
2635            id: uuid::Uuid::new_v4().into(),
2636            role: MessageRole::User,
2637            content: vec![
2638                ContentPart::Text(TextContentPart {
2639                    text: "Look at this image".to_string(),
2640                }),
2641                ContentPart::ImageFile(ImageFileContentPart {
2642                    image_id: uuid::Uuid::new_v4().into(),
2643                    filename: Some("test.png".to_string()),
2644                }),
2645            ],
2646            phase: None,
2647            thinking: None,
2648            thinking_signature: None,
2649            controls: None,
2650            metadata: None,
2651            external_actor: None,
2652            created_at: chrono::Utc::now(),
2653        };
2654
2655        assert!(LlmMessage::message_has_image_files(&message));
2656    }
2657
2658    #[test]
2659    fn test_message_has_image_files_without_image_file() {
2660        let message = Message {
2661            id: uuid::Uuid::new_v4().into(),
2662            role: MessageRole::User,
2663            content: vec![ContentPart::Text(TextContentPart {
2664                text: "Just text".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        assert!(!LlmMessage::message_has_image_files(&message));
2676    }
2677
2678    #[test]
2679    fn test_extract_image_file_ids() {
2680        let id1 = uuid::Uuid::new_v4();
2681        let id2 = uuid::Uuid::new_v4();
2682
2683        let message = Message {
2684            id: uuid::Uuid::new_v4().into(),
2685            role: MessageRole::User,
2686            content: vec![
2687                ContentPart::Text(TextContentPart {
2688                    text: "Look at these images".to_string(),
2689                }),
2690                ContentPart::ImageFile(ImageFileContentPart {
2691                    image_id: id1.into(),
2692                    filename: Some("test1.png".to_string()),
2693                }),
2694                ContentPart::ImageFile(ImageFileContentPart {
2695                    image_id: id2.into(),
2696                    filename: Some("test2.png".to_string()),
2697                }),
2698            ],
2699            phase: None,
2700            thinking: None,
2701            thinking_signature: None,
2702            controls: None,
2703            metadata: None,
2704            external_actor: None,
2705            created_at: chrono::Utc::now(),
2706        };
2707
2708        let ids = LlmMessage::extract_image_file_ids(&message);
2709        assert_eq!(ids.len(), 2);
2710        assert!(ids.contains(&id1));
2711        assert!(ids.contains(&id2));
2712    }
2713
2714    #[test]
2715    fn test_from_message_with_images_text_only() {
2716        let message = Message {
2717            id: uuid::Uuid::new_v4().into(),
2718            role: MessageRole::User,
2719            content: vec![ContentPart::Text(TextContentPart {
2720                text: "Hello".to_string(),
2721            })],
2722            phase: None,
2723            thinking: None,
2724            thinking_signature: None,
2725            controls: None,
2726            metadata: None,
2727            external_actor: None,
2728            created_at: chrono::Utc::now(),
2729        };
2730
2731        let resolved = std::collections::HashMap::new();
2732        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2733
2734        assert_eq!(llm_message.role, LlmMessageRole::User);
2735        match llm_message.content {
2736            LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
2737            _ => panic!("Expected text content"),
2738        }
2739    }
2740
2741    #[test]
2742    fn test_from_message_with_images_resolved_image() {
2743        let image_id = uuid::Uuid::new_v4();
2744        let message = Message {
2745            id: uuid::Uuid::new_v4().into(),
2746            role: MessageRole::User,
2747            content: vec![
2748                ContentPart::Text(TextContentPart {
2749                    text: "Look at this".to_string(),
2750                }),
2751                ContentPart::ImageFile(ImageFileContentPart {
2752                    image_id: image_id.into(),
2753                    filename: Some("test.png".to_string()),
2754                }),
2755            ],
2756            phase: None,
2757            thinking: None,
2758            thinking_signature: None,
2759            controls: None,
2760            metadata: None,
2761            external_actor: None,
2762            created_at: chrono::Utc::now(),
2763        };
2764
2765        let mut resolved = std::collections::HashMap::new();
2766        resolved.insert(
2767            image_id,
2768            crate::ResolvedImage::new("base64data", "image/png"),
2769        );
2770
2771        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2772
2773        match &llm_message.content {
2774            LlmMessageContent::Parts(parts) => {
2775                assert_eq!(parts.len(), 2);
2776                // First part should be text
2777                assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
2778                // Second part should be resolved image
2779                if let LlmContentPart::Image { url } = &parts[1] {
2780                    assert!(url.starts_with("data:image/png;base64,"));
2781                } else {
2782                    panic!("Expected image content part");
2783                }
2784            }
2785            _ => panic!("Expected parts content"),
2786        }
2787    }
2788
2789    #[test]
2790    fn test_from_message_with_images_unresolved_image() {
2791        let image_id = uuid::Uuid::new_v4();
2792        let message = Message {
2793            id: uuid::Uuid::new_v4().into(),
2794            role: MessageRole::User,
2795            content: vec![ContentPart::ImageFile(ImageFileContentPart {
2796                image_id: image_id.into(),
2797                filename: Some("missing.png".to_string()),
2798            })],
2799            phase: None,
2800            thinking: None,
2801            thinking_signature: None,
2802            controls: None,
2803            metadata: None,
2804            external_actor: None,
2805            created_at: chrono::Utc::now(),
2806        };
2807
2808        // Empty resolved map - image not found
2809        let resolved = std::collections::HashMap::new();
2810        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2811
2812        // Should have placeholder text for missing image
2813        // When there's only one part, it may return Text directly instead of Parts
2814        match &llm_message.content {
2815            LlmMessageContent::Text(text) => {
2816                assert!(text.contains("Image not found"));
2817            }
2818            LlmMessageContent::Parts(parts) => {
2819                assert_eq!(parts.len(), 1);
2820                if let LlmContentPart::Text { text } = &parts[0] {
2821                    assert!(text.contains("Image not found"));
2822                } else {
2823                    panic!("Expected text placeholder for missing image");
2824                }
2825            }
2826        }
2827    }
2828
2829    #[test]
2830    fn test_prepend_text_prefix_simple_text() {
2831        let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
2832        msg.prepend_text_prefix("[Alice] ");
2833        assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
2834    }
2835
2836    #[test]
2837    fn test_prepend_text_prefix_parts() {
2838        let mut msg = LlmMessage::parts(
2839            LlmMessageRole::User,
2840            vec![
2841                LlmContentPart::Text {
2842                    text: "Hello".to_string(),
2843                },
2844                LlmContentPart::Image {
2845                    url: "data:image/png;base64,abc".to_string(),
2846                },
2847            ],
2848        );
2849        msg.prepend_text_prefix("[Bob] ");
2850        match &msg.content {
2851            LlmMessageContent::Parts(parts) => {
2852                if let LlmContentPart::Text { text } = &parts[0] {
2853                    assert_eq!(text, "[Bob] Hello");
2854                } else {
2855                    panic!("Expected text part");
2856                }
2857            }
2858            _ => panic!("Expected parts content"),
2859        }
2860    }
2861
2862    #[test]
2863    fn test_prepend_text_prefix_parts_no_text() {
2864        let mut msg = LlmMessage::parts(
2865            LlmMessageRole::User,
2866            vec![LlmContentPart::Image {
2867                url: "data:image/png;base64,abc".to_string(),
2868            }],
2869        );
2870        msg.prepend_text_prefix("[Eve] ");
2871        match &msg.content {
2872            LlmMessageContent::Parts(parts) => {
2873                assert_eq!(parts.len(), 2);
2874                if let LlmContentPart::Text { text } = &parts[0] {
2875                    assert_eq!(text, "[Eve] ");
2876                } else {
2877                    panic!("Expected prepended text part");
2878                }
2879            }
2880            _ => panic!("Expected parts content"),
2881        }
2882    }
2883
2884    #[test]
2885    fn test_openrouter_plugin_config_is_empty() {
2886        assert!(OpenRouterPluginConfig::default().is_empty());
2887        assert!(
2888            !OpenRouterPluginConfig {
2889                web: Some(OpenRouterWebSearchPlugin::default()),
2890                file: None,
2891            }
2892            .is_empty()
2893        );
2894        assert!(
2895            !OpenRouterPluginConfig {
2896                web: None,
2897                file: Some(OpenRouterFilePlugin {}),
2898            }
2899            .is_empty()
2900        );
2901    }
2902
2903    #[test]
2904    fn test_openrouter_routing_is_empty_with_plugins() {
2905        let with_plugins = OpenRouterRoutingConfig {
2906            plugins: Some(OpenRouterPluginConfig {
2907                web: Some(OpenRouterWebSearchPlugin::default()),
2908                file: None,
2909            }),
2910            ..Default::default()
2911        };
2912        assert!(!with_plugins.is_empty());
2913
2914        let empty_plugins = OpenRouterRoutingConfig {
2915            plugins: Some(OpenRouterPluginConfig::default()),
2916            ..Default::default()
2917        };
2918        assert!(empty_plugins.is_empty());
2919    }
2920
2921    #[test]
2922    fn test_openrouter_web_search_plugin_serialization() {
2923        let plugin = OpenRouterWebSearchPlugin {
2924            max_results: Some(10),
2925            search_prompt: Some("search for Rust crates".to_string()),
2926        };
2927        let json = serde_json::to_value(&plugin).unwrap();
2928        assert_eq!(json["max_results"], 10);
2929        assert_eq!(json["search_prompt"], "search for Rust crates");
2930    }
2931
2932    #[test]
2933    fn test_openrouter_web_search_plugin_omits_none_fields() {
2934        let plugin = OpenRouterWebSearchPlugin::default();
2935        let json = serde_json::to_value(&plugin).unwrap();
2936        assert!(json.get("max_results").is_none());
2937        assert!(json.get("search_prompt").is_none());
2938    }
2939
2940    #[test]
2941    fn test_capacity_strategy_shared_capacity_is_noop() {
2942        let base = OpenRouterRoutingConfig {
2943            models: vec!["openai/gpt-5-mini".to_string()],
2944            capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
2945            ..Default::default()
2946        };
2947        let result = base.apply_capacity_strategy().unwrap();
2948        assert_eq!(
2949            result.capacity_strategy,
2950            Some(OpenRouterCapacityStrategy::SharedCapacity)
2951        );
2952        assert!(result.provider.is_none());
2953    }
2954
2955    #[test]
2956    fn test_capacity_strategy_none_is_noop() {
2957        let base = OpenRouterRoutingConfig {
2958            models: vec!["openai/gpt-5-mini".to_string()],
2959            capacity_strategy: None,
2960            ..Default::default()
2961        };
2962        let result = base.apply_capacity_strategy().unwrap();
2963        assert!(result.provider.is_none());
2964    }
2965
2966    #[test]
2967    fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
2968        let base = OpenRouterRoutingConfig {
2969            models: vec!["openai/gpt-5-mini".to_string()],
2970            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2971            ..Default::default()
2972        };
2973        let result = base.apply_capacity_strategy().unwrap();
2974        let provider = result.provider.as_ref().expect("provider set by ByokFirst");
2975        assert_eq!(provider.allow_fallbacks, Some(true));
2976    }
2977
2978    #[test]
2979    fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
2980        // If allow_fallbacks was already set explicitly, ByokFirst must not override it.
2981        let base = OpenRouterRoutingConfig {
2982            models: vec!["openai/gpt-5-mini".to_string()],
2983            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
2984            provider: Some(OpenRouterProviderRouting {
2985                allow_fallbacks: Some(false),
2986                ..Default::default()
2987            }),
2988            ..Default::default()
2989        };
2990        let result = base.apply_capacity_strategy().unwrap();
2991        let provider = result.provider.as_ref().unwrap();
2992        assert_eq!(provider.allow_fallbacks, Some(false));
2993    }
2994
2995    #[test]
2996    fn test_capacity_strategy_byok_only_requires_provider_only() {
2997        let base = OpenRouterRoutingConfig {
2998            models: vec!["openai/gpt-5-mini".to_string()],
2999            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3000            ..Default::default()
3001        };
3002        let err = base.apply_capacity_strategy().unwrap_err();
3003        assert!(
3004            err.contains("provider.only"),
3005            "error should mention provider.only: {err}"
3006        );
3007    }
3008
3009    #[test]
3010    fn test_capacity_strategy_byok_only_disables_fallbacks() {
3011        let base = OpenRouterRoutingConfig {
3012            models: vec!["openai/gpt-5-mini".to_string()],
3013            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3014            provider: Some(OpenRouterProviderRouting {
3015                only: vec!["my-byok-provider".to_string()],
3016                ..Default::default()
3017            }),
3018            ..Default::default()
3019        };
3020        let result = base.apply_capacity_strategy().unwrap();
3021        let provider = result.provider.as_ref().unwrap();
3022        assert_eq!(provider.allow_fallbacks, Some(false));
3023        assert_eq!(provider.only, vec!["my-byok-provider"]);
3024    }
3025
3026    #[test]
3027    fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
3028        let with_strategy = OpenRouterRoutingConfig {
3029            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3030            ..Default::default()
3031        };
3032        assert!(!with_strategy.is_empty());
3033
3034        let byok_first = OpenRouterRoutingConfig {
3035            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3036            ..Default::default()
3037        };
3038        assert!(!byok_first.is_empty());
3039
3040        let shared = OpenRouterRoutingConfig {
3041            capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
3042            ..Default::default()
3043        };
3044        assert!(shared.is_empty());
3045    }
3046
3047    // -------------------------------------------------------------------------
3048    // OpenRouterRoutingPreset tests
3049    // -------------------------------------------------------------------------
3050
3051    #[test]
3052    fn test_preset_no_presets_is_noop() {
3053        let base = OpenRouterRoutingConfig {
3054            models: vec!["openai/gpt-5-mini".to_string()],
3055            ..Default::default()
3056        };
3057        let result = base.apply_presets().unwrap();
3058        assert_eq!(result, base);
3059    }
3060
3061    #[test]
3062    fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
3063        let base = OpenRouterRoutingConfig {
3064            presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3065            ..Default::default()
3066        };
3067        let result = base.apply_presets().unwrap();
3068        assert!(result.presets.is_empty(), "presets cleared after apply");
3069        let provider = result.provider.expect("provider set by preset");
3070        assert_eq!(provider.require_parameters, Some(true));
3071        assert_eq!(
3072            provider.sort,
3073            Some(OpenRouterProviderSort::Simple(
3074                OpenRouterProviderSortBy::Price
3075            ))
3076        );
3077    }
3078
3079    #[test]
3080    fn test_preset_lowest_latency_review_sets_sort_throughput() {
3081        let base = OpenRouterRoutingConfig {
3082            presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
3083            ..Default::default()
3084        };
3085        let result = base.apply_presets().unwrap();
3086        let provider = result.provider.expect("provider set by preset");
3087        assert_eq!(
3088            provider.sort,
3089            Some(OpenRouterProviderSort::Simple(
3090                OpenRouterProviderSortBy::Throughput
3091            ))
3092        );
3093    }
3094
3095    #[test]
3096    fn test_preset_zdr_only_sets_zdr() {
3097        let base = OpenRouterRoutingConfig {
3098            presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3099            ..Default::default()
3100        };
3101        let result = base.apply_presets().unwrap();
3102        let provider = result.provider.expect("provider set");
3103        assert_eq!(provider.zdr, Some(true));
3104    }
3105
3106    #[test]
3107    fn test_preset_byok_first_sets_allow_fallbacks() {
3108        let base = OpenRouterRoutingConfig {
3109            presets: vec![OpenRouterRoutingPreset::ByokFirst],
3110            ..Default::default()
3111        };
3112        let result = base.apply_presets().unwrap();
3113        let provider = result.provider.expect("provider set");
3114        assert_eq!(provider.allow_fallbacks, Some(true));
3115    }
3116
3117    #[test]
3118    fn test_preset_no_data_collection_sets_data_collection_deny() {
3119        let base = OpenRouterRoutingConfig {
3120            presets: vec![OpenRouterRoutingPreset::NoDataCollection],
3121            ..Default::default()
3122        };
3123        let result = base.apply_presets().unwrap();
3124        let provider = result.provider.expect("provider set");
3125        assert_eq!(
3126            provider.data_collection,
3127            Some(OpenRouterDataCollection::Deny)
3128        );
3129    }
3130
3131    #[test]
3132    fn test_preset_strict_json_sets_require_parameters() {
3133        let base = OpenRouterRoutingConfig {
3134            presets: vec![OpenRouterRoutingPreset::StrictJson],
3135            ..Default::default()
3136        };
3137        let result = base.apply_presets().unwrap();
3138        let provider = result.provider.expect("provider set");
3139        assert_eq!(provider.require_parameters, Some(true));
3140    }
3141
3142    #[test]
3143    fn test_preset_reasoning_required_sets_require_parameters() {
3144        let base = OpenRouterRoutingConfig {
3145            presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
3146            ..Default::default()
3147        };
3148        let result = base.apply_presets().unwrap();
3149        let provider = result.provider.expect("provider set");
3150        assert_eq!(provider.require_parameters, Some(true));
3151    }
3152
3153    #[test]
3154    fn test_preset_max_price_converts_usd_per_million() {
3155        let base = OpenRouterRoutingConfig {
3156            presets: vec![OpenRouterRoutingPreset::MaxPrice {
3157                prompt_usd_per_million: Some(5.0),
3158                completion_usd_per_million: Some(15.0),
3159            }],
3160            ..Default::default()
3161        };
3162        let result = base.apply_presets().unwrap();
3163        let provider = result.provider.expect("provider set");
3164        let max_price = provider.max_price.expect("max_price set");
3165        // 5.0 USD/M → 5.0 / 1_000_000 per token
3166        let prompt = max_price.prompt.expect("prompt set");
3167        assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
3168        let completion = max_price.completion.expect("completion set");
3169        assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
3170    }
3171
3172    #[test]
3173    fn test_preset_max_price_rejects_negative_values() {
3174        let base = OpenRouterRoutingConfig {
3175            presets: vec![OpenRouterRoutingPreset::MaxPrice {
3176                prompt_usd_per_million: Some(-1.0),
3177                completion_usd_per_million: None,
3178            }],
3179            ..Default::default()
3180        };
3181        let err = base.apply_presets().unwrap_err();
3182        assert!(
3183            err.contains("non-negative"),
3184            "error should mention non-negative: {err}"
3185        );
3186    }
3187
3188    #[test]
3189    fn test_preset_max_price_both_none_no_provider_field() {
3190        let base = OpenRouterRoutingConfig {
3191            presets: vec![OpenRouterRoutingPreset::MaxPrice {
3192                prompt_usd_per_million: None,
3193                completion_usd_per_million: None,
3194            }],
3195            ..Default::default()
3196        };
3197        let result = base.apply_presets().unwrap();
3198        assert!(
3199            result.provider.is_none(),
3200            "MaxPrice with no dimensions should not produce a provider field"
3201        );
3202    }
3203
3204    #[test]
3205    fn test_preset_explicit_provider_overrides_preset() {
3206        let base = OpenRouterRoutingConfig {
3207            presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3208            provider: Some(OpenRouterProviderRouting {
3209                // Caller explicitly wants throughput sort, overriding Price preset
3210                sort: Some(OpenRouterProviderSort::Simple(
3211                    OpenRouterProviderSortBy::Throughput,
3212                )),
3213                ..Default::default()
3214            }),
3215            ..Default::default()
3216        };
3217        let result = base.apply_presets().unwrap();
3218        let provider = result.provider.expect("provider set");
3219        // Explicit sort wins
3220        assert_eq!(
3221            provider.sort,
3222            Some(OpenRouterProviderSort::Simple(
3223                OpenRouterProviderSortBy::Throughput
3224            ))
3225        );
3226        // But preset-derived require_parameters still set (not overridden by explicit)
3227        assert_eq!(provider.require_parameters, Some(true));
3228    }
3229
3230    #[test]
3231    fn test_preset_multiple_presets_combined() {
3232        let base = OpenRouterRoutingConfig {
3233            presets: vec![
3234                OpenRouterRoutingPreset::ZdrOnly,
3235                OpenRouterRoutingPreset::NoDataCollection,
3236                OpenRouterRoutingPreset::LowestLatencyReview,
3237            ],
3238            ..Default::default()
3239        };
3240        let result = base.apply_presets().unwrap();
3241        let provider = result.provider.expect("provider set");
3242        assert_eq!(provider.zdr, Some(true));
3243        assert_eq!(
3244            provider.data_collection,
3245            Some(OpenRouterDataCollection::Deny)
3246        );
3247        assert_eq!(
3248            provider.sort,
3249            Some(OpenRouterProviderSort::Simple(
3250                OpenRouterProviderSortBy::Throughput
3251            ))
3252        );
3253    }
3254
3255    #[test]
3256    fn test_preset_later_preset_overrides_sort() {
3257        let base = OpenRouterRoutingConfig {
3258            presets: vec![
3259                OpenRouterRoutingPreset::CheapestWithTools, // sets Price sort
3260                OpenRouterRoutingPreset::LowestLatencyReview, // overrides to Throughput
3261            ],
3262            ..Default::default()
3263        };
3264        let result = base.apply_presets().unwrap();
3265        let provider = result.provider.expect("provider set");
3266        // Later preset wins for sort
3267        assert_eq!(
3268            provider.sort,
3269            Some(OpenRouterProviderSort::Simple(
3270                OpenRouterProviderSortBy::Throughput
3271            ))
3272        );
3273        // require_parameters still set by CheapestWithTools
3274        assert_eq!(provider.require_parameters, Some(true));
3275    }
3276
3277    #[test]
3278    fn test_preset_non_empty_in_is_empty() {
3279        let with_preset = OpenRouterRoutingConfig {
3280            presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3281            ..Default::default()
3282        };
3283        assert!(!with_preset.is_empty());
3284
3285        let without = OpenRouterRoutingConfig::default();
3286        assert!(without.is_empty());
3287    }
3288}