Skip to main content

agentic_core/
models.rs

1use anyhow::Result;
2use reqwest::Client;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::HashMap;
6use std::sync::{Arc, Mutex, OnceLock};
7use std::time::Duration;
8
9// Global provider cache to ensure consistency across different ModelValidator instances
10static PROVIDER_CACHE: OnceLock<Arc<Mutex<HashMap<String, LocalProvider>>>> = OnceLock::new();
11
12fn get_global_provider_cache() -> &'static Arc<Mutex<HashMap<String, LocalProvider>>> {
13    PROVIDER_CACHE.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AtomicNote {
18    pub header_tags: Vec<String>,
19    pub body_text: String,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct OllamaModel {
24    pub name: String,
25    pub size: String,
26    pub modified: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct LocalModel {
31    pub name: String,
32    pub id: String,
33    pub provider: LocalProvider,
34    pub size: String,
35    pub modified: String,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub enum LocalProvider {
40    Ollama,
41    LMStudio,
42    OpenAI,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct OpenRouterModel {
47    pub id: String,
48    pub name: String,
49    pub description: String,
50    pub pricing: ModelPricing,
51    pub context_length: u32,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ModelPricing {
56    pub prompt: String,
57    pub completion: String,
58}
59
60#[derive(Debug, Serialize, Deserialize)]
61struct OllamaListResponse {
62    models: Vec<OllamaModelRaw>,
63}
64
65#[derive(Debug, Serialize, Deserialize)]
66struct OllamaModelRaw {
67    name: String,
68    size: i64,
69    modified_at: String,
70}
71
72#[derive(Debug, Serialize, Deserialize)]
73struct OpenRouterListResponse {
74    data: Vec<OpenRouterModelRaw>,
75}
76
77#[derive(Debug, Serialize, Deserialize)]
78struct OpenRouterModelRaw {
79    id: String,
80    name: String,
81    description: Option<String>,
82    pricing: ModelPricingRaw,
83    context_length: u32,
84}
85
86#[derive(Debug, Serialize, Deserialize)]
87struct ModelPricingRaw {
88    prompt: String,
89    completion: String,
90}
91
92#[derive(Debug, Serialize, Deserialize)]
93struct OpenAIListResponse {
94    data: Vec<OpenAIModelRaw>,
95}
96
97#[derive(Debug, Serialize, Deserialize)]
98struct OpenAIModelRaw {
99    id: String,
100    #[serde(default)]
101    name: Option<String>,
102    #[serde(default)]
103    created: Option<u64>,
104}
105
106pub struct ModelValidator {
107    client: Client,
108}
109
110impl ModelValidator {
111    pub fn new() -> Self {
112        let client = Client::builder()
113            .timeout(Duration::from_secs(5))
114            .build()
115            .unwrap_or_default();
116
117        Self { client }
118    }
119
120    pub async fn detect_provider_type(&self, endpoint: &str) -> LocalProvider {
121        // Check global cache first
122        let cache = get_global_provider_cache();
123        if let Ok(cache_lock) = cache.lock() {
124            if let Some(cached_provider) = cache_lock.get(endpoint) {
125                return cached_provider.clone();
126            }
127        }
128
129        // Perform actual detection
130        let detected_provider = self.perform_provider_detection(endpoint).await;
131
132        // Cache the result globally
133        if let Ok(mut cache_lock) = cache.lock() {
134            cache_lock.insert(endpoint.to_string(), detected_provider.clone());
135        }
136
137        detected_provider
138    }
139
140    async fn perform_provider_detection(&self, endpoint: &str) -> LocalProvider {
141        // Try OpenAI/LM Studio API first for port 1234
142        if endpoint.to_lowercase().contains("1234")
143            && self.test_openai_endpoint(endpoint).await.is_ok()
144        {
145            return LocalProvider::LMStudio;
146        }
147
148        // Try Ollama API
149        if self.test_ollama_endpoint(endpoint).await.is_ok() {
150            return LocalProvider::Ollama;
151        }
152
153        // Try generic OpenAI API
154        if self.test_openai_endpoint(endpoint).await.is_ok() {
155            return LocalProvider::OpenAI;
156        }
157
158        // Default to Ollama if all detection fails
159        LocalProvider::Ollama
160    }
161
162    async fn test_ollama_endpoint(&self, endpoint: &str) -> Result<()> {
163        let url = if endpoint.starts_with("http") {
164            format!("{}/api/tags", endpoint)
165        } else {
166            format!("http://{}/api/tags", endpoint)
167        };
168
169        let response = self.client.get(&url).send().await?;
170        if response.status().is_success() {
171            Ok(())
172        } else {
173            Err(anyhow::anyhow!("Ollama endpoint not accessible"))
174        }
175    }
176
177    async fn test_openai_endpoint(&self, endpoint: &str) -> Result<()> {
178        let normalized_endpoint = endpoint.to_lowercase();
179        let url = if normalized_endpoint.starts_with("http") {
180            format!("{}/v1/models", normalized_endpoint)
181        } else {
182            format!("http://{}/v1/models", normalized_endpoint)
183        };
184
185        let response = self.client.get(&url).send().await?;
186        if response.status().is_success() {
187            Ok(())
188        } else {
189            Err(anyhow::anyhow!("OpenAI endpoint not accessible"))
190        }
191    }
192
193    pub async fn fetch_local_models(&self, endpoint: &str) -> Result<Vec<LocalModel>> {
194        let provider = self.detect_provider_type(endpoint).await;
195
196        match provider {
197            LocalProvider::Ollama => {
198                let ollama_models = self.fetch_ollama_models(endpoint).await?;
199                let local_models = ollama_models
200                    .into_iter()
201                    .map(|model| LocalModel {
202                        name: model.name.clone(),
203                        id: model.name,
204                        provider: LocalProvider::Ollama,
205                        size: model.size,
206                        modified: model.modified,
207                    })
208                    .collect();
209                Ok(local_models)
210            }
211            LocalProvider::LMStudio | LocalProvider::OpenAI => {
212                self.fetch_openai_models(endpoint).await
213            }
214        }
215    }
216
217    pub async fn fetch_ollama_models(&self, endpoint: &str) -> Result<Vec<OllamaModel>> {
218        let url = if endpoint.starts_with("http") {
219            format!("{}/api/tags", endpoint)
220        } else {
221            format!("http://{}/api/tags", endpoint)
222        };
223
224        let response = self.client.get(&url).send().await?;
225
226        if !response.status().is_success() {
227            return Err(anyhow::anyhow!("Ollama endpoint not accessible"));
228        }
229
230        let ollama_response: OllamaListResponse = response.json().await?;
231
232        let models = ollama_response
233            .models
234            .into_iter()
235            .map(|raw| OllamaModel {
236                name: raw.name,
237                size: format_size(raw.size),
238                modified: format_relative_time(&raw.modified_at),
239            })
240            .collect();
241
242        Ok(models)
243    }
244
245    pub async fn fetch_openrouter_models(&self, api_key: &str) -> Result<Vec<OpenRouterModel>> {
246        let url = "https://openrouter.ai/api/v1/models";
247
248        let response = self
249            .client
250            .get(url)
251            .header("Authorization", format!("Bearer {}", api_key))
252            .send()
253            .await?;
254
255        if !response.status().is_success() {
256            return Err(anyhow::anyhow!(
257                "OpenRouter API not accessible or invalid API key"
258            ));
259        }
260
261        let openrouter_response: OpenRouterListResponse = response.json().await?;
262
263        let mut models: Vec<OpenRouterModel> = openrouter_response
264            .data
265            .into_iter()
266            .map(|raw| OpenRouterModel {
267                id: raw.id,
268                name: raw.name,
269                description: raw
270                    .description
271                    .unwrap_or_else(|| "No description available".to_string()),
272                pricing: ModelPricing {
273                    prompt: raw.pricing.prompt,
274                    completion: raw.pricing.completion,
275                },
276                context_length: raw.context_length,
277            })
278            .collect();
279
280        // Sort models: free models first, then paid models
281        models.sort_by(|a, b| {
282            let a_is_free = a.pricing.prompt == "0" && a.pricing.completion == "0";
283            let b_is_free = b.pricing.prompt == "0" && b.pricing.completion == "0";
284
285            match (a_is_free, b_is_free) {
286                (true, false) => std::cmp::Ordering::Less, // Free comes first
287                (false, true) => std::cmp::Ordering::Greater, // Paid comes after
288                _ => a.name.cmp(&b.name),                  // Same type, sort by name
289            }
290        });
291
292        Ok(models)
293    }
294
295    pub async fn fetch_openai_models(&self, endpoint: &str) -> Result<Vec<LocalModel>> {
296        let url = if endpoint.starts_with("http") {
297            format!("{}/v1/models", endpoint)
298        } else {
299            format!("http://{}/v1/models", endpoint)
300        };
301
302        let response = self.client.get(&url).send().await?;
303
304        if !response.status().is_success() {
305            return Err(anyhow::anyhow!("OpenAI/LM Studio endpoint not accessible"));
306        }
307
308        let openai_response: OpenAIListResponse = response.json().await?;
309
310        let provider = if endpoint.contains("1234") {
311            LocalProvider::LMStudio
312        } else {
313            LocalProvider::OpenAI
314        };
315
316        let models = openai_response
317            .data
318            .into_iter()
319            .map(|raw| LocalModel {
320                name: raw.name.unwrap_or_else(|| raw.id.clone()),
321                id: raw.id,
322                provider: provider.clone(),
323                size: "Unknown".to_string(),
324                modified: "recently".to_string(),
325            })
326            .collect();
327
328        Ok(models)
329    }
330
331    pub async fn validate_local_endpoint(&self, endpoint: &str, model: &str) -> Result<()> {
332        let provider = self.detect_provider_type(endpoint).await;
333
334        match provider {
335            LocalProvider::Ollama => {
336                let url = if endpoint.starts_with("http") {
337                    format!("{}/api/tags", endpoint)
338                } else {
339                    format!("http://{}/api/tags", endpoint)
340                };
341
342                let response = self.client.get(&url).send().await?;
343                if !response.status().is_success() {
344                    return Err(anyhow::anyhow!("Local endpoint not accessible"));
345                }
346
347                let models: Value = response.json().await?;
348                if let Some(models_array) = models.get("models").and_then(|m| m.as_array()) {
349                    let model_exists = models_array.iter().any(|m| {
350                        m.get("name")
351                            .and_then(|name| name.as_str())
352                            .map(|name| name == model)
353                            .unwrap_or(false)
354                    });
355
356                    if model_exists {
357                        Ok(())
358                    } else {
359                        Err(anyhow::anyhow!(
360                            "Model '{}' not found on local endpoint",
361                            model
362                        ))
363                    }
364                } else {
365                    Err(anyhow::anyhow!(
366                        "Invalid response format from local endpoint"
367                    ))
368                }
369            }
370            LocalProvider::LMStudio | LocalProvider::OpenAI => {
371                let url = if endpoint.starts_with("http") {
372                    format!("{}/v1/models", endpoint)
373                } else {
374                    format!("http://{}/v1/models", endpoint)
375                };
376
377                let response = self.client.get(&url).send().await?;
378                if !response.status().is_success() {
379                    return Err(anyhow::anyhow!("Local endpoint not accessible"));
380                }
381
382                let models: Value = response.json().await?;
383                if let Some(models_array) = models.get("data").and_then(|m| m.as_array()) {
384                    let model_exists = models_array.iter().any(|m| {
385                        m.get("id")
386                            .and_then(|id| id.as_str())
387                            .map(|id| id == model)
388                            .unwrap_or(false)
389                    });
390
391                    if model_exists {
392                        Ok(())
393                    } else {
394                        Err(anyhow::anyhow!(
395                            "Model '{}' not found on local endpoint",
396                            model
397                        ))
398                    }
399                } else {
400                    Err(anyhow::anyhow!(
401                        "Invalid response format from local endpoint"
402                    ))
403                }
404            }
405        }
406    }
407
408    pub async fn validate_cloud_endpoint(&self, api_key: &str, model: &str) -> Result<()> {
409        let url = "https://openrouter.ai/api/v1/models";
410
411        let response = self
412            .client
413            .get(url)
414            .header("Authorization", format!("Bearer {}", api_key))
415            .send()
416            .await?;
417
418        if !response.status().is_success() {
419            return Err(anyhow::anyhow!(
420                "Cloud API key invalid or endpoint not accessible"
421            ));
422        }
423
424        let models: Value = response.json().await?;
425
426        if let Some(models_array) = models.get("data").and_then(|m| m.as_array()) {
427            let model_exists = models_array.iter().any(|m| {
428                m.get("id")
429                    .and_then(|id| id.as_str())
430                    .map(|id| id == model)
431                    .unwrap_or(false)
432            });
433
434            if model_exists {
435                Ok(())
436            } else {
437                Err(anyhow::anyhow!("Model '{}' not found in OpenRouter", model))
438            }
439        } else {
440            Err(anyhow::anyhow!("Invalid response format from OpenRouter"))
441        }
442    }
443
444    pub async fn test_local_generation(&self, endpoint: &str, model: &str) -> Result<()> {
445        let url = if endpoint.starts_with("http") {
446            format!("{}/api/generate", endpoint)
447        } else {
448            format!("http://{}/api/generate", endpoint)
449        };
450
451        let payload = serde_json::json!({
452            "model": model,
453            "prompt": "Hello",
454            "stream": false,
455            "options": {
456                "num_predict": 1
457            }
458        });
459
460        let response = self.client.post(&url).json(&payload).send().await?;
461
462        if response.status().is_success() {
463            Ok(())
464        } else {
465            Err(anyhow::anyhow!(
466                "Failed to generate response from local model"
467            ))
468        }
469    }
470
471    pub async fn test_cloud_generation(&self, api_key: &str, model: &str) -> Result<()> {
472        let url = "https://openrouter.ai/api/v1/chat/completions";
473
474        let payload = serde_json::json!({
475            "model": model,
476            "messages": [{"role": "user", "content": "Hello"}],
477            "max_tokens": 1
478        });
479
480        let response = self
481            .client
482            .post(url)
483            .header("Authorization", format!("Bearer {}", api_key))
484            .header("Content-Type", "application/json")
485            .json(&payload)
486            .send()
487            .await?;
488
489        if response.status().is_success() {
490            Ok(())
491        } else {
492            Err(anyhow::anyhow!(
493                "Failed to generate response from cloud model"
494            ))
495        }
496    }
497}
498
499#[derive(Serialize)]
500struct LocalGenerationRequest<'a> {
501    model: &'a str,
502    prompt: &'a str,
503    stream: bool,
504}
505
506#[derive(Deserialize)]
507struct LocalGenerationResponse {
508    response: String,
509}
510
511pub async fn call_local_model(
512    endpoint: &str,
513    model: &str,
514    prompt: &str,
515) -> Result<String, anyhow::Error> {
516    let validator = ModelValidator::new();
517    let provider = validator.detect_provider_type(endpoint).await;
518
519    match provider {
520        LocalProvider::Ollama => call_ollama_model(endpoint, model, prompt).await,
521        LocalProvider::LMStudio | LocalProvider::OpenAI => {
522            call_openai_model(endpoint, model, prompt).await
523        }
524    }
525}
526
527pub async fn call_ollama_model(
528    endpoint: &str,
529    model: &str,
530    prompt: &str,
531) -> Result<String, anyhow::Error> {
532    let client = Client::new();
533    let url = if endpoint.starts_with("http") {
534        format!("{}/api/generate", endpoint)
535    } else {
536        format!("http://{}/api/generate", endpoint)
537    };
538
539    let payload = LocalGenerationRequest {
540        model,
541        prompt,
542        stream: false,
543    };
544
545    let response = client.post(&url).json(&payload).send().await?;
546
547    if response.status().is_success() {
548        let gen_response: LocalGenerationResponse = response.json().await?;
549        Ok(gen_response.response)
550    } else {
551        Err(anyhow::anyhow!(
552            "Failed to get response from local model. Status: {}",
553            response.status()
554        ))
555    }
556}
557
558#[derive(Serialize)]
559struct OpenAIGenerationRequest<'a> {
560    model: &'a str,
561    messages: Vec<serde_json::Value>,
562    max_tokens: u32,
563    temperature: f32,
564}
565
566#[derive(Deserialize)]
567struct OpenAIGenerationResponse {
568    choices: Vec<OpenAIChoice>,
569}
570
571#[derive(Deserialize)]
572struct OpenAIChoice {
573    message: OpenAIMessage,
574}
575
576#[derive(Deserialize)]
577struct OpenAIMessage {
578    content: String,
579}
580
581pub async fn call_openai_model(
582    endpoint: &str,
583    model: &str,
584    prompt: &str,
585) -> Result<String, anyhow::Error> {
586    let client = Client::new();
587    let url = if endpoint.starts_with("http") {
588        format!("{}/v1/chat/completions", endpoint)
589    } else {
590        format!("http://{}/v1/chat/completions", endpoint)
591    };
592
593    let payload = OpenAIGenerationRequest {
594        model,
595        messages: vec![serde_json::json!({
596            "role": "user",
597            "content": prompt
598        })],
599        max_tokens: 2000,
600        temperature: 0.7,
601    };
602
603    let response = client.post(&url).json(&payload).send().await?;
604
605    if response.status().is_success() {
606        let gen_response: OpenAIGenerationResponse = response.json().await?;
607        if let Some(choice) = gen_response.choices.first() {
608            Ok(choice.message.content.clone())
609        } else {
610            Err(anyhow::anyhow!("No response choices from OpenAI model"))
611        }
612    } else {
613        let status = response.status();
614        let error_text = response
615            .text()
616            .await
617            .unwrap_or_else(|_| "Unknown error".to_string());
618        Err(anyhow::anyhow!(
619            "Failed to get response from OpenAI model. Status: {}. Error: {}",
620            status,
621            error_text
622        ))
623    }
624}
625
626impl Default for ModelValidator {
627    fn default() -> Self {
628        Self::new()
629    }
630}
631
632fn format_size(bytes: i64) -> String {
633    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
634    let mut size = bytes as f64;
635    let mut unit_index = 0;
636
637    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
638        size /= 1024.0;
639        unit_index += 1;
640    }
641
642    if unit_index == 0 {
643        format!("{} {}", size as i64, UNITS[unit_index])
644    } else {
645        format!("{:.1} {}", size, UNITS[unit_index])
646    }
647}
648
649fn format_relative_time(_iso_time: &str) -> String {
650    // For now, just return a simple format
651    // Parse ISO time and return relative time
652    "recently".to_string()
653}