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