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