Skip to main content

everruns_provider/
driver_registry.rs

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