agentic_core/
models.rs

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