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