use std::collections::HashMap; use std::time::Duration; use bytes::Bytes; use futures_util::StreamExt; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use reqwest::Method; use serde::{Deserialize, Serialize}; use tauri::ipc::Channel; #[tauri::command] pub async fn lm_ping(base_url: String) -> Result { let trimmed = base_url.trim().trim_end_matches('/'); if trimmed.is_empty() { return Err("empty base url".into()); } let probe = format!("{trimmed}/models"); let parsed = reqwest::Url::parse(&probe).map_err(|e| e.to_string())?; match parsed.scheme() { "http" | "https" => {} s => return Err(format!("scheme not allowed: {s}")), } let host = parsed.host_str().ok_or_else(|| "missing host".to_string())?; if is_blocked_host(host) { return Err(format!("host not allowed: {host}")); } let client = reqwest::Client::builder() .timeout(Duration::from_secs(5)) .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|e| e.to_string())?; client .get(parsed) .send() .await .map(|r| r.status().as_u16()) .map_err(|e| e.to_string()) } fn is_blocked_host(host: &str) -> bool { matches!( host, "169.254.169.254" | "fd00:ec2::254" | "metadata.google.internal" | "metadata.azure.com" ) } // AI HTTP proxy — bypasses webview CORS / Mixed-Content / PNA so local-network // model servers (LM Studio, Ollama, vLLM) work in the production bundle. #[derive(Debug, Serialize)] pub struct HttpResponse { pub status: u16, pub headers: HashMap, pub body: Vec, } fn build_request( client: &reqwest::Client, method: &str, url: &str, headers: Option>, body: Option>, ) -> Result { let method = Method::from_bytes(method.as_bytes()).map_err(|e| e.to_string())?; let mut req = client.request(method, url); if let Some(h) = headers { let mut map = HeaderMap::new(); for (k, v) in h { let name = HeaderName::from_bytes(k.as_bytes()).map_err(|e| e.to_string())?; let value = HeaderValue::from_str(&v).map_err(|e| e.to_string())?; map.insert(name, value); } req = req.headers(map); } if let Some(b) = body { req = req.body(b); } Ok(req) } fn header_map_to_strings(headers: &HeaderMap) -> HashMap { let mut out = HashMap::with_capacity(headers.len()); for (k, v) in headers { if let Ok(s) = v.to_str() { out.insert(k.as_str().to_ascii_lowercase(), s.to_string()); } } out } #[tauri::command] pub async fn ai_http_request( url: String, method: String, headers: Option>, body: Option>, ) -> Result { let client = reqwest::Client::builder() // No total timeout — generative endpoints can take minutes for long outputs. // Connect timeout protects against unreachable hosts. .connect_timeout(Duration::from_secs(10)) .build() .map_err(|e| e.to_string())?; let req = build_request(&client, &method, &url, headers, body)?; let resp = req.send().await.map_err(|e| e.to_string())?; let status = resp.status().as_u16(); let headers = header_map_to_strings(resp.headers()); let body = resp.bytes().await.map_err(|e| e.to_string())?.to_vec(); Ok(HttpResponse { status, headers, body, }) } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum AiStreamEvent { Headers { status: u16, headers: HashMap, }, Chunk { bytes: Vec, }, End, Error { message: String, }, } #[tauri::command] pub async fn ai_http_stream( url: String, method: String, headers: Option>, body: Option>, on_event: Channel, ) -> Result<(), String> { let client = reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) .build() .map_err(|e| e.to_string())?; let req = build_request(&client, &method, &url, headers, body)?; let resp = match req.send().await { Ok(r) => r, Err(e) => { let _ = on_event.send(AiStreamEvent::Error { message: e.to_string(), }); return Err(e.to_string()); } }; let status = resp.status().as_u16(); let headers = header_map_to_strings(resp.headers()); let _ = on_event.send(AiStreamEvent::Headers { status, headers }); let mut stream = resp.bytes_stream(); while let Some(item) = stream.next().await { match item { Ok(chunk) => { let bytes: Bytes = chunk; if on_event .send(AiStreamEvent::Chunk { bytes: bytes.to_vec(), }) .is_err() { // Channel dropped (frontend aborted) — stop streaming. return Ok(()); } } Err(e) => { let _ = on_event.send(AiStreamEvent::Error { message: e.to_string(), }); return Err(e.to_string()); } } } let _ = on_event.send(AiStreamEvent::End); Ok(()) }