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 if endpoint.to_lowercase().contains("1234")
114 && self.test_openai_endpoint(endpoint).await.is_ok()
115 {
116 return LocalProvider::LMStudio;
117 }
118
119 if self.test_ollama_endpoint(endpoint).await.is_ok() {
121 return LocalProvider::Ollama;
122 }
123
124 if self.test_openai_endpoint(endpoint).await.is_ok() {
126 return LocalProvider::OpenAI;
127 }
128
129 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 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, (false, true) => std::cmp::Ordering::Greater, _ => a.name.cmp(&b.name), }
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 "recently".to_string()
624}