Skip to main content

ravenclaws/
llm.rs

1//! Multi-provider LLM client integration
2//!
3//! Supports LiteLLM, OpenAI, OpenRouter, Ollama, and Anthropic with a unified trait-based API.
4//! v0.5: Unified OpenAI-compatible client, retry/fallback, token budgets.
5
6use futures::Stream;
7use reqwest::{Client, Response};
8use serde::{Deserialize, Serialize};
9use std::pin::Pin;
10use std::sync::Arc;
11use std::time::Duration;
12use thiserror::Error;
13use tokio::time::sleep;
14use tracing::instrument;
15
16/// A streaming chunk of an LLM response
17#[derive(Debug, Clone)]
18#[allow(dead_code)]
19pub struct StreamChunk {
20    pub content: String,
21    #[allow(dead_code)]
22    pub finish_reason: Option<String>,
23}
24
25/// Type alias for streaming response
26#[allow(dead_code)]
27pub type StreamResult = Pin<Box<dyn Stream<Item = Result<StreamChunk, LLMError>> + Send>>;
28
29use crate::config::{LLMConfig, LLMProvider};
30
31/// Provider type for OpenAI-compatible APIs (v0.5 unified client)
32///
33/// # Stability
34/// This enum is `#[non_exhaustive]` — new variants may be added in minor releases.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum OpenAICompatibleProvider {
38    LiteLLM,
39    OpenAI,
40    OpenRouter,
41    /// Generic OpenAI-compatible endpoint (vLLM, llama.cpp, LM Studio, TGI, Groq, Together AI, etc.)
42    Generic,
43    /// Azure OpenAI Service — uses `api-key` header and `api-version` query parameter
44    Azure,
45}
46
47impl OpenAICompatibleProvider {
48    /// Get default endpoint for provider
49    pub fn default_endpoint(&self) -> &'static str {
50        match self {
51            OpenAICompatibleProvider::LiteLLM => "http://localhost:4000",
52            OpenAICompatibleProvider::OpenAI => "https://api.openai.com",
53            OpenAICompatibleProvider::OpenRouter => "https://openrouter.ai",
54            OpenAICompatibleProvider::Generic => "http://localhost:8000",
55            OpenAICompatibleProvider::Azure => "https://YOUR_RESOURCE.openai.azure.com",
56        }
57    }
58
59    /// Get provider name string
60    pub fn name(&self) -> &'static str {
61        match self {
62            OpenAICompatibleProvider::LiteLLM => "litellm",
63            OpenAICompatibleProvider::OpenAI => "openai",
64            OpenAICompatibleProvider::OpenRouter => "openrouter",
65            OpenAICompatibleProvider::Generic => "openai-compatible",
66            OpenAICompatibleProvider::Azure => "azure",
67        }
68    }
69
70    /// Check if provider requires special headers
71    #[allow(dead_code)]
72    pub fn requires_custom_headers(&self) -> bool {
73        matches!(
74            self,
75            OpenAICompatibleProvider::OpenRouter | OpenAICompatibleProvider::Azure
76        )
77    }
78}
79
80/// LLM provider error type.
81///
82/// # Stability
83/// This enum is `#[non_exhaustive]` — new variants may be added in minor releases.
84/// Match with a wildcard arm to handle future variants.
85#[derive(Error, Debug)]
86#[non_exhaustive]
87pub enum LLMError {
88    #[error("Request failed: {0}")]
89    RequestFailed(String),
90
91    #[error("Invalid response: {0}")]
92    InvalidResponse(String),
93
94    #[error("Rate limit exceeded")]
95    RateLimited,
96
97    #[error("Authentication failed")]
98    AuthFailed,
99
100    #[error("Provider not supported: {0}")]
101    #[allow(dead_code)]
102    ProviderNotSupported(String),
103
104    #[error("Token budget exceeded")]
105    TokenBudgetExceeded,
106
107    #[error("All providers failed after retries")]
108    AllProvidersFailed,
109
110    #[error("Circuit breaker open for provider: {0}")]
111    CircuitBreakerOpen(String),
112}
113
114impl LLMError {
115    /// Returns `true` if this error is transient and can be retried.
116    #[allow(dead_code)]
117    ///
118    /// Transient errors are those that may succeed on retry:
119    /// - `RequestFailed` — network issues, server errors
120    /// - `RateLimited` — back off and retry
121    /// - `CircuitBreakerOpen` — may close after recovery period
122    ///
123    /// Non-transient errors should NOT be retried:
124    /// - `AuthFailed` — credentials are wrong
125    /// - `TokenBudgetExceeded` — budget is exhausted
126    /// - `InvalidResponse` — response format is wrong
127    /// - `ProviderNotSupported` — wrong provider
128    /// - `AllProvidersFailed` — all providers have been tried
129    pub fn is_transient(&self) -> bool {
130        matches!(
131            self,
132            LLMError::RequestFailed(_) | LLMError::RateLimited | LLMError::CircuitBreakerOpen(_)
133        )
134    }
135}
136
137/// Retry configuration for LLM requests (v0.5)
138#[derive(Debug, Clone)]
139pub struct RetryConfig {
140    /// Maximum number of retries (default: 3)
141    pub max_retries: u32,
142    /// Base delay for exponential backoff in ms (default: 100)
143    pub base_delay_ms: u64,
144    /// Maximum delay in ms (default: 10000)
145    pub max_delay_ms: u64,
146    /// Jitter factor (0.0-1.0, default: 0.5)
147    pub jitter: f64,
148}
149
150impl Default for RetryConfig {
151    fn default() -> Self {
152        Self {
153            max_retries: 3,
154            base_delay_ms: 100,
155            max_delay_ms: 10000,
156            jitter: 0.5,
157        }
158    }
159}
160
161impl RetryConfig {
162    /// Calculate delay with exponential backoff and jitter
163    pub fn delay_for_attempt(&self, attempt: u32) -> Duration {
164        use rand::Rng;
165        let exp = 2u64.pow(attempt);
166        let base = self.base_delay_ms * exp;
167        let capped = base.min(self.max_delay_ms);
168        let jitter_range = (capped as f64) * self.jitter;
169        let jitter = rand::thread_rng().gen_range(-jitter_range..=jitter_range) as u64;
170        let delay = capped.saturating_add(jitter).max(self.base_delay_ms);
171        Duration::from_millis(delay)
172    }
173}
174
175/// Circuit breaker state (v0.5)
176///
177/// # Stability
178/// This enum is `#[non_exhaustive]` — new variants may be added in minor releases.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180#[non_exhaustive]
181pub enum CircuitState {
182    Closed,
183    Open,
184    HalfOpen,
185}
186
187/// Circuit breaker for provider resilience (v0.5)
188#[derive(Debug)]
189pub struct CircuitBreaker {
190    pub state: CircuitState,
191    pub failure_count: u32,
192    pub last_failure_time: Option<std::time::Instant>,
193    pub open_duration: Duration,
194}
195
196impl CircuitBreaker {
197    pub fn new(open_duration_secs: u64) -> Self {
198        Self {
199            state: CircuitState::Closed,
200            failure_count: 0,
201            last_failure_time: None,
202            open_duration: Duration::from_secs(open_duration_secs),
203        }
204    }
205
206    pub fn record_success(&mut self) {
207        self.failure_count = 0;
208        self.state = CircuitState::Closed;
209    }
210
211    pub fn record_failure(&mut self) {
212        self.failure_count += 1;
213        self.last_failure_time = Some(std::time::Instant::now());
214        if self.failure_count >= 5 {
215            self.state = CircuitState::Open;
216        }
217    }
218
219    pub fn can_execute(&mut self) -> bool {
220        match self.state {
221            CircuitState::Closed => true,
222            CircuitState::Open => {
223                if let Some(last) = self.last_failure_time {
224                    if last.elapsed() >= self.open_duration {
225                        self.state = CircuitState::HalfOpen;
226                        return true;
227                    }
228                }
229                false
230            }
231            CircuitState::HalfOpen => true,
232        }
233    }
234}
235
236/// Token budget tracker (v0.5)
237#[derive(Debug, Clone)]
238pub struct TokenBudget {
239    /// Maximum tokens allowed
240    pub max_tokens: u32,
241    /// Tokens used so far
242    pub used_tokens: u32,
243    /// Cost per 1K tokens (USD)
244    pub cost_per_1k: f64,
245}
246
247#[allow(dead_code)]
248impl TokenBudget {
249    pub fn new(max_tokens: u32, cost_per_1k: f64) -> Self {
250        Self {
251            max_tokens,
252            used_tokens: 0,
253            cost_per_1k,
254        }
255    }
256
257    pub fn remaining(&self) -> u32 {
258        self.max_tokens.saturating_sub(self.used_tokens)
259    }
260
261    pub fn can_spend(&self, tokens: u32) -> bool {
262        self.remaining() >= tokens
263    }
264
265    pub fn record_usage(&mut self, tokens: u32) {
266        self.used_tokens = self.used_tokens.saturating_add(tokens);
267    }
268
269    pub fn estimated_cost(&self) -> f64 {
270        (self.used_tokens as f64 / 1000.0) * self.cost_per_1k
271    }
272}
273
274/// A content part for multi-modal messages — text or image.
275///
276/// # Stability
277/// This enum is `#[non_exhaustive]` — new variants may be added in minor releases.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279#[serde(untagged)]
280#[non_exhaustive]
281pub enum ContentPart {
282    /// Plain text content
283    Text { text: String },
284    /// Image content as a data URI (base64-encoded)
285    /// Format: `data:{mime_type};base64,{data}`
286    ImageUrl {
287        #[serde(rename = "image_url")]
288        image_url: ImageUrlContent,
289    },
290}
291
292/// URL/content for an image in a multi-modal message
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct ImageUrlContent {
295    pub url: String,
296}
297
298impl ContentPart {
299    /// Create a text content part
300    pub fn text(text: impl Into<String>) -> Self {
301        Self::Text { text: text.into() }
302    }
303
304    /// Create an image content part from a data URI
305    pub fn image(data_uri: impl Into<String>) -> Self {
306        Self::ImageUrl {
307            image_url: ImageUrlContent {
308                url: data_uri.into(),
309            },
310        }
311    }
312
313    /// Serialize this content part as a `serde_json::Value` for OpenAI-compatible APIs.
314    pub fn to_openai_value(&self) -> serde_json::Value {
315        match self {
316            ContentPart::Text { text } => serde_json::json!({
317                "type": "text",
318                "text": text
319            }),
320            ContentPart::ImageUrl { image_url } => serde_json::json!({
321                "type": "image_url",
322                "image_url": {
323                    "url": image_url.url
324                }
325            }),
326        }
327    }
328
329    /// Serialize this content part as a `serde_json::Value` for Anthropic APIs.
330    pub fn to_anthropic_value(&self) -> serde_json::Value {
331        match self {
332            ContentPart::Text { text } => serde_json::json!({
333                "type": "text",
334                "text": text
335            }),
336            ContentPart::ImageUrl { image_url } => {
337                // Parse the data URI to extract media_type and base64 data
338                let url = &image_url.url;
339                if let Some(rest) = url.strip_prefix("data:") {
340                    if let Some((media_type, data)) = rest.split_once(";base64,") {
341                        return serde_json::json!({
342                            "type": "image",
343                            "source": {
344                                "type": "base64",
345                                "media_type": media_type,
346                                "data": data
347                            }
348                        });
349                    }
350                }
351                // Fallback: send as text if we can't parse the data URI
352                serde_json::json!({
353                    "type": "text",
354                    "text": format!("[Image: {}]", url.chars().take(100).collect::<String>())
355                })
356            }
357        }
358    }
359}
360
361/// Load an image file and return a data URI string.
362///
363/// Reads the file, detects the MIME type from the extension, base64-encodes
364/// the contents, and returns a data URI in the format `data:{mime};base64,{data}`.
365///
366/// Supported formats: PNG, JPEG, GIF, WebP.
367pub fn load_image(path: &std::path::Path) -> Result<String, crate::error::RavenClawsError> {
368    let data = std::fs::read(path).map_err(crate::error::RavenClawsError::IO)?;
369
370    let mime = match path
371        .extension()
372        .and_then(|e| e.to_str())
373        .map(|e| e.to_lowercase())
374        .as_deref()
375    {
376        Some("png") => "image/png",
377        Some("jpg") | Some("jpeg") => "image/jpeg",
378        Some("gif") => "image/gif",
379        Some("webp") => "image/webp",
380        _ => {
381            return Err(crate::error::RavenClawsError::CommandExecution(format!(
382                "Unsupported image format: '{}'. Supported: png, jpg, jpeg, gif, webp",
383                path.display()
384            )));
385        }
386    };
387
388    let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);
389    Ok(format!("data:{};base64,{}", mime, encoded))
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct ChatMessage {
394    pub role: String,
395    pub content: String,
396    /// Optional structured content parts for multi-modal messages.
397    /// When set, `content` is used as a fallback text representation.
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub content_parts: Option<Vec<ContentPart>>,
400}
401
402impl ChatMessage {
403    /// Create a new text-only chat message.
404    pub fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
405        Self {
406            role: role.into(),
407            content: content.into(),
408            content_parts: None,
409        }
410    }
411
412    /// Create a new multi-modal chat message with text and optional image attachments.
413    pub fn with_images(
414        role: impl Into<String>,
415        text: impl Into<String>,
416        image_data_uris: Vec<String>,
417    ) -> Self {
418        let text = text.into();
419        let mut parts = Vec::with_capacity(1 + image_data_uris.len());
420        parts.push(ContentPart::text(&text));
421        for uri in image_data_uris {
422            parts.push(ContentPart::image(uri));
423        }
424        Self {
425            role: role.into(),
426            content: text.clone(),
427            content_parts: Some(parts),
428        }
429    }
430
431    /// Serialize this message as a `serde_json::Value` for OpenAI-compatible APIs.
432    /// When `content_parts` is `Some`, produces the multi-modal content array format.
433    pub fn to_openai_message(&self) -> serde_json::Value {
434        match &self.content_parts {
435            Some(parts) => {
436                let content_array: Vec<serde_json::Value> =
437                    parts.iter().map(|p| p.to_openai_value()).collect();
438                serde_json::json!({
439                    "role": self.role,
440                    "content": content_array
441                })
442            }
443            None => {
444                serde_json::json!({
445                    "role": self.role,
446                    "content": self.content
447                })
448            }
449        }
450    }
451
452    /// Serialize this message as a `serde_json::Value` for Anthropic APIs.
453    pub fn to_anthropic_message(&self) -> serde_json::Value {
454        match &self.content_parts {
455            Some(parts) => {
456                let content_array: Vec<serde_json::Value> =
457                    parts.iter().map(|p| p.to_anthropic_value()).collect();
458                serde_json::json!({
459                    "role": self.role,
460                    "content": content_array
461                })
462            }
463            None => {
464                serde_json::json!({
465                    "role": self.role,
466                    "content": self.content
467                })
468            }
469        }
470    }
471
472    /// Get the base64 image data for Ollama's `images` array format.
473    /// Returns `None` if there are no image content parts.
474    pub fn ollama_images(&self) -> Option<Vec<String>> {
475        let parts = self.content_parts.as_ref()?;
476        let images: Vec<String> = parts
477            .iter()
478            .filter_map(|p| match p {
479                ContentPart::ImageUrl { image_url } => {
480                    let url = &image_url.url;
481                    url.strip_prefix("data:")
482                        .and_then(|rest| rest.split_once(";base64,").map(|x| x.1))
483                        .map(|s| s.to_string())
484                }
485                _ => None,
486            })
487            .collect();
488        if images.is_empty() {
489            None
490        } else {
491            Some(images)
492        }
493    }
494}
495
496#[derive(Debug, Clone, Serialize)]
497pub struct ChatRequest {
498    pub model: String,
499    #[serde(serialize_with = "serialize_messages_openai")]
500    pub messages: Vec<ChatMessage>,
501    #[serde(skip_serializing_if = "Option::is_none")]
502    pub temperature: Option<f32>,
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub max_tokens: Option<u32>,
505    #[serde(skip_serializing_if = "Option::is_none")]
506    pub stream: Option<bool>,
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub tools: Option<Vec<serde_json::Value>>,
509    #[serde(skip_serializing_if = "Option::is_none")]
510    pub tool_choice: Option<String>,
511}
512
513/// Serialize messages for OpenAI-compatible APIs, handling multi-modal content.
514fn serialize_messages_openai<S>(messages: &[ChatMessage], serializer: S) -> Result<S::Ok, S::Error>
515where
516    S: serde::Serializer,
517{
518    use serde::ser::SerializeSeq;
519    let mut seq = serializer.serialize_seq(Some(messages.len()))?;
520    for msg in messages {
521        let value = msg.to_openai_message();
522        seq.serialize_element(&value)?;
523    }
524    seq.end()
525}
526
527#[derive(Debug, Clone, Deserialize)]
528pub struct ChatResponse {
529    #[allow(dead_code)]
530    pub id: String,
531    #[allow(dead_code)]
532    pub object: String,
533    #[allow(dead_code)]
534    pub created: u64,
535    #[allow(dead_code)]
536    pub model: String,
537    pub choices: Vec<Choice>,
538    #[allow(dead_code)]
539    pub usage: Option<Usage>,
540}
541
542#[derive(Debug, Clone, Deserialize)]
543pub struct ToolCallResponse {
544    #[allow(dead_code)]
545    pub id: String,
546    #[allow(dead_code)]
547    #[serde(rename = "type")]
548    pub call_type: String,
549    pub function: FunctionCall,
550}
551
552#[derive(Debug, Clone, Deserialize)]
553pub struct FunctionCall {
554    pub name: String,
555    pub arguments: String,
556}
557
558#[derive(Debug, Clone, Deserialize)]
559pub struct Choice {
560    #[allow(dead_code)]
561    pub index: u32,
562    pub message: ChatMessage,
563    #[allow(dead_code)]
564    pub finish_reason: Option<String>,
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub tool_calls: Option<Vec<ToolCallResponse>>,
567}
568
569#[derive(Debug, Clone, Deserialize)]
570pub struct Usage {
571    #[allow(dead_code)]
572    pub prompt_tokens: u32,
573    #[allow(dead_code)]
574    pub completion_tokens: u32,
575    #[allow(dead_code)]
576    pub total_tokens: u32,
577}
578
579/// Trait for LLM providers — unified interface across all backends
580#[async_trait::async_trait]
581pub trait LLMProviderTrait: Send + Sync {
582    async fn chat(&self, messages: Vec<ChatMessage>) -> Result<ChatResponse, LLMError>;
583    #[allow(dead_code)]
584    async fn chat_stream(&self, messages: Vec<ChatMessage>) -> Result<StreamResult, LLMError> {
585        // Default: non-streaming fallback
586        let response = self.chat(messages).await?;
587        let content = response
588            .choices
589            .first()
590            .map(|c| c.message.content.clone())
591            .unwrap_or_default();
592        let finish_reason = response
593            .choices
594            .first()
595            .and_then(|c| c.finish_reason.clone());
596
597        let stream = futures::stream::once(async move {
598            Ok(StreamChunk {
599                content,
600                finish_reason,
601            })
602        });
603        Ok(Box::pin(stream))
604    }
605    fn provider_name(&self) -> &str;
606    fn model(&self) -> &str;
607}
608
609/// Shared response handler for OpenAI-compatible providers
610async fn handle_openai_response(response: Response) -> Result<ChatResponse, LLMError> {
611    let status = response.status();
612
613    if status.is_success() {
614        response
615            .json::<ChatResponse>()
616            .await
617            .map_err(|e| LLMError::InvalidResponse(e.to_string()))
618    } else if status == reqwest::StatusCode::UNAUTHORIZED {
619        Err(LLMError::AuthFailed)
620    } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
621        Err(LLMError::RateLimited)
622    } else {
623        let body = response
624            .text()
625            .await
626            .unwrap_or_else(|_| "Unknown error".to_string());
627        Err(LLMError::RequestFailed(format!("{}: {}", status, body)))
628    }
629}
630
631/// Unified OpenAI-compatible client (v0.5)
632/// Replaces separate LiteLLM, OpenAI, and OpenRouter clients
633pub struct OpenAICompatibleClient {
634    client: Client,
635    config: LLMConfig,
636    provider: OpenAICompatibleProvider,
637    retry_config: RetryConfig,
638    circuit_breaker: std::sync::Mutex<CircuitBreaker>,
639}
640
641impl OpenAICompatibleClient {
642    pub fn new(config: &LLMConfig, provider: OpenAICompatibleProvider) -> Result<Self, LLMError> {
643        let client = Client::builder()
644            .timeout(std::time::Duration::from_secs(config.timeout_secs))
645            .build()
646            .map_err(|e| LLMError::RequestFailed(format!("Failed to create HTTP client: {}", e)))?;
647
648        let retry_config = RetryConfig {
649            max_retries: config.retry_max,
650            base_delay_ms: config.retry_base_delay_ms,
651            max_delay_ms: config.retry_max_delay_ms,
652            jitter: 0.5,
653        };
654
655        Ok(Self {
656            client,
657            config: config.clone(),
658            provider,
659            retry_config,
660            circuit_breaker: std::sync::Mutex::new(CircuitBreaker::new(30)),
661        })
662    }
663
664    /// Send request with retry logic (v0.5)
665    async fn send_request_with_retry(
666        &self,
667        request: ChatRequest,
668    ) -> Result<ChatResponse, LLMError> {
669        let mut last_error = None;
670
671        for attempt in 0..=self.retry_config.max_retries {
672            // Check circuit breaker
673            {
674                let mut cb = self.circuit_breaker.lock().map_err(|_| {
675                    LLMError::RequestFailed("Circuit breaker lock poisoned".to_string())
676                })?;
677                if !cb.can_execute() {
678                    return Err(LLMError::CircuitBreakerOpen(
679                        self.provider.name().to_string(),
680                    ));
681                }
682            }
683
684            let result = self.send_request_inner(request.clone()).await;
685
686            match result {
687                Ok(response) => {
688                    // Record success in circuit breaker
689                    {
690                        let mut cb = self.circuit_breaker.lock().map_err(|_| {
691                            LLMError::RequestFailed("Circuit breaker lock poisoned".to_string())
692                        })?;
693                        cb.record_success();
694                    }
695                    return Ok(response);
696                }
697                Err(e) => {
698                    // Record failure
699                    {
700                        let mut cb = self.circuit_breaker.lock().map_err(|_| {
701                            LLMError::RequestFailed("Circuit breaker lock poisoned".to_string())
702                        })?;
703                        cb.record_failure();
704                    }
705
706                    last_error = Some(e);
707
708                    // Don't retry on auth failures
709                    if matches!(last_error, Some(LLMError::AuthFailed)) {
710                        return Err(last_error.unwrap());
711                    }
712
713                    // Wait before retry (if not last attempt)
714                    if attempt < self.retry_config.max_retries {
715                        let delay = self.retry_config.delay_for_attempt(attempt);
716                        sleep(delay).await;
717                    }
718                }
719            }
720        }
721
722        Err(last_error.unwrap_or(LLMError::AllProvidersFailed))
723    }
724
725    /// Inner send request (no retry)
726    async fn send_request_inner(&self, request: ChatRequest) -> Result<ChatResponse, LLMError> {
727        let req = self.apply_headers(self.client.post(self.endpoint()).json(&request));
728
729        let response = req
730            .send()
731            .await
732            .map_err(|e| LLMError::RequestFailed(e.to_string()))?;
733
734        handle_openai_response(response).await
735    }
736
737    fn build_request(&self, messages: Vec<ChatMessage>) -> ChatRequest {
738        ChatRequest {
739            model: self.config.model.clone(),
740            messages,
741            temperature: Some(0.7),
742            max_tokens: Some(2048),
743            stream: None,
744            tools: None,
745            tool_choice: None,
746        }
747    }
748
749    fn endpoint(&self) -> String {
750        let base = if self.config.endpoint.is_empty() {
751            self.provider.default_endpoint()
752        } else {
753            &self.config.endpoint
754        };
755        let mut url = format!("{}/v1/chat/completions", base.trim_end_matches('/'));
756        if self.provider == OpenAICompatibleProvider::Azure {
757            // Azure OpenAI requires api-version query parameter
758            // Default to 2024-02-15-preview if not specified; users can override via endpoint
759            if !url.contains("api-version") {
760                url = format!("{}?api-version=2024-02-15-preview", url);
761            }
762        }
763        url
764    }
765
766    fn apply_headers(&self, mut req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
767        if let Some(ref key) = self.config.api_key {
768            if self.provider == OpenAICompatibleProvider::Azure {
769                // Azure OpenAI uses api-key header (not Bearer)
770                req = req.header("api-key", key);
771            } else {
772                req = req.header("Authorization", format!("Bearer {}", key));
773            }
774        }
775
776        // Provider-specific headers
777        if self.provider == OpenAICompatibleProvider::OpenRouter {
778            req = req
779                .header("HTTP-Referer", "https://github.com/egkristi/RavenClaws")
780                .header("X-Title", "RavenClaws");
781        }
782
783        req
784    }
785
786    #[allow(dead_code)]
787    async fn send_request(&self, request: ChatRequest) -> Result<ChatResponse, LLMError> {
788        let req = self.apply_headers(self.client.post(self.endpoint()).json(&request));
789
790        let response = req
791            .send()
792            .await
793            .map_err(|e| LLMError::RequestFailed(e.to_string()))?;
794
795        handle_openai_response(response).await
796    }
797}
798
799#[async_trait::async_trait]
800impl LLMProviderTrait for OpenAICompatibleClient {
801    #[instrument(skip(self, messages), fields(provider = self.provider_name(), model = self.model()))]
802    async fn chat(&self, messages: Vec<ChatMessage>) -> Result<ChatResponse, LLMError> {
803        let request = self.build_request(messages);
804        self.send_request_with_retry(request).await
805    }
806
807    async fn chat_stream(&self, messages: Vec<ChatMessage>) -> Result<StreamResult, LLMError> {
808        let request = ChatRequest {
809            model: self.config.model.clone(),
810            messages,
811            temperature: Some(0.7),
812            max_tokens: Some(2048),
813            stream: Some(true),
814            tools: None,
815            tool_choice: None,
816        };
817
818        let req = self.apply_headers(self.client.post(self.endpoint()).json(&request));
819
820        let response = req
821            .send()
822            .await
823            .map_err(|e| LLMError::RequestFailed(e.to_string()))?;
824
825        let status = response.status();
826        if !status.is_success() {
827            if status == reqwest::StatusCode::UNAUTHORIZED {
828                return Err(LLMError::AuthFailed);
829            } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
830                return Err(LLMError::RateLimited);
831            } else {
832                let body = response
833                    .text()
834                    .await
835                    .unwrap_or_else(|_| "Unknown error".to_string());
836                return Err(LLMError::RequestFailed(format!("{}: {}", status, body)));
837            }
838        }
839
840        // Parse SSE stream — map byte chunks to StreamChunks, filtering empty ones
841        use futures::StreamExt;
842        let stream = response
843            .bytes_stream()
844            .filter_map(|chunk_result| async move {
845                match chunk_result {
846                    Err(e) => Some(Err(LLMError::RequestFailed(e.to_string()))),
847                    Ok(bytes) => {
848                        let text = String::from_utf8_lossy(&bytes);
849                        let mut content = String::new();
850                        let mut finish_reason = None;
851
852                        for line in text.lines() {
853                            if let Some(data) = line.strip_prefix("data: ") {
854                                if data == "[DONE]" {
855                                    finish_reason = Some("stop".to_string());
856                                    continue;
857                                }
858                                if let Ok(sse_chunk) =
859                                    serde_json::from_str::<serde_json::Value>(data)
860                                {
861                                    if let Some(choice) =
862                                        sse_chunk["choices"].as_array().and_then(|c| c.first())
863                                    {
864                                        if let Some(delta) = choice["delta"].as_object() {
865                                            if let Some(c) = delta["content"].as_str() {
866                                                content.push_str(c);
867                                            }
868                                        }
869                                        if let Some(reason) = choice["finish_reason"].as_str() {
870                                            if reason != "null" {
871                                                finish_reason = Some(reason.to_string());
872                                            }
873                                        }
874                                    }
875                                }
876                            }
877                        }
878
879                        if content.is_empty() && finish_reason.is_none() {
880                            None
881                        } else {
882                            Some(Ok(StreamChunk {
883                                content,
884                                finish_reason,
885                            }))
886                        }
887                    }
888                }
889            });
890
891        Ok(Box::pin(stream))
892    }
893
894    fn provider_name(&self) -> &str {
895        self.provider.name()
896    }
897
898    fn model(&self) -> &str {
899        &self.config.model
900    }
901}
902
903/// Ollama client (local/self-hosted models)
904pub struct OllamaClient {
905    client: Client,
906    config: LLMConfig,
907}
908
909impl OllamaClient {
910    pub fn new(config: &LLMConfig) -> Result<Self, LLMError> {
911        let client = Client::builder()
912            .timeout(std::time::Duration::from_secs(config.timeout_secs))
913            .build()
914            .map_err(|e| LLMError::RequestFailed(format!("Failed to create HTTP client: {}", e)))?;
915
916        Ok(Self {
917            client,
918            config: config.clone(),
919        })
920    }
921}
922
923#[async_trait::async_trait]
924impl LLMProviderTrait for OllamaClient {
925    #[instrument(skip(self, messages), fields(provider = self.provider_name(), model = self.model()))]
926    async fn chat(&self, messages: Vec<ChatMessage>) -> Result<ChatResponse, LLMError> {
927        // Ollama uses slightly different format
928        // For multi-modal, we need to convert messages to serde_json::Value
929        // to handle the `images` array on user messages
930        #[derive(Serialize)]
931        struct OllamaRequest {
932            model: String,
933            #[serde(serialize_with = "serialize_messages_ollama")]
934            messages: Vec<ChatMessage>,
935            stream: bool,
936        }
937
938        /// Serialize messages for Ollama API, handling multi-modal content.
939        fn serialize_messages_ollama<S>(
940            messages: &[ChatMessage],
941            serializer: S,
942        ) -> Result<S::Ok, S::Error>
943        where
944            S: serde::Serializer,
945        {
946            use serde::ser::SerializeSeq;
947            let mut seq = serializer.serialize_seq(Some(messages.len()))?;
948            for msg in messages {
949                let value = if let Some(images) = msg.ollama_images() {
950                    serde_json::json!({
951                        "role": msg.role,
952                        "content": msg.content,
953                        "images": images
954                    })
955                } else {
956                    serde_json::json!({
957                        "role": msg.role,
958                        "content": msg.content
959                    })
960                };
961                seq.serialize_element(&value)?;
962            }
963            seq.end()
964        }
965
966        let request = OllamaRequest {
967            model: self.config.model.clone(),
968            messages,
969            stream: false,
970        };
971
972        let response = self
973            .client
974            .post(format!(
975                "{}/api/chat",
976                self.config.endpoint.trim_end_matches('/')
977            ))
978            .json(&request)
979            .send()
980            .await
981            .map_err(|e| LLMError::RequestFailed(e.to_string()))?;
982
983        let status = response.status();
984
985        if status.is_success() {
986            // Ollama returns a different structure, convert to our standard format
987            #[derive(Deserialize)]
988            struct OllamaResponse {
989                model: String,
990                message: ChatMessage,
991                done: bool,
992            }
993
994            let ollama_resp = response
995                .json::<OllamaResponse>()
996                .await
997                .map_err(|e| LLMError::InvalidResponse(e.to_string()))?;
998
999            Ok(ChatResponse {
1000                id: format!("ollama-{}", uuid::Uuid::new_v4()),
1001                object: "chat.completion".to_string(),
1002                created: std::time::SystemTime::now()
1003                    .duration_since(std::time::UNIX_EPOCH)
1004                    .unwrap()
1005                    .as_secs(),
1006                model: ollama_resp.model,
1007                choices: vec![Choice {
1008                    index: 0,
1009                    message: ollama_resp.message,
1010                    finish_reason: if ollama_resp.done {
1011                        Some("stop".to_string())
1012                    } else {
1013                        None
1014                    },
1015                    tool_calls: None,
1016                }],
1017                usage: None, // Ollama doesn't always provide usage
1018            })
1019        } else if status == reqwest::StatusCode::UNAUTHORIZED {
1020            Err(LLMError::AuthFailed)
1021        } else {
1022            let body = response
1023                .text()
1024                .await
1025                .unwrap_or_else(|_| "Unknown error".to_string());
1026            Err(LLMError::RequestFailed(format!("{}: {}", status, body)))
1027        }
1028    }
1029
1030    fn provider_name(&self) -> &str {
1031        "ollama"
1032    }
1033
1034    fn model(&self) -> &str {
1035        &self.config.model
1036    }
1037}
1038
1039/// Anthropic native client (v0.6) — direct API with tool use and image support
1040pub struct AnthropicClient {
1041    client: Client,
1042    config: LLMConfig,
1043}
1044
1045impl AnthropicClient {
1046    pub fn new(config: &LLMConfig) -> Result<Self, LLMError> {
1047        let client = Client::builder()
1048            .timeout(std::time::Duration::from_secs(config.timeout_secs))
1049            .build()
1050            .map_err(|e| LLMError::RequestFailed(format!("Failed to create HTTP client: {}", e)))?;
1051
1052        Ok(Self {
1053            client,
1054            config: config.clone(),
1055        })
1056    }
1057}
1058
1059#[async_trait::async_trait]
1060impl LLMProviderTrait for AnthropicClient {
1061    #[instrument(skip(self, messages), fields(provider = self.provider_name(), model = self.model()))]
1062    async fn chat(&self, messages: Vec<ChatMessage>) -> Result<ChatResponse, LLMError> {
1063        // Anthropic uses a different request/response format
1064        #[derive(Serialize)]
1065        struct AnthropicRequest {
1066            model: String,
1067            max_tokens: u32,
1068            #[serde(serialize_with = "serialize_anthropic_messages")]
1069            messages: Vec<ChatMessage>,
1070            #[serde(skip_serializing_if = "Option::is_none")]
1071            system: Option<String>,
1072            #[serde(skip_serializing_if = "Option::is_none")]
1073            temperature: Option<f32>,
1074        }
1075
1076        /// Serialize messages for Anthropic API, handling multi-modal content.
1077        fn serialize_anthropic_messages<S>(
1078            messages: &[ChatMessage],
1079            serializer: S,
1080        ) -> Result<S::Ok, S::Error>
1081        where
1082            S: serde::Serializer,
1083        {
1084            use serde::ser::SerializeSeq;
1085            let mut seq = serializer.serialize_seq(Some(messages.len()))?;
1086            for msg in messages {
1087                let value = msg.to_anthropic_message();
1088                seq.serialize_element(&value)?;
1089            }
1090            seq.end()
1091        }
1092
1093        // Extract system prompt if present
1094        let system = messages
1095            .iter()
1096            .find(|m| m.role == "system")
1097            .map(|m| m.content.clone());
1098
1099        let anthropic_messages: Vec<ChatMessage> = messages
1100            .into_iter()
1101            .filter(|m| m.role != "system")
1102            .collect();
1103
1104        let request = AnthropicRequest {
1105            model: self.config.model.clone(),
1106            max_tokens: 2048,
1107            messages: anthropic_messages,
1108            system,
1109            temperature: Some(0.7),
1110        };
1111
1112        let api_key = self
1113            .config
1114            .api_key
1115            .clone()
1116            .ok_or_else(|| LLMError::AuthFailed)?;
1117
1118        let response = self
1119            .client
1120            .post("https://api.anthropic.com/v1/messages")
1121            .header("x-api-key", api_key)
1122            .header("anthropic-version", "2023-06-01")
1123            .header("content-type", "application/json")
1124            .json(&request)
1125            .send()
1126            .await
1127            .map_err(|e| LLMError::RequestFailed(e.to_string()))?;
1128
1129        let status = response.status();
1130
1131        if status.is_success() {
1132            // Anthropic response format
1133            #[derive(Deserialize)]
1134            #[allow(dead_code)]
1135            struct AnthropicResponse {
1136                id: String,
1137                #[serde(rename = "type")]
1138                response_type: String,
1139                role: String,
1140                content: Vec<AnthropicContentBlock>,
1141                model: String,
1142                stop_reason: Option<String>,
1143                #[serde(default)]
1144                usage: Option<AnthropicUsage>,
1145            }
1146
1147            #[derive(Deserialize)]
1148            #[serde(tag = "type", rename_all = "lowercase")]
1149            enum AnthropicContentBlock {
1150                Text {
1151                    text: String,
1152                },
1153                ToolUse {
1154                    id: String,
1155                    name: String,
1156                    input: serde_json::Value,
1157                },
1158            }
1159
1160            #[derive(Deserialize)]
1161            struct AnthropicUsage {
1162                input_tokens: u32,
1163                output_tokens: u32,
1164            }
1165
1166            let anthropic_resp = response
1167                .json::<AnthropicResponse>()
1168                .await
1169                .map_err(|e| LLMError::InvalidResponse(e.to_string()))?;
1170
1171            // Convert Anthropic content to our format
1172            let mut content = String::new();
1173            let mut tool_calls = None;
1174
1175            for block in anthropic_resp.content {
1176                match block {
1177                    AnthropicContentBlock::Text { text } => {
1178                        content.push_str(&text);
1179                    }
1180                    AnthropicContentBlock::ToolUse { id, name, input } => {
1181                        if tool_calls.is_none() {
1182                            tool_calls = Some(Vec::new());
1183                        }
1184                        if let Some(ref mut calls) = tool_calls {
1185                            calls.push(ToolCallResponse {
1186                                id,
1187                                call_type: "function".to_string(),
1188                                function: FunctionCall {
1189                                    name,
1190                                    arguments: input.to_string(),
1191                                },
1192                            });
1193                        }
1194                    }
1195                }
1196            }
1197
1198            Ok(ChatResponse {
1199                id: anthropic_resp.id,
1200                object: "chat.completion".to_string(),
1201                created: std::time::SystemTime::now()
1202                    .duration_since(std::time::UNIX_EPOCH)
1203                    .unwrap()
1204                    .as_secs(),
1205                model: anthropic_resp.model,
1206                choices: vec![Choice {
1207                    index: 0,
1208                    message: ChatMessage {
1209                        role: "assistant".to_string(),
1210                        content,
1211                        content_parts: None,
1212                    },
1213                    finish_reason: anthropic_resp.stop_reason,
1214                    tool_calls,
1215                }],
1216                usage: anthropic_resp.usage.map(|u| Usage {
1217                    prompt_tokens: u.input_tokens,
1218                    completion_tokens: u.output_tokens,
1219                    total_tokens: u.input_tokens + u.output_tokens,
1220                }),
1221            })
1222        } else if status == reqwest::StatusCode::UNAUTHORIZED {
1223            Err(LLMError::AuthFailed)
1224        } else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1225            Err(LLMError::RateLimited)
1226        } else {
1227            let body = response
1228                .text()
1229                .await
1230                .unwrap_or_else(|_| "Unknown error".to_string());
1231            Err(LLMError::RequestFailed(format!("{}: {}", status, body)))
1232        }
1233    }
1234
1235    fn provider_name(&self) -> &str {
1236        "anthropic"
1237    }
1238
1239    fn model(&self) -> &str {
1240        &self.config.model
1241    }
1242}
1243
1244/// Factory function to create the appropriate client based on provider type (v0.5 unified)
1245pub fn create_client(config: &LLMConfig) -> Result<Arc<dyn LLMProviderTrait>, LLMError> {
1246    match config.provider {
1247        LLMProvider::LiteLLM => {
1248            let unified = OpenAICompatibleClient::new(config, OpenAICompatibleProvider::LiteLLM)?;
1249            Ok(Arc::new(unified))
1250        }
1251        LLMProvider::OpenRouter => {
1252            let unified =
1253                OpenAICompatibleClient::new(config, OpenAICompatibleProvider::OpenRouter)?;
1254            Ok(Arc::new(unified))
1255        }
1256        LLMProvider::Ollama => Ok(Arc::new(OllamaClient::new(config)?)),
1257        LLMProvider::OpenAI => {
1258            let unified = OpenAICompatibleClient::new(config, OpenAICompatibleProvider::OpenAI)?;
1259            Ok(Arc::new(unified))
1260        }
1261        LLMProvider::Anthropic => Ok(Arc::new(AnthropicClient::new(config)?)),
1262        LLMProvider::OpenAICompatible => {
1263            let unified = OpenAICompatibleClient::new(config, OpenAICompatibleProvider::Generic)?;
1264            Ok(Arc::new(unified))
1265        }
1266        LLMProvider::Azure => {
1267            let unified = OpenAICompatibleClient::new(config, OpenAICompatibleProvider::Azure)?;
1268            Ok(Arc::new(unified))
1269        }
1270    }
1271}
1272
1273/// Multi-model manager for handling multiple providers simultaneously
1274#[derive(Clone)]
1275pub struct MultiModelManager {
1276    clients: Vec<Arc<dyn LLMProviderTrait>>,
1277}
1278
1279impl MultiModelManager {
1280    pub fn new(configs: Vec<LLMConfig>) -> Result<Self, LLMError> {
1281        let clients: Result<Vec<_>, _> = configs.iter().map(create_client).collect();
1282        Ok(Self { clients: clients? })
1283    }
1284
1285    pub fn get_client(&self, index: usize) -> Option<&Arc<dyn LLMProviderTrait>> {
1286        self.clients.get(index)
1287    }
1288
1289    pub fn client_count(&self) -> usize {
1290        self.clients.len()
1291    }
1292
1293    /// Round-robin selection for load balancing
1294    pub fn next_client(&self, last_index: usize) -> Option<&Arc<dyn LLMProviderTrait>> {
1295        if self.clients.is_empty() {
1296            return None;
1297        }
1298        let next = (last_index + 1) % self.clients.len();
1299        Some(&self.clients[next])
1300    }
1301}
1302
1303/// Provider fallback chain (v0.5) — tries providers in order until one succeeds
1304#[derive(Debug)]
1305pub struct ProviderFallbackChain {
1306    /// Provider configurations in fallback order
1307    pub configs: Vec<LLMConfig>,
1308    token_budget: Option<TokenBudget>,
1309}
1310
1311impl ProviderFallbackChain {
1312    pub fn new(configs: Vec<LLMConfig>) -> Self {
1313        Self {
1314            configs,
1315            token_budget: None,
1316        }
1317    }
1318
1319    pub fn with_token_budget(mut self, budget: TokenBudget) -> Self {
1320        self.token_budget = Some(budget);
1321        self
1322    }
1323
1324    /// Execute with fallback — tries each provider in order until success
1325    #[instrument(skip(self, messages))]
1326    pub async fn chat_with_fallback(
1327        &mut self,
1328        messages: Vec<ChatMessage>,
1329    ) -> Result<ChatResponse, LLMError> {
1330        let mut last_error = None;
1331
1332        for (i, config) in self.configs.iter().enumerate() {
1333            let client = match create_client(config) {
1334                Ok(c) => c,
1335                Err(e) => {
1336                    tracing::warn!(
1337                        "Failed to create client for provider {:?}: {}",
1338                        config.provider,
1339                        e
1340                    );
1341                    last_error = Some(e);
1342                    continue;
1343                }
1344            };
1345
1346            // Check token budget before making request
1347            if let Some(ref budget) = self.token_budget {
1348                // Estimate ~500 tokens for typical request
1349                if !budget.can_spend(500) {
1350                    return Err(LLMError::TokenBudgetExceeded);
1351                }
1352            }
1353
1354            match client.chat(messages.clone()).await {
1355                Ok(response) => {
1356                    // Record token usage if available
1357                    if let Some(ref mut budget) = self.token_budget {
1358                        if let Some(usage) = &response.usage {
1359                            budget.record_usage(usage.total_tokens);
1360                        }
1361                    }
1362                    return Ok(response);
1363                }
1364                Err(e) => {
1365                    tracing::warn!("Provider {} failed: {}", i, e);
1366                    last_error = Some(e);
1367                    // Continue to next provider
1368                }
1369            }
1370        }
1371
1372        Err(last_error.unwrap_or(LLMError::AllProvidersFailed))
1373    }
1374
1375    /// Get provider names in chain
1376    #[allow(dead_code)]
1377    pub fn provider_names(&self) -> Vec<String> {
1378        self.configs
1379            .iter()
1380            .map(|c| format!("{:?}", c.provider))
1381            .collect()
1382    }
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387    use super::*;
1388    use mockito::Server;
1389
1390    // ── Helper ──────────────────────────────────────────────────────────
1391
1392    fn make_chat_messages() -> Vec<ChatMessage> {
1393        vec![
1394            ChatMessage::new("system", "You are helpful."),
1395            ChatMessage::new("user", "Hello!"),
1396        ]
1397    }
1398
1399    fn sample_chat_response_json(model: &str) -> String {
1400        format!(
1401            r#"{{
1402            "id": "chat-123",
1403            "object": "chat.completion",
1404            "created": 1717000000,
1405            "model": "{}",
1406            "choices": [
1407                {{
1408                    "index": 0,
1409                    "message": {{
1410                        "role": "assistant",
1411                        "content": "Hi there!"
1412                    }},
1413                    "finish_reason": "stop"
1414                }}
1415            ],
1416            "usage": {{
1417                "prompt_tokens": 10,
1418                "completion_tokens": 5,
1419                "total_tokens": 15
1420            }}
1421        }}"#,
1422            model
1423        )
1424    }
1425
1426    fn sample_ollama_response_json(model: &str) -> String {
1427        format!(
1428            r#"{{
1429            "model": "{}",
1430            "message": {{
1431                "role": "assistant",
1432                "content": "Hi there!"
1433            }},
1434            "done": true
1435        }}"#,
1436            model
1437        )
1438    }
1439
1440    /// Helper: run an async test with a mockito server.
1441    /// mockito::Server::new() spawns a background thread with its own tokio
1442    /// runtime, so we must create it *before* entering the tokio test runtime.
1443    fn with_mockito<F, Fut>(f: F)
1444    where
1445        F: FnOnce(mockito::ServerGuard) -> Fut,
1446        Fut: std::future::Future<Output = ()>,
1447    {
1448        let server = Server::new();
1449        let rt = tokio::runtime::Runtime::new().unwrap();
1450        rt.block_on(f(server));
1451    }
1452
1453    // ── OpenAICompatibleClient tests (v0.5 unified) ─────────────────────
1454
1455    #[test]
1456    fn test_openai_compatible_provider_defaults() {
1457        assert_eq!(
1458            OpenAICompatibleProvider::LiteLLM.default_endpoint(),
1459            "http://localhost:4000"
1460        );
1461        assert_eq!(
1462            OpenAICompatibleProvider::OpenAI.default_endpoint(),
1463            "https://api.openai.com"
1464        );
1465        assert_eq!(
1466            OpenAICompatibleProvider::OpenRouter.default_endpoint(),
1467            "https://openrouter.ai"
1468        );
1469    }
1470
1471    #[test]
1472    fn test_openai_compatible_provider_names() {
1473        assert_eq!(OpenAICompatibleProvider::LiteLLM.name(), "litellm");
1474        assert_eq!(OpenAICompatibleProvider::OpenAI.name(), "openai");
1475        assert_eq!(OpenAICompatibleProvider::OpenRouter.name(), "openrouter");
1476    }
1477
1478    #[test]
1479    fn test_openai_compatible_requires_custom_headers() {
1480        assert!(!OpenAICompatibleProvider::LiteLLM.requires_custom_headers());
1481        assert!(OpenAICompatibleProvider::OpenRouter.requires_custom_headers());
1482        assert!(!OpenAICompatibleProvider::OpenAI.requires_custom_headers());
1483    }
1484
1485    #[test]
1486    fn test_openai_compatible_client_new() {
1487        let config = LLMConfig {
1488            provider: LLMProvider::LiteLLM,
1489            endpoint: "http://localhost:4000".to_string(),
1490            model: "gpt-4o-mini".to_string(),
1491            api_key: Some("test-key".to_string()),
1492            timeout_secs: 30,
1493            system_prompt: crate::config::default_system_prompt(),
1494            token_budget: None,
1495            retry_max: 3,
1496            retry_base_delay_ms: 100,
1497            retry_max_delay_ms: 10000,
1498        };
1499
1500        let client = OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::LiteLLM);
1501        assert!(client.is_ok());
1502        assert_eq!(client.unwrap().provider_name(), "litellm");
1503    }
1504
1505    #[test]
1506    fn test_openai_compatible_client_endpoint() {
1507        // Test with custom endpoint
1508        let config = LLMConfig {
1509            provider: LLMProvider::OpenAI,
1510            endpoint: "https://custom.api.example.com".to_string(),
1511            model: "gpt-4o".to_string(),
1512            api_key: Some("test-key".to_string()),
1513            timeout_secs: 30,
1514            system_prompt: crate::config::default_system_prompt(),
1515            token_budget: None,
1516            retry_max: 3,
1517            retry_base_delay_ms: 100,
1518            retry_max_delay_ms: 10000,
1519        };
1520
1521        let client =
1522            OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenAI).unwrap();
1523        // Endpoint is private, but we can verify provider name
1524        assert_eq!(client.provider_name(), "openai");
1525    }
1526
1527    #[test]
1528    fn test_openai_compatible_client_chat_success() {
1529        with_mockito(|mut server| async move {
1530            let mock = server
1531                .mock("POST", "/v1/chat/completions")
1532                .with_status(200)
1533                .with_header("content-type", "application/json")
1534                .with_body(sample_chat_response_json("gpt-4o-mini"))
1535                .create();
1536
1537            let config = LLMConfig {
1538                provider: LLMProvider::LiteLLM,
1539                endpoint: server.url(),
1540                model: "gpt-4o-mini".to_string(),
1541                api_key: Some("test-key".to_string()),
1542                timeout_secs: 30,
1543                system_prompt: crate::config::default_system_prompt(),
1544                token_budget: None,
1545                retry_max: 3,
1546                retry_base_delay_ms: 100,
1547                retry_max_delay_ms: 10000,
1548            };
1549
1550            let client =
1551                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::LiteLLM).unwrap();
1552            let response = client.chat(make_chat_messages()).await.unwrap();
1553
1554            assert_eq!(response.model, "gpt-4o-mini");
1555            assert_eq!(response.choices[0].message.content, "Hi there!");
1556            mock.assert();
1557        });
1558    }
1559
1560    #[test]
1561    fn test_openai_compatible_client_auth_failure() {
1562        with_mockito(|mut server| async move {
1563            let mock = server
1564                .mock("POST", "/v1/chat/completions")
1565                .with_status(401)
1566                .with_body(r#"{"error": "Unauthorized"}"#)
1567                .create();
1568
1569            let config = LLMConfig {
1570                provider: LLMProvider::LiteLLM,
1571                endpoint: server.url(),
1572                model: "gpt-4o-mini".to_string(),
1573                api_key: Some("bad-key".to_string()),
1574                timeout_secs: 30,
1575                system_prompt: crate::config::default_system_prompt(),
1576                token_budget: None,
1577                retry_max: 3,
1578                retry_base_delay_ms: 100,
1579                retry_max_delay_ms: 10000,
1580            };
1581
1582            let client =
1583                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::LiteLLM).unwrap();
1584            let err = client.chat(make_chat_messages()).await.unwrap_err();
1585
1586            assert!(matches!(err, LLMError::AuthFailed));
1587            mock.assert();
1588        });
1589    }
1590
1591    #[test]
1592    fn test_openai_compatible_client_rate_limit() {
1593        with_mockito(|mut server| async move {
1594            let mock = server
1595                .mock("POST", "/v1/chat/completions")
1596                .with_status(429)
1597                .with_body(r#"{"error": "Rate limited"}"#)
1598                .create();
1599
1600            let config = LLMConfig {
1601                provider: LLMProvider::LiteLLM,
1602                endpoint: server.url(),
1603                model: "gpt-4o-mini".to_string(),
1604                api_key: Some("test-key".to_string()),
1605                timeout_secs: 30,
1606                system_prompt: crate::config::default_system_prompt(),
1607                token_budget: None,
1608                retry_max: 0, // Disable retries for error-path tests
1609                retry_base_delay_ms: 100,
1610                retry_max_delay_ms: 10000,
1611            };
1612
1613            let client =
1614                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::LiteLLM).unwrap();
1615            let err = client.chat(make_chat_messages()).await.unwrap_err();
1616
1617            assert!(matches!(err, LLMError::RateLimited));
1618            mock.assert();
1619        });
1620    }
1621
1622    #[test]
1623    fn test_openrouter_client_uses_custom_headers() {
1624        with_mockito(|mut server| async move {
1625            let mock = server
1626                .mock("POST", "/v1/chat/completions")
1627                .match_header("HTTP-Referer", "https://github.com/egkristi/RavenClaws")
1628                .match_header("X-Title", "RavenClaws")
1629                .with_status(200)
1630                .with_body(sample_chat_response_json("claude-sonnet-4"))
1631                .create();
1632
1633            let config = LLMConfig {
1634                provider: LLMProvider::OpenRouter,
1635                endpoint: server.url(),
1636                model: "claude-sonnet-4".to_string(),
1637                api_key: Some("or-key".to_string()),
1638                timeout_secs: 30,
1639                system_prompt: crate::config::default_system_prompt(),
1640                token_budget: None,
1641                retry_max: 3,
1642                retry_base_delay_ms: 100,
1643                retry_max_delay_ms: 10000,
1644            };
1645
1646            let client =
1647                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenRouter).unwrap();
1648            let _ = client.chat(make_chat_messages()).await.unwrap();
1649            mock.assert();
1650        });
1651    }
1652
1653    // ── AnthropicClient tests (v0.5.3) ─────────────────────────────────
1654
1655    #[test]
1656    fn test_anthropic_client_new() {
1657        let config = LLMConfig {
1658            provider: LLMProvider::Anthropic,
1659            endpoint: String::new(),
1660            model: "claude-sonnet-4-20250514".to_string(),
1661            api_key: Some("sk-ant-test".to_string()),
1662            timeout_secs: 30,
1663            system_prompt: crate::config::default_system_prompt(),
1664            token_budget: None,
1665            retry_max: 3,
1666            retry_base_delay_ms: 100,
1667            retry_max_delay_ms: 10000,
1668        };
1669
1670        let client = AnthropicClient::new(&config);
1671        assert!(client.is_ok());
1672    }
1673
1674    #[test]
1675    fn test_anthropic_client_provider_name() {
1676        let config = LLMConfig {
1677            provider: LLMProvider::Anthropic,
1678            endpoint: String::new(),
1679            model: "claude-sonnet-4-20250514".to_string(),
1680            api_key: Some("sk-ant-test".to_string()),
1681            timeout_secs: 30,
1682            system_prompt: crate::config::default_system_prompt(),
1683            token_budget: None,
1684            retry_max: 3,
1685            retry_base_delay_ms: 100,
1686            retry_max_delay_ms: 10000,
1687        };
1688
1689        let client = AnthropicClient::new(&config).unwrap();
1690        assert_eq!(client.provider_name(), "anthropic");
1691    }
1692
1693    #[test]
1694    fn test_anthropic_client_model() {
1695        let config = LLMConfig {
1696            provider: LLMProvider::Anthropic,
1697            endpoint: String::new(),
1698            model: "claude-opus-4-20250514".to_string(),
1699            api_key: Some("sk-ant-test".to_string()),
1700            timeout_secs: 30,
1701            system_prompt: crate::config::default_system_prompt(),
1702            token_budget: None,
1703            retry_max: 3,
1704            retry_base_delay_ms: 100,
1705            retry_max_delay_ms: 10000,
1706        };
1707
1708        let client = AnthropicClient::new(&config).unwrap();
1709        assert_eq!(client.model(), "claude-opus-4-20250514");
1710    }
1711
1712    #[test]
1713    fn test_create_client_anthropic() {
1714        let config = LLMConfig {
1715            provider: LLMProvider::Anthropic,
1716            endpoint: String::new(),
1717            model: "claude-sonnet-4-20250514".to_string(),
1718            api_key: Some("sk-ant-test".to_string()),
1719            timeout_secs: 30,
1720            system_prompt: crate::config::default_system_prompt(),
1721            token_budget: None,
1722            retry_max: 3,
1723            retry_base_delay_ms: 100,
1724            retry_max_delay_ms: 10000,
1725        };
1726
1727        let client = create_client(&config);
1728        assert!(client.is_ok());
1729        assert_eq!(client.unwrap().provider_name(), "anthropic");
1730    }
1731
1732    // ── Retry & Circuit Breaker tests (v0.5) ───────────────────────────
1733
1734    #[test]
1735    fn test_retry_config_delay_calculation() {
1736        let config = RetryConfig {
1737            max_retries: 3,
1738            base_delay_ms: 100,
1739            max_delay_ms: 10000,
1740            jitter: 0.0, // No jitter for predictable testing
1741        };
1742
1743        // Exponential backoff: 100, 200, 400, 800...
1744        assert_eq!(config.delay_for_attempt(0).as_millis(), 100);
1745        assert_eq!(config.delay_for_attempt(1).as_millis(), 200);
1746        assert_eq!(config.delay_for_attempt(2).as_millis(), 400);
1747    }
1748
1749    #[test]
1750    fn test_retry_config_max_delay_cap() {
1751        let config = RetryConfig {
1752            max_retries: 10,
1753            base_delay_ms: 100,
1754            max_delay_ms: 1000,
1755            jitter: 0.0,
1756        };
1757
1758        // Should cap at max_delay_ms
1759        assert!(config.delay_for_attempt(10).as_millis() <= 1000);
1760    }
1761
1762    #[test]
1763    fn test_circuit_breaker_state_transitions() {
1764        let mut cb = CircuitBreaker::new(30);
1765
1766        // Initially closed
1767        assert_eq!(cb.state, CircuitState::Closed);
1768        assert!(cb.can_execute());
1769
1770        // Record 5 failures → should open
1771        for _ in 0..5 {
1772            cb.record_failure();
1773        }
1774        assert_eq!(cb.state, CircuitState::Open);
1775        assert!(!cb.can_execute());
1776    }
1777
1778    #[test]
1779    fn test_circuit_breaker_success_resets() {
1780        let mut cb = CircuitBreaker::new(30);
1781
1782        // Record 3 failures
1783        for _ in 0..3 {
1784            cb.record_failure();
1785        }
1786        assert_eq!(cb.failure_count, 3);
1787
1788        // Record success → should reset
1789        cb.record_success();
1790        assert_eq!(cb.failure_count, 0);
1791        assert_eq!(cb.state, CircuitState::Closed);
1792    }
1793
1794    #[test]
1795    fn test_token_budget_tracking() {
1796        let mut budget = TokenBudget::new(1000, 0.002); // $0.002 per 1K tokens
1797
1798        assert_eq!(budget.remaining(), 1000);
1799        assert!(budget.can_spend(500));
1800
1801        budget.record_usage(300);
1802        assert_eq!(budget.remaining(), 700);
1803        assert!(budget.can_spend(500));
1804
1805        budget.record_usage(500);
1806        assert_eq!(budget.remaining(), 200);
1807        assert!(!budget.can_spend(500));
1808
1809        // Estimated cost: 800 tokens / 1000 * $0.002 = $0.0016
1810        assert!((budget.estimated_cost() - 0.0016).abs() < 0.0001);
1811    }
1812
1813    #[test]
1814    fn test_provider_fallback_chain_creation() {
1815        let configs = vec![
1816            LLMConfig {
1817                provider: LLMProvider::LiteLLM,
1818                endpoint: "http://localhost:4000".to_string(),
1819                model: "gpt-4o".to_string(),
1820                api_key: Some("key1".to_string()),
1821                timeout_secs: 30,
1822                system_prompt: crate::config::default_system_prompt(),
1823                token_budget: None,
1824                retry_max: 3,
1825                retry_base_delay_ms: 100,
1826                retry_max_delay_ms: 10000,
1827            },
1828            LLMConfig {
1829                provider: LLMProvider::Ollama,
1830                endpoint: "http://localhost:11434".to_string(),
1831                model: "llama3.1".to_string(),
1832                api_key: None,
1833                timeout_secs: 30,
1834                system_prompt: crate::config::default_system_prompt(),
1835                token_budget: None,
1836                retry_max: 3,
1837                retry_base_delay_ms: 100,
1838                retry_max_delay_ms: 10000,
1839            },
1840        ];
1841
1842        let chain = ProviderFallbackChain::new(configs);
1843        assert_eq!(chain.provider_names(), vec!["LiteLLM", "Ollama"]);
1844    }
1845
1846    // ── LiteLLM mockito tests (legacy, deprecated) ─────────────────────
1847
1848    #[test]
1849    fn test_litellm_chat_auth_failure() {
1850        with_mockito(|mut server| async move {
1851            let mock = server
1852                .mock("POST", "/v1/chat/completions")
1853                .with_status(401)
1854                .with_header("content-type", "application/json")
1855                .with_body(r#"{"error": "Unauthorized"}"#)
1856                .create();
1857
1858            let config = LLMConfig {
1859                provider: LLMProvider::LiteLLM,
1860                endpoint: server.url(),
1861                model: "gpt-4o-mini".to_string(),
1862                api_key: Some("bad-key".to_string()),
1863                timeout_secs: 30,
1864                system_prompt: crate::config::default_system_prompt(),
1865                token_budget: None,
1866                retry_max: 3,
1867                retry_base_delay_ms: 100,
1868                retry_max_delay_ms: 10000,
1869            };
1870
1871            let client =
1872                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::LiteLLM).unwrap();
1873            let err = client.chat(make_chat_messages()).await.unwrap_err();
1874
1875            assert!(matches!(err, LLMError::AuthFailed));
1876            mock.assert();
1877        });
1878    }
1879
1880    #[test]
1881    fn test_litellm_chat_rate_limit() {
1882        with_mockito(|mut server| async move {
1883            let mock = server
1884                .mock("POST", "/v1/chat/completions")
1885                .with_status(429)
1886                .with_header("content-type", "application/json")
1887                .with_body(r#"{"error": "Rate limit exceeded"}"#)
1888                .create();
1889
1890            let config = LLMConfig {
1891                provider: LLMProvider::LiteLLM,
1892                endpoint: server.url(),
1893                model: "gpt-4o-mini".to_string(),
1894                api_key: Some("test-key".to_string()),
1895                timeout_secs: 30,
1896                system_prompt: crate::config::default_system_prompt(),
1897                token_budget: None,
1898                retry_max: 0,
1899                retry_base_delay_ms: 100,
1900                retry_max_delay_ms: 10000,
1901            };
1902
1903            let client =
1904                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::LiteLLM).unwrap();
1905            let err = client.chat(make_chat_messages()).await.unwrap_err();
1906
1907            assert!(matches!(err, LLMError::RateLimited));
1908            mock.assert();
1909        });
1910    }
1911
1912    #[test]
1913    fn test_litellm_chat_server_error() {
1914        with_mockito(|mut server| async move {
1915            let mock = server
1916                .mock("POST", "/v1/chat/completions")
1917                .with_status(500)
1918                .with_header("content-type", "application/json")
1919                .with_body(r#"{"error": "Internal server error"}"#)
1920                .create();
1921
1922            let config = LLMConfig {
1923                provider: LLMProvider::LiteLLM,
1924                endpoint: server.url(),
1925                model: "gpt-4o-mini".to_string(),
1926                api_key: Some("test-key".to_string()),
1927                timeout_secs: 30,
1928                system_prompt: crate::config::default_system_prompt(),
1929                token_budget: None,
1930                retry_max: 0,
1931                retry_base_delay_ms: 100,
1932                retry_max_delay_ms: 10000,
1933            };
1934
1935            let client =
1936                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::LiteLLM).unwrap();
1937            let err = client.chat(make_chat_messages()).await.unwrap_err();
1938
1939            assert!(matches!(err, LLMError::RequestFailed(_)));
1940            assert!(format!("{}", err).contains("500"));
1941            mock.assert();
1942        });
1943    }
1944
1945    #[test]
1946    fn test_litellm_chat_invalid_json() {
1947        with_mockito(|mut server| async move {
1948            let mock = server
1949                .mock("POST", "/v1/chat/completions")
1950                .with_status(200)
1951                .with_header("content-type", "application/json")
1952                .with_body("not-json")
1953                .create();
1954
1955            let config = LLMConfig {
1956                provider: LLMProvider::LiteLLM,
1957                endpoint: server.url(),
1958                model: "gpt-4o-mini".to_string(),
1959                api_key: Some("test-key".to_string()),
1960                timeout_secs: 30,
1961                system_prompt: crate::config::default_system_prompt(),
1962                token_budget: None,
1963                retry_max: 0,
1964                retry_base_delay_ms: 100,
1965                retry_max_delay_ms: 10000,
1966            };
1967
1968            let client =
1969                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::LiteLLM).unwrap();
1970            let err = client.chat(make_chat_messages()).await.unwrap_err();
1971
1972            assert!(matches!(err, LLMError::InvalidResponse(_)));
1973            mock.assert();
1974        });
1975    }
1976
1977    // ── OpenRouter mockito tests ───────────────────────────────────────
1978
1979    #[test]
1980    fn test_openrouter_chat_success() {
1981        with_mockito(|mut server| async move {
1982            let mock = server
1983                .mock("POST", "/v1/chat/completions")
1984                .with_status(200)
1985                .with_header("content-type", "application/json")
1986                .with_body(sample_chat_response_json(
1987                    "anthropic/claude-sonnet-4-20250514",
1988                ))
1989                .create();
1990
1991            let config = LLMConfig {
1992                provider: LLMProvider::OpenRouter,
1993                endpoint: server.url(),
1994                model: "anthropic/claude-sonnet-4-20250514".to_string(),
1995                api_key: Some("or-key".to_string()),
1996                timeout_secs: 30,
1997                system_prompt: crate::config::default_system_prompt(),
1998                token_budget: None,
1999                retry_max: 3,
2000                retry_base_delay_ms: 100,
2001                retry_max_delay_ms: 10000,
2002            };
2003
2004            let client =
2005                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenRouter).unwrap();
2006            let response = client.chat(make_chat_messages()).await.unwrap();
2007
2008            assert_eq!(response.model, "anthropic/claude-sonnet-4-20250514");
2009            assert_eq!(response.choices[0].message.content, "Hi there!");
2010            mock.assert();
2011        });
2012    }
2013
2014    #[test]
2015    fn test_openrouter_chat_auth_failure() {
2016        with_mockito(|mut server| async move {
2017            let mock = server
2018                .mock("POST", "/v1/chat/completions")
2019                .with_status(401)
2020                .with_header("content-type", "application/json")
2021                .with_body(r#"{"error": "Unauthorized"}"#)
2022                .create();
2023
2024            let config = LLMConfig {
2025                provider: LLMProvider::OpenRouter,
2026                endpoint: server.url(),
2027                model: "anthropic/claude-sonnet-4-20250514".to_string(),
2028                api_key: Some("bad-key".to_string()),
2029                timeout_secs: 30,
2030                system_prompt: crate::config::default_system_prompt(),
2031                token_budget: None,
2032                retry_max: 3,
2033                retry_base_delay_ms: 100,
2034                retry_max_delay_ms: 10000,
2035            };
2036
2037            let client =
2038                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenRouter).unwrap();
2039            let err = client.chat(make_chat_messages()).await.unwrap_err();
2040
2041            assert!(matches!(err, LLMError::AuthFailed));
2042            mock.assert();
2043        });
2044    }
2045
2046    #[test]
2047    fn test_openrouter_chat_rate_limit() {
2048        with_mockito(|mut server| async move {
2049            let mock = server
2050                .mock("POST", "/v1/chat/completions")
2051                .with_status(429)
2052                .with_header("content-type", "application/json")
2053                .with_body(r#"{"error": "Rate limited"}"#)
2054                .create();
2055
2056            let config = LLMConfig {
2057                provider: LLMProvider::OpenRouter,
2058                endpoint: server.url(),
2059                model: "anthropic/claude-sonnet-4-20250514".to_string(),
2060                api_key: Some("or-key".to_string()),
2061                timeout_secs: 30,
2062                system_prompt: crate::config::default_system_prompt(),
2063                token_budget: None,
2064                retry_max: 0, // Disable retries for error-path tests
2065                retry_base_delay_ms: 100,
2066                retry_max_delay_ms: 10000,
2067            };
2068
2069            let client =
2070                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenRouter).unwrap();
2071            let err = client.chat(make_chat_messages()).await.unwrap_err();
2072
2073            assert!(matches!(err, LLMError::RateLimited));
2074            mock.assert();
2075        });
2076    }
2077
2078    #[test]
2079    fn test_openrouter_chat_server_error() {
2080        with_mockito(|mut server| async move {
2081            let mock = server
2082                .mock("POST", "/v1/chat/completions")
2083                .with_status(500)
2084                .with_header("content-type", "application/json")
2085                .with_body(r#"{"error": "Internal error"}"#)
2086                .create();
2087
2088            let config = LLMConfig {
2089                provider: LLMProvider::OpenRouter,
2090                endpoint: server.url(),
2091                model: "anthropic/claude-sonnet-4-20250514".to_string(),
2092                api_key: Some("or-key".to_string()),
2093                timeout_secs: 30,
2094                system_prompt: crate::config::default_system_prompt(),
2095                token_budget: None,
2096                retry_max: 0, // Disable retries for error-path tests
2097                retry_base_delay_ms: 100,
2098                retry_max_delay_ms: 10000,
2099            };
2100
2101            let client =
2102                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenRouter).unwrap();
2103            let err = client.chat(make_chat_messages()).await.unwrap_err();
2104
2105            assert!(matches!(err, LLMError::RequestFailed(_)));
2106            assert!(format!("{}", err).contains("500"));
2107            mock.assert();
2108        });
2109    }
2110
2111    #[test]
2112    fn test_openrouter_chat_invalid_json() {
2113        with_mockito(|mut server| async move {
2114            let mock = server
2115                .mock("POST", "/v1/chat/completions")
2116                .with_status(200)
2117                .with_header("content-type", "application/json")
2118                .with_body("not-json")
2119                .create();
2120
2121            let config = LLMConfig {
2122                provider: LLMProvider::OpenRouter,
2123                endpoint: server.url(),
2124                model: "anthropic/claude-sonnet-4-20250514".to_string(),
2125                api_key: Some("or-key".to_string()),
2126                timeout_secs: 30,
2127                system_prompt: crate::config::default_system_prompt(),
2128                token_budget: None,
2129                retry_max: 0, // Disable retries for error-path tests
2130                retry_base_delay_ms: 100,
2131                retry_max_delay_ms: 10000,
2132            };
2133
2134            let client =
2135                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenRouter).unwrap();
2136            let err = client.chat(make_chat_messages()).await.unwrap_err();
2137
2138            assert!(matches!(err, LLMError::InvalidResponse(_)));
2139            mock.assert();
2140        });
2141    }
2142
2143    // ── OpenAI mockito tests ───────────────────────────────────────────
2144
2145    #[test]
2146    fn test_openai_chat_success() {
2147        with_mockito(|mut server| async move {
2148            let mock = server
2149                .mock("POST", "/v1/chat/completions")
2150                .with_status(200)
2151                .with_header("content-type", "application/json")
2152                .with_body(sample_chat_response_json("gpt-4o"))
2153                .create();
2154
2155            let config = LLMConfig {
2156                provider: LLMProvider::OpenAI,
2157                endpoint: server.url(),
2158                model: "gpt-4o".to_string(),
2159                api_key: Some("sk-test".to_string()),
2160                timeout_secs: 60,
2161                system_prompt: crate::config::default_system_prompt(),
2162                token_budget: None,
2163                retry_max: 3,
2164                retry_base_delay_ms: 100,
2165                retry_max_delay_ms: 10000,
2166            };
2167
2168            let client =
2169                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenAI).unwrap();
2170            let response = client.chat(make_chat_messages()).await.unwrap();
2171
2172            assert_eq!(response.model, "gpt-4o");
2173            assert_eq!(response.choices[0].message.content, "Hi there!");
2174            mock.assert();
2175        });
2176    }
2177
2178    #[test]
2179    fn test_openai_chat_auth_failure() {
2180        with_mockito(|mut server| async move {
2181            let mock = server
2182                .mock("POST", "/v1/chat/completions")
2183                .with_status(401)
2184                .with_header("content-type", "application/json")
2185                .with_body(r#"{"error": "Unauthorized"}"#)
2186                .create();
2187
2188            let config = LLMConfig {
2189                provider: LLMProvider::OpenAI,
2190                endpoint: server.url(),
2191                model: "gpt-4o".to_string(),
2192                api_key: Some("bad-key".to_string()),
2193                timeout_secs: 30,
2194                system_prompt: crate::config::default_system_prompt(),
2195                token_budget: None,
2196                retry_max: 3,
2197                retry_base_delay_ms: 100,
2198                retry_max_delay_ms: 10000,
2199            };
2200
2201            let client =
2202                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenAI).unwrap();
2203            let err = client.chat(make_chat_messages()).await.unwrap_err();
2204
2205            assert!(matches!(err, LLMError::AuthFailed));
2206            mock.assert();
2207        });
2208    }
2209
2210    #[test]
2211    fn test_openai_chat_rate_limit() {
2212        with_mockito(|mut server| async move {
2213            let mock = server
2214                .mock("POST", "/v1/chat/completions")
2215                .with_status(429)
2216                .with_header("content-type", "application/json")
2217                .with_body(r#"{"error": "Rate limited"}"#)
2218                .create();
2219
2220            let config = LLMConfig {
2221                provider: LLMProvider::OpenAI,
2222                endpoint: server.url(),
2223                model: "gpt-4o".to_string(),
2224                api_key: Some("sk-test".to_string()),
2225                timeout_secs: 30,
2226                system_prompt: crate::config::default_system_prompt(),
2227                token_budget: None,
2228                retry_max: 0, // Disable retries for error-path tests
2229                retry_base_delay_ms: 100,
2230                retry_max_delay_ms: 10000,
2231            };
2232
2233            let client =
2234                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenAI).unwrap();
2235            let err = client.chat(make_chat_messages()).await.unwrap_err();
2236
2237            assert!(matches!(err, LLMError::RateLimited));
2238            mock.assert();
2239        });
2240    }
2241
2242    #[test]
2243    fn test_openai_chat_server_error() {
2244        with_mockito(|mut server| async move {
2245            let mock = server
2246                .mock("POST", "/v1/chat/completions")
2247                .with_status(500)
2248                .with_header("content-type", "application/json")
2249                .with_body(r#"{"error": "Internal error"}"#)
2250                .create();
2251
2252            let config = LLMConfig {
2253                provider: LLMProvider::OpenAI,
2254                endpoint: server.url(),
2255                model: "gpt-4o".to_string(),
2256                api_key: Some("sk-test".to_string()),
2257                timeout_secs: 30,
2258                system_prompt: crate::config::default_system_prompt(),
2259                token_budget: None,
2260                retry_max: 0, // Disable retries for error-path tests
2261                retry_base_delay_ms: 100,
2262                retry_max_delay_ms: 10000,
2263            };
2264
2265            let client =
2266                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenAI).unwrap();
2267            let err = client.chat(make_chat_messages()).await.unwrap_err();
2268
2269            assert!(matches!(err, LLMError::RequestFailed(_)));
2270            assert!(format!("{}", err).contains("500"));
2271            mock.assert();
2272        });
2273    }
2274
2275    #[test]
2276    fn test_openai_chat_invalid_json() {
2277        with_mockito(|mut server| async move {
2278            let mock = server
2279                .mock("POST", "/v1/chat/completions")
2280                .with_status(200)
2281                .with_header("content-type", "application/json")
2282                .with_body("not-json")
2283                .create();
2284
2285            let config = LLMConfig {
2286                provider: LLMProvider::OpenAI,
2287                endpoint: server.url(),
2288                model: "gpt-4o".to_string(),
2289                api_key: Some("sk-test".to_string()),
2290                timeout_secs: 30,
2291                system_prompt: crate::config::default_system_prompt(),
2292                token_budget: None,
2293                retry_max: 0, // Disable retries for error-path tests
2294                retry_base_delay_ms: 100,
2295                retry_max_delay_ms: 10000,
2296            };
2297
2298            let client =
2299                OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenAI).unwrap();
2300            let err = client.chat(make_chat_messages()).await.unwrap_err();
2301
2302            assert!(matches!(err, LLMError::InvalidResponse(_)));
2303            mock.assert();
2304        });
2305    }
2306
2307    // ── Ollama mockito tests ───────────────────────────────────────────
2308
2309    #[test]
2310    fn test_ollama_chat_success() {
2311        with_mockito(|mut server| async move {
2312            let mock = server
2313                .mock("POST", "/api/chat")
2314                .with_status(200)
2315                .with_header("content-type", "application/json")
2316                .with_body(sample_ollama_response_json("llama3.1"))
2317                .create();
2318
2319            let config = LLMConfig {
2320                provider: LLMProvider::Ollama,
2321                endpoint: server.url(),
2322                model: "llama3.1".to_string(),
2323                api_key: None,
2324                timeout_secs: 30,
2325                system_prompt: crate::config::default_system_prompt(),
2326                token_budget: None,
2327                retry_max: 3,
2328                retry_base_delay_ms: 100,
2329                retry_max_delay_ms: 10000,
2330            };
2331
2332            let client = OllamaClient::new(&config).unwrap();
2333            let response = client.chat(make_chat_messages()).await.unwrap();
2334
2335            assert_eq!(response.model, "llama3.1");
2336            assert_eq!(response.choices[0].message.content, "Hi there!");
2337            assert_eq!(response.choices[0].finish_reason, Some("stop".to_string()));
2338            mock.assert();
2339        });
2340    }
2341
2342    #[test]
2343    fn test_ollama_chat_server_error() {
2344        with_mockito(|mut server| async move {
2345            let mock = server
2346                .mock("POST", "/api/chat")
2347                .with_status(500)
2348                .with_header("content-type", "application/json")
2349                .with_body(r#"{"error": "Model not loaded"}"#)
2350                .create();
2351
2352            let config = LLMConfig {
2353                provider: LLMProvider::Ollama,
2354                endpoint: server.url(),
2355                model: "llama3.1".to_string(),
2356                api_key: None,
2357                timeout_secs: 30,
2358                system_prompt: crate::config::default_system_prompt(),
2359                token_budget: None,
2360                retry_max: 3,
2361                retry_base_delay_ms: 100,
2362                retry_max_delay_ms: 10000,
2363            };
2364
2365            let client = OllamaClient::new(&config).unwrap();
2366            let err = client.chat(make_chat_messages()).await.unwrap_err();
2367
2368            assert!(matches!(err, LLMError::RequestFailed(_)));
2369            mock.assert();
2370        });
2371    }
2372
2373    #[test]
2374    fn test_ollama_chat_invalid_json() {
2375        with_mockito(|mut server| async move {
2376            let mock = server
2377                .mock("POST", "/api/chat")
2378                .with_status(200)
2379                .with_header("content-type", "application/json")
2380                .with_body("not-json")
2381                .create();
2382
2383            let config = LLMConfig {
2384                provider: LLMProvider::Ollama,
2385                endpoint: server.url(),
2386                model: "llama3.1".to_string(),
2387                api_key: None,
2388                timeout_secs: 30,
2389                system_prompt: crate::config::default_system_prompt(),
2390                token_budget: None,
2391                retry_max: 3,
2392                retry_base_delay_ms: 100,
2393                retry_max_delay_ms: 10000,
2394            };
2395
2396            let client = OllamaClient::new(&config).unwrap();
2397            let err = client.chat(make_chat_messages()).await.unwrap_err();
2398
2399            assert!(matches!(err, LLMError::InvalidResponse(_)));
2400            mock.assert();
2401        });
2402    }
2403
2404    #[test]
2405    fn test_ollama_chat_auth_failure() {
2406        with_mockito(|mut server| async move {
2407            let mock = server
2408                .mock("POST", "/api/chat")
2409                .with_status(401)
2410                .with_header("content-type", "application/json")
2411                .with_body(r#"{"error": "Unauthorized"}"#)
2412                .create();
2413
2414            let config = LLMConfig {
2415                provider: LLMProvider::Ollama,
2416                endpoint: server.url(),
2417                model: "llama3.1".to_string(),
2418                api_key: Some("bad-key".to_string()),
2419                timeout_secs: 30,
2420                system_prompt: crate::config::default_system_prompt(),
2421                token_budget: None,
2422                retry_max: 3,
2423                retry_base_delay_ms: 100,
2424                retry_max_delay_ms: 10000,
2425            };
2426
2427            let client = OllamaClient::new(&config).unwrap();
2428            let err = client.chat(make_chat_messages()).await.unwrap_err();
2429
2430            assert!(matches!(err, LLMError::AuthFailed));
2431            mock.assert();
2432        });
2433    }
2434
2435    // ── Factory function tests ─────────────────────────────────────────
2436
2437    #[test]
2438    fn test_create_client_factory_litellm() {
2439        let config = LLMConfig {
2440            provider: LLMProvider::LiteLLM,
2441            endpoint: "http://localhost:4000".to_string(),
2442            model: "gpt-4o-mini".to_string(),
2443            api_key: Some("test".to_string()),
2444            timeout_secs: 30,
2445            system_prompt: crate::config::default_system_prompt(),
2446            token_budget: None,
2447            retry_max: 3,
2448            retry_base_delay_ms: 100,
2449            retry_max_delay_ms: 10000,
2450        };
2451
2452        let client = create_client(&config).unwrap();
2453        assert_eq!(client.provider_name(), "litellm");
2454        assert_eq!(client.model(), "gpt-4o-mini");
2455    }
2456
2457    #[test]
2458    fn test_ollama_client_creation() {
2459        let config = LLMConfig {
2460            provider: LLMProvider::Ollama,
2461            endpoint: "http://localhost:11434".to_string(),
2462            model: "llama3.1".to_string(),
2463            api_key: None,
2464            timeout_secs: 30,
2465            system_prompt: crate::config::default_system_prompt(),
2466            token_budget: None,
2467            retry_max: 3,
2468            retry_base_delay_ms: 100,
2469            retry_max_delay_ms: 10000,
2470        };
2471
2472        let client = OllamaClient::new(&config).unwrap();
2473        assert_eq!(client.provider_name(), "ollama");
2474        assert_eq!(client.model(), "llama3.1");
2475    }
2476
2477    #[test]
2478    fn test_openai_client_creation() {
2479        let config = LLMConfig {
2480            provider: LLMProvider::OpenAI,
2481            endpoint: String::new(),
2482            model: "gpt-4o".to_string(),
2483            api_key: Some("sk-test".to_string()),
2484            timeout_secs: 60,
2485            system_prompt: crate::config::default_system_prompt(),
2486            token_budget: None,
2487            retry_max: 3,
2488            retry_base_delay_ms: 100,
2489            retry_max_delay_ms: 10000,
2490        };
2491
2492        let client =
2493            OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenAI).unwrap();
2494        assert_eq!(client.provider_name(), "openai");
2495        assert_eq!(client.model(), "gpt-4o");
2496    }
2497
2498    #[test]
2499    fn test_openrouter_client_creation() {
2500        let config = LLMConfig {
2501            provider: LLMProvider::OpenRouter,
2502            endpoint: String::new(),
2503            model: "anthropic/claude-sonnet-4-20250514".to_string(),
2504            api_key: Some("sk-test".to_string()),
2505            timeout_secs: 30,
2506            system_prompt: crate::config::default_system_prompt(),
2507            token_budget: None,
2508            retry_max: 3,
2509            retry_base_delay_ms: 100,
2510            retry_max_delay_ms: 10000,
2511        };
2512
2513        let client =
2514            OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenRouter).unwrap();
2515        assert_eq!(client.provider_name(), "openrouter");
2516        assert_eq!(client.model(), "anthropic/claude-sonnet-4-20250514");
2517    }
2518
2519    #[test]
2520    fn test_multi_model_manager_empty() {
2521        let manager = MultiModelManager::new(vec![]).unwrap();
2522        assert_eq!(manager.client_count(), 0);
2523        assert!(manager.get_client(0).is_none());
2524    }
2525
2526    #[test]
2527    fn test_multi_model_manager_single() {
2528        let config = LLMConfig {
2529            provider: LLMProvider::LiteLLM,
2530            endpoint: "http://localhost:4000".to_string(),
2531            model: "gpt-4o-mini".to_string(),
2532            api_key: Some("test".to_string()),
2533            timeout_secs: 30,
2534            system_prompt: crate::config::default_system_prompt(),
2535            token_budget: None,
2536            retry_max: 3,
2537            retry_base_delay_ms: 100,
2538            retry_max_delay_ms: 10000,
2539        };
2540
2541        let manager = MultiModelManager::new(vec![config]).unwrap();
2542        assert_eq!(manager.client_count(), 1);
2543        assert!(manager.get_client(0).is_some());
2544        assert_eq!(manager.get_client(0).unwrap().provider_name(), "litellm");
2545    }
2546
2547    #[test]
2548    fn test_multi_model_manager_multiple() {
2549        let configs = vec![
2550            LLMConfig {
2551                provider: LLMProvider::LiteLLM,
2552                endpoint: "http://localhost:4000".to_string(),
2553                model: "gpt-4o-mini".to_string(),
2554                api_key: Some("test".to_string()),
2555                timeout_secs: 30,
2556                system_prompt: crate::config::default_system_prompt(),
2557                token_budget: None,
2558                retry_max: 3,
2559                retry_base_delay_ms: 100,
2560                retry_max_delay_ms: 10000,
2561            },
2562            LLMConfig {
2563                provider: LLMProvider::Ollama,
2564                endpoint: "http://localhost:11434".to_string(),
2565                model: "llama3.1".to_string(),
2566                api_key: None,
2567                timeout_secs: 60,
2568                system_prompt: crate::config::default_system_prompt(),
2569                token_budget: None,
2570                retry_max: 3,
2571                retry_base_delay_ms: 100,
2572                retry_max_delay_ms: 10000,
2573            },
2574        ];
2575
2576        let manager = MultiModelManager::new(configs).unwrap();
2577        assert_eq!(manager.client_count(), 2);
2578        assert_eq!(manager.get_client(0).unwrap().provider_name(), "litellm");
2579        assert_eq!(manager.get_client(1).unwrap().provider_name(), "ollama");
2580    }
2581
2582    #[test]
2583    fn test_multi_model_next_client_round_robin() {
2584        let configs = vec![
2585            LLMConfig {
2586                provider: LLMProvider::LiteLLM,
2587                endpoint: "http://localhost:4000".to_string(),
2588                model: "gpt-4o-mini".to_string(),
2589                api_key: Some("test".to_string()),
2590                timeout_secs: 30,
2591                system_prompt: crate::config::default_system_prompt(),
2592                token_budget: None,
2593                retry_max: 3,
2594                retry_base_delay_ms: 100,
2595                retry_max_delay_ms: 10000,
2596            },
2597            LLMConfig {
2598                provider: LLMProvider::Ollama,
2599                endpoint: "http://localhost:11434".to_string(),
2600                model: "llama3.1".to_string(),
2601                api_key: None,
2602                timeout_secs: 60,
2603                system_prompt: crate::config::default_system_prompt(),
2604                token_budget: None,
2605                retry_max: 3,
2606                retry_base_delay_ms: 100,
2607                retry_max_delay_ms: 10000,
2608            },
2609        ];
2610
2611        let manager = MultiModelManager::new(configs).unwrap();
2612        // Start at index 0, next should be index 1
2613        let next = manager.next_client(0).unwrap();
2614        assert_eq!(next.provider_name(), "ollama");
2615        // Next after index 1 wraps to index 0
2616        let next = manager.next_client(1).unwrap();
2617        assert_eq!(next.provider_name(), "litellm");
2618    }
2619
2620    #[test]
2621    fn test_chat_request_serialization() {
2622        let request = ChatRequest {
2623            model: "gpt-4o-mini".to_string(),
2624            messages: vec![
2625                ChatMessage::new("system", "You are a helpful assistant."),
2626                ChatMessage::new("user", "Hello!"),
2627            ],
2628            temperature: Some(0.7),
2629            max_tokens: Some(2048),
2630            stream: None,
2631            tools: None,
2632            tool_choice: None,
2633        };
2634
2635        let json = serde_json::to_string(&request).unwrap();
2636        assert!(json.contains("gpt-4o-mini"));
2637        assert!(json.contains("system"));
2638        assert!(json.contains("user"));
2639        assert!(json.contains("Hello!"));
2640        assert!(json.contains("0.7"));
2641        // stream: None should be skipped
2642        assert!(!json.contains("stream"));
2643    }
2644
2645    #[test]
2646    fn test_chat_response_deserialization() {
2647        let json = r#"{
2648            "id": "chat-123",
2649            "object": "chat.completion",
2650            "created": 1717000000,
2651            "model": "gpt-4o-mini",
2652            "choices": [
2653                {
2654                    "index": 0,
2655                    "message": {
2656                        "role": "assistant",
2657                        "content": "Hello! How can I help you?"
2658                    },
2659                    "finish_reason": "stop"
2660                }
2661            ],
2662            "usage": {
2663                "prompt_tokens": 10,
2664                "completion_tokens": 20,
2665                "total_tokens": 30
2666            }
2667        }"#;
2668
2669        let response: ChatResponse = serde_json::from_str(json).unwrap();
2670        assert_eq!(response.id, "chat-123");
2671        assert_eq!(response.model, "gpt-4o-mini");
2672        assert_eq!(response.choices.len(), 1);
2673        assert_eq!(response.choices[0].message.role, "assistant");
2674        assert_eq!(
2675            response.choices[0].message.content,
2676            "Hello! How can I help you?"
2677        );
2678        assert_eq!(response.usage.unwrap().total_tokens, 30);
2679    }
2680
2681    #[test]
2682    fn test_multi_model_manager_new_invalid_config() {
2683        // Config with empty endpoint for LiteLLM should fail at client creation
2684        let configs = vec![LLMConfig {
2685            provider: LLMProvider::LiteLLM,
2686            endpoint: String::new(), // empty endpoint — will fail HTTP client creation
2687            model: "gpt-4o-mini".to_string(),
2688            api_key: None,
2689            timeout_secs: 30,
2690            system_prompt: crate::config::default_system_prompt(),
2691            token_budget: None,
2692            retry_max: 3,
2693            retry_base_delay_ms: 100,
2694            retry_max_delay_ms: 10000,
2695        }];
2696
2697        let result = MultiModelManager::new(configs);
2698        // The client creation itself won't fail (HTTP client doesn't validate endpoint),
2699        // but the request will fail. This tests the error propagation path.
2700        assert!(result.is_ok());
2701        let manager = result.unwrap();
2702        assert_eq!(manager.client_count(), 1);
2703    }
2704
2705    #[test]
2706    fn test_create_client_all_providers() {
2707        let test_cases = vec![
2708            (LLMProvider::LiteLLM, "litellm"),
2709            (LLMProvider::OpenRouter, "openrouter"),
2710            (LLMProvider::Ollama, "ollama"),
2711            (LLMProvider::OpenAI, "openai"),
2712        ];
2713
2714        for (provider, expected_name) in test_cases {
2715            let config = LLMConfig {
2716                provider,
2717                endpoint: "http://localhost:4000".to_string(),
2718                model: "test-model".to_string(),
2719                api_key: Some("test-key".to_string()),
2720                timeout_secs: 30,
2721                system_prompt: crate::config::default_system_prompt(),
2722                token_budget: None,
2723                retry_max: 3,
2724                retry_base_delay_ms: 100,
2725                retry_max_delay_ms: 10000,
2726            };
2727
2728            let client = create_client(&config).unwrap();
2729            assert_eq!(client.provider_name(), expected_name);
2730            assert_eq!(client.model(), "test-model");
2731        }
2732    }
2733
2734    #[test]
2735    fn test_llm_error_display() {
2736        let err = LLMError::RequestFailed("timeout".to_string());
2737        assert_eq!(format!("{}", err), "Request failed: timeout");
2738
2739        let err = LLMError::AuthFailed;
2740        assert_eq!(format!("{}", err), "Authentication failed");
2741
2742        let err = LLMError::RateLimited;
2743        assert_eq!(format!("{}", err), "Rate limit exceeded");
2744
2745        let err = LLMError::InvalidResponse("bad json".to_string());
2746        assert_eq!(format!("{}", err), "Invalid response: bad json");
2747
2748        let err = LLMError::ProviderNotSupported("custom".to_string());
2749        assert_eq!(format!("{}", err), "Provider not supported: custom");
2750    }
2751
2752    #[test]
2753    fn test_llm_error_is_debug() {
2754        let err = LLMError::RequestFailed("test".to_string());
2755        let debug = format!("{:?}", err);
2756        assert!(debug.contains("RequestFailed"));
2757    }
2758
2759    #[test]
2760    fn test_llm_error_is_send() {
2761        fn check_send<T: Send>() {}
2762        check_send::<LLMError>();
2763    }
2764
2765    #[test]
2766    fn test_llm_error_is_sync() {
2767        fn check_sync<T: Sync>() {}
2768        check_sync::<LLMError>();
2769    }
2770
2771    #[test]
2772    fn test_litellm_client_new_with_invalid_timeout() {
2773        // Very large timeout should still succeed (reqwest accepts large values)
2774        let config = LLMConfig {
2775            provider: LLMProvider::LiteLLM,
2776            endpoint: "http://localhost:4000".to_string(),
2777            model: "gpt-4o-mini".to_string(),
2778            api_key: Some("test".to_string()),
2779            timeout_secs: u64::MAX,
2780            system_prompt: crate::config::default_system_prompt(),
2781            token_budget: None,
2782            retry_max: 3,
2783            retry_base_delay_ms: 100,
2784            retry_max_delay_ms: 10000,
2785        };
2786
2787        let result = OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::LiteLLM);
2788        assert!(result.is_ok());
2789    }
2790
2791    #[test]
2792    fn test_openai_client_with_custom_endpoint() {
2793        let config = LLMConfig {
2794            provider: LLMProvider::OpenAI,
2795            endpoint: "https://custom.openai.example.com".to_string(),
2796            model: "gpt-4o".to_string(),
2797            api_key: Some("sk-test".to_string()),
2798            timeout_secs: 30,
2799            system_prompt: crate::config::default_system_prompt(),
2800            token_budget: None,
2801            retry_max: 3,
2802            retry_base_delay_ms: 100,
2803            retry_max_delay_ms: 10000,
2804        };
2805
2806        let client =
2807            OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenAI).unwrap();
2808        assert_eq!(client.provider_name(), "openai");
2809        assert_eq!(client.model(), "gpt-4o");
2810    }
2811
2812    #[test]
2813    fn test_openrouter_client_with_custom_endpoint() {
2814        let config = LLMConfig {
2815            provider: LLMProvider::OpenRouter,
2816            endpoint: "https://custom.openrouter.example.com".to_string(),
2817            model: "anthropic/claude-sonnet-4-20250514".to_string(),
2818            api_key: Some("or-key".to_string()),
2819            timeout_secs: 30,
2820            system_prompt: crate::config::default_system_prompt(),
2821            token_budget: None,
2822            retry_max: 3,
2823            retry_base_delay_ms: 100,
2824            retry_max_delay_ms: 10000,
2825        };
2826
2827        let client =
2828            OpenAICompatibleClient::new(&config, OpenAICompatibleProvider::OpenRouter).unwrap();
2829        assert_eq!(client.provider_name(), "openrouter");
2830        assert_eq!(client.model(), "anthropic/claude-sonnet-4-20250514");
2831    }
2832
2833    #[test]
2834    fn test_ollama_client_with_auth() {
2835        // Ollama with API key should still create successfully
2836        let config = LLMConfig {
2837            provider: LLMProvider::Ollama,
2838            endpoint: "http://localhost:11434".to_string(),
2839            model: "llama3.1".to_string(),
2840            api_key: Some("some-key".to_string()),
2841            timeout_secs: 30,
2842            system_prompt: crate::config::default_system_prompt(),
2843            token_budget: None,
2844            retry_max: 3,
2845            retry_base_delay_ms: 100,
2846            retry_max_delay_ms: 10000,
2847        };
2848
2849        let client = OllamaClient::new(&config).unwrap();
2850        assert_eq!(client.provider_name(), "ollama");
2851        assert_eq!(client.model(), "llama3.1");
2852    }
2853
2854    #[test]
2855    fn test_chat_request_no_temperature() {
2856        let request = ChatRequest {
2857            model: "gpt-4o-mini".to_string(),
2858            messages: vec![ChatMessage::new("user", "Hello!")],
2859            temperature: None,
2860            max_tokens: None,
2861            stream: None,
2862            tools: None,
2863            tool_choice: None,
2864        };
2865
2866        let json = serde_json::to_string(&request).unwrap();
2867        assert!(json.contains("gpt-4o-mini"));
2868        assert!(json.contains("Hello!"));
2869        // Optional fields should be skipped
2870        assert!(!json.contains("temperature"));
2871        assert!(!json.contains("max_tokens"));
2872        assert!(!json.contains("stream"));
2873    }
2874
2875    #[test]
2876    fn test_chat_response_deserialization_no_usage() {
2877        let json = r#"{
2878            "id": "chat-456",
2879            "object": "chat.completion",
2880            "created": 1717000001,
2881            "model": "gpt-4o",
2882            "choices": [
2883                {
2884                    "index": 0,
2885                    "message": {
2886                        "role": "assistant",
2887                        "content": "Sure!"
2888                    },
2889                    "finish_reason": "stop"
2890                }
2891            ]
2892        }"#;
2893
2894        let response: ChatResponse = serde_json::from_str(json).unwrap();
2895        assert_eq!(response.id, "chat-456");
2896        assert_eq!(response.model, "gpt-4o");
2897        assert_eq!(response.choices.len(), 1);
2898        assert!(response.usage.is_none());
2899    }
2900
2901    #[test]
2902    fn test_chat_response_deserialization_multiple_choices() {
2903        let json = r#"{
2904            "id": "chat-789",
2905            "object": "chat.completion",
2906            "created": 1717000002,
2907            "model": "gpt-4o",
2908            "choices": [
2909                {
2910                    "index": 0,
2911                    "message": {
2912                        "role": "assistant",
2913                        "content": "First choice"
2914                    },
2915                    "finish_reason": "stop"
2916                },
2917                {
2918                    "index": 1,
2919                    "message": {
2920                        "role": "assistant",
2921                        "content": "Second choice"
2922                    },
2923                    "finish_reason": "stop"
2924                }
2925            ]
2926        }"#;
2927
2928        let response: ChatResponse = serde_json::from_str(json).unwrap();
2929        assert_eq!(response.choices.len(), 2);
2930        assert_eq!(response.choices[0].message.content, "First choice");
2931        assert_eq!(response.choices[1].message.content, "Second choice");
2932    }
2933
2934    #[test]
2935    fn test_llm_error_into_boxed() {
2936        let err = LLMError::AuthFailed;
2937        let boxed: Box<dyn std::error::Error> = Box::new(err);
2938        assert!(format!("{}", boxed).contains("Authentication failed"));
2939    }
2940
2941    #[test]
2942    fn test_llm_error_into_string() {
2943        let err = LLMError::RateLimited;
2944        let msg: String = err.to_string();
2945        assert_eq!(msg, "Rate limit exceeded");
2946    }
2947
2948    #[test]
2949    fn test_create_client_with_empty_api_key() {
2950        // Some providers (Ollama) don't require an API key
2951        let config = LLMConfig {
2952            provider: LLMProvider::Ollama,
2953            endpoint: "http://localhost:11434".to_string(),
2954            model: "llama3.1".to_string(),
2955            api_key: None,
2956            timeout_secs: 30,
2957            system_prompt: crate::config::default_system_prompt(),
2958            token_budget: None,
2959            retry_max: 3,
2960            retry_base_delay_ms: 100,
2961            retry_max_delay_ms: 10000,
2962        };
2963
2964        let client = create_client(&config).unwrap();
2965        assert_eq!(client.provider_name(), "ollama");
2966    }
2967
2968    #[test]
2969    fn test_multi_model_manager_get_client_out_of_bounds() {
2970        let manager = MultiModelManager::new(vec![]).unwrap();
2971        assert!(manager.get_client(0).is_none());
2972        assert!(manager.get_client(100).is_none());
2973        assert!(manager.get_client(usize::MAX).is_none());
2974    }
2975
2976    #[test]
2977    fn test_multi_model_next_client_empty() {
2978        let manager = MultiModelManager::new(vec![]).unwrap();
2979        assert!(manager.next_client(0).is_none());
2980    }
2981
2982    #[test]
2983    fn test_multi_model_next_client_single() {
2984        let config = LLMConfig {
2985            provider: LLMProvider::LiteLLM,
2986            endpoint: "http://localhost:4000".to_string(),
2987            model: "gpt-4o-mini".to_string(),
2988            api_key: Some("test".to_string()),
2989            timeout_secs: 30,
2990            system_prompt: crate::config::default_system_prompt(),
2991            token_budget: None,
2992            retry_max: 3,
2993            retry_base_delay_ms: 100,
2994            retry_max_delay_ms: 10000,
2995        };
2996
2997        let manager = MultiModelManager::new(vec![config]).unwrap();
2998        // With one client, next_client wraps to index 0
2999        let next = manager.next_client(0).unwrap();
3000        assert_eq!(next.provider_name(), "litellm");
3001    }
3002}