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    /// Speed (service tier) for this call: "flex", "default", or "priority".
1177    /// Serialized as OpenAI `service_tier`; omitted when `None` so the
1178    /// provider keeps its default ("auto") routing.
1179    pub speed: Option<String>,
1180    /// Metadata to send with the API request for tracking and debugging.
1181    /// Keys and values are strings. Both OpenAI and Anthropic support metadata fields.
1182    /// Typically includes: session_id, agent_id, org_id, turn_id, exec_id.
1183    pub metadata: HashMap<String, String>,
1184    /// Previous response ID for stateful continuation (OpenAI Responses API).
1185    /// When set, the provider can skip re-encoding cached context.
1186    pub previous_response_id: Option<String>,
1187    /// Tool search configuration for deferred tool loading
1188    pub tool_search: Option<ToolSearchConfig>,
1189    /// Prompt caching configuration for provider-specific cache controls.
1190    pub prompt_cache: Option<PromptCacheConfig>,
1191    /// OpenRouter-only model fallback and provider routing controls.
1192    pub openrouter_routing: Option<OpenRouterRoutingConfig>,
1193    /// Request-level parallel tool calling preference (EVE-598).
1194    ///
1195    /// Serialized onto the provider request when `Some(_)`: OpenAI sets
1196    /// `parallel_tool_calls`; Anthropic maps `Some(false)` →
1197    /// `tool_choice.disable_parallel_tool_use = true`. `None` preserves
1198    /// provider defaults (no field sent).
1199    pub parallel_tool_calls: Option<bool>,
1200    /// Number of trailing messages that are volatile (regenerated every turn)
1201    /// and must not anchor a message-level prompt-cache breakpoint.
1202    ///
1203    /// `ReasonAtom` sets this to the count of live `<facts>` messages it appends
1204    /// at the conversation tail. Drivers that place a message cache breakpoint
1205    /// on the last block (Anthropic) skip this many trailing messages so the
1206    /// breakpoint lands on the last *stable* block — otherwise a tail that
1207    /// changes each turn would evict the conversation-history cache. `0` (the
1208    /// default) preserves the previous behavior exactly.
1209    pub volatile_suffix_len: usize,
1210}
1211
1212impl LlmCallConfig {
1213    /// Resolve the effective wire value for `parallel_tool_calls`, gated by
1214    /// whether the driver/model can express it on the request.
1215    ///
1216    /// Returns `None` (omit the field, keep the provider default) when the
1217    /// preference is unset or `supported` is `false`. Drivers call this with
1218    /// `self.supports_parallel_tool_calls(&config.model)` so the preference is
1219    /// only serialized where the provider has a control for it. The local tool
1220    /// scheduler honors the preference independently, so `Some(false)` still
1221    /// serializes execution even when this returns `None`.
1222    pub fn resolved_parallel_tool_calls(&self, supported: bool) -> Option<bool> {
1223        if supported {
1224            self.parallel_tool_calls
1225        } else {
1226            None
1227        }
1228    }
1229}
1230
1231impl From<&RuntimeAgent> for LlmCallConfig {
1232    fn from(runtime_agent: &RuntimeAgent) -> Self {
1233        Self {
1234            model: runtime_agent.model.clone(),
1235            temperature: runtime_agent.temperature,
1236            max_tokens: runtime_agent.max_tokens,
1237            tools: runtime_agent.tools.clone(),
1238            reasoning_effort: None, // Set by ReasonAtom from user message controls
1239            speed: None,            // Set by ReasonAtom from user message controls
1240            metadata: HashMap::new(), // Set by ReasonAtom with session/agent context
1241            previous_response_id: None,
1242            tool_search: runtime_agent.tool_search.clone(),
1243            prompt_cache: runtime_agent.prompt_cache.clone(),
1244            openrouter_routing: runtime_agent.openrouter_routing.clone(),
1245            parallel_tool_calls: runtime_agent.parallel_tool_calls,
1246            volatile_suffix_len: 0,
1247        }
1248    }
1249}
1250
1251/// Response from an LLM call (non-streaming)
1252#[derive(Debug, Clone)]
1253pub struct LlmResponse {
1254    pub text: String,
1255    /// Thinking content from extended thinking models (e.g., Claude with thinking enabled)
1256    pub thinking: Option<String>,
1257    /// Cryptographic signature for thinking content (Anthropic Claude)
1258    pub thinking_signature: Option<String>,
1259    pub tool_calls: Option<Vec<ToolCall>>,
1260    pub metadata: LlmCompletionMetadata,
1261}
1262
1263/// Builder for LlmCallConfig with fluent API
1264///
1265/// Use `from(&runtime_agent)` to start building from a RuntimeAgent, then chain
1266/// methods like `reasoning_effort()`, `temperature()`, etc. Call `build()`
1267/// to get the final config.
1268///
1269/// # Example
1270///
1271/// ```ignore
1272/// use everruns_core::llm::LlmCallConfigBuilder;
1273/// use everruns_core::runtime_agent::RuntimeAgent;
1274///
1275/// let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
1276/// let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
1277///     .reasoning_effort("high")
1278///     .temperature(0.7)
1279///     .build();
1280/// ```
1281pub struct LlmCallConfigBuilder {
1282    config: LlmCallConfig,
1283}
1284
1285impl LlmCallConfigBuilder {
1286    /// Start building from a RuntimeAgent
1287    pub fn from(runtime_agent: &RuntimeAgent) -> Self {
1288        Self {
1289            config: LlmCallConfig::from(runtime_agent),
1290        }
1291    }
1292
1293    /// Set reasoning effort level (for models that support it: low, medium, high)
1294    pub fn reasoning_effort(mut self, effort: impl Into<String>) -> Self {
1295        self.config.reasoning_effort = Some(effort.into());
1296        self
1297    }
1298
1299    /// Set speed (service tier): "flex", "default", or "priority"
1300    pub fn speed(mut self, speed: impl Into<String>) -> Self {
1301        self.config.speed = Some(speed.into());
1302        self
1303    }
1304
1305    /// Set the model
1306    pub fn model(mut self, model: impl Into<String>) -> Self {
1307        self.config.model = model.into();
1308        self
1309    }
1310
1311    /// Set temperature
1312    pub fn temperature(mut self, temp: f32) -> Self {
1313        self.config.temperature = Some(temp);
1314        self
1315    }
1316
1317    /// Set max tokens
1318    pub fn max_tokens(mut self, tokens: u32) -> Self {
1319        self.config.max_tokens = Some(tokens);
1320        self
1321    }
1322
1323    /// Set tools
1324    pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
1325        self.config.tools = tools;
1326        self
1327    }
1328
1329    /// Set metadata for API tracking
1330    ///
1331    /// This metadata is sent to the LLM provider for tracking and debugging.
1332    /// Typically includes session_id, agent_id, org_id, turn_id, exec_id.
1333    pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
1334        self.config.metadata = metadata;
1335        self
1336    }
1337
1338    /// Add a single metadata key-value pair
1339    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1340        self.config.metadata.insert(key.into(), value.into());
1341        self
1342    }
1343
1344    /// Set previous response ID for stateful continuation
1345    pub fn previous_response_id(mut self, id: Option<String>) -> Self {
1346        self.config.previous_response_id = id;
1347        self
1348    }
1349
1350    /// Set tool_search configuration
1351    pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
1352        self.config.tool_search = Some(config);
1353        self
1354    }
1355
1356    /// Set prompt caching configuration
1357    pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
1358        self.config.prompt_cache = Some(config);
1359        self
1360    }
1361
1362    /// Set OpenRouter model fallback and provider routing controls.
1363    pub fn openrouter_routing(mut self, config: OpenRouterRoutingConfig) -> Self {
1364        self.config.openrouter_routing = (!config.is_empty()).then_some(config);
1365        self
1366    }
1367
1368    /// Set the request-level parallel tool calling preference (EVE-598).
1369    pub fn parallel_tool_calls(mut self, parallel_tool_calls: Option<bool>) -> Self {
1370        self.config.parallel_tool_calls = parallel_tool_calls;
1371        self
1372    }
1373
1374    /// Set the number of trailing volatile messages that must not anchor a
1375    /// message-level prompt-cache breakpoint (see
1376    /// [`LlmCallConfig::volatile_suffix_len`]).
1377    pub fn volatile_suffix_len(mut self, len: usize) -> Self {
1378        self.config.volatile_suffix_len = len;
1379        self
1380    }
1381
1382    /// Build the configuration
1383    pub fn build(self) -> LlmCallConfig {
1384        self.config
1385    }
1386}
1387
1388// ============================================================================
1389// Conversion from Message
1390// ============================================================================
1391
1392impl From<&crate::message::Message> for LlmMessage {
1393    /// Convert a Message to LlmMessage (text-only, images become placeholders)
1394    ///
1395    /// This conversion is suitable for messages without images or when image
1396    /// resolution is not available. For multimodal messages, use
1397    /// `LlmMessage::from_message_with_images()` instead.
1398    fn from(msg: &crate::message::Message) -> Self {
1399        let role = match msg.role {
1400            crate::message::MessageRole::System => LlmMessageRole::System,
1401            crate::message::MessageRole::User => LlmMessageRole::User,
1402            crate::message::MessageRole::Agent => LlmMessageRole::Assistant,
1403            crate::message::MessageRole::ToolResult => LlmMessageRole::Tool,
1404        };
1405
1406        // Convert tool calls from ContentPart format to ToolCall format
1407        let tool_calls: Vec<ToolCall> = msg
1408            .tool_calls()
1409            .into_iter()
1410            .map(|tc| ToolCall {
1411                id: tc.id.clone(),
1412                name: tc.name.clone(),
1413                arguments: tc.arguments.clone(),
1414            })
1415            .collect();
1416
1417        LlmMessage {
1418            role,
1419            content: LlmMessageContent::Text(msg.content_to_llm_string()),
1420            tool_calls: if tool_calls.is_empty() {
1421                None
1422            } else {
1423                Some(tool_calls)
1424            },
1425            tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1426            phase: msg.phase,
1427            thinking: msg.thinking.clone(),
1428            thinking_signature: msg.thinking_signature.clone(),
1429        }
1430    }
1431}
1432
1433// ============================================================================
1434// Message Conversion with Images
1435// ============================================================================
1436
1437use crate::traits::ResolvedImage;
1438use uuid::Uuid;
1439
1440impl LlmMessage {
1441    /// Convert a Message to LlmMessage with resolved images
1442    ///
1443    /// This method handles multimodal messages by converting:
1444    /// - `text` content parts → `LlmContentPart::Text`
1445    /// - `image` content parts → `LlmContentPart::Image` (data URL)
1446    /// - `image_file` content parts → `LlmContentPart::Image` (resolved to data URL)
1447    /// - `tool_call` content parts → extracted to `tool_calls` field
1448    /// - `tool_result` content parts → text representation
1449    ///
1450    /// # Provider-specific formatting
1451    ///
1452    /// The `LlmContentPart::Image` uses data URLs which are converted by each provider:
1453    /// - **OpenAI**: `{ "type": "image_url", "image_url": { "url": "data:..." } }`
1454    /// - **Anthropic**: `{ "type": "image", "source": { "type": "base64", ... } }`
1455    ///
1456    /// # Arguments
1457    ///
1458    /// * `msg` - The message to convert
1459    /// * `resolved_images` - Pre-resolved images keyed by image_id
1460    pub fn from_message_with_images(
1461        msg: &crate::message::Message,
1462        resolved_images: &HashMap<Uuid, ResolvedImage>,
1463    ) -> Self {
1464        use crate::message::{ContentPart, MessageRole};
1465
1466        let role = match msg.role {
1467            MessageRole::System => LlmMessageRole::System,
1468            MessageRole::User => LlmMessageRole::User,
1469            MessageRole::Agent => LlmMessageRole::Assistant,
1470            MessageRole::ToolResult => LlmMessageRole::Tool,
1471        };
1472
1473        // Convert content parts to LlmContentParts
1474        let mut parts: Vec<LlmContentPart> = Vec::new();
1475        let mut tool_calls: Vec<ToolCall> = Vec::new();
1476
1477        for part in &msg.content {
1478            match part {
1479                ContentPart::Text(t) => {
1480                    parts.push(LlmContentPart::Text {
1481                        text: t.text.clone(),
1482                    });
1483                }
1484                ContentPart::Image(img) => {
1485                    // Convert inline image to data URL
1486                    if let Some(url) = &img.url {
1487                        parts.push(LlmContentPart::Image { url: url.clone() });
1488                    } else if let (Some(base64), Some(media_type)) = (&img.base64, &img.media_type)
1489                    {
1490                        let data_url = format!("data:{};base64,{}", media_type, base64);
1491                        parts.push(LlmContentPart::Image { url: data_url });
1492                    }
1493                }
1494                ContentPart::ImageFile(img_file) => {
1495                    // Resolve image_file to actual image data
1496                    if let Some(resolved) = resolved_images.get(&img_file.image_id.uuid()) {
1497                        parts.push(LlmContentPart::Image {
1498                            url: resolved.to_data_url(),
1499                        });
1500                    } else {
1501                        // Image not found - add placeholder text
1502                        parts.push(LlmContentPart::Text {
1503                            text: format!("[Image not found: {}]", img_file.image_id),
1504                        });
1505                    }
1506                }
1507                ContentPart::ToolCall(tc) => {
1508                    // Extract tool calls to separate field (don't include in content)
1509                    tool_calls.push(ToolCall {
1510                        id: tc.id.clone(),
1511                        name: tc.name.clone(),
1512                        arguments: tc.arguments.clone(),
1513                    });
1514                }
1515                ContentPart::ToolResult(tr) => {
1516                    // Convert tool result to text representation
1517                    let text = if let Some(err) = &tr.error {
1518                        format!("Tool error: {}", err)
1519                    } else if let Some(res) = &tr.result {
1520                        serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
1521                    } else {
1522                        "{}".to_string()
1523                    };
1524                    // Primary hard limit enforced by OutputHardLimitHook (EVE-225)
1525                    // at tool execution time. This backstop catches tool results
1526                    // that bypass ActAtom hooks (client-submitted, stored events).
1527                    let text = truncate_tool_result(text);
1528                    parts.push(LlmContentPart::Text { text });
1529                }
1530            }
1531        }
1532
1533        // Determine content format
1534        let content = if parts.len() == 1 && matches!(&parts[0], LlmContentPart::Text { .. }) {
1535            // Single text part - use simple Text format
1536            if let LlmContentPart::Text { text } = &parts[0] {
1537                LlmMessageContent::Text(text.clone())
1538            } else {
1539                LlmMessageContent::Parts(parts)
1540            }
1541        } else if parts.is_empty() {
1542            // No content parts - use empty text
1543            LlmMessageContent::Text(String::new())
1544        } else {
1545            // Multiple parts or non-text - use Parts format
1546            LlmMessageContent::Parts(parts)
1547        };
1548
1549        LlmMessage {
1550            role,
1551            content,
1552            tool_calls: if tool_calls.is_empty() {
1553                None
1554            } else {
1555                Some(tool_calls)
1556            },
1557            tool_call_id: msg.tool_call_id().map(|s| s.to_string()),
1558            phase: msg.phase,
1559            thinking: msg.thinking.clone(),
1560            thinking_signature: msg.thinking_signature.clone(),
1561        }
1562    }
1563
1564    /// Check if a message contains image_file references that need resolution
1565    pub fn message_has_image_files(msg: &crate::message::Message) -> bool {
1566        msg.content.iter().any(|p| p.is_image_file())
1567    }
1568
1569    /// Extract all image_file IDs from a message
1570    pub fn extract_image_file_ids(msg: &crate::message::Message) -> Vec<Uuid> {
1571        msg.content
1572            .iter()
1573            .filter_map(|p| match p {
1574                crate::message::ContentPart::ImageFile(f) => Some(f.image_id.uuid()),
1575                _ => None,
1576            })
1577            .collect()
1578    }
1579}
1580
1581// ============================================================================
1582// Driver Factory Types
1583// ============================================================================
1584
1585pub use crate::provider::DriverId;
1586
1587/// Extra provider-specific authentication/metadata beyond an API key.
1588///
1589/// Built-in providers ignore this; embedder-defined ([`DriverId::External`])
1590/// providers use it to carry OAuth tokens, account ids, or arbitrary extras
1591/// their driver factory needs.
1592#[derive(Debug, Clone, Default, PartialEq, Eq)]
1593pub struct ProviderMetadata {
1594    /// OAuth refresh token, when the provider authenticates via OAuth.
1595    pub refresh_token: Option<String>,
1596    /// Provider-side account identifier, when required.
1597    pub account_id: Option<String>,
1598    /// Arbitrary extra fields the driver factory understands.
1599    pub extra: Option<serde_json::Value>,
1600}
1601
1602/// Configuration for creating an LLM provider
1603#[derive(Debug, Clone)]
1604pub struct ProviderConfig {
1605    /// Type of provider
1606    pub provider_type: DriverId,
1607    /// API key for authentication
1608    pub api_key: Option<String>,
1609    /// Base URL override (optional)
1610    pub base_url: Option<String>,
1611    /// Extra provider-specific metadata (OAuth tokens, account ids, etc.).
1612    pub metadata: ProviderMetadata,
1613}
1614
1615impl ProviderConfig {
1616    /// Create a new provider config
1617    pub fn new(provider_type: DriverId) -> Self {
1618        Self {
1619            provider_type,
1620            api_key: None,
1621            base_url: None,
1622            metadata: ProviderMetadata::default(),
1623        }
1624    }
1625
1626    /// Set the API key
1627    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
1628        self.api_key = Some(api_key.into());
1629        self
1630    }
1631
1632    /// Set the base URL
1633    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
1634        self.base_url = Some(base_url.into());
1635        self
1636    }
1637
1638    /// Set provider-specific metadata.
1639    pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self {
1640        self.metadata = metadata;
1641        self
1642    }
1643}
1644
1645/// Everything a [`DriverFactory`] receives to build a driver instance.
1646///
1647/// Replaces the old `(api_key, base_url)` factory arguments so that
1648/// embedder-defined providers can receive richer auth via [`ProviderMetadata`]
1649/// without changing the factory signature again.
1650#[derive(Debug, Clone)]
1651pub struct DriverConfig {
1652    /// Provider type being created.
1653    pub provider_type: DriverId,
1654    /// Raw credential document, when one is configured. `None` for keyless
1655    /// providers (LlmSim, or external providers that authenticate via
1656    /// [`ProviderMetadata`]). For single-key drivers this is the API key
1657    /// verbatim; multi-field drivers should read [`DriverConfig::credentials`]
1658    /// instead of parsing this string.
1659    pub api_key: Option<String>,
1660    /// Typed credential fields parsed from the stored credential document (see
1661    /// [`crate::credential_schema::parse_credential_document`]). Multi-field
1662    /// drivers (Bedrock AWS keys, MAI Entra OAuth) read their declared fields
1663    /// from here instead of hand-parsing JSON out of `api_key`. Empty for
1664    /// keyless providers.
1665    pub credentials: std::collections::BTreeMap<String, String>,
1666    /// Base URL override, when configured.
1667    pub base_url: Option<String>,
1668    /// Extra provider-specific metadata.
1669    pub metadata: ProviderMetadata,
1670}
1671
1672impl DriverConfig {
1673    /// Build a driver config from a resolved [`ProviderConfig`], parsing the
1674    /// credential document into the typed [`DriverConfig::credentials`] map.
1675    /// This is the single point where the stored credential string becomes
1676    /// typed fields, so every driver-creation path (server, worker, sync, dev)
1677    /// gets the same typed view.
1678    pub fn from_provider_config(config: &ProviderConfig) -> Self {
1679        Self {
1680            provider_type: config.provider_type.clone(),
1681            credentials: crate::credential_schema::parse_credential_document(
1682                config.api_key.as_deref(),
1683            ),
1684            api_key: config.api_key.clone(),
1685            base_url: config.base_url.clone(),
1686            metadata: config.metadata.clone(),
1687        }
1688    }
1689
1690    /// A declared credential field's non-empty value, if present.
1691    pub fn credential(&self, name: &str) -> Option<&str> {
1692        self.credentials
1693            .get(name)
1694            .map(String::as_str)
1695            .filter(|s| !s.is_empty())
1696    }
1697}
1698
1699impl From<&crate::traits::ResolvedModel> for ProviderConfig {
1700    fn from(model: &crate::traits::ResolvedModel) -> Self {
1701        Self {
1702            provider_type: model.provider_type.clone(),
1703            api_key: model.api_key.clone(),
1704            base_url: model.base_url.clone(),
1705            metadata: model.provider_metadata.clone().unwrap_or_default(),
1706        }
1707    }
1708}
1709
1710/// Boxed chat driver for dynamic dispatch
1711pub type BoxedChatDriver = Box<dyn ChatDriver>;
1712
1713// ============================================================================
1714// EmbeddingsDriver Trait
1715// ============================================================================
1716
1717/// Request to embed a batch of text strings into dense vectors.
1718#[derive(Debug, Clone)]
1719pub struct EmbedRequest {
1720    /// Texts to embed. All texts in a batch share the same model.
1721    pub texts: Vec<String>,
1722    /// Provider-side model id (e.g. `text-embedding-3-small`).
1723    pub model: String,
1724}
1725
1726/// Response from an embedding request.
1727#[derive(Debug, Clone)]
1728pub struct EmbedResponse {
1729    /// One float vector per input text, in the same order.
1730    pub embeddings: Vec<Vec<f32>>,
1731    /// Total tokens consumed (for usage tracking). `None` if the provider
1732    /// does not report token counts.
1733    pub usage_tokens: Option<u32>,
1734}
1735
1736/// Error returned by [`EmbeddingsDriver::embed`].
1737#[derive(Debug, thiserror::Error)]
1738pub enum EmbeddingsDriverError {
1739    #[error("embeddings provider returned an error: {0}")]
1740    Provider(String),
1741    #[error("embeddings request failed: {0}")]
1742    Transport(String),
1743}
1744
1745/// Driver trait for text embedding services.
1746///
1747/// Implementors call their provider's embedding API and return dense float
1748/// vectors. Used by knowledge-base hybrid retrieval (see specs/knowledge-bases.md
1749/// and specs/providers.md phase 6).
1750#[async_trait]
1751pub trait EmbeddingsDriver: Send + Sync {
1752    /// Embed a batch of texts and return one vector per input.
1753    async fn embed(
1754        &self,
1755        request: EmbedRequest,
1756    ) -> std::result::Result<EmbedResponse, EmbeddingsDriverError>;
1757}
1758
1759/// Boxed embeddings driver for dynamic dispatch.
1760pub type BoxedEmbeddingsDriver = Box<dyn EmbeddingsDriver>;
1761
1762/// Factory function type for creating embeddings drivers.
1763pub type EmbeddingsDriverFactory =
1764    Arc<dyn Fn(&DriverConfig) -> BoxedEmbeddingsDriver + Send + Sync>;
1765
1766// ============================================================================
1767// Driver Registry
1768// ============================================================================
1769
1770/// Factory function type for creating chat drivers.
1771///
1772/// Receives a [`DriverConfig`] (provider type, optional key/base URL, and
1773/// provider metadata) and returns a boxed driver.
1774pub type DriverFactory = Arc<dyn Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync>;
1775
1776/// A typed service a provider driver can offer (see specs/providers.md).
1777///
1778/// Declared in code by each driver, never stored in the database. Only `Chat`
1779/// has a driver trait today; the set is additive and new kinds gain factories
1780/// on [`DriverDescriptor`] when their first consumer lands.
1781#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1782#[serde(rename_all = "snake_case")]
1783pub enum ServiceKind {
1784    /// Chat completion ([`ChatDriver`]).
1785    Chat,
1786    /// Text embeddings (planned: knowledge-base hybrid retrieval).
1787    Embeddings,
1788    /// Realtime voice sessions (server-side adapter using provider credentials).
1789    Realtime,
1790    /// Image generation.
1791    Images,
1792    /// Search-result reranking.
1793    Rerank,
1794}
1795
1796impl std::fmt::Display for ServiceKind {
1797    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1798        let s = match self {
1799            ServiceKind::Chat => "chat",
1800            ServiceKind::Embeddings => "embeddings",
1801            ServiceKind::Realtime => "realtime",
1802            ServiceKind::Images => "images",
1803            ServiceKind::Rerank => "rerank",
1804        };
1805        f.write_str(s)
1806    }
1807}
1808
1809/// Wire flavor of a driver's interactive OAuth connect flow.
1810///
1811/// A driver may let an org admin connect a provider by authorizing in the
1812/// browser instead of pasting an API key. The flow always yields a long-lived
1813/// credential that lands in `providers.credentials_encrypted`, exactly like a
1814/// hand-entered key — so runtime resolution is unchanged and non-admin users
1815/// are unaffected (see specs/providers.md "OAuth provider connection").
1816///
1817/// Only OpenRouter's PKCE flavor exists today. Adding OAuth to another driver
1818/// means a new variant here (which the server matches on) plus a
1819/// [`DriverOAuthConfig`] on that driver's descriptor — never a parallel set of
1820/// endpoints.
1821#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1822pub enum DriverOAuthFlow {
1823    /// OpenRouter one-click PKCE
1824    /// (<https://openrouter.ai/docs/guides/overview/auth/oauth>): redirect the
1825    /// admin to `authorize_url?callback_url=..&code_challenge=..&code_challenge_method=S256`,
1826    /// then POST JSON `{code, code_verifier, code_challenge_method}` to
1827    /// `token_url`; the `key` field of the response is the user-controlled API
1828    /// key to store. No client registration or secret is required (public PKCE
1829    /// client).
1830    OpenRouterPkce,
1831}
1832
1833/// A driver's declared OAuth connect flow.
1834///
1835/// Presence of this on a [`DriverDescriptor`] is what makes "Connect with
1836/// {provider}" available; absence means credentials must be entered manually.
1837#[derive(Debug, Clone)]
1838pub struct DriverOAuthConfig {
1839    /// Authorization endpoint the admin's browser is redirected to.
1840    pub authorize_url: String,
1841    /// Endpoint that exchanges the returned authorization code for a credential.
1842    pub token_url: String,
1843    /// Wire flavor of the two steps above.
1844    pub flow: DriverOAuthFlow,
1845}
1846
1847impl DriverOAuthConfig {
1848    /// OpenRouter's one-click PKCE connect flow.
1849    pub fn openrouter() -> Self {
1850        Self {
1851            authorize_url: "https://openrouter.ai/auth".to_string(),
1852            token_url: "https://openrouter.ai/api/v1/auth/keys".to_string(),
1853            flow: DriverOAuthFlow::OpenRouterPkce,
1854        }
1855    }
1856}
1857
1858/// A registered provider driver: identity, declared services, the credential
1859/// shape its providers must supply, and per-service factories.
1860///
1861/// The descriptor is the code-side unit of the providers domain model
1862/// (specs/providers.md): one descriptor per driver id, instantiated as many
1863/// org-scoped providers.
1864#[derive(Clone)]
1865pub struct DriverDescriptor {
1866    /// Driver id (also the registry key).
1867    pub id: DriverId,
1868    /// Human-readable driver name (e.g. "OpenAI", "AWS Bedrock").
1869    pub display_name: String,
1870    /// Services this driver's providers can power. Declared, not stored.
1871    pub services: Vec<ServiceKind>,
1872    /// Credential fields a provider instance must supply.
1873    pub credential_schema: CredentialFormSchema,
1874    /// Optional interactive OAuth connect flow. `Some` makes "Connect with
1875    /// {provider}" available as an alternative to entering a key by hand.
1876    pub oauth: Option<DriverOAuthConfig>,
1877    /// Chat service factory. `None` for drivers that only offer other services.
1878    pub chat: Option<DriverFactory>,
1879    /// Embeddings service factory. `None` for drivers that do not support embeddings.
1880    pub embeddings: Option<EmbeddingsDriverFactory>,
1881}
1882
1883impl DriverDescriptor {
1884    /// Descriptor for a chat-only driver with the default credential schema
1885    /// for the driver id (a single required `api_key` field for real
1886    /// providers; empty for `LlmSim` and `External`, which may authenticate
1887    /// via [`ProviderMetadata`]) and a display name derived from the id.
1888    pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1889    where
1890        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1891    {
1892        let id = id.into();
1893        Self {
1894            display_name: default_display_name(&id),
1895            credential_schema: default_credential_schema(&id),
1896            services: vec![ServiceKind::Chat],
1897            oauth: None,
1898            chat: Some(Arc::new(factory)),
1899            embeddings: None,
1900            id,
1901        }
1902    }
1903
1904    /// Whether the driver declares the given service.
1905    pub fn supports(&self, service: ServiceKind) -> bool {
1906        self.services.contains(&service)
1907    }
1908}
1909
1910impl std::fmt::Debug for DriverDescriptor {
1911    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1912        f.debug_struct("DriverDescriptor")
1913            .field("id", &self.id)
1914            .field("display_name", &self.display_name)
1915            .field("services", &self.services)
1916            .field("oauth", &self.oauth.is_some())
1917            .field("chat", &self.chat.is_some())
1918            .field("embeddings", &self.embeddings.is_some())
1919            .finish()
1920    }
1921}
1922
1923fn default_display_name(id: &DriverId) -> String {
1924    match id {
1925        DriverId::OpenAI => "OpenAI".to_string(),
1926        DriverId::OpenRouter => "OpenRouter".to_string(),
1927        DriverId::AzureOpenAI => "Azure OpenAI".to_string(),
1928        DriverId::OpenAICompletions => "OpenAI (Chat Completions)".to_string(),
1929        DriverId::Anthropic => "Anthropic".to_string(),
1930        DriverId::Gemini => "Google Gemini".to_string(),
1931        DriverId::Bedrock => "AWS Bedrock".to_string(),
1932        DriverId::Mai => "Microsoft MAI".to_string(),
1933        DriverId::Fireworks => "Fireworks AI".to_string(),
1934        DriverId::LlmSim => "LLM Simulator".to_string(),
1935        DriverId::External(id) => id.to_string(),
1936    }
1937}
1938
1939fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
1940    match id {
1941        // Keyless: simulator always; external drivers may auth via metadata.
1942        DriverId::LlmSim | DriverId::External(_) => CredentialFormSchema::empty(),
1943        _ => CredentialFormSchema::api_key(String::new()),
1944    }
1945}
1946
1947/// Registry for LLM drivers
1948///
1949/// Enables dependency inversion: provider crates (everruns-anthropic, everruns-openai)
1950/// register their drivers at startup. The core has no direct knowledge of implementations.
1951///
1952/// # Example
1953///
1954/// ```ignore
1955/// use everruns_core::{DriverRegistry, DriverId};
1956/// use everruns_anthropic::register_driver;
1957/// use everruns_openai::register_driver as register_openai;
1958///
1959/// let mut registry = DriverRegistry::new();
1960/// everruns_anthropic::register_driver(&mut registry);
1961/// everruns_openai::register_driver(&mut registry);
1962///
1963/// // Later, create a driver from config
1964/// let driver = registry.create_chat_driver(&config)?;
1965/// ```
1966#[derive(Clone, Default)]
1967pub struct DriverRegistry {
1968    descriptors: HashMap<DriverId, DriverDescriptor>,
1969}
1970
1971impl DriverRegistry {
1972    /// Create a new empty registry
1973    pub fn new() -> Self {
1974        Self {
1975            descriptors: HashMap::new(),
1976        }
1977    }
1978
1979    /// Register a full driver descriptor.
1980    ///
1981    /// Panics if a descriptor is already registered for the same driver id —
1982    /// silent overwrites hide double-registration bugs. Use
1983    /// [`Self::register_descriptor_or_replace`] to overwrite intentionally.
1984    pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
1985        if self.descriptors.contains_key(&descriptor.id) {
1986            panic!(
1987                "driver already registered for provider '{}'; \
1988                 use register_descriptor_or_replace to overwrite intentionally",
1989                descriptor.id
1990            );
1991        }
1992        self.descriptors.insert(descriptor.id.clone(), descriptor);
1993    }
1994
1995    /// Register a full driver descriptor, replacing any existing one.
1996    pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
1997        self.descriptors.insert(descriptor.id.clone(), descriptor);
1998    }
1999
2000    /// Register a driver factory for a provider type.
2001    ///
2002    /// Panics if a factory is already registered for `provider_type` — silent
2003    /// overwrites hide double-registration bugs. Use
2004    /// [`Self::register_or_replace`] to overwrite intentionally.
2005    pub fn register<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(DriverDescriptor::chat_only(provider_type, factory));
2010    }
2011
2012    /// Register a driver factory, replacing any existing one for the provider.
2013    ///
2014    /// Use when overwriting is intentional (e.g. swapping in an `LlmSim` driver
2015    /// for tests). Prefer [`Self::register`] otherwise so duplicates surface.
2016    pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
2017    where
2018        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
2019    {
2020        self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
2021    }
2022
2023    /// Register a driver factory for an embedder-defined external provider,
2024    /// keyed by its canonical id. The id is normalized to lowercase (via
2025    /// [`DriverId::external`]) so it matches parsed lookups regardless of
2026    /// the casing stored in the database or sent on the wire.
2027    pub fn register_external<F>(&mut self, id: impl Into<Arc<str>>, factory: F)
2028    where
2029        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
2030    {
2031        self.register(DriverId::external(id), factory);
2032    }
2033
2034    /// Create an LLM driver based on configuration
2035    ///
2036    /// API keys must be provided in the config for real providers. This function does NOT fall back to
2037    /// environment variables. Keys should be decrypted from the database and passed here.
2038    /// Exception: `LlmSim` and `External` providers do not require an API key
2039    /// (external providers may authenticate via [`ProviderMetadata`]).
2040    ///
2041    /// Returns `DriverNotRegistered` error if no driver is registered for the provider type.
2042    pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
2043        // API key is required for real built-in providers, but not for LlmSim
2044        // (testing), External providers, or Mai (which may all authenticate via
2045        // metadata-based auth — Mai supports Entra ID OAuth without an api_key).
2046        let requires_api_key = !matches!(
2047            config.provider_type,
2048            DriverId::LlmSim | DriverId::External(_) | DriverId::Mai
2049        );
2050        if requires_api_key && config.api_key.is_none() {
2051            return Err(AgentLoopError::llm(
2052                "API key is required. Configure the API key in provider settings.",
2053            ));
2054        }
2055
2056        // Look up the descriptor and its chat factory for this provider type
2057        let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2058            AgentLoopError::driver_not_registered(config.provider_type.to_string())
2059        })?;
2060        let factory = descriptor.chat.as_ref().ok_or_else(|| {
2061            AgentLoopError::llm(format!(
2062                "Provider driver '{}' does not implement the chat service.",
2063                config.provider_type
2064            ))
2065        })?;
2066
2067        // Create the driver using the factory
2068        let driver_config = DriverConfig::from_provider_config(config);
2069        Ok(factory(&driver_config))
2070    }
2071
2072    /// Check if a driver is registered for a provider type
2073    pub fn has_driver(&self, provider_type: &DriverId) -> bool {
2074        self.descriptors.contains_key(provider_type)
2075    }
2076
2077    /// Get the registered descriptor for a provider type.
2078    pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
2079        self.descriptors.get(provider_type)
2080    }
2081
2082    /// Whether the registered driver declares the given service.
2083    pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
2084        self.descriptors
2085            .get(provider_type)
2086            .is_some_and(|d| d.supports(service))
2087    }
2088
2089    /// Driver ids whose descriptors declare the given service.
2090    pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
2091        self.descriptors
2092            .values()
2093            .filter(|d| d.supports(service))
2094            .map(|d| d.id.clone())
2095            .collect()
2096    }
2097
2098    /// Get the list of registered provider types
2099    pub fn registered_providers(&self) -> Vec<DriverId> {
2100        self.descriptors.keys().cloned().collect()
2101    }
2102
2103    /// Create an embeddings driver based on configuration.
2104    ///
2105    /// API keys must be provided in the config for real providers. Exception:
2106    /// `LlmSim` and `External` providers do not require an API key.
2107    ///
2108    /// Returns an error if the driver is not registered or does not implement
2109    /// the embeddings service.
2110    pub fn create_embeddings_driver(
2111        &self,
2112        config: &ProviderConfig,
2113    ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
2114        let requires_api_key = !matches!(
2115            config.provider_type,
2116            DriverId::LlmSim | DriverId::External(_)
2117        );
2118        if requires_api_key && config.api_key.is_none() {
2119            return Err(EmbeddingsDriverError::Provider(
2120                "API key is required. Configure the API key in provider settings.".to_string(),
2121            ));
2122        }
2123        let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2124            EmbeddingsDriverError::Provider(format!(
2125                "No driver registered for provider '{}'",
2126                config.provider_type
2127            ))
2128        })?;
2129        let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
2130            EmbeddingsDriverError::Provider(format!(
2131                "Provider driver '{}' does not implement the embeddings service.",
2132                config.provider_type
2133            ))
2134        })?;
2135        let driver_config = DriverConfig::from_provider_config(config);
2136        Ok(factory(&driver_config))
2137    }
2138}
2139
2140/// Maximum tool result size in bytes before truncation (64 KiB).
2141/// Defense-in-depth backstop for tool results that bypass ActAtom hooks
2142/// (e.g. client-submitted or stored events). The primary hard limit is
2143/// enforced by `OutputHardLimitHook` (EVE-225) at tool execution time.
2144const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
2145
2146const TRUNCATION_SUFFIX: &str =
2147    "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
2148
2149fn truncate_tool_result(text: String) -> String {
2150    if text.len() <= MAX_TOOL_RESULT_BYTES {
2151        return text;
2152    }
2153    let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
2154    let mut end = content_budget;
2155    while end > 0 && !text.is_char_boundary(end) {
2156        end -= 1;
2157    }
2158    let mut truncated = text[..end].to_string();
2159    truncated.push_str(TRUNCATION_SUFFIX);
2160    truncated
2161}
2162
2163// ============================================================================
2164// Tests
2165// ============================================================================
2166
2167#[cfg(test)]
2168mod tests {
2169    use super::*;
2170
2171    #[test]
2172    fn test_disjoint_prompt_tokens_subtracts_cached_subset() {
2173        // Inclusive providers report a prompt count that includes cached reads;
2174        // normalization yields the non-cached remainder.
2175        assert_eq!(disjoint_prompt_tokens(1000, Some(800)), 200);
2176        // No cache reported => prompt count passes through unchanged.
2177        assert_eq!(disjoint_prompt_tokens(1000, None), 1000);
2178        assert_eq!(disjoint_prompt_tokens(1000, Some(0)), 1000);
2179        // Saturating: a provider reporting cache > input never underflows.
2180        assert_eq!(disjoint_prompt_tokens(800, Some(1000)), 0);
2181    }
2182
2183    #[test]
2184    fn test_resolved_parallel_tool_calls_gating() {
2185        let mut config = LlmCallConfig::from(&RuntimeAgent::new("p", "gpt-5.2"));
2186
2187        // No preference => always None.
2188        assert_eq!(config.resolved_parallel_tool_calls(true), None);
2189        assert_eq!(config.resolved_parallel_tool_calls(false), None);
2190
2191        // Preference passes through only when the driver/model supports it.
2192        config.parallel_tool_calls = Some(true);
2193        assert_eq!(config.resolved_parallel_tool_calls(true), Some(true));
2194        assert_eq!(config.resolved_parallel_tool_calls(false), None);
2195
2196        config.parallel_tool_calls = Some(false);
2197        assert_eq!(config.resolved_parallel_tool_calls(true), Some(false));
2198        assert_eq!(config.resolved_parallel_tool_calls(false), None);
2199    }
2200
2201    #[test]
2202    fn test_chat_driver_default_omits_parallel_tool_calls() {
2203        // Default trait impl is conservative: drivers opt in.
2204        struct DefaultDriver;
2205        #[async_trait]
2206        impl ChatDriver for DefaultDriver {
2207            async fn chat_completion_stream(
2208                &self,
2209                _messages: Vec<LlmMessage>,
2210                _config: &LlmCallConfig,
2211            ) -> Result<LlmResponseStream> {
2212                unreachable!()
2213            }
2214        }
2215        assert!(!DefaultDriver.supports_parallel_tool_calls("any-model"));
2216    }
2217
2218    #[test]
2219    fn test_fold_system_messages_none_when_absent() {
2220        let messages = vec![
2221            LlmMessage::text(LlmMessageRole::User, "hi"),
2222            LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2223        ];
2224        assert_eq!(fold_system_messages(&messages), None);
2225    }
2226
2227    #[test]
2228    fn test_fold_system_messages_single() {
2229        let messages = vec![
2230            LlmMessage::text(LlmMessageRole::System, "AGENT-PROMPT"),
2231            LlmMessage::text(LlmMessageRole::User, "hi"),
2232        ];
2233        assert_eq!(
2234            fold_system_messages(&messages),
2235            Some("AGENT-PROMPT".to_string())
2236        );
2237    }
2238
2239    #[test]
2240    fn test_fold_system_messages_accumulates_in_order() {
2241        // The agent system prompt plus a later notice/summary System message
2242        // (infinity_context / compaction) must both survive, in order — the
2243        // later one must not overwrite the real agent system prompt.
2244        let messages = vec![
2245            LlmMessage::text(LlmMessageRole::System, "A"),
2246            LlmMessage::text(LlmMessageRole::User, "hi"),
2247            LlmMessage::text(LlmMessageRole::Assistant, "ok"),
2248            LlmMessage::text(LlmMessageRole::System, "B"),
2249        ];
2250        assert_eq!(fold_system_messages(&messages), Some("A\n\nB".to_string()));
2251    }
2252
2253    #[test]
2254    fn test_fold_system_messages_concatenates_parts() {
2255        let messages = vec![LlmMessage::parts(
2256            LlmMessageRole::System,
2257            vec![
2258                LlmContentPart::text("foo"),
2259                LlmContentPart::image("data:image/png;base64,xxx"),
2260                LlmContentPart::text("bar"),
2261            ],
2262        )];
2263        assert_eq!(fold_system_messages(&messages), Some("foobar".to_string()));
2264    }
2265
2266    #[test]
2267    fn test_llm_call_config_builder_from_runtime_agent() {
2268        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2269        let llm_config = LlmCallConfigBuilder::from(&runtime_agent).build();
2270
2271        assert_eq!(llm_config.model, "gpt-4o");
2272        assert!(llm_config.reasoning_effort.is_none());
2273        assert!(llm_config.temperature.is_none());
2274        assert!(llm_config.max_tokens.is_none());
2275        assert!(llm_config.tools.is_empty());
2276        assert!(llm_config.metadata.is_empty());
2277        // No server tools configured on the agent → none on the call config.
2278        assert!(llm_config.openrouter_routing.is_none());
2279    }
2280
2281    #[test]
2282    fn runtime_agent_openrouter_routing_flows_into_call_config() {
2283        // Closes the assembly loop: a capability sets RuntimeAgent.openrouter_routing
2284        // (server tools), and the From<&RuntimeAgent> conversion the reason atom
2285        // uses must carry it through to the OpenRouter driver.
2286        let mut runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2287        runtime_agent.openrouter_routing = Some(OpenRouterRoutingConfig {
2288            server_tools: vec![OpenRouterServerTool::new(
2289                OpenRouterServerToolKind::WebSearch,
2290            )],
2291            ..Default::default()
2292        });
2293
2294        let llm_config = LlmCallConfig::from(&runtime_agent);
2295        let routing = llm_config
2296            .openrouter_routing
2297            .expect("server-tool routing survives into the call config");
2298        assert_eq!(routing.server_tools.len(), 1);
2299        assert_eq!(
2300            routing.server_tools[0].kind.wire_type(),
2301            "openrouter:web_search"
2302        );
2303    }
2304
2305    #[test]
2306    fn test_llm_call_config_builder_with_metadata() {
2307        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2308        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2309            .with_metadata("session_id", "session_abc123")
2310            .with_metadata("agent_id", "agent_xyz789")
2311            .build();
2312
2313        assert_eq!(
2314            llm_config.metadata.get("session_id"),
2315            Some(&"session_abc123".to_string())
2316        );
2317        assert_eq!(
2318            llm_config.metadata.get("agent_id"),
2319            Some(&"agent_xyz789".to_string())
2320        );
2321    }
2322
2323    #[test]
2324    fn test_llm_call_config_builder_with_metadata_hashmap() {
2325        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2326        let mut metadata = HashMap::new();
2327        metadata.insert("key1".to_string(), "value1".to_string());
2328        metadata.insert("key2".to_string(), "value2".to_string());
2329
2330        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2331            .metadata(metadata)
2332            .build();
2333
2334        assert_eq!(llm_config.metadata.get("key1"), Some(&"value1".to_string()));
2335        assert_eq!(llm_config.metadata.get("key2"), Some(&"value2".to_string()));
2336    }
2337
2338    #[test]
2339    fn test_llm_call_config_builder_with_reasoning_effort() {
2340        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2341        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2342            .reasoning_effort("high")
2343            .build();
2344
2345        assert_eq!(llm_config.reasoning_effort, Some("high".to_string()));
2346    }
2347
2348    #[test]
2349    fn test_llm_call_config_builder_with_all_options() {
2350        let runtime_agent = RuntimeAgent::new("You are helpful", "gpt-4o");
2351        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2352            .model("claude-3-opus")
2353            .reasoning_effort("medium")
2354            .temperature(0.7)
2355            .max_tokens(1000)
2356            .build();
2357
2358        assert_eq!(llm_config.model, "claude-3-opus");
2359        assert_eq!(llm_config.reasoning_effort, Some("medium".to_string()));
2360        assert_eq!(llm_config.temperature, Some(0.7));
2361        assert_eq!(llm_config.max_tokens, Some(1000));
2362    }
2363
2364    #[test]
2365    fn test_llm_call_config_builder_with_openrouter_routing() {
2366        let runtime_agent = RuntimeAgent::new("You are helpful", "openai/gpt-5-mini");
2367        let routing = OpenRouterRoutingConfig::fallback_models([
2368            "openai/gpt-5-mini",
2369            "anthropic/claude-sonnet-4.5",
2370        ]);
2371
2372        let llm_config = LlmCallConfigBuilder::from(&runtime_agent)
2373            .openrouter_routing(routing.clone())
2374            .build();
2375
2376        assert_eq!(llm_config.openrouter_routing, Some(routing));
2377    }
2378
2379    #[test]
2380    fn test_openrouter_fallback_models_empty_is_empty() {
2381        let routing = OpenRouterRoutingConfig::fallback_models(std::iter::empty::<String>());
2382
2383        assert!(routing.is_empty());
2384        assert_eq!(routing.route, None);
2385    }
2386
2387    #[test]
2388    fn test_openrouter_routing_validates_primary_model() {
2389        let routing = OpenRouterRoutingConfig::fallback_models([
2390            "openai/gpt-5-mini",
2391            "anthropic/claude-sonnet-4.5",
2392        ]);
2393
2394        assert!(
2395            routing
2396                .validate_for_primary_model("openai/gpt-5-mini")
2397                .is_ok()
2398        );
2399        let err = routing
2400            .validate_for_primary_model("anthropic/claude-sonnet-4.5")
2401            .unwrap_err();
2402        assert!(err.contains("models[0]"));
2403    }
2404
2405    #[test]
2406    fn test_openrouter_routing_rejects_fallback_without_models() {
2407        let routing = OpenRouterRoutingConfig {
2408            route: Some(OpenRouterRoute::Fallback),
2409            ..Default::default()
2410        };
2411
2412        let err = routing
2413            .validate_for_primary_model("openai/gpt-5-mini")
2414            .unwrap_err();
2415        assert!(err.contains("requires at least one model"));
2416    }
2417
2418    #[test]
2419    fn test_openrouter_routing_serializes_request_fields() {
2420        let routing = OpenRouterRoutingConfig {
2421            models: vec![
2422                "openai/gpt-5-mini".to_string(),
2423                "anthropic/claude-sonnet-4.5".to_string(),
2424            ],
2425            route: Some(OpenRouterRoute::Fallback),
2426            provider: Some(OpenRouterProviderRouting {
2427                order: vec!["anthropic".to_string(), "openai".to_string()],
2428                allow_fallbacks: Some(false),
2429                require_parameters: Some(true),
2430                data_collection: Some(OpenRouterDataCollection::Deny),
2431                zdr: Some(true),
2432                sort: Some(OpenRouterProviderSort::Advanced(
2433                    OpenRouterProviderSortOptions {
2434                        by: OpenRouterProviderSortBy::Throughput,
2435                        partition: Some(OpenRouterSortPartition::None),
2436                    },
2437                )),
2438                max_price: Some(OpenRouterMaxPrice {
2439                    prompt: Some(1.0),
2440                    completion: Some(2.0),
2441                    ..Default::default()
2442                }),
2443                ..Default::default()
2444            }),
2445            ..Default::default()
2446        };
2447
2448        let json = serde_json::to_value(routing).unwrap();
2449
2450        assert_eq!(
2451            json,
2452            serde_json::json!({
2453                "models": [
2454                    "openai/gpt-5-mini",
2455                    "anthropic/claude-sonnet-4.5"
2456                ],
2457                "route": "fallback",
2458                "provider": {
2459                    "order": ["anthropic", "openai"],
2460                    "allow_fallbacks": false,
2461                    "require_parameters": true,
2462                    "data_collection": "deny",
2463                    "zdr": true,
2464                    "sort": {
2465                        "by": "throughput",
2466                        "partition": "none"
2467                    },
2468                    "max_price": {
2469                        "prompt": 1.0,
2470                        "completion": 2.0
2471                    }
2472                }
2473            })
2474        );
2475    }
2476
2477    #[test]
2478    fn test_provider_type_parsing() {
2479        assert_eq!("openai".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2480        assert_eq!(
2481            "openrouter".parse::<DriverId>().unwrap(),
2482            DriverId::OpenRouter
2483        );
2484        assert_eq!(
2485            "openai_completions".parse::<DriverId>().unwrap(),
2486            DriverId::OpenAICompletions
2487        );
2488        assert_eq!(
2489            "azure_openai".parse::<DriverId>().unwrap(),
2490            DriverId::AzureOpenAI
2491        );
2492        assert_eq!(
2493            "anthropic".parse::<DriverId>().unwrap(),
2494            DriverId::Anthropic
2495        );
2496        assert_eq!("gemini".parse::<DriverId>().unwrap(), DriverId::Gemini);
2497        // Unknown ids parse to External rather than erroring.
2498        assert_eq!(
2499            "ollama".parse::<DriverId>().unwrap(),
2500            DriverId::external("ollama")
2501        );
2502        assert_eq!(
2503            "custom".parse::<DriverId>().unwrap(),
2504            DriverId::external("custom")
2505        );
2506    }
2507
2508    #[test]
2509    fn test_external_provider_id_is_case_insensitive() {
2510        // Built-in matching and external normalization are both case-folding,
2511        // so the same id in different casing resolves to one provider.
2512        assert_eq!("OpenAI".parse::<DriverId>().unwrap(), DriverId::OpenAI);
2513        assert_eq!(
2514            "Ollama".parse::<DriverId>().unwrap(),
2515            "ollama".parse::<DriverId>().unwrap()
2516        );
2517        assert_eq!(DriverId::external("OpenAI-Codex").as_str(), "openai-codex");
2518        // Registration and parsed lookup agree regardless of casing.
2519        assert_eq!(
2520            DriverId::external("MyProvider"),
2521            "myprovider".parse::<DriverId>().unwrap()
2522        );
2523    }
2524
2525    #[test]
2526    fn test_provider_type_display() {
2527        assert_eq!(DriverId::OpenAI.to_string(), "openai");
2528        assert_eq!(DriverId::OpenRouter.to_string(), "openrouter");
2529        assert_eq!(DriverId::AzureOpenAI.to_string(), "azure_openai");
2530        assert_eq!(
2531            DriverId::OpenAICompletions.to_string(),
2532            "openai_completions"
2533        );
2534        assert_eq!(DriverId::Anthropic.to_string(), "anthropic");
2535        assert_eq!(DriverId::Gemini.to_string(), "gemini");
2536    }
2537
2538    #[test]
2539    fn test_provider_config_builder() {
2540        let config = ProviderConfig::new(DriverId::Anthropic)
2541            .with_api_key("test-key")
2542            .with_base_url("https://custom.api.com");
2543
2544        assert_eq!(config.provider_type, DriverId::Anthropic);
2545        assert_eq!(config.api_key, Some("test-key".to_string()));
2546        assert_eq!(config.base_url, Some("https://custom.api.com".to_string()));
2547    }
2548
2549    #[test]
2550    fn test_driver_registry_requires_api_key() {
2551        // Register a mock factory
2552        let mut registry = DriverRegistry::new();
2553        registry.register(DriverId::OpenAI, |_config| {
2554            // Return a mock driver - just need something that compiles
2555            struct MockDriver;
2556            #[async_trait]
2557            impl ChatDriver for MockDriver {
2558                async fn chat_completion_stream(
2559                    &self,
2560                    _messages: Vec<LlmMessage>,
2561                    _config: &LlmCallConfig,
2562                ) -> Result<LlmResponseStream> {
2563                    unimplemented!()
2564                }
2565            }
2566            Box::new(MockDriver)
2567        });
2568
2569        // Driver without API key should fail
2570        let config = ProviderConfig::new(DriverId::OpenAI);
2571        let result = registry.create_chat_driver(&config);
2572        assert!(result.is_err());
2573
2574        // Driver with API key should succeed
2575        let config_with_key = ProviderConfig::new(DriverId::OpenAI).with_api_key("test-key");
2576        let result = registry.create_chat_driver(&config_with_key);
2577        assert!(result.is_ok());
2578    }
2579
2580    #[test]
2581    fn test_driver_registry_returns_error_for_unregistered_provider() {
2582        let registry = DriverRegistry::new();
2583        let config = ProviderConfig::new(DriverId::Anthropic).with_api_key("test-key");
2584
2585        let result = registry.create_chat_driver(&config);
2586
2587        // Should fail with DriverNotRegistered error
2588        if let Err(AgentLoopError::DriverNotRegistered(provider)) = result {
2589            assert_eq!(provider, "anthropic");
2590        } else {
2591            panic!("Expected DriverNotRegistered error");
2592        }
2593    }
2594
2595    #[test]
2596    fn test_driver_registry_registration() {
2597        let mut registry = DriverRegistry::new();
2598
2599        assert!(!registry.has_driver(&DriverId::OpenAI));
2600        assert!(!registry.has_driver(&DriverId::Anthropic));
2601
2602        registry.register(DriverId::OpenAI, |_config| {
2603            struct MockDriver;
2604            #[async_trait]
2605            impl ChatDriver for MockDriver {
2606                async fn chat_completion_stream(
2607                    &self,
2608                    _messages: Vec<LlmMessage>,
2609                    _config: &LlmCallConfig,
2610                ) -> Result<LlmResponseStream> {
2611                    unimplemented!()
2612                }
2613            }
2614            Box::new(MockDriver)
2615        });
2616
2617        assert!(registry.has_driver(&DriverId::OpenAI));
2618        assert!(!registry.has_driver(&DriverId::Anthropic));
2619    }
2620
2621    #[test]
2622    fn test_register_external_and_create_driver_without_api_key() {
2623        struct MockDriver;
2624        #[async_trait]
2625        impl ChatDriver for MockDriver {
2626            async fn chat_completion_stream(
2627                &self,
2628                _messages: Vec<LlmMessage>,
2629                _config: &LlmCallConfig,
2630            ) -> Result<LlmResponseStream> {
2631                unimplemented!()
2632            }
2633        }
2634
2635        let mut registry = DriverRegistry::new();
2636        registry.register_external("openai-codex", |config| {
2637            // External providers may authenticate via metadata, not an api_key.
2638            assert_eq!(config.provider_type, DriverId::external("openai-codex"));
2639            Box::new(MockDriver)
2640        });
2641
2642        assert!(registry.has_driver(&DriverId::external("openai-codex")));
2643
2644        // No api_key required for external providers.
2645        let config = ProviderConfig::new(DriverId::external("openai-codex")).with_metadata(
2646            ProviderMetadata {
2647                refresh_token: Some("rt".into()),
2648                ..Default::default()
2649            },
2650        );
2651        assert!(registry.create_chat_driver(&config).is_ok());
2652    }
2653
2654    #[test]
2655    fn test_register_defaults_to_chat_only_descriptor() {
2656        struct MockDriver;
2657        #[async_trait]
2658        impl ChatDriver for MockDriver {
2659            async fn chat_completion_stream(
2660                &self,
2661                _messages: Vec<LlmMessage>,
2662                _config: &LlmCallConfig,
2663            ) -> Result<LlmResponseStream> {
2664                unimplemented!()
2665            }
2666        }
2667
2668        let mut registry = DriverRegistry::new();
2669        registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2670
2671        let descriptor = registry.descriptor(&DriverId::Anthropic).unwrap();
2672        assert_eq!(descriptor.display_name, "Anthropic");
2673        assert_eq!(descriptor.services, vec![ServiceKind::Chat]);
2674        assert!(descriptor.chat.is_some());
2675        // Default credential shape is a single required api_key field.
2676        assert_eq!(descriptor.credential_schema.fields.len(), 1);
2677        assert_eq!(descriptor.credential_schema.fields[0].name, "api_key");
2678        assert!(descriptor.credential_schema.fields[0].required);
2679
2680        // Keyless drivers default to an empty schema.
2681        registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2682        let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2683        assert!(sim.credential_schema.fields.is_empty());
2684    }
2685
2686    #[test]
2687    fn test_descriptor_services_and_lookup() {
2688        struct MockDriver;
2689        #[async_trait]
2690        impl ChatDriver for MockDriver {
2691            async fn chat_completion_stream(
2692                &self,
2693                _messages: Vec<LlmMessage>,
2694                _config: &LlmCallConfig,
2695            ) -> Result<LlmResponseStream> {
2696                unimplemented!()
2697            }
2698        }
2699
2700        let mut registry = DriverRegistry::new();
2701        registry.register_descriptor(DriverDescriptor {
2702            services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2703            ..DriverDescriptor::chat_only(DriverId::OpenAI, |_config| Box::new(MockDriver))
2704        });
2705        registry.register(DriverId::Anthropic, |_config| Box::new(MockDriver));
2706
2707        assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Chat));
2708        assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2709        assert!(!registry.supports(&DriverId::Anthropic, ServiceKind::Realtime));
2710        assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2711
2712        let realtime = registry.providers_for(ServiceKind::Realtime);
2713        assert_eq!(realtime, vec![DriverId::OpenAI]);
2714        let mut chat = registry.providers_for(ServiceKind::Chat);
2715        chat.sort_by_key(|p| p.to_string());
2716        assert_eq!(chat, vec![DriverId::Anthropic, DriverId::OpenAI]);
2717    }
2718
2719    #[test]
2720    fn test_create_chat_driver_fails_without_chat_factory() {
2721        let mut registry = DriverRegistry::new();
2722        registry.register_descriptor(DriverDescriptor {
2723            id: DriverId::external("embeddings-only"),
2724            display_name: "Embeddings Only".to_string(),
2725            services: vec![ServiceKind::Embeddings],
2726            credential_schema: CredentialFormSchema::empty(),
2727            oauth: None,
2728            chat: None,
2729            embeddings: None,
2730        });
2731
2732        let config = ProviderConfig::new(DriverId::external("embeddings-only"));
2733        let err = match registry.create_chat_driver(&config) {
2734            Ok(_) => panic!("expected error for missing chat factory"),
2735            Err(err) => err,
2736        };
2737        assert!(
2738            err.to_string()
2739                .contains("does not implement the chat service"),
2740            "unexpected error: {err}"
2741        );
2742    }
2743
2744    #[test]
2745    #[should_panic(expected = "already registered")]
2746    fn test_register_duplicate_panics() {
2747        struct MockDriver;
2748        #[async_trait]
2749        impl ChatDriver for MockDriver {
2750            async fn chat_completion_stream(
2751                &self,
2752                _messages: Vec<LlmMessage>,
2753                _config: &LlmCallConfig,
2754            ) -> Result<LlmResponseStream> {
2755                unimplemented!()
2756            }
2757        }
2758
2759        let mut registry = DriverRegistry::new();
2760        registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2761        // Second registration for the same provider must panic.
2762        registry.register(DriverId::OpenAI, |_config| Box::new(MockDriver));
2763    }
2764
2765    #[test]
2766    fn test_register_or_replace_overwrites() {
2767        struct MockDriver;
2768        #[async_trait]
2769        impl ChatDriver for MockDriver {
2770            async fn chat_completion_stream(
2771                &self,
2772                _messages: Vec<LlmMessage>,
2773                _config: &LlmCallConfig,
2774            ) -> Result<LlmResponseStream> {
2775                unimplemented!()
2776            }
2777        }
2778
2779        let mut registry = DriverRegistry::new();
2780        registry.register(DriverId::LlmSim, |_config| Box::new(MockDriver));
2781        // Replacing intentionally must not panic.
2782        registry.register_or_replace(DriverId::LlmSim, |_config| Box::new(MockDriver));
2783        assert!(registry.has_driver(&DriverId::LlmSim));
2784    }
2785
2786    // ========================================================================
2787    // Image resolution tests
2788    // ========================================================================
2789
2790    use crate::{ContentPart, ImageFileContentPart, Message, MessageRole, TextContentPart};
2791
2792    #[test]
2793    fn test_message_has_image_files_with_image_file() {
2794        let message = Message {
2795            id: uuid::Uuid::new_v4().into(),
2796            role: MessageRole::User,
2797            content: vec![
2798                ContentPart::Text(TextContentPart {
2799                    text: "Look at this image".to_string(),
2800                }),
2801                ContentPart::ImageFile(ImageFileContentPart {
2802                    image_id: uuid::Uuid::new_v4().into(),
2803                    filename: Some("test.png".to_string()),
2804                }),
2805            ],
2806            phase: None,
2807            thinking: None,
2808            thinking_signature: None,
2809            controls: None,
2810            metadata: None,
2811            external_actor: None,
2812            created_at: chrono::Utc::now(),
2813        };
2814
2815        assert!(LlmMessage::message_has_image_files(&message));
2816    }
2817
2818    #[test]
2819    fn test_message_has_image_files_without_image_file() {
2820        let message = Message {
2821            id: uuid::Uuid::new_v4().into(),
2822            role: MessageRole::User,
2823            content: vec![ContentPart::Text(TextContentPart {
2824                text: "Just text".to_string(),
2825            })],
2826            phase: None,
2827            thinking: None,
2828            thinking_signature: None,
2829            controls: None,
2830            metadata: None,
2831            external_actor: None,
2832            created_at: chrono::Utc::now(),
2833        };
2834
2835        assert!(!LlmMessage::message_has_image_files(&message));
2836    }
2837
2838    #[test]
2839    fn test_extract_image_file_ids() {
2840        let id1 = uuid::Uuid::new_v4();
2841        let id2 = uuid::Uuid::new_v4();
2842
2843        let message = Message {
2844            id: uuid::Uuid::new_v4().into(),
2845            role: MessageRole::User,
2846            content: vec![
2847                ContentPart::Text(TextContentPart {
2848                    text: "Look at these images".to_string(),
2849                }),
2850                ContentPart::ImageFile(ImageFileContentPart {
2851                    image_id: id1.into(),
2852                    filename: Some("test1.png".to_string()),
2853                }),
2854                ContentPart::ImageFile(ImageFileContentPart {
2855                    image_id: id2.into(),
2856                    filename: Some("test2.png".to_string()),
2857                }),
2858            ],
2859            phase: None,
2860            thinking: None,
2861            thinking_signature: None,
2862            controls: None,
2863            metadata: None,
2864            external_actor: None,
2865            created_at: chrono::Utc::now(),
2866        };
2867
2868        let ids = LlmMessage::extract_image_file_ids(&message);
2869        assert_eq!(ids.len(), 2);
2870        assert!(ids.contains(&id1));
2871        assert!(ids.contains(&id2));
2872    }
2873
2874    #[test]
2875    fn test_from_message_with_images_text_only() {
2876        let message = Message {
2877            id: uuid::Uuid::new_v4().into(),
2878            role: MessageRole::User,
2879            content: vec![ContentPart::Text(TextContentPart {
2880                text: "Hello".to_string(),
2881            })],
2882            phase: None,
2883            thinking: None,
2884            thinking_signature: None,
2885            controls: None,
2886            metadata: None,
2887            external_actor: None,
2888            created_at: chrono::Utc::now(),
2889        };
2890
2891        let resolved = std::collections::HashMap::new();
2892        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2893
2894        assert_eq!(llm_message.role, LlmMessageRole::User);
2895        match llm_message.content {
2896            LlmMessageContent::Text(text) => assert_eq!(text, "Hello"),
2897            _ => panic!("Expected text content"),
2898        }
2899    }
2900
2901    #[test]
2902    fn test_from_message_with_images_resolved_image() {
2903        let image_id = uuid::Uuid::new_v4();
2904        let message = Message {
2905            id: uuid::Uuid::new_v4().into(),
2906            role: MessageRole::User,
2907            content: vec![
2908                ContentPart::Text(TextContentPart {
2909                    text: "Look at this".to_string(),
2910                }),
2911                ContentPart::ImageFile(ImageFileContentPart {
2912                    image_id: image_id.into(),
2913                    filename: Some("test.png".to_string()),
2914                }),
2915            ],
2916            phase: None,
2917            thinking: None,
2918            thinking_signature: None,
2919            controls: None,
2920            metadata: None,
2921            external_actor: None,
2922            created_at: chrono::Utc::now(),
2923        };
2924
2925        let mut resolved = std::collections::HashMap::new();
2926        resolved.insert(
2927            image_id,
2928            crate::ResolvedImage::new("base64data", "image/png"),
2929        );
2930
2931        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2932
2933        match &llm_message.content {
2934            LlmMessageContent::Parts(parts) => {
2935                assert_eq!(parts.len(), 2);
2936                // First part should be text
2937                assert!(matches!(&parts[0], LlmContentPart::Text { .. }));
2938                // Second part should be resolved image
2939                if let LlmContentPart::Image { url } = &parts[1] {
2940                    assert!(url.starts_with("data:image/png;base64,"));
2941                } else {
2942                    panic!("Expected image content part");
2943                }
2944            }
2945            _ => panic!("Expected parts content"),
2946        }
2947    }
2948
2949    #[test]
2950    fn test_from_message_with_images_unresolved_image() {
2951        let image_id = uuid::Uuid::new_v4();
2952        let message = Message {
2953            id: uuid::Uuid::new_v4().into(),
2954            role: MessageRole::User,
2955            content: vec![ContentPart::ImageFile(ImageFileContentPart {
2956                image_id: image_id.into(),
2957                filename: Some("missing.png".to_string()),
2958            })],
2959            phase: None,
2960            thinking: None,
2961            thinking_signature: None,
2962            controls: None,
2963            metadata: None,
2964            external_actor: None,
2965            created_at: chrono::Utc::now(),
2966        };
2967
2968        // Empty resolved map - image not found
2969        let resolved = std::collections::HashMap::new();
2970        let llm_message = LlmMessage::from_message_with_images(&message, &resolved);
2971
2972        // Should have placeholder text for missing image
2973        // When there's only one part, it may return Text directly instead of Parts
2974        match &llm_message.content {
2975            LlmMessageContent::Text(text) => {
2976                assert!(text.contains("Image not found"));
2977            }
2978            LlmMessageContent::Parts(parts) => {
2979                assert_eq!(parts.len(), 1);
2980                if let LlmContentPart::Text { text } = &parts[0] {
2981                    assert!(text.contains("Image not found"));
2982                } else {
2983                    panic!("Expected text placeholder for missing image");
2984                }
2985            }
2986        }
2987    }
2988
2989    #[test]
2990    fn test_prepend_text_prefix_simple_text() {
2991        let mut msg = LlmMessage::text(LlmMessageRole::User, "Hello bot");
2992        msg.prepend_text_prefix("[Alice] ");
2993        assert_eq!(msg.content_as_text(), "[Alice] Hello bot");
2994    }
2995
2996    #[test]
2997    fn test_prepend_text_prefix_parts() {
2998        let mut msg = LlmMessage::parts(
2999            LlmMessageRole::User,
3000            vec![
3001                LlmContentPart::Text {
3002                    text: "Hello".to_string(),
3003                },
3004                LlmContentPart::Image {
3005                    url: "data:image/png;base64,abc".to_string(),
3006                },
3007            ],
3008        );
3009        msg.prepend_text_prefix("[Bob] ");
3010        match &msg.content {
3011            LlmMessageContent::Parts(parts) => {
3012                if let LlmContentPart::Text { text } = &parts[0] {
3013                    assert_eq!(text, "[Bob] Hello");
3014                } else {
3015                    panic!("Expected text part");
3016                }
3017            }
3018            _ => panic!("Expected parts content"),
3019        }
3020    }
3021
3022    #[test]
3023    fn test_prepend_text_prefix_parts_no_text() {
3024        let mut msg = LlmMessage::parts(
3025            LlmMessageRole::User,
3026            vec![LlmContentPart::Image {
3027                url: "data:image/png;base64,abc".to_string(),
3028            }],
3029        );
3030        msg.prepend_text_prefix("[Eve] ");
3031        match &msg.content {
3032            LlmMessageContent::Parts(parts) => {
3033                assert_eq!(parts.len(), 2);
3034                if let LlmContentPart::Text { text } = &parts[0] {
3035                    assert_eq!(text, "[Eve] ");
3036                } else {
3037                    panic!("Expected prepended text part");
3038                }
3039            }
3040            _ => panic!("Expected parts content"),
3041        }
3042    }
3043
3044    #[test]
3045    fn test_openrouter_plugin_config_is_empty() {
3046        assert!(OpenRouterPluginConfig::default().is_empty());
3047        assert!(
3048            !OpenRouterPluginConfig {
3049                web: Some(OpenRouterWebSearchPlugin::default()),
3050                file: None,
3051            }
3052            .is_empty()
3053        );
3054        assert!(
3055            !OpenRouterPluginConfig {
3056                web: None,
3057                file: Some(OpenRouterFilePlugin {}),
3058            }
3059            .is_empty()
3060        );
3061    }
3062
3063    #[test]
3064    fn test_openrouter_routing_is_empty_with_plugins() {
3065        let with_plugins = OpenRouterRoutingConfig {
3066            plugins: Some(OpenRouterPluginConfig {
3067                web: Some(OpenRouterWebSearchPlugin::default()),
3068                file: None,
3069            }),
3070            ..Default::default()
3071        };
3072        assert!(!with_plugins.is_empty());
3073
3074        let empty_plugins = OpenRouterRoutingConfig {
3075            plugins: Some(OpenRouterPluginConfig::default()),
3076            ..Default::default()
3077        };
3078        assert!(empty_plugins.is_empty());
3079    }
3080
3081    #[test]
3082    fn test_openrouter_web_search_plugin_serialization() {
3083        let plugin = OpenRouterWebSearchPlugin {
3084            max_results: Some(10),
3085            search_prompt: Some("search for Rust crates".to_string()),
3086        };
3087        let json = serde_json::to_value(&plugin).unwrap();
3088        assert_eq!(json["max_results"], 10);
3089        assert_eq!(json["search_prompt"], "search for Rust crates");
3090    }
3091
3092    #[test]
3093    fn test_openrouter_web_search_plugin_omits_none_fields() {
3094        let plugin = OpenRouterWebSearchPlugin::default();
3095        let json = serde_json::to_value(&plugin).unwrap();
3096        assert!(json.get("max_results").is_none());
3097        assert!(json.get("search_prompt").is_none());
3098    }
3099
3100    #[test]
3101    fn test_capacity_strategy_shared_capacity_is_noop() {
3102        let base = OpenRouterRoutingConfig {
3103            models: vec!["openai/gpt-5-mini".to_string()],
3104            capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
3105            ..Default::default()
3106        };
3107        let result = base.apply_capacity_strategy().unwrap();
3108        assert_eq!(
3109            result.capacity_strategy,
3110            Some(OpenRouterCapacityStrategy::SharedCapacity)
3111        );
3112        assert!(result.provider.is_none());
3113    }
3114
3115    #[test]
3116    fn test_capacity_strategy_none_is_noop() {
3117        let base = OpenRouterRoutingConfig {
3118            models: vec!["openai/gpt-5-mini".to_string()],
3119            capacity_strategy: None,
3120            ..Default::default()
3121        };
3122        let result = base.apply_capacity_strategy().unwrap();
3123        assert!(result.provider.is_none());
3124    }
3125
3126    #[test]
3127    fn test_capacity_strategy_byok_first_sets_allow_fallbacks() {
3128        let base = OpenRouterRoutingConfig {
3129            models: vec!["openai/gpt-5-mini".to_string()],
3130            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3131            ..Default::default()
3132        };
3133        let result = base.apply_capacity_strategy().unwrap();
3134        let provider = result.provider.as_ref().expect("provider set by ByokFirst");
3135        assert_eq!(provider.allow_fallbacks, Some(true));
3136    }
3137
3138    #[test]
3139    fn test_capacity_strategy_byok_first_preserves_explicit_allow_fallbacks() {
3140        // If allow_fallbacks was already set explicitly, ByokFirst must not override it.
3141        let base = OpenRouterRoutingConfig {
3142            models: vec!["openai/gpt-5-mini".to_string()],
3143            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3144            provider: Some(OpenRouterProviderRouting {
3145                allow_fallbacks: Some(false),
3146                ..Default::default()
3147            }),
3148            ..Default::default()
3149        };
3150        let result = base.apply_capacity_strategy().unwrap();
3151        let provider = result.provider.as_ref().unwrap();
3152        assert_eq!(provider.allow_fallbacks, Some(false));
3153    }
3154
3155    #[test]
3156    fn test_capacity_strategy_byok_only_requires_provider_only() {
3157        let base = OpenRouterRoutingConfig {
3158            models: vec!["openai/gpt-5-mini".to_string()],
3159            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3160            ..Default::default()
3161        };
3162        let err = base.apply_capacity_strategy().unwrap_err();
3163        assert!(
3164            err.contains("provider.only"),
3165            "error should mention provider.only: {err}"
3166        );
3167    }
3168
3169    #[test]
3170    fn test_capacity_strategy_byok_only_disables_fallbacks() {
3171        let base = OpenRouterRoutingConfig {
3172            models: vec!["openai/gpt-5-mini".to_string()],
3173            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3174            provider: Some(OpenRouterProviderRouting {
3175                only: vec!["my-byok-provider".to_string()],
3176                ..Default::default()
3177            }),
3178            ..Default::default()
3179        };
3180        let result = base.apply_capacity_strategy().unwrap();
3181        let provider = result.provider.as_ref().unwrap();
3182        assert_eq!(provider.allow_fallbacks, Some(false));
3183        assert_eq!(provider.only, vec!["my-byok-provider"]);
3184    }
3185
3186    #[test]
3187    fn test_capacity_strategy_byok_only_not_empty_in_is_empty() {
3188        let with_strategy = OpenRouterRoutingConfig {
3189            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokOnly),
3190            ..Default::default()
3191        };
3192        assert!(!with_strategy.is_empty());
3193
3194        let byok_first = OpenRouterRoutingConfig {
3195            capacity_strategy: Some(OpenRouterCapacityStrategy::ByokFirst),
3196            ..Default::default()
3197        };
3198        assert!(!byok_first.is_empty());
3199
3200        let shared = OpenRouterRoutingConfig {
3201            capacity_strategy: Some(OpenRouterCapacityStrategy::SharedCapacity),
3202            ..Default::default()
3203        };
3204        assert!(shared.is_empty());
3205    }
3206
3207    // -------------------------------------------------------------------------
3208    // OpenRouterRoutingPreset tests
3209    // -------------------------------------------------------------------------
3210
3211    #[test]
3212    fn test_preset_no_presets_is_noop() {
3213        let base = OpenRouterRoutingConfig {
3214            models: vec!["openai/gpt-5-mini".to_string()],
3215            ..Default::default()
3216        };
3217        let result = base.apply_presets().unwrap();
3218        assert_eq!(result, base);
3219    }
3220
3221    #[test]
3222    fn test_preset_cheapest_with_tools_sets_require_parameters_and_sort_price() {
3223        let base = OpenRouterRoutingConfig {
3224            presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3225            ..Default::default()
3226        };
3227        let result = base.apply_presets().unwrap();
3228        assert!(result.presets.is_empty(), "presets cleared after apply");
3229        let provider = result.provider.expect("provider set by preset");
3230        assert_eq!(provider.require_parameters, Some(true));
3231        assert_eq!(
3232            provider.sort,
3233            Some(OpenRouterProviderSort::Simple(
3234                OpenRouterProviderSortBy::Price
3235            ))
3236        );
3237    }
3238
3239    #[test]
3240    fn test_preset_lowest_latency_review_sets_sort_throughput() {
3241        let base = OpenRouterRoutingConfig {
3242            presets: vec![OpenRouterRoutingPreset::LowestLatencyReview],
3243            ..Default::default()
3244        };
3245        let result = base.apply_presets().unwrap();
3246        let provider = result.provider.expect("provider set by preset");
3247        assert_eq!(
3248            provider.sort,
3249            Some(OpenRouterProviderSort::Simple(
3250                OpenRouterProviderSortBy::Throughput
3251            ))
3252        );
3253    }
3254
3255    #[test]
3256    fn test_preset_zdr_only_sets_zdr() {
3257        let base = OpenRouterRoutingConfig {
3258            presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3259            ..Default::default()
3260        };
3261        let result = base.apply_presets().unwrap();
3262        let provider = result.provider.expect("provider set");
3263        assert_eq!(provider.zdr, Some(true));
3264    }
3265
3266    #[test]
3267    fn test_preset_byok_first_sets_allow_fallbacks() {
3268        let base = OpenRouterRoutingConfig {
3269            presets: vec![OpenRouterRoutingPreset::ByokFirst],
3270            ..Default::default()
3271        };
3272        let result = base.apply_presets().unwrap();
3273        let provider = result.provider.expect("provider set");
3274        assert_eq!(provider.allow_fallbacks, Some(true));
3275    }
3276
3277    #[test]
3278    fn test_preset_no_data_collection_sets_data_collection_deny() {
3279        let base = OpenRouterRoutingConfig {
3280            presets: vec![OpenRouterRoutingPreset::NoDataCollection],
3281            ..Default::default()
3282        };
3283        let result = base.apply_presets().unwrap();
3284        let provider = result.provider.expect("provider set");
3285        assert_eq!(
3286            provider.data_collection,
3287            Some(OpenRouterDataCollection::Deny)
3288        );
3289    }
3290
3291    #[test]
3292    fn test_preset_strict_json_sets_require_parameters() {
3293        let base = OpenRouterRoutingConfig {
3294            presets: vec![OpenRouterRoutingPreset::StrictJson],
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_reasoning_required_sets_require_parameters() {
3304        let base = OpenRouterRoutingConfig {
3305            presets: vec![OpenRouterRoutingPreset::ReasoningRequired],
3306            ..Default::default()
3307        };
3308        let result = base.apply_presets().unwrap();
3309        let provider = result.provider.expect("provider set");
3310        assert_eq!(provider.require_parameters, Some(true));
3311    }
3312
3313    #[test]
3314    fn test_preset_max_price_converts_usd_per_million() {
3315        let base = OpenRouterRoutingConfig {
3316            presets: vec![OpenRouterRoutingPreset::MaxPrice {
3317                prompt_usd_per_million: Some(5.0),
3318                completion_usd_per_million: Some(15.0),
3319            }],
3320            ..Default::default()
3321        };
3322        let result = base.apply_presets().unwrap();
3323        let provider = result.provider.expect("provider set");
3324        let max_price = provider.max_price.expect("max_price set");
3325        // 5.0 USD/M → 5.0 / 1_000_000 per token
3326        let prompt = max_price.prompt.expect("prompt set");
3327        assert!((prompt - 5.0 / 1_000_000.0).abs() < f64::EPSILON);
3328        let completion = max_price.completion.expect("completion set");
3329        assert!((completion - 15.0 / 1_000_000.0).abs() < f64::EPSILON);
3330    }
3331
3332    #[test]
3333    fn test_preset_max_price_rejects_negative_values() {
3334        let base = OpenRouterRoutingConfig {
3335            presets: vec![OpenRouterRoutingPreset::MaxPrice {
3336                prompt_usd_per_million: Some(-1.0),
3337                completion_usd_per_million: None,
3338            }],
3339            ..Default::default()
3340        };
3341        let err = base.apply_presets().unwrap_err();
3342        assert!(
3343            err.contains("non-negative"),
3344            "error should mention non-negative: {err}"
3345        );
3346    }
3347
3348    #[test]
3349    fn test_preset_max_price_both_none_no_provider_field() {
3350        let base = OpenRouterRoutingConfig {
3351            presets: vec![OpenRouterRoutingPreset::MaxPrice {
3352                prompt_usd_per_million: None,
3353                completion_usd_per_million: None,
3354            }],
3355            ..Default::default()
3356        };
3357        let result = base.apply_presets().unwrap();
3358        assert!(
3359            result.provider.is_none(),
3360            "MaxPrice with no dimensions should not produce a provider field"
3361        );
3362    }
3363
3364    #[test]
3365    fn test_preset_explicit_provider_overrides_preset() {
3366        let base = OpenRouterRoutingConfig {
3367            presets: vec![OpenRouterRoutingPreset::CheapestWithTools],
3368            provider: Some(OpenRouterProviderRouting {
3369                // Caller explicitly wants throughput sort, overriding Price preset
3370                sort: Some(OpenRouterProviderSort::Simple(
3371                    OpenRouterProviderSortBy::Throughput,
3372                )),
3373                ..Default::default()
3374            }),
3375            ..Default::default()
3376        };
3377        let result = base.apply_presets().unwrap();
3378        let provider = result.provider.expect("provider set");
3379        // Explicit sort wins
3380        assert_eq!(
3381            provider.sort,
3382            Some(OpenRouterProviderSort::Simple(
3383                OpenRouterProviderSortBy::Throughput
3384            ))
3385        );
3386        // But preset-derived require_parameters still set (not overridden by explicit)
3387        assert_eq!(provider.require_parameters, Some(true));
3388    }
3389
3390    #[test]
3391    fn test_preset_multiple_presets_combined() {
3392        let base = OpenRouterRoutingConfig {
3393            presets: vec![
3394                OpenRouterRoutingPreset::ZdrOnly,
3395                OpenRouterRoutingPreset::NoDataCollection,
3396                OpenRouterRoutingPreset::LowestLatencyReview,
3397            ],
3398            ..Default::default()
3399        };
3400        let result = base.apply_presets().unwrap();
3401        let provider = result.provider.expect("provider set");
3402        assert_eq!(provider.zdr, Some(true));
3403        assert_eq!(
3404            provider.data_collection,
3405            Some(OpenRouterDataCollection::Deny)
3406        );
3407        assert_eq!(
3408            provider.sort,
3409            Some(OpenRouterProviderSort::Simple(
3410                OpenRouterProviderSortBy::Throughput
3411            ))
3412        );
3413    }
3414
3415    #[test]
3416    fn test_preset_later_preset_overrides_sort() {
3417        let base = OpenRouterRoutingConfig {
3418            presets: vec![
3419                OpenRouterRoutingPreset::CheapestWithTools, // sets Price sort
3420                OpenRouterRoutingPreset::LowestLatencyReview, // overrides to Throughput
3421            ],
3422            ..Default::default()
3423        };
3424        let result = base.apply_presets().unwrap();
3425        let provider = result.provider.expect("provider set");
3426        // Later preset wins for sort
3427        assert_eq!(
3428            provider.sort,
3429            Some(OpenRouterProviderSort::Simple(
3430                OpenRouterProviderSortBy::Throughput
3431            ))
3432        );
3433        // require_parameters still set by CheapestWithTools
3434        assert_eq!(provider.require_parameters, Some(true));
3435    }
3436
3437    #[test]
3438    fn test_preset_non_empty_in_is_empty() {
3439        let with_preset = OpenRouterRoutingConfig {
3440            presets: vec![OpenRouterRoutingPreset::ZdrOnly],
3441            ..Default::default()
3442        };
3443        assert!(!with_preset.is_empty());
3444
3445        let without = OpenRouterRoutingConfig::default();
3446        assert!(without.is_empty());
3447    }
3448}