lc/
webchatproxy.rs

1use anyhow::Result;
2use axum::{
3    extract::State,
4    http::{HeaderMap, StatusCode},
5    response::Json,
6    routing::{get, post},
7    Router,
8};
9use colored::Colorize;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::fs;
13use std::path::PathBuf;
14use std::process::{Command, Stdio};
15use std::sync::Arc;
16use tower_http::cors::CorsLayer;
17use uuid::Uuid;
18
19// Configuration for webchatproxy
20#[derive(Debug, Serialize, Deserialize, Clone)]
21pub struct WebChatProxyConfig {
22    pub providers: HashMap<String, WebChatProxyProviderConfig>,
23}
24
25#[derive(Debug, Serialize, Deserialize, Clone)]
26pub struct WebChatProxyProviderConfig {
27    pub auth_token: Option<String>,
28}
29
30impl WebChatProxyConfig {
31    pub fn load() -> Result<Self> {
32        let config_path = Self::config_file_path()?;
33
34        if config_path.exists() {
35            let content = fs::read_to_string(&config_path)?;
36            let config: WebChatProxyConfig = toml::from_str(&content)?;
37            Ok(config)
38        } else {
39            // Create default config
40            let config = WebChatProxyConfig {
41                providers: HashMap::new(),
42            };
43
44            // Ensure config directory exists
45            if let Some(parent) = config_path.parent() {
46                fs::create_dir_all(parent)?;
47            }
48
49            config.save()?;
50            Ok(config)
51        }
52    }
53
54    pub fn save(&self) -> Result<()> {
55        let config_path = Self::config_file_path()?;
56        let content = toml::to_string_pretty(self)?;
57        fs::write(&config_path, content)?;
58        Ok(())
59    }
60
61    pub fn set_provider_auth(&mut self, provider: &str, auth_token: &str) -> Result<()> {
62        let provider_config = WebChatProxyProviderConfig {
63            auth_token: Some(auth_token.to_string()),
64        };
65        self.providers.insert(provider.to_string(), provider_config);
66        Ok(())
67    }
68
69    pub fn get_provider_auth(&self, provider: &str) -> Option<&String> {
70        self.providers.get(provider)?.auth_token.as_ref()
71    }
72
73    fn config_file_path() -> Result<PathBuf> {
74        let config_dir =
75            dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
76
77        Ok(config_dir.join("lc").join("webchatproxy.toml"))
78    }
79}
80
81// Server state
82#[derive(Clone)]
83pub struct WebChatProxyState {
84    pub provider: String,
85    pub api_key: Option<String>,
86    pub config: WebChatProxyConfig,
87}
88
89// OpenAI-compatible request/response structures
90#[derive(Deserialize)]
91pub struct ChatCompletionRequest {
92    pub model: String,
93    pub messages: Vec<ChatMessage>,
94    #[allow(dead_code)]
95    pub max_tokens: Option<u32>,
96    #[allow(dead_code)]
97    pub temperature: Option<f32>,
98    #[allow(dead_code)]
99    pub stream: Option<bool>,
100}
101
102#[derive(Deserialize, Serialize, Clone)]
103pub struct ChatMessage {
104    pub role: String,
105    pub content: String,
106}
107
108#[derive(Serialize)]
109pub struct ChatCompletionResponse {
110    pub id: String,
111    pub object: String,
112    pub created: u64,
113    pub model: String,
114    pub choices: Vec<ChatChoice>,
115    pub usage: ChatUsage,
116}
117
118#[derive(Serialize)]
119pub struct ChatChoice {
120    pub index: u32,
121    pub message: ChatMessage,
122    pub finish_reason: String,
123}
124
125#[derive(Serialize)]
126pub struct ChatUsage {
127    pub prompt_tokens: u32,
128    pub completion_tokens: u32,
129    pub total_tokens: u32,
130}
131
132// OpenAI-compatible models response structures
133#[derive(Serialize)]
134pub struct ModelsListResponse {
135    pub object: String,
136    pub data: Vec<ModelInfo>,
137}
138
139#[derive(Serialize)]
140pub struct ModelInfo {
141    pub id: String,
142    pub object: String,
143    pub created: u64,
144    pub owned_by: String,
145}
146
147// Kagi-specific structures
148#[derive(Serialize)]
149pub struct KagiRequest {
150    pub focus: KagiFocus,
151    pub profile: KagiProfile,
152}
153
154#[derive(Serialize)]
155pub struct KagiFocus {
156    pub thread_id: Option<String>,
157    pub branch_id: String,
158    pub prompt: String,
159}
160
161#[derive(Serialize)]
162pub struct KagiProfile {
163    pub id: Option<String>,
164    pub personalizations: bool,
165    pub internet_access: bool,
166    pub model: String,
167    pub lens_id: Option<String>,
168}
169
170// Kagi models structures
171#[derive(Serialize, Deserialize, Debug, Clone)]
172pub struct KagiModelsResponse {
173    pub profiles: Vec<KagiModelProfile>,
174}
175
176#[derive(Serialize, Deserialize, Debug, Clone)]
177pub struct KagiModelProfile {
178    pub id: Option<String>,
179    pub name: String,
180    pub model: String,
181    pub model_name: String,
182    pub model_provider: String,
183    pub model_input_limit: Option<u32>,
184    pub scorecard: KagiScorecard,
185    pub model_provider_name: String,
186    pub internet_access: bool,
187    pub personalizations: bool,
188    pub shortcut: String,
189    pub is_default_profile: bool,
190}
191
192#[derive(Serialize, Deserialize, Debug, Clone)]
193pub struct KagiScorecard {
194    pub speed: f32,
195    pub accuracy: f32,
196    pub cost: f32,
197    pub context_window: f32,
198    pub privacy: f32,
199    pub description: Option<String>,
200    pub recommended: bool,
201}
202
203// Daemon management structures
204#[derive(Debug, Serialize, Deserialize, Clone)]
205pub struct DaemonInfo {
206    pub pid: u32,
207    pub host: String,
208    pub port: u16,
209    pub provider: String,
210    pub started_at: chrono::DateTime<chrono::Utc>,
211}
212
213#[derive(Debug, Serialize, Deserialize, Clone)]
214pub struct DaemonRegistry {
215    pub daemons: HashMap<String, DaemonInfo>,
216}
217
218impl DaemonRegistry {
219    pub fn load() -> Result<Self> {
220        let registry_path = Self::registry_file_path()?;
221
222        if registry_path.exists() {
223            let content = fs::read_to_string(&registry_path)?;
224            let registry: DaemonRegistry = toml::from_str(&content)?;
225            Ok(registry)
226        } else {
227            Ok(DaemonRegistry {
228                daemons: HashMap::new(),
229            })
230        }
231    }
232
233    pub fn save(&self) -> Result<()> {
234        let registry_path = Self::registry_file_path()?;
235
236        // Ensure directory exists
237        if let Some(parent) = registry_path.parent() {
238            fs::create_dir_all(parent)?;
239        }
240
241        let content = toml::to_string_pretty(self)?;
242        fs::write(&registry_path, content)?;
243        Ok(())
244    }
245
246    pub fn add_daemon(&mut self, provider: String, info: DaemonInfo) {
247        self.daemons.insert(provider, info);
248    }
249
250    pub fn remove_daemon(&mut self, provider: &str) -> Option<DaemonInfo> {
251        self.daemons.remove(provider)
252    }
253
254    #[allow(dead_code)]
255    pub fn get_daemon(&self, provider: &str) -> Option<&DaemonInfo> {
256        self.daemons.get(provider)
257    }
258
259    pub fn list_daemons(&self) -> &HashMap<String, DaemonInfo> {
260        &self.daemons
261    }
262
263    fn registry_file_path() -> Result<PathBuf> {
264        let config_dir =
265            dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
266
267        Ok(config_dir.join("lc").join("webchatproxy_daemons.toml"))
268    }
269}
270
271// Start the webchatproxy server
272pub async fn start_webchatproxy_server(
273    host: String,
274    port: u16,
275    provider: String,
276    api_key: Option<String>,
277) -> Result<()> {
278    let config = WebChatProxyConfig::load()?;
279
280    let state = WebChatProxyState {
281        provider: provider.clone(),
282        api_key,
283        config,
284    };
285
286    let app = Router::new()
287        .route("/chat/completions", post(chat_completions))
288        .route("/v1/chat/completions", post(chat_completions))
289        .route("/models", get(list_models))
290        .route("/v1/models", get(list_models))
291        .layer(CorsLayer::permissive())
292        .with_state(Arc::new(state));
293
294    let addr = format!("{}:{}", host, port);
295    println!(
296        "{} Starting webchatproxy server on {}",
297        "🚀".blue(),
298        addr.bold()
299    );
300
301    let listener = tokio::net::TcpListener::bind(&addr).await?;
302    println!("{} Server listening on http://{}", "✓".green(), addr);
303
304    axum::serve(listener, app).await?;
305
306    Ok(())
307}
308
309// Authentication middleware
310async fn authenticate(headers: &HeaderMap, state: &WebChatProxyState) -> Result<(), StatusCode> {
311    if let Some(expected_key) = &state.api_key {
312        if let Some(auth_header) = headers.get("authorization") {
313            if let Ok(auth_str) = auth_header.to_str() {
314                if let Some(token) = auth_str.strip_prefix("Bearer ") {
315                    if token == expected_key {
316                        return Ok(());
317                    }
318                }
319            }
320        }
321        return Err(StatusCode::UNAUTHORIZED);
322    }
323    Ok(())
324}
325
326// Main chat completions endpoint
327async fn chat_completions(
328    State(state): State<Arc<WebChatProxyState>>,
329    headers: HeaderMap,
330    Json(request): Json<ChatCompletionRequest>,
331) -> Result<Json<ChatCompletionResponse>, StatusCode> {
332    println!(
333        "🔄 Received chat completion request for provider: {}",
334        state.provider
335    );
336
337    // Authenticate if API key is configured
338    if let Err(e) = authenticate(&headers, &state).await {
339        println!("❌ Authentication failed");
340        return Err(e);
341    }
342
343    match state.provider.as_str() {
344        "kagi" => handle_kagi_request(&state, request).await,
345        _ => {
346            println!("❌ Unsupported provider: {}", state.provider);
347            Err(StatusCode::BAD_REQUEST)
348        }
349    }
350}
351
352// List models endpoint
353async fn list_models(
354    State(state): State<Arc<WebChatProxyState>>,
355    headers: HeaderMap,
356) -> Result<Json<ModelsListResponse>, StatusCode> {
357    println!(
358        "🔄 Received models list request for provider: {}",
359        state.provider
360    );
361
362    // Authenticate if API key is configured
363    if let Err(e) = authenticate(&headers, &state).await {
364        println!("❌ Authentication failed");
365        return Err(e);
366    }
367
368    match state.provider.as_str() {
369        "kagi" => handle_kagi_models_request(&state).await,
370        _ => {
371            println!("❌ Unsupported provider: {}", state.provider);
372            Err(StatusCode::BAD_REQUEST)
373        }
374    }
375}
376
377// Handle Kagi models list request
378async fn handle_kagi_models_request(
379    _state: &WebChatProxyState,
380) -> Result<Json<ModelsListResponse>, StatusCode> {
381    match fetch_kagi_models().await {
382        Ok(kagi_models) => {
383            let current_time = std::time::SystemTime::now()
384                .duration_since(std::time::UNIX_EPOCH)
385                .unwrap()
386                .as_secs();
387
388            let models: Vec<ModelInfo> = kagi_models
389                .into_iter()
390                .map(|model| ModelInfo {
391                    id: model.model.clone(),
392                    object: "model".to_string(),
393                    created: current_time,
394                    owned_by: model.model_provider_name.clone(),
395                })
396                .collect();
397
398            let response = ModelsListResponse {
399                object: "list".to_string(),
400                data: models,
401            };
402
403            println!(
404                "✅ Successfully fetched {} Kagi models",
405                response.data.len()
406            );
407            Ok(Json(response))
408        }
409        Err(e) => {
410            println!("❌ Failed to fetch Kagi models: {}", e);
411            Err(StatusCode::INTERNAL_SERVER_ERROR)
412        }
413    }
414}
415
416// Handle Kagi-specific requests
417async fn handle_kagi_request(
418    state: &WebChatProxyState,
419    request: ChatCompletionRequest,
420) -> Result<Json<ChatCompletionResponse>, StatusCode> {
421    // Get Kagi auth token
422    let auth_token = state
423        .config
424        .get_provider_auth("kagi")
425        .ok_or(StatusCode::UNAUTHORIZED)?;
426
427    // Extract the user message (last message with role "user")
428    let user_message = request
429        .messages
430        .iter()
431        .rev()
432        .find(|msg| msg.role == "user")
433        .ok_or(StatusCode::BAD_REQUEST)?;
434
435    // Create Kagi request
436    let kagi_request = KagiRequest {
437        focus: KagiFocus {
438            thread_id: None,
439            branch_id: "00000000-0000-4000-0000-000000000000".to_string(),
440            prompt: user_message.content.clone(),
441        },
442        profile: KagiProfile {
443            id: None,
444            personalizations: false,
445            internet_access: true,
446            model: request.model.clone(),
447            lens_id: None,
448        },
449    };
450
451    // Make request to Kagi using optimized client with connection pooling
452    let client = reqwest::Client::builder()
453        .pool_max_idle_per_host(10)
454        .pool_idle_timeout(std::time::Duration::from_secs(90))
455        .tcp_keepalive(std::time::Duration::from_secs(60))
456        .timeout(std::time::Duration::from_secs(60))
457        .connect_timeout(std::time::Duration::from_secs(10))
458        .build()
459        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
460    let response = client
461        .post("https://kagi.com/assistant/prompt")
462        .header("Content-Type", "application/json")
463        .header("x-kagi-authorization", auth_token)
464        .json(&kagi_request)
465        .send()
466        .await
467        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
468
469    if !response.status().is_success() {
470        return Err(StatusCode::INTERNAL_SERVER_ERROR);
471    }
472
473    let response_text = response
474        .text()
475        .await
476        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
477
478    // Parse Kagi response
479    let assistant_response =
480        parse_kagi_response(&response_text).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
481
482    // Create OpenAI-compatible response
483    let current_time = std::time::SystemTime::now()
484        .duration_since(std::time::UNIX_EPOCH)
485        .unwrap()
486        .as_secs();
487
488    let openai_response = ChatCompletionResponse {
489        id: format!("chatcmpl-{}", Uuid::new_v4()),
490        object: "chat.completion".to_string(),
491        created: current_time,
492        model: request.model,
493        choices: vec![ChatChoice {
494            index: 0,
495            message: ChatMessage {
496                role: "assistant".to_string(),
497                content: assistant_response,
498            },
499            finish_reason: "stop".to_string(),
500        }],
501        usage: ChatUsage {
502            prompt_tokens: 0, // Kagi doesn't provide token counts
503            completion_tokens: 0,
504            total_tokens: 0,
505        },
506    };
507
508    println!("✅ Successfully processed Kagi request");
509    Ok(Json(openai_response))
510}
511
512// Parse Kagi's HTML response to extract the assistant's message
513fn parse_kagi_response(html: &str) -> Result<String> {
514    let lines: Vec<&str> = html.lines().collect();
515
516    // Look for any <div hidden> tags that contain JSON with message content
517    for line in lines.iter() {
518        if line.contains("<div hidden>") && line.contains("{") {
519            // Extract content between <div hidden> and </div>
520            if let Some(start) = line.find("<div hidden>") {
521                let content_start = start + 12; // Length of '<div hidden>'
522                if let Some(end) = line[content_start..].find("</div>") {
523                    let json_content = &line[content_start..content_start + end];
524
525                    // Decode HTML entities
526                    let decoded_json = json_content
527                        .replace("&quot;", "\"")
528                        .replace("&lt;", "<")
529                        .replace("&gt;", ">")
530                        .replace("&amp;", "&")
531                        .replace("&#39;", "'");
532
533                    if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&decoded_json) {
534                        // Check if this has state "done" - this is the final response
535                        if let Some(state) = parsed.get("state").and_then(|v| v.as_str()) {
536                            if state == "done" {
537                                // First try to get the markdown content
538                                if let Some(md_content) = parsed.get("md").and_then(|v| v.as_str())
539                                {
540                                    if !md_content.trim().is_empty() {
541                                        return Ok(md_content.to_string());
542                                    }
543                                }
544
545                                // Fallback to reply content (HTML)
546                                if let Some(reply_content) =
547                                    parsed.get("reply").and_then(|v| v.as_str())
548                                {
549                                    if !reply_content.trim().is_empty() {
550                                        let stripped = strip_html_tags(reply_content);
551                                        return Ok(stripped);
552                                    }
553                                }
554                            }
555                        }
556
557                        // Also check for any JSON that has "md" or "reply" fields with substantial content
558                        if let Some(md_content) = parsed.get("md").and_then(|v| v.as_str()) {
559                            if !md_content.trim().is_empty() && md_content.len() > 10 {
560                                return Ok(md_content.to_string());
561                            }
562                        }
563
564                        if let Some(reply_content) = parsed.get("reply").and_then(|v| v.as_str()) {
565                            if !reply_content.trim().is_empty() && reply_content.len() > 10 {
566                                let stripped = strip_html_tags(reply_content);
567                                return Ok(stripped);
568                            }
569                        }
570                    }
571                }
572            }
573        }
574    }
575
576    anyhow::bail!("Could not parse Kagi response - no meaningful content found")
577}
578
579// Simple HTML tag stripper
580fn strip_html_tags(html: &str) -> String {
581    let mut result = String::new();
582    let mut in_tag = false;
583
584    for ch in html.chars() {
585        match ch {
586            '<' => in_tag = true,
587            '>' => in_tag = false,
588            _ if !in_tag => result.push(ch),
589            _ => {}
590        }
591    }
592
593    // Decode common HTML entities
594    result
595        .replace("&lt;", "<")
596        .replace("&gt;", ">")
597        .replace("&amp;", "&")
598        .replace("&quot;", "\"")
599        .replace("&#x27;", "'")
600}
601// Daemon management functions
602pub async fn start_webchatproxy_daemon(
603    host: String,
604    port: u16,
605    provider: String,
606    api_key: Option<String>,
607) -> Result<()> {
608    use std::env;
609    use std::fs::OpenOptions;
610
611    // Get the current executable path
612    let current_exe = env::current_exe()?;
613
614    // Create log directory
615    let log_dir = dirs::config_dir()
616        .ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?
617        .join("lc");
618    fs::create_dir_all(&log_dir)?;
619
620    let log_file = log_dir.join(format!("{}.log", provider));
621
622    // Build command arguments - remove the --daemon flag to prevent infinite recursion
623    let mut args = vec![
624        "w".to_string(),
625        "start".to_string(),
626        provider.clone(),
627        "--port".to_string(),
628        port.to_string(),
629        "--host".to_string(),
630        host.clone(),
631    ];
632
633    if let Some(ref key) = api_key {
634        args.push("--key".to_string());
635        args.push(key.clone());
636    }
637
638    // Create log file handles
639    let log_file_handle = OpenOptions::new()
640        .create(true)
641        .append(true)
642        .open(&log_file)?;
643
644    // Start the daemon process with proper detachment
645    let child = Command::new(&current_exe)
646        .args(&args)
647        .stdout(Stdio::from(log_file_handle.try_clone()?))
648        .stderr(Stdio::from(log_file_handle))
649        .stdin(Stdio::null())
650        .spawn()?;
651
652    let pid = child.id();
653
654    // Give the process a moment to start
655    tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
656
657    // Check if the process is still running
658    #[cfg(unix)]
659    {
660        use nix::sys::signal;
661        use nix::unistd::Pid;
662
663        let process_pid = Pid::from_raw(pid as i32);
664        match signal::kill(process_pid, None) {
665            Ok(_) => {
666                // Process is running, register it
667                let mut registry = DaemonRegistry::load()?;
668                let daemon_info = DaemonInfo {
669                    pid,
670                    host: host.clone(),
671                    port,
672                    provider: provider.clone(),
673                    started_at: chrono::Utc::now(),
674                };
675
676                registry.add_daemon(provider.clone(), daemon_info);
677                registry.save()?;
678
679                println!(
680                    "{} WebChatProxy daemon started for '{}' (PID: {})",
681                    "✓".green(),
682                    provider,
683                    pid
684                );
685                println!("{} Server running on {}:{}", "🚀".blue(), host, port);
686                println!("{} Logs: {}", "📝".blue(), log_file.display());
687
688                Ok(())
689            }
690            Err(_) => {
691                anyhow::bail!("Failed to start daemon process - process died immediately");
692            }
693        }
694    }
695
696    #[cfg(not(unix))]
697    {
698        // On non-Unix systems, assume the process started successfully
699        let mut registry = DaemonRegistry::load()?;
700        let daemon_info = DaemonInfo {
701            pid,
702            host: host.clone(),
703            port,
704            provider: provider.clone(),
705            started_at: chrono::Utc::now(),
706        };
707
708        registry.add_daemon(provider.clone(), daemon_info);
709        registry.save()?;
710
711        println!(
712            "{} WebChatProxy daemon started for '{}' (PID: {})",
713            "✓".green(),
714            provider,
715            pid
716        );
717        println!("{} Server running on {}:{}", "🚀".blue(), host, port);
718        println!("{} Logs: {}", "📝".blue(), log_file.display());
719
720        Ok(())
721    }
722}
723
724pub async fn stop_webchatproxy_daemon(provider: &str) -> Result<()> {
725    let mut registry = DaemonRegistry::load()?;
726
727    if let Some(daemon_info) = registry.remove_daemon(provider) {
728        // Try to kill the process
729        #[cfg(unix)]
730        {
731            use nix::sys::signal::{self, Signal};
732            use nix::unistd::Pid;
733
734            let pid = Pid::from_raw(daemon_info.pid as i32);
735            match signal::kill(pid, Signal::SIGTERM) {
736                Ok(_) => {
737                    registry.save()?;
738                    Ok(())
739                }
740                Err(e) => {
741                    // Process might already be dead, remove from registry anyway
742                    registry.save()?;
743                    Err(anyhow::anyhow!(
744                        "Failed to kill process {}: {}",
745                        daemon_info.pid,
746                        e
747                    ))
748                }
749            }
750        }
751
752        #[cfg(not(unix))]
753        {
754            // On non-Unix systems, just remove from registry
755            registry.save()?;
756            Ok(())
757        }
758    } else {
759        anyhow::bail!("No running daemon found for provider '{}'", provider);
760    }
761}
762
763pub async fn list_webchatproxy_daemons() -> Result<HashMap<String, DaemonInfo>> {
764    let mut registry = DaemonRegistry::load()?;
765    let mut active_daemons = HashMap::new();
766
767    // Check which processes are still alive
768    for (provider, daemon_info) in registry.list_daemons().clone() {
769        #[cfg(unix)]
770        {
771            use nix::sys::signal;
772            use nix::unistd::Pid;
773
774            let pid = Pid::from_raw(daemon_info.pid as i32);
775            match signal::kill(pid, None) {
776                Ok(_) => {
777                    // Process is alive
778                    active_daemons.insert(provider, daemon_info);
779                }
780                Err(_) => {
781                    // Process is dead, remove from registry
782                    registry.remove_daemon(&provider);
783                }
784            }
785        }
786
787        #[cfg(not(unix))]
788        {
789            // On non-Unix systems, assume all registered daemons are active
790            active_daemons.insert(provider, daemon_info);
791        }
792    }
793
794    // Save updated registry
795    registry.save()?;
796
797    Ok(active_daemons)
798}
799
800// Function to fetch Kagi models from the profile_list endpoint
801pub async fn fetch_kagi_models() -> Result<Vec<KagiModelProfile>> {
802    let config = WebChatProxyConfig::load()?;
803
804    // Get Kagi auth token
805    let auth_token = config.get_provider_auth("kagi").ok_or_else(|| {
806        anyhow::anyhow!("No Kagi authentication token configured. Set one with 'lc w p kagi auth'")
807    })?;
808
809    // Make request to Kagi profile_list endpoint using optimized client with connection pooling
810    let client = reqwest::Client::builder()
811        .pool_max_idle_per_host(10)
812        .pool_idle_timeout(std::time::Duration::from_secs(90))
813        .tcp_keepalive(std::time::Duration::from_secs(60))
814        .timeout(std::time::Duration::from_secs(60))
815        .connect_timeout(std::time::Duration::from_secs(10))
816        .build()?;
817    let response = client
818        .post("https://kagi.com/assistant/profile_list")
819        .header("Content-Type", "application/json")
820        .header("Cookie", format!("kagi_session={}", auth_token))
821        .json(&serde_json::json!({}))
822        .send()
823        .await?;
824
825    if !response.status().is_success() {
826        anyhow::bail!("Failed to fetch Kagi models: HTTP {}", response.status());
827    }
828
829    let response_text = response.text().await?;
830
831    // Parse the HTML response to extract JSON data
832    parse_kagi_models_response(&response_text)
833}
834
835// Parse Kagi's HTML response to extract model profiles
836fn parse_kagi_models_response(html: &str) -> Result<Vec<KagiModelProfile>> {
837    let lines: Vec<&str> = html.lines().collect();
838
839    // Look for the <div hidden> tag that contains the profiles JSON
840    for line in lines.iter() {
841        if line.contains("<div hidden>") && line.contains("profiles") {
842            // Extract content between <div hidden> and </div>
843            if let Some(start) = line.find("<div hidden>") {
844                let content_start = start + 12; // Length of '<div hidden>'
845                if let Some(end) = line[content_start..].find("</div>") {
846                    let json_content = &line[content_start..content_start + end];
847
848                    // Decode HTML entities
849                    let decoded_json = json_content
850                        .replace("&quot;", "\"")
851                        .replace("&lt;", "<")
852                        .replace("&gt;", ">")
853                        .replace("&amp;", "&")
854                        .replace("&#39;", "'");
855
856                    if let Ok(parsed) = serde_json::from_str::<KagiModelsResponse>(&decoded_json) {
857                        return Ok(parsed.profiles);
858                    }
859                }
860            }
861        }
862    }
863
864    anyhow::bail!("Could not parse Kagi models response - no profiles data found")
865}