Skip to main content

everruns_core/
driver_registry.rs

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