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