Skip to main content

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