Skip to main content

ares_llm/
pool.rs

1//! LLM Client Connection Pooling (DIR-44)
2//!
3//! This module provides connection pooling for LLM clients, enabling connection
4//! reuse across requests to reduce latency and resource consumption.
5//!
6//! # Architecture
7//!
8//! The pool maintains a set of pre-initialized `LLMClient` instances per provider
9//! configuration. Clients are checked out, used, and returned to the pool.
10//!
11//! # Features
12//!
13//! - Configurable maximum pool size per provider
14//! - Connection health checking with configurable TTL
15//! - Automatic stale connection cleanup
16//! - Graceful shutdown with connection draining
17//! - Fair distribution via round-robin or least-connections
18//!
19//! # Example
20//!
21//! ```rust,ignore
22//! use ares::llm::pool::{ClientPool, PoolConfig};
23//! use ares::llm::Provider;
24//!
25//! let config = PoolConfig::default();
26//! let pool = ClientPool::new(config);
27//!
28//! // Register a provider
29//! pool.register_provider("openai", provider).await?;
30//!
31//! // Get a pooled client
32//! let guard = pool.get("openai").await?;
33//! let response = guard.client().generate("Hello!").await?;
34//! // Client is automatically returned to pool when guard is dropped
35//! ```
36
37use crate::client::{LLMClient, Provider};
38use crate::governor::{GovernorConfig, ProviderGovernor};
39use ares_types::types::{AppError, Result};
40#[cfg(test)]
41use async_trait::async_trait;
42use parking_lot::{Mutex, RwLock};
43use serde::{Deserialize, Serialize};
44use std::collections::HashMap;
45use std::sync::Arc;
46use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
47use std::time::{Duration, Instant};
48use tokio::sync::{OwnedSemaphorePermit, Semaphore};
49
50/// Pool-specific errors for borrow/return operations (R42).
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub enum PoolError {
53    #[serde(rename = "pool_exhausted")]
54    PoolExhausted { max: usize },
55    #[serde(rename = "timeout")]
56    Timeout { timeout_ms: u64 },
57    #[serde(rename = "invalid_client")]
58    InvalidClient { reason: String },
59}
60
61impl std::fmt::Display for PoolError {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            Self::PoolExhausted { max } => write!(f, "pool exhausted (max={max})"),
65            Self::Timeout { timeout_ms } => write!(f, "pool acquire timeout after {timeout_ms}ms"),
66            Self::InvalidClient { reason } => write!(f, "invalid pooled client: {reason}"),
67        }
68    }
69}
70
71impl std::error::Error for PoolError {}
72
73impl From<PoolError> for AppError {
74    fn from(err: PoolError) -> Self {
75        AppError::LLM(err.to_string())
76    }
77}
78
79pub(crate) mod duration_secs {
80    use serde::{Deserialize, Deserializer, Serializer};
81    use std::time::Duration;
82
83    pub fn serialize<S>(value: &Duration, serializer: S) -> std::result::Result<S::Ok, S::Error>
84    where
85        S: Serializer,
86    {
87        serializer.serialize_u64(value.as_secs())
88    }
89
90    pub fn deserialize<'de, D>(deserializer: D) -> std::result::Result<Duration, D::Error>
91    where
92        D: Deserializer<'de>,
93    {
94        Ok(Duration::from_secs(u64::deserialize(deserializer)?))
95    }
96}
97
98/// Configuration for the client pool
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100pub struct PoolConfig {
101    /// Maximum number of in-flight dispatches admitted per provider.
102    ///
103    /// `None` (default) keeps admission unlimited — governed wrappers are
104    /// never installed and behavior is unchanged. See [`ProviderGovernor`]
105    /// for the WHO-vs-HOW-MUCH throttle split: this caps how much load one
106    /// backend absorbs; tenant-level quotas decide who may call at all.
107    #[serde(default)]
108    pub max_in_flight: Option<usize>,
109
110    /// Maximum time a dispatch waits for an in-flight slot before failing
111    /// closed (only relevant when `max_in_flight` is set).
112    #[serde(default = "default_acquire_timeout_secs", with = "duration_secs")]
113    pub governor_acquire_timeout: Duration,
114
115    /// Maximum number of clients per provider (default: 10)
116    pub max_connections_per_provider: usize,
117
118    /// Minimum number of idle clients to maintain per provider (default: 2)
119    pub min_idle_connections: usize,
120
121    /// Maximum time a client can be idle before being considered stale (default: 5 minutes)
122    #[serde(with = "duration_secs")]
123    pub idle_timeout: Duration,
124
125    /// Maximum lifetime of a client before forced refresh (default: 30 minutes)
126    #[serde(with = "duration_secs")]
127    pub max_lifetime: Duration,
128
129    /// How often to run health checks on idle connections (default: 60 seconds)
130    #[serde(with = "duration_secs")]
131    pub health_check_interval: Duration,
132
133    /// Timeout for acquiring a client from the pool (default: 30 seconds)
134    #[serde(with = "duration_secs")]
135    pub acquire_timeout: Duration,
136
137    /// Whether to enable connection health checking (default: true)
138    pub enable_health_check: bool,
139}
140
141fn default_acquire_timeout_secs() -> Duration {
142    Duration::from_secs(30)
143}
144
145impl Default for PoolConfig {
146    fn default() -> Self {
147        Self {
148            max_in_flight: None,
149            governor_acquire_timeout: default_acquire_timeout_secs(),
150            max_connections_per_provider: 10,
151            min_idle_connections: 2,
152            idle_timeout: Duration::from_secs(300), // 5 minutes
153            max_lifetime: Duration::from_secs(1800), // 30 minutes
154            health_check_interval: Duration::from_secs(60),
155            acquire_timeout: Duration::from_secs(30),
156            enable_health_check: true,
157        }
158    }
159}
160
161impl PoolConfig {
162    /// Create a new pool config with custom max connections
163    pub fn with_max_connections(mut self, max: usize) -> Self {
164        self.max_connections_per_provider = max;
165        self
166    }
167
168    /// Enable a per-provider in-flight cap of `max` concurrent dispatches.
169    ///
170    /// Admission happens at the wrap funnel ([`ProviderPool::acquire`] and
171    /// [`ProviderPool::try_acquire`]) so every checkout path — pooled or
172    /// freshly created clients alike — is governed identically. The permit
173    /// spans the whole call, streams included.
174    pub fn with_max_in_flight(mut self, max: usize) -> Self {
175        self.max_in_flight = Some(max);
176        self
177    }
178
179    /// Set the wait budget for acquiring an in-flight slot.
180    pub fn with_governor_acquire_timeout(mut self, timeout: Duration) -> Self {
181        self.governor_acquire_timeout = timeout;
182        self
183    }
184
185    /// Effective governor configuration for this pool.
186    pub fn governor_config(&self) -> GovernorConfig {
187        GovernorConfig {
188            max_in_flight: self.max_in_flight,
189            acquire_timeout: self.governor_acquire_timeout,
190        }
191    }
192
193    /// Create a new pool config with custom idle timeout
194    pub fn with_idle_timeout(mut self, timeout: Duration) -> Self {
195        self.idle_timeout = timeout;
196        self
197    }
198
199    /// Create a new pool config with custom max lifetime
200    pub fn with_max_lifetime(mut self, lifetime: Duration) -> Self {
201        self.max_lifetime = lifetime;
202        self
203    }
204
205    /// Disable health checking (useful for testing)
206    pub fn without_health_check(mut self) -> Self {
207        self.enable_health_check = false;
208        self
209    }
210}
211
212impl std::fmt::Display for PoolConfig {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        write!(
215            f,
216            "PoolConfig(max={}, min_idle={}, health_check={})",
217            self.max_connections_per_provider, self.min_idle_connections, self.enable_health_check
218        )
219    }
220}
221
222/// Metadata for a pooled client
223#[derive(Debug)]
224struct PooledClientMeta {
225    /// When this client was created
226    created_at: Instant,
227    /// When this client was last used
228    last_used: Instant,
229    /// Number of times this client has been used
230    #[allow(dead_code)] // Used for metrics/debugging
231    use_count: AtomicU64,
232}
233
234impl PooledClientMeta {
235    fn new() -> Self {
236        let now = Instant::now();
237        Self {
238            created_at: now,
239            last_used: now,
240            use_count: AtomicU64::new(0),
241        }
242    }
243
244    fn mark_used(&mut self) {
245        self.last_used = Instant::now();
246        self.use_count.fetch_add(1, Ordering::Relaxed);
247    }
248
249    fn is_stale(&self, config: &PoolConfig) -> bool {
250        let now = Instant::now();
251        let idle_duration = now.duration_since(self.last_used);
252        let lifetime = now.duration_since(self.created_at);
253
254        idle_duration > config.idle_timeout || lifetime > config.max_lifetime
255    }
256}
257
258#[derive(Debug)]
259enum BorrowFromIdle {
260    Found(PooledClient),
261    Exhausted,
262}
263
264#[derive(Debug, PartialEq, Eq, Clone, Copy)]
265pub enum ReturnDisposition {
266    Returned,
267    Dropped,
268}
269
270fn borrow_client(idle: &mut Vec<PooledClient>, config: &PoolConfig) -> BorrowFromIdle {
271    let mut found_idx = None;
272    for (idx, pooled) in idle.iter().enumerate() {
273        if health_check(&pooled.meta, config) {
274            found_idx = Some(idx);
275            break;
276        }
277    }
278    if let Some(idx) = found_idx {
279        return BorrowFromIdle::Found(idle.swap_remove(idx));
280    }
281    idle.retain(|c| health_check(&c.meta, config));
282    BorrowFromIdle::Exhausted
283}
284
285fn return_client(
286    idle: &mut Vec<PooledClient>,
287    client: Box<dyn LLMClient>,
288    config: &PoolConfig,
289) -> ReturnDisposition {
290    if idle.len() < config.max_connections_per_provider {
291        idle.push(PooledClient {
292            client,
293            meta: PooledClientMeta::new(),
294        });
295        ReturnDisposition::Returned
296    } else {
297        ReturnDisposition::Dropped
298    }
299}
300
301fn health_check(meta: &PooledClientMeta, config: &PoolConfig) -> bool {
302    if !config.enable_health_check {
303        return true;
304    }
305    !meta.is_stale(config)
306}
307
308fn pool_stats(
309    available: usize,
310    in_use: usize,
311    total_created: u64,
312    max_size: usize,
313    borrow_count: u64,
314    error_count: u64,
315) -> ProviderPoolStats {
316    ProviderPoolStats {
317        available,
318        in_use,
319        total: available.saturating_add(in_use),
320        total_created,
321        max_size,
322        borrow_count,
323        error_count,
324    }
325}
326
327fn validate_pooled_client(client: &dyn LLMClient) -> std::result::Result<(), PoolError> {
328    if client.model_name().trim().is_empty() {
329        return Err(PoolError::InvalidClient {
330            reason: "empty model name".to_string(),
331        });
332    }
333    Ok(())
334}
335
336/// A pooled LLM client with its metadata
337struct PooledClient {
338    client: Box<dyn LLMClient>,
339    meta: PooledClientMeta,
340}
341
342impl std::fmt::Debug for PooledClient {
343    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344        f.debug_struct("PooledClient")
345            .field("meta", &self.meta)
346            .finish()
347    }
348}
349
350/// Pool of clients for a single provider
351#[derive(Debug)]
352struct ProviderPool {
353    /// The provider configuration for creating new clients
354    provider: Provider,
355    /// Per-provider in-flight admission control (`max_in_flight`).
356    governor: Arc<ProviderGovernor>,
357    /// Pool of available clients
358    clients: Mutex<Vec<PooledClient>>,
359    /// Semaphore to limit concurrent connections
360    semaphore: Arc<Semaphore>,
361    /// Number of clients currently in use
362    in_use_count: AtomicUsize,
363    /// Total number of clients created (for stats)
364    total_created: AtomicU64,
365    borrow_count: AtomicU64,
366    error_count: AtomicU64,
367    /// Configuration reference
368    config: PoolConfig,
369}
370
371impl ProviderPool {
372    fn new(provider: Provider, config: PoolConfig) -> Self {
373        let semaphore = Arc::new(Semaphore::new(config.max_connections_per_provider));
374        Self {
375            provider,
376            governor: Arc::new(ProviderGovernor::new(config.governor_config())),
377            clients: Mutex::new(Vec::with_capacity(config.max_connections_per_provider)),
378            semaphore,
379            in_use_count: AtomicUsize::new(0),
380            total_created: AtomicU64::new(0),
381            borrow_count: AtomicU64::new(0),
382            error_count: AtomicU64::new(0),
383            config,
384        }
385    }
386
387    /// Get an available client from the pool, or create a new one
388    async fn acquire(
389        &self,
390    ) -> std::result::Result<(Box<dyn LLMClient>, OwnedSemaphorePermit), PoolError> {
391        let permit = match tokio::time::timeout(
392            self.config.acquire_timeout,
393            self.semaphore.clone().acquire_owned(),
394        )
395        .await
396        {
397            Ok(Ok(permit)) => permit,
398            Ok(Err(_)) => {
399                self.error_count.fetch_add(1, Ordering::Relaxed);
400                return Err(PoolError::PoolExhausted {
401                    max: self.config.max_connections_per_provider,
402                });
403            }
404            Err(_) => {
405                self.error_count.fetch_add(1, Ordering::Relaxed);
406                return Err(PoolError::Timeout {
407                    timeout_ms: self.config.acquire_timeout.as_millis() as u64,
408                });
409            }
410        };
411
412        self.borrow_count.fetch_add(1, Ordering::Relaxed);
413
414        let client = {
415            let borrowed = {
416                let mut clients = self.clients.lock();
417                match borrow_client(&mut clients, &self.config) {
418                    BorrowFromIdle::Found(mut pooled) => {
419                        validate_pooled_client(pooled.client.as_ref())?;
420                        pooled.meta.mark_used();
421                        Ok(pooled.client)
422                    }
423                    BorrowFromIdle::Exhausted => Err(()),
424                }
425            };
426            match borrowed {
427                Ok(client) => client,
428                Err(()) => {
429                    self.total_created.fetch_add(1, Ordering::Relaxed);
430                    let created = self.provider.create_client().await.map_err(|e| {
431                        self.error_count.fetch_add(1, Ordering::Relaxed);
432                        PoolError::InvalidClient {
433                            reason: e.to_string(),
434                        }
435                    })?;
436                    validate_pooled_client(created.as_ref())?;
437                    created
438                }
439            }
440        };
441
442        self.in_use_count.fetch_add(1, Ordering::Relaxed);
443        // Wrap AFTER checkout accounting but BEFORE handing the client out:
444        // every consumer of this pool now sees a governed client whose
445        // per-dispatch permits are enforced at call time. Unlimited pools
446        // get the original client back untouched.
447        let client = self.governor.wrap_if_limited(client);
448        Ok((client, permit))
449    }
450
451    async fn try_acquire(
452        &self,
453    ) -> std::result::Result<(Box<dyn LLMClient>, OwnedSemaphorePermit), PoolError> {
454        let permit = self.semaphore.clone().try_acquire_owned().map_err(|_| {
455            self.error_count.fetch_add(1, Ordering::Relaxed);
456            PoolError::PoolExhausted {
457                max: self.config.max_connections_per_provider,
458            }
459        })?;
460
461        self.borrow_count.fetch_add(1, Ordering::Relaxed);
462
463        let client = {
464            let borrowed = {
465                let mut clients = self.clients.lock();
466                match borrow_client(&mut clients, &self.config) {
467                    BorrowFromIdle::Found(mut pooled) => {
468                        validate_pooled_client(pooled.client.as_ref())?;
469                        pooled.meta.mark_used();
470                        Ok(pooled.client)
471                    }
472                    BorrowFromIdle::Exhausted => Err(()),
473                }
474            };
475            match borrowed {
476                Ok(client) => client,
477                Err(()) => {
478                    self.total_created.fetch_add(1, Ordering::Relaxed);
479                    let created = self.provider.create_client().await.map_err(|e| {
480                        self.error_count.fetch_add(1, Ordering::Relaxed);
481                        PoolError::InvalidClient {
482                            reason: e.to_string(),
483                        }
484                    })?;
485                    validate_pooled_client(created.as_ref())?;
486                    created
487                }
488            }
489        };
490
491        self.in_use_count.fetch_add(1, Ordering::Relaxed);
492        // Same funnel guarantee as `acquire`: the handed-out client is
493        // governed whenever a cap is configured.
494        let client = self.governor.wrap_if_limited(client);
495        Ok((client, permit))
496    }
497
498    /// Return a client to the pool
499    fn release(&self, client: Box<dyn LLMClient>) {
500        self.in_use_count.fetch_sub(1, Ordering::Relaxed);
501        let mut clients = self.clients.lock();
502        let _ = return_client(&mut clients, client, &self.config);
503    }
504
505    /// Remove stale connections from the pool
506    fn cleanup_stale(&self) -> usize {
507        let mut clients = self.clients.lock();
508        let before = clients.len();
509        clients.retain(|c| !c.meta.is_stale(&self.config));
510        before - clients.len()
511    }
512
513    /// Get pool statistics
514    fn stats(&self) -> ProviderPoolStats {
515        let clients = self.clients.lock();
516        pool_stats(
517            clients.len(),
518            self.in_use_count.load(Ordering::Relaxed),
519            self.total_created.load(Ordering::Relaxed),
520            self.config.max_connections_per_provider,
521            self.borrow_count.load(Ordering::Relaxed),
522            self.error_count.load(Ordering::Relaxed),
523        )
524    }
525
526    /// Drain all connections (for shutdown)
527    fn drain(&self) {
528        let mut clients = self.clients.lock();
529        clients.clear();
530    }
531}
532
533/// Statistics for a provider pool
534#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
535pub struct ProviderPoolStats {
536    pub available: usize,
537    pub in_use: usize,
538    pub total: usize,
539    pub total_created: u64,
540    pub max_size: usize,
541    pub borrow_count: u64,
542    pub error_count: u64,
543}
544
545impl std::fmt::Display for ProviderPoolStats {
546    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
547        write!(
548            f,
549            "idle={} active={} total={} created={} max={} borrows={} errors={}",
550            self.available,
551            self.in_use,
552            self.total,
553            self.total_created,
554            self.max_size,
555            self.borrow_count,
556            self.error_count
557        )
558    }
559}
560
561/// Overall pool statistics
562#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
563pub struct PoolStats {
564    pub providers: HashMap<String, ProviderPoolStats>,
565    pub total_available: usize,
566    pub total_in_use: usize,
567    pub total_connections: usize,
568    pub borrow_count: u64,
569    pub error_count: u64,
570}
571
572impl std::fmt::Display for PoolStats {
573    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
574        write!(
575            f,
576            "providers={} idle={} active={} total={} borrows={} errors={}",
577            self.providers.len(),
578            self.total_available,
579            self.total_in_use,
580            self.total_connections,
581            self.borrow_count,
582            self.error_count
583        )
584    }
585}
586
587/// Guard that returns a client to the pool when dropped
588pub struct PooledClientGuard {
589    client: Option<Box<dyn LLMClient>>,
590    pool: Arc<ProviderPool>,
591    _permit: OwnedSemaphorePermit,
592}
593
594impl std::fmt::Debug for PooledClientGuard {
595    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
596        f.debug_struct("PooledClientGuard")
597            .field("has_client", &self.client.is_some())
598            .field("pool", &self.pool)
599            .finish()
600    }
601}
602
603impl PooledClientGuard {
604    /// Get a reference to the underlying client
605    pub fn client(&self) -> &dyn LLMClient {
606        self.client.as_ref().expect("Client already taken").as_ref()
607    }
608
609    /// Get a mutable reference to the underlying client
610    pub fn client_mut(&mut self) -> &mut dyn LLMClient {
611        self.client.as_mut().expect("Client already taken").as_mut()
612    }
613
614    /// Take ownership of the client, preventing it from being returned to the pool
615    ///
616    /// This is useful if you need to move the client elsewhere, but be aware that
617    /// it won't be returned to the pool.
618    pub fn take(mut self) -> Box<dyn LLMClient> {
619        self.client.take().expect("Client already taken")
620    }
621}
622
623impl Drop for PooledClientGuard {
624    fn drop(&mut self) {
625        if let Some(client) = self.client.take() {
626            self.pool.release(client);
627        }
628    }
629}
630
631impl std::ops::Deref for PooledClientGuard {
632    type Target = Box<dyn LLMClient>;
633
634    fn deref(&self) -> &Self::Target {
635        self.client.as_ref().expect("Client already taken")
636    }
637}
638
639#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
640pub struct LLMPoolSnapshot {
641    pub config: PoolConfig,
642    pub stats: PoolStats,
643    pub providers: Vec<String>,
644    pub shutdown: bool,
645}
646
647pub type LLMPool = ClientPool;
648
649/// LLM Client Pool for managing reusable client connections
650///
651/// The pool maintains separate sub-pools for each registered provider,
652/// allowing efficient reuse of HTTP connections and client state.
653pub struct ClientPool {
654    config: PoolConfig,
655    providers: RwLock<HashMap<String, Arc<ProviderPool>>>,
656    shutdown: std::sync::atomic::AtomicBool,
657}
658
659impl ClientPool {
660    /// Create a new client pool with the given configuration
661    pub fn new(config: PoolConfig) -> Self {
662        Self {
663            config,
664            providers: RwLock::new(HashMap::new()),
665            shutdown: std::sync::atomic::AtomicBool::new(false),
666        }
667    }
668
669    /// Create a new client pool with default configuration
670    pub fn with_defaults() -> Self {
671        Self::new(PoolConfig::default())
672    }
673
674    /// Register a provider with the pool
675    ///
676    /// This creates a sub-pool for the given provider that will manage
677    /// client instances for that provider.
678    #[allow(unreachable_code, unused_variables)]
679    pub fn register_provider(&self, name: &str, provider: Provider) {
680        let pool = Arc::new(ProviderPool::new(provider, self.config.clone()));
681        let mut providers = self.providers.write();
682        providers.insert(name.to_string(), pool);
683    }
684
685    /// Check if a provider is registered
686    pub fn has_provider(&self, name: &str) -> bool {
687        self.providers.read().contains_key(name)
688    }
689
690    /// List all registered provider names
691    pub fn provider_names(&self) -> Vec<String> {
692        self.providers.read().keys().cloned().collect()
693    }
694
695    /// Get a client from the pool for the specified provider
696    ///
697    /// The returned guard will automatically return the client to the pool
698    /// when dropped.
699    pub async fn get(&self, provider_name: &str) -> Result<PooledClientGuard> {
700        self.get_with_error(provider_name).await.map_err(Into::into)
701    }
702
703    pub async fn get_with_error(
704        &self,
705        provider_name: &str,
706    ) -> std::result::Result<PooledClientGuard, PoolError> {
707        if self.shutdown.load(Ordering::Relaxed) {
708            return Err(PoolError::InvalidClient {
709                reason: "pool is shutting down".to_string(),
710            });
711        }
712
713        let pool = {
714            let providers = self.providers.read();
715            providers
716                .get(provider_name)
717                .cloned()
718                .ok_or_else(|| PoolError::InvalidClient {
719                    reason: format!("provider '{provider_name}' not registered in pool"),
720                })?
721        };
722
723        let (client, permit) = pool.acquire().await?;
724
725        Ok(PooledClientGuard {
726            client: Some(client),
727            pool,
728            _permit: permit,
729        })
730    }
731
732    pub async fn try_get(
733        &self,
734        provider_name: &str,
735    ) -> std::result::Result<PooledClientGuard, PoolError> {
736        if self.shutdown.load(Ordering::Relaxed) {
737            return Err(PoolError::InvalidClient {
738                reason: "pool is shutting down".to_string(),
739            });
740        }
741
742        let pool = {
743            let providers = self.providers.read();
744            providers
745                .get(provider_name)
746                .cloned()
747                .ok_or_else(|| PoolError::InvalidClient {
748                    reason: format!("provider '{provider_name}' not registered in pool"),
749                })?
750        };
751
752        let (client, permit) = pool.try_acquire().await?;
753
754        Ok(PooledClientGuard {
755            client: Some(client),
756            pool,
757            _permit: permit,
758        })
759    }
760
761    /// Get pool statistics
762    pub fn stats(&self) -> PoolStats {
763        let providers = self.providers.read();
764        let mut stats = PoolStats {
765            providers: HashMap::new(),
766            total_available: 0,
767            total_in_use: 0,
768            total_connections: 0,
769            borrow_count: 0,
770            error_count: 0,
771        };
772
773        for (name, pool) in providers.iter() {
774            let provider_stats = pool.stats();
775            stats.total_available += provider_stats.available;
776            stats.total_in_use += provider_stats.in_use;
777            stats.total_connections += provider_stats.total;
778            stats.borrow_count += provider_stats.borrow_count;
779            stats.error_count += provider_stats.error_count;
780            stats.providers.insert(name.clone(), provider_stats);
781        }
782
783        stats
784    }
785
786    /// Clean up stale connections across all providers
787    ///
788    /// Returns the total number of connections removed.
789    pub fn cleanup_stale(&self) -> usize {
790        let providers = self.providers.read();
791        providers.values().map(|p| p.cleanup_stale()).sum()
792    }
793
794    /// Start a background task that periodically cleans up stale connections
795    ///
796    /// The task runs until the pool is shut down.
797    pub fn start_cleanup_task(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
798        let pool = Arc::clone(self);
799        let interval = pool.config.health_check_interval;
800
801        tokio::spawn(async move {
802            let mut interval_timer = tokio::time::interval(interval);
803            loop {
804                interval_timer.tick().await;
805
806                if pool.shutdown.load(Ordering::Relaxed) {
807                    break;
808                }
809
810                let removed = pool.cleanup_stale();
811                if removed > 0 {
812                    tracing::debug!("Pool cleanup: removed {} stale connections", removed);
813                }
814            }
815        })
816    }
817
818    /// Gracefully shut down the pool
819    ///
820    /// This prevents new clients from being acquired and drains all existing
821    /// connections.
822    pub fn shutdown(&self) {
823        self.shutdown.store(true, Ordering::Relaxed);
824
825        let providers = self.providers.read();
826        for pool in providers.values() {
827            pool.drain();
828        }
829    }
830
831    /// Check if the pool is shut down
832    pub fn is_shutdown(&self) -> bool {
833        self.shutdown.load(Ordering::Relaxed)
834    }
835
836    pub fn snapshot(&self) -> LLMPoolSnapshot {
837        LLMPoolSnapshot {
838            config: self.config.clone(),
839            stats: self.stats(),
840            providers: self.provider_names(),
841            shutdown: self.is_shutdown(),
842        }
843    }
844}
845
846impl std::fmt::Display for ClientPool {
847    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
848        let snap = self.snapshot();
849        write!(f, "LLMPool(shutdown={}, {})", snap.shutdown, snap.stats)
850    }
851}
852
853impl Default for ClientPool {
854    fn default() -> Self {
855        Self::with_defaults()
856    }
857}
858
859/// Builder for creating a `ClientPool` with registered providers
860pub struct ClientPoolBuilder {
861    config: PoolConfig,
862    providers: Vec<(String, Provider)>,
863}
864
865impl ClientPoolBuilder {
866    /// Create a new builder with default configuration
867    pub fn new() -> Self {
868        Self {
869            config: PoolConfig::default(),
870            providers: Vec::new(),
871        }
872    }
873
874    /// Set the pool configuration
875    pub fn config(mut self, config: PoolConfig) -> Self {
876        self.config = config;
877        self
878    }
879
880    /// Add a provider to the pool
881    pub fn provider(mut self, name: impl Into<String>, provider: Provider) -> Self {
882        self.providers.push((name.into(), provider));
883        self
884    }
885
886    /// Build the client pool
887    pub fn build(self) -> ClientPool {
888        let pool = ClientPool::new(self.config);
889        for (name, provider) in self.providers {
890            pool.register_provider(&name, provider);
891        }
892        pool
893    }
894
895    /// Build the client pool wrapped in an Arc
896    pub fn build_arc(self) -> Arc<ClientPool> {
897        Arc::new(self.build())
898    }
899}
900
901impl Default for ClientPoolBuilder {
902    fn default() -> Self {
903        Self::new()
904    }
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910    use crate::client::test_support::MockLLMClient;
911    use std::sync::atomic::Ordering as AtomicOrdering;
912    fn mock_client(model: &str) -> Box<dyn LLMClient> {
913        Box::new(MockLLMClient::new(model))
914    }
915
916    fn test_stub_provider() -> Provider {
917        Provider::TestStub {
918            model: "mock".to_string(),
919        }
920    }
921
922    fn provider_pool(config: PoolConfig) -> Arc<ProviderPool> {
923        Arc::new(ProviderPool::new(test_stub_provider(), config))
924    }
925
926    fn seed_pool(pool: &ProviderPool, clients: Vec<Box<dyn LLMClient>>) {
927        let mut guard = pool.clients.lock();
928        for client in clients {
929            guard.push(PooledClient {
930                client,
931                meta: PooledClientMeta::new(),
932            });
933        }
934    }
935
936    fn register_seeded_pool(
937        client_pool: &ClientPool,
938        name: &str,
939        config: PoolConfig,
940        clients: Vec<Box<dyn LLMClient>>,
941    ) {
942        let sub = provider_pool(config);
943        seed_pool(&sub, clients);
944        client_pool.providers.write().insert(name.to_string(), sub);
945    }
946
947    #[test]
948    fn test_pool_config_defaults() {
949        let config = PoolConfig::default();
950        assert_eq!(config.max_connections_per_provider, 10);
951        assert_eq!(config.min_idle_connections, 2);
952        assert_eq!(config.idle_timeout, Duration::from_secs(300));
953        assert_eq!(config.max_lifetime, Duration::from_secs(1800));
954        assert_eq!(config.health_check_interval, Duration::from_secs(60));
955        assert_eq!(config.acquire_timeout, Duration::from_secs(30));
956        assert!(config.enable_health_check);
957    }
958
959    #[test]
960    fn test_pool_config_builder() {
961        let config = PoolConfig::default()
962            .with_max_connections(20)
963            .with_idle_timeout(Duration::from_secs(60))
964            .without_health_check();
965
966        assert_eq!(config.max_connections_per_provider, 20);
967        assert_eq!(config.idle_timeout, Duration::from_secs(60));
968        assert!(!config.enable_health_check);
969    }
970
971    #[test]
972    fn test_pool_config_with_max_lifetime() {
973        let config = PoolConfig::default().with_max_lifetime(Duration::from_secs(42));
974        assert_eq!(config.max_lifetime, Duration::from_secs(42));
975    }
976
977    #[test]
978    fn test_pool_config_clone_preserves_fields() {
979        let config = PoolConfig::default()
980            .with_max_connections(7)
981            .with_idle_timeout(Duration::from_secs(11))
982            .with_max_lifetime(Duration::from_secs(22))
983            .without_health_check();
984        let cloned = config.clone();
985        assert_eq!(cloned.max_connections_per_provider, 7);
986        assert_eq!(cloned.idle_timeout, Duration::from_secs(11));
987        assert_eq!(cloned.max_lifetime, Duration::from_secs(22));
988        assert!(!cloned.enable_health_check);
989    }
990
991    #[test]
992    fn test_pool_config_debug_format() {
993        let debug = format!("{:?}", PoolConfig::default().with_max_connections(3));
994        assert!(debug.contains("max_connections_per_provider"));
995        assert!(debug.contains('3'));
996    }
997
998    #[test]
999    fn test_pool_config_max_lifetime_stale() {
1000        let config = PoolConfig::default()
1001            .with_idle_timeout(Duration::from_millis(1))
1002            .with_max_lifetime(Duration::from_millis(5));
1003        let meta = PooledClientMeta::new();
1004        std::thread::sleep(Duration::from_millis(6));
1005        assert!(meta.is_stale(&config));
1006    }
1007
1008    #[test]
1009    fn test_pooled_client_meta_stale_detection() {
1010        let config = PoolConfig::default()
1011            .with_idle_timeout(Duration::from_millis(10))
1012            .with_max_lifetime(Duration::from_millis(50));
1013
1014        let meta = PooledClientMeta::new();
1015        assert!(!meta.is_stale(&config));
1016
1017        std::thread::sleep(Duration::from_millis(15));
1018        assert!(meta.is_stale(&config));
1019    }
1020
1021    #[test]
1022    fn test_pooled_client_meta_mark_used_increments() {
1023        let mut meta = PooledClientMeta::new();
1024        meta.mark_used();
1025        meta.mark_used();
1026        assert_eq!(meta.use_count.load(AtomicOrdering::Relaxed), 2);
1027    }
1028
1029    #[test]
1030    fn test_pooled_client_meta_mark_used_refreshes_idle_timer() {
1031        let config = PoolConfig::default().with_idle_timeout(Duration::from_millis(30));
1032        let mut meta = PooledClientMeta::new();
1033        std::thread::sleep(Duration::from_millis(20));
1034        meta.mark_used();
1035        assert!(!meta.is_stale(&config));
1036    }
1037
1038    #[test]
1039    fn test_pooled_client_debug_output() {
1040        let pooled = PooledClient {
1041            client: mock_client("debug-model"),
1042            meta: PooledClientMeta::new(),
1043        };
1044        let debug = format!("{pooled:?}");
1045        assert!(debug.contains("PooledClient"));
1046        assert!(debug.contains("meta"));
1047    }
1048
1049    #[test]
1050    fn test_provider_pool_stats_clone_and_debug() {
1051        let stats = pool_stats(2, 1, 5, 10, 3, 1);
1052        let cloned = stats.clone();
1053        assert_eq!(cloned.available, 2);
1054        assert_eq!(cloned.in_use, 1);
1055        let debug = format!("{stats:?}");
1056        assert!(debug.contains("available"));
1057    }
1058
1059    #[test]
1060    fn test_pool_stats_clone_and_debug() {
1061        let mut providers = HashMap::new();
1062        providers.insert("p1".to_string(), pool_stats(1, 0, 1, 3, 0, 0));
1063        let stats = PoolStats {
1064            providers,
1065            total_available: 1,
1066            total_in_use: 0,
1067            total_connections: 1,
1068            borrow_count: 0,
1069            error_count: 0,
1070        };
1071        let cloned = stats.clone();
1072        assert_eq!(cloned.total_available, 1);
1073        assert_eq!(cloned.providers.len(), 1);
1074        assert!(format!("{stats:?}").contains("total_in_use"));
1075    }
1076
1077    #[test]
1078    fn test_client_pool_default_impl() {
1079        let pool = ClientPool::default();
1080        assert!(!pool.is_shutdown());
1081        assert_eq!(pool.stats().total_available, 0);
1082    }
1083
1084    #[test]
1085    fn test_client_pool_builder_default_impl() {
1086        let builder = ClientPoolBuilder::default();
1087        let pool = builder.build();
1088        assert!(!pool.has_provider("anything"));
1089    }
1090
1091    #[test]
1092    fn test_builder_empty_build() {
1093        let pool = ClientPoolBuilder::new().build();
1094        assert!(pool.provider_names().is_empty());
1095        assert!(!pool.has_provider("missing"));
1096    }
1097
1098    #[test]
1099    fn test_builder_build_arc() {
1100        let pool = ClientPoolBuilder::new()
1101            .config(PoolConfig::default().with_max_connections(4))
1102            .build_arc();
1103        assert_eq!(pool.stats().total_available, 0);
1104        assert!(!pool.is_shutdown());
1105    }
1106
1107    #[test]
1108    fn test_pool_stats() {
1109        let pool = ClientPool::with_defaults();
1110        let stats = pool.stats();
1111        assert_eq!(stats.total_available, 0);
1112        assert_eq!(stats.total_in_use, 0);
1113        assert!(stats.providers.is_empty());
1114    }
1115
1116    #[test]
1117    fn test_cleanup_stale_on_empty_pool() {
1118        let pool = ClientPool::with_defaults();
1119        assert_eq!(pool.cleanup_stale(), 0);
1120    }
1121
1122    #[test]
1123    fn test_pool_shutdown() {
1124        let pool = ClientPool::with_defaults();
1125        assert!(!pool.is_shutdown());
1126        pool.shutdown();
1127        assert!(pool.is_shutdown());
1128    }
1129
1130    #[test]
1131    fn test_pool_double_shutdown_is_safe() {
1132        let pool = ClientPool::with_defaults();
1133        pool.shutdown();
1134        pool.shutdown();
1135        assert!(pool.is_shutdown());
1136    }
1137
1138    #[test]
1139    fn test_provider_registration() {
1140        let pool = ClientPool::with_defaults();
1141        pool.register_provider("ollama", test_stub_provider());
1142        assert!(pool.has_provider("ollama"));
1143        assert!(!pool.has_provider("openai"));
1144        assert_eq!(pool.provider_names(), vec!["ollama"]);
1145    }
1146
1147    #[test]
1148    fn test_builder_pattern() {
1149        let pool = ClientPoolBuilder::new()
1150            .config(PoolConfig::default().with_max_connections(5))
1151            .provider("ollama", test_stub_provider())
1152            .build();
1153        assert!(pool.has_provider("ollama"));
1154    }
1155
1156    #[test]
1157    fn test_builder_multiple_providers() {
1158        let pool = ClientPoolBuilder::new()
1159            .provider("a", test_stub_provider())
1160            .provider("b", test_stub_provider())
1161            .build();
1162        let mut names = pool.provider_names();
1163        names.sort();
1164        assert_eq!(names, vec!["a", "b"]);
1165    }
1166
1167    #[tokio::test]
1168    async fn test_get_unregistered_provider_error() {
1169        let pool = ClientPool::with_defaults();
1170        let result = pool.get("nonexistent").await;
1171        assert!(result.is_err());
1172        assert!(matches!(result.unwrap_err(), AppError::LLM(_)));
1173    }
1174
1175    #[tokio::test]
1176    async fn test_acquire_reuses_seeded_mock_without_network() {
1177        let config = PoolConfig::default()
1178            .with_max_connections(2)
1179            .without_health_check();
1180        let pool = ClientPool::new(config.clone());
1181        register_seeded_pool(&pool, "mock", config, vec![mock_client("seeded")]);
1182
1183        let guard = pool
1184            .get("mock")
1185            .await
1186            .expect("seeded client should be available");
1187        assert_eq!(guard.client().model_name(), "seeded");
1188        assert_eq!(pool.stats().providers["mock"].total_created, 0);
1189    }
1190
1191    #[tokio::test]
1192    async fn test_acquire_skips_stale_prefers_first_fresh() {
1193        let config = PoolConfig::default()
1194            .with_idle_timeout(Duration::from_millis(5))
1195            .with_max_lifetime(Duration::from_secs(60));
1196        let sub = provider_pool(config);
1197
1198        {
1199            let mut guard = sub.clients.lock();
1200            guard.push(PooledClient {
1201                client: mock_client("stale"),
1202                meta: PooledClientMeta::new(),
1203            });
1204            std::thread::sleep(Duration::from_millis(8));
1205            guard.push(PooledClient {
1206                client: mock_client("fresh"),
1207                meta: PooledClientMeta::new(),
1208            });
1209        }
1210
1211        let (client, permit) = sub.acquire().await.expect("acquire fresh client");
1212        assert_eq!(client.model_name(), "fresh");
1213        drop(client);
1214        drop(permit);
1215    }
1216
1217    #[tokio::test]
1218    async fn test_release_and_reacquire_reuses_pooled_client() {
1219        let config = PoolConfig::default()
1220            .with_max_connections(1)
1221            .without_health_check();
1222        let pool = ClientPool::new(config.clone());
1223        register_seeded_pool(&pool, "mock", config, vec![mock_client("reusable")]);
1224
1225        {
1226            let guard = pool.get("mock").await.expect("first acquire");
1227            assert_eq!(guard.client().model_name(), "reusable");
1228        }
1229
1230        let guard = pool.get("mock").await.expect("second acquire");
1231        assert_eq!(guard.client().model_name(), "reusable");
1232        let stats = pool.stats().providers["mock"].clone();
1233        assert_eq!(stats.total_created, 0);
1234        assert_eq!(stats.in_use, 1);
1235    }
1236
1237    #[tokio::test]
1238    async fn test_release_drops_client_when_idle_pool_full() {
1239        let config = PoolConfig::default()
1240            .with_max_connections(1)
1241            .without_health_check();
1242        let sub = provider_pool(config);
1243        seed_pool(&sub, vec![mock_client("idle-0")]);
1244
1245        let (client, permit) = sub.acquire().await.expect("acquire seeded client");
1246        sub.release(client);
1247        drop(permit);
1248        assert_eq!(sub.stats().available, 1);
1249        assert_eq!(sub.stats().in_use, 0);
1250
1251        let (overflow, permit2) = sub.acquire().await.expect("acquire again");
1252        sub.release(overflow);
1253        drop(permit2);
1254
1255        let stats = sub.stats();
1256        assert_eq!(stats.available, 1);
1257        assert_eq!(stats.in_use, 0);
1258        assert_eq!(stats.total_created, 0);
1259    }
1260
1261    #[tokio::test]
1262    async fn test_provider_pool_in_use_accounting() {
1263        let config = PoolConfig::default()
1264            .with_max_connections(2)
1265            .without_health_check();
1266        let sub = provider_pool(config);
1267        seed_pool(&sub, vec![mock_client("a")]);
1268
1269        let (_client, permit) = sub.acquire().await.expect("acquire");
1270        let stats = sub.stats();
1271        assert_eq!(stats.in_use, 1);
1272        assert_eq!(stats.available, 0);
1273        drop(permit);
1274    }
1275
1276    #[tokio::test]
1277    async fn test_cleanup_stale_removes_idle_clients() {
1278        let config = PoolConfig::default()
1279            .with_idle_timeout(Duration::from_millis(5))
1280            .without_health_check();
1281        let sub = provider_pool(config);
1282        {
1283            let mut guard = sub.clients.lock();
1284            guard.push(PooledClient {
1285                client: mock_client("old"),
1286                meta: PooledClientMeta::new(),
1287            });
1288            std::thread::sleep(Duration::from_millis(8));
1289        }
1290        let removed = sub.cleanup_stale();
1291        assert_eq!(removed, 1);
1292        assert_eq!(sub.stats().available, 0);
1293    }
1294
1295    #[tokio::test]
1296    async fn test_pooled_client_guard_debug_and_deref() {
1297        let config = PoolConfig::default()
1298            .with_max_connections(1)
1299            .without_health_check();
1300        let pool = ClientPool::new(config.clone());
1301        register_seeded_pool(&pool, "mock", config, vec![mock_client("guard")]);
1302
1303        let guard = pool.get("mock").await.expect("guard acquire");
1304        let debug = format!("{guard:?}");
1305        assert!(debug.contains("PooledClientGuard"));
1306        assert!(debug.contains("has_client"));
1307        assert_eq!(guard.model_name(), "guard");
1308    }
1309
1310    #[test]
1311    fn test_register_provider_overwrites_existing_name() {
1312        let pool = ClientPool::with_defaults();
1313        pool.register_provider("ollama", test_stub_provider());
1314        pool.register_provider("ollama", test_stub_provider());
1315        assert_eq!(pool.provider_names(), vec!["ollama"]);
1316    }
1317
1318    #[tokio::test]
1319    async fn test_stats_aggregate_multiple_providers() {
1320        let config = PoolConfig::default()
1321            .with_max_connections(2)
1322            .without_health_check();
1323        let pool = ClientPool::new(config.clone());
1324        register_seeded_pool(&pool, "a", config.clone(), vec![mock_client("a1")]);
1325        register_seeded_pool(&pool, "b", config, vec![mock_client("b1")]);
1326
1327        let _ga = pool.get("a").await.expect("provider a");
1328        let stats = pool.stats();
1329        assert_eq!(stats.providers.len(), 2);
1330        assert_eq!(stats.total_in_use, 1);
1331        assert_eq!(stats.total_available, 1);
1332    }
1333
1334    #[tokio::test]
1335    async fn test_shutdown_drains_seeded_clients() {
1336        let config = PoolConfig::default()
1337            .with_max_connections(2)
1338            .without_health_check();
1339        let pool = ClientPool::new(config.clone());
1340        register_seeded_pool(&pool, "mock", config, vec![mock_client("seeded")]);
1341        assert_eq!(pool.stats().total_available, 1);
1342
1343        pool.shutdown();
1344        assert!(pool.is_shutdown());
1345        assert_eq!(pool.stats().total_available, 0);
1346    }
1347
1348    #[tokio::test]
1349    async fn test_acquire_creates_client_when_pool_empty() {
1350        let config = PoolConfig::default()
1351            .with_max_connections(1)
1352            .without_health_check();
1353        let sub = provider_pool(config);
1354
1355        let (client, permit) = sub.acquire().await.expect("create via TestStub");
1356        assert_eq!(client.model_name(), "mock");
1357        drop(client);
1358        drop(permit);
1359        assert_eq!(sub.stats().total_created, 1);
1360    }
1361
1362    #[tokio::test]
1363    async fn test_pooled_client_guard_client_mut_and_take() {
1364        let config = PoolConfig::default()
1365            .with_max_connections(1)
1366            .without_health_check();
1367        let pool = ClientPool::new(config.clone());
1368        register_seeded_pool(&pool, "mock", config, vec![mock_client("guard-mut")]);
1369
1370        let mut guard = pool.get("mock").await.expect("guard acquire");
1371        assert_eq!(guard.client_mut().model_name(), "guard-mut");
1372        let taken = guard.take();
1373        assert_eq!(taken.model_name(), "guard-mut");
1374    }
1375
1376    fn serde_roundtrip<T>(value: &T) -> T
1377    where
1378        T: serde::Serialize + for<'de> serde::Deserialize<'de> + PartialEq + std::fmt::Debug,
1379    {
1380        let json = serde_json::to_string(value).unwrap();
1381        let parsed: T = serde_json::from_str(&json).unwrap();
1382        assert_eq!(*value, parsed);
1383        parsed
1384    }
1385
1386    #[test]
1387    fn test_pool_config_serde_roundtrip() {
1388        let config = PoolConfig::default()
1389            .with_max_connections(4)
1390            .with_idle_timeout(Duration::from_secs(90))
1391            .without_health_check();
1392        serde_roundtrip(&config);
1393    }
1394
1395    #[test]
1396    fn test_pool_stats_serde_roundtrip() {
1397        let mut providers = HashMap::new();
1398        providers.insert("mock".into(), pool_stats(1, 2, 3, 4, 5, 6));
1399        let stats = PoolStats {
1400            providers,
1401            total_available: 1,
1402            total_in_use: 2,
1403            total_connections: 3,
1404            borrow_count: 5,
1405            error_count: 6,
1406        };
1407        serde_roundtrip(&stats);
1408    }
1409
1410    #[test]
1411    fn test_provider_pool_stats_serde_roundtrip() {
1412        serde_roundtrip(&pool_stats(2, 1, 9, 10, 7, 2));
1413    }
1414
1415    #[test]
1416    fn test_llm_pool_snapshot_serde_roundtrip() {
1417        let pool = ClientPoolBuilder::new()
1418            .provider("mock", test_stub_provider())
1419            .build();
1420        serde_roundtrip(&pool.snapshot());
1421    }
1422
1423    #[test]
1424    fn test_llm_pool_type_alias() {
1425        let pool: LLMPool = ClientPool::with_defaults();
1426        assert_eq!(pool.stats().total_available, 0);
1427    }
1428
1429    #[test]
1430    fn test_pool_error_serde_roundtrip_exhausted() {
1431        serde_roundtrip(&PoolError::PoolExhausted { max: 3 });
1432    }
1433
1434    #[test]
1435    fn test_pool_error_serde_roundtrip_timeout() {
1436        serde_roundtrip(&PoolError::Timeout { timeout_ms: 250 });
1437    }
1438
1439    #[test]
1440    fn test_pool_error_serde_roundtrip_invalid_client() {
1441        serde_roundtrip(&PoolError::InvalidClient {
1442            reason: "bad".into(),
1443        });
1444    }
1445
1446    #[test]
1447    fn test_pool_error_display_variants() {
1448        assert!(
1449            PoolError::PoolExhausted { max: 2 }
1450                .to_string()
1451                .contains("pool exhausted")
1452        );
1453        assert!(
1454            PoolError::Timeout { timeout_ms: 10 }
1455                .to_string()
1456                .contains("timeout")
1457        );
1458        assert!(
1459            PoolError::InvalidClient { reason: "x".into() }
1460                .to_string()
1461                .contains("invalid")
1462        );
1463    }
1464
1465    #[test]
1466    fn test_pool_error_clone_debug() {
1467        let err = PoolError::PoolExhausted { max: 1 };
1468        assert_eq!(err, err.clone());
1469        assert!(format!("{err:?}").contains("PoolExhausted"));
1470    }
1471
1472    #[test]
1473    fn test_pool_config_display() {
1474        let s = PoolConfig::default().with_max_connections(6).to_string();
1475        assert!(s.contains("max=6"));
1476    }
1477
1478    #[test]
1479    fn test_pool_stats_display() {
1480        let stats = PoolStats {
1481            providers: HashMap::new(),
1482            total_available: 1,
1483            total_in_use: 2,
1484            total_connections: 3,
1485            borrow_count: 4,
1486            error_count: 5,
1487        };
1488        let s = stats.to_string();
1489        assert!(s.contains("idle=1"));
1490        assert!(s.contains("errors=5"));
1491    }
1492
1493    #[test]
1494    fn test_provider_pool_stats_display() {
1495        let s = pool_stats(1, 2, 0, 4, 9, 1).to_string();
1496        assert!(s.contains("borrows=9"));
1497        assert!(s.contains("total=3"));
1498    }
1499
1500    #[test]
1501    fn test_client_pool_display() {
1502        let pool = ClientPool::with_defaults();
1503        let s = pool.to_string();
1504        assert!(s.contains("LLMPool"));
1505        assert!(s.contains("shutdown=false"));
1506    }
1507
1508    #[test]
1509    fn test_borrow_client_returns_first_healthy() {
1510        let config = PoolConfig::default().without_health_check();
1511        let mut idle = vec![PooledClient {
1512            client: mock_client("a"),
1513            meta: PooledClientMeta::new(),
1514        }];
1515        match borrow_client(&mut idle, &config) {
1516            BorrowFromIdle::Found(p) => assert_eq!(p.client.model_name(), "a"),
1517            _ => panic!("expected found"),
1518        }
1519        assert!(idle.is_empty());
1520    }
1521
1522    #[test]
1523    fn test_borrow_client_purges_stale_entries() {
1524        let config = PoolConfig::default().with_idle_timeout(Duration::from_millis(1));
1525        let mut idle = vec![PooledClient {
1526            client: mock_client("stale"),
1527            meta: PooledClientMeta::new(),
1528        }];
1529        std::thread::sleep(Duration::from_millis(3));
1530        assert!(matches!(
1531            borrow_client(&mut idle, &config),
1532            BorrowFromIdle::Exhausted
1533        ));
1534        assert!(idle.is_empty());
1535    }
1536
1537    #[test]
1538    fn test_return_client_respects_capacity() {
1539        let config = PoolConfig::default()
1540            .with_max_connections(1)
1541            .without_health_check();
1542        let mut idle = vec![];
1543        assert_eq!(
1544            return_client(&mut idle, mock_client("a"), &config),
1545            ReturnDisposition::Returned
1546        );
1547        assert_eq!(
1548            return_client(&mut idle, mock_client("b"), &config),
1549            ReturnDisposition::Dropped
1550        );
1551        assert_eq!(idle.len(), 1);
1552    }
1553
1554    #[test]
1555    fn test_health_check_disabled_ignores_stale_meta() {
1556        let config = PoolConfig::default()
1557            .with_idle_timeout(Duration::from_millis(1))
1558            .without_health_check();
1559        let meta = PooledClientMeta::new();
1560        std::thread::sleep(Duration::from_millis(3));
1561        assert!(health_check(&meta, &config));
1562    }
1563
1564    #[test]
1565    fn test_health_check_enabled_rejects_stale_meta() {
1566        let config = PoolConfig::default().with_idle_timeout(Duration::from_millis(1));
1567        let meta = PooledClientMeta::new();
1568        std::thread::sleep(Duration::from_millis(3));
1569        assert!(!health_check(&meta, &config));
1570    }
1571
1572    #[test]
1573    fn test_pool_stats_helper_totals() {
1574        let stats = pool_stats(2, 3, 10, 8, 4, 1);
1575        assert_eq!(stats.total, 5);
1576        assert_eq!(stats.borrow_count, 4);
1577        assert_eq!(stats.error_count, 1);
1578    }
1579
1580    #[test]
1581    fn test_validate_pooled_client_rejects_empty_model() {
1582        let client = mock_client("");
1583        let err = validate_pooled_client(client.as_ref()).unwrap_err();
1584        assert!(matches!(err, PoolError::InvalidClient { .. }));
1585    }
1586
1587    #[test]
1588    fn test_validate_pooled_client_accepts_named_model() {
1589        validate_pooled_client(mock_client("ok").as_ref()).unwrap();
1590    }
1591
1592    #[tokio::test]
1593    async fn test_try_get_pool_exhausted_when_at_capacity() {
1594        let config = PoolConfig::default()
1595            .with_max_connections(1)
1596            .without_health_check();
1597        let pool = ClientPool::new(config.clone());
1598        register_seeded_pool(&pool, "mock", config, vec![mock_client("only")]);
1599
1600        let _guard = pool.try_get("mock").await.expect("first borrow");
1601        let err = pool.try_get("mock").await.unwrap_err();
1602        assert!(matches!(err, PoolError::PoolExhausted { max: 1 }));
1603        assert_eq!(pool.stats().providers["mock"].error_count, 1);
1604    }
1605
1606    #[tokio::test]
1607    async fn test_acquire_timeout_increments_error_count() {
1608        let mut config = PoolConfig::default()
1609            .with_max_connections(1)
1610            .with_idle_timeout(Duration::from_secs(60))
1611            .without_health_check();
1612        config.acquire_timeout = Duration::from_millis(50);
1613        let sub = provider_pool(config);
1614        seed_pool(&sub, vec![mock_client("held")]);
1615
1616        let (_c, permit) = sub.acquire().await.expect("hold permit");
1617        let err = match sub.acquire().await {
1618            Err(e) => e,
1619            Ok(_) => panic!("expected pool acquire timeout"),
1620        };
1621        assert!(matches!(err, PoolError::Timeout { .. }));
1622        assert_eq!(sub.stats().error_count, 1);
1623        drop(permit);
1624    }
1625
1626    #[tokio::test]
1627    async fn test_borrow_count_increments_on_success() {
1628        let config = PoolConfig::default()
1629            .with_max_connections(2)
1630            .without_health_check();
1631        let sub = provider_pool(config);
1632        seed_pool(&sub, vec![mock_client("x")]);
1633        let (_c, permit) = sub.acquire().await.unwrap();
1634        drop(permit);
1635        assert_eq!(sub.stats().borrow_count, 1);
1636    }
1637
1638    #[tokio::test]
1639    async fn test_concurrent_borrows_respect_max_connections() {
1640        let config = PoolConfig::default()
1641            .with_max_connections(2)
1642            .without_health_check();
1643        let pool = Arc::new(ClientPool::new(config.clone()));
1644        register_seeded_pool(
1645            &pool,
1646            "mock",
1647            config,
1648            vec![mock_client("c1"), mock_client("c2")],
1649        );
1650
1651        let (tx, mut rx) = tokio::sync::oneshot::channel();
1652        let pool_bg = Arc::clone(&pool);
1653        tokio::spawn(async move {
1654            let result = pool_bg.get("mock").await;
1655            let _ = tx.send(result);
1656        });
1657
1658        let g1 = pool.get("mock").await.expect("first");
1659        let g2 = pool.get("mock").await.expect("second");
1660        assert!(
1661            tokio::time::timeout(Duration::from_millis(100), &mut rx)
1662                .await
1663                .is_err(),
1664            "third borrow should still be waiting"
1665        );
1666        drop(g1);
1667        drop(g2);
1668        let third_result = rx
1669            .await
1670            .expect("channel")
1671            .expect("third completes after release");
1672        drop(third_result);
1673    }
1674
1675    #[tokio::test]
1676    async fn test_get_with_error_unregistered_invalid_client() {
1677        let pool = ClientPool::with_defaults();
1678        let err = pool.get_with_error("missing").await.unwrap_err();
1679        assert!(matches!(err, PoolError::InvalidClient { .. }));
1680    }
1681
1682    #[tokio::test]
1683    async fn test_get_with_error_shutdown_invalid_client() {
1684        let pool = ClientPool::with_defaults();
1685        pool.shutdown();
1686        let err = pool.get_with_error("anything").await.unwrap_err();
1687        assert!(matches!(err, PoolError::InvalidClient { .. }));
1688    }
1689
1690    #[tokio::test]
1691    async fn test_cleanup_stale_client_pool_aggregates() {
1692        let config = PoolConfig::default()
1693            .with_idle_timeout(Duration::from_millis(5))
1694            .without_health_check();
1695        let pool = ClientPool::new(config.clone());
1696        register_seeded_pool(&pool, "mock", config, vec![mock_client("old")]);
1697        std::thread::sleep(Duration::from_millis(8));
1698        assert_eq!(pool.cleanup_stale(), 1);
1699        assert_eq!(pool.stats().total_available, 0);
1700    }
1701
1702    #[tokio::test]
1703    async fn test_stats_track_aggregate_borrow_and_error_counts() {
1704        let config = PoolConfig::default()
1705            .with_max_connections(1)
1706            .without_health_check();
1707        let pool = ClientPool::new(config.clone());
1708        register_seeded_pool(&pool, "mock", config, vec![mock_client("one")]);
1709        let _g = pool.try_get("mock").await.unwrap();
1710        let _ = pool.try_get("mock").await;
1711        let stats = pool.stats();
1712        assert_eq!(stats.borrow_count, 1);
1713        assert_eq!(stats.error_count, 1);
1714    }
1715
1716    #[tokio::test]
1717    async fn test_acquire_removes_stale_before_creating_client() {
1718        let config = PoolConfig::default().with_idle_timeout(Duration::from_millis(5));
1719        let sub = provider_pool(config);
1720        {
1721            let mut guard = sub.clients.lock();
1722            guard.push(PooledClient {
1723                client: mock_client("stale-only"),
1724                meta: PooledClientMeta::new(),
1725            });
1726            std::thread::sleep(Duration::from_millis(8));
1727        }
1728        let (client, permit) = sub.acquire().await.expect("creates fresh client");
1729        assert_eq!(client.model_name(), "mock");
1730        assert_eq!(sub.stats().total_created, 1);
1731        drop(client);
1732        drop(permit);
1733    }
1734
1735    #[tokio::test]
1736    async fn test_race_release_then_reacquire() {
1737        let config = PoolConfig::default()
1738            .with_max_connections(1)
1739            .without_health_check();
1740        let pool = Arc::new(ClientPool::new(config.clone()));
1741        register_seeded_pool(&pool, "mock", config, vec![mock_client("race")]);
1742
1743        let g1 = pool.get("mock").await.expect("first");
1744        let pool2 = Arc::clone(&pool);
1745        let j = tokio::spawn(async move {
1746            tokio::time::sleep(Duration::from_millis(5)).await;
1747            pool2.get("mock").await
1748        });
1749        drop(g1);
1750        let g2 = j
1751            .await
1752            .expect("join")
1753            .expect("second acquire after release");
1754        assert_eq!(g2.model_name(), "race");
1755    }
1756
1757    #[test]
1758    fn test_return_disposition_debug_clone() {
1759        let d = ReturnDisposition::Returned;
1760        assert_eq!(d, d);
1761        assert!(format!("{d:?}").contains("Returned"));
1762    }
1763
1764    #[test]
1765    fn test_pool_stats_clone_preserves_aggregate_fields() {
1766        let stats = PoolStats {
1767            providers: HashMap::new(),
1768            total_available: 0,
1769            total_in_use: 0,
1770            total_connections: 0,
1771            borrow_count: 2,
1772            error_count: 3,
1773        };
1774        let cloned = stats.clone();
1775        assert_eq!(cloned.borrow_count, 2);
1776        assert_eq!(cloned.error_count, 3);
1777    }
1778
1779    #[tokio::test]
1780    async fn test_provider_pool_stats_after_release_shows_idle() {
1781        let config = PoolConfig::default()
1782            .with_max_connections(1)
1783            .without_health_check();
1784        let sub = provider_pool(config);
1785        let (client, permit) = sub.acquire().await.unwrap();
1786        sub.release(client);
1787        drop(permit);
1788        let stats = sub.stats();
1789        assert_eq!(stats.available, 1);
1790        assert_eq!(stats.in_use, 0);
1791        assert_eq!(stats.total, 1);
1792    }
1793
1794    #[test]
1795    fn test_pool_stats_default_aggregate_fields() {
1796        let stats = ClientPool::with_defaults().stats();
1797        assert_eq!(stats.total_connections, 0);
1798        assert_eq!(stats.borrow_count, 0);
1799        assert_eq!(stats.error_count, 0);
1800    }
1801
1802    #[test]
1803    fn test_return_disposition_variants() {
1804        assert_ne!(ReturnDisposition::Returned, ReturnDisposition::Dropped);
1805    }
1806
1807    #[tokio::test]
1808    async fn test_try_get_success_returns_guard() {
1809        let config = PoolConfig::default()
1810            .with_max_connections(1)
1811            .without_health_check();
1812        let pool = ClientPool::new(config.clone());
1813        register_seeded_pool(&pool, "mock", config, vec![mock_client("ok")]);
1814        let guard = pool.try_get("mock").await.expect("try_get ok");
1815        assert_eq!(guard.model_name(), "ok");
1816    }
1817
1818    #[tokio::test]
1819    async fn test_get_with_error_success_path() {
1820        let config = PoolConfig::default()
1821            .with_max_connections(1)
1822            .without_health_check();
1823        let pool = ClientPool::new(config.clone());
1824        register_seeded_pool(&pool, "mock", config, vec![mock_client("ok2")]);
1825        let guard = pool.get_with_error("mock").await.expect("get ok");
1826        assert_eq!(guard.model_name(), "ok2");
1827    }
1828
1829    #[test]
1830    fn test_llm_pool_snapshot_lists_providers() {
1831        let pool = ClientPoolBuilder::new()
1832            .provider("a", test_stub_provider())
1833            .provider("b", test_stub_provider())
1834            .build();
1835        let snap = pool.snapshot();
1836        let mut names = snap.providers;
1837        names.sort();
1838        assert_eq!(names, vec!["a", "b"]);
1839    }
1840
1841    #[test]
1842    fn test_health_check_fresh_client_meta() {
1843        let config = PoolConfig::default().with_idle_timeout(Duration::from_secs(60));
1844        let meta = PooledClientMeta::new();
1845        assert!(health_check(&meta, &config));
1846    }
1847
1848    #[tokio::test]
1849    async fn test_cleanup_stale_on_provider_pool() {
1850        let config = PoolConfig::default().with_idle_timeout(Duration::from_millis(5));
1851        let sub = provider_pool(config);
1852        {
1853            let mut guard = sub.clients.lock();
1854            guard.push(PooledClient {
1855                client: mock_client("gone"),
1856                meta: PooledClientMeta::new(),
1857            });
1858            std::thread::sleep(Duration::from_millis(8));
1859        }
1860        assert_eq!(sub.cleanup_stale(), 1);
1861    }
1862
1863    #[tokio::test]
1864    async fn test_get_after_shutdown() {
1865        let pool = ClientPool::with_defaults();
1866        pool.shutdown();
1867
1868        let result = pool.get("anything").await;
1869        assert!(result.is_err());
1870        assert!(matches!(result.unwrap_err(), AppError::LLM(_)));
1871    }
1872
1873    // ===== Per-provider in-flight governor =====
1874    use crate::client::LLMResponse;
1875    use ares_types::types::ToolDefinition;
1876    use futures::Stream;
1877
1878    /// Slow mock client: each `generate` sleeps then records the moment it
1879    /// runs, so a test can observe true concurrency (overlapping dispatches).
1880    struct SlowMockClient {
1881        delay: Duration,
1882        in_flight: Arc<AtomicUsize>,
1883        max_observed: Arc<AtomicUsize>,
1884    }
1885
1886    impl SlowMockClient {
1887        fn new(
1888            delay: Duration,
1889            in_flight: Arc<AtomicUsize>,
1890            max_observed: Arc<AtomicUsize>,
1891        ) -> Self {
1892            Self {
1893                delay,
1894                in_flight,
1895                max_observed,
1896            }
1897        }
1898    }
1899
1900    #[async_trait]
1901    impl LLMClient for SlowMockClient {
1902        async fn generate(&self, _prompt: &str) -> Result<String> {
1903            let now = self.in_flight.fetch_add(1, AtomicOrdering::Relaxed) + 1;
1904            self.max_observed.fetch_max(now, AtomicOrdering::Relaxed);
1905            tokio::time::sleep(self.delay).await;
1906            self.in_flight.fetch_sub(1, AtomicOrdering::Relaxed);
1907            Ok("slow".into())
1908        }
1909
1910        async fn generate_with_system(&self, _system: &str, prompt: &str) -> Result<String> {
1911            self.generate(prompt).await
1912        }
1913
1914        async fn generate_with_history(
1915            &self,
1916            messages: &[(String, String)],
1917        ) -> Result<LLMResponse> {
1918            let content = self
1919                .generate(messages.first().map(|(_, c)| c.as_str()).unwrap_or(""))
1920                .await?;
1921            Ok(LLMResponse {
1922                content,
1923                tool_calls: vec![],
1924                finish_reason: "stop".into(),
1925                usage: None,
1926            })
1927        }
1928
1929        async fn generate_with_tools(
1930            &self,
1931            prompt: &str,
1932            _tools: &[ToolDefinition],
1933        ) -> Result<LLMResponse> {
1934            let content = self.generate(prompt).await?;
1935            Ok(LLMResponse {
1936                content,
1937                tool_calls: vec![],
1938                finish_reason: "stop".into(),
1939                usage: None,
1940            })
1941        }
1942
1943        async fn generate_with_tools_and_history(
1944            &self,
1945            messages: &[crate::coordinator::ConversationMessage],
1946            _tools: &[ToolDefinition],
1947        ) -> Result<LLMResponse> {
1948            let content = self
1949                .generate(messages.first().map(|m| m.content.as_str()).unwrap_or(""))
1950                .await?;
1951            Ok(LLMResponse {
1952                content,
1953                tool_calls: vec![],
1954                finish_reason: "stop".into(),
1955                usage: None,
1956            })
1957        }
1958
1959        async fn stream(
1960            &self,
1961            _prompt: &str,
1962        ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
1963            Err(AppError::Internal("stream unused here".into()))
1964        }
1965
1966        async fn stream_with_system(
1967            &self,
1968            _system: &str,
1969            _prompt: &str,
1970        ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
1971            Err(AppError::Internal("stream unused here".into()))
1972        }
1973
1974        async fn stream_with_history(
1975            &self,
1976            _messages: &[(String, String)],
1977        ) -> Result<Box<dyn Stream<Item = Result<String>> + Send + Unpin>> {
1978            Err(AppError::Internal("stream unused here".into()))
1979        }
1980
1981        fn model_name(&self) -> &str {
1982            "slow-mock"
1983        }
1984    }
1985
1986    #[tokio::test(flavor = "multi_thread")]
1987    async fn governor_caps_concurrent_dispatch() {
1988        const PERMITS: usize = 2;
1989        const CALLS: usize = 8;
1990
1991        // Every task gets its own SLOW client (seeded, so no network-backed
1992        // creation happens): without the governor all eight 30ms bodies would
1993        // overlap and the high-water mark would hit 8.
1994        let in_flight = Arc::new(AtomicUsize::new(0));
1995        let max_observed = Arc::new(AtomicUsize::new(0));
1996        let slow_clients: Vec<Box<dyn LLMClient>> = (0..CALLS)
1997            .map(|_| {
1998                Box::new(SlowMockClient::new(
1999                    Duration::from_millis(30),
2000                    Arc::clone(&in_flight),
2001                    Arc::clone(&max_observed),
2002                )) as Box<dyn LLMClient>
2003            })
2004            .collect();
2005
2006        let config = PoolConfig::default()
2007            .with_max_in_flight(PERMITS)
2008            .with_governor_acquire_timeout(Duration::from_secs(5))
2009            .without_health_check();
2010        let pool = Arc::new(ClientPool::new(config.clone()));
2011        register_seeded_pool(&pool, "mock", config, slow_clients);
2012
2013        let mut handles = Vec::new();
2014        for _ in 0..CALLS {
2015            let pool = Arc::clone(&pool);
2016            handles.push(tokio::spawn(async move {
2017                let guard = pool.get("mock").await.expect("governed checkout");
2018                let _ = guard.client().generate("hello").await;
2019            }));
2020        }
2021        for handle in handles {
2022            handle.await.expect("task joins");
2023        }
2024
2025        let observed = max_observed.load(AtomicOrdering::Relaxed);
2026        assert_eq!(
2027            in_flight.load(AtomicOrdering::Relaxed),
2028            0,
2029            "all dispatches finished"
2030        );
2031        assert!(
2032            observed <= PERMITS,
2033            "max observed in-flight ({observed}) exceeded permits ({PERMITS})"
2034        );
2035        assert_eq!(observed, PERMITS, "cap should be reached under load");
2036    }
2037
2038    #[tokio::test(flavor = "multi_thread")]
2039    async fn unlimited_default_preserves_behavior() {
2040        // Default config: no cap configured. Many overlapping checkouts must
2041        // all proceed without waiting on any governor slot.
2042        let config = PoolConfig::default().without_health_check();
2043        let pool = ClientPool::new(config.clone());
2044        register_seeded_pool(&pool, "mock", config, vec![mock_client("free")]);
2045
2046        let guard = pool.get("mock").await.expect("checkout works");
2047        assert_eq!(guard.client().model_name(), "free");
2048        // The handed-out client is NOT a governor wrapper — no extra layer.
2049        let taken: Box<dyn LLMClient> = guard.take();
2050        assert_eq!(taken.model_name(), "free");
2051        drop(taken);
2052
2053        // And the wrap funnel itself is a pass-through when unlimited.
2054        let unlimited = ProviderGovernor::new(GovernorConfig::default());
2055        let wrapped = unlimited.wrap_if_limited(mock_client("passthrough"));
2056        assert_eq!(
2057            wrapped.model_name(),
2058            "passthrough",
2059            "unlimited governors must not install wrappers"
2060        );
2061
2062        let defaults = PoolConfig::default();
2063        assert_eq!(defaults.max_in_flight, None);
2064        assert_eq!(defaults.governor_config(), GovernorConfig::default());
2065    }
2066
2067    #[tokio::test]
2068    async fn permit_released_on_error_path() {
2069        let config = PoolConfig::default()
2070            .with_max_in_flight(1)
2071            .with_governor_acquire_timeout(Duration::from_millis(100))
2072            .without_health_check();
2073        let sub = provider_pool(config.clone());
2074
2075        // Take the only slot with a failing call: generate errors after the
2076        // admit. The permit must return even though the dispatch failed.
2077        let failing = SlowMockFailing;
2078        let guarded = sub.governor.wrap_if_limited(Box::new(failing));
2079        let err = guarded.generate("boom").await.unwrap_err();
2080        assert!(matches!(err, AppError::LLM(_)));
2081
2082        // The single slot is free again — an immediate second admission
2083        // succeeds without hitting the 100ms timeout.
2084        let ok_client = sub.governor.wrap_if_limited(Box::new(SlowMockClient::new(
2085            Duration::from_millis(1),
2086            Arc::new(AtomicUsize::new(0)),
2087            Arc::new(AtomicUsize::new(0)),
2088        )));
2089        let started = std::time::Instant::now();
2090        ok_client.generate("fine").await.expect("slot was released");
2091        assert!(
2092            started.elapsed() < Duration::from_millis(90),
2093            "second dispatch should not wait: slot was released by the error path"
2094        );
2095    }
2096
2097    /// Client that always fails AFTER being admitted.
2098    struct SlowMockFailing;
2099
2100    #[async_trait]
2101    impl LLMClient for SlowMockFailing {
2102        async fn generate(&self, _prompt: &str) -> Result<String> {
2103            Err(AppError::LLM("upstream rejected".into()))
2104        }
2105
2106        async fn generate_with_system(&self, _system: &str, _prompt: &str) -> Result<String> {
2107            Err(AppError::LLM("upstream rejected".into()))
2108        }
2109
2110        async fn generate_with_history(
2111            &self,
2112            _messages: &[(String, String)],
2113        ) -> Result<LLMResponse> {
2114            Err(AppError::LLM("upstream rejected".into()))
2115        }
2116
2117        async fn generate_with_tools(
2118            &self,
2119            _prompt: &str,
2120            _tools: &[ToolDefinition],
2121        ) -> Result<LLMResponse> {
2122            Err(AppError::LLM("upstream rejected".into()))
2123        }
2124
2125        async fn generate_with_tools_and_history(
2126            &self,
2127            _messages: &[crate::coordinator::ConversationMessage],
2128            _tools: &[ToolDefinition],
2129        ) -> Result<LLMResponse> {
2130            Err(AppError::LLM("upstream rejected".into()))
2131        }
2132
2133        async fn stream(
2134            &self,
2135            _prompt: &str,
2136        ) -> Result<Box<dyn Stream<Item = Result<String>> + Send + Unpin>> {
2137            Err(AppError::Internal("stream failed at setup".into()))
2138        }
2139
2140        async fn stream_with_system(
2141            &self,
2142            _system: &str,
2143            _prompt: &str,
2144        ) -> Result<Box<dyn Stream<Item = Result<String>> + Send + Unpin>> {
2145            Err(AppError::Internal("stream failed at setup".into()))
2146        }
2147
2148        async fn stream_with_history(
2149            &self,
2150            _messages: &[(String, String)],
2151        ) -> Result<Box<dyn Stream<Item = Result<String>> + Send + Unpin>> {
2152            Err(AppError::Internal("stream failed at setup".into()))
2153        }
2154
2155        fn model_name(&self) -> &str {
2156            "failing-mock"
2157        }
2158    }
2159}