Skip to main content

everruns_core/
driver_registry.rs

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