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
9static 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 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 let detected_provider = self.perform_provider_detection(endpoint).await;
131
132 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 if endpoint.to_lowercase().contains("1234")
143 && self.test_openai_endpoint(endpoint).await.is_ok()
144 {
145 return LocalProvider::LMStudio;
146 }
147
148 if self.test_ollama_endpoint(endpoint).await.is_ok() {
150 return LocalProvider::Ollama;
151 }
152
153 if self.test_openai_endpoint(endpoint).await.is_ok() {
155 return LocalProvider::OpenAI;
156 }
157
158 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 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, (false, true) => std::cmp::Ordering::Greater, _ => a.name.cmp(&b.name), }
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 "recently".to_string()
653}