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#[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 let config = WebChatProxyConfig {
41 providers: HashMap::new(),
42 };
43
44 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#[derive(Clone)]
83pub struct WebChatProxyState {
84 pub provider: String,
85 pub api_key: Option<String>,
86 pub config: WebChatProxyConfig,
87}
88
89#[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#[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#[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#[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#[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(®istry_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 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(®istry_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
271pub 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
309async 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
326async 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 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
352async 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 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
377async 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
416async fn handle_kagi_request(
418 state: &WebChatProxyState,
419 request: ChatCompletionRequest,
420) -> Result<Json<ChatCompletionResponse>, StatusCode> {
421 let auth_token = state
423 .config
424 .get_provider_auth("kagi")
425 .ok_or(StatusCode::UNAUTHORIZED)?;
426
427 let user_message = request
429 .messages
430 .iter()
431 .rev()
432 .find(|msg| msg.role == "user")
433 .ok_or(StatusCode::BAD_REQUEST)?;
434
435 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 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 let assistant_response =
480 parse_kagi_response(&response_text).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
481
482 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, completion_tokens: 0,
504 total_tokens: 0,
505 },
506 };
507
508 println!("✅ Successfully processed Kagi request");
509 Ok(Json(openai_response))
510}
511
512fn parse_kagi_response(html: &str) -> Result<String> {
514 let lines: Vec<&str> = html.lines().collect();
515
516 for line in lines.iter() {
518 if line.contains("<div hidden>") && line.contains("{") {
519 if let Some(start) = line.find("<div hidden>") {
521 let content_start = start + 12; if let Some(end) = line[content_start..].find("</div>") {
523 let json_content = &line[content_start..content_start + end];
524
525 let decoded_json = json_content
527 .replace(""", "\"")
528 .replace("<", "<")
529 .replace(">", ">")
530 .replace("&", "&")
531 .replace("'", "'");
532
533 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&decoded_json) {
534 if let Some(state) = parsed.get("state").and_then(|v| v.as_str()) {
536 if state == "done" {
537 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 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 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
579fn 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 result
595 .replace("<", "<")
596 .replace(">", ">")
597 .replace("&", "&")
598 .replace(""", "\"")
599 .replace("'", "'")
600}
601pub 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 let current_exe = env::current_exe()?;
613
614 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 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 let log_file_handle = OpenOptions::new()
640 .create(true)
641 .append(true)
642 .open(&log_file)?;
643
644 let child = Command::new(¤t_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 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
656
657 #[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 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 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 #[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 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 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 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 active_daemons.insert(provider, daemon_info);
779 }
780 Err(_) => {
781 registry.remove_daemon(&provider);
783 }
784 }
785 }
786
787 #[cfg(not(unix))]
788 {
789 active_daemons.insert(provider, daemon_info);
791 }
792 }
793
794 registry.save()?;
796
797 Ok(active_daemons)
798}
799
800pub async fn fetch_kagi_models() -> Result<Vec<KagiModelProfile>> {
802 let config = WebChatProxyConfig::load()?;
803
804 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 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_kagi_models_response(&response_text)
833}
834
835fn parse_kagi_models_response(html: &str) -> Result<Vec<KagiModelProfile>> {
837 let lines: Vec<&str> = html.lines().collect();
838
839 for line in lines.iter() {
841 if line.contains("<div hidden>") && line.contains("profiles") {
842 if let Some(start) = line.find("<div hidden>") {
844 let content_start = start + 12; if let Some(end) = line[content_start..].find("</div>") {
846 let json_content = &line[content_start..content_start + end];
847
848 let decoded_json = json_content
850 .replace(""", "\"")
851 .replace("<", "<")
852 .replace(">", ">")
853 .replace("&", "&")
854 .replace("'", "'");
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}