1use 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#[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#[allow(dead_code)]
27pub type StreamResult = Pin<Box<dyn Stream<Item = Result<StreamChunk, LLMError>> + Send>>;
28
29use crate::config::{LLMConfig, LLMProvider};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum OpenAICompatibleProvider {
38 LiteLLM,
39 OpenAI,
40 OpenRouter,
41 Generic,
43 Azure,
45}
46
47impl OpenAICompatibleProvider {
48 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 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 #[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#[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 #[allow(dead_code)]
117 pub fn is_transient(&self) -> bool {
130 matches!(
131 self,
132 LLMError::RequestFailed(_) | LLMError::RateLimited | LLMError::CircuitBreakerOpen(_)
133 )
134 }
135}
136
137#[derive(Debug, Clone)]
139pub struct RetryConfig {
140 pub max_retries: u32,
142 pub base_delay_ms: u64,
144 pub max_delay_ms: u64,
146 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180#[non_exhaustive]
181pub enum CircuitState {
182 Closed,
183 Open,
184 HalfOpen,
185}
186
187#[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#[derive(Debug, Clone)]
238pub struct TokenBudget {
239 pub max_tokens: u32,
241 pub used_tokens: u32,
243 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#[derive(Debug, Clone, Serialize, Deserialize)]
279#[serde(untagged)]
280#[non_exhaustive]
281pub enum ContentPart {
282 Text { text: String },
284 ImageUrl {
287 #[serde(rename = "image_url")]
288 image_url: ImageUrlContent,
289 },
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct ImageUrlContent {
295 pub url: String,
296}
297
298impl ContentPart {
299 pub fn text(text: impl Into<String>) -> Self {
301 Self::Text { text: text.into() }
302 }
303
304 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 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 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 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 serde_json::json!({
353 "type": "text",
354 "text": format!("[Image: {}]", url.chars().take(100).collect::<String>())
355 })
356 }
357 }
358 }
359}
360
361pub 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 #[serde(skip_serializing_if = "Option::is_none")]
399 pub content_parts: Option<Vec<ContentPart>>,
400}
401
402impl ChatMessage {
403 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 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 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 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 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
513fn 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#[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 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
609async 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
631pub 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 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 {
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 {
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 {
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 if matches!(last_error, Some(LLMError::AuthFailed)) {
710 return Err(last_error.unwrap());
711 }
712
713 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 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 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 req = req.header("api-key", key);
771 } else {
772 req = req.header("Authorization", format!("Bearer {}", key));
773 }
774 }
775
776 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 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
903pub 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 #[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 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 #[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, })
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
1039pub 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 #[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 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 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 #[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 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
1244pub 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#[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 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#[derive(Debug)]
1305pub struct ProviderFallbackChain {
1306 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 #[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 if let Some(ref budget) = self.token_budget {
1348 if !budget.can_spend(500) {
1350 return Err(LLMError::TokenBudgetExceeded);
1351 }
1352 }
1353
1354 match client.chat(messages.clone()).await {
1355 Ok(response) => {
1356 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 }
1369 }
1370 }
1371
1372 Err(last_error.unwrap_or(LLMError::AllProvidersFailed))
1373 }
1374
1375 #[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 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 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 #[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 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 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, 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 #[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 #[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, };
1742
1743 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 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 assert_eq!(cb.state, CircuitState::Closed);
1768 assert!(cb.can_execute());
1769
1770 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 for _ in 0..3 {
1784 cb.record_failure();
1785 }
1786 assert_eq!(cb.failure_count, 3);
1787
1788 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); 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 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 #[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 #[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, 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, 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, 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 #[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, 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, 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, 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 #[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 #[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 let next = manager.next_client(0).unwrap();
2614 assert_eq!(next.provider_name(), "ollama");
2615 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 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 let configs = vec![LLMConfig {
2685 provider: LLMProvider::LiteLLM,
2686 endpoint: String::new(), 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 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 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 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 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 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 let next = manager.next_client(0).unwrap();
3000 assert_eq!(next.provider_name(), "litellm");
3001 }
3002}