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