Skip to main content

everruns_provider/
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::compact::{CompactOutputItem, CompactRequest, CompactResponse};
18use crate::credential_schema::CredentialFormSchema;
19use crate::error::{AgentLoopError, LlmErrorKind, Result};
20use crate::tool_types::{ToolCall, ToolDefinition};
21use async_trait::async_trait;
22use chrono::{DateTime, Utc};
23use futures::Stream;
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26use std::pin::Pin;
27use std::sync::Arc;
28
29// ============================================================================
30// ChatDriver Trait
31// ============================================================================
32
33/// Type alias for the LLM response stream
34pub type LlmResponseStream = Pin<Box<dyn Stream<Item = Result<LlmStreamEvent>> + Send>>;
35
36/// Ordered provider-owned context returned by a native compaction operation.
37///
38/// The runtime carries this value without interpreting or exposing its opaque
39/// payload. The matching provider driver is responsible for putting the items
40/// back on the wire exactly as returned.
41#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(tag = "type", rename_all = "snake_case")]
43pub enum ProviderOpaqueContext {
44    /// Standalone `output` returned by OpenAI `/responses/compact`.
45    OpenResponsesCompact {
46        output: Vec<CompactOutputItem>,
47        #[serde(default, skip_serializing_if = "Option::is_none")]
48        reasoning_state: Option<crate::reasoning_updates::ReasoningState>,
49    },
50}
51
52impl std::fmt::Debug for ProviderOpaqueContext {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Self::OpenResponsesCompact { output, .. } => f
56                .debug_struct("OpenResponsesCompact")
57                .field("item_count", &output.len())
58                .finish_non_exhaustive(),
59        }
60    }
61}
62
63/// Structured provider error emitted inside an accepted response stream.
64///
65/// Providers should preserve the wire error code and HTTP status when they are
66/// available. Runtime retry classification uses those fields before falling
67/// back to the human-readable message for legacy drivers.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct LlmStreamError {
70    /// Stable machine-readable provider error code, when supplied.
71    pub code: Option<String>,
72    /// HTTP status associated with the stream error, when supplied.
73    pub status: Option<u16>,
74    /// Human-readable diagnostic text.
75    pub message: String,
76}
77
78impl LlmStreamError {
79    pub fn new(message: impl Into<String>) -> Self {
80        Self {
81            code: None,
82            status: None,
83            message: message.into(),
84        }
85    }
86
87    /// Build a stream error while preserving provider-supplied structure.
88    pub fn provider(
89        code: Option<impl Into<String>>,
90        status: Option<u16>,
91        message: impl Into<String>,
92    ) -> Self {
93        Self {
94            code: code.map(Into::into),
95            status,
96            message: message.into(),
97        }
98    }
99
100    /// Map the preserved structure to Everruns' semantic provider error kind.
101    pub fn kind(&self) -> LlmErrorKind {
102        if let Some(code) = self.code.as_deref()
103            && let Some(kind) = LlmErrorKind::from_provider_code(code)
104        {
105            return kind;
106        }
107        if let Some(status) = self.status {
108            return LlmErrorKind::from_provider_status(status, &self.message);
109        }
110        LlmErrorKind::from_error_text(&self.message)
111    }
112}
113
114impl std::error::Error for LlmStreamError {}
115
116impl std::fmt::Display for LlmStreamError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match (&self.code, self.status) {
119            (Some(code), Some(status)) => write!(f, "{code} ({status}): {}", self.message),
120            (Some(code), None) => write!(f, "{code}: {}", self.message),
121            (None, Some(status)) => write!(f, "({status}): {}", self.message),
122            (None, None) => f.write_str(&self.message),
123        }
124    }
125}
126
127impl From<String> for LlmStreamError {
128    fn from(message: String) -> Self {
129        Self::new(message)
130    }
131}
132
133impl From<&str> for LlmStreamError {
134    fn from(message: &str) -> Self {
135        Self::new(message)
136    }
137}
138
139/// Events emitted during LLM streaming
140///
141/// `#[non_exhaustive]`: new event kinds arrive with each provider capability
142/// (PDF input, native async tool calls), and a new variant must not break
143/// every consumer's `match`. Consumers ignore what they do not recognize.
144#[derive(Debug, Clone)]
145#[non_exhaustive]
146pub enum LlmStreamEvent {
147    /// Text delta (incremental content)
148    TextDelta(String),
149    /// Incremental readable reasoning for the reasoning block currently open.
150    ///
151    /// Always belongs to the reasoning channel, never the assistant-text
152    /// channel. `summary` marks provider-curated summary text (OpenAI
153    /// Responses) as opposed to raw chain-of-thought, so consumers can label
154    /// what they are showing instead of guessing.
155    ReasoningDelta { delta: String, summary: bool },
156    /// A reasoning block completed.
157    ///
158    /// Carries the whole artifact — readable text plus the opaque id,
159    /// signature and encrypted payload needed to replay it verbatim. One event
160    /// per block, in emission order, so interleaved thinking survives.
161    ReasoningItem(crate::reasoning::ReasoningContentPart),
162    /// Tool calls from the LLM
163    ToolCalls(Vec<ToolCall>),
164    /// Complete native async/custom call; requires a native-call coordinator.
165    NativeToolCall(crate::native_async::NativeToolCall),
166    /// Provider-native execution phase for the current assistant message,
167    /// surfaced mid-stream before completion (EVE-774).
168    ///
169    /// Only emitted by providers whose stream carries a native phase ahead of
170    /// the terminal `Done` metadata (OpenAI Responses exposes it on
171    /// `response.output_item.added`). Consumers use it as a best-effort hint to
172    /// classify streamed assistant text as commentary vs final answer; the
173    /// authoritative value is still the completed `Message.phase`. Other
174    /// providers never emit this and stay unclassified until completion.
175    MessagePhase(crate::execution_phase::ExecutionPhase),
176    /// Streaming completed
177    Done(Box<LlmCompletionMetadata>),
178    /// Error during streaming
179    Error(LlmStreamError),
180}
181
182/// Model information discovered from a provider's list_models API
183///
184/// Represents a model available from a provider. Used for dynamic model discovery
185/// to sync available models from provider APIs into the database.
186///
187/// The `discovered_profile` field carries structured capability/limit metadata
188/// parsed from the provider's API response (e.g., Anthropic's capabilities object).
189/// During model sync, this profile is merged with hardcoded profiles: hardcoded
190/// values take precedence (they include cost data not available from APIs),
191/// but discovered data fills gaps for models without hardcoded profiles.
192#[derive(Debug, Clone)]
193pub struct DiscoveredModel {
194    /// Model identifier (e.g., "gpt-5.2", "claude-opus-4-5-20251101")
195    pub model_id: String,
196    /// Human-readable display name (if provided by API)
197    pub display_name: Option<String>,
198    /// When the model was created/released
199    pub created_at: Option<DateTime<Utc>>,
200    /// Owner or organization (e.g., "openai", "system")
201    pub owned_by: Option<String>,
202    /// Service capabilities advertised for this concrete model (for example,
203    /// `chat` or `embeddings`). These are distinct from provider-level
204    /// services: an OpenAI provider supports both, but each model does not.
205    pub capabilities: Vec<String>,
206    /// Structured profile built from provider API metadata (capabilities, limits).
207    /// Populated by drivers that return rich model metadata (e.g., Anthropic /v1/models).
208    pub discovered_profile: Option<crate::model::ModelProfile>,
209}
210
211/// Metadata about LLM completion
212///
213/// Contains token usage and completion information from the LLM response.
214///
215/// Token buckets are **disjoint** by convention (see the `TokenUsage` event): drivers
216/// normalize provider wire formats at the boundary so `prompt_tokens` carries
217/// only non-cached input, with `cache_read_tokens` / `cache_creation_tokens`
218/// additive on top. Inclusive providers (OpenAI Responses / Chat Completions,
219/// Gemini) subtract their cached count from the reported prompt total via
220/// [`disjoint_prompt_tokens`]; Anthropic / Bedrock already report disjoint
221/// buckets and pass values through unchanged.
222///
223/// `#[non_exhaustive]`: usage and cost dimensions keep being added, so
224/// construct with [`LlmCompletionMetadata::default`] and assign fields.
225#[derive(Debug, Clone, Default)]
226#[non_exhaustive]
227pub struct LlmCompletionMetadata {
228    /// Total tokens used (non-cached prompt + cache read/creation + completion)
229    pub total_tokens: Option<u32>,
230    /// Non-cached prompt tokens (cached reads are excluded; see struct docs)
231    pub prompt_tokens: Option<u32>,
232    /// Completion tokens
233    pub completion_tokens: Option<u32>,
234    /// Tokens read from cache (reduces cost), disjoint from `prompt_tokens`
235    pub cache_read_tokens: Option<u32>,
236    /// Tokens written to cache (Anthropic-specific), disjoint from `prompt_tokens`
237    pub cache_creation_tokens: Option<u32>,
238    /// Authoritative cost of this generation in USD, when the provider reports
239    /// it inline (e.g. OpenRouter's `usage.cost`). `None` for providers that do
240    /// not return a cost.
241    pub provider_cost_usd: Option<f64>,
242    /// Model used
243    pub model: Option<String>,
244    /// Finish reason
245    pub finish_reason: Option<String>,
246    /// Retry metadata (present if rate limit retries occurred)
247    pub retry_metadata: Option<crate::llm_retry::RetryMetadata>,
248    /// Provider's response ID (e.g., OpenAI response ID from response.completed).
249    /// Used for `previous_response_id` chaining and OTel tracing.
250    pub response_id: Option<String>,
251    /// Execution phase from the provider's response (e.g., "commentary", "final_answer").
252    /// When present, this value should be preserved on the assistant message and sent
253    /// back as-is in subsequent requests. Only set by providers with native phase support.
254    pub phase: Option<String>,
255    /// Provider-reported prompt-cache diagnostics, verbatim.
256    ///
257    /// Present only when the request opted in via
258    /// [`LlmCallConfig::cache_diagnostics`] and the provider answered with a
259    /// diagnostics payload (today: Anthropic's `cache-diagnosis` beta). The
260    /// shape is provider-owned, so the runtime carries it without interpreting
261    /// it.
262    pub cache_diagnostics: Option<serde_json::Value>,
263}
264
265/// Normalize an inclusive provider's reported prompt-token count to the disjoint
266/// `TokenUsage` convention by subtracting the cached-read subset.
267///
268/// OpenAI (Responses & Chat Completions) and Gemini report a prompt token count
269/// that *includes* cached reads; callers pass that raw count plus the provider's
270/// cached-read count to get the non-cached remainder. Saturating subtraction
271/// guards against a provider reporting `cache_read > reported_input`. Anthropic /
272/// Bedrock already report disjoint buckets and must not call this.
273///
274pub fn disjoint_prompt_tokens(reported_input: u32, cache_read: Option<u32>) -> u32 {
275    reported_input.saturating_sub(cache_read.unwrap_or(0))
276}
277
278/// Trait for LLM drivers
279///
280/// Implementations handle provider-specific API calls and response parsing.
281///
282/// # Error contract
283///
284/// Drivers surface provider failures as `AgentLoopError` and classify them
285/// semantically at the provider boundary, where HTTP status and response body
286/// are still available:
287///
288/// - request-too-large conditions => `AgentLoopError::request_too_large`
289/// - missing/unknown model => `AgentLoopError::model_not_available`
290/// - everything else => `AgentLoopError::llm_kind(LlmErrorKind::..., msg)`,
291///   using `LlmErrorKind::from_provider_status` (HTTP drivers) or
292///   `LlmErrorKind::from_error_text` (SDK drivers without a status). Plain
293///   `AgentLoopError::llm` is reserved for unclassifiable errors; downstream
294///   then falls back to string classification.
295///
296/// Quota/billing exhaustion (`LlmErrorKind::QuotaExhausted`) is non-transient
297/// and must not be retried by driver retry loops even when the provider
298/// reports it under a transient status like 429.
299#[async_trait]
300pub trait ChatDriver: Send + Sync {
301    /// Opt in on a supported provider/model, retaining the synchronous fallback.
302    fn native_async_driver(
303        &self,
304        _model: &str,
305        _tools: std::collections::BTreeMap<String, Option<serde_json::Value>>,
306        _continuation: Option<crate::native_async::Delivery>,
307    ) -> Option<Arc<dyn ChatDriver>> {
308        None
309    }
310    /// Call the LLM with streaming response
311    async fn chat_completion_stream(
312        &self,
313        endpoint: &crate::runtime_provider::ProviderEndpoint,
314        messages: Vec<LlmMessage>,
315        config: &LlmCallConfig,
316    ) -> Result<LlmResponseStream>;
317
318    /// Call the LLM without streaming (convenience method)
319    async fn chat_completion(
320        &self,
321        endpoint: &crate::runtime_provider::ProviderEndpoint,
322        messages: Vec<LlmMessage>,
323        config: &LlmCallConfig,
324    ) -> Result<LlmResponse> {
325        use futures::StreamExt;
326
327        let mut stream = self
328            .chat_completion_stream(endpoint, messages, config)
329            .await?;
330        let mut text = String::new();
331        let mut reasoning: Vec<crate::reasoning::ReasoningContentPart> = Vec::new();
332        let mut tool_calls = Vec::new();
333        let mut metadata = LlmCompletionMetadata::default();
334
335        while let Some(event) = stream.next().await {
336            match event? {
337                LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
338                // Deltas are a live-rendering concern; the terminal
339                // `ReasoningItem` carries the durable artifact.
340                LlmStreamEvent::ReasoningDelta { .. } => {}
341                LlmStreamEvent::ReasoningItem(item) => reasoning.push(item),
342                LlmStreamEvent::ToolCalls(calls) => tool_calls = calls,
343                LlmStreamEvent::NativeToolCall(_) => {
344                    return Err(crate::error::AgentLoopError::config(
345                        "native async/custom calls require a streaming coordinator",
346                    ));
347                }
348                // Streamed phase hint is a mid-stream refinement only; the
349                // non-streaming collector relies on the terminal Done metadata.
350                LlmStreamEvent::MessagePhase(_) => {}
351                LlmStreamEvent::Done(meta) => metadata = *meta,
352                LlmStreamEvent::Error(err) => {
353                    return Err(crate::error::AgentLoopError::llm_kind(
354                        err.kind(),
355                        err.to_string(),
356                    ));
357                }
358            }
359        }
360
361        Ok(LlmResponse {
362            text,
363            reasoning,
364            tool_calls: if tool_calls.is_empty() {
365                None
366            } else {
367                Some(tool_calls)
368            },
369            metadata,
370        })
371    }
372
373    /// Whether this driver can complete without SSE on the wire.
374    ///
375    /// When `false` (the default), [`Self::chat_completion_non_streaming`]
376    /// falls back to collecting [`Self::chat_completion_stream`], so callers
377    /// still wait for one full response but the provider call streams
378    /// underneath. Drivers with a native `stream: false` JSON endpoint
379    /// return `true` and issue a single request/response call instead.
380    fn supports_native_non_streaming(&self) -> bool {
381        false
382    }
383
384    /// Call the LLM and wait for the full response without SSE.
385    ///
386    /// This is the non-streaming counterpart to
387    /// [`Self::chat_completion_stream`]: no `LlmStreamEvent`s reach the
388    /// caller. The default collects the stream; drivers with a native
389    /// non-streaming endpoint override this to use it.
390    async fn chat_completion_non_streaming(
391        &self,
392        endpoint: &crate::runtime_provider::ProviderEndpoint,
393        messages: Vec<LlmMessage>,
394        config: &LlmCallConfig,
395    ) -> Result<LlmResponse> {
396        self.chat_completion(endpoint, messages, config).await
397    }
398
399    /// List available models from the provider
400    ///
401    /// Returns `Ok(Some(models))` if the provider supports model listing,
402    /// or `Ok(None)` if not supported (e.g., custom endpoints, proxies).
403    ///
404    /// Implementations should filter to chat/completion models only,
405    /// excluding embedding models, TTS, whisper, etc.
406    async fn list_models(
407        &self,
408        _endpoint: &crate::runtime_provider::ProviderEndpoint,
409    ) -> Result<Option<Vec<DiscoveredModel>>> {
410        // Default: not supported. Providers override if they support listing.
411        Ok(None)
412    }
413
414    /// Check if this driver supports the compact endpoint
415    ///
416    /// The compact endpoint compresses conversation history by replacing
417    /// assistant messages, tool calls, and tool results with an encrypted
418    /// compaction item. User messages are kept verbatim.
419    ///
420    /// Returns `true` if the driver supports compaction, `false` otherwise.
421    /// Currently only supported by OpenAI's Responses API.
422    fn supports_compact(&self) -> bool {
423        // Default: not supported
424        false
425    }
426
427    /// Whether this driver persists Responses API state and can resolve tool
428    /// calls that are reachable only through `previous_response_id`.
429    ///
430    /// Stateless and custom drivers default to `false`; they must receive a
431    /// self-contained tool call/result transcript on every request.
432    fn supports_stateful_responses(&self) -> bool {
433        false
434    }
435
436    /// Effective context window for `model`, when the driver has authoritative
437    /// model metadata that is not represented by Everruns' built-in profiles.
438    ///
439    /// External drivers should override this so host policy does not guess from
440    /// a provider/model table that cannot describe their runtime model aliases.
441    fn effective_context_window(&self, _model: &str) -> Option<usize> {
442        None
443    }
444
445    /// Whether this driver can express the request-level `parallel_tool_calls`
446    /// preference on the wire for `model`.
447    ///
448    /// Drivers that map the preference onto a request field (OpenAI/Anthropic
449    /// families) return `true`; drivers whose provider API has no such control
450    /// (Gemini, Bedrock) return `false`. When `false`, the preference is omitted
451    /// from the request and is honored only by the local tool scheduler, so an
452    /// `avoid` preference still serializes tool execution on every provider.
453    ///
454    /// The default is `false` (conservative: omit unless a driver opts in).
455    fn supports_parallel_tool_calls(&self, _model: &str) -> bool {
456        false
457    }
458
459    /// Compact a conversation to reduce context size
460    ///
461    /// This method compresses conversation history by calling the provider's
462    /// compact endpoint. User messages are kept verbatim, while assistant
463    /// messages, tool calls, and tool results are replaced by an encrypted
464    /// compaction item that preserves latent context but is opaque.
465    ///
466    /// # Arguments
467    ///
468    /// * `request` - The compact request containing the model and input items
469    ///
470    /// # Returns
471    ///
472    /// Returns `Ok(Some(response))` if compaction succeeded,
473    /// `Ok(None)` if compaction is not supported by this driver,
474    /// or `Err` if an error occurred.
475    ///
476    /// The response contains the compacted output items which can be used
477    /// directly as input for the next chat completion call.
478    async fn compact(
479        &self,
480        _endpoint: &crate::runtime_provider::ProviderEndpoint,
481        _request: CompactRequest,
482    ) -> Result<Option<CompactResponse>> {
483        // Default: not supported
484        Ok(None)
485    }
486}
487
488/// Implement ChatDriver for `Box<dyn ChatDriver>` to allow dynamic dispatch
489#[async_trait]
490impl ChatDriver for Box<dyn ChatDriver> {
491    fn native_async_driver(
492        &self,
493        model: &str,
494        tools: std::collections::BTreeMap<String, Option<serde_json::Value>>,
495        continuation: Option<crate::native_async::Delivery>,
496    ) -> Option<Arc<dyn ChatDriver>> {
497        (**self).native_async_driver(model, tools, continuation)
498    }
499    async fn chat_completion_stream(
500        &self,
501        endpoint: &crate::runtime_provider::ProviderEndpoint,
502        messages: Vec<LlmMessage>,
503        config: &LlmCallConfig,
504    ) -> Result<LlmResponseStream> {
505        (**self)
506            .chat_completion_stream(endpoint, messages, config)
507            .await
508    }
509
510    async fn chat_completion(
511        &self,
512        endpoint: &crate::runtime_provider::ProviderEndpoint,
513        messages: Vec<LlmMessage>,
514        config: &LlmCallConfig,
515    ) -> Result<LlmResponse> {
516        (**self).chat_completion(endpoint, messages, config).await
517    }
518
519    fn supports_native_non_streaming(&self) -> bool {
520        (**self).supports_native_non_streaming()
521    }
522
523    async fn chat_completion_non_streaming(
524        &self,
525        endpoint: &crate::runtime_provider::ProviderEndpoint,
526        messages: Vec<LlmMessage>,
527        config: &LlmCallConfig,
528    ) -> Result<LlmResponse> {
529        (**self)
530            .chat_completion_non_streaming(endpoint, messages, config)
531            .await
532    }
533
534    async fn list_models(
535        &self,
536        endpoint: &crate::runtime_provider::ProviderEndpoint,
537    ) -> Result<Option<Vec<DiscoveredModel>>> {
538        (**self).list_models(endpoint).await
539    }
540
541    fn supports_compact(&self) -> bool {
542        (**self).supports_compact()
543    }
544
545    fn supports_stateful_responses(&self) -> bool {
546        (**self).supports_stateful_responses()
547    }
548
549    fn effective_context_window(&self, model: &str) -> Option<usize> {
550        (**self).effective_context_window(model)
551    }
552
553    fn supports_parallel_tool_calls(&self, model: &str) -> bool {
554        (**self).supports_parallel_tool_calls(model)
555    }
556
557    async fn compact(
558        &self,
559        endpoint: &crate::runtime_provider::ProviderEndpoint,
560        request: CompactRequest,
561    ) -> Result<Option<CompactResponse>> {
562        (**self).compact(endpoint, request).await
563    }
564}
565
566// ============================================================================
567// Message Types
568// ============================================================================
569
570/// Message format for LLM calls (provider-agnostic)
571#[derive(Debug, Clone)]
572pub struct LlmMessage {
573    /// Provider-native call identities, retained alongside portable fallbacks.
574    pub native_tool_calls: Vec<crate::native_async::NativeToolCall>,
575    pub role: LlmMessageRole,
576    pub content: LlmMessageContent,
577    pub tool_calls: Option<Vec<ToolCall>>,
578    pub tool_call_id: Option<String>,
579    /// Execution phase for assistant messages.
580    /// Helps models distinguish between intermediate working commentary (`Commentary`)
581    /// and completed answers (`FinalAnswer`) in multi-step tool-calling flows.
582    /// Only set on assistant messages. Must be preserved when replaying conversation history.
583    pub phase: Option<crate::execution_phase::ExecutionPhase>,
584    /// Provider reasoning artifacts for this assistant turn, in emission order.
585    ///
586    /// Drivers replay these verbatim in the position the provider issued them:
587    /// each keeps its own signature, id and encrypted payload, so interleaved
588    /// thinking and per-call thought signatures survive a round trip. Empty for
589    /// messages without reasoning.
590    pub reasoning: Vec<crate::reasoning::ReasoningContentPart>,
591    /// Astra effort transition immediately before this message. Other protocols
592    /// ignore it; it is never rendered as conversation text.
593    pub configuration_update: Option<crate::model::ReasoningEffort>,
594}
595
596impl LlmMessage {
597    /// Create a message with text content
598    pub fn text(role: LlmMessageRole, content: impl Into<String>) -> Self {
599        Self {
600            native_tool_calls: Vec::new(),
601            role,
602            content: LlmMessageContent::Text(content.into()),
603            tool_calls: None,
604            tool_call_id: None,
605            phase: None,
606            reasoning: Vec::new(),
607            configuration_update: None,
608        }
609    }
610
611    /// Create a message with content parts (text, images, audio)
612    pub fn parts(role: LlmMessageRole, parts: Vec<LlmContentPart>) -> Self {
613        Self {
614            native_tool_calls: Vec::new(),
615            role,
616            content: LlmMessageContent::Parts(parts),
617            tool_calls: None,
618            tool_call_id: None,
619            phase: None,
620            reasoning: Vec::new(),
621            configuration_update: None,
622        }
623    }
624
625    /// Get content as plain text string (for simple cases)
626    pub fn content_as_text(&self) -> String {
627        self.content.to_text()
628    }
629
630    /// Prepend a prefix to the first text content.
631    ///
632    /// Used by ReasonAtom to inject external actor identity (e.g. `"[Alice] "`)
633    /// into user messages from external channels.
634    pub fn prepend_text_prefix(&mut self, prefix: &str) {
635        match &mut self.content {
636            LlmMessageContent::Text(text) => {
637                *text = format!("{}{}", prefix, text);
638            }
639            LlmMessageContent::Parts(parts) => {
640                for part in parts.iter_mut() {
641                    if let LlmContentPart::Text { text } = part {
642                        *text = format!("{}{}", prefix, text);
643                        return;
644                    }
645                }
646                // No text part found — prepend one
647                parts.insert(
648                    0,
649                    LlmContentPart::Text {
650                        text: prefix.to_string(),
651                    },
652                );
653            }
654        }
655    }
656}
657
658/// Fold every `System`-role message into a single string, joined in order with
659/// blank lines.
660///
661/// Multiple system messages legitimately occur in one request: the agent system
662/// prompt plus, e.g., `infinity_context`'s hidden-history notice or
663/// `compaction`'s `[CONVERSATION_SUMMARY]`. Drivers that map the system role into
664/// a dedicated top-level field (Anthropic `system`, Gemini `system_instruction`,
665/// OpenResponses `instructions`) must accumulate rather than overwrite — otherwise
666/// the real agent system prompt is silently dropped and only the last notice
667/// survives. Returns `None` when there are no system messages.
668pub fn fold_system_messages(messages: &[LlmMessage]) -> Option<String> {
669    let mut system: Option<String> = None;
670    for msg in messages {
671        if msg.role == LlmMessageRole::System {
672            let text = msg.content.to_text();
673            system = Some(match system.take() {
674                Some(existing) if !existing.is_empty() => format!("{existing}\n\n{text}"),
675                _ => text,
676            });
677        }
678    }
679    system
680}
681
682/// Message content - either a simple string or array of content parts
683#[derive(Debug, Clone)]
684pub enum LlmMessageContent {
685    /// Simple text content
686    Text(String),
687    /// Array of content parts (text, images, audio)
688    Parts(Vec<LlmContentPart>),
689}
690
691impl LlmMessageContent {
692    /// Convert to plain text (concatenates text parts, ignores media)
693    pub fn to_text(&self) -> String {
694        match self {
695            LlmMessageContent::Text(s) => s.clone(),
696            LlmMessageContent::Parts(parts) => parts
697                .iter()
698                .filter_map(|p| match p {
699                    LlmContentPart::Text { text } => Some(text.clone()),
700                    _ => None,
701                })
702                .collect::<Vec<_>>()
703                .join(""),
704        }
705    }
706
707    /// Check if content is simple text
708    pub fn is_text(&self) -> bool {
709        matches!(self, LlmMessageContent::Text(_))
710    }
711
712    /// Check if content has multiple parts
713    pub fn is_parts(&self) -> bool {
714        matches!(self, LlmMessageContent::Parts(_))
715    }
716}
717
718impl From<String> for LlmMessageContent {
719    fn from(s: String) -> Self {
720        LlmMessageContent::Text(s)
721    }
722}
723
724impl From<&str> for LlmMessageContent {
725    fn from(s: &str) -> Self {
726        LlmMessageContent::Text(s.to_string())
727    }
728}
729
730/// A single content part within a message
731///
732/// `#[non_exhaustive]` for the same reason as [`LlmStreamEvent`]: new content
733/// kinds are additive and must not break downstream `match`es.
734#[derive(Debug, Clone)]
735#[non_exhaustive]
736pub enum LlmContentPart {
737    /// Text content
738    Text { text: String },
739    /// Image content (base64 data URL or HTTP URL)
740    Image { url: String },
741    /// Audio content (base64 data URL)
742    Audio { url: String },
743    /// File content, e.g. a PDF document (base64 data URL or file URL)
744    File {
745        url: String,
746        filename: Option<String>,
747    },
748}
749
750impl LlmContentPart {
751    /// Create a text content part
752    pub fn text(text: impl Into<String>) -> Self {
753        LlmContentPart::Text { text: text.into() }
754    }
755
756    /// Create an image content part from URL (can be data URL or HTTP URL)
757    pub fn image(url: impl Into<String>) -> Self {
758        LlmContentPart::Image { url: url.into() }
759    }
760
761    /// Create an audio content part from URL (typically a data URL)
762    pub fn audio(url: impl Into<String>) -> Self {
763        LlmContentPart::Audio { url: url.into() }
764    }
765
766    /// Create a file content part from URL (typically a data URL)
767    pub fn file(url: impl Into<String>, filename: Option<String>) -> Self {
768        LlmContentPart::File {
769            url: url.into(),
770            filename,
771        }
772    }
773}
774
775/// Message role for LLM calls
776#[derive(Debug, Clone, PartialEq, Eq)]
777pub enum LlmMessageRole {
778    System,
779    User,
780    Assistant,
781    Tool,
782}
783
784// ============================================================================
785// Configuration and Response Types
786// ============================================================================
787
788/// Configuration for tool_search (deferred tool loading).
789///
790/// When enabled, the driver groups tools into namespaces and marks them with
791/// `defer_loading: true` so the model only loads full schemas on-demand.
792/// This reduces token usage for agents with many tools.
793#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
794pub struct ToolSearchConfig {
795    /// Enable tool_search for this request (requires model support)
796    pub enabled: bool,
797    /// Minimum number of tools before activating tool_search.
798    /// Below this threshold, full schemas are sent even when enabled.
799    pub threshold: usize,
800}
801
802/// Strategy for prompt caching.
803#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
804#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
805#[serde(rename_all = "snake_case")]
806pub enum PromptCacheStrategy {
807    /// Let each driver choose the safest provider-specific behavior.
808    #[default]
809    Auto,
810    /// Cache only the developer-instruction prefix on supporting models.
811    /// The changing conversation suffix is not written to cache.
812    Explicit,
813}
814
815/// Configuration for prompt caching.
816///
817/// Drivers translate this into provider-specific request options when possible.
818/// Unsupported providers or models should ignore it without failing the call.
819#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
820#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
821pub struct PromptCacheConfig {
822    /// Enable prompt caching for this request.
823    pub enabled: bool,
824    /// Strategy the driver should use when enabling prompt caching.
825    #[serde(default)]
826    pub strategy: PromptCacheStrategy,
827    /// Existing Gemini cached content resource name (`cachedContents/{id}`).
828    ///
829    /// When set, the Gemini driver uses explicit caching via the
830    /// `cachedContent` request field. When absent, Gemini falls back to its
831    /// default provider behavior (for example implicit caching on supported
832    /// models).
833    #[serde(default, skip_serializing_if = "Option::is_none")]
834    pub gemini_cached_content: Option<String>,
835}
836
837/// Per-request prompt-cache diagnostics controls.
838///
839/// Anthropic's `cache-diagnosis` beta fingerprints each request and, on the
840/// next one, reports where the prompt prefix diverged (model, system prompt,
841/// tools, or message history) instead of leaving a silent cache miss. Drivers
842/// that have no diagnostics protocol ignore this without failing the call.
843#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
844#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
845pub struct CacheDiagnosticsConfig {
846    /// Opt this request into diagnostics.
847    pub enabled: bool,
848    /// Provider response id of the request to compare this one against.
849    ///
850    /// `None` opts in without a prior request: Anthropic requires the field to
851    /// be present and explicitly `null` on the first turn, so drivers must
852    /// serialize it rather than skip it.
853    #[serde(default, skip_serializing_if = "Option::is_none")]
854    pub previous_message_id: Option<String>,
855}
856
857/// Configuration for an LLM call
858///
859/// `#[non_exhaustive]`: new per-call knobs arrive most releases, and a field
860/// addition must not break every downstream consumer (and force a breaking
861/// bump that cascades through the whole publish cone). Construct with
862/// [`LlmCallConfig::new`] or [`LlmCallConfig::default`] and assign fields, or
863/// use [`LlmCallConfigBuilder`].
864#[derive(Debug, Clone, Default)]
865#[non_exhaustive]
866pub struct LlmCallConfig {
867    /// Durable Astra baseline and effective effort; absent for other modes.
868    pub reasoning_state: Option<crate::reasoning_updates::ReasoningState>,
869    pub model: String,
870    pub temperature: Option<f32>,
871    pub max_tokens: Option<u32>,
872    pub tools: Vec<ToolDefinition>,
873    /// Reasoning effort for models that support it.
874    ///
875    /// `None` means unset — the provider keeps its default. `Some(None)` is the
876    /// caller explicitly asking for no reasoning, which drivers honor by
877    /// omitting the reasoning request fields rather than sending a default.
878    pub reasoning_effort: Option<crate::model::ReasoningEffort>,
879    /// Speed (service tier) for this call: "flex", "default", or "priority".
880    /// Serialized as OpenAI `service_tier`; omitted when `None` so the
881    /// provider keeps its default ("auto") routing.
882    pub speed: Option<String>,
883    /// Verbosity for this call: "low", "medium", or "high". Serialized as
884    /// OpenAI `verbosity`; omitted when `None` so the provider keeps its
885    /// default ("medium") output length.
886    pub verbosity: Option<String>,
887    /// Metadata to send with the API request for tracking and debugging.
888    /// Keys and values are strings. Both OpenAI and Anthropic support metadata fields.
889    /// Typically includes: session_id, agent_id, org_id, turn_id, exec_id.
890    pub metadata: HashMap<String, String>,
891    /// Previous response ID for stateful continuation (OpenAI Responses API).
892    /// When set, the provider can skip re-encoding cached context.
893    pub previous_response_id: Option<String>,
894    /// Standalone, ordered native compact output for this request.
895    ///
896    /// This is mutually exclusive with `previous_response_id`. Provider
897    /// drivers must serialize it as the request input without transcript-delta
898    /// trimming or structural pruning.
899    pub provider_opaque_context: Option<ProviderOpaqueContext>,
900    /// Tool search configuration for deferred tool loading
901    pub tool_search: Option<ToolSearchConfig>,
902    /// Prompt caching configuration for provider-specific cache controls.
903    pub prompt_cache: Option<PromptCacheConfig>,
904    /// Driver-namespaced opaque per-call options (`"<driver-id>/<option>"`, e.g.
905    /// `"openrouter/routing"`). Each entry's shape is owned by the driver crate
906    /// named in the key; this crate never interprets the values.
907    pub driver_options: HashMap<String, serde_json::Value>,
908    /// Request-level parallel tool calling preference (EVE-598).
909    ///
910    /// Serialized onto the provider request when `Some(_)`: OpenAI sets
911    /// `parallel_tool_calls`; Anthropic maps `Some(false)` →
912    /// `tool_choice.disable_parallel_tool_use = true`. `None` preserves
913    /// provider defaults (no field sent).
914    pub parallel_tool_calls: Option<bool>,
915    /// Number of trailing messages that are volatile (regenerated every turn)
916    /// and must not anchor a message-level prompt-cache breakpoint.
917    ///
918    /// `ReasonAtom` sets this to the count of live `<facts>` messages it appends
919    /// at the conversation tail. Drivers that place a message cache breakpoint
920    /// on the last block (Anthropic) skip this many trailing messages so the
921    /// breakpoint lands on the last *stable* block — otherwise a tail that
922    /// changes each turn would evict the conversation-history cache. `0` (the
923    /// default) preserves the previous behavior exactly.
924    pub volatile_suffix_len: usize,
925    /// Extra HTTP headers to attach to every provider request made for this
926    /// call.
927    ///
928    /// Merged case-insensitively over the driver's protocol headers and the
929    /// provider's configured/auth headers, so a caller value replaces an
930    /// existing header instead of appending a second copy. Connection-level
931    /// headers are dropped (see
932    /// [`merge_request_headers`](crate::driver_helpers::merge_request_headers)).
933    pub extra_headers: Vec<(String, String)>,
934    /// Prompt-cache diagnostics requested for this call.
935    pub cache_diagnostics: Option<CacheDiagnosticsConfig>,
936}
937
938impl LlmCallConfig {
939    /// Create a config for `model`, leaving every other knob at its default.
940    pub fn new(model: impl Into<String>) -> Self {
941        Self {
942            model: model.into(),
943            ..Default::default()
944        }
945    }
946
947    /// Resolve the effective wire value for `parallel_tool_calls`, gated by
948    /// whether the driver/model can express it on the request.
949    ///
950    /// Returns `None` (omit the field, keep the provider default) when the
951    /// preference is unset or `supported` is `false`. Drivers call this with
952    /// `self.supports_parallel_tool_calls(&config.model)` so the preference is
953    /// only serialized where the provider has a control for it. The local tool
954    /// scheduler honors the preference independently, so `Some(false)` still
955    /// serializes execution even when this returns `None`.
956    pub fn resolved_parallel_tool_calls(&self, supported: bool) -> Option<bool> {
957        if supported {
958            self.parallel_tool_calls
959        } else {
960            None
961        }
962    }
963}
964
965// The `From<&RuntimeAgent>` adapter for LlmCallConfig lives in
966// everruns-core (`llm_conversions`), since RuntimeAgent is a core domain type.
967
968/// Response from an LLM call (non-streaming)
969#[derive(Debug, Clone)]
970pub struct LlmResponse {
971    pub text: String,
972    /// Provider reasoning artifacts, in emission order.
973    pub reasoning: Vec<crate::reasoning::ReasoningContentPart>,
974    pub tool_calls: Option<Vec<ToolCall>>,
975    pub metadata: LlmCompletionMetadata,
976}
977
978/// Builder for LlmCallConfig with fluent API
979///
980/// Chain methods like `reasoning_effort()`, `temperature()`, etc. and call
981/// `build()` to get the final config. To start from a core `RuntimeAgent`, use
982/// `everruns_core::llm_conversions::llm_call_config_builder_from_agent`.
983pub struct LlmCallConfigBuilder {
984    config: LlmCallConfig,
985}
986
987impl LlmCallConfigBuilder {
988    /// Construct a builder wrapping an existing config.
989    pub fn from_config(config: LlmCallConfig) -> Self {
990        Self { config }
991    }
992
993    /// Set reasoning effort for models that support it.
994    pub fn reasoning_effort(mut self, effort: crate::model::ReasoningEffort) -> Self {
995        self.config.reasoning_effort = Some(effort);
996        self
997    }
998
999    /// Set speed (service tier): "flex", "default", or "priority"
1000    pub fn speed(mut self, speed: impl Into<String>) -> Self {
1001        self.config.speed = Some(speed.into());
1002        self
1003    }
1004
1005    /// Set verbosity: "low", "medium", or "high"
1006    pub fn verbosity(mut self, verbosity: impl Into<String>) -> Self {
1007        self.config.verbosity = Some(verbosity.into());
1008        self
1009    }
1010
1011    /// Set the model
1012    pub fn model(mut self, model: impl Into<String>) -> Self {
1013        self.config.model = model.into();
1014        self
1015    }
1016
1017    /// Set temperature
1018    pub fn temperature(mut self, temp: f32) -> Self {
1019        self.config.temperature = Some(temp);
1020        self
1021    }
1022
1023    /// Set max tokens
1024    pub fn max_tokens(mut self, tokens: u32) -> Self {
1025        self.config.max_tokens = Some(tokens);
1026        self
1027    }
1028
1029    /// Set tools
1030    pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
1031        self.config.tools = tools;
1032        self
1033    }
1034
1035    /// Set metadata for API tracking
1036    ///
1037    /// This metadata is sent to the LLM provider for tracking and debugging.
1038    /// Typically includes session_id, agent_id, org_id, turn_id, exec_id.
1039    pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
1040        self.config.metadata = metadata;
1041        self
1042    }
1043
1044    /// Add a single metadata key-value pair
1045    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1046        self.config.metadata.insert(key.into(), value.into());
1047        self
1048    }
1049
1050    /// Set previous response ID for stateful continuation
1051    pub fn previous_response_id(mut self, id: Option<String>) -> Self {
1052        self.config.previous_response_id = id;
1053        self
1054    }
1055
1056    /// Set standalone provider-owned compact context for the request.
1057    pub fn provider_opaque_context(mut self, context: Option<ProviderOpaqueContext>) -> Self {
1058        self.config.provider_opaque_context = context;
1059        self
1060    }
1061
1062    /// Set tool_search configuration
1063    pub fn tool_search(mut self, config: ToolSearchConfig) -> Self {
1064        self.config.tool_search = Some(config);
1065        self
1066    }
1067
1068    /// Set prompt caching configuration
1069    pub fn prompt_cache(mut self, config: PromptCacheConfig) -> Self {
1070        self.config.prompt_cache = Some(config);
1071        self
1072    }
1073
1074    /// Set a driver-namespaced opaque per-call option (`"<driver-id>/<option>"`).
1075    /// The value's shape is owned by the driver crate named in the key; this
1076    /// crate passes it through untouched.
1077    pub fn driver_option(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
1078        self.config.driver_options.insert(key.into(), value);
1079        self
1080    }
1081
1082    /// Set the request-level parallel tool calling preference (EVE-598).
1083    pub fn parallel_tool_calls(mut self, parallel_tool_calls: Option<bool>) -> Self {
1084        self.config.parallel_tool_calls = parallel_tool_calls;
1085        self
1086    }
1087
1088    /// Set the number of trailing volatile messages that must not anchor a
1089    /// message-level prompt-cache breakpoint (see
1090    /// [`LlmCallConfig::volatile_suffix_len`]).
1091    pub fn volatile_suffix_len(mut self, len: usize) -> Self {
1092        self.config.volatile_suffix_len = len;
1093        self
1094    }
1095
1096    /// Replace the extra HTTP headers sent with this call.
1097    pub fn extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
1098        self.config.extra_headers = headers;
1099        self
1100    }
1101
1102    /// Add one extra HTTP header to send with this call.
1103    pub fn extra_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
1104        self.config.extra_headers.push((name.into(), value.into()));
1105        self
1106    }
1107
1108    /// Request provider prompt-cache diagnostics for this call.
1109    pub fn cache_diagnostics(mut self, config: CacheDiagnosticsConfig) -> Self {
1110        self.config.cache_diagnostics = Some(config);
1111        self
1112    }
1113
1114    /// Build the configuration
1115    pub fn build(self) -> LlmCallConfig {
1116        self.config
1117    }
1118}
1119
1120// The Message->LlmMessage adapters (plain, with-images, and image-file
1121// helpers) live in everruns-core (`llm_conversions`): they depend on core
1122// domain types (Message, ContentPart, ResolvedImage).
1123
1124// ============================================================================
1125// Driver Factory Types
1126// ============================================================================
1127
1128pub use crate::provider::DriverId;
1129
1130/// Extra provider-specific authentication/metadata beyond an API key.
1131///
1132/// Built-in providers ignore this; embedder-defined ([`DriverId::External`])
1133/// providers use it to carry OAuth tokens, account ids, or arbitrary extras
1134/// their driver factory needs.
1135#[derive(Clone, Default, PartialEq, Eq)]
1136pub struct ProviderMetadata {
1137    /// OAuth refresh token, when the provider authenticates via OAuth.
1138    pub refresh_token: Option<String>,
1139    /// Provider-side account identifier, when required.
1140    pub account_id: Option<String>,
1141    /// Arbitrary extra fields the driver factory understands.
1142    pub extra: Option<serde_json::Value>,
1143}
1144
1145impl std::fmt::Debug for ProviderMetadata {
1146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1147        f.debug_struct("ProviderMetadata")
1148            .field(
1149                "refresh_token",
1150                &self.refresh_token.as_ref().map(|_| "<configured>"),
1151            )
1152            .field("account_id", &self.account_id)
1153            .field("extra", &self.extra.as_ref().map(|_| "<configured>"))
1154            .finish()
1155    }
1156}
1157
1158/// Configuration for creating an LLM provider
1159///
1160/// `#[non_exhaustive]`: construct with [`ProviderConfig::new`] or
1161/// [`ProviderConfig::for_provider`] and the `with_*` setters, so that adding a
1162/// connection-level field stays a non-breaking change.
1163#[derive(Clone)]
1164#[non_exhaustive]
1165pub struct ProviderConfig {
1166    /// Runtime service identity selected by the model.
1167    pub provider: crate::runtime_provider::ProviderKey,
1168    /// Type of provider
1169    pub provider_type: DriverId,
1170    /// API key for authentication
1171    pub api_key: Option<String>,
1172    /// Base URL override (optional)
1173    pub base_url: Option<String>,
1174    /// Extra provider-specific metadata (OAuth tokens, account ids, etc.).
1175    pub metadata: ProviderMetadata,
1176    /// Connection-level request options (extra headers, diagnostics opt-in)
1177    /// applied to every call made through this provider.
1178    pub request_options: crate::provider::ProviderRequestOptions,
1179}
1180
1181impl ProviderConfig {
1182    /// Create a new provider config
1183    pub fn new(provider_type: DriverId) -> Self {
1184        let provider = crate::runtime_provider::ProviderKey::new(provider_type.as_str());
1185        Self {
1186            provider,
1187            provider_type,
1188            api_key: None,
1189            base_url: None,
1190            metadata: ProviderMetadata::default(),
1191            request_options: Default::default(),
1192        }
1193    }
1194
1195    /// Configure a runtime provider id independently from its hosted
1196    /// integration kind.
1197    pub fn for_provider(
1198        provider: impl Into<crate::runtime_provider::ProviderKey>,
1199        provider_type: DriverId,
1200    ) -> Self {
1201        Self {
1202            provider: provider.into(),
1203            provider_type,
1204            api_key: None,
1205            base_url: None,
1206            metadata: ProviderMetadata::default(),
1207            request_options: Default::default(),
1208        }
1209    }
1210
1211    /// Set the API key
1212    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
1213        self.api_key = Some(api_key.into());
1214        self
1215    }
1216
1217    /// Set the base URL
1218    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
1219        self.base_url = Some(base_url.into());
1220        self
1221    }
1222
1223    /// Set provider-specific metadata.
1224    pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self {
1225        self.metadata = metadata;
1226        self
1227    }
1228
1229    /// Set the connection-level request options.
1230    pub fn with_request_options(
1231        mut self,
1232        request_options: crate::provider::ProviderRequestOptions,
1233    ) -> Self {
1234        self.request_options = request_options;
1235        self
1236    }
1237}
1238
1239impl std::fmt::Debug for ProviderConfig {
1240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1241        f.debug_struct("ProviderConfig")
1242            .field("provider", &self.provider)
1243            .field("provider_type", &self.provider_type)
1244            .field("auth", &self.api_key.as_ref().map(|_| "<configured>"))
1245            .field("base_url", &self.base_url.as_ref().map(|_| "<configured>"))
1246            .field(
1247                "metadata",
1248                &self.metadata.extra.as_ref().map(|_| "<configured>"),
1249            )
1250            .finish()
1251    }
1252}
1253
1254/// Everything a [`DriverFactory`] receives to build a driver instance.
1255///
1256/// Replaces the old `(api_key, base_url)` factory arguments so that
1257/// embedder-defined providers can receive richer auth via [`ProviderMetadata`]
1258/// without changing the factory signature again.
1259#[derive(Clone)]
1260pub struct DriverConfig {
1261    /// Runtime service identity.
1262    pub provider: crate::runtime_provider::ProviderKey,
1263    /// Provider type being created.
1264    pub provider_type: DriverId,
1265    /// Raw credential document, when one is configured. `None` for keyless
1266    /// providers (LlmSim, or external providers that authenticate via
1267    /// [`ProviderMetadata`]). For single-key drivers this is the API key
1268    /// verbatim; multi-field drivers should read [`DriverConfig::credentials`]
1269    /// instead of parsing this string.
1270    pub api_key: Option<String>,
1271    /// Typed credential fields parsed from the stored credential document (see
1272    /// [`crate::credential_schema::parse_credential_document`]). Multi-field
1273    /// drivers (Bedrock AWS keys, MAI Entra OAuth) read their declared fields
1274    /// from here instead of hand-parsing JSON out of `api_key`. Empty for
1275    /// keyless providers.
1276    pub credentials: std::collections::BTreeMap<String, String>,
1277    /// Base URL override, when configured.
1278    pub base_url: Option<String>,
1279    /// Extra provider-specific metadata.
1280    pub metadata: ProviderMetadata,
1281}
1282
1283impl DriverConfig {
1284    /// Build a driver config from a resolved [`ProviderConfig`], parsing the
1285    /// credential document into the typed [`DriverConfig::credentials`] map.
1286    /// This is the single point where the stored credential string becomes
1287    /// typed fields, so every driver-creation path (server, worker, sync, dev)
1288    /// gets the same typed view.
1289    pub fn from_provider_config(config: &ProviderConfig) -> Self {
1290        Self {
1291            provider: config.provider.clone(),
1292            provider_type: config.provider_type.clone(),
1293            credentials: crate::credential_schema::parse_credential_document(
1294                config.api_key.as_deref(),
1295            ),
1296            api_key: config.api_key.clone(),
1297            base_url: config.base_url.clone(),
1298            metadata: config.metadata.clone(),
1299        }
1300    }
1301
1302    /// A declared credential field's non-empty value, if present.
1303    pub fn credential(&self, name: &str) -> Option<&str> {
1304        self.credentials
1305            .get(name)
1306            .map(String::as_str)
1307            .filter(|s| !s.is_empty())
1308    }
1309}
1310
1311impl std::fmt::Debug for DriverConfig {
1312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1313        f.debug_struct("DriverConfig")
1314            .field("provider", &self.provider)
1315            .field("provider_type", &self.provider_type)
1316            .field("auth", &self.api_key.as_ref().map(|_| "<configured>"))
1317            .field(
1318                "credential_fields",
1319                &self.credentials.keys().collect::<Vec<_>>(),
1320            )
1321            .field("base_url", &self.base_url.as_ref().map(|_| "<configured>"))
1322            .finish()
1323    }
1324}
1325
1326/// Boxed chat driver for dynamic dispatch
1327pub type BoxedChatDriver = Box<dyn ChatDriver>;
1328
1329// ============================================================================
1330// EmbeddingsDriver Trait
1331// ============================================================================
1332
1333/// Request to embed a batch of text strings into dense vectors.
1334#[derive(Debug, Clone)]
1335pub struct EmbedRequest {
1336    /// Texts to embed. All texts in a batch share the same model.
1337    pub texts: Vec<String>,
1338    /// Provider-side model id (e.g. `text-embedding-3-small`).
1339    pub model: String,
1340}
1341
1342/// Response from an embedding request.
1343#[derive(Debug, Clone)]
1344pub struct EmbedResponse {
1345    /// One float vector per input text, in the same order.
1346    pub embeddings: Vec<Vec<f32>>,
1347    /// Total tokens consumed (for usage tracking). `None` if the provider
1348    /// does not report token counts.
1349    pub usage_tokens: Option<u32>,
1350    /// Actual cost of this call in USD, as reported by the provider inline
1351    /// (OpenAI-compatible gateways report `usage.cost`). `None` for providers
1352    /// that do not return a cost — direct OpenAI does not, same as the chat
1353    /// path (EVE-894).
1354    pub actual_cost_usd: Option<f64>,
1355}
1356
1357/// Error returned by [`EmbeddingsDriver::embed`].
1358#[derive(Debug, thiserror::Error)]
1359pub enum EmbeddingsDriverError {
1360    #[error("embeddings provider returned an error: {0}")]
1361    Provider(String),
1362    #[error("embeddings request failed: {0}")]
1363    Transport(String),
1364}
1365
1366/// Driver trait for text embedding services.
1367///
1368/// Implementors call their provider's embedding API and return dense float
1369/// vectors. Used by knowledge-base hybrid retrieval (see knowledge/runtime-resources/knowledge-bases.md
1370/// and knowledge/foundations/providers.md phase 6).
1371#[async_trait]
1372pub trait EmbeddingsDriver: Send + Sync {
1373    /// Embed a batch of texts and return one vector per input.
1374    async fn embed(
1375        &self,
1376        endpoint: &crate::runtime_provider::ProviderEndpoint,
1377        request: EmbedRequest,
1378    ) -> std::result::Result<EmbedResponse, EmbeddingsDriverError>;
1379}
1380
1381#[async_trait]
1382impl EmbeddingsDriver for Box<dyn EmbeddingsDriver> {
1383    async fn embed(
1384        &self,
1385        endpoint: &crate::runtime_provider::ProviderEndpoint,
1386        request: EmbedRequest,
1387    ) -> std::result::Result<EmbedResponse, EmbeddingsDriverError> {
1388        (**self).embed(endpoint, request).await
1389    }
1390}
1391
1392/// Boxed embeddings driver for dynamic dispatch.
1393pub type BoxedEmbeddingsDriver = Box<dyn EmbeddingsDriver>;
1394
1395/// Factory function type for creating embeddings drivers.
1396pub type EmbeddingsDriverFactory =
1397    Arc<dyn Fn(&DriverConfig) -> BoxedEmbeddingsDriver + Send + Sync>;
1398
1399// ============================================================================
1400// Driver Registry
1401// ============================================================================
1402
1403/// Factory function type for creating chat drivers.
1404///
1405/// Receives a [`DriverConfig`] (provider type, optional key/base URL, and
1406/// provider metadata) and returns a boxed driver.
1407pub type DriverFactory = Arc<dyn Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync>;
1408
1409/// A fully constructed driver whose provider selection is valid but whose
1410/// credential document is not yet configured.
1411///
1412/// Hosts must be able to assemble a turn context so setup commands can repair
1413/// provider configuration. The gate therefore sits at the first operation
1414/// that could reach the provider, rather than at driver construction time.
1415struct CredentialGateDriver {
1416    inner: BoxedChatDriver,
1417    message: String,
1418}
1419
1420impl CredentialGateDriver {
1421    fn error(&self) -> AgentLoopError {
1422        AgentLoopError::llm_kind(LlmErrorKind::Authentication, self.message.clone())
1423    }
1424}
1425
1426#[async_trait]
1427impl ChatDriver for CredentialGateDriver {
1428    async fn chat_completion_stream(
1429        &self,
1430        _endpoint: &crate::runtime_provider::ProviderEndpoint,
1431        _messages: Vec<LlmMessage>,
1432        _config: &LlmCallConfig,
1433    ) -> Result<LlmResponseStream> {
1434        Err(self.error())
1435    }
1436
1437    async fn list_models(
1438        &self,
1439        _endpoint: &crate::runtime_provider::ProviderEndpoint,
1440    ) -> Result<Option<Vec<DiscoveredModel>>> {
1441        Err(self.error())
1442    }
1443
1444    fn supports_compact(&self) -> bool {
1445        self.inner.supports_compact()
1446    }
1447
1448    fn supports_stateful_responses(&self) -> bool {
1449        self.inner.supports_stateful_responses()
1450    }
1451
1452    fn effective_context_window(&self, model: &str) -> Option<usize> {
1453        self.inner.effective_context_window(model)
1454    }
1455
1456    fn supports_parallel_tool_calls(&self, model: &str) -> bool {
1457        self.inner.supports_parallel_tool_calls(model)
1458    }
1459
1460    async fn compact(
1461        &self,
1462        _endpoint: &crate::runtime_provider::ProviderEndpoint,
1463        _request: CompactRequest,
1464    ) -> Result<Option<CompactResponse>> {
1465        Err(self.error())
1466    }
1467}
1468
1469/// Applies a provider connection's [`ProviderRequestOptions`] to every call made
1470/// through it, by rewriting the per-call [`LlmCallConfig`] before delegating.
1471///
1472/// This sits above the wire drivers on purpose: the options are expressed in
1473/// terms drivers already understand (`extra_headers`, `cache_diagnostics`), so
1474/// no driver needs to know that a connection can carry them, and a driver that
1475/// implements neither simply ignores the config it is handed.
1476struct RequestOptionsDriver {
1477    inner: Arc<dyn ChatDriver>,
1478    options: crate::provider::ProviderRequestOptions,
1479}
1480
1481impl RequestOptionsDriver {
1482    /// Wrap `driver` when `options` change anything; otherwise hand it back
1483    /// unchanged so the common path adds no indirection.
1484    fn wrap(
1485        driver: BoxedChatDriver,
1486        options: &crate::provider::ProviderRequestOptions,
1487    ) -> BoxedChatDriver {
1488        if options.is_empty() {
1489            return driver;
1490        }
1491        Box::new(Self {
1492            inner: Arc::from(driver),
1493            options: options.clone(),
1494        })
1495    }
1496
1497    fn apply(&self, config: &LlmCallConfig) -> LlmCallConfig {
1498        let mut config = config.clone();
1499        config.extra_headers.extend(self.options.header_pairs());
1500        if self.options.cache_diagnostics {
1501            config.cache_diagnostics = Some(CacheDiagnosticsConfig {
1502                enabled: true,
1503                // Chain to the previous generation of this turn so the provider
1504                // can report *where* the prompt prefix diverged. `None` on the
1505                // first call opts in without a comparison point.
1506                previous_message_id: config.previous_response_id.clone(),
1507            });
1508        }
1509        config
1510    }
1511}
1512
1513#[async_trait]
1514impl ChatDriver for RequestOptionsDriver {
1515    fn native_async_driver(
1516        &self,
1517        model: &str,
1518        tools: std::collections::BTreeMap<String, Option<serde_json::Value>>,
1519        continuation: Option<crate::native_async::Delivery>,
1520    ) -> Option<Arc<dyn ChatDriver>> {
1521        Some(Arc::new(Self {
1522            inner: self.inner.native_async_driver(model, tools, continuation)?,
1523            options: self.options.clone(),
1524        }))
1525    }
1526    async fn chat_completion_stream(
1527        &self,
1528        endpoint: &crate::runtime_provider::ProviderEndpoint,
1529        messages: Vec<LlmMessage>,
1530        config: &LlmCallConfig,
1531    ) -> Result<LlmResponseStream> {
1532        self.inner
1533            .chat_completion_stream(endpoint, messages, &self.apply(config))
1534            .await
1535    }
1536
1537    async fn chat_completion(
1538        &self,
1539        endpoint: &crate::runtime_provider::ProviderEndpoint,
1540        messages: Vec<LlmMessage>,
1541        config: &LlmCallConfig,
1542    ) -> Result<LlmResponse> {
1543        self.inner
1544            .chat_completion(endpoint, messages, &self.apply(config))
1545            .await
1546    }
1547
1548    fn supports_native_non_streaming(&self) -> bool {
1549        self.inner.supports_native_non_streaming()
1550    }
1551
1552    async fn chat_completion_non_streaming(
1553        &self,
1554        endpoint: &crate::runtime_provider::ProviderEndpoint,
1555        messages: Vec<LlmMessage>,
1556        config: &LlmCallConfig,
1557    ) -> Result<LlmResponse> {
1558        self.inner
1559            .chat_completion_non_streaming(endpoint, messages, &self.apply(config))
1560            .await
1561    }
1562
1563    async fn list_models(
1564        &self,
1565        endpoint: &crate::runtime_provider::ProviderEndpoint,
1566    ) -> Result<Option<Vec<DiscoveredModel>>> {
1567        self.inner.list_models(endpoint).await
1568    }
1569
1570    fn supports_compact(&self) -> bool {
1571        self.inner.supports_compact()
1572    }
1573
1574    fn supports_stateful_responses(&self) -> bool {
1575        self.inner.supports_stateful_responses()
1576    }
1577
1578    fn effective_context_window(&self, model: &str) -> Option<usize> {
1579        self.inner.effective_context_window(model)
1580    }
1581
1582    fn supports_parallel_tool_calls(&self, model: &str) -> bool {
1583        self.inner.supports_parallel_tool_calls(model)
1584    }
1585
1586    async fn compact(
1587        &self,
1588        endpoint: &crate::runtime_provider::ProviderEndpoint,
1589        request: CompactRequest,
1590    ) -> Result<Option<CompactResponse>> {
1591        self.inner.compact(endpoint, request).await
1592    }
1593}
1594
1595/// A typed service a provider driver can offer (see knowledge/foundations/providers.md).
1596///
1597/// Declared in code by each driver, never stored in the database. Only `Chat`
1598/// has a driver trait today; the set is additive and new kinds gain factories
1599/// on [`DriverDescriptor`] when their first consumer lands.
1600///
1601/// Defined in `everruns-model-profiles` (profile data is keyed by service
1602/// kind) and re-exported here for source compatibility.
1603pub use everruns_model_profiles::ServiceKind;
1604
1605/// Wire flavor of a driver's interactive OAuth connect flow.
1606///
1607/// A driver may let an org admin connect a provider by authorizing in the
1608/// browser instead of pasting an API key. The flow always yields a long-lived
1609/// credential that lands in `providers.credentials_encrypted`, exactly like a
1610/// hand-entered key — so runtime resolution is unchanged and non-admin users
1611/// are unaffected (see knowledge/foundations/providers.md "OAuth provider connection").
1612///
1613/// Only OpenRouter's PKCE flavor exists today. Adding OAuth to another driver
1614/// means a new variant here (which the server matches on) plus a
1615/// [`DriverOAuthConfig`] on that driver's descriptor — never a parallel set of
1616/// endpoints.
1617#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1618pub enum DriverOAuthFlow {
1619    /// OpenRouter one-click PKCE
1620    /// (<https://openrouter.ai/docs/guides/overview/auth/oauth>): redirect the
1621    /// admin to `authorize_url?callback_url=..&code_challenge=..&code_challenge_method=S256`,
1622    /// then POST JSON `{code, code_verifier, code_challenge_method}` to
1623    /// `token_url`; the `key` field of the response is the user-controlled API
1624    /// key to store. No client registration or secret is required (public PKCE
1625    /// client).
1626    OpenRouterPkce,
1627}
1628
1629/// A driver's declared OAuth connect flow.
1630///
1631/// Presence of this on a [`DriverDescriptor`] is what makes "Connect with
1632/// {provider}" available; absence means credentials must be entered manually.
1633#[derive(Debug, Clone)]
1634pub struct DriverOAuthConfig {
1635    /// Authorization endpoint the admin's browser is redirected to.
1636    pub authorize_url: String,
1637    /// Endpoint that exchanges the returned authorization code for a credential.
1638    pub token_url: String,
1639    /// Wire flavor of the two steps above.
1640    pub flow: DriverOAuthFlow,
1641}
1642
1643impl DriverOAuthConfig {
1644    /// OpenRouter's one-click PKCE connect flow.
1645    pub fn openrouter() -> Self {
1646        Self {
1647            authorize_url: "https://openrouter.ai/auth".to_string(),
1648            token_url: "https://openrouter.ai/api/v1/auth/keys".to_string(),
1649            flow: DriverOAuthFlow::OpenRouterPkce,
1650        }
1651    }
1652}
1653
1654/// A registered provider driver: identity, declared services, the credential
1655/// shape its providers must supply, and per-service factories.
1656///
1657/// The descriptor is the code-side unit of the providers domain model
1658/// (knowledge/foundations/providers.md): one descriptor per driver id, instantiated as many
1659/// org-scoped providers.
1660#[derive(Clone)]
1661pub struct DriverDescriptor {
1662    /// Driver id (also the registry key).
1663    pub id: DriverId,
1664    /// Human-readable driver name (e.g. "OpenAI", "AWS Bedrock").
1665    pub display_name: String,
1666    /// Services this driver's providers can power. Declared, not stored.
1667    pub services: Vec<ServiceKind>,
1668    /// Credential fields a provider instance must supply.
1669    pub credential_schema: CredentialFormSchema,
1670    /// Environment variable that overrides this driver's default endpoint in
1671    /// standalone/dev use (e.g. `OPENAI_BASE_URL`), following the vendor's own
1672    /// SDK convention.
1673    ///
1674    /// Declared here rather than on the credential schema because the base URL
1675    /// is not a credential field: it lives on `ProviderConfig`, never in the
1676    /// stored credential document, and connectors share the schema type without
1677    /// having endpoints at all. `None` for drivers whose vendor defines no
1678    /// endpoint variable, or whose endpoint is part of the credential itself
1679    /// (Bedrock's region).
1680    pub base_url_env: Option<String>,
1681    /// Optional interactive OAuth connect flow. `Some` makes "Connect with
1682    /// {provider}" available as an alternative to entering a key by hand.
1683    pub oauth: Option<DriverOAuthConfig>,
1684    /// Chat service factory. `None` for drivers that only offer other services.
1685    pub chat: Option<DriverFactory>,
1686    /// Embeddings service factory. `None` for drivers that do not support embeddings.
1687    pub embeddings: Option<EmbeddingsDriverFactory>,
1688}
1689
1690impl DriverDescriptor {
1691    /// Descriptor for a chat-only driver with the default credential schema
1692    /// for the driver id (a single required `api_key` field for real
1693    /// providers; empty for `LlmSim` and `External`, which may authenticate
1694    /// via [`ProviderMetadata`]) and a display name derived from the id.
1695    pub fn chat_only<F>(id: impl Into<DriverId>, factory: F) -> Self
1696    where
1697        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1698    {
1699        let id = id.into();
1700        Self {
1701            display_name: default_display_name(&id),
1702            credential_schema: default_credential_schema(&id),
1703            base_url_env: None,
1704            services: vec![ServiceKind::Chat],
1705            oauth: None,
1706            chat: Some(Arc::new(factory)),
1707            embeddings: None,
1708            id,
1709        }
1710    }
1711
1712    /// Declare the environment variable that overrides the default endpoint.
1713    pub fn with_base_url_env(mut self, base_url_env: impl Into<String>) -> Self {
1714        self.base_url_env = Some(base_url_env.into());
1715        self
1716    }
1717
1718    /// Whether the driver declares the given service.
1719    pub fn supports(&self, service: ServiceKind) -> bool {
1720        self.services.contains(&service)
1721    }
1722
1723    /// Every environment variable this driver declares, credential fields first
1724    /// in schema order and the endpoint override last.
1725    ///
1726    /// Drives actionable "set X or Y" messages and the published credential
1727    /// documentation, both of which would otherwise have to restate names the
1728    /// driver already owns.
1729    pub fn declared_env_vars(&self) -> Vec<String> {
1730        self.credential_schema
1731            .fields
1732            .iter()
1733            .flat_map(|field| field.env.iter().cloned())
1734            .chain(self.base_url_env.clone())
1735            .collect()
1736    }
1737
1738    /// The declared endpoint override, when this driver declares one and it is
1739    /// set to a non-empty value in the given environment.
1740    pub fn base_url_from_env<F>(&self, lookup: F) -> Option<String>
1741    where
1742        F: Fn(&str) -> Option<String>,
1743    {
1744        self.base_url_env
1745            .as_deref()
1746            .and_then(lookup)
1747            .filter(|value| !value.trim().is_empty())
1748    }
1749}
1750
1751impl std::fmt::Debug for DriverDescriptor {
1752    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1753        f.debug_struct("DriverDescriptor")
1754            .field("id", &self.id)
1755            .field("display_name", &self.display_name)
1756            .field("services", &self.services)
1757            .field("oauth", &self.oauth.is_some())
1758            .field("chat", &self.chat.is_some())
1759            .field("embeddings", &self.embeddings.is_some())
1760            .finish()
1761    }
1762}
1763
1764fn default_display_name(id: &DriverId) -> String {
1765    id.as_str().replace(['_', '-'], " ")
1766}
1767
1768fn default_credential_schema(id: &DriverId) -> CredentialFormSchema {
1769    if id == &DriverId::LlmSim {
1770        CredentialFormSchema::empty()
1771    } else {
1772        // No environment variable: the registry cannot know a driver's vendor
1773        // convention, and guessing one is what the per-driver declaration
1774        // exists to prevent. A driver that wants env resolution overrides this
1775        // schema and names its own variables.
1776        CredentialFormSchema {
1777            fields: vec![
1778                crate::credential_schema::FormField::password("api_key", "API Key").required(),
1779            ],
1780            instructions_markdown: String::new(),
1781        }
1782    }
1783}
1784
1785/// Registry for LLM drivers
1786///
1787/// Enables dependency inversion: provider crates (everruns-anthropic, everruns-openai)
1788/// register their drivers at startup. The core has no direct knowledge of implementations.
1789///
1790/// # Example
1791///
1792/// ```ignore
1793/// use everruns_core::{DriverRegistry, DriverId};
1794/// use everruns_anthropic::register_driver;
1795/// use everruns_openai::register_driver as register_openai;
1796///
1797/// let mut registry = DriverRegistry::new();
1798/// everruns_anthropic::register_driver(&mut registry);
1799/// everruns_openai::register_driver(&mut registry);
1800///
1801/// // Later, create a driver from config
1802/// let driver = registry.create_chat_driver(&config)?;
1803/// ```
1804#[derive(Clone, Default)]
1805pub struct DriverRegistry {
1806    descriptors: HashMap<DriverId, DriverDescriptor>,
1807    providers: crate::runtime_provider::RuntimeProviderRegistry,
1808}
1809
1810impl DriverRegistry {
1811    /// Create a new empty registry
1812    pub fn new() -> Self {
1813        Self {
1814            descriptors: HashMap::new(),
1815            providers: crate::runtime_provider::RuntimeProviderRegistry::new(),
1816        }
1817    }
1818
1819    /// Register an application-supplied runtime provider directly.
1820    pub fn register_provider(
1821        &mut self,
1822        provider: crate::runtime_provider::RuntimeProvider,
1823    ) -> Result<()> {
1824        self.providers.register(provider)
1825    }
1826
1827    /// Explicitly replace an application-supplied runtime provider.
1828    pub fn replace_provider(
1829        &mut self,
1830        provider: crate::runtime_provider::RuntimeProvider,
1831    ) -> Option<Arc<crate::runtime_provider::RuntimeProvider>> {
1832        self.providers.replace(provider)
1833    }
1834
1835    /// Look up a directly registered runtime provider by service identity.
1836    pub fn provider(
1837        &self,
1838        id: &crate::runtime_provider::ProviderKey,
1839    ) -> Option<Arc<crate::runtime_provider::RuntimeProvider>> {
1840        self.providers.get(id)
1841    }
1842
1843    /// Register a full driver descriptor.
1844    ///
1845    /// Panics if a descriptor is already registered for the same driver id —
1846    /// silent overwrites hide double-registration bugs. Use
1847    /// [`Self::register_descriptor_or_replace`] to overwrite intentionally.
1848    pub fn register_descriptor(&mut self, descriptor: DriverDescriptor) {
1849        if self.descriptors.contains_key(&descriptor.id) {
1850            panic!(
1851                "driver already registered for provider '{}'; \
1852                 use register_descriptor_or_replace to overwrite intentionally",
1853                descriptor.id
1854            );
1855        }
1856        self.descriptors.insert(descriptor.id.clone(), descriptor);
1857    }
1858
1859    /// Register a full driver descriptor, replacing any existing one.
1860    pub fn register_descriptor_or_replace(&mut self, descriptor: DriverDescriptor) {
1861        self.descriptors.insert(descriptor.id.clone(), descriptor);
1862    }
1863
1864    /// Register a driver factory for a provider type.
1865    ///
1866    /// Panics if a factory is already registered for `provider_type` — silent
1867    /// overwrites hide double-registration bugs. Use
1868    /// [`Self::register_or_replace`] to overwrite intentionally.
1869    pub fn register<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1870    where
1871        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1872    {
1873        self.register_descriptor(DriverDescriptor::chat_only(provider_type, factory));
1874    }
1875
1876    /// Register a driver factory, replacing any existing one for the provider.
1877    ///
1878    /// Use when overwriting is intentional (e.g. swapping in an `LlmSim` driver
1879    /// for tests). Prefer [`Self::register`] otherwise so duplicates surface.
1880    pub fn register_or_replace<F>(&mut self, provider_type: impl Into<DriverId>, factory: F)
1881    where
1882        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1883    {
1884        self.register_descriptor_or_replace(DriverDescriptor::chat_only(provider_type, factory));
1885    }
1886
1887    /// Register a driver factory for an embedder-defined external provider,
1888    /// keyed by its canonical id. The id is normalized to lowercase (via
1889    /// [`DriverId::external`]) so it matches parsed lookups regardless of
1890    /// the casing stored in the database or sent on the wire.
1891    pub fn register_external<F>(&mut self, id: impl AsRef<str>, factory: F)
1892    where
1893        F: Fn(&DriverConfig) -> BoxedChatDriver + Send + Sync + 'static,
1894    {
1895        let mut descriptor = DriverDescriptor::chat_only(DriverId::external(id), factory);
1896        descriptor.credential_schema = CredentialFormSchema::empty();
1897        self.register_descriptor(descriptor);
1898    }
1899
1900    /// Create an LLM driver based on configuration
1901    ///
1902    /// This function does not fall back to environment variables. Keys should
1903    /// be decrypted by the host and passed here. A selected provider with
1904    /// missing credentials still produces a driver so commands can inspect the
1905    /// turn and repair configuration; that driver rejects every provider
1906    /// operation locally before network I/O.
1907    ///
1908    /// Returns `DriverNotRegistered` error if no driver is registered for the provider type.
1909    pub fn create_chat_driver(&self, config: &ProviderConfig) -> Result<BoxedChatDriver> {
1910        if let Some(provider) = self.providers.get(&config.provider) {
1911            return Ok(RequestOptionsDriver::wrap(
1912                (*provider).clone().into_boxed_driver(),
1913                &config.request_options,
1914            ));
1915        }
1916        let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
1917            AgentLoopError::driver_not_registered(config.provider_type.to_string())
1918        })?;
1919        // Look up the descriptor and its chat factory for this provider type
1920        let factory = descriptor.chat.as_ref().ok_or_else(|| {
1921            AgentLoopError::llm(format!(
1922                "Provider driver '{}' does not implement the chat service.",
1923                config.provider_type
1924            ))
1925        })?;
1926
1927        // Create the driver using the factory
1928        let driver_config = DriverConfig::from_provider_config(config);
1929        let driver = factory(&driver_config);
1930        let mut credential_fields = driver_config.credentials.clone();
1931        if let Some(serde_json::Value::Object(extra)) = &driver_config.metadata.extra {
1932            for (name, value) in extra {
1933                if let Some(value) = value.as_str() {
1934                    credential_fields
1935                        .entry(name.clone())
1936                        .or_insert_with(|| value.to_string());
1937                }
1938            }
1939        }
1940        let credential_errors = descriptor.credential_schema.validate(&credential_fields);
1941        if credential_errors.is_empty() {
1942            Ok(RequestOptionsDriver::wrap(driver, &config.request_options))
1943        } else {
1944            let message = if descriptor.credential_schema.fields.len() == 1
1945                && descriptor.credential_schema.fields[0].name == "api_key"
1946            {
1947                "API key is required. Configure the API key in provider settings.".to_string()
1948            } else {
1949                format!(
1950                    "Provider credentials are required. Configure provider settings: {}",
1951                    credential_errors.join(" ")
1952                )
1953            };
1954            Ok(Box::new(CredentialGateDriver {
1955                inner: driver,
1956                message,
1957            }))
1958        }
1959    }
1960
1961    /// Check if a driver is registered for a provider type
1962    pub fn has_driver(&self, provider_type: &DriverId) -> bool {
1963        self.descriptors.contains_key(provider_type)
1964    }
1965
1966    /// Get the registered descriptor for a provider type.
1967    pub fn descriptor(&self, provider_type: &DriverId) -> Option<&DriverDescriptor> {
1968        self.descriptors.get(provider_type)
1969    }
1970
1971    /// Whether the registered driver declares the given service.
1972    pub fn supports(&self, provider_type: &DriverId, service: ServiceKind) -> bool {
1973        self.descriptors
1974            .get(provider_type)
1975            .is_some_and(|d| d.supports(service))
1976    }
1977
1978    /// Driver ids whose descriptors declare the given service.
1979    pub fn providers_for(&self, service: ServiceKind) -> Vec<DriverId> {
1980        self.descriptors
1981            .values()
1982            .filter(|d| d.supports(service))
1983            .map(|d| d.id.clone())
1984            .collect()
1985    }
1986
1987    /// Get the list of registered provider types
1988    pub fn registered_providers(&self) -> Vec<DriverId> {
1989        self.descriptors.keys().cloned().collect()
1990    }
1991
1992    /// Runtime provider ids registered directly by an application.
1993    pub fn registered_provider_ids(&self) -> Vec<String> {
1994        self.providers.ids()
1995    }
1996
1997    /// Create an embeddings driver based on configuration.
1998    ///
1999    /// API keys must be provided in the config for real providers. Exception:
2000    /// `LlmSim` and `External` providers do not require an API key.
2001    ///
2002    /// Returns an error if the driver is not registered or does not implement
2003    /// the embeddings service.
2004    pub fn create_embeddings_driver(
2005        &self,
2006        config: &ProviderConfig,
2007    ) -> std::result::Result<BoxedEmbeddingsDriver, EmbeddingsDriverError> {
2008        let requires_api_key = config.provider_type != DriverId::LlmSim;
2009        if requires_api_key && config.api_key.is_none() {
2010            return Err(EmbeddingsDriverError::Provider(
2011                "API key is required. Configure the API key in provider settings.".to_string(),
2012            ));
2013        }
2014        let descriptor = self.descriptors.get(&config.provider_type).ok_or_else(|| {
2015            EmbeddingsDriverError::Provider(format!(
2016                "No driver registered for provider '{}'",
2017                config.provider_type
2018            ))
2019        })?;
2020        let factory = descriptor.embeddings.as_ref().ok_or_else(|| {
2021            EmbeddingsDriverError::Provider(format!(
2022                "Provider driver '{}' does not implement the embeddings service.",
2023                config.provider_type
2024            ))
2025        })?;
2026        let driver_config = DriverConfig::from_provider_config(config);
2027        Ok(factory(&driver_config))
2028    }
2029}
2030
2031/// Maximum tool result size in bytes before truncation (64 KiB).
2032/// Defense-in-depth backstop for tool results that bypass ActAtom hooks
2033/// (e.g. client-submitted or stored events). The primary hard limit is
2034/// enforced by `OutputHardLimitHook` (EVE-225) at tool execution time.
2035const MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
2036
2037const TRUNCATION_SUFFIX: &str =
2038    "\n\n[Output truncated — exceeded 64 KiB limit. Try quiet flags, pipes, or redirect to file.]";
2039
2040pub fn truncate_tool_result(text: String) -> String {
2041    if text.len() <= MAX_TOOL_RESULT_BYTES {
2042        return text;
2043    }
2044    let content_budget = MAX_TOOL_RESULT_BYTES.saturating_sub(TRUNCATION_SUFFIX.len());
2045    let mut end = content_budget;
2046    while end > 0 && !text.is_char_boundary(end) {
2047        end -= 1;
2048    }
2049    let mut truncated = text[..end].to_string();
2050    truncated.push_str(TRUNCATION_SUFFIX);
2051    truncated
2052}
2053
2054// ============================================================================
2055// Tests
2056// ============================================================================
2057
2058#[cfg(test)]
2059mod tests {
2060    use super::*;
2061    use crate::runtime_provider::ProviderEndpoint;
2062
2063    #[test]
2064    fn test_disjoint_prompt_tokens_subtracts_cached_subset() {
2065        // Inclusive providers report a prompt count that includes cached reads;
2066        // normalization yields the non-cached remainder.
2067        assert_eq!(disjoint_prompt_tokens(1000, Some(800)), 200);
2068        // No cache reported => prompt count passes through unchanged.
2069        assert_eq!(disjoint_prompt_tokens(1000, None), 1000);
2070        assert_eq!(disjoint_prompt_tokens(1000, Some(0)), 1000);
2071        // Saturating: a provider reporting cache > input never underflows.
2072        assert_eq!(disjoint_prompt_tokens(800, Some(1000)), 0);
2073    }
2074
2075    /// A call config with nothing set beyond the model, so a test can assert on
2076    /// exactly what a wrapper adds.
2077    fn bare_call_config() -> LlmCallConfig {
2078        LlmCallConfig {
2079            model: "claude-opus-4-8".to_string(),
2080            temperature: None,
2081            max_tokens: None,
2082            tools: vec![],
2083            reasoning_effort: None,
2084            speed: None,
2085            verbosity: None,
2086            metadata: HashMap::new(),
2087            previous_response_id: None,
2088            provider_opaque_context: None,
2089            tool_search: None,
2090            prompt_cache: None,
2091            driver_options: Default::default(),
2092            parallel_tool_calls: None,
2093            volatile_suffix_len: 0,
2094            extra_headers: Vec::new(),
2095            cache_diagnostics: None,
2096            reasoning_state: None,
2097        }
2098    }
2099
2100    #[test]
2101    fn provider_config_debug_redacts_runtime_values() {
2102        let config = ProviderConfig::new(DriverId::OpenAI)
2103            .with_api_key("secret-key")
2104            .with_base_url("https://user:password@example.test/v1?token=secret")
2105            .with_metadata(ProviderMetadata {
2106                refresh_token: Some("refresh-secret".into()),
2107                account_id: Some("account-1".into()),
2108                extra: Some(serde_json::json!({ "client_secret": "metadata-secret" })),
2109            });
2110        let debug = format!("{config:?}");
2111        assert!(debug.contains("ProviderConfig"));
2112        assert!(debug.contains("openai"));
2113        assert!(debug.contains("<configured>"));
2114        for secret in [
2115            "secret-key",
2116            "password",
2117            "token=secret",
2118            "refresh-secret",
2119            "metadata-secret",
2120        ] {
2121            assert!(!debug.contains(secret), "debug output exposed {secret}");
2122        }
2123    }
2124
2125    #[test]
2126    fn system_messages_fold_only_system_text_in_transcript_order() {
2127        use LlmMessageRole::{Assistant, System, Tool, User};
2128        for (messages, expected) in [
2129            (vec![], None),
2130            (
2131                vec![
2132                    LlmMessage::text(User, "user"),
2133                    LlmMessage::text(Assistant, "answer"),
2134                    LlmMessage::text(Tool, "result"),
2135                ],
2136                None,
2137            ),
2138            (vec![LlmMessage::text(System, "")], Some("")),
2139            (
2140                vec![
2141                    LlmMessage::text(System, "rules"),
2142                    LlmMessage::text(User, "question"),
2143                ],
2144                Some("rules"),
2145            ),
2146            (
2147                vec![
2148                    LlmMessage::text(System, "first"),
2149                    LlmMessage::text(User, "question"),
2150                    LlmMessage::text(System, "second"),
2151                    LlmMessage::text(Assistant, "answer"),
2152                    LlmMessage::text(System, "third"),
2153                ],
2154                Some("first\n\nsecond\n\nthird"),
2155            ),
2156            (
2157                vec![
2158                    LlmMessage::parts(
2159                        System,
2160                        vec![
2161                            LlmContentPart::text("foo"),
2162                            LlmContentPart::image("image"),
2163                            LlmContentPart::audio("audio"),
2164                            LlmContentPart::text("bar"),
2165                        ],
2166                    ),
2167                    LlmMessage::text(System, "next"),
2168                ],
2169                Some("foobar\n\nnext"),
2170            ),
2171        ] {
2172            assert_eq!(fold_system_messages(&messages).as_deref(), expected);
2173        }
2174    }
2175
2176    #[test]
2177    fn prefix_preserves_all_media_and_changes_only_the_first_text_part() {
2178        let mut plain = LlmMessage::text(LlmMessageRole::User, "Hello");
2179        plain.prepend_text_prefix("[Alice] ");
2180        assert!(
2181            matches!(plain.content, LlmMessageContent::Text(ref text) if text == "[Alice] Hello")
2182        );
2183        for (parts, expected) in [
2184            (vec![], vec![("text", "[Alice] ")]),
2185            (
2186                vec![
2187                    LlmContentPart::image("image"),
2188                    LlmContentPart::audio("audio"),
2189                ],
2190                vec![("text", "[Alice] "), ("image", "image"), ("audio", "audio")],
2191            ),
2192            (
2193                vec![
2194                    LlmContentPart::text("Hello"),
2195                    LlmContentPart::image("image"),
2196                ],
2197                vec![("text", "[Alice] Hello"), ("image", "image")],
2198            ),
2199            (
2200                vec![
2201                    LlmContentPart::image("image"),
2202                    LlmContentPart::text("Hello"),
2203                    LlmContentPart::audio("audio"),
2204                    LlmContentPart::text("later"),
2205                ],
2206                vec![
2207                    ("image", "image"),
2208                    ("text", "[Alice] Hello"),
2209                    ("audio", "audio"),
2210                    ("text", "later"),
2211                ],
2212            ),
2213            (
2214                vec![LlmContentPart::text(""), LlmContentPart::text("later")],
2215                vec![("text", "[Alice] "), ("text", "later")],
2216            ),
2217        ] {
2218            let mut message = LlmMessage::parts(LlmMessageRole::Tool, parts);
2219            message.tool_call_id = Some("call-1".into());
2220            message.prepend_text_prefix("[Alice] ");
2221            let LlmMessageContent::Parts(parts) = &message.content else {
2222                panic!("parts must remain parts")
2223            };
2224            let actual: Vec<_> = parts
2225                .iter()
2226                .map(|part| match part {
2227                    LlmContentPart::Text { text } => ("text", text.as_str()),
2228                    LlmContentPart::Image { url } => ("image", url.as_str()),
2229                    LlmContentPart::Audio { url } => ("audio", url.as_str()),
2230                    LlmContentPart::File { url, .. } => ("file", url.as_str()),
2231                })
2232                .collect();
2233            assert_eq!(actual, expected);
2234            assert_eq!(message.role, LlmMessageRole::Tool);
2235            assert_eq!(message.tool_call_id.as_deref(), Some("call-1"));
2236        }
2237    }
2238    struct FixtureDriver(&'static str);
2239
2240    #[async_trait]
2241    impl ChatDriver for FixtureDriver {
2242        async fn chat_completion_stream(
2243            &self,
2244            _: &ProviderEndpoint,
2245            _: Vec<LlmMessage>,
2246            _: &LlmCallConfig,
2247        ) -> Result<LlmResponseStream> {
2248            Ok(Box::pin(futures::stream::iter([
2249                Ok(LlmStreamEvent::TextDelta(self.0.into())),
2250                Ok(LlmStreamEvent::Done(Box::default())),
2251            ])))
2252        }
2253        async fn list_models(&self, _: &ProviderEndpoint) -> Result<Option<Vec<DiscoveredModel>>> {
2254            Ok(Some(vec![DiscoveredModel {
2255                model_id: self.0.into(),
2256                display_name: None,
2257                created_at: None,
2258                owned_by: None,
2259                capabilities: vec!["chat".into()],
2260                discovered_profile: None,
2261            }]))
2262        }
2263        async fn compact(
2264            &self,
2265            _: &ProviderEndpoint,
2266            request: CompactRequest,
2267        ) -> Result<Option<CompactResponse>> {
2268            Ok(Some(CompactResponse {
2269                output: vec![crate::compact::CompactOutputItem::Compaction {
2270                    encrypted_content: request.model,
2271                }],
2272                usage: None,
2273            }))
2274        }
2275        fn supports_compact(&self) -> bool {
2276            true
2277        }
2278        fn supports_stateful_responses(&self) -> bool {
2279            true
2280        }
2281        fn effective_context_window(&self, model: &str) -> Option<usize> {
2282            (model == "known").then_some(12345)
2283        }
2284        fn supports_parallel_tool_calls(&self, model: &str) -> bool {
2285            model == "known"
2286        }
2287    }
2288
2289    fn compact_fixture() -> CompactRequest {
2290        CompactRequest {
2291            reasoning_state: None,
2292            model: "compact-model".into(),
2293            input: vec![],
2294            previous_response_id: None,
2295            instructions: None,
2296        }
2297    }
2298
2299    #[tokio::test]
2300    async fn default_and_boxed_drivers_preserve_optional_operations_and_model_capabilities() {
2301        struct DefaultDriver;
2302        #[async_trait]
2303        impl ChatDriver for DefaultDriver {
2304            async fn chat_completion_stream(
2305                &self,
2306                _: &ProviderEndpoint,
2307                _: Vec<LlmMessage>,
2308                _: &LlmCallConfig,
2309            ) -> Result<LlmResponseStream> {
2310                Ok(Box::pin(futures::stream::empty()))
2311            }
2312        }
2313        let endpoint = ProviderEndpoint::default();
2314        assert!(!DefaultDriver.supports_compact());
2315        assert!(!DefaultDriver.supports_stateful_responses());
2316        assert!(!DefaultDriver.supports_parallel_tool_calls("known"));
2317        assert_eq!(DefaultDriver.effective_context_window("known"), None);
2318        assert!(
2319            DefaultDriver
2320                .list_models(&endpoint)
2321                .await
2322                .unwrap()
2323                .is_none()
2324        );
2325        assert!(
2326            DefaultDriver
2327                .compact(&endpoint, compact_fixture())
2328                .await
2329                .unwrap()
2330                .is_none()
2331        );
2332        let boxed: BoxedChatDriver = Box::new(FixtureDriver("boxed"));
2333        assert!(boxed.supports_compact());
2334        assert!(boxed.supports_stateful_responses());
2335        for (model, expected) in [("known", true), ("unknown", false)] {
2336            assert_eq!(boxed.supports_parallel_tool_calls(model), expected);
2337            assert_eq!(
2338                boxed.effective_context_window(model),
2339                expected.then_some(12345)
2340            );
2341        }
2342        assert_eq!(
2343            boxed
2344                .chat_completion(&endpoint, vec![], &bare_call_config())
2345                .await
2346                .unwrap()
2347                .text,
2348            "boxed"
2349        );
2350    }
2351
2352    #[tokio::test]
2353    async fn registry_replacement_changes_factory_and_preserves_other_descriptors() {
2354        let mut registry = DriverRegistry::new();
2355        assert!(registry.registered_providers().is_empty());
2356        registry.register(DriverId::LlmSim, |_| Box::new(FixtureDriver("first")));
2357        registry.register_descriptor(DriverDescriptor {
2358            display_name: "OpenAI custom".into(),
2359            services: vec![ServiceKind::Chat, ServiceKind::Realtime],
2360            ..DriverDescriptor::chat_only(DriverId::OpenAI, |_| Box::new(FixtureDriver("other")))
2361        });
2362        let config = ProviderConfig::new(DriverId::LlmSim);
2363        let endpoint = ProviderEndpoint::default();
2364        assert_eq!(
2365            registry
2366                .create_chat_driver(&config)
2367                .unwrap()
2368                .chat_completion(&endpoint, vec![], &bare_call_config())
2369                .await
2370                .unwrap()
2371                .text,
2372            "first"
2373        );
2374        registry.register_or_replace(DriverId::LlmSim, |_| Box::new(FixtureDriver("replacement")));
2375        assert_eq!(
2376            registry
2377                .create_chat_driver(&config)
2378                .unwrap()
2379                .chat_completion(&endpoint, vec![], &bare_call_config())
2380                .await
2381                .unwrap()
2382                .text,
2383            "replacement"
2384        );
2385        assert!(registry.has_driver(&DriverId::LlmSim));
2386        assert!(!registry.has_driver(&DriverId::Anthropic));
2387        assert_eq!(
2388            registry.providers_for(ServiceKind::Realtime),
2389            vec![DriverId::OpenAI]
2390        );
2391        let mut chat = registry.providers_for(ServiceKind::Chat);
2392        chat.sort_by_key(|id| id.to_string());
2393        assert_eq!(chat, vec![DriverId::LlmSim, DriverId::OpenAI]);
2394        assert!(registry.supports(&DriverId::OpenAI, ServiceKind::Realtime));
2395        assert!(!registry.supports(&DriverId::LlmSim, ServiceKind::Realtime));
2396        assert!(!registry.supports(&DriverId::Gemini, ServiceKind::Chat));
2397        assert_eq!(
2398            registry.descriptor(&DriverId::OpenAI).unwrap().display_name,
2399            "OpenAI custom"
2400        );
2401        assert_eq!(
2402            registry
2403                .create_chat_driver(
2404                    &ProviderConfig::new(DriverId::OpenAI).with_api_key("synthetic-key")
2405                )
2406                .unwrap()
2407                .chat_completion(&endpoint, vec![], &bare_call_config())
2408                .await
2409                .unwrap()
2410                .text,
2411            "other"
2412        );
2413        let defaults = DriverDescriptor::chat_only(DriverId::Anthropic, |_| {
2414            Box::new(FixtureDriver("default"))
2415        });
2416        assert_eq!(defaults.display_name, "anthropic");
2417        let sim = registry.descriptor(&DriverId::LlmSim).unwrap();
2418        assert!(sim.credential_schema.fields.is_empty());
2419        assert_eq!(sim.services, vec![ServiceKind::Chat]);
2420        assert!(sim.chat.is_some());
2421        let real = registry.descriptor(&DriverId::OpenAI).unwrap();
2422        assert_eq!(real.credential_schema.fields.len(), 1);
2423        assert_eq!(real.credential_schema.fields[0].name, "api_key");
2424        assert!(real.credential_schema.fields[0].required);
2425        assert!(registry.descriptor(&DriverId::Gemini).is_none());
2426    }
2427
2428    #[test]
2429    #[should_panic(expected = "already registered")]
2430    fn duplicate_registration_rejects_an_existing_driver() {
2431        let mut registry = DriverRegistry::new();
2432        registry.register(DriverId::OpenAI, |_| Box::new(FixtureDriver("first")));
2433        registry.register(DriverId::OpenAI, |_| Box::new(FixtureDriver("second")));
2434    }
2435
2436    #[tokio::test]
2437    async fn factory_receives_complete_config_and_external_metadata_auth_remains_keyless() {
2438        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
2439        let capture = seen.clone();
2440        let mut registry = DriverRegistry::new();
2441        registry.register_external("CUSTOM", move |config| {
2442            capture.lock().unwrap().push(config.clone());
2443            Box::new(FixtureDriver("external"))
2444        });
2445        let metadata = ProviderMetadata {
2446            refresh_token: Some("refresh".into()),
2447            account_id: Some("account".into()),
2448            extra: Some(serde_json::json!({"region":"west"})),
2449        };
2450        for key in [None, Some("synthetic-key")] {
2451            let mut config =
2452                ProviderConfig::for_provider("connection", DriverId::external("custom"))
2453                    .with_base_url("https://gateway.example/v1")
2454                    .with_metadata(metadata.clone());
2455            if let Some(key) = key {
2456                config = config.with_api_key(key);
2457            }
2458            let response = registry
2459                .create_chat_driver(&config)
2460                .unwrap()
2461                .chat_completion(&ProviderEndpoint::default(), vec![], &bare_call_config())
2462                .await
2463                .unwrap();
2464            assert_eq!(response.text, "external");
2465            let received = seen.lock().unwrap().pop().unwrap();
2466            assert_eq!(received.provider.as_str(), "connection");
2467            assert_eq!(received.provider_type, DriverId::external("custom"));
2468            assert_eq!(received.api_key.as_deref(), key);
2469            assert_eq!(received.credential("api_key"), key);
2470            assert_eq!(received.credentials.len(), usize::from(key.is_some()));
2471            assert_eq!(
2472                received.base_url.as_deref(),
2473                Some("https://gateway.example/v1")
2474            );
2475            assert_eq!(received.metadata, metadata);
2476        }
2477        assert!(
2478            registry
2479                .descriptor(&DriverId::external("custom"))
2480                .unwrap()
2481                .credential_schema
2482                .fields
2483                .is_empty()
2484        );
2485    }
2486
2487    #[test]
2488    fn registry_distinguishes_missing_driver_from_missing_chat_service() {
2489        let mut registry = DriverRegistry::new();
2490        assert!(
2491            matches!(registry.create_chat_driver(&ProviderConfig::new(DriverId::Anthropic)), Err(AgentLoopError::DriverNotRegistered(id)) if id == "anthropic")
2492        );
2493        registry.register_descriptor(DriverDescriptor {
2494            id: DriverId::external("embeddings-only"),
2495            display_name: "Embeddings Only".into(),
2496            services: vec![ServiceKind::Embeddings],
2497            credential_schema: CredentialFormSchema::empty(),
2498            base_url_env: None,
2499            oauth: None,
2500            chat: None,
2501            embeddings: None,
2502        });
2503        match registry
2504            .create_chat_driver(&ProviderConfig::new(DriverId::external("embeddings-only")))
2505        {
2506            Err(AgentLoopError::Llm(error)) => assert_eq!(
2507                error.message,
2508                "Provider driver 'embeddings-only' does not implement the chat service."
2509            ),
2510            _ => panic!("expected a missing-chat-service error"),
2511        }
2512    }
2513
2514    #[tokio::test]
2515    async fn credential_gate_rejects_every_io_operation_before_dispatch() {
2516        struct ForbiddenDriver;
2517        #[async_trait]
2518        impl ChatDriver for ForbiddenDriver {
2519            async fn chat_completion_stream(
2520                &self,
2521                _: &ProviderEndpoint,
2522                _: Vec<LlmMessage>,
2523                _: &LlmCallConfig,
2524            ) -> Result<LlmResponseStream> {
2525                panic!("unauthenticated stream dispatch")
2526            }
2527            async fn list_models(
2528                &self,
2529                _: &ProviderEndpoint,
2530            ) -> Result<Option<Vec<DiscoveredModel>>> {
2531                panic!("unauthenticated model dispatch")
2532            }
2533            async fn compact(
2534                &self,
2535                _: &ProviderEndpoint,
2536                _: CompactRequest,
2537            ) -> Result<Option<CompactResponse>> {
2538                panic!("unauthenticated compact dispatch")
2539            }
2540        }
2541        let mut registry = DriverRegistry::new();
2542        registry.register(DriverId::OpenAI, |config| {
2543            if config.api_key.is_some() {
2544                Box::new(FixtureDriver("authenticated"))
2545            } else {
2546                Box::new(ForbiddenDriver)
2547            }
2548        });
2549        let driver = registry
2550            .create_chat_driver(&ProviderConfig::new(DriverId::OpenAI))
2551            .unwrap();
2552        let endpoint = ProviderEndpoint::default();
2553        let stream_error = match driver
2554            .chat_completion_stream(&endpoint, vec![], &bare_call_config())
2555            .await
2556        {
2557            Err(error) => error,
2558            Ok(_) => panic!("expected authentication error"),
2559        };
2560        for error in [
2561            stream_error,
2562            driver
2563                .chat_completion(&endpoint, vec![], &bare_call_config())
2564                .await
2565                .unwrap_err(),
2566            driver.list_models(&endpoint).await.unwrap_err(),
2567            driver
2568                .compact(&endpoint, compact_fixture())
2569                .await
2570                .unwrap_err(),
2571        ] {
2572            assert_eq!(error.llm_error_kind(), Some(LlmErrorKind::Authentication));
2573            assert_eq!(
2574                error.to_string(),
2575                "LLM error: API key is required. Configure the API key in provider settings."
2576            );
2577        }
2578        let driver = registry
2579            .create_chat_driver(
2580                &ProviderConfig::new(DriverId::OpenAI).with_api_key("synthetic-key"),
2581            )
2582            .unwrap();
2583        assert_eq!(
2584            driver
2585                .chat_completion(&endpoint, vec![], &bare_call_config())
2586                .await
2587                .unwrap()
2588                .text,
2589            "authenticated"
2590        );
2591        assert_eq!(
2592            driver.list_models(&endpoint).await.unwrap().unwrap()[0].model_id,
2593            "authenticated"
2594        );
2595        assert_eq!(
2596            serde_json::to_value(
2597                driver
2598                    .compact(&endpoint, compact_fixture())
2599                    .await
2600                    .unwrap()
2601                    .unwrap()
2602                    .output
2603            )
2604            .unwrap(),
2605            serde_json::json!([{"type":"compaction","encrypted_content":"compact-model"}])
2606        );
2607    }
2608
2609    #[tokio::test]
2610    async fn request_options_preserve_calls_and_apply_headers_and_diagnostics_independently() {
2611        struct CapturingDriver(Arc<std::sync::Mutex<Vec<LlmCallConfig>>>);
2612        impl CapturingDriver {
2613            fn capture(
2614                &self,
2615                endpoint: &ProviderEndpoint,
2616                messages: &[LlmMessage],
2617                config: &LlmCallConfig,
2618            ) {
2619                assert_eq!(
2620                    endpoint.url("probe").as_deref(),
2621                    Some("https://gateway.example/v1/probe")
2622                );
2623                assert_eq!(messages.len(), 1);
2624                assert_eq!(messages[0].role, LlmMessageRole::User);
2625                assert_eq!(messages[0].content_as_text(), "request text");
2626                self.0.lock().unwrap().push(config.clone());
2627            }
2628        }
2629        #[async_trait]
2630        impl ChatDriver for CapturingDriver {
2631            async fn chat_completion_stream(
2632                &self,
2633                endpoint: &ProviderEndpoint,
2634                messages: Vec<LlmMessage>,
2635                config: &LlmCallConfig,
2636            ) -> Result<LlmResponseStream> {
2637                self.capture(endpoint, &messages, config);
2638                FixtureDriver("stream")
2639                    .chat_completion_stream(endpoint, messages, config)
2640                    .await
2641            }
2642            async fn chat_completion(
2643                &self,
2644                endpoint: &ProviderEndpoint,
2645                messages: Vec<LlmMessage>,
2646                config: &LlmCallConfig,
2647            ) -> Result<LlmResponse> {
2648                self.capture(endpoint, &messages, config);
2649                FixtureDriver("completion")
2650                    .chat_completion(endpoint, messages, config)
2651                    .await
2652            }
2653        }
2654        let provider = crate::Provider::new("fixture", FixtureDriver("endpoint"))
2655            .base_url("https://gateway.example/v1");
2656        for (headers, diagnostics) in [(false, false), (true, false), (false, true), (true, true)] {
2657            let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
2658            let options = crate::provider::ProviderRequestOptions {
2659                headers: if headers {
2660                    vec![crate::provider::ProviderRequestHeader {
2661                        name: "x-base".into(),
2662                        value: "connection".into(),
2663                    }]
2664                } else {
2665                    vec![]
2666                },
2667                cache_diagnostics: diagnostics,
2668            };
2669            let driver =
2670                RequestOptionsDriver::wrap(Box::new(CapturingDriver(seen.clone())), &options);
2671            let mut config = bare_call_config();
2672            config.model = "requested-model".into();
2673            config.temperature = Some(0.25);
2674            config.max_tokens = Some(42);
2675            config
2676                .metadata
2677                .insert("session_id".into(), "session-one".into());
2678            config.previous_response_id = Some("response-one".into());
2679            config.extra_headers = vec![("x-base".into(), "original".into())];
2680            config.cache_diagnostics = Some(CacheDiagnosticsConfig {
2681                enabled: false,
2682                previous_message_id: Some("existing".into()),
2683            });
2684            let mut stream = driver
2685                .chat_completion_stream(
2686                    provider.endpoint(),
2687                    vec![LlmMessage::text(LlmMessageRole::User, "request text")],
2688                    &config,
2689                )
2690                .await
2691                .unwrap();
2692            use futures::StreamExt;
2693            assert!(
2694                matches!(stream.next().await.unwrap().unwrap(), LlmStreamEvent::TextDelta(text) if text == "stream")
2695            );
2696            assert!(matches!(
2697                stream.next().await.unwrap().unwrap(),
2698                LlmStreamEvent::Done(_)
2699            ));
2700            assert!(stream.next().await.is_none());
2701            assert_eq!(
2702                driver
2703                    .chat_completion(
2704                        provider.endpoint(),
2705                        vec![LlmMessage::text(LlmMessageRole::User, "request text")],
2706                        &config
2707                    )
2708                    .await
2709                    .unwrap()
2710                    .text,
2711                "completion"
2712            );
2713            let mut expected_headers = vec![("x-base".into(), "original".into())];
2714            if headers {
2715                expected_headers.push(("x-base".into(), "connection".into()));
2716            }
2717            let observed = seen.lock().unwrap();
2718            assert_eq!(observed.len(), 2);
2719            for received in observed.iter() {
2720                assert_eq!(received.extra_headers, expected_headers);
2721                let diagnostic = received.cache_diagnostics.as_ref().unwrap();
2722                assert_eq!(diagnostic.enabled, diagnostics);
2723                assert_eq!(
2724                    diagnostic.previous_message_id.as_deref(),
2725                    Some(if diagnostics {
2726                        "response-one"
2727                    } else {
2728                        "existing"
2729                    })
2730                );
2731                assert_eq!(received.model, "requested-model");
2732                assert_eq!(received.temperature, Some(0.25));
2733                assert_eq!(received.max_tokens, Some(42));
2734                assert_eq!(received.metadata, config.metadata);
2735                assert_eq!(received.previous_response_id, config.previous_response_id);
2736            }
2737            assert_eq!(
2738                config.extra_headers,
2739                vec![("x-base".into(), "original".into())]
2740            );
2741            assert!(!config.cache_diagnostics.as_ref().unwrap().enabled);
2742            assert_eq!(
2743                config
2744                    .cache_diagnostics
2745                    .as_ref()
2746                    .unwrap()
2747                    .previous_message_id
2748                    .as_deref(),
2749                Some("existing")
2750            );
2751        }
2752        let options = crate::provider::ProviderRequestOptions {
2753            headers: vec![],
2754            cache_diagnostics: true,
2755        };
2756        let wrapped = RequestOptionsDriver::wrap(Box::new(FixtureDriver("forwarded")), &options);
2757        assert!(wrapped.supports_compact());
2758        assert!(wrapped.supports_stateful_responses());
2759        for (model, expected) in [("known", true), ("unknown", false)] {
2760            assert_eq!(wrapped.supports_parallel_tool_calls(model), expected);
2761            assert_eq!(
2762                wrapped.effective_context_window(model),
2763                expected.then_some(12345)
2764            );
2765        }
2766        assert_eq!(
2767            wrapped
2768                .list_models(provider.endpoint())
2769                .await
2770                .unwrap()
2771                .unwrap()[0]
2772                .model_id,
2773            "forwarded"
2774        );
2775        assert_eq!(
2776            serde_json::to_value(
2777                wrapped
2778                    .compact(provider.endpoint(), compact_fixture())
2779                    .await
2780                    .unwrap()
2781                    .unwrap()
2782                    .output
2783            )
2784            .unwrap(),
2785            serde_json::json!([{"type":"compaction","encrypted_content":"compact-model"}])
2786        );
2787    }
2788}