diff --git a/hyperdrive/dependencies/anthropic-api-key-manager b/hyperdrive/dependencies/anthropic-api-key-manager index aeaa740d0..e17d63dc2 160000 --- a/hyperdrive/dependencies/anthropic-api-key-manager +++ b/hyperdrive/dependencies/anthropic-api-key-manager @@ -1 +1 @@ -Subproject commit aeaa740d03fbaa29de82f7983aa8c06137d57bfd +Subproject commit e17d63dc2d3e4381013e4533fa24e8894045b22e diff --git a/hyperdrive/packages/file-explorer/explorer/Cargo.toml b/hyperdrive/packages/file-explorer/explorer/Cargo.toml index 884c9deeb..33fefce8f 100644 --- a/hyperdrive/packages/file-explorer/explorer/Cargo.toml +++ b/hyperdrive/packages/file-explorer/explorer/Cargo.toml @@ -1,20 +1,17 @@ [dependencies] anyhow = "1.0" md5 = "0.7" +hyperapp_macro = "0.1.1" process_macros = "0.1" serde_json = "1.0" serde_urlencoded = "0.7" tracing = "0.1.37" wit-bindgen = "0.42.1" -[dependencies.hyperprocess_macro] -git = "https://github.com/hyperware-ai/hyperprocess-macro" -rev = "66884c0" - [dependencies.hyperware_process_lib] features = ["hyperapp"] git = "https://github.com/hyperware-ai/process_lib" -rev = "4beff93" +rev = "1a6ad9d" [dependencies.serde] features = ["derive"] diff --git a/hyperdrive/packages/file-explorer/explorer/src/lib.rs b/hyperdrive/packages/file-explorer/explorer/src/lib.rs index 482cd9432..cbcfb5411 100644 --- a/hyperdrive/packages/file-explorer/explorer/src/lib.rs +++ b/hyperdrive/packages/file-explorer/explorer/src/lib.rs @@ -1,4 +1,3 @@ -use hyperprocess_macro::hyperprocess; use hyperware_process_lib::hyperapp::{add_response_header, get_path, send, SaveOptions}; use hyperware_process_lib::logging::{debug, error, info, init_logging, Level}; use hyperware_process_lib::our; @@ -35,7 +34,7 @@ struct FileExplorerState { cwd: String, } -#[hyperprocess( +#[hyperapp_macro::hyperapp( name = "file-explorer", ui = Some(HttpBindingConfig::default().secure_subdomain(true)), endpoints = vec![ diff --git a/hyperdrive/packages/spider/spider/Cargo.toml b/hyperdrive/packages/spider/spider/Cargo.toml index 16fb2dc27..6f5b2539a 100644 --- a/hyperdrive/packages/spider/spider/Cargo.toml +++ b/hyperdrive/packages/spider/spider/Cargo.toml @@ -13,13 +13,13 @@ wit-bindgen = "0.42.1" features = ["serde"] version = "0.4" -[dependencies.hyperprocess_macro] -git = "https://github.com/hyperware-ai/hyperprocess-macro" -rev = "98fac21" +[dependencies.hyperapp_macro] +git = "https://github.com/hyperware-ai/hyperapp-macro" +rev = "5c7cc7a" [dependencies.hyperware-anthropic-sdk] git = "https://github.com/hyperware-ai/hyperware-anthropic-sdk" -rev = "607bbc6" +rev = "1fc7b0c" [dependencies.hyperware-parse-wit] path = "../crates/hyperware-parse-wit" @@ -27,7 +27,7 @@ path = "../crates/hyperware-parse-wit" [dependencies.hyperware_process_lib] features = ["hyperapp"] git = "https://github.com/hyperware-ai/process_lib" -rev = "232fe25" +rev = "41f25ce" [dependencies.serde] features = ["derive"] @@ -45,6 +45,7 @@ features = ["serde"] version = "0.220.0" [features] +public-mode = [] simulation-mode = [] [lib] diff --git a/hyperdrive/packages/spider/spider/src/lib.rs b/hyperdrive/packages/spider/spider/src/lib.rs index 19123e8ed..7f4b0dceb 100644 --- a/hyperdrive/packages/spider/spider/src/lib.rs +++ b/hyperdrive/packages/spider/spider/src/lib.rs @@ -6,7 +6,8 @@ use chrono::Utc; use serde_json::{json, Value}; use uuid::Uuid; -use hyperprocess_macro::hyperprocess; +#[cfg(feature = "public-mode")] +use hyperware_process_lib::hyperapp::{get_http_request, get_request_header, get_ws_channel_addr}; use hyperware_process_lib::{ homepage::add_to_homepage, http::{ @@ -14,7 +15,8 @@ use hyperware_process_lib::{ server::{send_ws_push, WsMessageType}, }, hyperapp::{add_response_header, source}, - our, println, Address, LazyLoadBlob, ProcessId, Request, + logging::{debug, error, info, warn}, + our, Address, LazyLoadBlob, ProcessId, Request, }; #[cfg(not(feature = "simulation-mode"))] use spider_caller_utils::anthropic_api_key_manager::request_api_key_remote_rpc; @@ -23,6 +25,8 @@ mod provider; use provider::create_llm_provider; mod types; +#[cfg(feature = "public-mode")] +use types::RateLimitError; use types::{ AddMcpServerReq, ApiKey, ApiKeyInfo, ChatClient, ChatReq, ChatRes, ConfigRes, ConnectMcpServerReq, Conversation, ConversationMetadata, CreateSpiderKeyReq, @@ -67,7 +71,7 @@ const HYPERGRID: &str = "operator:hypergrid:ware.hypr"; const TODO: &str = "todo:todo:ware.hypr"; const TTSTT: (&str, &str, &str) = ("ttstt", "spider", "sys"); -#[hyperprocess( +#[hyperapp_macro::hyperapp( name = "Spider", ui = Some(HttpBindingConfig::default()), endpoints = vec![ @@ -96,7 +100,7 @@ impl SpiderState { const RETRY_DELAY_S: u64 = 2; const TIMEOUT_S: u64 = 15; - println!("Spider: Waiting for hypermap-cacher to be ready..."); + info!("Waiting for hypermap-cacher to be ready..."); loop { // Create GetStatus request JSON @@ -113,8 +117,8 @@ impl SpiderState { if response_str.contains("IsStarting") || response_str.contains(r#""IsStarting""#) { - println!( - "Spider: hypermap-cacher is still starting (attempt {}). Retrying in {}s...", + debug!( + "hypermap-cacher is still starting (attempt {}). Retrying in {}s...", attempt, RETRY_DELAY_S ); std::thread::sleep(std::time::Duration::from_secs(RETRY_DELAY_S)); @@ -125,25 +129,25 @@ impl SpiderState { if response_str.contains("GetStatus") || response_str.contains("last_cached_block") { - println!("Spider: hypermap-cacher is ready!"); + info!("hypermap-cacher is ready!"); break; } } // If we get here, we got some response we don't understand, but cacher is at least responding - println!("Spider: hypermap-cacher responded, proceeding with initialization"); + info!("hypermap-cacher responded, proceeding with initialization"); break; } Ok(Err(e)) => { - println!( - "Spider: Error response from hypermap-cacher (attempt {}): {:?}", + warn!( + "Error response from hypermap-cacher (attempt {}): {:?}", attempt, e ); std::thread::sleep(std::time::Duration::from_secs(RETRY_DELAY_S)); attempt += 1; } Err(e) => { - println!( - "Spider: Failed to contact hypermap-cacher (attempt {}): {:?}", + warn!( + "Failed to contact hypermap-cacher (attempt {}): {:?}", attempt, e ); std::thread::sleep(std::time::Duration::from_secs(RETRY_DELAY_S)); @@ -164,7 +168,7 @@ impl SpiderState { self.next_channel_id = 1000; // Start channel IDs at 1000 let our_node = our().node.clone(); - println!("Spider MCP client initialized on node: {}", our_node); + info!("MCP client initialized on node: {}", our_node); // Register Build Container tool provider let build_container_provider = BuildContainerToolProvider::new(); @@ -201,10 +205,10 @@ impl SpiderState { }; self.mcp_servers.push(build_container_server); - println!("Spider: Build Container MCP server initialized"); + info!("Build Container MCP server initialized"); } else { // Server exists, refresh its tools from the provider - println!("Spider: Refreshing Build Container tools on startup"); + debug!("Refreshing Build Container tools on startup"); // Get fresh tools from provider let build_container_provider = BuildContainerToolProvider::new(); @@ -217,8 +221,8 @@ impl SpiderState { .find(|s| s.id == "build_container") { server.tools = fresh_tools; - println!( - "Spider: Build Container tools refreshed with {} tools", + debug!( + "Build Container tools refreshed with {} tools", server.tools.len() ); } @@ -261,18 +265,18 @@ impl SpiderState { connected: true, // Always mark as connected }; self.mcp_servers.push(hyperware_server); - println!("Spider: Hyperware MCP server initialized"); + info!("Hyperware MCP server initialized"); } else { // Server exists, refresh its tools from the provider - println!("Spider: Refreshing Hyperware tools on startup"); + debug!("Refreshing Hyperware tools on startup"); // Get fresh tools from provider let hyperware_provider = HyperwareToolProvider::new(); let fresh_tools = hyperware_provider.get_tools(self); // Update the existing server's tools if let Some(server) = self.mcp_servers.iter_mut().find(|s| s.id == "hyperware") { server.tools = fresh_tools; - println!( - "Spider: Hyperware tools refreshed with {} tools", + debug!( + "Hyperware tools refreshed with {} tools", server.tools.len() ); } @@ -306,9 +310,9 @@ impl SpiderState { }; self.mcp_servers.push(hypergrid_server); - println!("Spider: Hypergrid MCP server initialized (unconfigured)"); + info!("Hypergrid MCP server initialized (unconfigured)"); } else { - println!("Spider: Refreshing Hypergrid tools on startup"); + debug!("Refreshing Hypergrid tools on startup"); // Get fresh tools from provider let hypergrid_provider = HypergridToolProvider::new("hypergrid_default".to_string()); @@ -321,8 +325,8 @@ impl SpiderState { .find(|s| s.id == "hypergrid_default") { server.tools = fresh_tools; - println!( - "Spider: Hypergrid tools refreshed with {} tools", + debug!( + "Hypergrid tools refreshed with {} tools", server.tools.len() ); } @@ -330,12 +334,12 @@ impl SpiderState { // Restore hypergrid connections for configured servers for server in self.mcp_servers.iter() { if server.transport.transport_type == "hypergrid" { - println!( - "Spider: Found hypergrid server '{}' (id: {})", + debug!( + "Found hypergrid server '{}' (id: {})", server.name, server.id ); - println!(" - URL: {:?}", server.transport.url); - println!( + debug!(" - URL: {:?}", server.transport.url); + debug!( " - Token: {}", server .transport @@ -348,9 +352,9 @@ impl SpiderState { }) .unwrap_or_else(|| "None".to_string()) ); - println!(" - Client ID: {:?}", server.transport.hypergrid_client_id); - println!(" - Node: {:?}", server.transport.hypergrid_node); - println!(" - Tools: {} available", server.tools.len()); + debug!(" - Client ID: {:?}", server.transport.hypergrid_client_id); + debug!(" - Node: {:?}", server.transport.hypergrid_node); + debug!(" - Tools: {} available", server.tools.len()); if let (Some(url), Some(token), Some(client_id), Some(node)) = ( &server.transport.url, @@ -371,15 +375,12 @@ impl SpiderState { }; self.hypergrid_connections .insert(server.id.clone(), hypergrid_conn); - println!( - "Spider: ✅ Restored hypergrid connection for {} ({})", + info!( + "Restored hypergrid connection for {} ({})", server.name, node ); } else { - println!( - "Spider: ⚠️ Hypergrid server '{}' is not fully configured", - server.name - ); + warn!("Hypergrid server '{}' is not fully configured", server.name); } } } @@ -410,9 +411,9 @@ impl SpiderState { }; self.spider_api_keys.push(admin_key.clone()); - println!("Spider: Created admin GUI key: {}", admin_key.key); + info!("Created admin GUI key: {}", admin_key.key); } else { - println!("Spider: Admin GUI key already exists"); + debug!("Admin GUI key already exists"); } // VFS directory creation will be handled when actually saving files @@ -423,7 +424,7 @@ impl SpiderState { self.mcp_servers.iter().map(|s| s.id.clone()).collect(); for server_id in servers_to_reconnect { - println!("Auto-reconnecting to MCP server: {}", server_id); + debug!("Auto-reconnecting to MCP server: {}", server_id); // Retry logic with exponential backoff let max_retries = 10; @@ -440,7 +441,7 @@ impl SpiderState { }) .map(|k| k.key.clone()) .unwrap_or_else(|| { - println!("Warning: No admin key found for auto-reconnect"); + warn!("No admin key found for auto-reconnect"); String::new() }); @@ -450,18 +451,18 @@ impl SpiderState { }; match self.connect_mcp_server(connect_request).await { Ok(msg) => { - println!("Auto-reconnect successful: {}", msg); + debug!("Auto-reconnect successful: {}", msg); success = true; break; } Err(e) => { - println!( + warn!( "Failed to auto-reconnect to MCP server {} (attempt {}/{}): {}", server_id, attempt, max_retries, e ); if attempt < max_retries { - println!("Retrying in {} ms...", retry_delay_ms); + debug!("Retrying in {} ms...", retry_delay_ms); let _ = hyperware_process_lib::hyperapp::sleep(retry_delay_ms).await; // Exponential backoff with max delay of 10 seconds @@ -472,7 +473,7 @@ impl SpiderState { } if !success { - println!( + error!( "Failed to reconnect to MCP server {} after {} attempts", server_id, max_retries ); @@ -482,7 +483,7 @@ impl SpiderState { // Check if we need to request a free API key #[cfg(not(feature = "simulation-mode"))] if self.api_keys.is_empty() { - println!("Spider: No API keys configured, requesting free trial key..."); + info!("No API keys configured, requesting free trial key..."); let api_key_dispenser = Address::new(API_KEY_DISPENSER_NODE, API_KEY_DISPENSER_PROCESS_ID); @@ -490,7 +491,7 @@ impl SpiderState { // Call the RPC function to request an API key match request_api_key_remote_rpc(&api_key_dispenser).await { Ok(Ok(api_key)) => { - println!("Spider: Successfully obtained free trial API key"); + info!("Successfully obtained free trial API key"); // Add the key to our API keys let encrypted_key = encrypt_key(&api_key); self.api_keys.push(( @@ -509,15 +510,15 @@ impl SpiderState { self.show_trial_key_notification = true; } Ok(Err(e)) => { - println!("Spider: API key dispenser returned error: {}", e); + error!("API key dispenser returned error: {}", e); } Err(e) => { - println!("Spider: API key dispenser send error: {}", e); + error!("API key dispenser send error: {}", e); } } } - println!("Spider initialization complete"); + info!("Initialization complete"); } #[ws] @@ -527,13 +528,13 @@ impl SpiderState { message_type: WsMessageType, blob: LazyLoadBlob, ) { - println!("handle_websocket {channel_id}"); + debug!("handle_websocket {channel_id}"); match message_type { WsMessageType::Text | WsMessageType::Binary => { let message_bytes = blob.bytes.clone(); let message_str = String::from_utf8(message_bytes).unwrap_or_default(); - println!("handle_websocket: got {message_str}"); + debug!("handle_websocket: got {message_str}"); // Parse the incoming message using typed enum match serde_json::from_str::(&message_str) { @@ -544,6 +545,16 @@ impl SpiderState { if self.validate_spider_key(&api_key) && self.validate_permission(&api_key, "write") { + // Capture IP address for rate limiting in public-mode + #[cfg(feature = "public-mode")] + let ip_address = Self::get_client_ip(Some(channel_id)); + #[cfg(feature = "public-mode")] + debug!("[RATE-LIMIT] WebSocket Auth - captured IP for channel {}: {:?}", channel_id, ip_address); + #[cfg(not(feature = "public-mode"))] + let ip_address: Option< + String, + > = None; + self.chat_clients.insert( channel_id, ChatClient { @@ -551,6 +562,7 @@ impl SpiderState { api_key: api_key.clone(), conversation_id: None, connected_at: Utc::now().timestamp() as u64, + ip_address, }, ); @@ -608,6 +620,48 @@ impl SpiderState { return; } + // Rate limiting for public-mode + #[cfg(feature = "public-mode")] + { + debug!("[RATE-LIMIT] Checking rate limit for chat message on channel {}", channel_id); + debug!( + "[RATE-LIMIT] Client IP address: {:?}", + client.ip_address + ); + if let Some(ref ip) = client.ip_address { + if let Err(e) = self.check_rate_limit(ip) { + debug!( + "[RATE-LIMIT] Rate limit BLOCKED for {}: {}", + ip, e + ); + // Send structured error for frontend + let error = RateLimitError { + error_type: "OutOfRequests".to_string(), + message: e, + retry_after_seconds: self + .get_retry_after_seconds(ip), + }; + let response = WsServerMessage::Error { + error: serde_json::to_string(&error).unwrap(), + }; + let json = + serde_json::to_string(&response).unwrap(); + send_ws_push( + channel_id, + WsMessageType::Text, + LazyLoadBlob::new( + Some("application/json"), + json, + ), + ); + return; + } + debug!("[RATE-LIMIT] Rate limit OK for {}", ip); + } else { + warn!("[RATE-LIMIT] No IP address available - rate limiting SKIPPED!"); + } + } + // Convert WsChatPayload to ChatReq let chat_request = ChatReq { api_key: client.api_key, @@ -669,10 +723,7 @@ impl SpiderState { self.active_chat_cancellation.get(&channel_id) { cancel_flag.store(true, Ordering::Relaxed); - println!( - "Spider: Cancelling chat request for channel {}", - channel_id - ); + debug!("Cancelling chat request for channel {}", channel_id); // Send cancellation confirmation let response = WsServerMessage::Status { @@ -700,8 +751,8 @@ impl SpiderState { } } Err(e) => { - println!( - "Spider: Failed to parse WebSocket message from channel {}: {}", + warn!( + "Failed to parse WebSocket message from channel {}: {}", channel_id, e ); let error_response = WsServerMessage::Error { @@ -719,7 +770,7 @@ impl SpiderState { WsMessageType::Close => { // Clean up client connection self.chat_clients.remove(&channel_id); - println!("Chat client {} disconnected", channel_id); + debug!("Chat client {} disconnected", channel_id); } WsMessageType::Ping | WsMessageType::Pong => { // Handle ping/pong for keepalive @@ -736,31 +787,28 @@ impl SpiderState { ) { match message_type { WsMessageType::Text | WsMessageType::Binary => { - println!("Got WS Text"); + debug!("Got WS Text"); // Handle incoming message from the WebSocket server let message_bytes = blob.bytes; // Parse the message as JSON let message_str = String::from_utf8(message_bytes).unwrap_or_default(); - println!( - "Spider: Received WebSocket message on channel {}: {}", + debug!( + "Received WebSocket message on channel {}: {}", channel_id, message_str ); if let Ok(json_msg) = serde_json::from_str::(&message_str) { self.handle_mcp_message(channel_id, json_msg); } else { - println!( - "Spider: Failed to parse MCP message from channel {}: {}", + warn!( + "Failed to parse MCP message from channel {}: {}", channel_id, message_str ); } } WsMessageType::Close => { // Handle connection close - println!( - "Spider: WebSocket connection closed for channel {}", - channel_id - ); + debug!("WebSocket connection closed for channel {}", channel_id); // Find and disconnect the server if let Some(conn) = self.ws_connections.remove(&channel_id) { @@ -769,7 +817,7 @@ impl SpiderState { self.mcp_servers.iter_mut().find(|s| s.id == conn.server_id) { server.connected = false; - println!("Spider: MCP server {} disconnected", server.name); + info!("MCP server {} disconnected", server.name); } // Also remove any ws_mcp server that was created for this connection @@ -1360,6 +1408,32 @@ impl SpiderState { #[local] #[http] async fn chat(&mut self, request: ChatReq) -> Result { + // Rate limiting for public-mode + #[cfg(feature = "public-mode")] + { + debug!("[RATE-LIMIT] HTTP /chat endpoint - checking rate limit"); + let ip = Self::get_client_ip(None); + debug!("[RATE-LIMIT] HTTP /chat - client IP: {:?}", ip); + if let Some(ref ip_addr) = ip { + if let Err(e) = self.check_rate_limit(ip_addr) { + warn!( + "[RATE-LIMIT] HTTP /chat - rate limit BLOCKED for {}: {}", + ip_addr, e + ); + // Return structured error for frontend + let error = RateLimitError { + error_type: "OutOfRequests".to_string(), + message: e, + retry_after_seconds: self.get_retry_after_seconds(ip_addr), + }; + return Err(serde_json::to_string(&error).unwrap()); + } + debug!("[RATE-LIMIT] HTTP /chat - rate limit OK for {}", ip_addr); + } else { + warn!("[RATE-LIMIT] HTTP /chat - WARNING: No IP address available - rate limiting SKIPPED!"); + } + } + // Use the shared internal chat processing logic (without WebSocket streaming) let source = source(); if source.publisher() == "sys" @@ -1536,6 +1610,152 @@ impl SpiderState { .any(|k| k.key == key && k.permissions.contains(&permission.to_string())) } + /// Get client IP address from request headers (proxy-aware) or socket address + /// For WebSocket contexts, pass the channel_id to look up the stored socket address. + #[cfg(feature = "public-mode")] + fn get_client_ip(channel_id: Option) -> Option { + debug!("=== get_client_ip DEBUG ==="); + + // Check if we have HTTP context at all + let http_req = get_http_request(); + debug!( + "[RATE-LIMIT] HTTP request context exists: {}", + http_req.is_some() + ); + + // First try X-Forwarded-For header (proxy scenario) + let xff = get_request_header("X-Forwarded-For"); + debug!("[RATE-LIMIT] X-Forwarded-For header: {:?}", xff); + if let Some(ref xff_val) = xff { + // X-Forwarded-For can be comma-separated; take the first (original client) + if let Some(first_ip) = xff_val.split(',').next() { + let ip = first_ip.trim().to_string(); + debug!("[RATE-LIMIT] Extracted IP from X-Forwarded-For: {}", ip); + debug!("=== END get_client_ip DEBUG ==="); + return Some(ip); + } + } + + // Fallback to X-Real-IP header + let real_ip = get_request_header("X-Real-IP"); + debug!("[RATE-LIMIT] X-Real-IP header: {:?}", real_ip); + if let Some(ref real_ip_val) = real_ip { + let ip = real_ip_val.trim().to_string(); + debug!("[RATE-LIMIT] Using X-Real-IP: {}", ip); + debug!("=== END get_client_ip DEBUG ==="); + return Some(ip); + } + + // Try Cf-Connecting-Ip (Cloudflare) - just for debug logging + let cf_ip = get_request_header("Cf-Connecting-Ip"); + debug!("[RATE-LIMIT] Cf-Connecting-Ip header: {:?}", cf_ip); + + // Try HTTP socket address + let http_socket_result = http_req.and_then(|req| req.source_socket_addr().ok()); + debug!("[RATE-LIMIT] HTTP socket address: {:?}", http_socket_result); + if let Some(addr) = http_socket_result { + let ip = addr.ip().to_string(); + debug!("[RATE-LIMIT] Using HTTP socket address: {}", ip); + debug!("=== END get_client_ip DEBUG ==="); + return Some(ip); + } + + // Fallback to WebSocket channel address (for WS contexts) + if let Some(ch_id) = channel_id { + let ws_addr = get_ws_channel_addr(ch_id); + debug!( + "[RATE-LIMIT] WebSocket channel {} address: {:?}", + ch_id, ws_addr + ); + if let Some(addr_str) = ws_addr { + // Parse socket address string to extract IP (format: "ip:port") + if let Some(ip) = addr_str.split(':').next() { + let ip = ip.to_string(); + debug!("[RATE-LIMIT] Using WebSocket channel address: {}", ip); + debug!("=== END get_client_ip DEBUG ==="); + return Some(ip); + } + } + } + + debug!("[RATE-LIMIT] Final get_client_ip result: None"); + debug!("=== END get_client_ip DEBUG ==="); + None + } + + /// Check rate limit for an IP address. Returns Ok(()) if allowed, Err with message if rate limited. + #[cfg(feature = "public-mode")] + fn check_rate_limit(&mut self, ip: &str) -> Result<(), String> { + const MAX_CHATS_PER_DAY: usize = 3; + const WINDOW_SECONDS: u64 = 24 * 60 * 60; // 24 hours + + debug!("[RATE-LIMIT] check_rate_limit called for IP: {}", ip); + debug!( + "[RATE-LIMIT] Current ip_chat_counts keys: {:?}", + self.ip_chat_counts.keys().collect::>() + ); + + let now = Utc::now().timestamp() as u64; + let cutoff = now - WINDOW_SECONDS; + + // Get or create entry for this IP + let timestamps = self + .ip_chat_counts + .entry(ip.to_string()) + .or_insert_with(Vec::new); + + // Remove expired timestamps + let before_cleanup = timestamps.len(); + timestamps.retain(|&t| t > cutoff); + debug!( + "[RATE-LIMIT] Timestamps for {} after cleanup: {} (was {})", + ip, + timestamps.len(), + before_cleanup + ); + + // Check if limit exceeded + if timestamps.len() >= MAX_CHATS_PER_DAY { + warn!( + "[RATE-LIMIT] RATE LIMIT EXCEEDED for {}: {} >= {}", + ip, + timestamps.len(), + MAX_CHATS_PER_DAY + ); + return Err(format!( + "Rate limit exceeded: {} chats allowed per 24 hours. Try again later.", + MAX_CHATS_PER_DAY + )); + } + + // Record this chat + timestamps.push(now); + debug!( + "[RATE-LIMIT] Recorded chat for {}. New count: {}/{}", + ip, + timestamps.len(), + MAX_CHATS_PER_DAY + ); + Ok(()) + } + + /// Get seconds until the oldest chat request expires (for retry_after_seconds) + #[cfg(feature = "public-mode")] + fn get_retry_after_seconds(&self, ip: &str) -> Option { + const WINDOW_SECONDS: u64 = 24 * 60 * 60; + + if let Some(timestamps) = self.ip_chat_counts.get(ip) { + if let Some(&oldest) = timestamps.iter().min() { + let now = Utc::now().timestamp() as u64; + let expires_at = oldest + WINDOW_SECONDS; + if expires_at > now { + return Some(expires_at - now); + } + } + } + None + } + fn cleanup_disconnected_build_containers(&mut self) { // Find all ws_mcp_* servers that are disconnected let disconnected_server_ids: Vec = self @@ -1549,7 +1769,7 @@ impl SpiderState { .collect(); if !disconnected_server_ids.is_empty() { - println!( + info!( "Spider: Cleaning up {} disconnected Build Container MCP connections", disconnected_server_ids.len() ); @@ -1560,7 +1780,7 @@ impl SpiderState { if let Ok(old_channel_id) = channel_str.parse::() { // Remove from ws_connections if it exists if self.ws_connections.remove(&old_channel_id).is_some() { - println!( + debug!( "Spider: Removed ws_connection for channel {}", old_channel_id ); @@ -1583,12 +1803,12 @@ impl SpiderState { // Remove the server from mcp_servers list self.mcp_servers.retain(|s| s.id != server_id); - println!("Spider: Removed Build Container MCP server {}", server_id); + info!("Spider: Removed Build Container MCP server {}", server_id); } - println!("Spider: Build Container cleanup complete"); + info!("Spider: Build Container cleanup complete"); } else { - println!("Spider: No disconnected Build Container MCP connections to clean up"); + debug!("Spider: No disconnected Build Container MCP connections to clean up"); } } @@ -1792,7 +2012,7 @@ impl SpiderState { .unwrap_or("Unknown Key".to_string()) }; - println!( + info!( "Spider: Starting new conversation {} with provider {} (key: {})", conversation_id, llm_provider, key_name ); @@ -1857,7 +2077,7 @@ impl SpiderState { // Convert audio to text using ttstt match self.convert_audio_to_text(&message.content).await { Ok(text) => { - println!("Spider: Converted audio to text: {}", text); + debug!("Spider: Converted audio to text: {}", text); message.content = MessageContent::Text(text); } Err(e) => { @@ -1981,7 +2201,7 @@ impl SpiderState { if let Some(cancel_flag) = self.active_chat_cancellation.get(&ch_id) { let is_cancelled = cancel_flag.load(Ordering::Relaxed); if is_cancelled { - println!( + info!( "Spider: Chat request cancelled at iteration {}", iteration_count ); @@ -2018,7 +2238,7 @@ impl SpiderState { Ok(response) => response, Err(e) => { // Log the error for debugging - println!("Spider: Error calling LLM provider {}: {}", llm_provider, e); + error!("Spider: Error calling LLM provider {}: {}", llm_provider, e); // Check if it's an API key error if e.contains("401") || e.contains("unauthorized") || e.contains("api key") { @@ -2045,26 +2265,26 @@ impl SpiderState { }; // Check if the response contains tool calls - println!("[DEBUG] LLM response received:"); - println!( + debug!("[DEBUG] LLM response received:"); + debug!( "[DEBUG] - content: {}", match &llm_response.content { MessageContent::Text(t) => t.as_str(), MessageContent::Audio(_) | MessageContent::BaseSixFourAudio(_) => "